diff --git a/packages/mdc-snackbar/adapter.js b/packages/mdc-snackbar/adapter.js index 4986747c6a4..3fa8e867d2f 100644 --- a/packages/mdc-snackbar/adapter.js +++ b/packages/mdc-snackbar/adapter.js @@ -45,6 +45,8 @@ class MDCSnackbarAdapter { /** @param {string} className */ removeClass(className) {} + announce() {} + notifyOpening() {} notifyOpened() {} diff --git a/packages/mdc-snackbar/constants.js b/packages/mdc-snackbar/constants.js index fa972df77bb..8a2fbf5df9a 100644 --- a/packages/mdc-snackbar/constants.js +++ b/packages/mdc-snackbar/constants.js @@ -32,21 +32,32 @@ const strings = { 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', + + ARIA_LIVE_LABEL_TEXT_ATTR: 'data-mdc-snackbar-label-text', }; 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, + + /** + * Number of milliseconds to wait between temporarily clearing the label text + * in the DOM and subsequently restoring it. This is necessary to force IE 11 + * to pick up the `aria-live` content change and announce it to the user. + */ + ARIA_LIVE_DELAY_MS: 1000, }; export {cssClasses, strings, numbers}; diff --git a/packages/mdc-snackbar/foundation.js b/packages/mdc-snackbar/foundation.js index 2dab917ccdc..7778d406146 100644 --- a/packages/mdc-snackbar/foundation.js +++ b/packages/mdc-snackbar/foundation.js @@ -50,6 +50,7 @@ class MDCSnackbarFoundation extends MDCFoundation { return /** @type {!MDCSnackbarAdapter} */ ({ addClass: (/* className: string */) => {}, removeClass: (/* className: string */) => {}, + announce: () => {}, notifyOpening: () => {}, notifyOpened: () => {}, notifyClosing: (/* reason: string */) => {}, @@ -99,6 +100,7 @@ class MDCSnackbarFoundation extends MDCFoundation { this.adapter_.notifyOpening(); this.adapter_.removeClass(CLOSING); this.adapter_.addClass(OPENING); + this.adapter_.announce(); // Wait a frame once display is no longer "none", to establish basis for animation this.runNextAnimationFrame_(() => { diff --git a/packages/mdc-snackbar/index.js b/packages/mdc-snackbar/index.js index 6b1eaae7547..72da5211c3c 100644 --- a/packages/mdc-snackbar/index.js +++ b/packages/mdc-snackbar/index.js @@ -24,6 +24,7 @@ import {MDCComponent} from '@material/base/index'; import MDCSnackbarFoundation from './foundation'; import {strings} from './constants'; +import * as util from './util'; import * as ponyfill from '@material/dom/ponyfill'; const { @@ -42,6 +43,15 @@ class MDCSnackbar extends MDCComponent { /** @type {!HTMLElement} */ this.surfaceEl_; + /** @type {!HTMLElement} */ + this.labelEl_; + + /** @type {!HTMLElement} */ + this.actionButtonEl_; + + /** @type {function(!HTMLElement, !HTMLElement=): void} */ + this.announce_; + /** @private {!Function} */ this.handleKeyDown_; @@ -49,8 +59,17 @@ class MDCSnackbar extends MDCComponent { this.handleSurfaceClick_; } + /** + * @param {function(): function(!HTMLElement, !HTMLElement=):void} announceFactory + */ + initialize(announceFactory = () => util.announce) { + this.announce_ = announceFactory(); + } + initialSyncWithDOM() { this.surfaceEl_ = /** @type {!HTMLElement} */ (this.root_.querySelector(SURFACE_SELECTOR)); + this.labelEl_ = /** @type {!HTMLElement} */ (this.root_.querySelector(LABEL_SELECTOR)); + this.actionButtonEl_ = /** @type {!HTMLElement} */ (this.root_.querySelector(ACTION_BUTTON_SELECTOR)); this.handleKeyDown_ = (evt) => this.foundation_.handleKeyDown(evt); this.handleSurfaceClick_ = (evt) => { @@ -92,6 +111,7 @@ class MDCSnackbar extends MDCComponent { return new MDCSnackbarFoundation({ addClass: (className) => this.root_.classList.add(className), removeClass: (className) => this.root_.classList.remove(className), + announce: () => this.announce_(this.labelEl_), notifyOpening: () => this.emit(OPENING_EVENT, {}), notifyOpened: () => this.emit(OPENED_EVENT, {}), notifyClosing: (reason) => this.emit(CLOSING_EVENT, reason ? {reason} : {}), @@ -138,28 +158,28 @@ class MDCSnackbar extends MDCComponent { * @return {string} */ get labelText() { - return this.root_.querySelector(LABEL_SELECTOR).textContent; + return this.labelEl_.textContent; } /** * @param {string} labelText */ set labelText(labelText) { - this.root_.querySelector(LABEL_SELECTOR).textContent = labelText; + this.labelEl_.textContent = labelText; } /** * @return {string} */ get actionButtonText() { - return this.root_.querySelector(ACTION_BUTTON_SELECTOR).textContent; + return this.actionButtonEl_.textContent; } /** * @param {string} actionButtonText */ set actionButtonText(actionButtonText) { - this.root_.querySelector(ACTION_BUTTON_SELECTOR).textContent = actionButtonText; + this.actionButtonEl_.textContent = actionButtonText; } /** diff --git a/packages/mdc-snackbar/util.js b/packages/mdc-snackbar/util.js new file mode 100644 index 00000000000..4551780b19e --- /dev/null +++ b/packages/mdc-snackbar/util.js @@ -0,0 +1,90 @@ +/** + * @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 {numbers, strings} from './constants'; + +const {ARIA_LIVE_DELAY_MS} = numbers; +const {ARIA_LIVE_LABEL_TEXT_ATTR} = strings; + +/** + * @param {!HTMLElement} ariaEl + * @param {!HTMLElement=} labelEl + */ +function announce(ariaEl, labelEl = ariaEl) { + const priority = ariaEl.getAttribute('aria-live'); + const labelText = labelEl.textContent.trim(); // Ignore ` ` (see below) + if (!labelText) { + return; + } + + // Temporarily disable `aria-live` to prevent JAWS+Firefox from announcing the message twice. + ariaEl.setAttribute('aria-live', 'off'); + + // Temporarily clear `textContent` to force a DOM mutation event that will be detected by screen readers. + // `aria-live` elements are only announced when the element's `textContent` *changes*, so snackbars + // sent to the browser in the initial HTML response won't be read unless we clear the element's `textContent` first. + // Similarly, displaying the same snackbar message twice in a row doesn't trigger a DOM mutation event, + // so screen readers won't announce the second message unless we first clear `textContent`. + // + // We have to clear the label text two different ways to make it work in all browsers and screen readers: + // + // 1. `textContent = ''` is required for IE11 + JAWS + // 2. `innerHTML = ' '` is required for Chrome + JAWS and NVDA + // + // All other browser/screen reader combinations support both methods. + // + // The wrapper `` visually hides the space character so that it doesn't cause jank when added/removed. + // N.B.: Setting `position: absolute`, `opacity: 0`, or `height: 0` prevents Chrome from detecting the DOM change. + // + // This technique has been tested in: + // + // * JAWS 2019: + // - Chrome 70 + // - Firefox 60 (ESR) + // - IE 11 + // * NVDA 2018: + // - Chrome 70 + // - Firefox 60 (ESR) + // - IE 11 + labelEl.textContent = ''; + labelEl.innerHTML = ' '; + + // Prevent visual jank by temporarily displaying the label text in the ::before pseudo-element. + // CSS generated content is normally announced by screen readers + // (except in IE 11; see https://tink.uk/accessibility-support-for-css-generated-content/); + // however, `aria-live` is turned off, so this DOM update will be ignored by screen readers. + labelEl.setAttribute(ARIA_LIVE_LABEL_TEXT_ATTR, labelText); + + setTimeout(() => { + // Allow screen readers to announce changes to the DOM again. + ariaEl.setAttribute('aria-live', priority); + + // Remove the message from the ::before pseudo-element. + labelEl.removeAttribute(ARIA_LIVE_LABEL_TEXT_ATTR); + + // Restore the original label text, which will be announced by screen readers. + labelEl.textContent = labelText; + }, ARIA_LIVE_DELAY_MS); +} + +export {announce}; diff --git a/test/screenshot/spec/mdc-snackbar/classes/baseline-with-action.html b/test/screenshot/spec/mdc-snackbar/classes/baseline-with-action.html index b39c765bd7d..6f95196bc65 100644 --- a/test/screenshot/spec/mdc-snackbar/classes/baseline-with-action.html +++ b/test/screenshot/spec/mdc-snackbar/classes/baseline-with-action.html @@ -47,12 +47,11 @@