Skip to content

Commit

Permalink
feat(focus-trap): return whether shifting focus was successful (#6279)
Browse files Browse the repository at this point in the history
Adds return types to the focus trap methods, allowing the consumer to react based on whether the focus trap managed to find a focusable element. This is useful for cases like the dialog where focus could be left behind if it is a purely text-based dialog.
  • Loading branch information
crisbeto authored and mmalerba committed Aug 8, 2017
1 parent a190de7 commit 7626c51
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 23 deletions.
33 changes: 30 additions & 3 deletions src/cdk/a11y/focus-trap.spec.ts
Expand Up @@ -15,6 +15,7 @@ describe('FocusTrap', () => {
SimpleFocusTrap,
FocusTrapTargets,
FocusTrapWithSvg,
FocusTrapWithoutFocusableElements,
],
providers: [InteractivityChecker, Platform, FocusTrapFactory]
});
Expand All @@ -35,22 +36,37 @@ describe('FocusTrap', () => {
it('wrap focus from end to start', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
focusTrapInstance.focusFirstTabbableElement();
const result = focusTrapInstance.focusFirstTabbableElement();

expect(document.activeElement.nodeName.toLowerCase())
.toBe('input', 'Expected input element to be focused');
expect(result).toBe(true, 'Expected return value to be true if focus was shifted.');
});

it('should wrap focus from start to end', () => {
// Because we can't mimic a real tab press focus change in a unit test, just call the
// focus event handler directly.
focusTrapInstance.focusLastTabbableElement();
const result = focusTrapInstance.focusLastTabbableElement();

// In iOS button elements are never tabbable, so the last element will be the input.
let lastElement = new Platform().IOS ? 'input' : 'button';
const lastElement = new Platform().IOS ? 'input' : 'button';

expect(document.activeElement.nodeName.toLowerCase())
.toBe(lastElement, `Expected ${lastElement} element to be focused`);

expect(result).toBe(true, 'Expected return value to be true if focus was shifted.');
});

it('should return false if it did not manage to find a focusable element', () => {
fixture.destroy();

const newFixture = TestBed.createComponent(FocusTrapWithoutFocusableElements);
newFixture.detectChanges();

const focusTrap = newFixture.componentInstance.focusTrapDirective.focusTrap;
const result = focusTrap.focusFirstTabbableElement();

expect(result).toBe(false);
});

it('should be enabled by default', () => {
Expand Down Expand Up @@ -199,3 +215,14 @@ class FocusTrapTargets {
class FocusTrapWithSvg {
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
}

@Component({
template: `
<div cdkTrapFocus>
<p>Hello</p>
</div>
`
})
class FocusTrapWithoutFocusableElements {
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
}
74 changes: 54 additions & 20 deletions src/cdk/a11y/focus-trap.ts
Expand Up @@ -88,8 +88,13 @@ export class FocusTrap {
}

this._ngZone.runOutsideAngular(() => {
this._startAnchor!.addEventListener('focus', () => this.focusLastTabbableElement());
this._endAnchor!.addEventListener('focus', () => this.focusFirstTabbableElement());
this._startAnchor!.addEventListener('focus', () => {
this.focusLastTabbableElement();
});

this._endAnchor!.addEventListener('focus', () => {
this.focusFirstTabbableElement();
});

if (this._element.parentNode) {
this._element.parentNode.insertBefore(this._startAnchor!, this._element);
Expand All @@ -100,26 +105,38 @@ export class FocusTrap {

/**
* Waits for the zone to stabilize, then either focuses the first element that the
* user specified, or the first tabbable element..
* user specified, or the first tabbable element.
* @returns Returns a promise that resolves with a boolean, depending
* on whether focus was moved successfuly.
*/
focusInitialElementWhenReady() {
this._executeOnStable(() => this.focusInitialElement());
focusInitialElementWhenReady(): Promise<boolean> {
return new Promise<boolean>(resolve => {
this._executeOnStable(() => resolve(this.focusInitialElement()));
});
}

/**
* Waits for the zone to stabilize, then focuses
* the first tabbable element within the focus trap region.
* @returns Returns a promise that resolves with a boolean, depending
* on whether focus was moved successfuly.
*/
focusFirstTabbableElementWhenReady() {
this._executeOnStable(() => this.focusFirstTabbableElement());
focusFirstTabbableElementWhenReady(): Promise<boolean> {
return new Promise<boolean>(resolve => {
this._executeOnStable(() => resolve(this.focusFirstTabbableElement()));
});
}

/**
* Waits for the zone to stabilize, then focuses
* the last tabbable element within the focus trap region.
* @returns Returns a promise that resolves with a boolean, depending
* on whether focus was moved successfuly.
*/
focusLastTabbableElementWhenReady() {
this._executeOnStable(() => this.focusLastTabbableElement());
focusLastTabbableElementWhenReady(): Promise<boolean> {
return new Promise<boolean>(resolve => {
this._executeOnStable(() => resolve(this.focusLastTabbableElement()));
});
}

/**
Expand All @@ -146,30 +163,47 @@ export class FocusTrap {
markers[markers.length - 1] : this._getLastTabbableElement(this._element);
}

/** Focuses the element that should be focused when the focus trap is initialized. */
focusInitialElement() {
let redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
/**
* Focuses the element that should be focused when the focus trap is initialized.
* @returns Returns whether focus was moved successfuly.
*/
focusInitialElement(): boolean {
const redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;

if (redirectToElement) {
redirectToElement.focus();
} else {
this.focusFirstTabbableElement();
return true;
}

return this.focusFirstTabbableElement();
}

/** Focuses the first tabbable element within the focus trap region. */
focusFirstTabbableElement() {
let redirectToElement = this._getRegionBoundary('start');
/**
* Focuses the first tabbable element within the focus trap region.
* @returns Returns whether focus was moved successfuly.
*/
focusFirstTabbableElement(): boolean {
const redirectToElement = this._getRegionBoundary('start');

if (redirectToElement) {
redirectToElement.focus();
}

return !!redirectToElement;
}

/** Focuses the last tabbable element within the focus trap region. */
focusLastTabbableElement() {
let redirectToElement = this._getRegionBoundary('end');
/**
* Focuses the last tabbable element within the focus trap region.
* @returns Returns whether focus was moved successfuly.
*/
focusLastTabbableElement(): boolean {
const redirectToElement = this._getRegionBoundary('end');

if (redirectToElement) {
redirectToElement.focus();
}

return !!redirectToElement;
}

/** Get the first tabbable element from a DOM subtree (inclusive). */
Expand Down

0 comments on commit 7626c51

Please sign in to comment.