Skip to content

Commit 769996e

Browse files
authored
feat(multiple): add options to autoFocus field for dialogs (#22780)
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 b17ed9d commit 769996e

File tree

18 files changed

+732
-167
lines changed

18 files changed

+732
-167
lines changed

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

+9-2
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

+67-24
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
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';
1111
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
1212
import {
1313
BasePortalOutlet,
@@ -26,6 +26,7 @@ import {
2626
EmbeddedViewRef,
2727
HostBinding,
2828
Inject,
29+
NgZone,
2930
OnDestroy,
3031
Optional,
3132
ViewChild,
@@ -123,6 +124,8 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
123124
private _elementRef: ElementRef<HTMLElement>,
124125
private _focusTrapFactory: FocusTrapFactory,
125126
private _changeDetectorRef: ChangeDetectorRef,
127+
private readonly _interactivityChecker: InteractivityChecker,
128+
private readonly _ngZone: NgZone,
126129
@Optional() @Inject(DOCUMENT) _document: any,
127130
/** The dialog configuration. */
128131
public _config: DialogConfig) {
@@ -138,7 +141,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
138141
})).subscribe(event => {
139142
// Emit lifecycle events based on animation `done` callback.
140143
if (event.toState === 'enter') {
141-
this._autoFocusFirstTabbableElement();
144+
this._autoFocus();
142145
this._afterEnter.next();
143146
this._afterEnter.complete();
144147
}
@@ -242,34 +245,74 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
242245
}
243246

244247
/**
245-
* Autofocus the first tabbable element inside of the dialog, if there is not a tabbable element,
246-
* 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.
247251
*/
248-
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+
element.addEventListener('mousedown', () => element.removeAttribute('tabindex'));
259+
});
260+
}
261+
element.focus(options);
262+
}
263+
264+
/**
265+
* Focuses the first element that matches the given selector within the focus trap.
266+
* @param selector The CSS selector for the element to set focus to.
267+
*/
268+
private _focusByCssSelector(selector: string, options?: FocusOptions) {
269+
let elementToFocus =
270+
this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null;
271+
if (elementToFocus) {
272+
this._forceFocus(elementToFocus, options);
273+
}
274+
}
275+
276+
/**
277+
* Autofocus the element specified by the autoFocus field. When autoFocus is not 'dialog', if
278+
* for some reason the element cannot be focused, the dialog container will be focused.
279+
*/
280+
private _autoFocus() {
249281
const element = this._elementRef.nativeElement;
250282

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

@@ -278,7 +321,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
278321
const toFocus = this._elementFocusedBeforeDialogWasOpened;
279322
// We need the extra check, because IE can set the `activeElement` to null in some cases.
280323
if (toFocus && typeof toFocus.focus === 'function') {
281-
const activeElement = this._document.activeElement;
324+
const activeElement = _getFocusedElementPierceShadowDom();
282325
const element = this._elementRef.nativeElement;
283326

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

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

+41-4
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,8 @@ describe('Dialog', () => {
882882
beforeEach(() => document.body.appendChild(overlayContainerElement));
883883
afterEach(() => document.body.removeChild(overlayContainerElement));
884884

885-
it('should focus the first tabbable element of the dialog on open', fakeAsync(() => {
885+
it('should focus the first tabbable element of the dialog on open (the default)',
886+
fakeAsync(() => {
886887
dialog.openFromComponent(PizzaMsg, {
887888
viewContainerRef: testViewContainerRef
888889
});
@@ -894,16 +895,52 @@ describe('Dialog', () => {
894895
.toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.');
895896
}));
896897

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

903919
viewContainerFixture.detectChanges();
904920
flushMicrotasks();
905921

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

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

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

+1-2
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

+17-4
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
override _initializeWithAttachedContent() {

0 commit comments

Comments
 (0)