Skip to content

Commit 8fcac0d

Browse files
committed
feat(multiple): add options to autoFocus field for dialogs
Before this PR, autoFocus was a boolean that allowed users to specify whether the container element or the first tabbable element is focused on dialog open. Now you can also specify focusing the first header element or use a CSS selector and focus the first element that matches that. If these elements can't be focused, then the container element is focused by default. This applies to other components that are similar to dialog and also have a autoFocus field. Fixes #22678
1 parent 23dfbbb commit 8fcac0d

File tree

18 files changed

+721
-168
lines changed

18 files changed

+721
-168
lines changed

src/cdk-experimental/dialog/dialog-config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {Direction} from '@angular/cdk/bidi';
1010
import {ComponentType} from '@angular/cdk/overlay';
1111
import {CdkDialogContainer} from './dialog-container';
1212

13+
/** Options for where to set focus to automatically on dialog open */
14+
export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
15+
1316
/** Valid ARIA roles for a dialog element. */
1417
export type DialogRole = 'dialog' | 'alertdialog';
1518

@@ -84,8 +87,12 @@ export class DialogConfig<D = any> {
8487
/** Aria label to assign to the dialog element */
8588
ariaLabel?: string | null = null;
8689

87-
/** Whether the dialog should focus the first focusable element on open. */
88-
autoFocus?: boolean = true;
90+
/**
91+
* Where the dialog should focus on open.
92+
* @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or
93+
* AutoFocusTarget instead.
94+
*/
95+
autoFocus?: AutoFocusTarget | string | boolean = 'first-tabbable';
8996

9097
/** Duration of the enter animation. Has to be a valid CSS value (e.g. 100ms). */
9198
enterAnimationDuration?: string = '225ms';

src/cdk-experimental/dialog/dialog-container.ts

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import {animate, AnimationEvent, state, style, transition, trigger} from '@angular/animations';
10-
import {FocusTrapFactory} from '@angular/cdk/a11y';
10+
import {FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y';
11+
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
1112
import {
1213
BasePortalOutlet,
1314
CdkPortalOutlet,
@@ -25,6 +26,7 @@ import {
2526
EmbeddedViewRef,
2627
HostBinding,
2728
Inject,
29+
NgZone,
2830
OnDestroy,
2931
Optional,
3032
ViewChild,
@@ -122,6 +124,8 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
122124
private _elementRef: ElementRef<HTMLElement>,
123125
private _focusTrapFactory: FocusTrapFactory,
124126
private _changeDetectorRef: ChangeDetectorRef,
127+
private readonly _interactivityChecker: InteractivityChecker,
128+
private readonly _ngZone: NgZone,
125129
@Optional() @Inject(DOCUMENT) _document: any,
126130
/** The dialog configuration. */
127131
public _config: DialogConfig) {
@@ -137,7 +141,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
137141
})).subscribe(event => {
138142
// Emit lifecycle events based on animation `done` callback.
139143
if (event.toState === 'enter') {
140-
this._autoFocusFirstTabbableElement();
144+
this._autoFocus();
141145
this._afterEnter.next();
142146
this._afterEnter.complete();
143147
}
@@ -228,7 +232,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
228232
/** Saves a reference to the element that was focused before the dialog was opened. */
229233
private _savePreviouslyFocusedElement() {
230234
if (this._document) {
231-
this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement;
235+
this._elementFocusedBeforeDialogWasOpened = _getFocusedElementPierceShadowDom();
232236
}
233237
}
234238

@@ -241,34 +245,72 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
241245
}
242246

243247
/**
244-
* Autofocus the first tabbable element inside of the dialog, if there is not a tabbable element,
245-
* focus the dialog instead.
248+
* Focuses the provided element. If the element is not focusable, it will add a tabIndex
249+
* attribute to forcefully focus it. The attribute is removed after focus is moved.
250+
* @param element The element to focus.
246251
*/
247-
private _autoFocusFirstTabbableElement() {
252+
private _forceFocus(element: HTMLElement, options?: FocusOptions) {
253+
if (!this._interactivityChecker.isFocusable(element)) {
254+
element.tabIndex = -1;
255+
// The tabindex attribute should be removed to avoid navigating to that element again
256+
this._ngZone.runOutsideAngular(() =>
257+
element.addEventListener('blur', () => element.removeAttribute('tabindex')));
258+
}
259+
element.focus(options);
260+
}
261+
262+
/**
263+
* Focuses the first element that matches the given selector within the focus trap.
264+
* @param selector The CSS selector for the element to set focus to.
265+
*/
266+
private _focusByCssSelector(selector: string, options?: FocusOptions) {
267+
let elementToFocus =
268+
this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null;
269+
if (elementToFocus) {
270+
this._forceFocus(elementToFocus, options);
271+
}
272+
}
273+
274+
/**
275+
* Autofocus the element specified by the autoFocus field. When autoFocus is not 'dialog', if
276+
* for some reason the element cannot be focused, the dialog container will be focused.
277+
*/
278+
private _autoFocus() {
248279
const element = this._elementRef.nativeElement;
249280

250281
// If were to attempt to focus immediately, then the content of the dialog would not yet be
251282
// ready in instances where change detection has to run first. To deal with this, we simply
252-
// wait for the microtask queue to be empty.
253-
if (this._config.autoFocus) {
254-
this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => {
255-
// If we didn't find any focusable elements inside the dialog, focus the
256-
// container so the user can't tab into other elements behind it.
257-
if (!hasMovedFocus) {
283+
// wait for the microtask queue to be empty when setting focus when autoFocus isn't set to
284+
// dialog. If the element inside the dialog can't be focused, then the container is focused
285+
// so the user can't tab into other elements behind it.
286+
switch (this._config.autoFocus) {
287+
case false:
288+
case 'dialog':
289+
const activeElement = _getFocusedElementPierceShadowDom();
290+
// Ensure that focus is on the dialog container. It's possible that a different
291+
// component tried to move focus while the open animation was running. See:
292+
// https://github.com/angular/components/issues/16215. Note that we only want to do this
293+
// if the focus isn't inside the dialog already, because it's possible that the consumer
294+
// turned off `autoFocus` in order to move focus themselves.
295+
if (activeElement !== element && !element.contains(activeElement)) {
258296
element.focus();
259297
}
260-
});
261-
} else {
262-
const activeElement = this._document.activeElement;
263-
264-
// Otherwise ensure that focus is on the dialog container. It's possible that a different
265-
// component tried to move focus while the open animation was running. See:
266-
// https://github.com/angular/components/issues/16215. Note that we only want to do this
267-
// if the focus isn't inside the dialog already, because it's possible that the consumer
268-
// turned off `autoFocus` in order to move focus themselves.
269-
if (activeElement !== element && !element.contains(activeElement)) {
270-
element.focus();
271-
}
298+
break;
299+
case true:
300+
case 'first-tabbable':
301+
this._focusTrap.focusInitialElementWhenReady()
302+
.then(hasMovedFocus => {
303+
if (!hasMovedFocus) {
304+
element.focus();
305+
}
306+
});
307+
break;
308+
case 'first-heading':
309+
this._focusByCssSelector('h1, h2, h3, h4, h5, h6');
310+
break;
311+
default:
312+
this._focusByCssSelector(this._config.autoFocus!);
313+
break;
272314
}
273315
}
274316

@@ -277,7 +319,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
277319
const toFocus = this._elementFocusedBeforeDialogWasOpened;
278320
// We need the extra check, because IE can set the `activeElement` to null in some cases.
279321
if (toFocus && typeof toFocus.focus === 'function') {
280-
const activeElement = this._document.activeElement;
322+
const activeElement = _getFocusedElementPierceShadowDom();
281323
const element = this._elementRef.nativeElement;
282324

283325
// Make sure that focus is still inside the dialog or is on the body (usually because a

src/cdk-experimental/dialog/dialog.spec.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,8 @@ describe('Dialog', () => {
880880
beforeEach(() => document.body.appendChild(overlayContainerElement));
881881
afterEach(() => document.body.removeChild(overlayContainerElement));
882882

883-
it('should focus the first tabbable element of the dialog on open', fakeAsync(() => {
883+
it('should focus the first tabbable element of the dialog on open (the default)',
884+
fakeAsync(() => {
884885
dialog.openFromComponent(PizzaMsg, {
885886
viewContainerRef: testViewContainerRef
886887
});
@@ -892,16 +893,52 @@ describe('Dialog', () => {
892893
.toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.');
893894
}));
894895

895-
it('should allow disabling focus of the first tabbable element', fakeAsync(() => {
896+
it('should focus the dialog element on open', fakeAsync(() => {
896897
dialog.openFromComponent(PizzaMsg, {
897898
viewContainerRef: testViewContainerRef,
898-
autoFocus: false
899+
autoFocus: 'dialog'
900+
});
901+
902+
viewContainerFixture.detectChanges();
903+
flushMicrotasks();
904+
905+
let container =
906+
overlayContainerElement.querySelector('cdk-dialog-container') as HTMLInputElement;
907+
908+
expect(document.activeElement).toBe(container, 'Expected container to be focused on open');
909+
}));
910+
911+
it('should focus the first header element on open', fakeAsync(() => {
912+
dialog.openFromComponent(ContentElementDialog, {
913+
viewContainerRef: testViewContainerRef,
914+
autoFocus: 'first-heading'
899915
});
900916

901917
viewContainerFixture.detectChanges();
902918
flushMicrotasks();
903919

904-
expect(document.activeElement!.tagName).not.toBe('INPUT');
920+
let firstHeader =
921+
overlayContainerElement.querySelector('h1[tabindex="-1"]') as HTMLInputElement;
922+
923+
expect(document.activeElement)
924+
.toBe(firstHeader, 'Expected first header to be focused on open');
925+
}));
926+
927+
it('should focus the first element that matches the css selector from autoFocus on open',
928+
fakeAsync(() => {
929+
dialog.openFromComponent(PizzaMsg, {
930+
viewContainerRef: testViewContainerRef,
931+
autoFocus: 'p'
932+
});
933+
934+
viewContainerFixture.detectChanges();
935+
flushMicrotasks();
936+
937+
let firstParagraph =
938+
overlayContainerElement.querySelector('p[tabindex="-1"]') as HTMLInputElement;
939+
940+
expect(document.activeElement)
941+
.toBe(firstParagraph, 'Expected first paragraph to be focused on open');
905942
}));
906943

907944
it('should re-focus trigger element when dialog closes', fakeAsync(() => {

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,7 @@ export class FocusTrap {
128128
}
129129

130130
/**
131-
* Waits for the zone to stabilize, then either focuses the first element that the
132-
* user specified, or the first tabbable element.
131+
* Waits for the zone to stabilize, then focuses the first tabbable element.
133132
* @returns Returns a promise that resolves with a boolean, depending
134133
* on whether focus was moved successfully.
135134
*/

src/material-experimental/mdc-dialog/dialog-container.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {FocusMonitor, FocusTrapFactory} from '@angular/cdk/a11y';
9+
import {FocusMonitor, FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y';
1010
import {DOCUMENT} from '@angular/common';
1111
import {
1212
ChangeDetectionStrategy,
@@ -16,7 +16,8 @@ import {
1616
Inject,
1717
OnDestroy,
1818
Optional,
19-
ViewEncapsulation
19+
ViewEncapsulation,
20+
NgZone
2021
} from '@angular/core';
2122
import {MatDialogConfig, _MatDialogContainerBase} from '@angular/material/dialog';
2223
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
@@ -65,9 +66,21 @@ export class MatDialogContainer extends _MatDialogContainerBase implements OnDes
6566
changeDetectorRef: ChangeDetectorRef,
6667
@Optional() @Inject(DOCUMENT) document: any,
6768
config: MatDialogConfig,
69+
checker: InteractivityChecker,
70+
ngZone: NgZone,
6871
@Optional() @Inject(ANIMATION_MODULE_TYPE) private _animationMode?: string,
69-
focusMonitor?: FocusMonitor) {
70-
super(elementRef, focusTrapFactory, changeDetectorRef, document, config, focusMonitor);
72+
focusMonitor?: FocusMonitor
73+
) {
74+
super(
75+
elementRef,
76+
focusTrapFactory,
77+
changeDetectorRef,
78+
document,
79+
config,
80+
checker,
81+
ngZone,
82+
focusMonitor
83+
);
7184
}
7285

7386
_initializeWithAttachedContent() {

0 commit comments

Comments
 (0)