Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(multiple): add options to autoFocus field for dialogs #22780

Merged
merged 1 commit into from Jul 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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) {
Copy link
Collaborator

@zelliott zelliott Jun 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to move this out into an a11y helper? We have an identical function in our g3 app.

EDIT: Plus I now see there are multiple identical implementations throughout this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that these methods will exist in cdk/dialog once that's created in a future PR

Copy link
Member

@jelbourn jelbourn Jun 9, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I told Amy to do it this way since cdk/dialog is the correct place for this logic. It just has the unfortunate property of not existing yet.

if (!this._interactivityChecker.isFocusable(element)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of checking if the element is focusable, we could just call element.focus(), then check to see if element === document.activeElement. If not, then apply tabindex = "-1" and focus again. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Although it seems strange to have different functionality for the same thing. @jelbourn do you have thoughts on this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably stick with using InteractivityChecker mostly for consistency, but also because I vaguely recall that Firefox will sometimes(?) not actually focus an element until the next microtask tick.

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