Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

Commit

Permalink
feat(dialog): Support default action button (#3600)
Browse files Browse the repository at this point in the history
  • Loading branch information
kfranqueiro authored Sep 21, 2018
1 parent 4da83dd commit 3aa18e2
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 25 deletions.
21 changes: 20 additions & 1 deletion packages/mdc-dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,24 @@ This will also be disabled if the `mdc-dialog--stacked` modifier class is applie
component is instantiated, but note that dialog action button labels are recommended to be short enough to fit on a
single line if possible.

#### Default Action Button

MDC Dialog supports indicating that one of its action buttons represents the default action, triggered by pressing the
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.
For example:

```html
...
<footer class="mdc-dialog__actions">
<button type="button" class="mdc-button mdc-dialog__button" data-mdc-dialog-action="close">Cancel</button>
<button type="button" class="mdc-button mdc-dialog__button mdc-dialog__button--default" data-mdc-dialog-action="accept">OK</button>
</footer>
...
```

#### Actions and Selections

Dialogs which require making a choice via selection controls should initially disable any button which performs an
Expand Down Expand Up @@ -305,13 +323,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>`.
`eventTargetHasClass(target: !EventTarget, className: string) => void` | Returns `true` if the target element has the given CSS class, otherwise `false`.
`eventTargetMatches(target: !EventTarget, selector: string) => void` | Returns `true` if the target element matches the given CSS selector, otherwise `false`.
`computeBoundingRect()`: Forces the component to recalculate its layout; in the vanilla DOM implementation, this calls `computeBoundingClientRect`.
`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).
`releaseFocus() => void` | Removes any effects of focus trapping on the dialog surface (see [Handling Focus Trapping](#handling-focus-trapping) below for more details).
`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` | 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.
`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
5 changes: 3 additions & 2 deletions packages/mdc-dialog/adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ class MDCDialogAdapter {

/**
* @param {!EventTarget} target
* @param {string} className
* @param {string} selector
* @return {boolean}
*/
eventTargetHasClass(target, className) {}
eventTargetMatches(target, selector) {}

/** @return {!ClientRect} */
computeBoundingRect() {}
Expand All @@ -83,6 +83,7 @@ class MDCDialogAdapter {
*/
getActionFromEvent(event) {}

clickDefaultButton() {}
reverseButtons() {}

notifyOpening() {}
Expand Down
7 changes: 6 additions & 1 deletion packages/mdc-dialog/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,22 @@ const cssClasses = {
OPEN: 'mdc-dialog--open',
OPENING: 'mdc-dialog--opening',
CLOSING: 'mdc-dialog--closing',
SCRIM: 'mdc-dialog__scrim',
SCROLLABLE: 'mdc-dialog--scrollable',
STACKED: 'mdc-dialog--stacked',
SCROLL_LOCK: 'mdc-dialog-scroll-lock',
};

const strings = {
SCRIM_SELECTOR: '.mdc-dialog__scrim',
CONTAINER_SELECTOR: '.mdc-dialog__container',
SURFACE_SELECTOR: '.mdc-dialog__surface',
CONTENT_SELECTOR: '.mdc-dialog__content',
BUTTON_SELECTOR: '.mdc-dialog__button',
DEFAULT_BUTTON_SELECTOR: '.mdc-dialog__button--default',
SUPPRESS_DEFAULT_PRESS_SELECTOR: [
'textarea',
'.mdc-menu .mdc-list-item',
].join(', '),

OPENING_EVENT: 'MDCDialog:opening',
OPENED_EVENT: 'MDCDialog:opened',
Expand Down
11 changes: 8 additions & 3 deletions packages/mdc-dialog/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ class MDCDialogFoundation extends MDCFoundation {
hasClass: (/* className: string */) => {},
addBodyClass: (/* className: string */) => {},
removeBodyClass: (/* className: string */) => {},
eventTargetHasClass: (/* target: !EventTarget, className: string */) => {},
eventTargetMatches: (/* target: !EventTarget, selector: string */) => {},
computeBoundingRect: () => {},
trapFocus: () => {},
releaseFocus: () => {},
isContentScrollable: () => {},
areButtonsStacked: () => {},
getActionFromEvent: (/* event: !Event */) => {},
clickDefaultButton: () => {},
reverseButtons: () => {},
notifyOpening: () => {},
notifyOpened: () => {},
Expand Down Expand Up @@ -236,14 +237,18 @@ class MDCDialogFoundation extends MDCFoundation {
*/
handleInteraction(evt) {
const isClick = evt.type === 'click';
const isEnter = evt.key === 'Enter' || evt.keyCode === 13;

// Check for scrim click first since it doesn't require querying ancestors
if (isClick && this.adapter_.eventTargetHasClass(evt.target, cssClasses.SCRIM) && this.scrimClickAction_ !== '') {
if (isClick && this.adapter_.eventTargetMatches(evt.target, strings.SCRIM_SELECTOR) &&
this.scrimClickAction_ !== '') {
this.close(this.scrimClickAction_);
} else if (isClick || evt.key === 'Space' || evt.keyCode === 32 || evt.key === 'Enter' || evt.keyCode === 13) {
} else if (isClick || evt.key === 'Space' || evt.keyCode === 32 || isEnter) {
const action = this.adapter_.getActionFromEvent(evt);
if (action) {
this.close(action);
} else if (isEnter && !this.adapter_.eventTargetMatches(evt.target, strings.SUPPRESS_DEFAULT_PRESS_SELECTOR)) {
this.adapter_.clickDefaultButton();
}
}
}
Expand Down
13 changes: 11 additions & 2 deletions packages/mdc-dialog/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {MDCRipple} from '@material/ripple/index';

import MDCDialogFoundation from './foundation';
import * as util from './util';
import {closest} from '@material/dom/ponyfill';
import {closest, matches} from '@material/dom/ponyfill';

import createFocusTrap from 'focus-trap';

Expand All @@ -45,6 +45,9 @@ class MDCDialog extends MDCComponent {
/** @private {!Array<!Element>} */
this.buttons_;

/** @private {?Element} */
this.defaultButton_;

/** @private {!Element} */
this.container_;

Expand Down Expand Up @@ -112,6 +115,7 @@ class MDCDialog extends MDCComponent {
this.container_ = /** @type {!Element} */ (this.root_.querySelector(strings.CONTAINER_SELECTOR));
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.buttonRipples_ = [];
this.focusTrapFactory_ = focusTrapFactory;
this.initialFocusEl_ = initialFocusEl;
Expand Down Expand Up @@ -177,7 +181,7 @@ class MDCDialog extends MDCComponent {
hasClass: (className) => this.root_.classList.contains(className),
addBodyClass: (className) => document.body.classList.add(className),
removeBodyClass: (className) => document.body.classList.remove(className),
eventTargetHasClass: (target, className) => target.classList.contains(className),
eventTargetMatches: (target, selector) => matches(target, selector),
computeBoundingRect: () => this.root_.getBoundingClientRect(),
trapFocus: () => this.focusTrap_.activate(),
releaseFocus: () => this.focusTrap_.deactivate(),
Expand All @@ -187,6 +191,11 @@ class MDCDialog extends MDCComponent {
const element = closest(event.target, `[${strings.ACTION_ATTRIBUTE}]`);
return element && element.getAttribute(strings.ACTION_ATTRIBUTE);
},
clickDefaultButton: () => {
if (this.defaultButton_) {
this.defaultButton_.click();
}
},
reverseButtons: () => {
this.buttons_.reverse();
this.buttons_.forEach((button) => button.parentElement.appendChild(button));
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="test-font--redact-all">Cancel</span>
</button>
<button type="button" class="mdc-button mdc-dialog__button" data-mdc-dialog-action="yes">
<button type="button" class="mdc-button mdc-dialog__button mdc-dialog__button--default" data-mdc-dialog-action="yes">
<span class="test-font--redact-all">OK</span>
</button>
</footer>
Expand Down
44 changes: 34 additions & 10 deletions test/unit/mdc-dialog/foundation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,16 @@ import {cssClasses, strings, numbers} from '../../../packages/mdc-dialog/constan
import {createMockRaf} from '../helpers/raf';
import MDCDialogFoundation from '../../../packages/mdc-dialog/foundation';

const INTERACTION_EVENTS = [
{type: 'click', target: {}},
const ENTER_EVENTS = [
{type: 'keydown', key: 'Enter', target: {}},
{type: 'keydown', keyCode: 13, target: {}},
];

const INTERACTION_EVENTS = [
{type: 'click', target: {}},
{type: 'keydown', key: 'Space', target: {}},
{type: 'keydown', keyCode: 32, target: {}},
];
].concat(ENTER_EVENTS);

suite('MDCDialogFoundation');

Expand All @@ -57,9 +60,9 @@ test('exports numbers', () => {
test('default adapter returns a complete adapter implementation', () => {
verifyDefaultAdapter(MDCDialogFoundation, [
'addClass', 'removeClass', 'hasClass',
'addBodyClass', 'removeBodyClass', 'eventTargetHasClass',
'addBodyClass', 'removeBodyClass', 'eventTargetMatches',
'computeBoundingRect', 'trapFocus', 'releaseFocus',
'isContentScrollable', 'areButtonsStacked', 'getActionFromEvent', 'reverseButtons',
'isContentScrollable', 'areButtonsStacked', 'getActionFromEvent', 'clickDefaultButton', 'reverseButtons',
'notifyOpening', 'notifyOpened', 'notifyClosing', 'notifyClosed',
]);
});
Expand Down Expand Up @@ -383,23 +386,44 @@ test(`interaction does nothing when ${strings.ACTION_ATTRIBUTE} attribute is not
});
});

