Skip to content

Commit

Permalink
feat(dialog): Add Adapter#getInitialFocusEl. (#4719)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
joyzhong authored and abhiomkar committed Jun 11, 2019
1 parent ff2873e commit 1108307
Show file tree
Hide file tree
Showing 8 changed files with 35 additions and 20 deletions.
18 changes: 12 additions & 6 deletions packages/mdc-dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
...
<footer class="mdc-dialog__actions">
<button type="button" class="mdc-button mdc-dialog__button" data-mdc-dialog-action="close">
<span class="mdc-button__label">Cancel</span>
</button>
<button type="button" class="mdc-button mdc-dialog__button mdc-dialog__button--default" data-mdc-dialog-action="accept">
<button type="button" class="mdc-button mdc-dialog__button" data-mdc-dialog-action="accept" data-mdc-dialog-button-default>
<span class="mdc-button__label">OK</span>
</button>
</footer>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<body>`.
`removeBodyClass(className: string) => void` | Removes a class from the `<body>`.
`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.
Expand Down
4 changes: 3 additions & 1 deletion packages/mdc-dialog/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
13 changes: 8 additions & 5 deletions packages/mdc-dialog/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
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()
Expand All @@ -86,7 +85,6 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {

initialize(
focusTrapFactory?: MDCDialogFocusTrapFactory,
initialFocusEl?: HTMLElement,
) {
const container = this.root_.querySelector<HTMLElement>(strings.CONTAINER_SELECTOR);
if (!container) {
Expand All @@ -95,9 +93,8 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
this.container_ = container;
this.content_ = this.root_.querySelector<HTMLElement>(strings.CONTENT_SELECTOR);
this.buttons_ = [].slice.call(this.root_.querySelectorAll<HTMLElement>(strings.BUTTON_SELECTOR));
this.defaultButton_ = this.root_.querySelector<HTMLElement>(strings.DEFAULT_BUTTON_SELECTOR);
this.defaultButton_ = this.root_.querySelector<HTMLElement>(`[${strings.BUTTON_DEFAULT_ATTRIBUTE}]`);
this.focusTrapFactory_ = focusTrapFactory;
this.initialFocusEl_ = initialFocusEl;
this.buttonRipples_ = [];

for (const buttonEl of this.buttons_) {
Expand All @@ -106,7 +103,8 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
}

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_);
Expand Down Expand Up @@ -168,6 +166,7 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
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<MDCDialogCloseEventDetail>(strings.CLOSED_EVENT, action ? {action} : {}),
Expand All @@ -187,4 +186,8 @@ export class MDCDialog extends MDCComponent<MDCDialogFoundation> {
};
return new MDCDialogFoundation(adapter);
}

private getInitialFocusEl_(): HTMLElement|null {
return document.querySelector(`[${strings.INITIAL_FOCUS_ATTRIBUTE}]`);
}
}
3 changes: 2 additions & 1 deletion packages/mdc-dialog/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/mdc-dialog/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class MDCDialogFoundation extends MDCFoundation<MDCDialogAdapter> {
clickDefaultButton: () => undefined,
eventTargetMatches: () => false,
getActionFromEvent: () => '',
getInitialFocusEl: () => null,
hasClass: () => false,
isContentScrollable: () => false,
notifyClosed: () => undefined,
Expand Down Expand Up @@ -109,7 +110,7 @@ export class MDCDialogFoundation extends MDCFoundation<MDCDialogAdapter> {

this.animationTimer_ = setTimeout(() => {
this.handleAnimationTimerEnd_();
this.adapter_.trapFocus();
this.adapter_.trapFocus(this.adapter_.getInitialFocusEl());
this.adapter_.notifyOpened();
}, numbers.DIALOG_ANIMATION_OPEN_TIME_MS);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ <h2 class="mdc-dialog__title test-dialog__title" id="test-dialog__title">Confirm
<button type="button" class="mdc-button mdc-dialog__button" data-mdc-dialog-action="cancel">
<span class="mdc-button__label test-font--redact-all">Cancel</span>
</button>
<button type="button" class="mdc-button mdc-dialog__button mdc-dialog__button--default" data-mdc-dialog-action="yes">
<button type="button" class="mdc-button mdc-dialog__button" data-mdc-dialog-action="yes" data-mdc-dialog-button-default>
<span class="mdc-button__label test-font--redact-all">OK</span>
</button>
</footer>
Expand Down
6 changes: 4 additions & 2 deletions test/unit/mdc-dialog/foundation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand Down Expand Up @@ -184,14 +184,16 @@ 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
clock.runToFrame();
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', () => {
Expand Down
6 changes: 3 additions & 3 deletions test/unit/mdc-dialog/mdc-dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down

0 comments on commit 1108307

Please sign in to comment.