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..657413dfdcb8 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] @@ -995,6 +1040,7 @@ class ShadowDomComponent {} const TEST_DIRECTIVES = [ ComponentWithChildViewContainer, ComponentWithTemplateRef, + ContentElementDialog, PizzaMsg, TacoMsg, DirectiveWithViewContainer, @@ -1009,6 +1055,7 @@ const TEST_DIRECTIVES = [ entryComponents: [ ComponentWithChildViewContainer, ComponentWithTemplateRef, + ContentElementDialog, PizzaMsg, TacoMsg, BottomSheetWithInjectedData, 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: {