test(`click closes dialog when ${cssClasses.SCRIM} class is present`, () => {
test('enter keydown calls adapter.clickDefaultButton', () => {
const {foundation, mockAdapter} = setupTest();

ENTER_EVENTS.forEach((event) => {
foundation.handleInteraction(event);
td.verify(mockAdapter.clickDefaultButton());
td.reset();
});
});

test('enter keydown does not call adapter.clickDefaultButton when it should be suppressed', () => {
const {foundation, mockAdapter} = setupTest();

ENTER_EVENTS.forEach((event) => {
td.when(mockAdapter.eventTargetMatches(event.target, strings.SUPPRESS_DEFAULT_PRESS_SELECTOR)).thenReturn(true);
foundation.handleInteraction(event);
td.verify(mockAdapter.clickDefaultButton(), {times: 0});
td.reset();
});
});

test(`click closes dialog when ${strings.SCRIM_SELECTOR} selector matches`, () => {
const {foundation, mockAdapter} = setupTest();
const evt = {type: 'click', target: {}};
foundation.close = td.func('close');
td.when(mockAdapter.eventTargetHasClass(evt.target, cssClasses.SCRIM)).thenReturn(true);
td.when(mockAdapter.eventTargetMatches(evt.target, strings.SCRIM_SELECTOR)).thenReturn(true);

foundation.open();
foundation.handleInteraction(evt);

td.verify(foundation.close(foundation.getScrimClickAction()));
});

test(`click does nothing when ${cssClasses.SCRIM} class is present but scrim click action is empty string`, () => {
test(`click does nothing when ${strings.SCRIM_SELECTOR} class is present but scrimClickAction is empty string`, () => {
const {foundation, mockAdapter} = setupTest();
const evt = {type: 'click', target: {}};
foundation.close = td.func('close');
td.when(mockAdapter.eventTargetHasClass(evt.target, cssClasses.SCRIM)).thenReturn(true);
td.when(mockAdapter.eventTargetMatches(evt.target, strings.SCRIM_SELECTOR)).thenReturn(true);

foundation.setScrimClickAction('');
foundation.open();
Expand Down Expand Up @@ -428,7 +452,7 @@ test('escape keydown closes the dialog (via keyCode property)', () => {
td.verify(foundation.close(foundation.getEscapeKeyAction()));
});

test('escape keydown does nothing if escape key action is set to empty string', () => {
test('escape keydown does nothing if escapeKeyAction is set to empty string', () => {
const {foundation} = setupTest();
foundation.close = td.func('close');

Expand Down
31 changes: 26 additions & 5 deletions test/unit/mdc-dialog/mdc-dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ function getFixture() {
</div>`;
}

function setupTest() {
const fixture = getFixture();
function setupTest(fixture = getFixture()) {
const root = fixture.querySelector('.mdc-dialog');
const component = new MDCDialog(root);
const title = fixture.querySelector('.mdc-dialog__title');
Expand Down Expand Up @@ -294,13 +293,13 @@ test('adapter#removeBodyClass removes a class from the body', () => {
assert.isFalse(body.classList.contains('mdc-dialog--scroll-lock'));
});

test('adapter#eventTargetHasClass returns whether or not the className is in the target\'s classList', () => {
test('adapter#eventTargetMatches returns whether or not the target matches the selector', () => {
const {component} = setupTest();
const target = bel`<div class="existent-class"></div>`;
const {adapter_: adapter} = component.getDefaultFoundation();

assert.isTrue(adapter.eventTargetHasClass(target, 'existent-class'));
assert.isFalse(adapter.eventTargetHasClass(target, 'non-existent-class'));
assert.isTrue(adapter.eventTargetMatches(target, '.existent-class'));
assert.isFalse(adapter.eventTargetMatches(target, '.non-existent-class'));
});

test('adapter#computeBoundingRect calls getBoundingClientRect() on root', () => {
Expand Down Expand Up @@ -443,6 +442,28 @@ 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}`, () => {
const fixture = getFixture();
const yesButton = fixture.querySelector('[data-mdc-dialog-action="yes"]');
yesButton.classList.add(strings.DEFAULT_BUTTON_SELECTOR.slice(1));

const {component} = setupTest(fixture);
yesButton.click = td.func('click');

component.getDefaultFoundation().adapter_.clickDefaultButton();
td.verify(yesButton.click());
});

test(`adapter#clickDefaultButton does nothing if nothing matches ${strings.DEFAULT_BUTTON_SELECTOR}`, () => {
const {component, yesButton, noButton} = setupTest();
yesButton.click = td.func('click');
noButton.click = td.func('click');

assert.doesNotThrow(() => component.getDefaultFoundation().adapter_.clickDefaultButton());
td.verify(yesButton.click(), {times: 0});
td.verify(noButton.click(), {times: 0});
});

test('adapter#reverseButtons reverses the order of children under the actions element', () => {
const {component, actions, yesButton, noButton, cancelButton} = setupTest();
component.getDefaultFoundation().adapter_.reverseButtons();
Expand Down

0 comments on commit 3aa18e2

Please sign in to comment.