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(dialog): Add Foundation#setInitialFocusEl API. #4719

Merged
merged 8 commits into from
May 16, 2019
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
joyzhong marked this conversation as resolved.
Show resolved Hide resolved
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-element-focus` | Optional. Add to an element to indicate that it is the element to initially focus on after the dialog has opened. This is the element that should be passed in to `Adapter#getInitialFocusEl`.
joyzhong marked this conversation as resolved.
Show resolved Hide resolved

## `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-element-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.DEFAULT_BUTTON_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_EL_ATTRIBUTE}]`);
}
}
3 changes: 2 additions & 1 deletion packages/mdc-dialog/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ export const strings = {
CLOSING_EVENT: 'MDCDialog:closing',
CONTAINER_SELECTOR: '.mdc-dialog__container',
CONTENT_SELECTOR: '.mdc-dialog__content',
DEFAULT_BUTTON_SELECTOR: '.mdc-dialog__button--default',
DEFAULT_BUTTON_ATTRIBUTE: 'data-mdc-dialog-button-default',
joyzhong marked this conversation as resolved.
Show resolved Hide resolved
DESTROY_ACTION: 'destroy',
INITIAL_FOCUS_EL_ATTRIBUTE: 'data-mdc-dialog-element-focus',
joyzhong marked this conversation as resolved.
Show resolved Hide resolved
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());
joyzhong marked this conversation as resolved.
Show resolved Hide resolved
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.DEFAULT_BUTTON_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.DEFAULT_BUTTON_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.DEFAULT_BUTTON_ATTRIBUTE}`, () => {
const {component, yesButton, noButton} = setupTest();
yesButton.click = td.func('click');
noButton.click = td.func('click');
Expand Down