Skip to content

Commit

Permalink
update(autocomplete): provide proper accessibility
Browse files Browse the repository at this point in the history
* Removes the static messages, which should be detected by the screenreaders

* Introduces a Screenreader Announcer service (as in Material 2 - angular/components#238)

* Service can be used for other components as well (e.g Toast, Tooltip)

Fixes angular#9603.
  • Loading branch information
devversion committed Oct 3, 2016
1 parent 1b9245a commit f525dd4
Show file tree
Hide file tree
Showing 5 changed files with 276 additions and 20 deletions.
98 changes: 98 additions & 0 deletions src/components/autocomplete/autocomplete.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,104 @@ describe('<md-autocomplete>', function() {

});

describe('accessibility', function() {

var $mdLiveAnnouncer, $timeout, $mdConstant = null;
var liveEl, scope, element, ctrl = null;

var BASIC_TEMPLATE =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' md-min-length="0"' +
' placeholder="placeholder">' +
' <span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';

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();
Expand Down
54 changes: 41 additions & 13 deletions src/components/autocomplete/js/autocompleteController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +19,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
noBlur = false,
selectedItemWatchers = [],
hasFocus = false,
lastCount = 0,
fetchesInProgress = 0,
enableWrapScroll = null,
inputModelCtrl = null;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -481,15 +495,15 @@ 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;
event.stopPropagation();
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
Expand Down Expand Up @@ -806,22 +820,36 @@ 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);
});

}

/**
* Returns the ARIA message for how many results match the current query.
* @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.';
Expand Down Expand Up @@ -896,8 +924,8 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,

if ($scope.selectOnMatch) selectItemOnMatch();

updateMessages();
positionDropdown();
reportMessages(true, ReportType.Count);
}

/**
Expand Down
8 changes: 1 addition & 7 deletions src/components/autocomplete/js/autocompleteDirective.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,7 @@ function MdAutocomplete ($$mdSvgRegistry) {
</li>' + noItemsTemplate + '\
</ul>\
</md-virtual-repeat-container>\
</md-autocomplete-wrap>\
<aria-status\
class="md-visually-hidden"\
role="status"\
aria-live="assertive">\
<p ng-repeat="message in $mdAutocompleteCtrl.messages track by $index" ng-if="message">{{message}}</p>\
</aria-status>';
</md-autocomplete-wrap>';

function getItemTemplate() {
var templateTag = element.find('md-item-template').detach(),
Expand Down
88 changes: 88 additions & 0 deletions src/core/services/liveAnnouncer/live-announcer.js
Original file line number Diff line number Diff line change
@@ -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.
*
* <hljs lang="js">
* module.controller('AppCtrl', function($mdLiveAnnouncer) {
* // Basic announcement (Polite Mode)
* $mdLiveAnnouncer.announce('Hey Google');
*
* // Custom announcement (Assertive Mode)
* $mdLiveAnnouncer.announce('Hey Google', 'assertive');
* });
* </hljs>
*
*/
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;
};
48 changes: 48 additions & 0 deletions src/core/services/liveAnnouncer/live-announcer.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});

});

0 comments on commit f525dd4

Please sign in to comment.