Skip to content

Commit

Permalink
feat(multiple): add options to autoFocus field for dialogs
Browse files Browse the repository at this point in the history
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
  • Loading branch information
amysorto authored and mmalerba committed Jul 27, 2021
1 parent 0742bab commit bce7b34
Show file tree
Hide file tree
Showing 18 changed files with 737 additions and 167 deletions.
11 changes: 9 additions & 2 deletions src/cdk-experimental/dialog/dialog-config.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -84,8 +87,12 @@ export class DialogConfig<D = any> {
/** 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';
Expand Down
91 changes: 67 additions & 24 deletions src/cdk-experimental/dialog/dialog-container.ts
Expand Up @@ -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,
Expand All @@ -26,6 +26,7 @@ import {
EmbeddedViewRef,
HostBinding,
Inject,
NgZone,
OnDestroy,
Optional,
ViewChild,
Expand Down Expand Up @@ -123,6 +124,8 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy {
private _elementRef: ElementRef<HTMLElement>,
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) {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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
Expand Down
45 changes: 41 additions & 4 deletions src/cdk-experimental/dialog/dialog.spec.ts
Expand Up @@ -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
});
Expand All @@ -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(() => {
Expand Down
3 changes: 1 addition & 2 deletions src/cdk/a11y/focus-trap/focus-trap.ts
Expand Up @@ -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.
*/
Expand Down
21 changes: 17 additions & 4 deletions src/material-experimental/mdc-dialog/dialog-container.ts
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down

0 comments on commit bce7b34

Please sign in to comment.