From c4fb24b0b2a0aba15c9d21a4633fb230de4e242f Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Thu, 16 May 2019 14:23:26 -0400 Subject: [PATCH] feat(dialog): Add Adapter#getInitialFocusEl. (#4719) Add Adapter#getInitialFocusEl API. initialFocusEl will be the element passed in to Adapter#trapFocus. This formalizes the a11y-aligned idea of adding focus to an initial element (in #trapFocus) into the API. BREAKING CHANGE: Dialog Adapter#getInitialFocusEl has been added and Adapter#trapFocus first argument is now the initialFocusEl. --- packages/mdc-dialog/README.md | 18 ++++++++++++------ packages/mdc-dialog/adapter.ts | 4 +++- packages/mdc-dialog/component.ts | 13 ++++++++----- packages/mdc-dialog/constants.ts | 3 ++- packages/mdc-dialog/foundation.ts | 3 ++- .../classes/baseline-confirmation.html | 2 +- test/unit/mdc-dialog/foundation.test.js | 6 ++++-- test/unit/mdc-dialog/mdc-dialog.test.js | 6 +++--- 8 files changed, 35 insertions(+), 20 deletions(-) diff --git a/packages/mdc-dialog/README.md b/packages/mdc-dialog/README.md index fb8d7f74f28..cdf8145cc8f 100644 --- a/packages/mdc-dialog/README.md +++ b/packages/mdc-dialog/README.md @@ -251,16 +251,15 @@ MDC Dialog supports indicating that one of its action buttons represents the def Enter key. This can be used e.g. for single-choice Confirmation Dialogs to accelerate the process of making a selection, avoiding the need to tab through to the appropriate button to confirm the choice. -To indicate that a button represents the default action, add the `mdc-dialog__button--default` modifier class. +To indicate that a button represents the default action, add the `data-mdc-dialog-button-default` data attribute. For example: - ```html ... @@ -336,6 +335,12 @@ Mixin | Description > *NOTE*: The `max-width` and `max-height` mixins only apply their maximum when the viewport is large enough to accommodate the specified value when accounting for the specified margin on either side. When the viewport is smaller, the dialog is sized such that the given margin is retained around the edges. +## Other Customizations +Data Attributes | Description +--- | --- +`data-mdc-dialog-button-default` | Optional. Add to a button to indicate that it is the default action button (see Default Action Button section above). +`data-mdc-dialog-initial-focus` | Optional. Add to an element to indicate that it is the element to initially focus on after the dialog has opened. + ## `MDCDialog` Properties and Methods Property | Value Type | Description @@ -373,13 +378,14 @@ Method Signature | Description `hasClass(className: string) => boolean` | Returns whether the given class exists on the root element. `addBodyClass(className: string) => void` | Adds a class to the ``. `removeBodyClass(className: string) => void` | Removes a class from the ``. -`eventTargetMatches(target: EventTarget \| null, selector: string) => void` | Returns `true` if the target element matches the given CSS selector, otherwise `false`. -`trapFocus() => void` | Sets up the DOM such that keyboard navigation is restricted to focusable elements within the dialog surface (see [Handling Focus Trapping](#handling-focus-trapping) below for more details). +`eventTargetMatches(target: EventTarget | null, selector: string) => void` | Returns `true` if the target element matches the given CSS selector, otherwise `false`. +`trapFocus(initialFocusEl: HTMLElement|null) => void` | Sets up the DOM such that keyboard navigation is restricted to focusable elements within the dialog surface (see [Handling Focus Trapping](#handling-focus-trapping) below for more details). Moves focus to `initialFocusEl`, if set. `releaseFocus() => void` | Removes any effects of focus trapping on the dialog surface (see [Handling Focus Trapping](#handling-focus-trapping) below for more details). +`getInitialFocusEl() => HTMLElement|null` | Returns the `data-mdc-dialog-initial-focus` element to add focus to after the dialog has opened. `isContentScrollable() => boolean` | Returns `true` if `mdc-dialog__content` can be scrolled by the user, otherwise `false`. `areButtonsStacked() => boolean` | Returns `true` if `mdc-dialog__action` buttons (`mdc-dialog__button`) are stacked vertically, otherwise `false` if they are side-by-side. `getActionFromEvent(event: Event) => string \| null` | Retrieves the value of the `data-mdc-dialog-action` attribute from the given event's target, or an ancestor of the target. -`clickDefaultButton() => void` | Invokes `click()` on the `mdc-dialog__button--default` element, if one exists in the dialog. +`clickDefaultButton() => void` | Invokes `click()` on the `data-mdc-dialog-button-default` element, if one exists in the dialog. `reverseButtons() => void` | Reverses the order of action buttons in the `mdc-dialog__actions` element. Used when switching between stacked and unstacked button layouts. `notifyOpening() => void` | Broadcasts an event denoting that the dialog has just started to open. `notifyOpened() => void` | Broadcasts an event denoting that the dialog has finished opening. diff --git a/packages/mdc-dialog/adapter.ts b/packages/mdc-dialog/adapter.ts index 880d2f5bbcf..ff54538bcc5 100644 --- a/packages/mdc-dialog/adapter.ts +++ b/packages/mdc-dialog/adapter.ts @@ -40,8 +40,10 @@ export interface MDCDialogAdapter { areButtonsStacked(): boolean; getActionFromEvent(evt: Event): string | null; - trapFocus(): void; + trapFocus(focusElement: HTMLElement|null): void; releaseFocus(): void; + // Element to focus on after dialog has opened. + getInitialFocusEl(): HTMLElement|null; clickDefaultButton(): void; reverseButtons(): void; diff --git a/packages/mdc-dialog/component.ts b/packages/mdc-dialog/component.ts index ff90dab50cc..1c3c77a255f 100644 --- a/packages/mdc-dialog/component.ts +++ b/packages/mdc-dialog/component.ts @@ -72,7 +72,6 @@ export class MDCDialog extends MDCComponent { private container_!: HTMLElement; // assigned in initialize() private content_!: HTMLElement | null; // assigned in initialize() private defaultButton_!: HTMLElement | null; // assigned in initialize() - private initialFocusEl_?: HTMLElement; // assigned in initialize() private focusTrap_!: FocusTrap; // assigned in initialSyncWithDOM() private focusTrapFactory_?: MDCDialogFocusTrapFactory; // assigned in initialize() @@ -86,7 +85,6 @@ export class MDCDialog extends MDCComponent { initialize( focusTrapFactory?: MDCDialogFocusTrapFactory, - initialFocusEl?: HTMLElement, ) { const container = this.root_.querySelector(strings.CONTAINER_SELECTOR); if (!container) { @@ -95,9 +93,8 @@ export class MDCDialog extends MDCComponent { this.container_ = container; this.content_ = this.root_.querySelector(strings.CONTENT_SELECTOR); this.buttons_ = [].slice.call(this.root_.querySelectorAll(strings.BUTTON_SELECTOR)); - this.defaultButton_ = this.root_.querySelector(strings.DEFAULT_BUTTON_SELECTOR); + this.defaultButton_ = this.root_.querySelector(`[${strings.BUTTON_DEFAULT_ATTRIBUTE}]`); this.focusTrapFactory_ = focusTrapFactory; - this.initialFocusEl_ = initialFocusEl; this.buttonRipples_ = []; for (const buttonEl of this.buttons_) { @@ -106,7 +103,8 @@ export class MDCDialog extends MDCComponent { } initialSyncWithDOM() { - this.focusTrap_ = util.createFocusTrapInstance(this.container_, this.focusTrapFactory_, this.initialFocusEl_); + this.focusTrap_ = util.createFocusTrapInstance( + this.container_, this.focusTrapFactory_, this.getInitialFocusEl_() || undefined); this.handleClick_ = this.foundation_.handleClick.bind(this.foundation_); this.handleKeydown_ = this.foundation_.handleKeydown.bind(this.foundation_); @@ -168,6 +166,7 @@ export class MDCDialog extends MDCComponent { const element = closest(evt.target as Element, `[${strings.ACTION_ATTRIBUTE}]`); return element && element.getAttribute(strings.ACTION_ATTRIBUTE); }, + getInitialFocusEl: () => this.getInitialFocusEl_(), hasClass: (className) => this.root_.classList.contains(className), isContentScrollable: () => util.isScrollable(this.content_), notifyClosed: (action) => this.emit(strings.CLOSED_EVENT, action ? {action} : {}), @@ -187,4 +186,8 @@ export class MDCDialog extends MDCComponent { }; return new MDCDialogFoundation(adapter); } + + private getInitialFocusEl_(): HTMLElement|null { + return document.querySelector(`[${strings.INITIAL_FOCUS_ATTRIBUTE}]`); + } } diff --git a/packages/mdc-dialog/constants.ts b/packages/mdc-dialog/constants.ts index 122a7073424..121215208ac 100644 --- a/packages/mdc-dialog/constants.ts +++ b/packages/mdc-dialog/constants.ts @@ -32,14 +32,15 @@ export const cssClasses = { export const strings = { ACTION_ATTRIBUTE: 'data-mdc-dialog-action', + BUTTON_DEFAULT_ATTRIBUTE: 'data-mdc-dialog-button-default', BUTTON_SELECTOR: '.mdc-dialog__button', CLOSED_EVENT: 'MDCDialog:closed', CLOSE_ACTION: 'close', CLOSING_EVENT: 'MDCDialog:closing', CONTAINER_SELECTOR: '.mdc-dialog__container', CONTENT_SELECTOR: '.mdc-dialog__content', - DEFAULT_BUTTON_SELECTOR: '.mdc-dialog__button--default', DESTROY_ACTION: 'destroy', + INITIAL_FOCUS_ATTRIBUTE: 'data-mdc-dialog-initial-focus', OPENED_EVENT: 'MDCDialog:opened', OPENING_EVENT: 'MDCDialog:opening', SCRIM_SELECTOR: '.mdc-dialog__scrim', diff --git a/packages/mdc-dialog/foundation.ts b/packages/mdc-dialog/foundation.ts index e6eeffe41f7..3f67529c44e 100644 --- a/packages/mdc-dialog/foundation.ts +++ b/packages/mdc-dialog/foundation.ts @@ -46,6 +46,7 @@ export class MDCDialogFoundation extends MDCFoundation { clickDefaultButton: () => undefined, eventTargetMatches: () => false, getActionFromEvent: () => '', + getInitialFocusEl: () => null, hasClass: () => false, isContentScrollable: () => false, notifyClosed: () => undefined, @@ -109,7 +110,7 @@ export class MDCDialogFoundation extends MDCFoundation { this.animationTimer_ = setTimeout(() => { this.handleAnimationTimerEnd_(); - this.adapter_.trapFocus(); + this.adapter_.trapFocus(this.adapter_.getInitialFocusEl()); this.adapter_.notifyOpened(); }, numbers.DIALOG_ANIMATION_OPEN_TIME_MS); }); diff --git a/test/screenshot/spec/mdc-dialog/classes/baseline-confirmation.html b/test/screenshot/spec/mdc-dialog/classes/baseline-confirmation.html index 4ab907ced6a..05badd41138 100644 --- a/test/screenshot/spec/mdc-dialog/classes/baseline-confirmation.html +++ b/test/screenshot/spec/mdc-dialog/classes/baseline-confirmation.html @@ -98,7 +98,7 @@

