Skip to content

Commit db52793

Browse files
committed
feat(focus-trap): return whether shifting focus was successful
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 5967f6e commit db52793

File tree

2 files changed

+77
-21
lines changed

2 files changed

+77
-21
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: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -100,26 +100,38 @@ export class FocusTrap {
100100

101101
/**
102102
* Waits for the zone to stabilize, then either focuses the first element that the
103-
* user specified, or the first tabbable element..
103+
* user specified, or the first tabbable element.
104+
* @returns Returns a promise that resolves/rejects, depending
105+
* on whether focus was moved successfuly.
104106
*/
105-
focusInitialElementWhenReady() {
106-
this._executeOnStable(() => this.focusInitialElement());
107+
focusInitialElementWhenReady(): Promise<void> {
108+
return new Promise<void>((resolve, reject) => {
109+
this._executeOnStable(() => this.focusInitialElement() ? resolve() : reject());
110+
});
107111
}
108112

109113
/**
110114
* Waits for the zone to stabilize, then focuses
111115
* the first tabbable element within the focus trap region.
116+
* @returns Returns a promise that resolves/rejects, depending
117+
* on whether focus was moved successfuly.
112118
*/
113-
focusFirstTabbableElementWhenReady() {
114-
this._executeOnStable(() => this.focusFirstTabbableElement());
119+
focusFirstTabbableElementWhenReady(): Promise<void> {
120+
return new Promise<void>((resolve, reject) => {
121+
this._executeOnStable(() => this.focusFirstTabbableElement() ? resolve() : reject());
122+
});
115123
}
116124

117125
/**
118126
* Waits for the zone to stabilize, then focuses
119127
* the last tabbable element within the focus trap region.
128+
* @returns Returns a promise that resolves/rejects, depending
129+
* on whether focus was moved successfuly.
120130
*/
121-
focusLastTabbableElementWhenReady() {
122-
this._executeOnStable(() => this.focusLastTabbableElement());
131+
focusLastTabbableElementWhenReady(): Promise<void> {
132+
return new Promise<void>((resolve, reject) => {
133+
this._executeOnStable(() => this.focusLastTabbableElement() ? resolve() : reject());
134+
});
123135
}
124136

125137
/**
@@ -146,30 +158,47 @@ export class FocusTrap {
146158
markers[markers.length - 1] : this._getLastTabbableElement(this._element);
147159
}
148160

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;
161+
/**
162+
* Focuses the element that should be focused when the focus trap is initialized.
163+
* @returns Returns whether focus was moved successfuly.
164+
*/
165+
focusInitialElement(): boolean {
166+
const redirectToElement = this._element.querySelector('[cdk-focus-initial]') as HTMLElement;
167+
152168
if (redirectToElement) {
153169
redirectToElement.focus();
154-
} else {
155-
this.focusFirstTabbableElement();
170+
return true;
156171
}
172+
173+
return this.focusFirstTabbableElement();
157174
}
158175

159-
/** Focuses the first tabbable element within the focus trap region. */
160-
focusFirstTabbableElement() {
161-
let redirectToElement = this._getRegionBoundary('start');
176+
/**
177+
* Focuses the first tabbable element within the focus trap region.
178+
* @returns Returns whether focus was moved successfuly.
179+
*/
180+
focusFirstTabbableElement(): boolean {
181+
const redirectToElement = this._getRegionBoundary('start');
182+
162183
if (redirectToElement) {
163184
redirectToElement.focus();
164185
}
186+
187+
return !!redirectToElement;
165188
}
166189

167-
/** Focuses the last tabbable element within the focus trap region. */
168-
focusLastTabbableElement() {
169-
let redirectToElement = this._getRegionBoundary('end');
190+
/**
191+
* Focuses the last tabbable element within the focus trap region.
192+
* @returns Returns whether focus was moved successfuly.
193+
*/
194+
focusLastTabbableElement(): boolean {
195+
const redirectToElement = this._getRegionBoundary('end');
196+
170197
if (redirectToElement) {
171198
redirectToElement.focus();
172199
}
200+
201+
return !!redirectToElement;
173202
}
174203

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

0 commit comments

Comments
 (0)