Skip to content

Commit 7626c51

Browse files
crisbetommalerba
authored andcommitted
feat(focus-trap): return whether shifting focus was successful (#6279)
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.
1 parent a190de7 commit 7626c51

File tree

2 files changed

+84
-23
lines changed

2 files changed

+84
-23
lines changed

src/cdk/a11y/focus-trap.spec.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('FocusTrap', () => {
1515
SimpleFocusTrap,
1616
FocusTrapTargets,
1717
FocusTrapWithSvg,
18+
FocusTrapWithoutFocusableElements,
1819
],
1920
providers: [InteractivityChecker, Platform, FocusTrapFactory]
2021
});
@@ -35,22 +36,37 @@ describe('FocusTrap', () => {
3536
it('wrap focus from end to start', () => {
3637
// Because we can't mimic a real tab press focus change in a unit test, just call the
3738
// focus event handler directly.
38-
focusTrapInstance.focusFirstTabbableElement();
39+
const result = focusTrapInstance.focusFirstTabbableElement();
3940

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

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

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

5254
expect(document.activeElement.nodeName.toLowerCase())
5355
.toBe(lastElement, `Expected ${lastElement} element to be focused`);
56+
57+
expect(result).toBe(true, 'Expected return value to be true if focus was shifted.');
58+
});
59+
60+
it('should return false if it did not manage to find a focusable element', () => {
61+
fixture.destroy();
62+
63+
const newFixture = TestBed.createComponent(FocusTrapWithoutFocusableElements);
64+
newFixture.detectChanges();
65+
66+
const focusTrap = newFixture.componentInstance.focusTrapDirective.focusTrap;
67+
const result = focusTrap.focusFirstTabbableElement();
68+
69+
expect(result).toBe(false);
5470
});
5571

5672
it('should be enabled by default', () => {
@@ -199,3 +215,14 @@ class FocusTrapTargets {
199215
class FocusTrapWithSvg {
200216
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
201217
}
218+
219+
@Component({
220+
template: `
221+
<div cdkTrapFocus>
222+
<p>Hello</p>
223+
</div>
224+
`
225+
})
226+
class FocusTrapWithoutFocusableElements {
227+
@ViewChild(FocusTrapDirective) focusTrapDirective: FocusTrapDirective;
228+
}

src/cdk/a11y/focus-trap.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,13 @@ export class FocusTrap {
8888
}
8989

9090
this._ngZone.runOutsideAngular(() => {
91-
this._startAnchor!.addEventListener('focus', () => this.focusLastTabbableElement());
92-
this._endAnchor!.addEventListener('focus', () => this.focusFirstTabbableElement());
91+
this._startAnchor!.addEventListener('focus', () => {
92+
this.focusLastTabbableElement();
93+
});
94+
95+
this._endAnchor!.addEventListener('focus', () => {
96+
this.focusFirstTabbableElement();
97+
});
9398

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

101106
/**
102107
* Waits for the zone to stabilize, then either focuses the first element that the
103-
* user specified, or the first tabbable element..
108+
* user specified, or the first tabbable element.
109+
* @returns Returns a promise that resolves with a boolean, depending
110+
* on whether focus was moved successfuly.
104111
*/
105-
focusInitialElementWhenReady() {
106-
this._executeOnStable(() => this.focusInitialElement());
112+
focusInitialElementWhenReady(): Promise<boolean> {
113+
return new Promise<boolean>(resolve => {
114+
this._executeOnStable(() => resolve(this.focusInitialElement()));
115+
});
107116
}
108117

109118
/**
110119
* Waits for the zone to stabilize, then focuses
111120
* the first tabbable element within the focus trap region.
121+
* @returns Returns a promise that resolves with a boolean, depending
122+
* on whether focus was moved successfuly.
112123
*/
113-
focusFirstTabbableElementWhenReady() {
114-
this._executeOnStable(() => this.focusFirstTabbableElement());
124+
focusFirstTabbableElementWhenReady(): Promise<boolean> {
125+
return new Promise<boolean>(resolve => {
126+
this._executeOnStable(() => resolve(this.focusFirstTabbableElement()));
127+
});
115128
}
116129

117130
/**
118131
* Waits for the zone to stabilize, then focuses
119132
* the last tabbable element within the focus trap region.
133+
* @returns Returns a promise that resolves with a boolean, depending
134+
* on whether focus was moved successfuly.
120135
*/
121-
focusLastTabbableElementWhenReady() {
122-
this._executeOnStable(() => this.focusLastTabbableElement());
136+
focusLastTabbableElementWhenReady(): Promise<boolean> {
137+
return new Promise<boolean>(resolve => {
138+
this._executeOnStable(() => resolve(this.focusLastTabbableElement()));
139+
});
123140
}
124141

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

149-
/** Focuses the element that should be focused when the focus trap is initialized. */
150-
focusInitialElement() {
151-
let redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
166+
/**
167+
* Focuses the element that should be focused when the focus trap is initialized.
168+
* @returns Returns whether focus was moved successfuly.
169+
*/
170+
focusInitialElement(): boolean {
171+
const redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
172+
152173
if (redirectToElement) {
153174
redirectToElement.focus();
154-
} else {
155-
this.focusFirstTabbableElement();
175+
return true;
156176
}
177+
178+
return this.focusFirstTabbableElement();
157179
}
158180

159-
/** Focuses the first tabbable element within the focus trap region. */
160-
focusFirstTabbableElement() {
161-
let redirectToElement = this._getRegionBoundary('start');
181+
/**
182+
* Focuses the first tabbable element within the focus trap region.
183+
* @returns Returns whether focus was moved successfuly.
184+
*/
185+
focusFirstTabbableElement(): boolean {
186+
const redirectToElement = this._getRegionBoundary('start');
187+
162188
if (redirectToElement) {
163189
redirectToElement.focus();
164190
}
191+
192+
return !!redirectToElement;
165193
}
166194

167-
/** Focuses the last tabbable element within the focus trap region. */
168-
focusLastTabbableElement() {
169-
let redirectToElement = this._getRegionBoundary('end');
195+
/**
196+
* Focuses the last tabbable element within the focus trap region.
197+
* @returns Returns whether focus was moved successfuly.
198+
*/
199+
focusLastTabbableElement(): boolean {
200+
const redirectToElement = this._getRegionBoundary('end');
201+
170202
if (redirectToElement) {
171203
redirectToElement.focus();
172204
}
205+
206+
return !!redirectToElement;
173207
}
174208

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

0 commit comments

Comments
 (0)