From a1104853ac518115078205f15ff00dbd2ed1435e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 20 Aug 2017 12:28:47 +0200 Subject: [PATCH] feat(dialog): add ariaLabel and focusOnOpen config options Based on the discussion on https://github.com/angular/material2/pull/6360#discussion_r133581835, these changes add the ability to set the `aria-label` of a dialog, as well as the element that should be focus when the dialog is opened. --- src/lib/dialog/dialog-config.ts | 9 +++++ src/lib/dialog/dialog-container.ts | 14 ++++++-- src/lib/dialog/dialog.scss | 1 + src/lib/dialog/dialog.spec.ts | 54 ++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/lib/dialog/dialog-config.ts b/src/lib/dialog/dialog-config.ts index 3fa7e657765f..04ad39c625fd 100644 --- a/src/lib/dialog/dialog-config.ts +++ b/src/lib/dialog/dialog-config.ts @@ -69,5 +69,14 @@ export class MdDialogConfig { /** ID of the element that describes the dialog. */ ariaDescribedBy?: string | null = null; + /** Aria label to assign to the dialog element */ + ariaLabel?: string | null = null; + + /** + * Selector for an element to be focused when the dialog is opened. If omitted or the + * element is not found, the dialog will focus the first focusable element. + */ + focusOnOpen?: string | null = null; + // TODO(jelbourn): add configuration for lifecycle hooks, ARIA labelling. } diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index 235f46cd7177..2e442aaacb56 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -28,6 +28,7 @@ import { } from '@angular/cdk/portal'; import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y'; import {MdDialogConfig} from './dialog-config'; +import {first} from '@angular/cdk/rxjs'; /** @@ -64,8 +65,10 @@ export function throwMdDialogContentAlreadyAttachedError() { ], host: { 'class': 'mat-dialog-container', + 'tabindex': '-1', '[attr.role]': '_config?.role', - '[attr.aria-labelledby]': '_ariaLabelledBy', + '[attr.aria-labelledby]': '_config?.ariaLabel ? null : _ariaLabelledBy', + '[attr.aria-label]': '_config?.ariaLabel', '[attr.aria-describedby]': '_config?.ariaDescribedBy || null', '[@slideDialog]': '_state', '(@slideDialog.start)': '_onAnimationStart($event)', @@ -142,7 +145,14 @@ export class MdDialogContainer extends BasePortalHost { // 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. - this._focusTrap.focusInitialElementWhenReady(); + first.call(this._ngZone.onStable).subscribe(() => { + const toFocus = this._config.focusOnOpen ? + // Start from the parent to allow for the dialog container itself to be focused as well. + this._elementRef.nativeElement.parentNode.querySelector(this._config.focusOnOpen) : + null; + + toFocus ? toFocus.focus() : this._focusTrap.focusInitialElement(); + }); } /** Restores focus to the element that was focused before the dialog opened. */ diff --git a/src/lib/dialog/dialog.scss b/src/lib/dialog/dialog.scss index dd6d21c965a0..ab09312d4aeb 100644 --- a/src/lib/dialog/dialog.scss +++ b/src/lib/dialog/dialog.scss @@ -17,6 +17,7 @@ $mat-dialog-button-margin: 8px !default; box-sizing: border-box; overflow: auto; max-width: $mat-dialog-max-width; + outline: 0; // The dialog container should completely fill its parent overlay element. width: 100%; diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 398fd269caa1..181f7bcf6d91 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -618,6 +618,34 @@ describe('MdDialog', () => { .toBe('INPUT', 'Expected first tabbable element (input) in the dialog to be focused.'); })); + it('should be able to specify what element should be focused on open', fakeAsync(() => { + dialog.open(PizzaMsg, { focusOnOpen: 'button' }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement.tagName).toBe('BUTTON'); + })); + + it('should fall back to the first focusable element, if the override is not found', + fakeAsync(() => { + dialog.open(PizzaMsg, { focusOnOpen: 'unicorn' }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement.tagName).toBe('INPUT'); + })); + + it('should be able to focus the dialog container', fakeAsync(() => { + dialog.open(PizzaMsg, { focusOnOpen: '[role="dialog"]' }); + + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement.tagName).toBe('MD-DIALOG-CONTAINER'); + })); + it('should re-focus trigger element when dialog closes', fakeAsync(() => { // Create a element that has focus before the dialog is opened. let button = document.createElement('button'); @@ -749,6 +777,32 @@ describe('MdDialog', () => { })); }); + + describe('aria-label', () => { + it('should be able to set a custom aria-label', () => { + dialog.open(PizzaMsg, { + ariaLabel: 'Hello there', + viewContainerRef: testViewContainerRef + }); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('md-dialog-container')!; + expect(container.getAttribute('aria-label')).toBe('Hello there'); + }); + + it('should not set the aria-labelledby automatically if it has an aria-label', fakeAsync(() => { + dialog.open(ContentElementDialog, { + ariaLabel: 'Hello there', + viewContainerRef: testViewContainerRef + }); + viewContainerFixture.detectChanges(); + tick(); + viewContainerFixture.detectChanges(); + + const container = overlayContainerElement.querySelector('md-dialog-container')!; + expect(container.hasAttribute('aria-labelledby')).toBe(false); + })); + }); }); describe('MdDialog with a parent MdDialog', () => {