From 2e7b61baa1426d05cd6dbe26ef07eb10c0c1ab93 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 | 91 +++++--- 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 | 80 +++++-- .../bottom-sheet/bottom-sheet.spec.ts | 77 +++++-- src/material/dialog/dialog-config.ts | 11 +- src/material/dialog/dialog-container.ts | 87 ++++++-- src/material/dialog/dialog.spec.ts | 107 +++++++++- src/material/sidenav/drawer.spec.ts | 7 +- src/material/sidenav/drawer.ts | 105 +++++++-- .../public_api_guard/material/bottom-sheet.md | 11 +- tools/public_api_guard/material/dialog.md | 11 +- tools/public_api_guard/material/sidenav.md | 14 +- 18 files changed, 732 insertions(+), 167 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 a41e5b130b8b..4f9b5dd6ba63 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -7,7 +7,7 @@ */ 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, @@ -26,6 +26,7 @@ import { EmbeddedViewRef, HostBinding, Inject, + NgZone, OnDestroy, Optional, ViewChild, @@ -123,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) { @@ -138,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(); } @@ -242,34 +245,74 @@ 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.addEventListener('mousedown', () => 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 = _getFocusedElementPierceShadowDom(); - - // 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, [role="heading"]'); + break; + default: + this._focusByCssSelector(this._config.autoFocus!); + break; } } @@ -278,7 +321,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 ea50b0d591b5..1dbdd33187cd 100644 --- a/src/cdk-experimental/dialog/dialog.spec.ts +++ b/src/cdk-experimental/dialog/dialog.spec.ts @@ -882,7 +882,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 }); @@ -894,16 +895,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 abda03401073..7b8123bbffb9 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 + ); } override _initializeWithAttachedContent() { diff --git a/src/material-experimental/mdc-dialog/dialog.spec.ts b/src/material-experimental/mdc-dialog/dialog.spec.ts index 1913ce109313..089153936790 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 9314af80d667..24c66c7209c0 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,39 @@ 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.addEventListener('mousedown', () => 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 +240,34 @@ 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(); + break; + case 'first-heading': + this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]'); + 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 9bbeac037384..1ff5c44823ec 100644 --- a/src/material/bottom-sheet/bottom-sheet.spec.ts +++ b/src/material/bottom-sheet/bottom-sheet.spec.ts @@ -610,7 +610,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(); @@ -622,29 +622,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(() => { @@ -867,7 +904,7 @@ describe('MatBottomSheet with default options', () => { const defaultConfig: MatBottomSheetConfig = { hasBackdrop: false, disableClose: true, - autoFocus: false + autoFocus: 'dialog' }; TestBed.configureTestingModule({ @@ -973,6 +1010,14 @@ class PizzaMsg { @Component({template: '

Taco

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

This is the title

+

This is the paragraph

+ ` +}) +class ContentElementDialog {} + @Component({ template: '', providers: [MatBottomSheet] @@ -997,6 +1042,7 @@ class ShadowDomComponent {} const TEST_DIRECTIVES = [ ComponentWithChildViewContainer, ComponentWithTemplateRef, + ContentElementDialog, PizzaMsg, TacoMsg, DirectiveWithViewContainer, @@ -1011,6 +1057,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 2175add9bc2a..e66bae7eec05 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,72 @@ 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.addEventListener('mousedown', () => 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(); + break; + case 'first-heading': + this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]'); + 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 e3cc09ccf9fd..9bc880dd75ca 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -998,7 +998,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 @@ -1022,11 +1023,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(); @@ -1048,6 +1049,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', () => { @@ -1116,7 +1172,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 }); @@ -1128,16 +1185,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(); + + 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(); - 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.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 6c59d0429803..0eac9cdb5306 100644 --- a/src/material/sidenav/drawer.spec.ts +++ b/src/material/sidenav/drawer.spec.ts @@ -582,8 +582,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(); @@ -610,7 +611,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..90776445316e 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,32 @@ 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: AutoFocusTarget | string | boolean) { + if (value === 'true' || value === 'false') { + value = coerceBooleanProperty(value); + } + this._autoFocus = value; } - set autoFocus(value: boolean) { this._autoFocus = coerceBooleanProperty(value); } - private _autoFocus: boolean | undefined; + private _autoFocus: AutoFocusTarget | string | boolean | undefined; /** * Whether the drawer is opened. We overload this because we trigger an event when it @@ -262,6 +284,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 +332,68 @@ 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.addEventListener('mousedown', () => 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, [role="heading"]'); + break; + default: + this._focusByCssSelector(this.autoFocus!); + break; + } } /** @@ -332,7 +401,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 +546,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.md b/tools/public_api_guard/material/bottom-sheet.md index 2d1ca2e6eae7..70a4208f75e8 100644 --- a/tools/public_api_guard/material/bottom-sheet.md +++ b/tools/public_api_guard/material/bottom-sheet.md @@ -25,6 +25,8 @@ import * as i3 from '@angular/material/core'; import * as i4 from '@angular/cdk/portal'; import { InjectionToken } from '@angular/core'; import { Injector } from '@angular/core'; +import { InteractivityChecker } from '@angular/cdk/a11y'; +import { NgZone } from '@angular/core'; import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; @@ -34,6 +36,9 @@ import { TemplatePortal } from '@angular/cdk/portal'; import { TemplateRef } from '@angular/core'; import { ViewContainerRef } from '@angular/core'; +// @public +export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + // @public export const MAT_BOTTOM_SHEET_DATA: InjectionToken; @@ -64,7 +69,7 @@ export const matBottomSheetAnimations: { // @public export class MatBottomSheetConfig { ariaLabel?: string | null; - autoFocus?: boolean; + autoFocus?: AutoFocusTarget | string | boolean; backdropClass?: string; closeOnNavigation?: boolean; data?: D | null; @@ -79,7 +84,7 @@ export class MatBottomSheetConfig { // @public export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestroy { - 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); _animationState: 'void' | 'visible' | 'hidden'; _animationStateChanged: EventEmitter; @@ -100,7 +105,7 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public (undocumented) diff --git a/tools/public_api_guard/material/dialog.md b/tools/public_api_guard/material/dialog.md index 8c40f3573061..31e78075329c 100644 --- a/tools/public_api_guard/material/dialog.md +++ b/tools/public_api_guard/material/dialog.md @@ -27,7 +27,9 @@ import * as i4 from '@angular/cdk/portal'; import * as i5 from '@angular/material/core'; import { InjectionToken } from '@angular/core'; import { Injector } from '@angular/core'; +import { InteractivityChecker } from '@angular/cdk/a11y'; import { Location as Location_2 } from '@angular/common'; +import { NgZone } from '@angular/core'; import { Observable } from 'rxjs'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; @@ -43,6 +45,9 @@ import { TemplateRef } from '@angular/core'; import { Type } from '@angular/core'; import { ViewContainerRef } from '@angular/core'; +// @public +export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + // @public export function _closeDialogVia(ref: MatDialogRef, interactionType: FocusOrigin, result?: R): void; @@ -152,7 +157,7 @@ export class MatDialogConfig { ariaDescribedBy?: string | null; ariaLabel?: string | null; ariaLabelledBy?: string | null; - autoFocus?: boolean; + autoFocus?: AutoFocusTarget | string | boolean; backdropClass?: string | string[]; closeOnNavigation?: boolean; componentFactoryResolver?: ComponentFactoryResolver; @@ -190,7 +195,7 @@ export class MatDialogContainer extends _MatDialogContainerBase { // @public export abstract class _MatDialogContainerBase extends BasePortalOutlet { constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _changeDetectorRef: ChangeDetectorRef, _document: any, - _config: MatDialogConfig, _focusMonitor?: FocusMonitor | undefined); + _config: MatDialogConfig, _interactivityChecker: InteractivityChecker, _ngZone: NgZone, _focusMonitor?: FocusMonitor | undefined); _animationStateChanged: EventEmitter; _ariaLabelledBy: string | null; attachComponentPortal(portal: ComponentPortal): ComponentRef; @@ -217,7 +222,7 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet { // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration<_MatDialogContainerBase, never, never, {}, {}, never>; // (undocumented) - 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]>; } // @public diff --git a/tools/public_api_guard/material/sidenav.md b/tools/public_api_guard/material/sidenav.md index ce42e5c6fb95..b1cf9df053e6 100644 --- a/tools/public_api_guard/material/sidenav.md +++ b/tools/public_api_guard/material/sidenav.md @@ -24,6 +24,7 @@ import * as i4 from '@angular/material/core'; import * as i5 from '@angular/cdk/platform'; import * as i6 from '@angular/cdk/scrolling'; import { InjectionToken } from '@angular/core'; +import { InteractivityChecker } from '@angular/cdk/a11y'; import { NgZone } from '@angular/core'; import { NumberInput } from '@angular/cdk/coercion'; import { Observable } from 'rxjs'; @@ -34,6 +35,9 @@ import { ScrollDispatcher } from '@angular/cdk/scrolling'; import { Subject } from 'rxjs'; import { ViewportRuler } from '@angular/cdk/scrolling'; +// @public +type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; + // @public const MAT_DRAWER_CONTAINER: InjectionToken; @@ -45,7 +49,7 @@ export function MAT_DRAWER_DEFAULT_AUTOSIZE_FACTORY(): boolean; // @public export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestroy { - 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); // (undocumented) _animationDoneListener(event: AnimationEvent_2): void; readonly _animationEnd: Subject; @@ -53,8 +57,8 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr // (undocumented) _animationStartListener(event: AnimationEvent_2): void; _animationState: 'open-instant' | 'open' | 'void'; - get autoFocus(): boolean; - set autoFocus(value: boolean); + get autoFocus(): AutoFocusTarget | string | boolean; + set autoFocus(value: AutoFocusTarget | string | boolean); close(): Promise; readonly closedStart: Observable; readonly _closedStream: Observable; @@ -69,7 +73,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr set mode(value: MatDrawerMode); readonly _modeChanged: Subject; // (undocumented) - static ngAcceptInputType_autoFocus: BooleanInput; + static ngAcceptInputType_autoFocus: AutoFocusTarget | string | BooleanInput; // (undocumented) static ngAcceptInputType_disableClose: BooleanInput; // (undocumented) @@ -93,7 +97,7 @@ export class MatDrawer implements AfterContentInit, AfterContentChecked, OnDestr // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public