diff --git a/demos/snackbar.html b/demos/snackbar.html index 3d0185f96f7..d1e8c0b673a 100644 --- a/demos/snackbar.html +++ b/demos/snackbar.html @@ -27,7 +27,15 @@ @@ -88,33 +99,26 @@

Basic Example

+
-
-
-
- -
+
+
+ +
+ d="M1.73,12.91 8.1,19.28 22.79,4.59"/>
+
-
-
+
- +
Basic Example
+
-
- - + +
+ +
+ +
+
-
- - +
+ +
+
- - + + + +
Basic Example
+ +
+ +
@@ -171,28 +205,52 @@

Basic Example

var MDCSnackbar = global.mdc.snackbar.MDCSnackbar; var snackbar = new MDCSnackbar(document.getElementById('mdc-js-snackbar')); var rtlSnackbar = new MDCSnackbar(document.getElementById('mdc-rtl-js-snackbar')); + var alignStartSnackbar = new MDCSnackbar(document.getElementById('mdc-align-start-js-snackbar')); + var alignStartRTLSnackbar = new MDCSnackbar(document.getElementById('mdc-align-start-rtl-js-snackbar')); var messageInput = document.getElementById('message'); var actionInput = document.getElementById('action'); var multilineInput = document.getElementById('multiline'); var actionOnBottomInput = document.getElementById('action-on-bottom'); + var actionOnBottomCheckbox = document.getElementById('action-on-bottom-checkbox'); var dismissOnActionInput = document.getElementById('dismiss-on-action'); + var textFields = document.querySelectorAll('.mdc-textfield'); + + // Since Action on Bottom cannot be checked if Multi-line Input + // is not, we start with a disabled Action on Bottom option + actionOnBottomCheckbox.classList.add('mdc-checkbox--disabled'); + actionOnBottomInput.disabled = true; + actionOnBottomInput.checked = false; var show = function(sb) { snackbar.dismissesOnAction = dismissOnActionInput.checked; var data = { message: messageInput.value, actionOnBottom: actionOnBottomInput.checked, - multiline: multilineInput.checked + multiline: multilineInput.checked, + timeout: 2750 }; + if (actionInput.value) { data.actionText = actionInput.value; data.actionHandler = function() { console.log(data); } } + sb.show(data); }; + multilineInput.addEventListener('click', function () { + if (!multilineInput.checked) { + actionOnBottomCheckbox.classList.add('mdc-checkbox--disabled'); + actionOnBottomInput.disabled = true; + actionOnBottomInput.checked = false; + } else { + actionOnBottomCheckbox.classList.remove('mdc-checkbox--disabled'); + actionOnBottomInput.disabled = false; + } + }); + document.getElementById('show-snackbar').addEventListener('click', function() { show(snackbar); }); @@ -201,6 +259,22 @@

Basic Example

