From e75ccf49f6a3a69d11977c14c6022f88ff119fc5 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Mon, 12 Jun 2017 12:51:23 -0400 Subject: [PATCH 1/8] feat(snackbar): Enable dark theme support --- demos/snackbar.html | 66 +++++++++++++++---------- packages/mdc-snackbar/_variables.scss | 1 + packages/mdc-snackbar/mdc-snackbar.scss | 15 ++++++ 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/demos/snackbar.html b/demos/snackbar.html index 3d0185f96f7..69a7bd59436 100644 --- a/demos/snackbar.html +++ b/demos/snackbar.html @@ -29,6 +29,14 @@ /* initialize it off screen. */ .loading .example .mdc-snackbar { transform: translateY(100%); } + .mdc-theme--dark { + background-color: #333; + } + + .mdc-theme--dark .hero { + background-color: #2d2d2d; + } + /* Override style for hero example. */ .hero .mdc-snackbar { position: relative; @@ -88,33 +96,26 @@

Basic Example

- -
-
-
- -
+
+ +
+
+ +
+ d="M1.73,12.91 8.1,19.28 22.79,4.59"/>
+
-
-
+
- +
Basic Example
+
-
- - + +
+ +
+ +
+
-
- - +
+ +
+
- - + +
Basic Example var multilineInput = document.getElementById('multiline'); var actionOnBottomInput = document.getElementById('action-on-bottom'); var dismissOnActionInput = document.getElementById('dismiss-on-action'); + var textFields = document.querySelectorAll('.mdc-textfield'); var show = function(sb) { snackbar.dismissesOnAction = dismissOnActionInput.checked; @@ -201,6 +209,14 @@

Basic Example

show(rtlSnackbar); }); + 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/_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/mdc-snackbar.scss b/packages/mdc-snackbar/mdc-snackbar.scss index b7ccf0ff3b2..5e02fd8da71 100644 --- a/packages/mdc-snackbar/mdc-snackbar.scss +++ b/packages/mdc-snackbar/mdc-snackbar.scss @@ -33,6 +33,11 @@ transform: translate(0, 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; @@ -84,6 +89,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 +109,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; From f47ac75e0d780faee7c1d569b42cf8d708348fc4 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Mon, 12 Jun 2017 14:23:17 -0400 Subject: [PATCH 2/8] docs(demos): Ensure Action on Bottom depends on Multi Line for its disabled state in Snackbar demo --- demos/snackbar.html | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/demos/snackbar.html b/demos/snackbar.html index 69a7bd59436..cdbebcbdaaf 100644 --- a/demos/snackbar.html +++ b/demos/snackbar.html @@ -97,9 +97,9 @@

Basic Example


- +
-
+
@@ -182,9 +182,16 @@

Basic Example

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 = { @@ -201,6 +208,17 @@

Basic Example

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); }); From c69ab1263a0c48eb5c2a6d9e9cdaf630eeb42fe2 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Mon, 12 Jun 2017 14:55:06 -0400 Subject: [PATCH 3/8] docs(snackbar): Add example of how to respond to Snackbar action --- packages/mdc-snackbar/README.md | 35 ++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/mdc-snackbar/README.md b/packages/mdc-snackbar/README.md index 1cc9b36cb20..fb0dff702be 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 @@ -109,6 +109,7 @@ import {MDCSnackbar} from 'mdc-snackbar'; const snackbar = new MDCSnackbar(document.querySelector('.mdc-snackbar')); ``` + ### Showing a message and action Once you have obtained an MDCSnackbar instance attached to the DOM, you can use @@ -125,6 +126,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. From 63a3915a26706400c96e5f874699e290538c6962 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Tue, 13 Jun 2017 13:49:42 -0400 Subject: [PATCH 4/8] feat(snackbar): Add start-aligned snackbar support --- demos/snackbar.html | 48 ++++++++++++++++++++++--- packages/mdc-snackbar/README.md | 17 +++++++++ packages/mdc-snackbar/mdc-snackbar.scss | 32 ++++++++++++++--- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/demos/snackbar.html b/demos/snackbar.html index cdbebcbdaaf..e632e522fb1 100644 --- a/demos/snackbar.html +++ b/demos/snackbar.html @@ -27,7 +27,7 @@ @@ -143,8 +146,10 @@

Basic Example


- - + + + +
Basic Example
+ +
+ +
@@ -178,6 +205,8 @@

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'); @@ -197,14 +226,17 @@

Basic Example

var data = { message: messageInput.value, actionOnBottom: actionOnBottomInput.checked, - multiline: multilineInput.checked + multiline: multilineInput.checked, + timeout: 3000 }; + if (actionInput.value) { data.actionText = actionInput.value; data.actionHandler = function() { console.log(data); } } + sb.show(data); }; @@ -227,6 +259,14 @@

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'); }); diff --git a/packages/mdc-snackbar/README.md b/packages/mdc-snackbar/README.md index fb0dff702be..c0df3abae64 100644 --- a/packages/mdc-snackbar/README.md +++ b/packages/mdc-snackbar/README.md @@ -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 diff --git a/packages/mdc-snackbar/mdc-snackbar.scss b/packages/mdc-snackbar/mdc-snackbar.scss index 5e02fd8da71..9a2034094c5 100644 --- a/packages/mdc-snackbar/mdc-snackbar.scss +++ b/packages/mdc-snackbar/mdc-snackbar.scss @@ -30,7 +30,7 @@ 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; @@ -44,22 +44,46 @@ @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); + } } } From c72990a8388426738430984dc66adaccc643c90e Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Wed, 14 Jun 2017 14:04:06 -0400 Subject: [PATCH 5/8] fix(snackbar): Remove unneccessary clearing of text which caused shifted button text in rtl --- packages/mdc-snackbar/foundation.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/mdc-snackbar/foundation.js b/packages/mdc-snackbar/foundation.js index 2f4f48f2bf5..afdc9f0120a 100644 --- a/packages/mdc-snackbar/foundation.js +++ b/packages/mdc-snackbar/foundation.js @@ -145,8 +145,6 @@ export default class MDCSnackbarFoundation extends MDCFoundation { 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_(); From 0947a021479457ea00f2d83decd82fc24d9b6bdb Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Wed, 14 Jun 2017 16:04:50 -0400 Subject: [PATCH 6/8] feat(snackbar): Implement accessibility focus logic --- packages/mdc-snackbar/foundation.js | 101 +++++++++++++++++++++------- packages/mdc-snackbar/index.js | 8 +++ 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/packages/mdc-snackbar/foundation.js b/packages/mdc-snackbar/foundation.js index afdc9f0120a..beb8e6cf995 100644 --- a/packages/mdc-snackbar/foundation.js +++ b/packages/mdc-snackbar/foundation.js @@ -36,6 +36,10 @@ export default class MDCSnackbarFoundation extends MDCFoundation { setActionText: (/* actionText: string */) => {}, setActionAriaHidden: () => {}, unsetActionAriaHidden: () => {}, + registerFocusHandler: () => {}, + deregisterFocusHandler: () => {}, + registerBlurHandler: () => {}, + deregisterBlurHandler: () => {}, registerActionClickHandler: (/* handler: EventListener */) => {}, deregisterActionClickHandler: (/* handler: EventListener */) => {}, registerTransitionEndHandler: (/* handler: EventListener */) => {}, @@ -51,9 +55,37 @@ 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.snackbarHasFocus_ = false; + this.snackbarData_ = null; this.queue_ = []; - this.actionClickHandler_ = () => this.invokeAction_(); + + this.actionClickHandler_ = () => { + this.actionWasClicked_ = true; + this.invokeAction_(); + }; + this.focusHandler_ = () => { + if (this.firstFocus_) { + this.setFocusOnAction_(); + } + + this.firstFocus_ = false; + }; + this.blurHandler_ = () => { + this.snackbarHasFocus_ = false; + clearTimeout(this.timeoutId_); + this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || MESSAGE_TIMEOUT); + }; + this.visibilitychangeHandler_ = () => { + clearTimeout(this.timeoutId_); + this.snackbarHasFocus_ = true; + + if (!this.adapter_.visibilityIsHidden()) { + setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || MESSAGE_TIMEOUT); + } + }; } init() { @@ -64,6 +96,8 @@ export default class MDCSnackbarFoundation extends MDCFoundation { destroy() { this.adapter_.deregisterActionClickHandler(this.actionClickHandler_); + this.adapter_.deregisterFocusHandler(this.focusHandler_); + this.adapter_.deregisterBlurHandler(this.focusHandler_); } dismissesOnAction() { @@ -75,37 +109,43 @@ export default class MDCSnackbarFoundation extends MDCFoundation { } show(data) { - if (!data) { + this.snackbarData_ = data; + this.firstFocus_ = true; + this.adapter_.registerVisbilityChangeHandler(this.visibilitychangeHandler_); + this.adapter_.registerFocusHandler(this.focusHandler_); + this.adapter_.registerBlurHandler(this.blurHandler_); + + 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); + this.queue_.push(this.snackbarData_); return; } 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 +157,12 @@ 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 || MESSAGE_TIMEOUT); + } + + setFocusOnAction_() { + this.adapter_.setFocus(); + this.snackbarHasFocus_ = true; } invokeAction_() { @@ -136,21 +181,25 @@ export default class MDCSnackbarFoundation extends MDCFoundation { } cleanup_() { - const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; - - this.adapter_.removeClass(ACTIVE); - - const handler = () => { - this.adapter_.deregisterTransitionEndHandler(handler); - this.adapter_.removeClass(MULTILINE); - this.adapter_.removeClass(ACTION_ON_BOTTOM); - this.setActionHidden_(true); - this.adapter_.setAriaHidden(); - this.active_ = false; - this.showNext_(); - }; + if (!this.snackbarHasFocus_ || this.actionWasClicked_) { + const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; + + this.adapter_.removeClass(ACTIVE); + + const handler = () => { + 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; + clearTimeout(this.timeoutId_); + this.showNext_(); + }; - this.adapter_.registerTransitionEndHandler(handler); + this.adapter_.registerTransitionEndHandler(handler); + } } showNext_() { diff --git a/packages/mdc-snackbar/index.js b/packages/mdc-snackbar/index.js index 6121f08cf59..6f872a4849d 100644 --- a/packages/mdc-snackbar/index.js +++ b/packages/mdc-snackbar/index.js @@ -47,6 +47,14 @@ 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, + registerFocusHandler: (handler) => document.body.addEventListener('focus', handler, true), + deregisterFocusHandler: (handler) => document.body.addEventListener('focus', handler, true), + registerBlurHandler: (handler) => getActionButton().addEventListener('blur', handler, true), + deregisterBlurHandler: (handler) => getActionButton().addEventListener('blur', handler, true), + registerVisbilityChangeHandler: (handler) => document.addEventListener('visibilitychange', handler), + deregisterVisbilityChangeHandler: (handler) => document.addEventListener('visibilitychange', handler), registerActionClickHandler: (handler) => getActionButton().addEventListener('click', handler), deregisterActionClickHandler: (handler) => getActionButton().removeEventListener('click', handler), registerTransitionEndHandler: From 0f3a0599edef170afa0a6ab3fa7555af301d5cca Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Tue, 20 Jun 2017 14:00:02 -0400 Subject: [PATCH 7/8] WIP --- demos/snackbar.html | 2 +- packages/mdc-snackbar/README.md | 7 +- packages/mdc-snackbar/foundation.js | 74 ++++++++++++------- packages/mdc-snackbar/index.js | 12 +-- test/unit/mdc-snackbar/foundation.test.js | 16 ++-- test/unit/mdc-snackbar/mdc-snackbar.test.js | 81 ++++++++++++++++++++- 6 files changed, 150 insertions(+), 42 deletions(-) diff --git a/demos/snackbar.html b/demos/snackbar.html index e632e522fb1..d1e8c0b673a 100644 --- a/demos/snackbar.html +++ b/demos/snackbar.html @@ -227,7 +227,7 @@

Basic Example

message: messageInput.value, actionOnBottom: actionOnBottomInput.checked, multiline: multilineInput.checked, - timeout: 3000 + timeout: 2750 }; if (actionInput.value) { diff --git a/packages/mdc-snackbar/README.md b/packages/mdc-snackbar/README.md index c0df3abae64..4c0bc7f3bc0 100644 --- a/packages/mdc-snackbar/README.md +++ b/packages/mdc-snackbar/README.md @@ -126,7 +126,6 @@ import {MDCSnackbar} from 'mdc-snackbar'; const snackbar = new MDCSnackbar(document.querySelector('.mdc-snackbar')); ``` - ### Showing a message and action Once you have obtained an MDCSnackbar instance attached to the DOM, you can use @@ -203,6 +202,12 @@ The adapter for snackbars must provide the following functions, with correct sig | `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. | +| `registerFocusHandler(handler: EventListener) => void` | Registers an event handler to be called when a `focus` event is triggered on the `body` | +| `deregisterFocusHandler(handler: EventListener) => void` | Deregisters a `focus` event handler from the `body` | +| `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 | | `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/foundation.js b/packages/mdc-snackbar/foundation.js index beb8e6cf995..f5beaf93ddf 100644 --- a/packages/mdc-snackbar/foundation.js +++ b/packages/mdc-snackbar/foundation.js @@ -36,10 +36,13 @@ export default class MDCSnackbarFoundation extends MDCFoundation { setActionText: (/* actionText: string */) => {}, setActionAriaHidden: () => {}, unsetActionAriaHidden: () => {}, - registerFocusHandler: () => {}, - deregisterFocusHandler: () => {}, - registerBlurHandler: () => {}, - deregisterBlurHandler: () => {}, + visibilityIsHidden: () => /* boolean */ false, + registerBlurHandler: (/* handler: EventListener */) => {}, + deregisterBlurHandler: (/* 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 */) => {}, @@ -58,26 +61,14 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.actionWasClicked_ = false; this.dismissOnAction_ = true; this.firstFocus_ = true; + this.pointerDownRecognized_ = false; this.snackbarHasFocus_ = false; this.snackbarData_ = null; this.queue_ = []; - this.actionClickHandler_ = () => { this.actionWasClicked_ = true; this.invokeAction_(); }; - this.focusHandler_ = () => { - if (this.firstFocus_) { - this.setFocusOnAction_(); - } - - this.firstFocus_ = false; - }; - this.blurHandler_ = () => { - this.snackbarHasFocus_ = false; - clearTimeout(this.timeoutId_); - this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || MESSAGE_TIMEOUT); - }; this.visibilitychangeHandler_ = () => { clearTimeout(this.timeoutId_); this.snackbarHasFocus_ = true; @@ -86,6 +77,21 @@ export default class MDCSnackbarFoundation extends MDCFoundation { setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || 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 || MESSAGE_TIMEOUT); + }; } init() { @@ -96,8 +102,11 @@ export default class MDCSnackbarFoundation extends MDCFoundation { destroy() { this.adapter_.deregisterActionClickHandler(this.actionClickHandler_); - this.adapter_.deregisterFocusHandler(this.focusHandler_); this.adapter_.deregisterBlurHandler(this.focusHandler_); + this.adapter_.deregisterVisibilityChangeHandler(this.visibilitychangeHandler_); + ['touchstart', 'mousedown', 'focus'].forEach((evtType) => { + this.adapter_.deregisterCapturedInteractionHandler(evtType, this.interactionHandler_); + }); } dismissesOnAction() { @@ -109,11 +118,14 @@ export default class MDCSnackbarFoundation extends MDCFoundation { } show(data) { + clearTimeout(this.timeoutId_); this.snackbarData_ = data; this.firstFocus_ = true; - this.adapter_.registerVisbilityChangeHandler(this.visibilitychangeHandler_); - this.adapter_.registerFocusHandler(this.focusHandler_); + this.adapter_.registerVisibilityChangeHandler(this.visibilitychangeHandler_); this.adapter_.registerBlurHandler(this.blurHandler_); + ['touchstart', 'mousedown', 'focus'].forEach((evtType) => { + this.adapter_.registerCapturedInteractionHandler(evtType, this.interactionHandler_); + }); if (!this.snackbarData_) { throw new Error( @@ -125,10 +137,8 @@ export default class MDCSnackbarFoundation extends MDCFoundation { if (this.snackbarData_.actionHandler && !this.snackbarData_.actionText) { throw new Error('Please provide action text with the handler.'); } - if (this.active) { this.queue_.push(this.snackbarData_); - return; } const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; @@ -160,9 +170,21 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || 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_() { @@ -174,19 +196,21 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.actionHandler_(); } finally { if (this.dismissOnAction_) { - clearTimeout(this.timeoutId_); this.cleanup_(); } } } cleanup_() { - if (!this.snackbarHasFocus_ || this.actionWasClicked_) { + const allowDismissal = !this.snackbarHasFocus_ || this.actionWasClicked_; + + if (allowDismissal) { const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; this.adapter_.removeClass(ACTIVE); const handler = () => { + clearTimeout(this.timeoutId_); this.adapter_.deregisterTransitionEndHandler(handler); this.adapter_.removeClass(MULTILINE); this.adapter_.removeClass(ACTION_ON_BOTTOM); @@ -194,7 +218,6 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.adapter_.setAriaHidden(); this.active_ = false; this.snackbarHasFocus_ = false; - clearTimeout(this.timeoutId_); this.showNext_(); }; @@ -206,7 +229,6 @@ export default class MDCSnackbarFoundation extends MDCFoundation { 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 6f872a4849d..9e19ac5c748 100644 --- a/packages/mdc-snackbar/index.js +++ b/packages/mdc-snackbar/index.js @@ -49,12 +49,14 @@ export class MDCSnackbar extends MDCComponent { setMessageText: (text) => { getText().textContent = text; }, setFocus: () => getActionButton().focus(), visibilityIsHidden: () => document.hidden, - registerFocusHandler: (handler) => document.body.addEventListener('focus', handler, true), - deregisterFocusHandler: (handler) => document.body.addEventListener('focus', handler, true), registerBlurHandler: (handler) => getActionButton().addEventListener('blur', handler, true), - deregisterBlurHandler: (handler) => getActionButton().addEventListener('blur', handler, true), - registerVisbilityChangeHandler: (handler) => document.addEventListener('visibilitychange', handler), - deregisterVisbilityChangeHandler: (handler) => document.addEventListener('visibilitychange', handler), + deregisterBlurHandler: (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/test/unit/mdc-snackbar/foundation.test.js b/test/unit/mdc-snackbar/foundation.test.js index b98acf9cec6..14e61f29e3c 100644 --- a/test/unit/mdc-snackbar/foundation.test.js +++ b/test/unit/mdc-snackbar/foundation.test.js @@ -44,9 +44,11 @@ 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', + 'setActionText', 'setActionAriaHidden', 'unsetActionAriaHidden', 'visibilityIsHidden', + 'registerBlurHandler', 'deregisterBlurHandler', 'registerVisibilityChangeHandler', + 'deregisterVisibilityChangeHandler', 'registerCapturedInteractionHandler', + 'deregisterCapturedInteractionHandler', 'registerActionClickHandler', + 'deregisterActionClickHandler', 'registerTransitionEndHandler', 'deregisterTransitionEndHandler', ]); // Test default methods methods.forEach((m) => assert.doesNotThrow(defaultAdapter[m])); @@ -216,7 +218,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 +227,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 +306,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)); diff --git a/test/unit/mdc-snackbar/mdc-snackbar.test.js b/test/unit/mdc-snackbar/mdc-snackbar.test.js index 4cf5978f9b3..58297208994 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,84 @@ test('foundationAdapter#unsetActionAriaHidden removes "aria-hidden" from the act assert.isNotOk(actionButton.getAttribute('aria-hidden')); }); +// TODO: return to this +test('adapter#setFocus sets focus on the action button', () => { + const {actionButton, component} = setupTest(); + component.getDefaultFoundation().adapter_.setFocus(); + assert.equal(document.activeElement, actionButton); +}); + +// TODO: return to this +test.only('adapter#visibilityIsHidden returns the document.hidden property', () => { + const {component} = setupTest(); + assert.isTrue(component.getDefaultFoundation().adapter_.visibilityIsHidden()); +}); + +test.only('adapter#registerBlurHandler adds a handler to be called on a blur event', () => { + const {actionButton, component} = setupTest(); + const handler = td.func('blurHandler'); + + component.getDefaultFoundation().adapter_.registerBlurHandler(handler); + domEvents.emit(actionButton, 'blur'); + + td.verify(handler(td.matchers.anything())); +}); + +test.only('adapter#deregisterBlurHandler 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_.deregisterBlurHandler(handler); + domEvents.emit(actionButton, 'blur'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test.only('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.only('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.only('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.only('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'); From e563eca10b76f518fe86763bf2e5fcd999aad785 Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Thu, 22 Jun 2017 16:46:52 -0400 Subject: [PATCH 8/8] WIP --- package.json | 2 +- packages/mdc-snackbar/README.md | 10 +- packages/mdc-snackbar/foundation.js | 20 +-- packages/mdc-snackbar/index.js | 4 +- test/unit/mdc-snackbar/foundation.test.js | 155 +++++++++++++++++++- test/unit/mdc-snackbar/mdc-snackbar.test.js | 30 ++-- 6 files changed, 186 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 3681ad0d663..af38aaf38d6 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "stylelint-order": "^0.4.3", "stylelint-scss": "^1.4.1", "stylelint-selector-bem-pattern": "^1.0.0", - "testdouble": "^3.0.0", + "testdouble": "3.0.0", "to-slug-case": "^1.0.0", "validate-commit-msg": "^2.6.1", "webpack": "^2.2.1", diff --git a/packages/mdc-snackbar/README.md b/packages/mdc-snackbar/README.md index 4c0bc7f3bc0..96589ae83bd 100644 --- a/packages/mdc-snackbar/README.md +++ b/packages/mdc-snackbar/README.md @@ -198,16 +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. | -| `registerFocusHandler(handler: EventListener) => void` | Registers an event handler to be called when a `focus` event is triggered on the `body` | -| `deregisterFocusHandler(handler: EventListener) => void` | Deregisters a `focus` event handler from the `body` | +| `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/foundation.js b/packages/mdc-snackbar/foundation.js index f5beaf93ddf..3b02f8a8f9e 100644 --- a/packages/mdc-snackbar/foundation.js +++ b/packages/mdc-snackbar/foundation.js @@ -32,13 +32,14 @@ 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, - registerBlurHandler: (/* handler: EventListener */) => {}, - deregisterBlurHandler: (/* handler: EventListener */) => {}, + registerCapturedBlurHandler: (/* handler: EventListener */) => {}, + deregisterCapturedBlurHandler: (/* handler: EventListener */) => {}, registerVisibilityChangeHandler: (/* handler: EventListener */) => {}, deregisterVisibilityChangeHandler: (/* handler: EventListener */) => {}, registerCapturedInteractionHandler: (/* evtType: string, handler: EventListener */) => {}, @@ -74,7 +75,7 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.snackbarHasFocus_ = true; if (!this.adapter_.visibilityIsHidden()) { - setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || MESSAGE_TIMEOUT); + setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || numbers.MESSAGE_TIMEOUT); } }; this.interactionHandler_ = (evt) => { @@ -90,7 +91,7 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.blurHandler_ = () => { clearTimeout(this.timeoutId_); this.snackbarHasFocus_ = false; - this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || MESSAGE_TIMEOUT); + this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || numbers.MESSAGE_TIMEOUT); }; } @@ -102,7 +103,7 @@ export default class MDCSnackbarFoundation extends MDCFoundation { destroy() { this.adapter_.deregisterActionClickHandler(this.actionClickHandler_); - this.adapter_.deregisterBlurHandler(this.focusHandler_); + this.adapter_.deregisterCapturedBlurHandler(this.blurHandler_); this.adapter_.deregisterVisibilityChangeHandler(this.visibilitychangeHandler_); ['touchstart', 'mousedown', 'focus'].forEach((evtType) => { this.adapter_.deregisterCapturedInteractionHandler(evtType, this.interactionHandler_); @@ -122,7 +123,7 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.snackbarData_ = data; this.firstFocus_ = true; this.adapter_.registerVisibilityChangeHandler(this.visibilitychangeHandler_); - this.adapter_.registerBlurHandler(this.blurHandler_); + this.adapter_.registerCapturedBlurHandler(this.blurHandler_); ['touchstart', 'mousedown', 'focus'].forEach((evtType) => { this.adapter_.registerCapturedInteractionHandler(evtType, this.interactionHandler_); }); @@ -142,7 +143,6 @@ export default class MDCSnackbarFoundation extends MDCFoundation { } const {ACTIVE, MULTILINE, ACTION_ON_BOTTOM} = cssClasses; - const {MESSAGE_TIMEOUT} = numbers; this.adapter_.setMessageText(this.snackbarData_.message); @@ -167,7 +167,7 @@ export default class MDCSnackbarFoundation extends MDCFoundation { this.adapter_.addClass(ACTIVE); this.adapter_.unsetAriaHidden(); - this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || MESSAGE_TIMEOUT); + this.timeoutId_ = setTimeout(this.cleanup_.bind(this), this.snackbarData_.timeout || numbers.MESSAGE_TIMEOUT); } handlePossibleTabKeyboardFocus_() { diff --git a/packages/mdc-snackbar/index.js b/packages/mdc-snackbar/index.js index 9e19ac5c748..7fe7d3da567 100644 --- a/packages/mdc-snackbar/index.js +++ b/packages/mdc-snackbar/index.js @@ -49,8 +49,8 @@ export class MDCSnackbar extends MDCComponent { setMessageText: (text) => { getText().textContent = text; }, setFocus: () => getActionButton().focus(), visibilityIsHidden: () => document.hidden, - registerBlurHandler: (handler) => getActionButton().addEventListener('blur', handler, true), - deregisterBlurHandler: (handler) => getActionButton().removeEventListener('blur', handler, true), + 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) => diff --git a/test/unit/mdc-snackbar/foundation.test.js b/test/unit/mdc-snackbar/foundation.test.js index 14e61f29e3c..51241b58e2d 100644 --- a/test/unit/mdc-snackbar/foundation.test.js +++ b/test/unit/mdc-snackbar/foundation.test.js @@ -43,18 +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', 'visibilityIsHidden', - 'registerBlurHandler', 'deregisterBlurHandler', 'registerVisibilityChangeHandler', + 'addClass', 'removeClass', 'setAriaHidden', 'unsetAriaHidden', 'setActionAriaHidden', + 'unsetActionAriaHidden', 'setActionText', 'setMessageText', 'setFocus', 'visibilityIsHidden', + 'registerCapturedBlurHandler', 'deregisterCapturedBlurHandler', 'registerVisibilityChangeHandler', 'deregisterVisibilityChangeHandler', 'registerCapturedInteractionHandler', 'deregisterCapturedInteractionHandler', 'registerActionClickHandler', - 'deregisterActionClickHandler', 'registerTransitionEndHandler', 'deregisterTransitionEndHandler', + '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; @@ -76,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(); @@ -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 58297208994..18054db97f1 100644 --- a/test/unit/mdc-snackbar/mdc-snackbar.test.js +++ b/test/unit/mdc-snackbar/mdc-snackbar.test.js @@ -118,41 +118,45 @@ test('foundationAdapter#unsetActionAriaHidden removes "aria-hidden" from the act assert.isNotOk(actionButton.getAttribute('aria-hidden')); }); -// TODO: return to this test('adapter#setFocus sets focus on the action button', () => { - const {actionButton, component} = setupTest(); + 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); }); -// TODO: return to this -test.only('adapter#visibilityIsHidden returns the document.hidden property', () => { +test('adapter#visibilityIsHidden returns the document.hidden property', () => { const {component} = setupTest(); - assert.isTrue(component.getDefaultFoundation().adapter_.visibilityIsHidden()); + assert.equal(component.getDefaultFoundation().adapter_.visibilityIsHidden(), document.hidden); }); -test.only('adapter#registerBlurHandler adds a handler to be called on a blur event', () => { +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_.registerBlurHandler(handler); + component.getDefaultFoundation().adapter_.registerCapturedBlurHandler(handler); domEvents.emit(actionButton, 'blur'); td.verify(handler(td.matchers.anything())); }); -test.only('adapter#deregisterBlurHandler removes a handler to be called on a blur event', () => { +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_.deregisterBlurHandler(handler); + component.getDefaultFoundation().adapter_.deregisterCapturedBlurHandler(handler); domEvents.emit(actionButton, 'blur'); td.verify(handler(td.matchers.anything()), {times: 0}); }); -test.only('adapter#registerVisibilityChangeHandler adds a handler to be called on a visibilitychange event', () => { +test('adapter#registerVisibilityChangeHandler adds a handler to be called on a visibilitychange event', () => { const {component} = setupTest(); const handler = td.func('visibilitychangeHandler'); @@ -162,7 +166,7 @@ test.only('adapter#registerVisibilityChangeHandler adds a handler to be called o td.verify(handler(td.matchers.anything())); }); -test.only('adapter#deregisterVisibilityChangeHandler removes a handler to be called on a visibilitychange event', () => { +test('adapter#deregisterVisibilityChangeHandler removes a handler to be called on a visibilitychange event', () => { const {component} = setupTest(); const handler = td.func('visibilitychangeHandler'); @@ -173,7 +177,7 @@ test.only('adapter#deregisterVisibilityChangeHandler removes a handler to be cal td.verify(handler(td.matchers.anything()), {times: 0}); }); -test.only('adapter#registerCapturedInteractionHandler adds a handler to be called when a given event occurs', () => { +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'; @@ -184,7 +188,7 @@ test.only('adapter#registerCapturedInteractionHandler adds a handler to be calle td.verify(handler(td.matchers.anything())); }); -test.only('adapter#deregisterCapturedInteractionHandler removes a handler to be called when a given event occurs', () => { +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';