Confirm - diff --git a/test/unit/mdc-dialog/foundation.test.js b/test/unit/mdc-dialog/foundation.test.js index a38c12d638f..7772f1b1a03 100644 --- a/test/unit/mdc-dialog/foundation.test.js +++ b/test/unit/mdc-dialog/foundation.test.js @@ -54,7 +54,7 @@ test('default adapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCDialogFoundation, [ 'addClass', 'removeClass', 'hasClass', 'addBodyClass', 'removeBodyClass', 'eventTargetMatches', - 'trapFocus', 'releaseFocus', + 'trapFocus', 'releaseFocus', 'getInitialFocusEl', 'isContentScrollable', 'areButtonsStacked', 'getActionFromEvent', 'clickDefaultButton', 'reverseButtons', 'notifyOpening', 'notifyOpened', 'notifyClosing', 'notifyClosed', ]); @@ -184,6 +184,8 @@ test('#open activates focus trapping on the dialog surface', () => { const {foundation, mockAdapter} = setupTest(); const clock = installClock(); + const button = document.createElement('button'); + td.when(mockAdapter.getInitialFocusEl()).thenReturn(button); foundation.open(); // Wait for application of opening class and setting of additional timeout prior to full open animation timeout @@ -191,7 +193,7 @@ test('#open activates focus trapping on the dialog surface', () => { clock.tick(100); clock.tick(numbers.DIALOG_ANIMATION_OPEN_TIME_MS); - td.verify(mockAdapter.trapFocus()); + td.verify(mockAdapter.trapFocus(button)); }); test('#close deactivates focus trapping on the dialog surface', () => { diff --git a/test/unit/mdc-dialog/mdc-dialog.test.js b/test/unit/mdc-dialog/mdc-dialog.test.js index 47a0287f883..1a75fc8ffa0 100644 --- a/test/unit/mdc-dialog/mdc-dialog.test.js +++ b/test/unit/mdc-dialog/mdc-dialog.test.js @@ -473,10 +473,10 @@ test('adapter#getActionFromEvent returns null when attribute is not present', () assert.isNull(action); }); -test(`adapter#clickDefaultButton invokes click() on button matching ${strings.DEFAULT_BUTTON_SELECTOR}`, () => { +test(`adapter#clickDefaultButton invokes click() on button matching ${strings.BUTTON_DEFAULT_ATTRIBUTE}`, () => { const fixture = getFixture(); const yesButton = fixture.querySelector('[data-mdc-dialog-action="yes"]'); - yesButton.classList.add(strings.DEFAULT_BUTTON_SELECTOR.slice(1)); + yesButton.setAttribute(strings.BUTTON_DEFAULT_ATTRIBUTE, 'true'); const {component} = setupTest(fixture); yesButton.click = td.func('click'); @@ -485,7 +485,7 @@ test(`adapter#clickDefaultButton invokes click() on button matching ${strings.DE td.verify(yesButton.click()); }); -test(`adapter#clickDefaultButton does nothing if nothing matches ${strings.DEFAULT_BUTTON_SELECTOR}`, () => { +test(`adapter#clickDefaultButton does nothing if nothing matches ${strings.BUTTON_DEFAULT_ATTRIBUTE}`, () => { const {component, yesButton, noButton} = setupTest(); yesButton.click = td.func('click'); noButton.click = td.func('click');