show(rtlSnackbar); }); + document.getElementById('show-start-aligned-snackbar').addEventListener('click', function() { + show(alignStartSnackbar); + }); + + document.getElementById('show-start-aligned-rtl-snackbar').addEventListener('click', function() { + show(alignStartRTLSnackbar); + }); + + document.getElementById('toggle-dark-theme').addEventListener('click', function(evt) { + document.body.classList.contains('mdc-theme--dark') ? document.body.classList.remove('mdc-theme--dark') : document.body.classList.add('mdc-theme--dark'); + }); + + textFields.forEach(function(tf) { + mdc.textfield.MDCTextfield.attachTo(tf); + }) + // Remove any element hiding after loading. window.onload = function() { document.body.className = ''; }; })(this); diff --git a/packages/mdc-snackbar/README.md b/packages/mdc-snackbar/README.md index 1cc9b36cb20..96589ae83bd 100644 --- a/packages/mdc-snackbar/README.md +++ b/packages/mdc-snackbar/README.md @@ -18,7 +18,7 @@ path: /catalog/snackbars/ The MDC Snackbar component is a spec-aligned snackbar/toast component adhering to the [Material Design snackbars & toasts requirements](https://material.io/guidelines/components/snackbars-toasts.html#snackbars-toasts-specs). -It requires JavaScript the trigger the display and hide of the snackbar. +It requires JavaScript to show and hide itself. ## Design & API Documentation @@ -53,6 +53,23 @@ npm install --save @material/snackbar
``` +### Start Aligned Snackbars (tablet and desktop only) + +MDC Snackbar can be start aligned (including in RTL contexts). To create a start-aligned +snackbar, add the `mdc-snackbar--align-start` modifier class to the root element. + +```html + +``` + ### Using the JS Component MDC Snackbar ships with a Component / Foundation combo which provides the API for showing snackbar @@ -125,6 +142,38 @@ properties and their usage. | multiline | Whether to show the snackbar with space for multiple lines of text | Optional | Boolean | | actionOnBottom | Whether to show the action below the multiple lines of text | Optional, applies when multiline is true | Boolean | +### Responding to a Snackbar Action + +To respond to a snackbar action, assign a function to the optional `actionHandler` property in the object that gets passed to the `show` method. If you choose to set this property, you *must _also_* set the `actionText` property. + +```html + +``` + +```js +import {MDCSnackbar} from 'mdc-snackbar'; + +const snackbar = new MDCSnackbar(document.querySelector('.mdc-snackbar')); +const dataObj = { + message: messageInput.value, + actionText: 'Undo', + actionHandler: function () { + console.log('my cool function'); + } +}; + +snackbar.show(dataObj); +``` + + ### Keep snackbar when the action button is pressed By default the snackbar will be dimissed when the user presses the action button. @@ -149,10 +198,18 @@ The adapter for snackbars must provide the following functions, with correct sig | `removeClass(className: string) => void` | Removes a class from the root element. | | `setAriaHidden() => void` | Sets `aria-hidden="true"` on the root element. | | `unsetAriaHidden() => void` | Removes the `aria-hidden` attribute from the root element. | -| `setMessageText(message: string) => void` | Set the text content of the message element. | -| `setActionText(actionText: string) => void` | Set the text content of the action element. | | `setActionAriaHidden() => void` | Sets `aria-hidden="true"` on the action element. | | `unsetActionAriaHidden() => void` | Removes the `aria-hidden` attribute from the action element. | +| `setActionText(actionText: string) => void` | Set the text content of the action element. | +| `setMessageText(message: string) => void` | Set the text content of the message element. | +| `setFocus() => void` | Sets focus on the action button. | +| `visibilityIsHidden() => boolean` | Returns document.hidden property. | +| `registerBlurHandler(handler: EventListener) => void` | Registers an event handler to be called when a `blur` event is triggered on the action button | +| `deregisterBlurHandler(handler: EventListener) => void` | Deregisters a `blur` event handler from the actionButton | +| `registerVisibilityChangeHandler(handler: EventListener) => void` | Registers an event handler to be called when a 'visibilitychange' event occurs | +| `deregisterVisibilityChangeHandler(handler: EventListener) => void` | Deregisters an event handler to be called when a 'visibilitychange' event occurs | +| `registerCapturedInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event handler to be called when the given event type is triggered on the `body` | +| `deregisterCapturedInteractionHandler(evtType: string, handler: EventListener) => void` | Deregisters an event handler from the `body` | | `registerActionClickHandler(handler: EventListener) => void` | Registers an event handler to be called when a `click` event is triggered on the action element. | | `deregisterActionClickHandler(handler: EventListener) => void` | Deregisters an event handler from a `click` event on the action element. This will only be called with handlers that have previously been passed to `registerActionClickHandler` calls. | | `registerTransitionEndHandler(handler: EventListener) => void` | Registers an event handler to be called when an `transitionend` event is triggered on the root element. Note that you must account for vendor prefixes in order for this to work correctly. | diff --git a/packages/mdc-snackbar/_variables.scss b/packages/mdc-snackbar/_variables.scss index 86e25edab04..163c7396f36 100644 --- a/packages/mdc-snackbar/_variables.scss +++ b/packages/mdc-snackbar/_variables.scss @@ -16,6 +16,7 @@ // Hard coded since the color is not present in any palette. $mdc-snackbar-background-color: #323232; +$mdc-snackbar-background-color-on-dark: #fafafa; $mdc-snackbar-foreground-color: white; // TODO: Better spot to pull this breakpoint? //$snackbar-tablet-breakpoint: $grid-tablet-breakpoint; diff --git a/packages/mdc-snackbar/foundation.js b/packages/mdc-snackbar/foundation.js index 2f4f48f2bf5..3b02f8a8f9e 100644 --- a/packages/mdc-snackbar/foundation.js +++ b/packages/mdc-snackbar/foundation.js @@ -32,10 +32,18 @@ export default class MDCSnackbarFoundation extends MDCFoundation { removeClass: (/* className: string */) => {}, setAriaHidden: () => {}, unsetAriaHidden: () => {}, - setMessageText: (/* message: string */) => {}, - setActionText: (/* actionText: string */) => {}, setActionAriaHidden: () => {}, unsetActionAriaHidden: () => {}, + setActionText: (/* actionText: string */) => {}, + setMessageText: (/* message: string */) => {}, + setFocus: () => {}, + visibilityIsHidden: () => /* boolean */ false, + registerCapturedBlurHandler: (/* handler: EventListener */) => {}, + deregisterCapturedBlurHandler: (/* handler: EventListener */) => {}, + registerVisibilityChangeHandler: (/* handler: EventListener */) => {}, + deregisterVisibilityChangeHandler: (/* handler: EventListener */) => {}, + registerCapturedInteractionHandler: (/* evtType: string, handler: EventListener */) => {}, + deregisterCapturedInteractionHandler: (/* evtType: string, handler: EventListener */) => {}, registerActionClickHandler: (/* handler: EventListener */) => {}, deregisterActionClickHandler: (/* handler: EventListener */) => {}, registerTransitionEndHandler: (/* handler: EventListener */) => {}, @@ -51,9 +59,40 @@ export default class MDCSnackbarFoundation extends MDCFoundation { super(Object.assign(MDCSnackbarFoundation.defaultAdapter, adapter)); this.active_ = false; + this.actionWasClicked_ = false; this.dismissOnAction_ = true; + this.firstFocus_ = true; + this.pointerDownRecognized_ = false; + this.snackbarHasFocus_ = false; + this.snackbarData_ = null; this.queue_ = []; - this.actionClickHandler_ = () => this.invokeAction_(); + this.actionClickHandler_ = () => { + this.actionWasClicked_ = true; + this.invokeAction_(); + }; + this.visibilitychangeHandler_ = () => { + clearTimeout(this.timeoutId_); + this.snackbarHasFocus_ = true; + + if (!this.adapter_.visibilityIsHidden()) { + setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || numbers.MESSAGE_TIMEOUT); + } + }; + this.interactionHandler_ = (evt) => { + if (evt.type == 'touchstart' || evt.type == 'mousedown') { + this.pointerDownRecognized_ = true; + } + this.handlePossibleTabKeyboardFocus_(evt); + + if (evt.type == 'focus') { + this.pointerDownRecognized_ = false; + } + }; + this.blurHandler_ = () => { + clearTimeout(this.timeoutId_); + this.snackbarHasFocus_ = false; + this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || numbers.MESSAGE_TIMEOUT); + }; } init() { @@ -64,6 +103,11 @@ export default class MDCSnackbarFoundation extends MDCFoundation { destroy() { this.adapter_.deregisterActionClickHandler(this.actionClickHandler_); + this.adapter_.deregisterCapturedBlurHandler(this.blurHandler_); + this.adapter_.deregisterVisibilityChangeHandler(this.visibilitychangeHandler_); + ['touchstart', 'mousedown', 'focus'].forEach((evtType) => { + this.adapter_.deregisterCapturedInteractionHandler(evtType, this.interactionHandler_); + }); } dismissesOnAction() { @@ -75,37 +119,43 @@ export default class MDCSnackbarFoundation extends MDCFoundation { } show(data) { - if (!data) { + clearTimeout(this.timeoutId_); + this.snackbarData_ = data; + this.firstFocus_ = true; + this.adapter_.registerVisibilityChangeHandler(this.visibilitychangeHandler_); + this.adapter_.registerCapturedBlurHandler(this.blurHandler_); + ['touchstart', 'mousedown', 'focus'].forEach((evtType) => { + this.adapter_.registerCapturedInteractionHandler(evtType, this.interactionHandler_); + }); + + if (!this.snackbarData_) { throw new Error( 'Please provide a data object with at least a message to display.'); } - if (!data.message) { + if (!this.snackbarData_.message) { throw new Error('Please provide a message to be displayed.'); } - if (data.actionHandler && !data.actionText) { + if (this.snackbarData_.actionHandler && !this.snackbarData_.actionText) { throw new Error('Please provide action text with the handler.'); } - if (this.active) { - this.queue_.push(data); - return; + this.queue_.push(this.snackbarData_); } const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; - const {MESSAGE_TIMEOUT} = numbers; - this.adapter_.setMessageText(data.message); + this.adapter_.setMessageText(this.snackbarData_.message); - if (data.multiline) { + if (this.snackbarData_.multiline) { this.adapter_.addClass(MULTILINE); - if (data.actionOnBottom) { + if (this.snackbarData_.actionOnBottom) { this.adapter_.addClass(ACTION_ON_BOTTOM); } } - if (data.actionHandler) { - this.adapter_.setActionText(data.actionText); - this.actionHandler_ = data.actionHandler; + if (this.snackbarData_.actionHandler) { + this.adapter_.setActionText(this.snackbarData_.actionText); + this.actionHandler_ = this.snackbarData_.actionHandler; this.setActionHidden_(false); } else { this.setActionHidden_(true); @@ -117,7 +167,24 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.adapter_.addClass(ACTIVE); this.adapter_.unsetAriaHidden(); - this.timeoutId_ = setTimeout(this.cleanup_.bind(this), data.timeout || MESSAGE_TIMEOUT); + this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || numbers.MESSAGE_TIMEOUT); + } + + handlePossibleTabKeyboardFocus_() { + const hijackFocus = + this.firstFocus_ && !this.pointerDownRecognized_; + + if (hijackFocus) { + this.setFocusOnAction_(); + } + + this.firstFocus_ = false; + } + + setFocusOnAction_() { + this.adapter_.setFocus(); + this.snackbarHasFocus_ = true; + this.firstFocus_ = false; } invokeAction_() { @@ -129,37 +196,39 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.actionHandler_(); } finally { if (this.dismissOnAction_) { - clearTimeout(this.timeoutId_); this.cleanup_(); } } } cleanup_() { - const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; + const allowDismissal = !this.snackbarHasFocus_ || this.actionWasClicked_; - this.adapter_.removeClass(ACTIVE); + if (allowDismissal) { + const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; - const handler = () => { - this.adapter_.deregisterTransitionEndHandler(handler); - this.adapter_.removeClass(MULTILINE); - this.adapter_.removeClass(ACTION_ON_BOTTOM); - this.setActionHidden_(true); - this.adapter_.setMessageText(null); - this.adapter_.setActionText(null); - this.adapter_.setAriaHidden(); - this.active_ = false; - this.showNext_(); - }; + this.adapter_.removeClass(ACTIVE); - this.adapter_.registerTransitionEndHandler(handler); + const handler = () => { + clearTimeout(this.timeoutId_); + this.adapter_.deregisterTransitionEndHandler(handler); + this.adapter_.removeClass(MULTILINE); + this.adapter_.removeClass(ACTION_ON_BOTTOM); + this.setActionHidden_(true); + this.adapter_.setAriaHidden(); + this.active_ = false; + this.snackbarHasFocus_ = false; + this.showNext_(); + }; + + this.adapter_.registerTransitionEndHandler(handler); + } } showNext_() { if (!this.queue_.length) { return; } - this.show(this.queue_.shift()); } diff --git a/packages/mdc-snackbar/index.js b/packages/mdc-snackbar/index.js index 6121f08cf59..7fe7d3da567 100644 --- a/packages/mdc-snackbar/index.js +++ b/packages/mdc-snackbar/index.js @@ -47,6 +47,16 @@ export class MDCSnackbar extends MDCComponent { unsetActionAriaHidden: () => getActionButton().removeAttribute('aria-hidden'), setActionText: (text) => { getActionButton().textContent = text; }, setMessageText: (text) => { getText().textContent = text; }, + setFocus: () => getActionButton().focus(), + visibilityIsHidden: () => document.hidden, + registerCapturedBlurHandler: (handler) => getActionButton().addEventListener('blur', handler, true), + deregisterCapturedBlurHandler: (handler) => getActionButton().removeEventListener('blur', handler, true), + registerVisibilityChangeHandler: (handler) => document.addEventListener('visibilitychange', handler), + deregisterVisibilityChangeHandler: (handler) => document.removeEventListener('visibilitychange', handler), + registerCapturedInteractionHandler: (evt, handler) => + document.body.addEventListener(evt, handler, true), + deregisterCapturedInteractionHandler: (evt, handler) => + document.body.removeEventListener(evt, handler, true), registerActionClickHandler: (handler) => getActionButton().addEventListener('click', handler), deregisterActionClickHandler: (handler) => getActionButton().removeEventListener('click', handler), registerTransitionEndHandler: diff --git a/packages/mdc-snackbar/mdc-snackbar.scss b/packages/mdc-snackbar/mdc-snackbar.scss index b7ccf0ff3b2..9a2034094c5 100644 --- a/packages/mdc-snackbar/mdc-snackbar.scss +++ b/packages/mdc-snackbar/mdc-snackbar.scss @@ -30,31 +30,60 @@ justify-content: flex-start; padding-right: 24px; padding-left: 24px; - transform: translate(0, 100%); + transform: translate(-50%, 100%); transition: mdc-animation-exit(transform, .25s); background-color: $mdc-snackbar-background-color; + + @include mdc-theme-dark(".mdc-snackbar") { + background-color: $mdc-snackbar-background-color-on-dark; + } + will-change: transform; pointer-events: none; @media (max-width: ($mdc-snackbar-tablet-breakpoint - 1)) { left: 0; width: calc(100% - 48px); + transform: translate(0, 100%); } @media (min-width: $mdc-snackbar-tablet-breakpoint) { min-width: 288px; max-width: 568px; - transform: translate(-50%, 100%); border-radius: 2px; } + &--align-start { + @media (min-width: $mdc-snackbar-tablet-breakpoint) { + bottom: 24px; + + @include mdc-rtl-reflexive-position(left, 24px); + + transform: translate(0, 200%); + } + + @media (max-width: ($mdc-snackbar-tablet-breakpoint - 1)) { + bottom: 0; + left: 0; + width: calc(100% - 48px); + transform: translate(0, 100%); + } + } + &--active { - transform: translate(0, 0); + transform: translate(0); pointer-events: auto; transition: mdc-animation-enter(transform, .25s); - @media (min-width: $mdc-snackbar-tablet-breakpoint) { + &:not(.mdc-snackbar--align-start) { transform: translate(-50%, 0); + + @media (max-width: ($mdc-snackbar-tablet-breakpoint - 1)) { + bottom: 0; + left: 0; + width: calc(100% - 48px); + transform: translate(0); + } } } @@ -84,6 +113,11 @@ height: 48px; transition: mdc-animation-exit(opacity, .3s); color: $mdc-snackbar-foreground-color; + + @include mdc-theme-dark(".mdc-snackbar") { + @include mdc-theme-prop(color, text-primary-on-light); + } + opacity: 0; } @@ -99,6 +133,11 @@ &__action-button { @include mdc-theme-prop(color, accent); + + @include mdc-theme-dark(".mdc-snackbar") { + @include mdc-theme-prop(color, primary); + } + @include mdc-rtl-reflexive-box(margin, right, -16px, ".mdc-snackbar"); min-width: auto; diff --git a/test/unit/mdc-snackbar/foundation.test.js b/test/unit/mdc-snackbar/foundation.test.js index b98acf9cec6..51241b58e2d 100644 --- a/test/unit/mdc-snackbar/foundation.test.js +++ b/test/unit/mdc-snackbar/foundation.test.js @@ -43,16 +43,19 @@ test('defaultAdapter returns a complete adapter implementation', () => { assert.equal(methods.length, Object.keys(defaultAdapter).length, 'Every adapter key must be a function'); assert.deepEqual(methods, [ - 'addClass', 'removeClass', 'setAriaHidden', 'unsetAriaHidden', 'setMessageText', - 'setActionText', 'setActionAriaHidden', 'unsetActionAriaHidden', - 'registerActionClickHandler', 'deregisterActionClickHandler', - 'registerTransitionEndHandler', 'deregisterTransitionEndHandler', + 'addClass', 'removeClass', 'setAriaHidden', 'unsetAriaHidden', 'setActionAriaHidden', + 'unsetActionAriaHidden', 'setActionText', 'setMessageText', 'setFocus', 'visibilityIsHidden', + 'registerCapturedBlurHandler', 'deregisterCapturedBlurHandler', 'registerVisibilityChangeHandler', + 'deregisterVisibilityChangeHandler', 'registerCapturedInteractionHandler', + 'deregisterCapturedInteractionHandler', 'registerActionClickHandler', + 'deregisterActionClickHandler', 'registerTransitionEndHandler', + 'deregisterTransitionEndHandler', ]); // Test default methods methods.forEach((m) => assert.doesNotThrow(defaultAdapter[m])); }); -test('#init calls adapter.registerActionClickHandler() with a action click handler function', () => { +test('#init calls adapter.registerActionClickHandler() with an action click handler function', () => { const {foundation, mockAdapter} = setupTest(); const {isA} = td.matchers; @@ -74,6 +77,30 @@ test('#destroy calls adapter.deregisterActionClickHandler() with a registerActio td.verify(mockAdapter.deregisterActionClickHandler(changeHandler)); }); +test('#destroy calls adapter.deregisterVisibilityChangeHandler() with a function', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.destroy(); + td.verify(mockAdapter.deregisterVisibilityChangeHandler(isA(Function))); +}); + +test('#destroy calls adapter.deregisterCapturedBlurHandler() with a function', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.destroy(); + td.verify(mockAdapter.deregisterCapturedBlurHandler(isA(Function))); +}); + +test('#destroy calls adapter.deregisterCapturedInteractionHandler() with an event type and function 3 times', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.destroy(); + td.verify(mockAdapter.deregisterCapturedInteractionHandler(isA(String), isA(Function)), {times: 3}); +}); + test('#init calls adapter.setAriaHidden to ensure snackbar starts hidden', () => { const {foundation, mockAdapter} = setupTest(); @@ -216,7 +243,8 @@ test('#show while snackbar is already showing will queue the data object.', () = message: 'Message Archived', }); - td.verify(mockAdapter.setMessageText('Message Archived'), {times: 0}); + td.verify(mockAdapter.setMessageText('Message Deleted')); + td.verify(mockAdapter.setMessageText('Message Archived')); }); test('#show while snackbar is already showing will show after the timeout and transition end', () => { @@ -224,14 +252,13 @@ test('#show while snackbar is already showing will show after the timeout and tr const {foundation, mockAdapter} = setupTest(); const {isA} = td.matchers; - foundation.init(); - let transEndHandler; td.when(mockAdapter.registerTransitionEndHandler(isA(Function))) .thenDo((handler) => { transEndHandler = handler; }); + foundation.init(); foundation.show({ message: 'Message Deleted', }); @@ -304,8 +331,6 @@ test('#show will clean up snackbar after the timeout and transition end', () => clock.tick(numbers.MESSAGE_TIMEOUT); transEndHandler(); - td.verify(mockAdapter.setMessageText(null)); - td.verify(mockAdapter.setActionText(null)); td.verify(mockAdapter.removeClass(cssClasses.MULTILINE)); td.verify(mockAdapter.removeClass(cssClasses.ACTION_ON_BOTTOM)); td.verify(mockAdapter.deregisterTransitionEndHandler(transEndHandler)); @@ -313,6 +338,30 @@ test('#show will clean up snackbar after the timeout and transition end', () => clock.uninstall(); }); +test('#show calls adapter.registerVisibilityChangeHandler() with a function', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.show({message: 'foo'}); + td.verify(mockAdapter.registerVisibilityChangeHandler(isA(Function))); +}); + +test('#show calls adapter.registerCapturedBlurHandler() with a function', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.show({message: 'foo'}); + td.verify(mockAdapter.registerCapturedBlurHandler(isA(Function))); +}); + +test('#show calls adapter.registerCapturedInteractionHandler() with an event type and function 3 times', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.show({message: 'foo'}); + td.verify(mockAdapter.registerCapturedInteractionHandler(isA(String), isA(Function)), {times: 3}); +}); + test('snackbar is dismissed after action button is pressed', () => { const {foundation, mockAdapter} = setupTest(); const {isA} = td.matchers; @@ -363,3 +412,99 @@ test('snackbar is not dismissed after action button is pressed if setDismissOnAc td.verify(mockAdapter.removeClass(cssClasses.ACTIVE), {times: 0}); }); + +test('snackbar is not dismissed if action button gets focus', () => { + const {foundation, mockAdapter} = setupTest(); + const evtType = 'focus'; + const mockEvent = {type: 'focus'}; + let focusEvent; + + td.when(mockAdapter.registerCapturedInteractionHandler(evtType, td.matchers.isA(Function))) + .thenDo((evtType, handler) => { + focusEvent = handler; + }); + + foundation.init(); + foundation.show({message: 'foo'}); + focusEvent(mockEvent); + + foundation.show({message: 'foo'}); + + td.verify(mockAdapter.removeClass(cssClasses.ACTIVE), {times: 0}); +}); + +test('focus hijacks the snackbar timeout if no click or touchstart occurs', () => { + const {foundation, mockAdapter} = setupTest(); + const mockEvent = {type: 'focus'}; + let tabEvent; + + td.when(mockAdapter.registerCapturedInteractionHandler(mockEvent.type, td.matchers.isA(Function))) + .thenDo((evt, handler) => { + tabEvent = handler; + }); + + foundation.init(); + foundation.show({message: 'foo'}); + tabEvent(mockEvent); + + td.verify(mockAdapter.removeClass(cssClasses.ACTIVE), {times: 0}); +}); + +test('focus does not hijack the snackbar timeout if it occurs as a result' + + 'of a mousedown or touchstart', () => { + const clock = lolex.install(); + const {foundation, mockAdapter} = setupTest(); + const mockFocusEvent = {type: 'focus'}; + const mockMouseEvent = {type: 'mousedown'}; + let focusEvent; + let mouseEvent; + + td.when(mockAdapter.registerCapturedInteractionHandler(mockFocusEvent.type, td.matchers.isA(Function))) + .thenDo((evt, handler) => { + focusEvent = handler; + }); + td.when(mockAdapter.registerCapturedInteractionHandler(mockMouseEvent.type, td.matchers.isA(Function))) + .thenDo((evt, handler) => { + mouseEvent = handler; + }); + + foundation.init(); + foundation.show({message: 'foo'}); + mouseEvent(mockMouseEvent); + focusEvent(mockFocusEvent); + clock.tick(numbers.MESSAGE_TIMEOUT); + + td.verify(mockAdapter.removeClass(cssClasses.ACTIVE)); + clock.uninstall(); +}); + +test('blur resets the snackbar timeout', () => { + const clock = lolex.install(); + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + const mockBlurEvent = {type: 'blur'}; + const mockFocusEvent = {type: 'focus'}; + let focusEvent; + let blurEvent; + + td.when(mockAdapter.registerCapturedInteractionHandler(mockFocusEvent.type, td.matchers.isA(Function))) + .thenDo((evt, handler) => { + focusEvent = handler; + }); + td.when(mockAdapter.registerCapturedBlurHandler(isA(Function))) + .thenDo((handler) => { + blurEvent = handler; + }); + + foundation.init(); + foundation.show({message: 'foo'}); + focusEvent(mockFocusEvent); + // Sanity Check + td.verify(mockAdapter.removeClass(cssClasses.ACTIVE), {times: 0}); + + blurEvent(mockBlurEvent); + clock.tick(numbers.MESSAGE_TIMEOUT); + td.verify(mockAdapter.removeClass(cssClasses.ACTIVE)); + + clock.uninstall(); +}); diff --git a/test/unit/mdc-snackbar/mdc-snackbar.test.js b/test/unit/mdc-snackbar/mdc-snackbar.test.js index 4cf5978f9b3..18054db97f1 100644 --- a/test/unit/mdc-snackbar/mdc-snackbar.test.js +++ b/test/unit/mdc-snackbar/mdc-snackbar.test.js @@ -36,8 +36,9 @@ function getFixture() { function setupTest() { const root = getFixture(); + const actionButton = root.querySelector(strings.ACTION_BUTTON_SELECTOR); const component = new MDCSnackbar(root); - return {root, component}; + return {root, actionButton, component}; } suite('MDCSnackbar'); @@ -117,6 +118,88 @@ test('foundationAdapter#unsetActionAriaHidden removes "aria-hidden" from the act assert.isNotOk(actionButton.getAttribute('aria-hidden')); }); +test('adapter#setFocus sets focus on the action button', () => { + const {root, actionButton, component} = setupTest(); + const handler = td.func('fixture focus handler'); + root.addEventListener('focus', handler); + document.body.appendChild(root); + + component.getDefaultFoundation().adapter_.setFocus(); + + assert.equal(document.activeElement, actionButton); + document.body.removeChild(root); +}); + +test('adapter#visibilityIsHidden returns the document.hidden property', () => { + const {component} = setupTest(); + assert.equal(component.getDefaultFoundation().adapter_.visibilityIsHidden(), document.hidden); +}); + +test('adapter#registerCapturedBlurHandler adds a handler to be called on a blur event', () => { + const {actionButton, component} = setupTest(); + const handler = td.func('blurHandler'); + + component.getDefaultFoundation().adapter_.registerCapturedBlurHandler(handler); + domEvents.emit(actionButton, 'blur'); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterCapturedBlurHandler removes a handler to be called on a blur event', () => { + const {actionButton, component} = setupTest(); + const handler = td.func('blurHandler'); + + actionButton.addEventListener('blur', handler, true); + component.getDefaultFoundation().adapter_.deregisterCapturedBlurHandler(handler); + domEvents.emit(actionButton, 'blur'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#registerVisibilityChangeHandler adds a handler to be called on a visibilitychange event', () => { + const {component} = setupTest(); + const handler = td.func('visibilitychangeHandler'); + + component.getDefaultFoundation().adapter_.registerVisibilityChangeHandler(handler); + domEvents.emit(document, 'visibilitychange'); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterVisibilityChangeHandler removes a handler to be called on a visibilitychange event', () => { + const {component} = setupTest(); + const handler = td.func('visibilitychangeHandler'); + + document.addEventListener('visibilitychange', handler); + component.getDefaultFoundation().adapter_.deregisterVisibilityChangeHandler(handler); + domEvents.emit(document, 'visibilitychange'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#registerCapturedInteractionHandler adds a handler to be called when a given event occurs', () => { + const {component} = setupTest(); + const handler = td.func('interactionHandler'); + const mockEvent = 'click'; + + component.getDefaultFoundation().adapter_.registerCapturedInteractionHandler(mockEvent, handler); + domEvents.emit(document.body, mockEvent); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterCapturedInteractionHandler removes a handler to be called when a given event occurs', () => { + const {component} = setupTest(); + const handler = td.func('interactionHandler'); + const mockEvent = 'click'; + + document.body.addEventListener(mockEvent, handler, true); + component.getDefaultFoundation().adapter_.deregisterCapturedInteractionHandler(mockEvent, handler); + domEvents.emit(document.body, mockEvent); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + test('foundationAdapter#registerActionClickHandler adds the handler to be called when action is clicked', () => { const {root, component} = setupTest(); const handler = td.func('clickHandler');