From c12cb532482a4c95be55d955b8cc80d463d6ab9c Mon Sep 17 00:00:00 2001 From: Amy Sorto <8575252+amysorto@users.noreply.github.com> Date: Mon, 24 May 2021 12:22:51 -0400 Subject: [PATCH] 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 --- src/cdk-experimental/dialog/dialog-config.ts | 11 +- .../dialog/dialog-container.ts | 92 +++++--- src/cdk-experimental/dialog/dialog.spec.ts | 45 +++- src/cdk/a11y/focus-trap/focus-trap.ts | 3 +- .../mdc-dialog/dialog-container.ts | 21 +- .../mdc-dialog/dialog.spec.ts | 202 ++++++++++++++---- .../mdc-dialog/public-api.ts | 1 + .../bottom-sheet/bottom-sheet-config.ts | 15 +- .../bottom-sheet/bottom-sheet-container.ts | 83 +++++-- .../bottom-sheet/bottom-sheet.spec.ts | 75 +++++-- src/material/dialog/dialog-config.ts | 11 +- src/material/dialog/dialog-container.ts | 90 ++++++-- src/material/dialog/dialog.spec.ts | 107 +++++++++- src/material/sidenav/drawer.spec.ts | 7 +- src/material/sidenav/drawer.ts | 98 +++++++-- .../material/bottom-sheet.d.ts | 8 +- tools/public_api_guard/material/dialog.d.ts | 8 +- tools/public_api_guard/material/sidenav.d.ts | 10 +- 18 files changed, 719 insertions(+), 168 deletions(-) diff --git a/src/cdk-experimental/dialog/dialog-config.ts b/src/cdk-experimental/dialog/dialog-config.ts index 71cc1872c070..abeca57623e6 100644 --- a/src/cdk-experimental/dialog/dialog-config.ts +++ b/src/cdk-experimental/dialog/dialog-config.ts @@ -10,6 +10,9 @@ import {Direction} from '@angular/cdk/bidi'; import {ComponentType} from '@angular/cdk/overlay'; import {CdkDialogContainer} from './dialog-container'; +/** Options for where to set focus to automatically on dialog open */ +export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + /** Valid ARIA roles for a dialog element. */ export type DialogRole = 'dialog' | 'alertdialog'; @@ -84,8 +87,12 @@ export class DialogConfig { /** Aria label to assign to the dialog element */ ariaLabel?: string | null = null; - /** Whether the dialog should focus the first focusable element on open. */ - autoFocus?: boolean = true; + /** + * Where the dialog should focus on open. + * @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or + * AutoFocusTarget instead. + */ + autoFocus?: AutoFocusTarget | string | boolean = 'first-tabbable'; /** Duration of the enter animation. Has to be a valid CSS value (e.g. 100ms). */ enterAnimationDuration?: string = '225ms'; diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 1d3fd9247cc9..cb1f9284c5d3 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -7,7 +7,8 @@ */ import {animate, AnimationEvent, state, style, transition, trigger} from '@angular/animations'; -import {FocusTrapFactory} from '@angular/cdk/a11y'; +import {FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y'; +import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; import { BasePortalOutlet, CdkPortalOutlet, @@ -25,6 +26,7 @@ import { EmbeddedViewRef, HostBinding, Inject, + NgZone, OnDestroy, Optional, ViewChild, @@ -122,6 +124,8 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory, private _changeDetectorRef: ChangeDetectorRef, + private readonly _interactivityChecker: InteractivityChecker, + private readonly _ngZone: NgZone, @Optional() @Inject(DOCUMENT) _document: any, /** The dialog configuration. */ public _config: DialogConfig) { @@ -137,7 +141,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { })).subscribe(event => { // Emit lifecycle events based on animation `done` callback. if (event.toState === 'enter') { - this._autoFocusFirstTabbableElement(); + this._autoFocus(); this._afterEnter.next(); this._afterEnter.complete(); } @@ -228,7 +232,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { /** Saves a reference to the element that was focused before the dialog was opened. */ private _savePreviouslyFocusedElement() { if (this._document) { - this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement; + this._elementFocusedBeforeDialogWasOpened = _getFocusedElementPierceShadowDom(); } } @@ -241,34 +245,72 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { } /** - * Autofocus the first tabbable element inside of the dialog, if there is not a tabbable element, - * focus the dialog instead. + * Focuses the provided element. If the element is not focusable, it will add a tabIndex + * attribute to forcefully focus it. The attribute is removed after focus is moved. + * @param element The element to focus. */ - private _autoFocusFirstTabbableElement() { + private _forceFocus(element: HTMLElement, options?: FocusOptions) { + if (!this._interactivityChecker.isFocusable(element)) { + element.tabIndex = -1; + // The tabindex attribute should be removed to avoid navigating to that element again + this._ngZone.runOutsideAngular(() => + element.addEventListener('blur', () => element.removeAttribute('tabindex'))); + } + element.focus(options); + } + + /** + * Focuses the first element that matches the given selector within the focus trap. + * @param selector The CSS selector for the element to set focus to. + */ + private _focusByCssSelector(selector: string, options?: FocusOptions) { + let elementToFocus = + this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null; + if (elementToFocus) { + this._forceFocus(elementToFocus, options); + } + } + + /** + * Autofocus the element specified by the autoFocus field. When autoFocus is not 'dialog', if + * for some reason the element cannot be focused, the dialog container will be focused. + */ + private _autoFocus() { const element = this._elementRef.nativeElement; // If were to attempt to focus immediately, then the content of the dialog would not yet be // ready in instances where change detection has to run first. To deal with this, we simply - // wait for the microtask queue to be empty. - if (this._config.autoFocus) { - this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => { - // If we didn't find any focusable elements inside the dialog, focus the - // container so the user can't tab into other elements behind it. - if (!hasMovedFocus) { + // wait for the microtask queue to be empty when setting focus when autoFocus isn't set to + // dialog. If the element inside the dialog can't be focused, then the container is focused + // so the user can't tab into other elements behind it. + switch (this._config.autoFocus) { + case false: + case 'dialog': + const activeElement = _getFocusedElementPierceShadowDom(); + // Ensure that focus is on the dialog container. It's possible that a different + // component tried to move focus while the open animation was running. See: + // https://github.com/angular/components/issues/16215. Note that we only want to do this + // if the focus isn't inside the dialog already, because it's possible that the consumer + // turned off `autoFocus` in order to move focus themselves. + if (activeElement !== element && !element.contains(activeElement)) { element.focus(); } - }); - } else { - const activeElement = this._document.activeElement; - - // Otherwise ensure that focus is on the dialog container. It's possible that a different - // component tried to move focus while the open animation was running. See: - // https://github.com/angular/components/issues/16215. Note that we only want to do this - // if the focus isn't inside the dialog already, because it's possible that the consumer - // turned off `autoFocus` in order to move focus themselves. - if (activeElement !== element && !element.contains(activeElement)) { - element.focus(); - } + break; + case true: + case 'first-tabbable': + this._focusTrap.focusInitialElementWhenReady() + .then(hasMovedFocus => { + if (!hasMovedFocus) { + element.focus(); + } + }); + break; + case 'first-heading': + this._focusByCssSelector('h1, h2, h3, h4, h5, h6'); + break; + default: + this._focusByCssSelector(this._config.autoFocus!); + break; } } @@ -277,7 +319,7 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { const toFocus = this._elementFocusedBeforeDialogWasOpened; // We need the extra check, because IE can set the `activeElement` to null in some cases. if (toFocus && typeof toFocus.focus === 'function') { - const activeElement = this._document.activeElement; + const activeElement = _getFocusedElementPierceShadowDom(); const element = this._elementRef.nativeElement; // Make sure that focus is still inside the dialog or is on the body (usually because a diff --git a/src/cdk-experimental/dialog/dialog.spec.ts b/src/cdk-experimental/dialog/dialog.spec.ts index 67f098fa4fae..d5145c8bb8c8 100644 --- a/src/cdk-experimental/dialog/dialog.spec.ts +++ b/src/cdk-experimental/dialog/dialog.spec.ts @@ -880,7 +880,8 @@ describe('Dialog', () => { beforeEach(() => document.body.appendChild(overlayContainerElement)); afterEach(() => document.body.removeChild(overlayContainerElement)); - it('should focus the first tabbable element of the dialog on open', fakeAsync(() => { + it('should focus the first tabbable element of the dialog on open (the default)', + fakeAsync(() => { dialog.openFromComponent(PizzaMsg, { viewContainerRef: testViewContainerRef }); @@ -892,16 +893,52 @@ describe('Dialog', () => { .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); })); - it('should allow disabling focus of the first tabbable element', fakeAsync(() => { + it('should focus the dialog element on open', fakeAsync(() => { dialog.openFromComponent(PizzaMsg, { viewContainerRef: testViewContainerRef, - autoFocus: false + autoFocus: 'dialog' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let container = + overlayContainerElement.querySelector('cdk-dialog-container') as HTMLInputElement; + + expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); + })); + + it('should focus the first header element on open', fakeAsync(() => { + dialog.openFromComponent(ContentElementDialog, { + viewContainerRef: testViewContainerRef, + autoFocus: 'first-heading' }); viewContainerFixture.detectChanges(); flushMicrotasks(); - expect(document.activeElement!.tagName).not.toBe('INPUT'); + let firstHeader = + overlayContainerElement.querySelector('h1[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to be focused on open'); + })); + + it('should focus the first element that matches the css selector from autoFocus on open', + fakeAsync(() => { + dialog.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: 'p' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstParagraph = + overlayContainerElement.querySelector('p[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstParagraph, 'Expected first paragraph to be focused on open'); })); it('should re-focus trigger element when dialog closes', fakeAsync(() => { diff --git a/src/cdk/a11y/focus-trap/focus-trap.ts b/src/cdk/a11y/focus-trap/focus-trap.ts index 2d91993ab7eb..737c6fc9e795 100644 --- a/src/cdk/a11y/focus-trap/focus-trap.ts +++ b/src/cdk/a11y/focus-trap/focus-trap.ts @@ -128,8 +128,7 @@ export class FocusTrap { } /** - * Waits for the zone to stabilize, then either focuses the first element that the - * user specified, or the first tabbable element. + * Waits for the zone to stabilize, then focuses the first tabbable element. * @returns Returns a promise that resolves with a boolean, depending * on whether focus was moved successfully. */ diff --git a/src/material-experimental/mdc-dialog/dialog-container.ts b/src/material-experimental/mdc-dialog/dialog-container.ts index c1dd88f82f42..17b01642a40b 100644 --- a/src/material-experimental/mdc-dialog/dialog-container.ts +++ b/src/material-experimental/mdc-dialog/dialog-container.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusMonitor, FocusTrapFactory} from '@angular/cdk/a11y'; +import {FocusMonitor, FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y'; import {DOCUMENT} from '@angular/common'; import { ChangeDetectionStrategy, @@ -16,7 +16,8 @@ import { Inject, OnDestroy, Optional, - ViewEncapsulation + ViewEncapsulation, + NgZone } from '@angular/core'; import {MatDialogConfig, _MatDialogContainerBase} from '@angular/material/dialog'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; @@ -65,9 +66,21 @@ export class MatDialogContainer extends _MatDialogContainerBase implements OnDes changeDetectorRef: ChangeDetectorRef, @Optional() @Inject(DOCUMENT) document: any, config: MatDialogConfig, + checker: InteractivityChecker, + ngZone: NgZone, @Optional() @Inject(ANIMATION_MODULE_TYPE) private _animationMode?: string, - focusMonitor?: FocusMonitor) { - super(elementRef, focusTrapFactory, changeDetectorRef, document, config, focusMonitor); + focusMonitor?: FocusMonitor + ) { + super( + elementRef, + focusTrapFactory, + changeDetectorRef, + document, + config, + checker, + ngZone, + focusMonitor + ); } _initializeWithAttachedContent() { diff --git a/src/material-experimental/mdc-dialog/dialog.spec.ts b/src/material-experimental/mdc-dialog/dialog.spec.ts index cfde5a9011a7..cbd5d21abced 100644 --- a/src/material-experimental/mdc-dialog/dialog.spec.ts +++ b/src/material-experimental/mdc-dialog/dialog.spec.ts @@ -934,33 +934,114 @@ describe('MDC-based MatDialog', () => { expect(document.activeElement).toBe(input, 'Expected input to stay focused after click'); })); + it('should recapture focus to the first tabbable element when clicking on the backdrop with ' + + 'autoFocus set to "first-tabbable" (the default)', fakeAsync(() => { + dialog.open(PizzaMsg, { + disableClose: true, + viewContainerRef: testViewContainerRef + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let input = overlayContainerElement.querySelector('input') as HTMLInputElement; + + expect(document.activeElement).toBe(input, 'Expected input to be focused on open'); + + input.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement).toBe(input, 'Expected input to stay focused after click'); + })); + it('should recapture focus to the container when clicking on the backdrop with ' + - 'autoFocus disabled', - fakeAsync(() => { - dialog.open( - PizzaMsg, - {disableClose: true, viewContainerRef: testViewContainerRef, autoFocus: false}); + 'autoFocus set to "dialog"', + fakeAsync(() => { + dialog.open(PizzaMsg, { + disableClose: true, + viewContainerRef: testViewContainerRef, + autoFocus: 'dialog' + }); - viewContainerFixture.detectChanges(); - flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); - let backdrop = - overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; - let container = - overlayContainerElement.querySelector('.mat-mdc-dialog-container') as HTMLInputElement; + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let container = + overlayContainerElement.querySelector('.mat-mdc-dialog-container') as HTMLInputElement; - expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); + expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); - container.blur(); // Programmatic clicks might not move focus so we simulate it. - backdrop.click(); - viewContainerFixture.detectChanges(); - flush(); + container.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); - expect(document.activeElement) - .toBe(container, 'Expected container to stay focused after click'); - })); + expect(document.activeElement) + .toBe(container, 'Expected container to stay focused after click'); + })); }); + it('should recapture focus to the first header when clicking on the backdrop with ' + + 'autoFocus set to "first-heading"', fakeAsync(() => { + dialog.open(ContentElementDialog, { + disableClose: true, + viewContainerRef: testViewContainerRef, + autoFocus: 'first-heading' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let firstHeader = overlayContainerElement + .querySelector('.mat-mdc-dialog-title[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to be focused on open'); + + firstHeader.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to stay focused after click'); + })); + + it('should recapture focus to the first element that matches the css selector when ' + + 'clicking on the backdrop with autoFocus set to a css selector', fakeAsync(() => { + dialog.open(ContentElementDialog, { + disableClose: true, + viewContainerRef: testViewContainerRef, + autoFocus: 'button' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let firstButton = + overlayContainerElement.querySelector('[mat-dialog-close]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstButton, 'Expected first button to be focused on open'); + + firstButton.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement) + .toBe(firstButton, 'Expected first button to stay focused after click'); + })); + describe('hasBackdrop option', () => { it('should have a backdrop', () => { dialog.open(PizzaMsg, {hasBackdrop: true, viewContainerRef: testViewContainerRef}); @@ -1015,34 +1096,77 @@ describe('MDC-based MatDialog', () => { beforeEach(() => document.body.appendChild(overlayContainerElement)); afterEach(() => document.body.removeChild(overlayContainerElement)); - it('should focus the first tabbable element of the dialog on open', fakeAsync(() => { - dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + it('should focus the first tabbable element of the dialog on open (the default)', + fakeAsync(() => { + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); - viewContainerFixture.detectChanges(); - flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); - expect(document.activeElement!.tagName) - .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); - })); + expect(document.activeElement!.tagName) + .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); + })); - it('should allow disabling focus of the first tabbable element', fakeAsync(() => { - dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef, autoFocus: false}); + it('should focus the dialog element on open', fakeAsync(() => { + dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: 'dialog' + }); - viewContainerFixture.detectChanges(); - flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); - expect(document.activeElement!.tagName).not.toBe('INPUT'); - })); + let container = + overlayContainerElement.querySelector('.mat-mdc-dialog-container') as HTMLInputElement; + + expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); + })); + + it('should focus the first header element on open', fakeAsync(() => { + dialog.open(ContentElementDialog, { + viewContainerRef: testViewContainerRef, + autoFocus: 'first-heading' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstHeader = + overlayContainerElement.querySelector('h1[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to be focused on open'); + })); + + it('should focus the first element that matches the css selector from autoFocus on open', + fakeAsync(() => { + dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: 'p' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstParagraph = + overlayContainerElement.querySelector('p[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstParagraph, 'Expected first paragraph to be focused on open'); + })); it('should attach the focus trap even if automatic focus is disabled', fakeAsync(() => { - dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef, autoFocus: false}); + dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: 'false' + }); - viewContainerFixture.detectChanges(); - flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); - expect(overlayContainerElement.querySelectorAll('.cdk-focus-trap-anchor').length) - .toBeGreaterThan(0); - })); + expect(overlayContainerElement.querySelectorAll('.cdk-focus-trap-anchor').length) + .toBeGreaterThan(0); + })); it('should re-focus trigger element when dialog closes', fakeAsync(() => { // Create a element that has focus before the dialog is opened. @@ -1655,7 +1779,7 @@ describe('MDC-based MatDialog with default options', () => { minHeight: '50px', maxWidth: '150px', maxHeight: '150px', - autoFocus: false, + autoFocus: 'dialog', }; TestBed.configureTestingModule({ diff --git a/src/material-experimental/mdc-dialog/public-api.ts b/src/material-experimental/mdc-dialog/public-api.ts index ed20e70d8967..04ccac4c20c1 100644 --- a/src/material-experimental/mdc-dialog/public-api.ts +++ b/src/material-experimental/mdc-dialog/public-api.ts @@ -13,6 +13,7 @@ export * from './dialog-container'; export * from './module'; export { + AutoFocusTarget, MatDialogState, MatDialogConfig, matDialogAnimations, diff --git a/src/material/bottom-sheet/bottom-sheet-config.ts b/src/material/bottom-sheet/bottom-sheet-config.ts index 9bf09e225af7..3fe197d44461 100644 --- a/src/material/bottom-sheet/bottom-sheet-config.ts +++ b/src/material/bottom-sheet/bottom-sheet-config.ts @@ -10,6 +10,9 @@ import {Direction} from '@angular/cdk/bidi'; import {ScrollStrategy} from '@angular/cdk/overlay'; import {InjectionToken, ViewContainerRef} from '@angular/core'; +/** Options for where to set focus to automatically on dialog open */ +export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + /** Injection token that can be used to access the data that was passed in to a bottom sheet. */ export const MAT_BOTTOM_SHEET_DATA = new InjectionToken('MatBottomSheetData'); @@ -48,11 +51,15 @@ export class MatBottomSheetConfig { */ closeOnNavigation?: boolean = true; - // Note that this is disabled by default, because while the a11y recommendations are to focus - // the first focusable element, doing so prevents screen readers from reading out the + // Note that this is set to 'dialog' by default, because while the a11y recommendations + // are to focus the first focusable element, doing so prevents screen readers from reading out the // rest of the bottom sheet content. - /** Whether the bottom sheet should focus the first focusable element on open. */ - autoFocus?: boolean = false; + /** + * Where the bottom sheet should focus on open. + * @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or + * AutoFocusTarget instead. + */ + autoFocus?: AutoFocusTarget | string | boolean = 'dialog'; /** * Whether the bottom sheet should restore focus to the diff --git a/src/material/bottom-sheet/bottom-sheet-container.ts b/src/material/bottom-sheet/bottom-sheet-container.ts index 45366617cce3..7b578f9e197f 100644 --- a/src/material/bottom-sheet/bottom-sheet-container.ts +++ b/src/material/bottom-sheet/bottom-sheet-container.ts @@ -19,6 +19,7 @@ import { EventEmitter, Inject, Optional, + NgZone, } from '@angular/core'; import {AnimationEvent} from '@angular/animations'; import { @@ -33,7 +34,7 @@ import {MatBottomSheetConfig} from './bottom-sheet-config'; import {matBottomSheetAnimations} from './bottom-sheet-animations'; import {Subscription} from 'rxjs'; import {DOCUMENT} from '@angular/common'; -import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; +import {FocusTrap, FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y'; import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; // TODO(crisbeto): consolidate some logic between this, MatDialog and MatSnackBar @@ -92,6 +93,8 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr private _elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, private _focusTrapFactory: FocusTrapFactory, + private readonly _interactivityChecker: InteractivityChecker, + private readonly _ngZone: NgZone, breakpointObserver: BreakpointObserver, @Optional() @Inject(DOCUMENT) document: any, /** The bottom sheet configuration. */ @@ -197,7 +200,37 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr } } - /** Moves the focus inside the focus trap. */ + /** + * Focuses the provided element. If the element is not focusable, it will add a tabIndex + * attribute to forcefully focus it. The attribute is removed after focus is moved. + * @param element The element to focus. + */ + private _forceFocus(element: HTMLElement, options?: FocusOptions) { + if (!this._interactivityChecker.isFocusable(element)) { + element.tabIndex = -1; + // The tabindex attribute should be removed to avoid navigating to that element again + this._ngZone.runOutsideAngular(() => + element.addEventListener('blur', () => element.removeAttribute('tabindex'))); + } + element.focus(options); + } + + /** + * Focuses the first element that matches the given selector within the focus trap. + * @param selector The CSS selector for the element to set focus to. + */ + private _focusByCssSelector(selector: string, options?: FocusOptions) { + let elementToFocus = + this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null; + if (elementToFocus) { + this._forceFocus(elementToFocus, options); + } + } + + /** + * Moves the focus inside the focus trap. When autoFocus is not set to 'bottom-sheet', + * if focus cannot be moved then focus will go to the bottom sheet container. + */ private _trapFocus() { const element = this._elementRef.nativeElement; @@ -205,19 +238,39 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr this._focusTrap = this._focusTrapFactory.create(element); } - if (this.bottomSheetConfig.autoFocus) { - this._focusTrap.focusInitialElementWhenReady(); - } else { - const activeElement = _getFocusedElementPierceShadowDom(); - - // Otherwise ensure that focus is on the container. It's possible that a different - // component tried to move focus while the open animation was running. See: - // https://github.com/angular/components/issues/16215. Note that we only want to do this - // if the focus isn't inside the bottom sheet already, because it's possible that the - // consumer turned off `autoFocus` in order to move focus themselves. - if (activeElement !== element && !element.contains(activeElement)) { - element.focus(); - } + // If were to attempt to focus immediately, then the content of the bottom sheet would not + // yet be ready in instances where change detection has to run first. To deal with this, + // we simply wait for the microtask queue to be empty when setting focus when autoFocus + // isn't set to bottom sheet. If the element inside the bottom sheet can't be focused, + // then the container is focused so the user can't tab into other elements behind it. + switch (this.bottomSheetConfig.autoFocus) { + case false: + case 'dialog': + const activeElement = _getFocusedElementPierceShadowDom(); + // Ensure that focus is on the bottom sheet container. It's possible that a different + // component tried to move focus while the open animation was running. See: + // https://github.com/angular/components/issues/16215. Note that we only want to do this + // if the focus isn't inside the bottom sheet already, because it's possible that the + // consumer specified `autoFocus` in order to move focus themselves. + if (activeElement !== element && !element.contains(activeElement)) { + element.focus(); + } + break; + case true: + case 'first-tabbable': + this._focusTrap.focusInitialElementWhenReady() + .then(hasMovedFocus => { + if (!hasMovedFocus) { + element.focus(); + } + }); + break; + case 'first-heading': + this._focusByCssSelector('h1, h2, h3, h4, h5, h6'); + break; + default: + this._focusByCssSelector(this.bottomSheetConfig.autoFocus!); + break; } } diff --git a/src/material/bottom-sheet/bottom-sheet.spec.ts b/src/material/bottom-sheet/bottom-sheet.spec.ts index 1167f5e734ba..e432251f8d83 100644 --- a/src/material/bottom-sheet/bottom-sheet.spec.ts +++ b/src/material/bottom-sheet/bottom-sheet.spec.ts @@ -608,7 +608,7 @@ describe('MatBottomSheet', () => { it('should create a focus trap if autoFocus is disabled', fakeAsync(() => { bottomSheet.open(PizzaMsg, { viewContainerRef: testViewContainerRef, - autoFocus: false + autoFocus: false, }); viewContainerFixture.detectChanges(); @@ -620,29 +620,66 @@ describe('MatBottomSheet', () => { })); it('should focus the first tabbable element of the bottom sheet on open when' + - 'autoFocus is enabled', fakeAsync(() => { - bottomSheet.open(PizzaMsg, { - viewContainerRef: testViewContainerRef, - autoFocus: true - }); + 'autoFocus is set to "first-tabbable"', fakeAsync(() => { + bottomSheet.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: 'first-tabbable' + }); - viewContainerFixture.detectChanges(); - flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); - expect(document.activeElement!.tagName).toBe('INPUT', - 'Expected first tabbable element (input) in the sheet to be focused.'); - })); + expect(document.activeElement!.tagName) + .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); + })); - it('should allow disabling focus of the first tabbable element', fakeAsync(() => { + it('should focus the bottom sheet element on open when autoFocus is set to ' + + '"dialog" (the default)', fakeAsync(() => { bottomSheet.open(PizzaMsg, { viewContainerRef: testViewContainerRef, - autoFocus: false }); viewContainerFixture.detectChanges(); flushMicrotasks(); - expect(document.activeElement!.tagName).not.toBe('INPUT'); + let container = + overlayContainerElement.querySelector('.mat-bottom-sheet-container') as HTMLInputElement; + + expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); + })); + + it('should focus the bottom sheet element on open when autoFocus is set to ' + + '"first-heading"', fakeAsync(() => { + bottomSheet.open(ContentElementDialog, { + viewContainerRef: testViewContainerRef, + autoFocus: 'first-heading' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstHeader = + overlayContainerElement.querySelector('h1[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to be focused on open'); + })); + + it('should focus the first element that matches the css selector on open when ' + + 'autoFocus is set to a css selector', fakeAsync(() => { + bottomSheet.open(ContentElementDialog, { + viewContainerRef: testViewContainerRef, + autoFocus: 'p' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstParagraph = + overlayContainerElement.querySelector('p[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstParagraph, 'Expected first paragraph to be focused on open'); })); it('should re-focus trigger element when bottom sheet closes', fakeAsync(() => { @@ -865,7 +902,7 @@ describe('MatBottomSheet with default options', () => { const defaultConfig: MatBottomSheetConfig = { hasBackdrop: false, disableClose: true, - autoFocus: false + autoFocus: 'dialog' }; TestBed.configureTestingModule({ @@ -971,6 +1008,14 @@ class PizzaMsg { @Component({template: '

Taco

'}) class TacoMsg {} +@Component({ + template: ` +

This is the title

+

This is the paragraph

+ ` +}) +class ContentElementDialog {} + @Component({ template: '', providers: [MatBottomSheet] diff --git a/src/material/dialog/dialog-config.ts b/src/material/dialog/dialog-config.ts index c8657b2ffb35..44ee19efd36b 100644 --- a/src/material/dialog/dialog-config.ts +++ b/src/material/dialog/dialog-config.ts @@ -10,6 +10,9 @@ import {ViewContainerRef, ComponentFactoryResolver} from '@angular/core'; import {Direction} from '@angular/cdk/bidi'; import {ScrollStrategy} from '@angular/cdk/overlay'; +/** Options for where to set focus to automatically on dialog open */ +export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + /** Valid ARIA roles for a dialog element. */ export type DialogRole = 'dialog' | 'alertdialog'; @@ -95,8 +98,12 @@ export class MatDialogConfig { /** Aria label to assign to the dialog element. */ ariaLabel?: string | null = null; - /** Whether the dialog should focus the first focusable element on open. */ - autoFocus?: boolean = true; + /** + * Where the dialog should focus on open. + * @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or + * AutoFocusTarget instead. + */ + autoFocus?: AutoFocusTarget | string | boolean = 'first-tabbable'; /** * Whether the dialog should restore focus to the diff --git a/src/material/dialog/dialog-container.ts b/src/material/dialog/dialog-container.ts index 5e5ed7ad90e5..861a58d08ada 100644 --- a/src/material/dialog/dialog-container.ts +++ b/src/material/dialog/dialog-container.ts @@ -7,7 +7,13 @@ */ import {AnimationEvent} from '@angular/animations'; -import {FocusMonitor, FocusOrigin, FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; +import { + FocusMonitor, + FocusOrigin, + FocusTrap, + FocusTrapFactory, + InteractivityChecker +} from '@angular/cdk/a11y'; import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; import { BasePortalOutlet, @@ -27,6 +33,7 @@ import { EmbeddedViewRef, EventEmitter, Inject, + NgZone, Optional, ViewChild, ViewEncapsulation, @@ -89,6 +96,8 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet { @Optional() @Inject(DOCUMENT) _document: any, /** The dialog configuration. */ public _config: MatDialogConfig, + private readonly _interactivityChecker: InteractivityChecker, + private readonly _ngZone: NgZone, private _focusMonitor?: FocusMonitor) { super(); @@ -151,28 +160,75 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet { /** Moves focus back into the dialog if it was moved out. */ _recaptureFocus() { if (!this._containsFocus()) { - const focusContainer = !this._config.autoFocus || !this._focusTrap.focusInitialElement(); + this._trapFocus(); + } + } - if (focusContainer) { - this._elementRef.nativeElement.focus(); - } + /** + * Focuses the provided element. If the element is not focusable, it will add a tabIndex + * attribute to forcefully focus it. The attribute is removed after focus is moved. + * @param element The element to focus. + */ + private _forceFocus(element: HTMLElement, options?: FocusOptions) { + if (!this._interactivityChecker.isFocusable(element)) { + element.tabIndex = -1; + // The tabindex attribute should be removed to avoid navigating to that element again + this._ngZone.runOutsideAngular(() => + element.addEventListener('blur', () => element.removeAttribute('tabindex'))); } + element.focus(options); } - /** Moves the focus inside the focus trap. */ + /** + * Focuses the first element that matches the given selector within the focus trap. + * @param selector The CSS selector for the element to set focus to. + */ + private _focusByCssSelector(selector: string, options?: FocusOptions) { + let elementToFocus = + this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null; + if (elementToFocus) { + this._forceFocus(elementToFocus, options); + } + } + + /** + * Moves the focus inside the focus trap. When autoFocus is not set to 'dialog', if focus + * cannot be moved then focus will go to the dialog container. + */ protected _trapFocus() { - // If we were to attempt to focus immediately, then the content of the dialog would not yet be + const element = this._elementRef.nativeElement; + // If were to attempt to focus immediately, then the content of the dialog would not yet be // ready in instances where change detection has to run first. To deal with this, we simply - // wait for the microtask queue to be empty. - if (this._config.autoFocus) { - this._focusTrap.focusInitialElementWhenReady(); - } else if (!this._containsFocus()) { - // Otherwise ensure that focus is on the dialog container. It's possible that a different - // component tried to move focus while the open animation was running. See: - // https://github.com/angular/components/issues/16215. Note that we only want to do this - // if the focus isn't inside the dialog already, because it's possible that the consumer - // turned off `autoFocus` in order to move focus themselves. - this._elementRef.nativeElement.focus(); + // wait for the microtask queue to be empty when setting focus when autoFocus isn't set to + // dialog. If the element inside the dialog can't be focused, then the container is focused + // so the user can't tab into other elements behind it. + switch (this._config.autoFocus) { + case false: + case 'dialog': + // Ensure that focus is on the dialog container. It's possible that a different + // component tried to move focus while the open animation was running. See: + // https://github.com/angular/components/issues/16215. Note that we only want to do this + // if the focus isn't inside the dialog already, because it's possible that the consumer + // turned off `autoFocus` in order to move focus themselves. + if (!this._containsFocus()) { + element.focus(); + } + break; + case true: + case 'first-tabbable': + this._focusTrap.focusInitialElementWhenReady() + .then(hasMovedFocus => { + if (!hasMovedFocus) { + element.focus(); + } + }); + break; + case 'first-heading': + this._focusByCssSelector('h1, h2, h3, h4, h5, h6'); + break; + default: + this._focusByCssSelector(this._config.autoFocus!); + break; } } diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 687e31d70ac5..e7d0340af180 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -984,7 +984,8 @@ describe('MatDialog', () => { expect(overlayContainerElement.querySelector('mat-dialog-container')).toBeFalsy(); })); - it('should recapture focus when clicking on the backdrop', fakeAsync(() => { + it('should recapture focus to the first tabbable element when clicking on the backdrop with ' + + 'autoFocus set to "first-tabbable" (the default)', fakeAsync(() => { dialog.open(PizzaMsg, { disableClose: true, viewContainerRef: testViewContainerRef @@ -1007,11 +1008,11 @@ describe('MatDialog', () => { })); it('should recapture focus to the container when clicking on the backdrop with ' + - 'autoFocus disabled', fakeAsync(() => { + 'autoFocus set to "dialog"', fakeAsync(() => { dialog.open(PizzaMsg, { disableClose: true, viewContainerRef: testViewContainerRef, - autoFocus: false + autoFocus: 'dialog' }); viewContainerFixture.detectChanges(); @@ -1033,6 +1034,61 @@ describe('MatDialog', () => { .toBe(container, 'Expected container to stay focused after click'); })); + it('should recapture focus to the first header when clicking on the backdrop with ' + + 'autoFocus set to "first-heading"', fakeAsync(() => { + dialog.open(ContentElementDialog, { + disableClose: true, + viewContainerRef: testViewContainerRef, + autoFocus: 'first-heading' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let firstHeader = overlayContainerElement + .querySelector('.mat-dialog-title[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to be focused on open'); + + firstHeader.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to stay focused after click'); + })); + + it('should recapture focus to the first element that matches the css selector when ' + + 'clicking on the backdrop with autoFocus set to a css selector', fakeAsync(() => { + dialog.open(ContentElementDialog, { + disableClose: true, + viewContainerRef: testViewContainerRef, + autoFocus: 'button' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let backdrop = + overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; + let firstButton = + overlayContainerElement.querySelector('[mat-dialog-close]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstButton, 'Expected first button to be focused on open'); + + firstButton.blur(); // Programmatic clicks might not move focus so we simulate it. + backdrop.click(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement) + .toBe(firstButton, 'Expected first button to stay focused after click'); + })); }); describe('hasBackdrop option', () => { @@ -1101,7 +1157,8 @@ describe('MatDialog', () => { beforeEach(() => document.body.appendChild(overlayContainerElement)); afterEach(() => document.body.removeChild(overlayContainerElement)); - it('should focus the first tabbable element of the dialog on open', fakeAsync(() => { + it('should focus the first tabbable element of the dialog on open (the default)', + fakeAsync(() => { dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef }); @@ -1113,16 +1170,52 @@ describe('MatDialog', () => { .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); })); - it('should allow disabling focus of the first tabbable element', fakeAsync(() => { + it('should focus the dialog element on open', fakeAsync(() => { dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef, - autoFocus: false + autoFocus: 'dialog' }); viewContainerFixture.detectChanges(); flushMicrotasks(); - expect(document.activeElement!.tagName).not.toBe('INPUT'); + let container = + overlayContainerElement.querySelector('.mat-dialog-container') as HTMLInputElement; + + expect(document.activeElement).toBe(container, 'Expected container to be focused on open'); + })); + + it('should focus the first header element on open', fakeAsync(() => { + dialog.open(ContentElementDialog, { + viewContainerRef: testViewContainerRef, + autoFocus: 'first-heading' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstHeader = + overlayContainerElement.querySelector('h1[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstHeader, 'Expected first header to be focused on open'); + })); + + it('should focus the first element that matches the css selector from autoFocus on open', + fakeAsync(() => { + dialog.open(PizzaMsg, { + viewContainerRef: testViewContainerRef, + autoFocus: 'p' + }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + let firstParagraph = + overlayContainerElement.querySelector('p[tabindex="-1"]') as HTMLInputElement; + + expect(document.activeElement) + .toBe(firstParagraph, 'Expected first paragraph to be focused on open'); })); it('should attach the focus trap even if automatic focus is disabled', fakeAsync(() => { diff --git a/src/material/sidenav/drawer.spec.ts b/src/material/sidenav/drawer.spec.ts index 19047dde91da..43bee0cd0597 100644 --- a/src/material/sidenav/drawer.spec.ts +++ b/src/material/sidenav/drawer.spec.ts @@ -580,8 +580,9 @@ describe('MatDrawer', () => { expect(document.activeElement).toBe(lastFocusableElement); })); - it('should auto-focus when opened in "side" mode when enabled explicitly', fakeAsync(() => { - drawer.autoFocus = true; + it('should auto-focus to first tabbable element when opened in "side" mode' + + 'when enabled explicitly', fakeAsync(() => { + drawer.autoFocus = 'first-tabbable'; testComponent.mode = 'side'; fixture.detectChanges(); lastFocusableElement.focus(); @@ -608,7 +609,7 @@ describe('MatDrawer', () => { })); it('should be able to disable auto focus', fakeAsync(() => { - drawer.autoFocus = false; + drawer.autoFocus = 'dialog'; testComponent.mode = 'push'; fixture.detectChanges(); lastFocusableElement.focus(); diff --git a/src/material/sidenav/drawer.ts b/src/material/sidenav/drawer.ts index 17804b88a89d..4eb3b4674480 100644 --- a/src/material/sidenav/drawer.ts +++ b/src/material/sidenav/drawer.ts @@ -6,7 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ import {AnimationEvent} from '@angular/animations'; -import {FocusMonitor, FocusOrigin, FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; +import { + FocusMonitor, + FocusOrigin, + FocusTrap, + FocusTrapFactory, + InteractivityChecker +} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes'; @@ -61,6 +67,8 @@ export function throwMatDuplicatedDrawerError(position: string) { throw Error(`A drawer was already declared for 'position="${position}"'`); } +/** Options for where to set focus to automatically on dialog open */ +export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; /** Result of the toggle promise that indicates the state of the drawer. */ export type MatDrawerToggleResult = 'open' | 'close'; @@ -178,18 +186,27 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr * Whether the drawer should focus the first focusable element automatically when opened. * Defaults to false in when `mode` is set to `side`, otherwise defaults to `true`. If explicitly * enabled, focus will be moved into the sidenav in `side` mode as well. + * @breaking-change 14.0.0 Remove boolean option from autoFocus. Use string or AutoFocusTarget + * instead. */ @Input() - get autoFocus(): boolean { + get autoFocus(): AutoFocusTarget | string | boolean { const value = this._autoFocus; - // Note that usually we disable auto focusing in `side` mode, because we don't know how the - // sidenav is being used, but in some cases it still makes sense to do it. If the consumer - // explicitly enabled `autoFocus`, we take it as them always wanting to enable it. - return value == null ? this.mode !== 'side' : value; + // Note that usually we don't allow autoFocus to be set to `first-tabbable` in `side` mode, + // because we don't know how the sidenav is being used, but in some cases it still makes + // sense to do it. The consumer can explicitly set `autoFocus`. + if (value == null) { + if (this.mode === 'side') { + return 'dialog'; + } else { + return 'first-tabbable'; + } + } + return value; } - set autoFocus(value: boolean) { this._autoFocus = coerceBooleanProperty(value); } - private _autoFocus: boolean | undefined; + set autoFocus(value: AutoFocusTarget | string | boolean) { this._autoFocus = value; } + private _autoFocus: AutoFocusTarget | string | boolean | undefined; /** * Whether the drawer is opened. We overload this because we trigger an event when it @@ -262,6 +279,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr private _focusMonitor: FocusMonitor, private _platform: Platform, private _ngZone: NgZone, + private readonly _interactivityChecker: InteractivityChecker, @Optional() @Inject(DOCUMENT) private _doc: any, @Optional() @Inject(MAT_DRAWER_CONTAINER) public _container?: MatDrawerContainer) { @@ -309,22 +327,66 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr }); } + /** + * Focuses the provided element. If the element is not focusable, it will add a tabIndex + * attribute to forcefully focus it. The attribute is removed after focus is moved. + * @param element The element to focus. + */ + private _forceFocus(element: HTMLElement, options?: FocusOptions) { + if (!this._interactivityChecker.isFocusable(element)) { + element.tabIndex = -1; + // The tabindex attribute should be removed to avoid navigating to that element again + this._ngZone.runOutsideAngular(() => + element.addEventListener('blur', () => element.removeAttribute('tabindex'))); + } + element.focus(options); + } + + /** + * Focuses the first element that matches the given selector within the focus trap. + * @param selector The CSS selector for the element to set focus to. + */ + private _focusByCssSelector(selector: string, options?: FocusOptions) { + let elementToFocus = + this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null; + if (elementToFocus) { + this._forceFocus(elementToFocus, options); + } + } + /** * Moves focus into the drawer. Note that this works even if * the focus trap is disabled in `side` mode. */ private _takeFocus() { - if (!this.autoFocus || !this._focusTrap) { + if (!this._focusTrap) { return; } - this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => { - // If there were no focusable elements, focus the sidenav itself so the keyboard navigation - // still works. We need to check that `focus` is a function due to Universal. - if (!hasMovedFocus && typeof this._elementRef.nativeElement.focus === 'function') { - this._elementRef.nativeElement.focus(); - } - }); + const element = this._elementRef.nativeElement; + + // When autoFocus is not on the sidenav, if the element cannot be focused or does + // not exist, focus the sidenav itself so the keyboard navigation still works. + // We need to check that `focus` is a function due to Universal. + switch (this.autoFocus) { + case false: + case 'dialog': + return; + case true: + case 'first-tabbable': + this._focusTrap.focusInitialElementWhenReady().then(hasMovedFocus => { + if (!hasMovedFocus && typeof this._elementRef.nativeElement.focus === 'function') { + element.focus(); + } + }); + break; + case 'first-heading': + this._focusByCssSelector('h1, h2, h3, h4, h5, h6'); + break; + default: + this._focusByCssSelector(this.autoFocus!); + break; + } } /** @@ -332,7 +394,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr * If no element was focused at that time, the focus will be restored to the drawer. */ private _restoreFocus() { - if (!this.autoFocus) { + if (this.autoFocus === 'dialog') { return; } @@ -477,7 +539,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr } static ngAcceptInputType_disableClose: BooleanInput; - static ngAcceptInputType_autoFocus: BooleanInput; + static ngAcceptInputType_autoFocus: AutoFocusTarget | string | BooleanInput; static ngAcceptInputType_opened: BooleanInput; } diff --git a/tools/public_api_guard/material/bottom-sheet.d.ts b/tools/public_api_guard/material/bottom-sheet.d.ts index 8c72c921a4b5..bfad671bd031 100644 --- a/tools/public_api_guard/material/bottom-sheet.d.ts +++ b/tools/public_api_guard/material/bottom-sheet.d.ts @@ -1,3 +1,5 @@ +export declare type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + export declare const MAT_BOTTOM_SHEET_DATA: InjectionToken; export declare const MAT_BOTTOM_SHEET_DEFAULT_OPTIONS: InjectionToken>; @@ -20,7 +22,7 @@ export declare const matBottomSheetAnimations: { export declare class MatBottomSheetConfig { ariaLabel?: string | null; - autoFocus?: boolean; + autoFocus?: AutoFocusTarget | string | boolean; backdropClass?: string; closeOnNavigation?: boolean; data?: D | null; @@ -39,7 +41,7 @@ export declare class MatBottomSheetContainer extends BasePortalOutlet implements _portalOutlet: CdkPortalOutlet; attachDomPortal: (portal: DomPortal) => void; bottomSheetConfig: MatBottomSheetConfig; - constructor(_elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, _focusTrapFactory: FocusTrapFactory, breakpointObserver: BreakpointObserver, document: any, + constructor(_elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, _focusTrapFactory: FocusTrapFactory, _interactivityChecker: InteractivityChecker, _ngZone: NgZone, breakpointObserver: BreakpointObserver, document: any, bottomSheetConfig: MatBottomSheetConfig); _onAnimationDone(event: AnimationEvent): void; _onAnimationStart(event: AnimationEvent): void; @@ -49,7 +51,7 @@ export declare class MatBottomSheetContainer extends BasePortalOutlet implements exit(): void; ngOnDestroy(): void; static ɵcmp: i0.ɵɵComponentDeclaration; - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } export declare class MatBottomSheetModule { diff --git a/tools/public_api_guard/material/dialog.d.ts b/tools/public_api_guard/material/dialog.d.ts index fef5226f83eb..d482bd0fa87b 100644 --- a/tools/public_api_guard/material/dialog.d.ts +++ b/tools/public_api_guard/material/dialog.d.ts @@ -29,7 +29,7 @@ export declare abstract class _MatDialogContainerBase extends BasePortalOutlet { _portalOutlet: CdkPortalOutlet; attachDomPortal: (portal: DomPortal) => void; constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _changeDetectorRef: ChangeDetectorRef, _document: any, - _config: MatDialogConfig, _focusMonitor?: FocusMonitor | undefined); + _config: MatDialogConfig, _interactivityChecker: InteractivityChecker, _ngZone: NgZone, _focusMonitor?: FocusMonitor | undefined); _initializeWithAttachedContent(): void; _recaptureFocus(): void; protected _restoreFocus(): void; @@ -38,9 +38,11 @@ export declare abstract class _MatDialogContainerBase extends BasePortalOutlet { attachComponentPortal(portal: ComponentPortal): ComponentRef; attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef; static ɵdir: i0.ɵɵDirectiveDeclaration<_MatDialogContainerBase, never, never, {}, {}, never>; - static ɵfac: i0.ɵɵFactoryDeclaration<_MatDialogContainerBase, [null, null, null, { optional: true; }, null, null]>; + static ɵfac: i0.ɵɵFactoryDeclaration<_MatDialogContainerBase, [null, null, null, { optional: true; }, null, null, null, null]>; } +export declare type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + export interface DialogPosition { bottom?: string; left?: string; @@ -101,7 +103,7 @@ export declare class MatDialogConfig { ariaDescribedBy?: string | null; ariaLabel?: string | null; ariaLabelledBy?: string | null; - autoFocus?: boolean; + autoFocus?: AutoFocusTarget | string | boolean; backdropClass?: string | string[]; closeOnNavigation?: boolean; componentFactoryResolver?: ComponentFactoryResolver; diff --git a/tools/public_api_guard/material/sidenav.d.ts b/tools/public_api_guard/material/sidenav.d.ts index dfa4945d4eec..539e3aa4186f 100644 --- a/tools/public_api_guard/material/sidenav.d.ts +++ b/tools/public_api_guard/material/sidenav.d.ts @@ -10,8 +10,8 @@ export declare class MatDrawer implements AfterContentInit, AfterContentChecked, _container?: MatDrawerContainer | undefined; readonly _modeChanged: Subject; readonly _openedStream: Observable; - get autoFocus(): boolean; - set autoFocus(value: boolean); + get autoFocus(): AutoFocusTarget | string | boolean; + set autoFocus(value: AutoFocusTarget | string | boolean); readonly closedStart: Observable; get disableClose(): boolean; set disableClose(value: boolean); @@ -24,7 +24,7 @@ export declare class MatDrawer implements AfterContentInit, AfterContentChecked, readonly openedStart: Observable; get position(): 'start' | 'end'; set position(value: 'start' | 'end'); - constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _focusMonitor: FocusMonitor, _platform: Platform, _ngZone: NgZone, _doc: any, _container?: MatDrawerContainer | undefined); + constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _focusMonitor: FocusMonitor, _platform: Platform, _ngZone: NgZone, _interactivityChecker: InteractivityChecker, _doc: any, _container?: MatDrawerContainer | undefined); _animationDoneListener(event: AnimationEvent): void; _animationStartListener(event: AnimationEvent): void; _closeViaBackdropClick(): Promise; @@ -35,11 +35,11 @@ export declare class MatDrawer implements AfterContentInit, AfterContentChecked, ngOnDestroy(): void; open(openedVia?: FocusOrigin): Promise; toggle(isOpen?: boolean, openedVia?: FocusOrigin): Promise; - static ngAcceptInputType_autoFocus: BooleanInput; + static ngAcceptInputType_autoFocus: AutoFocusTarget | string | BooleanInput; static ngAcceptInputType_disableClose: BooleanInput; static ngAcceptInputType_opened: BooleanInput; static ɵcmp: i0.ɵɵComponentDeclaration; - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } export declare const matDrawerAnimations: {