From 413b54e96cbb056d566319e54703d26996ec2eb5 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Fri, 17 Mar 2017 18:54:39 -0400 Subject: [PATCH] feat(dialog): Implement a dialog component (#395) --- demos/dialog.html | 200 ++++++++ demos/index.html | 1 + package.json | 1 + packages/material-components-web/index.js | 3 + .../material-components-web.scss | 1 + packages/material-components-web/package.json | 1 + packages/mdc-dialog/README.md | 280 +++++++++++ packages/mdc-dialog/constants.js | 32 ++ packages/mdc-dialog/foundation.js | 192 ++++++++ packages/mdc-dialog/index.js | 103 ++++ packages/mdc-dialog/mdc-dialog.scss | 177 +++++++ packages/mdc-dialog/package.json | 26 + packages/mdc-dialog/util.js | 55 +++ test/unit/mdc-dialog/foundation.test.js | 448 ++++++++++++++++++ test/unit/mdc-dialog/mdc-dialog.test.js | 338 +++++++++++++ test/unit/mdc-dialog/util.test.js | 74 +++ webpack.config.js | 2 + 17 files changed, 1934 insertions(+) create mode 100644 demos/dialog.html create mode 100644 packages/mdc-dialog/README.md create mode 100644 packages/mdc-dialog/constants.js create mode 100644 packages/mdc-dialog/foundation.js create mode 100644 packages/mdc-dialog/index.js create mode 100644 packages/mdc-dialog/mdc-dialog.scss create mode 100644 packages/mdc-dialog/package.json create mode 100644 packages/mdc-dialog/util.js create mode 100644 test/unit/mdc-dialog/foundation.test.js create mode 100644 test/unit/mdc-dialog/mdc-dialog.test.js create mode 100644 test/unit/mdc-dialog/util.test.js diff --git a/demos/dialog.html b/demos/dialog.html new file mode 100644 index 00000000000..eba6d28a8ff --- /dev/null +++ b/demos/dialog.html @@ -0,0 +1,200 @@ + + + + + + MDC-Web Dialog Demo + + + + + + + +
+ + + +
+
+
+

MDC Web Dialog

+

Modal dialog windows for the web

+
+ + +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+
+ + + + + + diff --git a/demos/index.html b/demos/index.html index 1be751b8989..3346aa78c05 100644 --- a/demos/index.html +++ b/demos/index.html @@ -28,6 +28,7 @@
  • Button
  • Card
  • Checkbox
  • +
  • Dialog
  • Drawer (temporary)
  • Drawer (permanent, above toolbar)
  • Drawer (permanent, below toolbar)
  • diff --git a/package.json b/package.json index ccafb73cf0d..2dbf7a19cdc 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "button", "card", "checkbox", + "dialog", "drawer", "elevation", "fab", diff --git a/packages/material-components-web/index.js b/packages/material-components-web/index.js index 21e14e68bf4..a8d7f2b4291 100644 --- a/packages/material-components-web/index.js +++ b/packages/material-components-web/index.js @@ -21,6 +21,7 @@ import * as gridList from '@material/grid-list'; import * as iconToggle from '@material/icon-toggle'; import * as radio from '@material/radio'; import * as ripple from '@material/ripple'; +import * as dialog from '@material/dialog'; import * as drawer from '@material/drawer'; import * as textfield from '@material/textfield'; import * as snackbar from '@material/snackbar'; @@ -30,6 +31,7 @@ import autoInit from '@material/auto-init'; // Register all components autoInit.register('MDCCheckbox', checkbox.MDCCheckbox); +autoInit.register('MDCDialog', dialog.MDCDialog); autoInit.register('MDCTemporaryDrawer', drawer.MDCTemporaryDrawer); autoInit.register('MDCRipple', ripple.MDCRipple); autoInit.register('MDCGridList', gridList.MDCGridList); @@ -50,6 +52,7 @@ export { radio, ripple, snackbar, + dialog, drawer, textfield, menu, diff --git a/packages/material-components-web/material-components-web.scss b/packages/material-components-web/material-components-web.scss index 4d5c691d6bf..1529bec68a2 100644 --- a/packages/material-components-web/material-components-web.scss +++ b/packages/material-components-web/material-components-web.scss @@ -18,6 +18,7 @@ @import "@material/button/mdc-button"; @import "@material/card/mdc-card"; @import "@material/checkbox/mdc-checkbox"; +@import "@material/dialog/mdc-dialog"; @import "@material/drawer/mdc-drawer"; @import "@material/elevation/mdc-elevation"; @import "@material/fab/mdc-fab"; diff --git a/packages/material-components-web/package.json b/packages/material-components-web/package.json index 150f5719fda..9b5cf5d18a2 100644 --- a/packages/material-components-web/package.json +++ b/packages/material-components-web/package.json @@ -19,6 +19,7 @@ "@material/button": "^0.3.1", "@material/card": "^0.1.4", "@material/checkbox": "^0.2.2", + "@material/dialog": "^0.0.0", "@material/drawer": "^0.1.4", "@material/elevation": "^0.1.3", "@material/fab": "^0.3.3", diff --git a/packages/mdc-dialog/README.md b/packages/mdc-dialog/README.md new file mode 100644 index 00000000000..e627d26ac72 --- /dev/null +++ b/packages/mdc-dialog/README.md @@ -0,0 +1,280 @@ +# MDC Dialog + +The MDC Dialog component is a spec-aligned dialog component adhering to the +[Material Design dialog pattern](https://material.google.com/patterns/navigation-dialog.html). +It implements a modal dialog window. You may notice that full screen components outlined in the dialog spec +do not appear in MDC Dialog. This is because they have been deemed to be outside of the scope of what +a dialog should be. + +## Installation + +``` +npm install --save @material/dialog +``` + +## Dialog usage + +Dialogs inform users about a specific task and may contain critical information or require decisions. + +```html + +``` + +> **NOTE**: The `.mdc-dialog__footer__button--accept` element _must_ be the _final focusable element_ within the dialog +in order for this component to function properly. + +In the example above, we've created a dialog box in an `aside` element. Note that you can place content inside +the dialog. There are two types: dialog & dialogs with scrollable content. These are declared using CSS classes. + +Some dialogs will not be tall enough to accomodate everything you would like to display in them. For this there is a +`mdc-dialog__body--scrollable` modifier to allow scrolling in the dialog. + +```html + +``` + +Note that unlike the css classnames, the specific ID names used do not have to be _exactly_ the same as listed above. +They only need to match the values set for their corresponding aria attributes. + +### Using the Component + +MDC Dialog ships with a Component / Foundation combo which allows for frameworks to richly integrate the +correct dialog behaviors into idiomatic components. + +#### Including in code + +##### ES2015 + +```javascript +import {MDCDialog, MDCDialogFoundation} from 'mdc-dialog'; +``` + +##### CommonJS + +```javascript +const mdcDialog = require('mdc-dialog'); +const MDCDialog = mdcDialog.MDCDialog; +const MDCDialogFoundation = mdcDialog.MDCDialogFoundation; +``` + +##### AMD + +```javascript +require(['path/to/mdc-dialog'], mdcDialog => { + const MDCDialog = mdcDrawer.MDCDialog; + const MDCDialogFoundation = mdcDialog.MDCDialogFoundation; +}); +``` + +##### Global + +```javascript +const MDCDialog = mdc.dialog.MDCDialog; +const MDCDialogFoundation = mdc.dialog.MDCDialogFoundation; +``` + +#### Automatic Instantiation + +If you do not care about retaining the component instance for the temporary drawer, simply call `attachTo()` +and pass it a DOM element. This however, is only useful if you do not need to pass a callback to the dialog +when the user selects Accept or Cancel. + +```javascript +mdc.dialog.MDCDialog.attachTo(document.querySelector('#my-mdc-dialog')); +``` + +#### Manual Instantiation + +Dialogs can easily be initialized using their default constructors as well, similar to `attachTo`. + +```javascript +import {MDCDialog} from 'mdc-dialog'; + +const dialog = new MDCDialog(document.querySelector('#my-mdc-dialog')); +``` + +#### Using the dialog component +```js +var dialog = new mdc.dialog.MDCDialog(document.querySelector('#mdc-dialog-default')); + +dialog.listen('MDCDialog:accept', () => { + console.log('accepted'); +}) + +dialog.listen('MDCDialog:cancel', () => { + console.log('canceled'); +}) + +document.querySelector('#default-dialog-activation').addEventListener('click', function (evt) { + dialog.lastFocusedTarget = evt.target; + dialog.show(); +}) +``` + +### Dialog component API + +#### MDCDialog.open + +Boolean. True when the dialog is shown, false otherwise. + +#### MDCDialog.lastFocusedTarget + +EventTarget, usually an HTMLElement. Represents the element that was focused on the page before the dialog is shown. If set, +the dialog will return focus to this element when closed. _This property should be set before calls to show()_. + + +#### MDCDialog.initialize() => void + +Attaches ripples to the dialog footer buttons + +#### MDCDialog.destroy() => void + +Cleans up ripples when dialog is destroyed + +#### MDCDialog.show() => void + +Shows the dialog + +#### MDCDialog.close() => void + +Closes the dialog + +### Dialog Events + +#### MDCDialog:accept + +Broadcast when a user actions on the `.mdc-dialog__footer__button--accept` element. + +#### MDCDialog:cancel + +Broadcast when a user actions on the `.mdc-dialog__footer__button--cancel` element. + +### Using the Foundation Class + +MDC Dialog ships with an `MDCDialogFoundation` class that external frameworks and libraries can +use to integrate the component. As with all foundation classes, an adapter object must be provided. + +> **NOTE**: Components themselves must manage adding ripples to dialog buttons, should they choose to +do so. We provide instructions on how to add ripples to buttons within the [mdc-button README](https://github.com/material-components/material-components-web/tree/master/packages/mdc-button#adding-ripples-to-buttons). + +### Adapter API + +| Method Signature | Description | +| --- | --- | +| `hasClass(className: string) => boolean` | Returns boolean indicating whether the root has a given class. | +| `addClass(className: string) => void` | Adds a class to the root element. | +| `removeClass(className: string) => void` | Removes a class from the root element. | +| `setAttr(attr: string, val: string) => void` | Sets the given attribute to the given value 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) => boolean` | Returns true if target has className, false otherwise. | +| `registerInteractionHandler(evt: string, handler: EventListener) => void` | Adds an event listener to the root element, for the specified event name. | +| `deregisterInteractionHandler(evt: string, handler: EventListener) => void` | Removes an event listener from the root element, for the specified event name. | +| `registerSurfaceInteractionHandler(evt: string, handler: EventListener) => void` | Registers an event handler on the dialog surface element. | +| `deregisterSurfaceInteractionHandler(evt: string, handler: EventListener) => void` | Deregisters an event handler from the dialog surface element. | +| `registerDocumentKeydownHandler(handler: EventListener) => void` | Registers an event handler on the `document` object for a `keydown` event. | +| `deregisterDocumentKeydownHandler(handler: EventListener) => void` | Deregisters an event handler on the `document` object for a `keydown` event. | +| `registerFocusTrappingHandler(handler: EventListener) => void` | Registers a focus event listener with a given handler at the capture phase on the document. | +| `deregisterFocusTrappingHandler(handler: EventListener) => void` | Deregisters a focus event listener from a given handler at the capture phase on the document. | +| `numFocusableTargets() => number` | Returns the number of focusable elements in the dialog | +| `setDialogFocusFirstTarget() => void` | Resets focus to the first focusable element in the dialog | +| `setInitialFocus() => void` | Sets focus on the `mdc-dialog__footer__button--accept` element. | +| `getFocusableElements() => Array` | Returns the list of focusable elements inside the dialog. | +| `saveElementTabState(el: Element) => void` | Saves the current tab index for the element in a way which it can be retrieved later on. | +| `restoreElementTabState(el: Element) => void` | Restores the saved tab index (if any) for an element. | +| `makeElementUntabbable(el: Element) => void` | Makes an element untabbable, e.g. by setting the `tabindex` to `-1`. | +| `setBodyAttr(attr: string, val: string) => void` | Sets the given attribute to the given value on the body element. | +| `rmBodyAttr(attr: string) => void` | Removes the given attribute from the body element. | +| `getFocusedTarget() => Element` | Returns the currently focused element, e.g. `document.activeElement`. | +| `setFocusedTarget(target: EventTarget) => void` | Sets focus on the given target, e.g. by calling `focus()` | +| `notifyAccept() => {}` | Broadcasts an event denoting that the user has accepted the dialog. | +| `notifyCancel() => {}` | Broadcasts an event denoting that the user has cancelled the dialog. | + + +### The full foundation API + +#### MDCDialogFoundation.open() => void + +Opens the dialog, registers appropriate event listners, sets aria attributes, focuses elements. + +#### MDCDialogFoundation.close() => void + +Closes the dialog, deregisters appropriate event listners, resets aria attributes, focuses elements. + +#### MDCDialogFoundation.accept(notifyChange = false) => void + +Closes the dialog. If `notifyChange` is true, calls the adapter's `notifyAccept()` method. + +#### MDCDialogFoundation.cancel(notifyChange = false) => void + +Closes the dialog. If `notifyChange` is true, calls the adapter's `notifyCancel()` method. + +#### MDCDialogFoundation.isOpen() => Boolean + +Returns true if the dialog is open, false otherwise. + +## Theming - Dark Theme Considerations + +When using `mdc-theme--dark` / `mdc-dialog--theme-dark`, the dialog by default sets its background color to `#303030`. You can override this by either overridding the +`--mdc-dialog-dark-theme-bg-color`, overridding the `$mdc-dialog-dark-theme-bg-color` sass variable, or directly in CSS: + +```css +.mdc-theme--dark .mdc-dialog__surface, +.mdc-dialog--theme-dark .mdc-dialog__surface { + background-color: /* custom bg color */; +} +``` diff --git a/packages/mdc-dialog/constants.js b/packages/mdc-dialog/constants.js new file mode 100644 index 00000000000..408cfdbf006 --- /dev/null +++ b/packages/mdc-dialog/constants.js @@ -0,0 +1,32 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const cssClasses = { + ROOT: 'mdc-dialog', + OPEN: 'mdc-dialog--open', + BACKDROP: 'mdc-dialog__backdrop', + SCROLL_LOCK: 'mdc-dialog-scroll-lock', + ACCEPT_BTN: 'mdc-dialog__footer__button--accept', + CANCEL_BTN: 'mdc-dialog__footer__button--cancel', +}; + +export const strings = { + OPEN_DIALOG_SELECTOR: '.mdc-dialog--open', + DIALOG_SURFACE_SELECTOR: '.mdc-dialog__surface', + ACCEPT_SELECTOR: '.mdc-dialog__footer__button--accept', + FOCUSABLE_ELEMENTS: 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), ' + + 'button:not([disabled]), iframe, object, embed, [tabindex], [contenteditable]', +}; diff --git a/packages/mdc-dialog/foundation.js b/packages/mdc-dialog/foundation.js new file mode 100644 index 00000000000..ce2777510df --- /dev/null +++ b/packages/mdc-dialog/foundation.js @@ -0,0 +1,192 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {MDCFoundation} from '@material/base'; +import {cssClasses, strings} from './constants'; + +export default class MDCDialogFoundation extends MDCFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get defaultAdapter() { + return { + hasClass: (/* className: string */) => {}, + addClass: (/* className: string */) => {}, + removeClass: (/* className: string */) => {}, + setAttr: (/* attr: string, val: string */) => {}, + addBodyClass: (/* className: string */) => {}, + removeBodyClass: (/* className: string */) => {}, + eventTargetHasClass: (/* target: EventTarget, className: string */) => /* boolean */ false, + registerInteractionHandler: (/* evt: string, handler: EventListener */) => {}, + deregisterInteractionHandler: (/* evt: string, handler: EventListener */) => {}, + registerSurfaceInteractionHandler: (/* evt: string, handler: EventListener */) => {}, + deregisterSurfaceInteractionHandler: (/* evt: string, handler: EventListener */) => {}, + registerDocumentKeydownHandler: (/* handler: EventListener */) => {}, + deregisterDocumentKeydownHandler: (/* handler: EventListener */) => {}, + registerFocusTrappingHandler: (/* handler: EventListener */) => {}, + deregisterFocusTrappingHandler: (/* handler: EventListener */) => {}, + numFocusableTargets: () => /* number */ 0, + setDialogFocusFirstTarget: () => {}, + setInitialFocus: () => {}, + getFocusableElements: (/* handler: EventListener */) => /* Array */ [], + saveElementTabState: (/* el: Element */) => {}, + restoreElementTabState: (/* el: Element */) => {}, + makeElementUntabbable: (/* el: Element */) => {}, + setBodyAttr: (/* attr: string, val: string */) => {}, + rmBodyAttr: (/* attr: string */) => {}, + getFocusedTarget: () => {}, + setFocusedTarget: (/* target: EventTarget */) => {}, + notifyAccept: () => {}, + notifyCancel: () => {}, + }; + } + + constructor(adapter) { + super(Object.assign(MDCDialogFoundation.defaultAdapter, adapter)); + + this.lastFocusedTarget_ = null; + this.currentFocusedElIndex_ = -1; + this.isOpen_ = false; + this.isResettingToFirstFocusTarget_ = false; + this.componentClickHandler_ = () => this.cancel(true); + this.dialogClickHandler_ = (evt) => this.handleDialogClick_(evt); + this.focusHandler_ = (evt) => this.setFocus_(evt); + this.documentKeydownHandler_ = (evt) => { + if (evt.key && evt.key === 'Escape' || evt.keyCode === 27) { + this.cancel(true); + } + }; + } + + destroy() { + // Ensure that dialog is cleaned up when destroyed + this.close(); + } + + open() { + this.lastFocusedTarget_ = this.adapter_.getFocusedTarget(); + this.makeTabbable_(); + this.adapter_.registerDocumentKeydownHandler(this.documentKeydownHandler_); + this.adapter_.registerSurfaceInteractionHandler('click', this.dialogClickHandler_); + this.adapter_.registerInteractionHandler('click', this.componentClickHandler_); + this.adapter_.setInitialFocus(); + this.adapter_.registerFocusTrappingHandler(this.focusHandler_); + this.disableScroll_(); + this.adapter_.setBodyAttr('aria-hidden', 'true'); + this.adapter_.setAttr('aria-hidden', 'false'); + this.adapter_.addClass(MDCDialogFoundation.cssClasses.OPEN); + this.isOpen_ = true; + this.currentFocusedElIndex_ = this.adapter_.numFocusableTargets() - 1; + } + + close() { + this.makeUntabbable_(); + this.adapter_.deregisterSurfaceInteractionHandler('click', this.dialogClickHandler_); + this.adapter_.deregisterDocumentKeydownHandler(this.documentKeydownHandler_); + this.adapter_.deregisterInteractionHandler('click', this.componentClickHandler_); + this.adapter_.deregisterFocusTrappingHandler(this.focusHandler_); + this.adapter_.removeClass(MDCDialogFoundation.cssClasses.OPEN); + this.enableScroll_(); + this.adapter_.rmBodyAttr('aria-hidden'); + this.adapter_.setAttr('aria-hidden', 'true'); + + if (this.lastFocusedTarget_) { + this.adapter_.setFocusedTarget(this.lastFocusedTarget_); + } + this.lastFocusedTarget_ = null; + } + + isOpen() { + return this.isOpen_; + } + + accept(shouldNotify) { + if (shouldNotify) { + this.adapter_.notifyAccept(); + } + + this.close(); + } + + cancel(shouldNotify) { + if (shouldNotify) { + this.adapter_.notifyCancel(); + } + + this.close(); + } + + handleDialogClick_(evt) { + evt.stopPropagation(); + const {target} = evt; + if (this.adapter_.eventTargetHasClass(target, cssClasses.ACCEPT_BTN)) { + this.accept(true); + } else if (this.adapter_.eventTargetHasClass(target, cssClasses.CANCEL_BTN)) { + this.cancel(true); + } + } + + makeUntabbable_() { + const elements = this.adapter_.getFocusableElements(); + if (elements) { + for (let i = 0; i < elements.length; i++) { + this.adapter_.saveElementTabState(elements[i]); + this.adapter_.makeElementUntabbable(elements[i]); + } + } + } + + makeTabbable_() { + const elements = this.adapter_.getFocusableElements(); + if (elements) { + for (let i = 0; i < elements.length; i++) { + this.adapter_.restoreElementTabState(elements[i]); + } + } + } + + setFocus_(evt) { + if (!evt.relatedTarget) { + // Do not increment the focused el index when re-focusing on same element, e.g. switching windows + return; + } + + if (this.isResettingToFirstFocusTarget_) { + return; + } + + this.currentFocusedElIndex_ = (this.currentFocusedElIndex_ + 1) % this.adapter_.numFocusableTargets(); + + if (this.currentFocusedElIndex_ === 0) { + this.isResettingToFirstFocusTarget_ = true; + this.adapter_.setDialogFocusFirstTarget(); + this.isResettingToFirstFocusTarget_ = false; + } + } + + disableScroll_() { + this.adapter_.addBodyClass(cssClasses.SCROLL_LOCK); + } + + enableScroll_() { + this.adapter_.removeBodyClass(cssClasses.SCROLL_LOCK); + } +} diff --git a/packages/mdc-dialog/index.js b/packages/mdc-dialog/index.js new file mode 100644 index 00000000000..1a435fa5968 --- /dev/null +++ b/packages/mdc-dialog/index.js @@ -0,0 +1,103 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {MDCComponent} from '@material/base'; +import {MDCRipple} from '@material/ripple'; + +import MDCDialogFoundation from './foundation'; +import * as util from './util'; + +export {MDCDialogFoundation}; +export {util}; + +export class MDCDialog extends MDCComponent { + static attachTo(root) { + return new MDCDialog(root); + } + + get open() { + return this.foundation_.isOpen(); + } + + get acceptButton_() { + return this.root_.querySelector(MDCDialogFoundation.strings.ACCEPT_SELECTOR); + } + + get dialogSurface_() { + return this.root_.querySelector(MDCDialogFoundation.strings.DIALOG_SURFACE_SELECTOR); + } + + initialize() { + this.lastFocusedTarget = null; + this.footerBtnRipples_ = []; + + const footerBtns = this.root_.querySelectorAll('.mdc-dialog__footer__button'); + for (let i = 0, footerBtn; footerBtn = footerBtns[i]; i++) { + this.footerBtnRipples_.push(new MDCRipple(footerBtn)); + } + } + + destroy() { + this.footerBtnRipples_.forEach((ripple) => ripple.destroy()); + } + + show() { + this.foundation_.open(); + } + + close() { + this.foundation_.close(); + } + + getDefaultFoundation() { + const {FOCUSABLE_ELEMENTS} = MDCDialogFoundation.strings; + + return new MDCDialogFoundation({ + hasClass: (className) => this.root_.classList.contains(className), + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + setAttr: (attr, val) => this.root_.setAttribute(attr, val), + addBodyClass: (className) => document.body.classList.add(className), + removeBodyClass: (className) => document.body.classList.remove(className), + eventTargetHasClass: (target, className) => target.classList.contains(className), + registerInteractionHandler: (evt, handler) => + this.root_.addEventListener(evt, handler, util.applyPassive()), + deregisterInteractionHandler: (evt, handler) => + this.root_.removeEventListener(evt, handler, util.applyPassive()), + registerSurfaceInteractionHandler: (evt, handler) => + this.dialogSurface_.addEventListener(evt, handler), + deregisterSurfaceInteractionHandler: (evt, handler) => + this.dialogSurface_.removeEventListener(evt, handler), + registerDocumentKeydownHandler: (handler) => document.addEventListener('keydown', handler), + deregisterDocumentKeydownHandler: (handler) => document.removeEventListener('keydown', handler), + registerFocusTrappingHandler: (handler) => document.addEventListener('focus', handler, true), + deregisterFocusTrappingHandler: (handler) => document.removeEventListener('focus', handler, true), + numFocusableTargets: () => this.dialogSurface_.querySelectorAll(FOCUSABLE_ELEMENTS).length, + setDialogFocusFirstTarget: () => this.dialogSurface_.querySelectorAll(FOCUSABLE_ELEMENTS)[0].focus(), + setInitialFocus: () => this.acceptButton_.focus(), + getFocusableElements: () => this.dialogSurface_.querySelectorAll(FOCUSABLE_ELEMENTS), + saveElementTabState: (el) => util.saveElementTabState(el), + restoreElementTabState: (el) => util.restoreElementTabState(el), + makeElementUntabbable: (el) => el.setAttribute('tabindex', -1), + setBodyAttr: (attr, val) => document.body.setAttribute(attr, val), + rmBodyAttr: (attr) => document.body.removeAttribute(attr), + getFocusedTarget: () => document.activeElement, + setFocusedTarget: (target) => target.focus(), + notifyAccept: () => this.emit('MDCDialog:accept'), + notifyCancel: () => this.emit('MDCDialog:cancel'), + }); + } +} diff --git a/packages/mdc-dialog/mdc-dialog.scss b/packages/mdc-dialog/mdc-dialog.scss new file mode 100644 index 00000000000..921b22e573d --- /dev/null +++ b/packages/mdc-dialog/mdc-dialog.scss @@ -0,0 +1,177 @@ +// Copyright 2017 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@import "@material/animation/functions"; +@import "@material/elevation/mixins"; +@import "@material/rtl/mixins"; +@import "@material/theme/mixins"; +@import "@material/typography/mixins"; +@import "@material/typography/variables"; + +$mdc-dialog-dark-theme-bg-color: #303030 !default; + +:root { + --mdc-dialog-dark-theme-bg-color: $mdc-dialog-dark-theme-bg-color; +} + +// postcss-bem-linter: define dialog + +.mdc-dialog { + display: flex; + position: fixed; + align-items: center; + justify-content: center; + opacity: 0; + z-index: -1; + + &__surface { + display: inline-flex; + position: absolute; + bottom: 50vh; + flex-direction: column; + + @include mdc-elevation(24); + + width: calc(100% - 30px); + min-width: 640px; + max-width: 865px; + transform: translateY(150px) scale(.8); + transition: mdc-animation-enter(opacity, 120ms), mdc-animation-enter(transform, 120ms); + + @include mdc-theme-prop(background-color, background); + + @include mdc-theme-dark(".mdc-dialog") { + @include mdc-theme-prop(color, text-primary-on-dark); + + background-color: $mdc-dialog-dark-theme-bg-color; + background-color: var(--mdc-dialog-dark-theme-bg-color, #{$mdc-dialog-dark-theme-bg-color}); + } + + @include mdc-rtl(".mdc-dialog") { + text-align: right; + } + + opacity: 0; + } + + &__backdrop { + display: flex; + position: fixed; + top: 0; + left: 0; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + @include mdc-theme-prop(background-color, text-primary-on-light); + + opacity: 0; + z-index: -1; + } + + &__header { + display: flex; + align-items: center; + padding: 24px 24px 0; + + &__empty { + padding: 0; + } + + @include mdc-rtl(".mdc-dialog") { + text-align: right; + } + + &__title { + flex: 1; + margin: 0; + + @include mdc-typography(title); + } + } + + &__body { + margin-top: 20px; + padding: 0 24px 24px; + + @include mdc-theme-prop(color, text-secondary-on-light); + + @include mdc-theme-dark(".mdc-dialog", true) { + @include mdc-theme-prop(color, text-secondary-on-dark); + } + + @include mdc-typography(body1); + + &--scrollable { + max-height: 195px; + border-top: 1px solid rgba(0, 0, 0, .1); + border-bottom: 1px solid rgba(0, 0, 0, .1); + overflow-y: scroll; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + } + + &__footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + padding: 8px; + } + + // TODO: Replace with breakpoint variable + @media (max-width: 640px) { + min-width: 280px; + + &__surface { + min-width: 280px; + } + + &__body { + line-height: 24px; + } + } +} + +.mdc-dialog--open { + display: flex; + top: 0; + left: 0; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + opacity: 1; + z-index: 2; + + .mdc-dialog__surface { + transform: translateY(0) scale(1); + transition: mdc-animation-enter(opacity, 120ms), mdc-animation-enter(transform, 120ms); + opacity: 1; + } + + .mdc-dialog__backdrop { + transition: mdc-animation-enter(opacity, 120ms); + opacity: .3; + } +} + +// postcss-bem-linter: end + +.mdc-dialog-scroll-lock { + height: 100vh; + overflow: hidden; +} diff --git a/packages/mdc-dialog/package.json b/packages/mdc-dialog/package.json new file mode 100644 index 00000000000..18cfc89634e --- /dev/null +++ b/packages/mdc-dialog/package.json @@ -0,0 +1,26 @@ +{ + "name": "@material/dialog", + "version": "0.0.0", + "description": "The Material Components Web dialog component", + "license": "Apache-2.0", + "keywords": [ + "material components", + "material design", + "dialog", + "modal" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web.git" + }, + "dependencies": { + "@material/animation": "^0.1.3", + "@material/base": "^0.1.1", + "@material/elevation": "^0.1.2", + "@material/ripple": "^0.4.0", + "@material/rtl": "^0.1.1", + "@material/theme": "^0.1.1", + "@material/typography": "^0.1.1" + } +} diff --git a/packages/mdc-dialog/util.js b/packages/mdc-dialog/util.js new file mode 100644 index 00000000000..13ac47a5e30 --- /dev/null +++ b/packages/mdc-dialog/util.js @@ -0,0 +1,55 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const TAB_DATA = 'data-mdc-tabindex'; +const TAB_DATA_HANDLED = 'data-mdc-tabindex-handled'; + +let supportsPassive_; + +// Determine whether the current browser supports passive event listeners, and if so, use them. +export function applyPassive(globalObj = window, forceRefresh = false) { + if (supportsPassive_ === undefined || forceRefresh) { + let isSupported = false; + try { + globalObj.document.addEventListener('test', null, {get passive() { + isSupported = true; + }}); + } catch (e) { } + + supportsPassive_ = isSupported; + } + + return supportsPassive_ ? {passive: true} : false; +} + +export function saveElementTabState(el) { + if (el.hasAttribute('tabindex')) { + el.setAttribute(TAB_DATA, el.getAttribute('tabindex')); + } + el.setAttribute(TAB_DATA_HANDLED, true); +} + +export function restoreElementTabState(el) { + // Only modify elements we've already handled, in case anything was dynamically added since we saved state. + if (el.hasAttribute(TAB_DATA_HANDLED)) { + if (el.hasAttribute(TAB_DATA)) { + el.setAttribute('tabindex', el.getAttribute(TAB_DATA)); + el.removeAttribute(TAB_DATA); + } else { + el.removeAttribute('tabindex'); + } + el.removeAttribute(TAB_DATA_HANDLED); + } +} diff --git a/test/unit/mdc-dialog/foundation.test.js b/test/unit/mdc-dialog/foundation.test.js new file mode 100644 index 00000000000..9bc308921f2 --- /dev/null +++ b/test/unit/mdc-dialog/foundation.test.js @@ -0,0 +1,448 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and * limitations under the License. + */ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {setupFoundationTest} from '../helpers/setup'; +import {verifyDefaultAdapter, captureHandlers} from '../helpers/foundation'; + +import {cssClasses} from '../../../packages/mdc-dialog/constants'; +import MDCDialogFoundation from '../../../packages/mdc-dialog/foundation'; + +suite('MDCDialogFoundation'); + +test('exports cssClasses', () => { + assert.isOk('cssClasses' in MDCDialogFoundation); +}); + +test('default adapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCDialogFoundation, [ + 'hasClass', 'addClass', 'removeClass', + 'addBodyClass', 'removeBodyClass', 'eventTargetHasClass', + 'registerInteractionHandler', 'deregisterInteractionHandler', + 'registerSurfaceInteractionHandler', 'deregisterSurfaceInteractionHandler', + 'registerDocumentKeydownHandler', 'deregisterDocumentKeydownHandler', + 'registerFocusTrappingHandler', 'deregisterFocusTrappingHandler', + 'numFocusableTargets', 'setDialogFocusFirstTarget', 'setInitialFocus', + 'getFocusableElements', 'saveElementTabState', 'restoreElementTabState', + 'makeElementUntabbable', 'setBodyAttr', 'rmBodyAttr', 'setAttr', + 'getFocusedTarget', 'setFocusedTarget', 'notifyAccept', 'notifyCancel', + ]); +}); + +function setupTest() { + return setupFoundationTest(MDCDialogFoundation); +} + +test('#destroy closes the dialog to perform any necessary cleanup', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.destroy(); + + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); +}); + +test('#isOpen returns true when the dialog is open', () => { + const {foundation} = setupTest(); + + foundation.open(); + assert.isOk(foundation.isOpen()); +}); + +test('#isOpen returns false when the dialog is closed', () => { + const {foundation} = setupTest(); + + foundation.close(); + assert.isNotOk(foundation.isOpen()); +}); + +test('#open registers all events registered within open()', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + + td.verify(mockAdapter.registerSurfaceInteractionHandler('click', td.matchers.isA(Function))); + td.verify(mockAdapter.registerDocumentKeydownHandler(td.matchers.isA(Function))); + td.verify(mockAdapter.registerInteractionHandler('click', td.matchers.isA(Function))); + td.verify(mockAdapter.registerFocusTrappingHandler(td.matchers.isA(Function))); +}); + +test('#close deregisters all events registered within open()', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + foundation.close(); + + td.verify(mockAdapter.deregisterSurfaceInteractionHandler('click', td.matchers.isA(Function))); + td.verify(mockAdapter.deregisterDocumentKeydownHandler(td.matchers.isA(Function))); + td.verify(mockAdapter.deregisterInteractionHandler('click', td.matchers.isA(Function))); + td.verify(mockAdapter.deregisterFocusTrappingHandler(td.matchers.isA(Function))); +}); + +test('#open adds the open class to reveal the dialog', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + td.verify(mockAdapter.addClass(cssClasses.OPEN)); +}); + +test('#close removes the open class to hide the dialog', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.close(); + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); +}); + +test('#open adds scroll lock class to the body', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + td.verify(mockAdapter.addBodyClass(cssClasses.SCROLL_LOCK)); +}); + +test('#close removes the scroll lock class from dialog background', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.close(); + td.verify(mockAdapter.removeBodyClass(cssClasses.SCROLL_LOCK)); +}); + +test('#open makes elements tabbable', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.hasClass(cssClasses.OPEN)).thenReturn(false); + td.when(mockAdapter.getFocusableElements()).thenReturn(['foo', 'bar']); + + foundation.init(); + foundation.open(); + td.verify(mockAdapter.restoreElementTabState('foo')); + td.verify(mockAdapter.restoreElementTabState('bar')); +}); + +test('#close makes elements untabbable', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.hasClass(cssClasses.OPEN)).thenReturn(true); + td.when(mockAdapter.getFocusableElements()).thenReturn(['foo', 'bar']); + + foundation.open(); + foundation.close(); + td.verify(mockAdapter.makeElementUntabbable('foo')); + td.verify(mockAdapter.makeElementUntabbable('bar')); +}); + +test('#open sets aria attributes for dialog', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + td.verify(mockAdapter.setAttr('aria-hidden', 'false')); + td.verify(mockAdapter.setBodyAttr('aria-hidden', 'true')); +}); + +test('#close sets aria attributes for dialog', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.close(); + td.verify(mockAdapter.setAttr('aria-hidden', 'true')); + td.verify(mockAdapter.rmBodyAttr('aria-hidden')); +}); + +test('#open sets default focus', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + td.verify(mockAdapter.setInitialFocus()); +}); + +test('#accept closes the dialog', () => { + const {foundation} = setupTest(); + + foundation.accept(); + assert.isFalse(foundation.isOpen()); +}); + +test('#accept calls accept when shouldNotify is set to true', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.accept(true); + td.verify(mockAdapter.notifyAccept()); +}); + +test('#cancel closes the dialog', () => { + const {foundation} = setupTest(); + + foundation.cancel(); + assert.isFalse(foundation.isOpen()); +}); + +test('#cancel calls notifyCancel when shouldNotify is set to true', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.cancel(true); + td.verify(mockAdapter.notifyCancel()); +}); + +test('on dialog surface click calls evt.stopPropagation() to prevent click from propagating to background el', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerSurfaceInteractionHandler'); + const evt = { + stopPropagation: td.func('evt.stopPropagation'), + target: {}, + }; + + foundation.open(); + handlers.click(evt); + + td.verify(evt.stopPropagation()); +}); + +test('on dialog surface click closes and notifies acceptance if event target is the accept button', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerSurfaceInteractionHandler'); + const evt = { + stopPropagation: () => {}, + target: {}, + }; + + td.when(mockAdapter.eventTargetHasClass(evt.target, cssClasses.ACCEPT_BTN)).thenReturn(true); + foundation.open(); + handlers.click(evt); + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); + td.verify(mockAdapter.notifyAccept()); +}); + +test('on dialog surface click closes and notifies cancellation if event target is the cancel button', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerSurfaceInteractionHandler'); + const evt = { + stopPropagation: () => {}, + target: {}, + }; + + td.when(mockAdapter.eventTargetHasClass(evt.target, cssClasses.CANCEL_BTN)).thenReturn(true); + foundation.open(); + handlers.click(evt); + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); + td.verify(mockAdapter.notifyCancel()); +}); + +test('on dialog surface click does not close or notify if the event target is not the ' + + 'accept or cancel button', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerSurfaceInteractionHandler'); + const evt = { + target: {}, + stopPropagation: () => {}, + }; + + td.when(mockAdapter.eventTargetHasClass(evt.target, td.matchers.isA(String))).thenReturn(false); + foundation.open(); + handlers.click(evt); + td.verify(mockAdapter.removeClass(cssClasses.OPEN), {times: 0}); + td.verify(mockAdapter.notifyCancel(), {times: 0}); + td.verify(mockAdapter.notifyAccept(), {times: 0}); +}); + +test('on click closese the dialog and notifies cancellation', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + + foundation.open(); + handlers.click(); + + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); + td.verify(mockAdapter.notifyCancel()); +}); + +test('on document keydown closes the dialog when escape key is pressed', () => { + const {foundation, mockAdapter} = setupTest(); + let keydown; + td.when(mockAdapter.registerDocumentKeydownHandler(td.matchers.isA(Function))).thenDo((handler) => { + keydown = handler; + }); + foundation.init(); + foundation.open(); + + keydown({ + key: 'Escape', + }); + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); +}); + +test('on document keydown closes the dialog when escape key is pressed using keycode', () => { + const {foundation, mockAdapter} = setupTest(); + let keydown; + td.when(mockAdapter.registerDocumentKeydownHandler(td.matchers.isA(Function))).thenDo((handler) => { + keydown = handler; + }); + foundation.init(); + foundation.open(); + + keydown({ + keyCode: 27, + }); + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); +}); + +test('on document keydown calls notifyCancel', () => { + const {foundation, mockAdapter} = setupTest(); + + let keydown; + td.when(mockAdapter.registerDocumentKeydownHandler(td.matchers.isA(Function))).thenDo((handler) => { + keydown = handler; + }); + foundation.init(); + foundation.open(); + + keydown({ + key: 'Escape', + }); + + td.verify(mockAdapter.notifyCancel()); +}); + +test('on document keydown does nothing when key other than escape is pressed', () => { + const {foundation, mockAdapter} = setupTest(); + let keydown; + td.when(mockAdapter.registerDocumentKeydownHandler(td.matchers.isA(Function))).thenDo((handler) => { + keydown = handler; + }); + foundation.init(); + foundation.open(); + + keydown({ + key: 'Enter', + }); + td.verify(mockAdapter.removeClass(cssClasses.OPEN), {times: 0}); +}); + +test('on focus does not call setDialogFocusFirstTarget if previous focus target is not last target', () => { + const {foundation, mockAdapter} = setupTest(); + let focusTrappingHandler; + const focusEvent = { + relatedTarget: {}, + }; + + td.when(mockAdapter.numFocusableTargets()).thenReturn(2); + td.when(mockAdapter.registerFocusTrappingHandler(td.matchers.isA(Function))).thenDo((handler) => { + focusTrappingHandler = handler; + }); + + foundation.open(); + focusTrappingHandler(focusEvent); + focusTrappingHandler(focusEvent); + + td.verify(mockAdapter.setDialogFocusFirstTarget()); +}); + +test('on focus resets focus to first target when last focus target was previously focused', () => { + const {foundation, mockAdapter} = setupTest(); + let focusTrappingHandler; + const focusEvent = { + relatedTarget: {}, + }; + + td.when(mockAdapter.numFocusableTargets()).thenReturn(2); + td.when(mockAdapter.registerFocusTrappingHandler(td.matchers.isA(Function))).thenDo((handler) => { + focusTrappingHandler = handler; + }); + + foundation.open(); + focusTrappingHandler(focusEvent); + + td.verify(mockAdapter.setDialogFocusFirstTarget()); +}); + +test('on focus does not increment the focus element index when `relatedTarget` is absent from the event', () => { + const {foundation, mockAdapter} = setupTest(); + let focusTrappingHandler; + const focusEvent = { + relatedTarget: null, + }; + + td.when(mockAdapter.numFocusableTargets()).thenReturn(2); + td.when(mockAdapter.registerFocusTrappingHandler(td.matchers.isA(Function))).thenDo((handler) => { + focusTrappingHandler = handler; + }); + + foundation.open(); + focusTrappingHandler(focusEvent); + + td.verify(mockAdapter.setDialogFocusFirstTarget(), {times: 0}); +}); + +test('on focus ensures redundant focus events do not trample the index', () => { + const {foundation, mockAdapter} = setupTest(); + let focusTrappingHandler; + const focusEvent = { + relatedTarget: {}, + }; + let timesSetDialogFocusFirstTargetWasCalled = 0; + + td.when(mockAdapter.numFocusableTargets()).thenReturn(1); + td.when(mockAdapter.registerFocusTrappingHandler(td.matchers.isA(Function))).thenDo((handler) => { + focusTrappingHandler = handler; + }); + + foundation.open(); + + // Call additional focus() event within setDialogFocus...(), simulating the focus handler being + // triggered by the manual focusing of that element. Also manually increment the amount of times + // this method was called so that we don't need to call it an additional time when verifying. + td.when(mockAdapter.setDialogFocusFirstTarget()).thenDo(() => { + timesSetDialogFocusFirstTargetWasCalled++; + focusTrappingHandler(focusEvent); + }); + + // Ensure that additional focus() event did not lead to additional calls to + // setDialogFocusFirstTarget. + focusTrappingHandler(focusEvent); + assert.equal(timesSetDialogFocusFirstTargetWasCalled, 1); +}); + +test('#open regisers focus trapping handler after initial focus is assigned', () => { + const {foundation, mockAdapter} = setupTest(); + let setInitialFocusCalledBeforeFocusHandlerRegistered = false; + let registerFocusTrappingHandlerCalled = false; + + td.when(mockAdapter.registerFocusTrappingHandler(td.matchers.isA(Function))).thenDo(() => { + registerFocusTrappingHandlerCalled = true; + }); + + td.when(mockAdapter.setInitialFocus()).thenDo(() => { + setInitialFocusCalledBeforeFocusHandlerRegistered = !registerFocusTrappingHandlerCalled; + }); + + foundation.open(); + + assert.isTrue(setInitialFocusCalledBeforeFocusHandlerRegistered); +}); + +test('#close does not call setFocusedTarget if there is no lastFocusedTarget ', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.close(); + + td.verify(mockAdapter.setFocusedTarget(td.matchers.anything()), {times: 0}); +}); + +test('#close calls setFocusedTarget if lastFocusedTarget evaluates to true', () => { + const {foundation, mockAdapter} = setupTest(); + const lastFocusedTarget = {}; + + td.when(mockAdapter.getFocusedTarget()).thenReturn(lastFocusedTarget); + foundation.open(); + foundation.close(); + + td.verify(mockAdapter.setFocusedTarget(lastFocusedTarget)); +}); diff --git a/test/unit/mdc-dialog/mdc-dialog.test.js b/test/unit/mdc-dialog/mdc-dialog.test.js new file mode 100644 index 00000000000..33870498b47 --- /dev/null +++ b/test/unit/mdc-dialog/mdc-dialog.test.js @@ -0,0 +1,338 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {assert} from 'chai'; +import bel from 'bel'; +import domEvents from 'dom-events'; +import td from 'testdouble'; +import {createMockRaf} from '../helpers/raf'; +import {strings} from '../../../packages/mdc-dialog/constants'; +import {MDCDialog} from '../../../packages/mdc-dialog'; +import {supportsCssVariables} from '../../../packages/mdc-ripple/util'; + +function getFixture() { + return bel` +
    + + +
    `; +} + +function setupTest() { + const fixture = getFixture(); + const openDialog = fixture.querySelector('.open-dialog'); + const root = fixture.querySelector('.mdc-dialog'); + const component = new MDCDialog(root); + const acceptButton = fixture.querySelector('.mdc-dialog__footer__button--accept'); + const cancelButton = fixture.querySelector('.mdc-dialog__footer__button--cancel'); + return {openDialog, root, acceptButton, cancelButton, component}; +} + +suite('MDCDialog'); + +test('attachTo returns a component instance', () => { + assert.isOk(MDCDialog.attachTo(getFixture().querySelector('.mdc-dialog')) instanceof MDCDialog); +}); + +if (supportsCssVariables(window)) { + test('#initialize attaches ripple elements to all footer buttons', () => { + const raf = createMockRaf(); + const {acceptButton, cancelButton} = setupTest(); + raf.flush(); + + assert.isTrue(acceptButton.classList.contains('mdc-ripple-upgraded')); + assert.isTrue(cancelButton.classList.contains('mdc-ripple-upgraded')); + raf.restore(); + }); + + test('#destroy cleans up all ripples on footer buttons', () => { + const raf = createMockRaf(); + const {component, acceptButton, cancelButton} = setupTest(); + raf.flush(); + + component.destroy(); + raf.flush(); + + assert.isFalse(acceptButton.classList.contains('mdc-ripple-upgraded')); + assert.isFalse(cancelButton.classList.contains('mdc-ripple-upgraded')); + raf.restore(); + }); +} + +test('#show opens the dialog', () => { + const {component} = setupTest(); + + component.show(); + assert.isTrue(component.open); +}); + +test('#close hides the dialog', () => { + const {component} = setupTest(); + + component.close(); + assert.isFalse(component.open); +}); + +test('adapter#hasClass returns true if the root element has specified class', () => { + const {root, component} = setupTest(); + root.classList.add('foo'); assert.isOk(component.getDefaultFoundation().adapter_.hasClass('foo')); +}); + +test('adapter#hasClass returns false if the root element does not have specified class', () => { + const {component} = setupTest(); + assert.isNotOk(component.getDefaultFoundation().adapter_.hasClass('foo')); +}); + +test('foundationAdapter#addClass adds a class to the root element', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.addClass('foo'); + assert.isOk(root.classList.contains('foo')); +}); + +test('adapter#removeClass removes a class from the root element', () => { + const {root, component} = setupTest(); + root.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeClass('foo'); + assert.isNotOk(root.classList.contains('foo')); +}); + +test('adapter#addBodyClass adds a class to the body, locking the background scroll', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.addBodyClass('mdc-dialog--scroll-lock'); + assert.isOk(document.querySelector('body').classList.contains('mdc-dialog--scroll-lock')); +}); + +test('adapter#removeBodyClass adds a class to the body, locking the background scroll', () => { + const {component} = setupTest(); + const body = document.querySelector('body'); + + body.classList.add('mdc-dialog--scroll-lock'); + component.getDefaultFoundation().adapter_.removeBodyClass('mdc-dialog--scroll-lock'); + assert.isNotOk(body.classList.contains('mdc-dialog--scroll-lock')); +}); + +test('adapter#registerInteractionHandler adds an event listener to the root element', () => { + const {root, component} = setupTest(); + const handler = td.func('eventHandler'); + + component.getDefaultFoundation().adapter_.registerInteractionHandler('click', handler); + domEvents.emit(root, 'click'); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterInteractionHandler removes an event listener from the root element', () => { + const {root, component} = setupTest(); + const handler = td.func('eventHandler'); + root.addEventListener('click', handler); + + component.getDefaultFoundation().adapter_.deregisterInteractionHandler('click', handler); + domEvents.emit(root, 'click'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#registerSurfaceInteractionHandler adds an event listener to the root element', () => { + const {root, component} = setupTest(); + const dialog = root.querySelector(strings.DIALOG_SURFACE_SELECTOR); + const handler = td.func('eventHandler'); + + component.getDefaultFoundation().adapter_.registerSurfaceInteractionHandler('click', handler); + domEvents.emit(dialog, 'click'); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterSurfaceInteractionHandler removes an event listener from the root element', () => { + const {root, component} = setupTest(); + const dialog = root.querySelector(strings.DIALOG_SURFACE_SELECTOR); + const handler = td.func('eventHandler'); + + dialog.addEventListener('click', handler); + component.getDefaultFoundation().adapter_.deregisterSurfaceInteractionHandler('click', handler); + domEvents.emit(dialog, 'click'); + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#registerDocumentKeydownHandler attaches a "keydown" handler to the document', () => { + const {component} = setupTest(); + const handler = td.func('keydownHandler'); + + component.getDefaultFoundation().adapter_.registerDocumentKeydownHandler(handler); + domEvents.emit(document, 'keydown'); + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterDocumentKeydownHandler removes a "keydown" handler from the document', () => { + const {component} = setupTest(); + const handler = td.func('keydownHandler'); + + document.addEventListener('keydown', handler); + component.getDefaultFoundation().adapter_.deregisterDocumentKeydownHandler(handler); + domEvents.emit(document, 'keydown'); + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#eventTargetHasClass returns whether or not the className is in the target\'s classList', () => { + const {component} = setupTest(); + const target = bel`
    `; + const {adapter_: adapter} = component.getDefaultFoundation(); + + assert.isTrue(adapter.eventTargetHasClass(target, 'existent-class')); + assert.isFalse(adapter.eventTargetHasClass(target, 'non-existent-class')); +}); + +test('adapter#registerFocusTrappingHandler attaches a "focus" handler to the document', () => { + const {component} = setupTest(); + const handler = td.func('eventHandler'); + + component.getDefaultFoundation().adapter_.registerFocusTrappingHandler(handler); + domEvents.emit(document, 'focus'); + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterFocusTrappingHandler removes a "focus" handler from the document', () => { + const {component} = setupTest(); + const handler = td.func('eventHandler'); + + component.getDefaultFoundation().adapter_.registerFocusTrappingHandler(handler); + component.getDefaultFoundation().adapter_.deregisterFocusTrappingHandler(handler); + domEvents.emit(document, 'focus'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#getFocusableElements returns all the focusable elements in the dialog', () => { + const root = bel` + + `; + const component = new MDCDialog(root); + assert.equal(component.getDefaultFoundation().adapter_.getFocusableElements().length, 2); +}); + +test('adapter#makeElementUntabbable sets a tab index of -1 on the element', () => { + const root = bel` + + `; + + const component = new MDCDialog(root); + const el = root.querySelector('#foo'); + component.getDefaultFoundation().adapter_.makeElementUntabbable(el); + assert.equal(el.getAttribute('tabindex'), '-1'); +}); + +test('adapter#notifyAccept emits MDCDialog:accept', () => { + const {component} = setupTest(); + + const handler = td.func('acceptHandler'); + + component.listen('MDCDialog:accept', handler); + component.getDefaultFoundation().adapter_.notifyAccept(); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#notifyCancel emits MDCDialog:cancel', () => { + const {component} = setupTest(); + + const handler = td.func('cancelHandler'); + + component.listen('MDCDialog:cancel', handler); + component.getDefaultFoundation().adapter_.notifyCancel(); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#setFocusedTarget focuses the target given to it', () => { + const {component} = setupTest(); + const target = { + focus: td.func('focus'), + }; + + component.getDefaultFoundation().adapter_.setFocusedTarget(target); + + td.verify(target.focus()); +}); + +test('adapter#setBodyAttr sets an attribute to the given value on the body', () => { + const {component} = setupTest(); + + component.getDefaultFoundation().adapter_.setBodyAttr('aria-hidden', 'true'); + assert.equal(document.body.getAttribute('aria-hidden'), 'true'); + document.body.removeAttribute('aria-hidden'); +}); + +test('adapter#rmBodyAttr removes an attribute from the body', () => { + const {component} = setupTest(); + + document.body.setAttribute('aria-hidden', 'true'); + component.getDefaultFoundation().adapter_.rmBodyAttr('aria-hidden'); + assert.isFalse(document.body.hasAttribute('aria-hidden')); +}); + +test('adapter#setAttr sets an attribute to the given value on the root element', () => { + const {component, root} = setupTest(); + component.getDefaultFoundation().adapter_.setAttr('aria-hidden', 'true'); + assert.equal(root.getAttribute('aria-hidden'), 'true'); +}); diff --git a/test/unit/mdc-dialog/util.test.js b/test/unit/mdc-dialog/util.test.js new file mode 100644 index 00000000000..1cc34dce732 --- /dev/null +++ b/test/unit/mdc-dialog/util.test.js @@ -0,0 +1,74 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {assert} from 'chai'; +import bel from 'bel'; + +import * as utils from '../../../packages/mdc-dialog/util'; + +suite('MDCDialog - util'); + +test('applyPassive returns an options object for browsers that support passive event listeners', () => { + const mockWindow = { + document: { + addEventListener: function(name, method, options) { + return options.passive; + }, + }, + }; + assert.deepEqual(utils.applyPassive(mockWindow, true), {passive: true}); +}); + +test('applyPassive returns false for browsers that do not support passive event listeners', () => { + const mockWindow = { + document: { + addEventListener: function() { + throw new Error(); + }, + }, + }; + assert.isNotOk(utils.applyPassive(mockWindow, true)); +}); + +test('saveElementTabState saves the tab index of an element', () => { + const el = bel`
    `; + utils.saveElementTabState(el); + assert.equal(el.getAttribute('data-mdc-tabindex'), '42'); + assert.equal(el.getAttribute('data-mdc-tabindex-handled'), 'true'); +}); + +test('saveElementTabState marks an element as handled, as long as it is tabbable', () => { + const el = bel``; + utils.saveElementTabState(el); + assert.equal(el.getAttribute('data-mdc-tabindex'), null); + assert.equal(el.getAttribute('data-mdc-tabindex-handled'), 'true'); +}); + +test('restoreElementTabState restores the tab index of an element that was saved earlier', () => { + const el = bel``; + utils.restoreElementTabState(el); + assert.equal(el.getAttribute('tabindex'), '42'); + assert.equal(el.getAttribute('data-mdc-tabindex'), null); + assert.equal(el.getAttribute('data-mdc-tabindex-handled'), null); +}); + +test('restoreElementTabState removes the temporary tabindex of an implicitly tabbable element', () => { + const el = bel``; + utils.restoreElementTabState(el); + assert.equal(el.getAttribute('tabindex'), null); + assert.equal(el.getAttribute('data-mdc-tabindex'), null); + assert.equal(el.getAttribute('data-mdc-tabindex-handled'), null); +}); diff --git a/webpack.config.js b/webpack.config.js index 319b025648c..f1ebdc7c5ba 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -75,6 +75,7 @@ module.exports = [{ autoInit: [path.resolve('./packages/mdc-auto-init/index.js')], base: [path.resolve('./packages/mdc-base/index.js')], checkbox: [path.resolve('./packages/mdc-checkbox/index.js')], + dialog: [path.resolve('./packages/mdc-dialog/index.js')], drawer: [path.resolve('./packages/mdc-drawer/index.js')], formField: [path.resolve('./packages/mdc-form-field/index.js')], gridList: [path.resolve('./packages/mdc-grid-list/index.js')], @@ -140,6 +141,7 @@ module.exports = [{ 'mdc.button': path.resolve('./packages/mdc-button/mdc-button.scss'), 'mdc.card': path.resolve('./packages/mdc-card/mdc-card.scss'), 'mdc.checkbox': path.resolve('./packages/mdc-checkbox/mdc-checkbox.scss'), + 'mdc.dialog': path.resolve('./packages/mdc-dialog/mdc-dialog.scss'), 'mdc.drawer': path.resolve('./packages/mdc-drawer/mdc-drawer.scss'), 'mdc.elevation': path.resolve('./packages/mdc-elevation/mdc-elevation.scss'), 'mdc.fab': path.resolve('./packages/mdc-fab/mdc-fab.scss'),