From f525dd4783b1c8a180b48e6f64d8bf8ecce0840e Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Mon, 3 Oct 2016 21:39:16 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFupdate(autocomplete):=20provide=20prop?= =?UTF-8?q?er=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Removes the static messages, which should be detected by the screenreaders * Introduces a Screenreader Announcer service (as in Material 2 - https://github.com/angular/material2/pull/238) * Service can be used for other components as well (e.g Toast, Tooltip) Fixes #9603. --- .../autocomplete/autocomplete.spec.js | 98 +++++++++++++++++++ .../autocomplete/js/autocompleteController.js | 54 +++++++--- .../autocomplete/js/autocompleteDirective.js | 8 +- .../services/liveAnnouncer/live-announcer.js | 88 +++++++++++++++++ .../liveAnnouncer/live-announcer.spec.js | 48 +++++++++ 5 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 src/core/services/liveAnnouncer/live-announcer.js create mode 100644 src/core/services/liveAnnouncer/live-announcer.spec.js diff --git a/src/components/autocomplete/autocomplete.spec.js b/src/components/autocomplete/autocomplete.spec.js index acb140a3c50..1e14bded923 100644 --- a/src/components/autocomplete/autocomplete.spec.js +++ b/src/components/autocomplete/autocomplete.spec.js @@ -1379,6 +1379,104 @@ describe('', function() { }); + describe('accessibility', function() { + + var $mdLiveAnnouncer, $timeout, $mdConstant = null; + var liveEl, scope, element, ctrl = null; + + var BASIC_TEMPLATE = + '' + + ' {{item.display}}' + + ''; + + beforeEach(inject(function ($injector) { + $mdLiveAnnouncer = $injector.get('$mdLiveAnnouncer'); + $mdConstant = $injector.get('$mdConstant'); + $timeout = $injector.get('$timeout'); + + liveEl = $mdLiveAnnouncer._liveElement; + scope = createScope(); + element = compile(BASIC_TEMPLATE, scope); + ctrl = element.controller('mdAutocomplete'); + + // Flush the initial autocomplete timeout to gather the elements. + $timeout.flush(); + })); + + it('should announce count on dropdown open', function() { + + ctrl.focus(); + waitForVirtualRepeat(); + + expect(ctrl.hidden).toBe(false); + + expect(liveEl.textContent).toBe('There are 3 matches available.'); + }); + + it('should announce count and selection on dropdown open', function() { + + // Manually enable md-autoselect for the autocomplete. + ctrl.index = 0; + + ctrl.focus(); + waitForVirtualRepeat(); + + expect(ctrl.hidden).toBe(false); + + // Expect the announcement to contain the current selection in the dropdown. + expect(liveEl.textContent).toBe(scope.items[0].display + ' There are 3 matches available.'); + }); + + it('should announce the selection when using the arrow keys', function() { + + ctrl.focus(); + waitForVirtualRepeat(); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + + // Flush twice, because the display value will be resolved asynchronously and then the live-announcer will + // be triggered. + $timeout.flush(); + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(liveEl.textContent).toBe(scope.items[0].display); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + + // Flush twice, because the display value will be resolved asynchronously and then the live-announcer will + // be triggered. + $timeout.flush(); + $timeout.flush(); + + expect(ctrl.index).toBe(1); + expect(liveEl.textContent).toBe(scope.items[1].display); + }); + + it('should announce the count when matches change', function() { + + ctrl.focus(); + waitForVirtualRepeat(); + + expect(ctrl.hidden).toBe(false); + expect(liveEl.textContent).toBe('There are 3 matches available.'); + + scope.$apply('searchText = "fo"'); + $timeout.flush(); + + expect(liveEl.textContent).toBe('There is 1 match available.'); + }); + + }); + describe('API access', function() { it('clears the selected item', inject(function($timeout) { var scope = createScope(); diff --git a/src/components/autocomplete/js/autocompleteController.js b/src/components/autocomplete/js/autocompleteController.js index 4e7fae97c2c..67a0616adea 100644 --- a/src/components/autocomplete/js/autocompleteController.js +++ b/src/components/autocomplete/js/autocompleteController.js @@ -8,7 +8,7 @@ var ITEM_HEIGHT = 48, INPUT_PADDING = 2; // Padding provided by `md-input-container` function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, $window, - $animate, $rootElement, $attrs, $q, $log) { + $animate, $rootElement, $attrs, $q, $log, $mdLiveAnnouncer) { // Internal Variables. var ctrl = this, @@ -19,7 +19,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, noBlur = false, selectedItemWatchers = [], hasFocus = false, - lastCount = 0, fetchesInProgress = 0, enableWrapScroll = null, inputModelCtrl = null; @@ -35,7 +34,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, ctrl.loading = false; ctrl.hidden = true; ctrl.index = null; - ctrl.messages = []; ctrl.id = $mdUtil.nextUid(); ctrl.isDisabled = null; ctrl.isRequired = null; @@ -58,6 +56,15 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, ctrl.loadingIsVisible = loadingIsVisible; ctrl.positionDropdown = positionDropdown; + /** + * Report types to be used for the $mdLiveAnnouncer + * @enum {number} Unique flag id. + */ + var ReportType = { + Count: 1, + Selected: 2 + }; + return init(); //-- initialization methods @@ -268,6 +275,10 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, if (!hidden && oldHidden) { positionDropdown(); + // Report in polite mode, because the screenreader should finish the default description of + // the input. element. + reportMessages(true, ReportType.Count | ReportType.Selected); + if (elements) { $mdUtil.disableScrollAround(elements.ul); enableWrapScroll = disableElementScrollEvents(angular.element(elements.wrap)); @@ -414,14 +425,17 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, if (searchText !== val) { $scope.selectedItem = null; + // trigger change event if available if (searchText !== previousSearchText) announceTextChange(); // cancel results if search text is not long enough if (!isMinLengthMet()) { ctrl.matches = []; + setLoading(false); - updateMessages(); + reportMessages(false, ReportType.Count); + } else { handleQuery(); } @@ -481,7 +495,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, event.preventDefault(); ctrl.index = Math.min(ctrl.index + 1, ctrl.matches.length - 1); updateScroll(); - updateMessages(); + reportMessages(false, ReportType.Selected); break; case $mdConstant.KEY_CODE.UP_ARROW: if (ctrl.loading) return; @@ -489,7 +503,7 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, event.preventDefault(); ctrl.index = ctrl.index < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1); updateScroll(); - updateMessages(); + reportMessages(false, ReportType.Selected); break; case $mdConstant.KEY_CODE.TAB: // If we hit tab, assume that we've left the list so it will close @@ -806,13 +820,29 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, } } + /** - * Updates the ARIA messages + * Reports given message types to supported screenreaders. + * @param {boolean} isPolite Whether the announcement should be polite. + * @param {!number} types Message flags to be reported to the screenreader. */ - function updateMessages () { - getCurrentDisplayValue().then(function (msg) { - ctrl.messages = [ getCountMessage(), msg ]; + function reportMessages(isPolite, types) { + + var politeness = isPolite ? 'polite' : 'assertive'; + var messages = []; + + if (types & ReportType.Selected && ctrl.index !== -1) { + messages.push(getCurrentDisplayValue()); + } + + if (types & ReportType.Count) { + messages.push($q.resolve(getCountMessage())); + } + + $q.all(messages).then(function(data) { + $mdLiveAnnouncer.announce(data.join(' '), politeness); }); + } /** @@ -820,8 +850,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, * @returns {*} */ function getCountMessage () { - if (lastCount === ctrl.matches.length) return ''; - lastCount = ctrl.matches.length; switch (ctrl.matches.length) { case 0: return 'There are no matches available.'; @@ -896,8 +924,8 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, if ($scope.selectOnMatch) selectItemOnMatch(); - updateMessages(); positionDropdown(); + reportMessages(true, ReportType.Count); } /** diff --git a/src/components/autocomplete/js/autocompleteDirective.js b/src/components/autocomplete/js/autocompleteDirective.js index 5a087e53337..ab33a7b8a13 100644 --- a/src/components/autocomplete/js/autocompleteDirective.js +++ b/src/components/autocomplete/js/autocompleteDirective.js @@ -264,13 +264,7 @@ function MdAutocomplete ($$mdSvgRegistry) { ' + noItemsTemplate + '\ \ \ - \ - \ -

{{message}}

\ - '; + '; function getItemTemplate() { var templateTag = element.find('md-item-template').detach(), diff --git a/src/core/services/liveAnnouncer/live-announcer.js b/src/core/services/liveAnnouncer/live-announcer.js new file mode 100644 index 00000000000..9510cbd566b --- /dev/null +++ b/src/core/services/liveAnnouncer/live-announcer.js @@ -0,0 +1,88 @@ +/** + * @ngdoc module + * @name material.core.liveannouncer + * @description + * Angular Material Live Announcer to provide accessibility for Voice Readers. + */ +angular + .module('material.core') + .service('$mdLiveAnnouncer', MdLiveAnnouncer); + +/** + * @ngdoc service + * @name $mdLiveAnnouncer + * @module material.core.liveannouncer + * + * @description + * + * Service to announce messages to supported screenreaders. + * + * > The `$mdLiveAnnouncer` service is internally used for components to provide proper accessibility. + * + * + * module.controller('AppCtrl', function($mdLiveAnnouncer) { + * // Basic announcement (Polite Mode) + * $mdLiveAnnouncer.announce('Hey Google'); + * + * // Custom announcement (Assertive Mode) + * $mdLiveAnnouncer.announce('Hey Google', 'assertive'); + * }); + * + * + */ +function MdLiveAnnouncer($timeout) { + /** @private @const @type {!angular.$timeout} */ + this._$timeout = $timeout; + + /** @private @const @type {!HTMLElement} */ + this._liveElement = this._createLiveElement(); + + /** @private @const @type {!number} */ + this._announceTimeout = 100; +} + +/** + * @ngdoc method + * @name $mdLiveAnnouncer#announce + * @description Announces messages to supported screenreaders. + * @param {string} message Message to be announced to the screenreader + * @param {'off'|'polite'|'assertive'} politeness The politeness of the announcer element. + */ +MdLiveAnnouncer.prototype.announce = function(message, politeness) { + if (!politeness) { + politeness = 'polite'; + } + + var self = this; + + self._liveElement.textContent = ''; + self._liveElement.setAttribute('aria-live', politeness); + + // This 100ms timeout is necessary for some browser + screen-reader combinations: + // - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout. + // - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a + // second time without clearing and then using a non-zero delay. + // (using JAWS 17 at time of this writing). + self._$timeout(function() { + self._liveElement.textContent = message; + }, self._announceTimeout, false); +}; + +/** + * Creates a live announcer element, which listens for DOM changes and announces them + * to the screenreaders. + * @returns {!HTMLElement} + * @private + */ +MdLiveAnnouncer.prototype._createLiveElement = function() { + var liveEl = document.createElement('div'); + + liveEl.classList.add('md-visually-hidden'); + liveEl.setAttribute('role', 'status'); + liveEl.setAttribute('aria-atomic', 'true'); + liveEl.setAttribute('aria-live', 'polite'); + + document.body.appendChild(liveEl); + + return liveEl; +}; diff --git a/src/core/services/liveAnnouncer/live-announcer.spec.js b/src/core/services/liveAnnouncer/live-announcer.spec.js new file mode 100644 index 00000000000..c07539b4932 --- /dev/null +++ b/src/core/services/liveAnnouncer/live-announcer.spec.js @@ -0,0 +1,48 @@ +describe('$mdLiveAnnouncer', function() { + + var $mdLiveAnnouncer, $timeout = null; + var liveEl = null; + + beforeEach(module('material.core')); + + beforeEach(inject(function ($injector) { + $mdLiveAnnouncer = $injector.get('$mdLiveAnnouncer'); + $timeout = $injector.get('$timeout'); + + liveEl = $mdLiveAnnouncer._liveElement; + })); + + it('should correctly update the announce text', function() { + $mdLiveAnnouncer.announce('Hey Google'); + + expect(liveEl.textContent).toBe(''); + + $timeout.flush(); + + expect(liveEl.textContent).toBe('Hey Google'); + }); + + it('should correctly update the politeness attribute', function() { + $mdLiveAnnouncer.announce('Hey Google', 'assertive'); + + $timeout.flush(); + + expect(liveEl.textContent).toBe('Hey Google'); + expect(liveEl.getAttribute('aria-live')).toBe('assertive'); + }); + + it('should apply the aria-live value polite by default', function() { + $mdLiveAnnouncer.announce('Hey Google'); + + $timeout.flush(); + + expect(liveEl.textContent).toBe('Hey Google'); + expect(liveEl.getAttribute('aria-live')).toBe('polite'); + }); + + it('should have proper aria attributes to be detected', function() { + expect(liveEl.getAttribute('aria-atomic')).toBe('true'); + expect(liveEl.getAttribute('role')).toBe('status'); + }); + +});