From 67edd9182912d5b071cd20fafb3a7736b87dc68e Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Tue, 17 Apr 2018 19:32:51 +0200 Subject: [PATCH] fix(dialog): restore focus with the proper focus origin * Restores the trigger focus upon dialog close with the proper focus origin that caused the dialog closing. For example, a backdrop click leads to a focus restore via `mouse`. Pressing `escape` leads to a focus restore via `keyboard`. Clicking the `matDialogClose` button will depend on the type of interaction (e.g. `click` or `keyboard`) References #8420. --- src/lib/dialog/dialog-container.ts | 33 +++-- src/lib/dialog/dialog-content-directives.ts | 12 +- src/lib/dialog/dialog-ref.ts | 9 +- src/lib/dialog/dialog.spec.ts | 155 +++++++++++++++++++- src/lib/dialog/dialog.ts | 2 +- 5 files changed, 191 insertions(+), 20 deletions(-) diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index a214567a6fb5..34becadbcd36 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -28,7 +28,7 @@ import { CdkPortalOutlet, TemplatePortal } from '@angular/cdk/portal'; -import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; +import {FocusTrap, FocusMonitor, FocusOrigin, FocusTrapFactory} from '@angular/cdk/a11y'; import {MatDialogConfig} from './dialog-config'; @@ -80,6 +80,13 @@ export class MatDialogContainer extends BasePortalOutlet { /** Element that was focused before the dialog was opened. Save this to restore upon close. */ private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null; + /** + * Type of interaction that led to the dialog being closed. This is used to determine + * whether the focus style will be applied when returning focus to its original location + * after the dialog is closed. + */ + _closeInteractionType: FocusOrigin = 'program'; + /** State of the dialog animation. */ _state: 'void' | 'enter' | 'exit' = 'enter'; @@ -92,14 +99,12 @@ export class MatDialogContainer extends BasePortalOutlet { /** ID for the container DOM element. */ _id: string; - constructor( - private _elementRef: ElementRef, - private _focusTrapFactory: FocusTrapFactory, - private _changeDetectorRef: ChangeDetectorRef, - @Optional() @Inject(DOCUMENT) private _document: any, - /** The dialog configuration. */ - public _config: MatDialogConfig) { - + constructor(private _elementRef: ElementRef, + private _focusTrapFactory: FocusTrapFactory, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(DOCUMENT) private _document: any, + /** The dialog configuration. */ public _config: MatDialogConfig, + private _focusMonitor?: FocusMonitor) { super(); } @@ -145,11 +150,15 @@ export class MatDialogContainer extends BasePortalOutlet { /** Restores focus to the element that was focused before the dialog opened. */ private _restoreFocus() { - const toFocus = this._elementFocusedBeforeDialogWasOpened; + const previousElement = this._elementFocusedBeforeDialogWasOpened; // We need the extra check, because IE can set the `activeElement` to null in some cases. - if (toFocus && typeof toFocus.focus === 'function') { - toFocus.focus(); + if (previousElement && typeof previousElement.focus === 'function') { + if (this._focusMonitor) { + this._focusMonitor.focusVia(previousElement, this._closeInteractionType); + } else { + previousElement.focus(); + } } if (this._focusTrap) { diff --git a/src/lib/dialog/dialog-content-directives.ts b/src/lib/dialog/dialog-content-directives.ts index c3f5b7bb4bc4..22e20c56b2bd 100644 --- a/src/lib/dialog/dialog-content-directives.ts +++ b/src/lib/dialog/dialog-content-directives.ts @@ -28,7 +28,7 @@ let dialogElementUid = 0; selector: `button[mat-dialog-close], button[matDialogClose]`, exportAs: 'matDialogClose', host: { - '(click)': 'dialogRef.close(dialogResult)', + '(click)': '_onButtonClick($event)', '[attr.aria-label]': 'ariaLabel', 'type': 'button', // Prevents accidental form submits. } @@ -65,6 +65,16 @@ export class MatDialogClose implements OnInit, OnChanges { this.dialogResult = proxiedChange.currentValue; } } + + _onButtonClick(event: MouseEvent) { + // Determinate the focus origin using the click event, because using the FocusMonitor will + // result in incorrect origins. Most of the time, close buttons will be auto focused in the + // dialog, and therefore clicking the button won't result in a focus change. This means that + // the FocusMonitor won't detect any origin change, and will always output `program`. + const focusOrigin = event.screenX === 0 && event.screenY === 0 ? 'keyboard' : 'mouse'; + + this.dialogRef._closeVia(focusOrigin, this.dialogResult); + } } /** diff --git a/src/lib/dialog/dialog-ref.ts b/src/lib/dialog/dialog-ref.ts index 10b855e9d503..a42e9d502869 100644 --- a/src/lib/dialog/dialog-ref.ts +++ b/src/lib/dialog/dialog-ref.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {FocusOrigin} from '@angular/cdk/a11y'; import {ESCAPE} from '@angular/cdk/keycodes'; import {GlobalPositionStrategy, OverlayRef} from '@angular/cdk/overlay'; import {Location} from '@angular/common'; @@ -82,7 +83,7 @@ export class MatDialogRef { _overlayRef.keydownEvents() .pipe(filter(event => event.keyCode === ESCAPE && !this.disableClose)) - .subscribe(() => this.close()); + .subscribe(() => this._closeVia('keyboard')); if (location) { // Close the dialog when the user goes forwards/backwards in history or when the location @@ -117,6 +118,12 @@ export class MatDialogRef { this._containerInstance._startExitAnimation(); } + /** Closes the dialog with the specified interaction type. */ + _closeVia(interactionType: FocusOrigin, dialogResult?: R) { + this._containerInstance._closeInteractionType = interactionType; + this.close(dialogResult); + } + /** * Gets an observable that is notified when the dialog is finished opening. */ diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index d1edf75cccb0..6d1af4c1d5ec 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -25,8 +25,9 @@ import {SpyLocation} from '@angular/common/testing'; import {Directionality} from '@angular/cdk/bidi'; import {MatDialogContainer} from './dialog-container'; import {OverlayContainer, ScrollStrategy, ScrollDispatcher, Overlay} from '@angular/cdk/overlay'; +import {FocusOrigin, FocusMonitor} from '@angular/cdk/a11y'; import {A, ESCAPE} from '@angular/cdk/keycodes'; -import {dispatchKeyboardEvent} from '@angular/cdk/testing'; +import {dispatchKeyboardEvent, dispatchMouseEvent, patchElementFocus} from '@angular/cdk/testing'; import { MAT_DIALOG_DATA, MatDialog, @@ -42,6 +43,7 @@ describe('MatDialog', () => { let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; let scrolledSubject = new Subject(); + let focusMonitor: FocusMonitor; let testViewContainerRef: ViewContainerRef; let viewContainerFixture: ComponentFixture; @@ -61,13 +63,14 @@ describe('MatDialog', () => { TestBed.compileComponents(); })); - beforeEach(inject([MatDialog, Location, OverlayContainer], - (d: MatDialog, l: Location, oc: OverlayContainer) => { + beforeEach(inject([MatDialog, Location, OverlayContainer, FocusMonitor], + (d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => { dialog = d; mockLocation = l as SpyLocation; overlayContainer = oc; overlayContainerElement = oc.getContainerElement(); - })); + focusMonitor = fm; + })); afterEach(() => { overlayContainer.ngOnDestroy(); @@ -967,6 +970,148 @@ describe('MatDialog', () => { document.body.removeChild(button); })); + it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an element. + patchElementFocus(button); + + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + + expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred'); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('keyboard', 'Expected the trigger button to be focused via keyboard'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an element. + patchElementFocus(button); + + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + + const backdrop = overlayContainerElement + .querySelector('.cdk-overlay-backdrop') as HTMLElement; + + backdrop.click(); + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('mouse', 'Expected the trigger button to be focused via mouse'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should re-focus via keyboard if the close button has been triggered through keyboard', + fakeAsync(() => { + + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an element. + patchElementFocus(button); + + dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + + const closeButton = overlayContainerElement + .querySelector('button[mat-dialog-close]') as HTMLElement; + + // Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click` + // event with a MouseEvent, which has coordinates that are out of the element boundaries. + dispatchMouseEvent(closeButton, 'click', 0, 0); + + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('keyboard', 'Expected the trigger button to be focused via keyboard'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + + it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => { + const button = document.createElement('button'); + let lastFocusOrigin: FocusOrigin = null; + + focusMonitor.monitor(button, false) + .subscribe(focusOrigin => lastFocusOrigin = focusOrigin); + + document.body.appendChild(button); + button.focus(); + + // Patch the element focus after the initial and real focus, because otherwise the + // `activeElement` won't be set, and the dialog won't be able to restore focus to an element. + patchElementFocus(button); + + dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef}); + + tick(500); + viewContainerFixture.detectChanges(); + + const closeButton = overlayContainerElement + .querySelector('button[mat-dialog-close]') as HTMLElement; + + // The dialog close button detects the focus origin by inspecting the click event. If + // coordinates of the click are not present, it assumes that the click has been triggered + // by keyboard. + dispatchMouseEvent(closeButton, 'click', 10, 10); + + viewContainerFixture.detectChanges(); + tick(500); + + expect(lastFocusOrigin!) + .toBe('mouse', 'Expected the trigger button to be focused via mouse'); + + focusMonitor.stopMonitoring(button); + document.body.removeChild(button); + })); + it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => { // Create a element that has focus before the dialog is opened. let button = document.createElement('button'); @@ -989,7 +1134,7 @@ describe('MatDialog', () => { tick(500); viewContainerFixture.detectChanges(); - flushMicrotasks(); + flush(); expect(document.activeElement.id).toBe('input-to-be-focused', 'Expected that the trigger was refocused after the dialog is closed.'); diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts index 3467c66bb5cf..9fbaf92b7e4f 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -239,7 +239,7 @@ export class MatDialog { if (config.hasBackdrop) { overlayRef.backdropClick().subscribe(() => { if (!dialogRef.disableClose) { - dialogRef.close(); + dialogRef._closeVia('mouse'); } }); }