diff --git a/package.json b/package.json index 4c1738a3ad2..feb60ffecaf 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "mdc-select", "mdc-selection-control", "mdc-slider", + "mdc-snackbar", "mdc-switch", "mdc-tab", "mdc-tab-indicator", diff --git a/packages/mdc-snackbar/_mixins.scss b/packages/mdc-snackbar/_mixins.scss index 3172d359fc0..c1f1938e94d 100644 --- a/packages/mdc-snackbar/_mixins.scss +++ b/packages/mdc-snackbar/_mixins.scss @@ -39,9 +39,9 @@ } } -@mixin mdc-snackbar-shape-radius($radius) { +@mixin mdc-snackbar-shape-radius($radius, $rtl-reflexive: false) { .mdc-snackbar__surface { - @include mdc-shape-radius($radius); + @include mdc-shape-radius($radius, $rtl-reflexive: false); } } diff --git a/packages/mdc-snackbar/adapter.js b/packages/mdc-snackbar/adapter.js new file mode 100644 index 00000000000..4986747c6a4 --- /dev/null +++ b/packages/mdc-snackbar/adapter.js @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/* eslint no-unused-vars: [2, {"args": "none"}] */ + +/** + * Adapter for MDC Snackbar. Provides an interface for managing: + * - CSS classes + * - Event handlers + * + * Additionally, provides type information for the adapter to the Closure + * compiler. + * + * Implement this adapter for your framework of choice to delegate updates to + * the component in your framework of choice. See architecture documentation + * for more details. + * https://github.com/material-components/material-components-web/blob/master/docs/code/architecture.md + * + * @record + */ +class MDCSnackbarAdapter { + /** @param {string} className */ + addClass(className) {} + + /** @param {string} className */ + removeClass(className) {} + + notifyOpening() {} + notifyOpened() {} + + /** + * @param {string} reason + */ + notifyClosing(reason) {} + + /** + * @param {string} reason + */ + notifyClosed(reason) {} +} + +export default MDCSnackbarAdapter; diff --git a/packages/mdc-snackbar/constants.js b/packages/mdc-snackbar/constants.js new file mode 100644 index 00000000000..fa972df77bb --- /dev/null +++ b/packages/mdc-snackbar/constants.js @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +const cssClasses = { + OPENING: 'mdc-snackbar--opening', + OPEN: 'mdc-snackbar--open', + CLOSING: 'mdc-snackbar--closing', +}; + +const strings = { + SURFACE_SELECTOR: '.mdc-snackbar__surface', + LABEL_SELECTOR: '.mdc-snackbar__label', + ACTION_BUTTON_SELECTOR: '.mdc-snackbar__action-button', + ACTION_ICON_SELECTOR: '.mdc-snackbar__action-icon', + LABEL_TEXT_ATTR: 'data-mdc-snackbar-label-text', + OPENING_EVENT: 'MDCSnackbar:opening', + OPENED_EVENT: 'MDCSnackbar:opened', + CLOSING_EVENT: 'MDCSnackbar:closing', + CLOSED_EVENT: 'MDCSnackbar:closed', + REASON_ACTION: 'action', + REASON_DISMISS: 'dismiss', +}; + +const numbers = { + MIN_AUTO_DISMISS_TIMEOUT_MS: 4000, + MAX_AUTO_DISMISS_TIMEOUT_MS: 10000, + DEFAULT_AUTO_DISMISS_TIMEOUT_MS: 5000, + SNACKBAR_ANIMATION_OPEN_TIME_MS: 150, + SNACKBAR_ANIMATION_CLOSE_TIME_MS: 225, +}; + +export {cssClasses, strings, numbers}; diff --git a/packages/mdc-snackbar/foundation.js b/packages/mdc-snackbar/foundation.js new file mode 100644 index 00000000000..2dab917ccdc --- /dev/null +++ b/packages/mdc-snackbar/foundation.js @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +/* eslint no-unused-vars: ["error", {"argsIgnorePattern": "evt", "varsIgnorePattern": "Adapter$"}] */ + +import {MDCFoundation} from '@material/base/index'; +import MDCSnackbarAdapter from './adapter'; +import {cssClasses, numbers, strings} from './constants'; + +const {OPENING, OPEN, CLOSING} = cssClasses; +const {REASON_ACTION, REASON_DISMISS} = strings; + +class MDCSnackbarFoundation extends MDCFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get numbers() { + return numbers; + } + + /** + * @return {!MDCSnackbarAdapter} + */ + static get defaultAdapter() { + return /** @type {!MDCSnackbarAdapter} */ ({ + addClass: (/* className: string */) => {}, + removeClass: (/* className: string */) => {}, + notifyOpening: () => {}, + notifyOpened: () => {}, + notifyClosing: (/* reason: string */) => {}, + notifyClosed: (/* reason: string */) => {}, + }); + } + + /** + * @param {!MDCSnackbarAdapter=} adapter + */ + constructor(adapter) { + super(Object.assign(MDCSnackbarFoundation.defaultAdapter, adapter)); + + /** @private {boolean} */ + this.isOpen_ = false; + + /** @private {number} */ + this.animationFrame_ = 0; + + /** @private {number} */ + this.animationTimer_ = 0; + + /** @private {number} */ + this.autoDismissTimer_ = 0; + + /** @private {number} */ + this.autoDismissTimeoutMs_ = numbers.DEFAULT_AUTO_DISMISS_TIMEOUT_MS; + + /** @private {boolean} */ + this.closeOnEscape_ = true; + } + + destroy() { + this.clearAutoDismissTimer_(); + cancelAnimationFrame(this.animationFrame_); + this.animationFrame_ = 0; + clearTimeout(this.animationTimer_); + this.animationTimer_ = 0; + this.adapter_.removeClass(OPENING); + this.adapter_.removeClass(OPEN); + this.adapter_.removeClass(CLOSING); + } + + open() { + this.clearAutoDismissTimer_(); + this.isOpen_ = true; + this.adapter_.notifyOpening(); + this.adapter_.removeClass(CLOSING); + this.adapter_.addClass(OPENING); + + // Wait a frame once display is no longer "none", to establish basis for animation + this.runNextAnimationFrame_(() => { + this.adapter_.addClass(OPEN); + + this.animationTimer_ = setTimeout(() => { + this.handleAnimationTimerEnd_(); + this.adapter_.notifyOpened(); + this.autoDismissTimer_ = setTimeout(() => { + this.close(REASON_DISMISS); + }, this.getTimeoutMs()); + }, numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + }); + } + + /** + * @param {string=} reason Why the snackbar was closed. Value will be passed to CLOSING_EVENT and CLOSED_EVENT via the + * `event.detail.reason` property. Standard values are REASON_ACTION and REASON_DISMISS, but custom + * client-specific values may also be used if desired. + */ + close(reason = '') { + if (!this.isOpen_) { + // Avoid redundant close calls (and events), e.g. repeated interactions as the snackbar is animating closed + return; + } + + cancelAnimationFrame(this.animationFrame_); + this.animationFrame_ = 0; + this.clearAutoDismissTimer_(); + + this.isOpen_ = false; + this.adapter_.notifyClosing(reason); + this.adapter_.addClass(cssClasses.CLOSING); + this.adapter_.removeClass(cssClasses.OPEN); + this.adapter_.removeClass(cssClasses.OPENING); + + clearTimeout(this.animationTimer_); + this.animationTimer_ = setTimeout(() => { + this.handleAnimationTimerEnd_(); + this.adapter_.notifyClosed(reason); + }, numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS); + } + + /** + * @return {boolean} + */ + isOpen() { + return this.isOpen_; + } + + /** + * @return {number} + */ + getTimeoutMs() { + return this.autoDismissTimeoutMs_; + } + + /** + * @param {number} timeoutMs + */ + setTimeoutMs(timeoutMs) { + // Use shorter variable names to make the code more readable + const minValue = numbers.MIN_AUTO_DISMISS_TIMEOUT_MS; + const maxValue = numbers.MAX_AUTO_DISMISS_TIMEOUT_MS; + + if (timeoutMs <= maxValue && timeoutMs >= minValue) { + this.autoDismissTimeoutMs_ = timeoutMs; + } else { + throw new Error(`timeoutMs must be an integer in the range ${minValue}–${maxValue}, but got '${timeoutMs}'`); + } + } + + /** + * @return {boolean} + */ + getCloseOnEscape() { + return this.closeOnEscape_; + } + + /** + * @param {boolean} closeOnEscape + */ + setCloseOnEscape(closeOnEscape) { + this.closeOnEscape_ = closeOnEscape; + } + + /** + * @param {!KeyboardEvent} evt + */ + handleKeyDown(evt) { + if (this.getCloseOnEscape() && (evt.key === 'Escape' || evt.keyCode === 27)) { + this.close(REASON_DISMISS); + } + } + + /** + * @param {!MouseEvent} evt + */ + handleActionButtonClick(evt) { + this.close(REASON_ACTION); + } + + /** + * @param {!MouseEvent} evt + */ + handleActionIconClick(evt) { + this.close(REASON_DISMISS); + } + + /** @private */ + clearAutoDismissTimer_() { + clearTimeout(this.autoDismissTimer_); + this.autoDismissTimer_ = 0; + } + + /** @private */ + handleAnimationTimerEnd_() { + this.animationTimer_ = 0; + this.adapter_.removeClass(cssClasses.OPENING); + this.adapter_.removeClass(cssClasses.CLOSING); + } + + /** + * Runs the given logic on the next animation frame, using setTimeout to factor in Firefox reflow behavior. + * @param {Function} callback + * @private + */ + runNextAnimationFrame_(callback) { + cancelAnimationFrame(this.animationFrame_); + this.animationFrame_ = requestAnimationFrame(() => { + this.animationFrame_ = 0; + clearTimeout(this.animationTimer_); + this.animationTimer_ = setTimeout(callback, 0); + }); + } +} + +export default MDCSnackbarFoundation; diff --git a/packages/mdc-snackbar/index.js b/packages/mdc-snackbar/index.js index b6a2ba8539e..6b1eaae7547 100644 --- a/packages/mdc-snackbar/index.js +++ b/packages/mdc-snackbar/index.js @@ -21,4 +21,196 @@ * THE SOFTWARE. */ -export class MDCSnackbar {} +import {MDCComponent} from '@material/base/index'; +import MDCSnackbarFoundation from './foundation'; +import {strings} from './constants'; +import * as ponyfill from '@material/dom/ponyfill'; + +const { + SURFACE_SELECTOR, LABEL_SELECTOR, ACTION_BUTTON_SELECTOR, ACTION_ICON_SELECTOR, + OPENING_EVENT, OPENED_EVENT, CLOSING_EVENT, CLOSED_EVENT, +} = strings; + +class MDCSnackbar extends MDCComponent { + static attachTo(root) { + return new MDCSnackbar(root); + } + + constructor(...args) { + super(...args); + + /** @type {!HTMLElement} */ + this.surfaceEl_; + + /** @private {!Function} */ + this.handleKeyDown_; + + /** @private {!Function} */ + this.handleSurfaceClick_; + } + + initialSyncWithDOM() { + this.surfaceEl_ = /** @type {!HTMLElement} */ (this.root_.querySelector(SURFACE_SELECTOR)); + + this.handleKeyDown_ = (evt) => this.foundation_.handleKeyDown(evt); + this.handleSurfaceClick_ = (evt) => { + if (this.isActionButton_(evt.target)) { + this.foundation_.handleActionButtonClick(evt); + } else if (this.isActionIcon_(evt.target)) { + this.foundation_.handleActionIconClick(evt); + } + }; + + this.registerKeyDownHandler_(this.handleKeyDown_); + this.registerSurfaceClickHandler_(this.handleSurfaceClick_); + } + + destroy() { + super.destroy(); + this.deregisterKeyDownHandler_(this.handleKeyDown_); + this.deregisterSurfaceClickHandler_(this.handleSurfaceClick_); + } + + open() { + this.foundation_.open(); + } + + /** + * @param {string=} reason Why the snackbar was closed. Value will be passed to CLOSING_EVENT and CLOSED_EVENT via the + * `event.detail.reason` property. Standard values are REASON_ACTION and REASON_DISMISS, but custom + * client-specific values may also be used if desired. + */ + close(reason = '') { + this.foundation_.close(reason); + } + + /** + * @return {!MDCSnackbarFoundation} + */ + getDefaultFoundation() { + /* eslint brace-style: "off" */ + return new MDCSnackbarFoundation({ + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + notifyOpening: () => this.emit(OPENING_EVENT, {}), + notifyOpened: () => this.emit(OPENED_EVENT, {}), + notifyClosing: (reason) => this.emit(CLOSING_EVENT, reason ? {reason} : {}), + notifyClosed: (reason) => this.emit(CLOSED_EVENT, reason ? {reason} : {}), + }); + } + + /** + * @return {number} + */ + get timeoutMs() { + return this.foundation_.getTimeoutMs(); + } + + /** + * @param {number} timeoutMs + */ + set timeoutMs(timeoutMs) { + this.foundation_.setTimeoutMs(timeoutMs); + } + + /** + * @return {boolean} + */ + get closeOnEscape() { + return this.foundation_.getCloseOnEscape(); + } + + /** + * @param {boolean} closeOnEscape + */ + set closeOnEscape(closeOnEscape) { + this.foundation_.setCloseOnEscape(closeOnEscape); + } + + /** + * @return {boolean} + */ + get isOpen() { + return this.foundation_.isOpen(); + } + + /** + * @return {string} + */ + get labelText() { + return this.root_.querySelector(LABEL_SELECTOR).textContent; + } + + /** + * @param {string} labelText + */ + set labelText(labelText) { + this.root_.querySelector(LABEL_SELECTOR).textContent = labelText; + } + + /** + * @return {string} + */ + get actionButtonText() { + return this.root_.querySelector(ACTION_BUTTON_SELECTOR).textContent; + } + + /** + * @param {string} actionButtonText + */ + set actionButtonText(actionButtonText) { + this.root_.querySelector(ACTION_BUTTON_SELECTOR).textContent = actionButtonText; + } + + /** + * @param {!Function} handler + * @private + */ + registerKeyDownHandler_(handler) { + this.listen('keydown', handler); + } + + /** + * @param {!Function} handler + * @private + */ + deregisterKeyDownHandler_(handler) { + this.unlisten('keydown', handler); + } + + /** + * @param {!Function} handler + * @private + */ + registerSurfaceClickHandler_(handler) { + this.surfaceEl_.addEventListener('click', handler); + } + + /** + * @param {!Function} handler + * @private + */ + deregisterSurfaceClickHandler_(handler) { + this.surfaceEl_.removeEventListener('click', handler); + } + + /** + * @param {!Element} target + * @return {boolean} + * @private + */ + isActionButton_(target) { + return Boolean(ponyfill.closest(target, ACTION_BUTTON_SELECTOR)); + } + + /** + * @param {!Element} target + * @return {boolean} + * @private + */ + isActionIcon_(target) { + return Boolean(ponyfill.closest(target, ACTION_ICON_SELECTOR)); + } +} + +export {MDCSnackbar, MDCSnackbarFoundation}; diff --git a/packages/mdc-snackbar/mdc-snackbar.scss b/packages/mdc-snackbar/mdc-snackbar.scss index bc4bfab17d1..41ee1186e77 100644 --- a/packages/mdc-snackbar/mdc-snackbar.scss +++ b/packages/mdc-snackbar/mdc-snackbar.scss @@ -34,7 +34,7 @@ @include mdc-snackbar-z-index($mdc-snackbar-z-index); @include mdc-snackbar-viewport-margin($mdc-snackbar-viewport-margin-narrow); - display: flex; + display: none; position: fixed; right: 0; bottom: 0; @@ -43,10 +43,6 @@ justify-content: center; box-sizing: border-box; - // Prevent snackbar from A) receiving mouse events while closed, and B) being visible during elastic scrolling. - // We can't use `display: none` on the root or surface elements because it prevents CSS transitions from animating. - transform: translateY(200vh); - // Ignore mouse events on the root layout element. pointer-events: none; @@ -70,6 +66,12 @@ @include mdc-snackbar-shape-radius($mdc-snackbar-shape-radius); } +.mdc-snackbar--opening, +.mdc-snackbar--open, +.mdc-snackbar--closing { + display: flex; +} + .mdc-snackbar--wide { @include mdc-snackbar-viewport-margin($mdc-snackbar-viewport-margin-wide); } @@ -78,11 +80,6 @@ justify-content: flex-start; } -.mdc-snackbar--open, -.mdc-snackbar--closing { - transform: none; -} - .mdc-snackbar__surface { display: flex; align-items: center; @@ -91,11 +88,6 @@ transform: scale(.8); opacity: 0; - .mdc-snackbar--stacked & { - flex-direction: column; - align-items: flex-start; - } - .mdc-snackbar--open & { transform: scale(1); transition: @@ -109,25 +101,22 @@ transform: scale(1); transition: mdc-animation-exit-permanent(opacity, $mdc-snackbar-exit-duration, $mdc-snackbar-exit-delay); } + + .mdc-snackbar--stacked & { + flex-direction: column; + align-items: flex-start; + } } .mdc-snackbar__label { @include mdc-typography($mdc-snackbar-label-type-scale); - display: none; flex-grow: 1; box-sizing: border-box; margin: 0; // 14px top/bottom padding needed to make the height 48px. padding: 14px 16px; - - // stylelint-disable plugin/selector-bem-pattern - .mdc-snackbar--open &, - .mdc-snackbar--closing & { - display: block; - } - // stylelint-enable plugin/selector-bem-pattern } // Used to prevent visual jank when announcing label text to screen readers. @@ -140,18 +129,11 @@ .mdc-snackbar__actions { @include mdc-rtl-reflexive-property(margin, 0, $mdc-snackbar-padding); - display: none; + display: flex; flex-shrink: 0; align-items: center; box-sizing: border-box; - // stylelint-disable plugin/selector-bem-pattern - .mdc-snackbar--open &, - .mdc-snackbar--closing & { - display: flex; - } - // stylelint-enable plugin/selector-bem-pattern - .mdc-snackbar--stacked & { align-self: flex-end; margin-bottom: $mdc-snackbar-padding; diff --git a/packages/mdc-snackbar/package.json b/packages/mdc-snackbar/package.json index 7e5bf30333c..7b90f2d9fdb 100644 --- a/packages/mdc-snackbar/package.json +++ b/packages/mdc-snackbar/package.json @@ -17,6 +17,7 @@ "@material/animation": "^0.41.0", "@material/base": "^0.41.0", "@material/button": "^0.41.0", + "@material/dom": "^0.41.0", "@material/icon-button": "^0.41.0", "@material/ripple": "^0.41.0", "@material/rtl": "^0.40.1", diff --git a/test/screenshot/spec/mdc-snackbar/fixture.js b/test/screenshot/spec/mdc-snackbar/fixture.js index eaffb1feebf..577b50ccea6 100644 --- a/test/screenshot/spec/mdc-snackbar/fixture.js +++ b/test/screenshot/spec/mdc-snackbar/fixture.js @@ -22,5 +22,51 @@ */ window.mdc.testFixture.fontsLoaded.then(() => { + /** @type {!Array} */ + const queue = []; + + /** @param {function(): void} fn */ + function enqueue(fn) { + queue.push(fn); + if (queue.length === 1) { + fn(); + } + } + + function dequeue() { + queue.shift(); + const nextFn = queue[0]; + if (nextFn) { + setTimeout(nextFn, 250); // Insert a brief delay between queued snackbars (it's less visually jarring) + } + } + + // Export snackbar instances to `window` for manual testing/debugging in dev tools + window.mdc.testFixture.snackbars = []; + + [].forEach.call(document.querySelectorAll('.mdc-snackbar'), (rootEl) => { + /** @type {!MDCSnackbar} */ + const snackbar = mdc.snackbar.MDCSnackbar.attachTo(rootEl); + + const openButtonEl = document.querySelector(`[data-test-snackbar-id="${rootEl.id}"]`); + if (openButtonEl) { + openButtonEl.addEventListener('click', () => enqueue(() => snackbar.open())); + } + + const {OPENING_EVENT, OPENED_EVENT, CLOSING_EVENT, CLOSED_EVENT} = mdc.snackbar.MDCSnackbarFoundation.strings; + [OPENING_EVENT, OPENED_EVENT, CLOSING_EVENT, CLOSED_EVENT].forEach((eventName) => { + snackbar.listen(eventName, (evt) => console.log(evt.type, evt.detail)); + }); + + snackbar.listen(CLOSED_EVENT, dequeue); + + const timeoutMs = parseInt(rootEl.getAttribute('data-test-snackbar-timeout-ms'), 10); + if (timeoutMs > 0) { + snackbar.timeoutMs = timeoutMs; + } + + window.mdc.testFixture.snackbars.push(snackbar); + }); + window.mdc.testFixture.notifyDomReady(); }); diff --git a/test/unit/mdc-snackbar/foundation.test.js b/test/unit/mdc-snackbar/foundation.test.js new file mode 100644 index 00000000000..63549eac573 --- /dev/null +++ b/test/unit/mdc-snackbar/foundation.test.js @@ -0,0 +1,348 @@ +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {setupFoundationTest} from '../helpers/setup'; +import {verifyDefaultAdapter} from '../helpers/foundation'; + +import {cssClasses, strings, numbers} from '../../../packages/mdc-snackbar/constants'; +import {install as installClock} from '../helpers/clock'; +import MDCSnackbarFoundation from '../../../packages/mdc-snackbar/foundation'; + +suite('MDCSnackbarFoundation'); + +test('exports cssClasses', () => { + assert.deepEqual(MDCSnackbarFoundation.cssClasses, cssClasses); +}); + +test('exports strings', () => { + assert.deepEqual(MDCSnackbarFoundation.strings, strings); +}); + +test('exports numbers', () => { + assert.deepEqual(MDCSnackbarFoundation.numbers, numbers); +}); + +test('default adapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCSnackbarFoundation, [ + 'addClass', 'removeClass', 'notifyOpening', 'notifyOpened', 'notifyClosing', 'notifyClosed', + ]); +}); + +/** + * @return {{mockAdapter: !MDCSnackbarAdapter, foundation: !MDCSnackbarFoundation}} + */ +function setupTest() { + const adapterFoundationPair = /** @type {{mockAdapter: !MDCSnackbarAdapter, foundation: !MDCSnackbarFoundation}} */ ( + setupFoundationTest(MDCSnackbarFoundation) + ); + adapterFoundationPair.foundation.init(); + return adapterFoundationPair; +} + +test('#destroy removes all animating and open classes', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + foundation.destroy(); + + td.verify(mockAdapter.removeClass(cssClasses.OPENING)); + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); + td.verify(mockAdapter.removeClass(cssClasses.CLOSING)); +}); + +test('#destroy cancels all timers', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + foundation.close = td.func('close'); + + foundation.open(); + foundation.destroy(); + + // Note: #open uses a combination of rAF and setTimeout due to Firefox behavior, so we need to wait 2 ticks + clock.runToFrame(); + clock.runToFrame(); + + td.verify(mockAdapter.addClass(cssClasses.OPEN), {times: 0}); + td.reset(); + + clock.runToFrame(); + td.verify(foundation.close(strings.REASON_DISMISS), {times: 0}); +}); + +test('#open adds CSS classes after rAF', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.open(); + td.verify(mockAdapter.addClass(cssClasses.OPEN), {times: 0}); + + // Note: #open uses a combination of rAF and setTimeout due to Firefox behavior, so we need to wait 2 ticks + clock.runToFrame(); + clock.runToFrame(); + td.verify(mockAdapter.addClass(cssClasses.OPEN)); +}); + +test('#close removes CSS classes', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.open(); + td.reset(); + foundation.close(); + + td.verify(mockAdapter.removeClass(cssClasses.OPEN)); +}); + +test('#close cancels rAF scheduled by open if still pending', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.open(); + td.reset(); + foundation.close(); + + // Note: #open uses a combination of rAF and setTimeout due to Firefox behavior, so we need to wait 2 ticks + clock.runToFrame(); + clock.runToFrame(); + td.verify(mockAdapter.addClass(cssClasses.OPEN), {times: 0}); +}); + +test('#open adds the opening class to start an animation, and removes it after the animation is done', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.open(); + clock.runToFrame(); + clock.tick(100); + + td.verify(mockAdapter.addClass(cssClasses.OPENING)); + td.verify(mockAdapter.removeClass(cssClasses.OPENING), {times: 0}); + clock.tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + td.verify(mockAdapter.removeClass(cssClasses.OPENING)); +}); + +test('#close adds the closing class to start an animation, and removes it after the animation is done', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.open(); + clock.tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + td.reset(); + foundation.close(); + + td.verify(mockAdapter.addClass(cssClasses.CLOSING)); + td.verify(mockAdapter.removeClass(cssClasses.CLOSING), {times: 0}); + clock.tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS); + td.verify(mockAdapter.removeClass(cssClasses.CLOSING)); +}); + +test('#open emits "opening" and "opened" events', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.open(); + clock.runToFrame(); + clock.tick(100); + + td.verify(mockAdapter.notifyOpening(), {times: 1}); + clock.tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + td.verify(mockAdapter.notifyOpened(), {times: 1}); +}); + +test('#close emits "closing" and "closed" events', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.open(); + clock.tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + td.reset(); + foundation.close(); + + td.verify(mockAdapter.notifyClosing(''), {times: 1}); + clock.tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS); + td.verify(mockAdapter.notifyClosed(''), {times: 1}); + + foundation.open(); + clock.tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + td.reset(); + + const reason = 'reason'; + foundation.close(reason); + td.verify(mockAdapter.notifyClosing(reason), {times: 1}); + clock.tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS); + td.verify(mockAdapter.notifyClosed(reason), {times: 1}); +}); + +test('#close does nothing if the snackbar is already closed', () => { + const {foundation, mockAdapter} = setupTest(); + const clock = installClock(); + + foundation.close(); + clock.runToFrame(); + clock.tick(numbers.SNACKBAR_ANIMATION_CLOSE_TIME_MS); + td.verify(mockAdapter.removeClass(cssClasses.OPEN), {times: 0}); + td.verify(mockAdapter.removeClass(cssClasses.OPENING), {times: 0}); + td.verify(mockAdapter.addClass(cssClasses.CLOSING), {times: 0}); + td.verify(mockAdapter.notifyClosing(''), {times: 0}); + td.verify(mockAdapter.notifyClosed(''), {times: 0}); +}); + +test('#open automatically dismisses snackbar after timeout', () => { + const {foundation} = setupTest(); + const clock = installClock(); + foundation.close = td.func('close'); + + foundation.open(); + + // Note: #open uses a combination of rAF and setTimeout due to Firefox behavior, so we need to wait 2 ticks + clock.runToFrame(); + clock.runToFrame(); + + // Auto-dismiss timeout + clock.tick(numbers.SNACKBAR_ANIMATION_OPEN_TIME_MS); + clock.tick(foundation.getTimeoutMs()); + + td.verify(foundation.close(strings.REASON_DISMISS), {times: 1}); +}); + +test('#isOpen returns false when the snackbar has never been opened', () => { + const {foundation} = setupTest(); + assert.isFalse(foundation.isOpen()); +}); + +test('#isOpen returns true when the snackbar is open', () => { + const {foundation} = setupTest(); + + foundation.open(); + + assert.isTrue(foundation.isOpen()); +}); + +test('#isOpen returns false when the snackbar is closed after being open', () => { + const {foundation} = setupTest(); + + foundation.open(); + foundation.close(); + + assert.isFalse(foundation.isOpen()); +}); + +test('escape keydown closes snackbar when closeOnEscape is true (via key property)', () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + + foundation.open(); + foundation.handleKeyDown({key: 'Escape'}); + + td.verify(foundation.close(strings.REASON_DISMISS)); +}); + +test('escape keydown closes snackbar when closeOnEscape is true (via keyCode property)', () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + + foundation.open(); + foundation.handleKeyDown({keyCode: 27}); + + td.verify(foundation.close(strings.REASON_DISMISS)); +}); + +test('escape keydown does not close snackbar when closeOnEscape is false (via key property)', () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + foundation.setCloseOnEscape(false); + + foundation.open(); + foundation.handleKeyDown({key: 'Escape'}); + + td.verify(foundation.close(strings.REASON_DISMISS), {times: 0}); +}); + +test('escape keydown does not close snackbar when closeOnEscape is false (via keyCode property)', () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + foundation.setCloseOnEscape(false); + + foundation.open(); + foundation.handleKeyDown({keyCode: 27}); + + td.verify(foundation.close(strings.REASON_DISMISS), {times: 0}); +}); + +test('keydown does nothing when key other than escape is pressed', () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + + foundation.open(); + foundation.handleKeyDown({key: 'Enter'}); + + td.verify(foundation.close(strings.REASON_DISMISS), {times: 0}); +}); + +test(`#handleActionButtonClick closes the snackbar with reason "${strings.REASON_ACTION}"`, () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + + foundation.open(); + foundation.handleActionButtonClick({}); + + td.verify(foundation.close(strings.REASON_ACTION)); +}); + +test(`#handleActionIconClick closes the snackbar with reason "${strings.REASON_DISMISS}"`, () => { + const {foundation} = setupTest(); + foundation.close = td.func('close'); + + foundation.open(); + foundation.handleActionIconClick({}); + + td.verify(foundation.close(strings.REASON_DISMISS)); +}); + +test('#setTimeoutMs throws an error for values outside the min/max range', () => { + const {foundation} = setupTest(); + assert.throws(() => foundation.setTimeoutMs(numbers.MIN_AUTO_DISMISS_TIMEOUT_MS - 1)); + assert.throws(() => foundation.setTimeoutMs(numbers.MAX_AUTO_DISMISS_TIMEOUT_MS + 1)); +}); + +test('#getTimeoutMs returns the default value', () => { + const {foundation} = setupTest(); + assert.equal(foundation.getTimeoutMs(), numbers.DEFAULT_AUTO_DISMISS_TIMEOUT_MS); +}); + +test('#getTimeoutMs returns the value set by setTimeoutMs', () => { + const {foundation} = setupTest(); + const timeoutMs = numbers.MAX_AUTO_DISMISS_TIMEOUT_MS - 1; + foundation.setTimeoutMs(timeoutMs); + assert.equal(foundation.getTimeoutMs(), timeoutMs); +}); + +test('#getCloseOnEscape returns the value set by setCloseOnEscape', () => { + const {foundation} = setupTest(); + foundation.setCloseOnEscape(false); + assert.equal(foundation.getCloseOnEscape(), false); + foundation.setCloseOnEscape(true); + assert.equal(foundation.getCloseOnEscape(), true); +}); diff --git a/test/unit/mdc-snackbar/mdc-snackbar.test.js b/test/unit/mdc-snackbar/mdc-snackbar.test.js new file mode 100644 index 00000000000..9f69a6c408a --- /dev/null +++ b/test/unit/mdc-snackbar/mdc-snackbar.test.js @@ -0,0 +1,319 @@ +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import {assert} from 'chai'; +import bel from 'bel'; +import domEvents from 'dom-events'; +import td from 'testdouble'; +import {strings, numbers} from '../../../packages/mdc-snackbar/constants'; +import {MDCSnackbar, MDCSnackbarFoundation} from '../../../packages/mdc-snackbar/index'; + +/** + * @return {!HTMLElement} + */ +function getFixture() { + return bel` +
+
+
+
Can't send photo. Retry in 5 seconds.
+
+ + +
+
+
+
`; +} + +/** + * @param {!HTMLElement} fixture + * @return {{ + * root: !HTMLElement, + * component: MDCSnackbar, surface: !HTMLElement, + * label: !HTMLElement, + * actions: !HTMLElement, + * actionButton: !HTMLElement, + * actionIcon: !HTMLElement + * }} + */ +function setupTest(fixture = getFixture()) { + const root = fixture.querySelector('.mdc-snackbar'); + const surface = fixture.querySelector('.mdc-snackbar__surface'); + const label = fixture.querySelector('.mdc-snackbar__label'); + const actions = fixture.querySelector('.mdc-snackbar__actions'); + const actionButton = fixture.querySelector('.mdc-snackbar__action-button'); + const actionIcon = fixture.querySelector('.mdc-snackbar__action-icon'); + const component = new MDCSnackbar(root); + return {component, root, surface, label, actions, actionButton, actionIcon}; +} + +/** + * @param {!HTMLElement} fixture + * @return {{ + * root: !HTMLElement, + * component: !MDCSnackbar, + * mockFoundation: !MDCSnackbarFoundation + * }} + */ +function setupTestWithMocks(fixture = getFixture()) { + const root = fixture.querySelector('.mdc-snackbar'); + const surface = fixture.querySelector('.mdc-snackbar__surface'); + const label = fixture.querySelector('.mdc-snackbar__label'); + const actions = fixture.querySelector('.mdc-snackbar__actions'); + const actionButton = fixture.querySelector('.mdc-snackbar__action-button'); + const actionIcon = fixture.querySelector('.mdc-snackbar__action-icon'); + + const MockFoundationCtor = td.constructor(MDCSnackbarFoundation); + + /** @type {!MDCSnackbarFoundation} */ + const mockFoundation = new MockFoundationCtor(); + + /** @type {!MDCSnackbar} */ + const component = new MDCSnackbar(root, mockFoundation); + + return {component, mockFoundation, root, surface, label, actions, actionButton, actionIcon}; +} + +suite('MDCSnackbar'); + +test('attachTo returns a component instance', () => { + assert.instanceOf(MDCSnackbar.attachTo(getFixture().querySelector('.mdc-snackbar')), MDCSnackbar); +}); + +test('#initialSyncWithDOM registers click handlers for action button and action icon', () => { + const {component, mockFoundation, actionButton, actionIcon} = setupTestWithMocks(); + component.open(); + domEvents.emit(actionButton, 'click', {bubbles: true}); + td.verify(mockFoundation.handleActionButtonClick(td.matchers.isA(Event)), {times: 1}); + domEvents.emit(actionIcon, 'click', {bubbles: true}); + td.verify(mockFoundation.handleActionIconClick(td.matchers.isA(Event)), {times: 1}); + component.destroy(); +}); + +test('#initialSyncWithDOM registers keydown handler on the root element', () => { + const {component, mockFoundation, root} = setupTestWithMocks(); + component.open(); + domEvents.emit(root, 'keydown'); + td.verify(mockFoundation.handleKeyDown(td.matchers.isA(Event)), {times: 1}); + component.destroy(); +}); + +test('#destroy deregisters click handler on the root element', () => { + const {component, mockFoundation, actionButton, actionIcon} = setupTestWithMocks(); + component.open(); + component.destroy(); + domEvents.emit(actionButton, 'click', {bubbles: true}); + td.verify(mockFoundation.handleActionButtonClick(td.matchers.isA(Event)), {times: 0}); + domEvents.emit(actionIcon, 'click', {bubbles: true}); + td.verify(mockFoundation.handleActionIconClick(td.matchers.isA(Event)), {times: 0}); +}); + +test('#destroy deregisters keydown handler on the root element', () => { + const {component, mockFoundation, root} = setupTestWithMocks(); + component.open(); + component.destroy(); + domEvents.emit(root, 'keydown'); + td.verify(mockFoundation.handleKeyDown(td.matchers.isA(Event)), {times: 0}); +}); + +test('clicking on surface does nothing', () => { + const {component, mockFoundation, surface} = setupTestWithMocks(); + component.open(); + domEvents.emit(surface, 'click', {bubbles: true}); + td.verify(mockFoundation.handleActionButtonClick(td.matchers.isA(Event)), {times: 0}); + td.verify(mockFoundation.handleActionIconClick(td.matchers.isA(Event)), {times: 0}); + td.verify(mockFoundation.close(td.matchers.anything()), {times: 0}); + component.destroy(); +}); + +test('#open forwards to MDCSnackbarFoundation#open', () => { + const {component, mockFoundation} = setupTestWithMocks(); + + component.open(); + td.verify(mockFoundation.open()); +}); + +test('#close forwards to MDCSnackbarFoundation#close', () => { + const {component, mockFoundation} = setupTestWithMocks(); + const reason = 'reason'; + + component.open(); + component.close(reason); + td.verify(mockFoundation.close(reason)); + + component.close(); + td.verify(mockFoundation.close('')); +}); + +test('get isOpen forwards to MDCSnackbarFoundation#isOpen', () => { + const {component, mockFoundation} = setupTestWithMocks(); + + component.isOpen; + td.verify(mockFoundation.isOpen()); +}); + +test('get closeOnEscape forwards to MDCSnackbarFoundation#getCloseOnEscape', () => { + const {component, mockFoundation} = setupTestWithMocks(); + + component.closeOnEscape; + td.verify(mockFoundation.getCloseOnEscape()); +}); + +test('set closeOnEscape forwards to MDCSnackbarFoundation#setCloseOnEscape', () => { + const {component, mockFoundation} = setupTestWithMocks(); + + component.closeOnEscape = false; + td.verify(mockFoundation.setCloseOnEscape(false)); + component.closeOnEscape = true; + td.verify(mockFoundation.setCloseOnEscape(false)); +}); + +test('get timeoutMs forwards to MDCSnackbarFoundation#getTimeoutMs', () => { + const {component, mockFoundation} = setupTestWithMocks(); + + component.timeoutMs; + td.verify(mockFoundation.getTimeoutMs()); +}); + +test('set timeoutMs forwards to MDCSnackbarFoundation#setTimeoutMs', () => { + const {component, mockFoundation} = setupTestWithMocks(); + + component.timeoutMs = numbers.MAX_AUTO_DISMISS_TIMEOUT_MS; + td.verify(mockFoundation.setTimeoutMs(numbers.MAX_AUTO_DISMISS_TIMEOUT_MS)); +}); + +test('get labelText returns label textContent', () => { + const {component, label} = setupTestWithMocks(); + + assert.equal(component.labelText, label.textContent); +}); + +test('set labelText forwards to MDCSnackbarFoundation#setActionButtonText', () => { + const {component} = setupTestWithMocks(); + + component.labelText = 'foo'; + assert.equal(component.labelText, 'foo'); +}); + +test('get actionButtonText returns button textContent', () => { + const {component, actionButton} = setupTestWithMocks(); + + assert.equal(component.actionButtonText, actionButton.textContent); +}); + +test('set actionButtonText forwards to MDCSnackbarFoundation#setActionButtonText', () => { + const {component} = setupTestWithMocks(); + + component.actionButtonText = 'foo'; + assert.equal(component.actionButtonText, 'foo'); +}); + +test('adapter#addClass adds a class to the root element', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.addClass('foo'); + assert.isTrue(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.isFalse(root.classList.contains('foo')); +}); + +test(`adapter#notifyOpening emits ${strings.OPENING_EVENT}`, () => { + const {component} = setupTest(); + + const handler = td.func('notifyOpeningHandler'); + + component.listen(strings.OPENING_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyOpening(); + component.unlisten(strings.OPENING_EVENT, handler); + + td.verify(handler(td.matchers.anything())); +}); + +test(`adapter#notifyOpened emits ${strings.OPENED_EVENT}`, () => { + const {component} = setupTest(); + + const handler = td.func('notifyOpenedHandler'); + + component.listen(strings.OPENED_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyOpened(); + component.unlisten(strings.OPENED_EVENT, handler); + + td.verify(handler(td.matchers.anything())); +}); + +test(`adapter#notifyClosing emits ${strings.CLOSING_EVENT} without action if passed action is empty string`, () => { + const {component} = setupTest(); + + const handler = td.func('notifyClosingHandler'); + + component.listen(strings.CLOSING_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyClosing(''); + component.unlisten(strings.CLOSING_EVENT, handler); + + td.verify(handler(td.matchers.contains({detail: {}}))); +}); + +test(`adapter#notifyClosing emits ${strings.CLOSING_EVENT} with reason`, () => { + const {component} = setupTest(); + const reason = 'reason'; + + const handler = td.func('notifyClosingHandler'); + + component.listen(strings.CLOSING_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyClosing(reason); + component.unlisten(strings.CLOSING_EVENT, handler); + + td.verify(handler(td.matchers.contains({detail: {reason}}))); +}); + +test(`adapter#notifyClosed emits ${strings.CLOSED_EVENT} without reason if passed reason is empty string`, () => { + const {component} = setupTest(); + + const handler = td.func('notifyClosedHandler'); + + component.listen(strings.CLOSED_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyClosed(''); + component.unlisten(strings.CLOSED_EVENT, handler); + + td.verify(handler(td.matchers.contains({detail: {}}))); +}); + +test(`adapter#notifyClosed emits ${strings.CLOSED_EVENT} with reason`, () => { + const {component} = setupTest(); + const reason = 'reason'; + + const handler = td.func('notifyClosedHandler'); + + component.listen(strings.CLOSED_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyClosed(reason); + component.unlisten(strings.CLOSED_EVENT, handler); + + td.verify(handler(td.matchers.contains({detail: {reason}}))); +});