Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.

Commit ff86bba

Browse files
devversionkara
authored andcommitted
fix(autocomplete): provide proper accessibility (#9744)
* 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 #9603.
1 parent 3387109 commit ff86bba

File tree

5 files changed

+276
-20
lines changed

5 files changed

+276
-20
lines changed

src/components/autocomplete/autocomplete.spec.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,104 @@ describe('<md-autocomplete>', function() {
13791379

13801380
});
13811381

1382+
describe('accessibility', function() {
1383+
1384+
var $mdLiveAnnouncer, $timeout, $mdConstant = null;
1385+
var liveEl, scope, element, ctrl = null;
1386+
1387+
var BASIC_TEMPLATE =
1388+
'<md-autocomplete' +
1389+
' md-selected-item="selectedItem"' +
1390+
' md-search-text="searchText"' +
1391+
' md-items="item in match(searchText)"' +
1392+
' md-item-text="item.display"' +
1393+
' md-min-length="0"' +
1394+
' placeholder="placeholder">' +
1395+
' <span md-highlight-text="searchText">{{item.display}}</span>' +
1396+
'</md-autocomplete>';
1397+
1398+
beforeEach(inject(function ($injector) {
1399+
$mdLiveAnnouncer = $injector.get('$mdLiveAnnouncer');
1400+
$mdConstant = $injector.get('$mdConstant');
1401+
$timeout = $injector.get('$timeout');
1402+
1403+
liveEl = $mdLiveAnnouncer._liveElement;
1404+
scope = createScope();
1405+
element = compile(BASIC_TEMPLATE, scope);
1406+
ctrl = element.controller('mdAutocomplete');
1407+
1408+
// Flush the initial autocomplete timeout to gather the elements.
1409+
$timeout.flush();
1410+
}));
1411+
1412+
it('should announce count on dropdown open', function() {
1413+
1414+
ctrl.focus();
1415+
waitForVirtualRepeat();
1416+
1417+
expect(ctrl.hidden).toBe(false);
1418+
1419+
expect(liveEl.textContent).toBe('There are 3 matches available.');
1420+
});
1421+
1422+
it('should announce count and selection on dropdown open', function() {
1423+
1424+
// Manually enable md-autoselect for the autocomplete.
1425+
ctrl.index = 0;
1426+
1427+
ctrl.focus();
1428+
waitForVirtualRepeat();
1429+
1430+
expect(ctrl.hidden).toBe(false);
1431+
1432+
// Expect the announcement to contain the current selection in the dropdown.
1433+
expect(liveEl.textContent).toBe(scope.items[0].display + ' There are 3 matches available.');
1434+
});
1435+
1436+
it('should announce the selection when using the arrow keys', function() {
1437+
1438+
ctrl.focus();
1439+
waitForVirtualRepeat();
1440+
1441+
expect(ctrl.hidden).toBe(false);
1442+
1443+
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
1444+
1445+
// Flush twice, because the display value will be resolved asynchronously and then the live-announcer will
1446+
// be triggered.
1447+
$timeout.flush();
1448+
$timeout.flush();
1449+
1450+
expect(ctrl.index).toBe(0);
1451+
expect(liveEl.textContent).toBe(scope.items[0].display);
1452+
1453+
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
1454+
1455+
// Flush twice, because the display value will be resolved asynchronously and then the live-announcer will
1456+
// be triggered.
1457+
$timeout.flush();
1458+
$timeout.flush();
1459+
1460+
expect(ctrl.index).toBe(1);
1461+
expect(liveEl.textContent).toBe(scope.items[1].display);
1462+
});
1463+
1464+
it('should announce the count when matches change', function() {
1465+
1466+
ctrl.focus();
1467+
waitForVirtualRepeat();
1468+
1469+
expect(ctrl.hidden).toBe(false);
1470+
expect(liveEl.textContent).toBe('There are 3 matches available.');
1471+
1472+
scope.$apply('searchText = "fo"');
1473+
$timeout.flush();
1474+
1475+
expect(liveEl.textContent).toBe('There is 1 match available.');
1476+
});
1477+
1478+
});
1479+
13821480
describe('API access', function() {
13831481
it('clears the selected item', inject(function($timeout) {
13841482
var scope = createScope();

src/components/autocomplete/js/autocompleteController.js

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ var ITEM_HEIGHT = 48,
88
INPUT_PADDING = 2; // Padding provided by `md-input-container`
99

1010
function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, $window,
11-
$animate, $rootElement, $attrs, $q, $log) {
11+
$animate, $rootElement, $attrs, $q, $log, $mdLiveAnnouncer) {
1212

1313
// Internal Variables.
1414
var ctrl = this,
@@ -19,7 +19,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
1919
noBlur = false,
2020
selectedItemWatchers = [],
2121
hasFocus = false,
22-
lastCount = 0,
2322
fetchesInProgress = 0,
2423
enableWrapScroll = null,
2524
inputModelCtrl = null;
@@ -35,7 +34,6 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
3534
ctrl.loading = false;
3635
ctrl.hidden = true;
3736
ctrl.index = null;
38-
ctrl.messages = [];
3937
ctrl.id = $mdUtil.nextUid();
4038
ctrl.isDisabled = null;
4139
ctrl.isRequired = null;
@@ -58,6 +56,15 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
5856
ctrl.loadingIsVisible = loadingIsVisible;
5957
ctrl.positionDropdown = positionDropdown;
6058

59+
/**
60+
* Report types to be used for the $mdLiveAnnouncer
61+
* @enum {number} Unique flag id.
62+
*/
63+
var ReportType = {
64+
Count: 1,
65+
Selected: 2
66+
};
67+
6168
return init();
6269

6370
//-- initialization methods
@@ -268,6 +275,10 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
268275
if (!hidden && oldHidden) {
269276
positionDropdown();
270277

278+
// Report in polite mode, because the screenreader should finish the default description of
279+
// the input. element.
280+
reportMessages(true, ReportType.Count | ReportType.Selected);
281+
271282
if (elements) {
272283
$mdUtil.disableScrollAround(elements.ul);
273284
enableWrapScroll = disableElementScrollEvents(angular.element(elements.wrap));
@@ -414,14 +425,17 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
414425
if (searchText !== val) {
415426
$scope.selectedItem = null;
416427

428+
417429
// trigger change event if available
418430
if (searchText !== previousSearchText) announceTextChange();
419431

420432
// cancel results if search text is not long enough
421433
if (!isMinLengthMet()) {
422434
ctrl.matches = [];
435+
423436
setLoading(false);
424-
updateMessages();
437+
reportMessages(false, ReportType.Count);
438+
425439
} else {
426440
handleQuery();
427441
}
@@ -481,15 +495,15 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
481495
event.preventDefault();
482496
ctrl.index = Math.min(ctrl.index + 1, ctrl.matches.length - 1);
483497
updateScroll();
484-
updateMessages();
498+
reportMessages(false, ReportType.Selected);
485499
break;
486500
case $mdConstant.KEY_CODE.UP_ARROW:
487501
if (ctrl.loading) return;
488502
event.stopPropagation();
489503
event.preventDefault();
490504
ctrl.index = ctrl.index < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1);
491505
updateScroll();
492-
updateMessages();
506+
reportMessages(false, ReportType.Selected);
493507
break;
494508
case $mdConstant.KEY_CODE.TAB:
495509
// If we hit tab, assume that we've left the list so it will close
@@ -806,22 +820,36 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
806820
}
807821
}
808822

823+
809824
/**
810-
* Updates the ARIA messages
825+
* Reports given message types to supported screenreaders.
826+
* @param {boolean} isPolite Whether the announcement should be polite.
827+
* @param {!number} types Message flags to be reported to the screenreader.
811828
*/
812-
function updateMessages () {
813-
getCurrentDisplayValue().then(function (msg) {
814-
ctrl.messages = [ getCountMessage(), msg ];
829+
function reportMessages(isPolite, types) {
830+
831+
var politeness = isPolite ? 'polite' : 'assertive';
832+
var messages = [];
833+
834+
if (types & ReportType.Selected && ctrl.index !== -1) {
835+
messages.push(getCurrentDisplayValue());
836+
}
837+
838+
if (types & ReportType.Count) {
839+
messages.push($q.resolve(getCountMessage()));
840+
}
841+
842+
$q.all(messages).then(function(data) {
843+
$mdLiveAnnouncer.announce(data.join(' '), politeness);
815844
});
845+
816846
}
817847

818848
/**
819849
* Returns the ARIA message for how many results match the current query.
820850
* @returns {*}
821851
*/
822852
function getCountMessage () {
823-
if (lastCount === ctrl.matches.length) return '';
824-
lastCount = ctrl.matches.length;
825853
switch (ctrl.matches.length) {
826854
case 0:
827855
return 'There are no matches available.';
@@ -896,8 +924,8 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming,
896924

897925
if ($scope.selectOnMatch) selectItemOnMatch();
898926

899-
updateMessages();
900927
positionDropdown();
928+
reportMessages(true, ReportType.Count);
901929
}
902930

903931
/**

src/components/autocomplete/js/autocompleteDirective.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,7 @@ function MdAutocomplete ($$mdSvgRegistry) {
264264
</li>' + noItemsTemplate + '\
265265
</ul>\
266266
</md-virtual-repeat-container>\
267-
</md-autocomplete-wrap>\
268-
<aria-status\
269-
class="md-visually-hidden"\
270-
role="status"\
271-
aria-live="assertive">\
272-
<p ng-repeat="message in $mdAutocompleteCtrl.messages track by $index" ng-if="message">{{message}}</p>\
273-
</aria-status>';
267+
</md-autocomplete-wrap>';
274268

275269
function getItemTemplate() {
276270
var templateTag = element.find('md-item-template').detach(),
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @ngdoc module
3+
* @name material.core.liveannouncer
4+
* @description
5+
* Angular Material Live Announcer to provide accessibility for Voice Readers.
6+
*/
7+
angular
8+
.module('material.core')
9+
.service('$mdLiveAnnouncer', MdLiveAnnouncer);
10+
11+
/**
12+
* @ngdoc service
13+
* @name $mdLiveAnnouncer
14+
* @module material.core.liveannouncer
15+
*
16+
* @description
17+
*
18+
* Service to announce messages to supported screenreaders.
19+
*
20+
* > The `$mdLiveAnnouncer` service is internally used for components to provide proper accessibility.
21+
*
22+
* <hljs lang="js">
23+
* module.controller('AppCtrl', function($mdLiveAnnouncer) {
24+
* // Basic announcement (Polite Mode)
25+
* $mdLiveAnnouncer.announce('Hey Google');
26+
*
27+
* // Custom announcement (Assertive Mode)
28+
* $mdLiveAnnouncer.announce('Hey Google', 'assertive');
29+
* });
30+
* </hljs>
31+
*
32+
*/
33+
function MdLiveAnnouncer($timeout) {
34+
/** @private @const @type {!angular.$timeout} */
35+
this._$timeout = $timeout;
36+
37+
/** @private @const @type {!HTMLElement} */
38+
this._liveElement = this._createLiveElement();
39+
40+
/** @private @const @type {!number} */
41+
this._announceTimeout = 100;
42+
}
43+
44+
/**
45+
* @ngdoc method
46+
* @name $mdLiveAnnouncer#announce
47+
* @description Announces messages to supported screenreaders.
48+
* @param {string} message Message to be announced to the screenreader
49+
* @param {'off'|'polite'|'assertive'} politeness The politeness of the announcer element.
50+
*/
51+
MdLiveAnnouncer.prototype.announce = function(message, politeness) {
52+
if (!politeness) {
53+
politeness = 'polite';
54+
}
55+
56+
var self = this;
57+
58+
self._liveElement.textContent = '';
59+
self._liveElement.setAttribute('aria-live', politeness);
60+
61+
// This 100ms timeout is necessary for some browser + screen-reader combinations:
62+
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
63+
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
64+
// second time without clearing and then using a non-zero delay.
65+
// (using JAWS 17 at time of this writing).
66+
self._$timeout(function() {
67+
self._liveElement.textContent = message;
68+
}, self._announceTimeout, false);
69+
};
70+
71+
/**
72+
* Creates a live announcer element, which listens for DOM changes and announces them
73+
* to the screenreaders.
74+
* @returns {!HTMLElement}
75+
* @private
76+
*/
77+
MdLiveAnnouncer.prototype._createLiveElement = function() {
78+
var liveEl = document.createElement('div');
79+
80+
liveEl.classList.add('md-visually-hidden');
81+
liveEl.setAttribute('role', 'status');
82+
liveEl.setAttribute('aria-atomic', 'true');
83+
liveEl.setAttribute('aria-live', 'polite');
84+
85+
document.body.appendChild(liveEl);
86+
87+
return liveEl;
88+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
describe('$mdLiveAnnouncer', function() {
2+
3+
var $mdLiveAnnouncer, $timeout = null;
4+
var liveEl = null;
5+
6+
beforeEach(module('material.core'));
7+
8+
beforeEach(inject(function ($injector) {
9+
$mdLiveAnnouncer = $injector.get('$mdLiveAnnouncer');
10+
$timeout = $injector.get('$timeout');
11+
12+
liveEl = $mdLiveAnnouncer._liveElement;
13+
}));
14+
15+
it('should correctly update the announce text', function() {
16+
$mdLiveAnnouncer.announce('Hey Google');
17+
18+
expect(liveEl.textContent).toBe('');
19+
20+
$timeout.flush();
21+
22+
expect(liveEl.textContent).toBe('Hey Google');
23+
});
24+
25+
it('should correctly update the politeness attribute', function() {
26+
$mdLiveAnnouncer.announce('Hey Google', 'assertive');
27+
28+
$timeout.flush();
29+
30+
expect(liveEl.textContent).toBe('Hey Google');
31+
expect(liveEl.getAttribute('aria-live')).toBe('assertive');
32+
});
33+
34+
it('should apply the aria-live value polite by default', function() {
35+
$mdLiveAnnouncer.announce('Hey Google');
36+
37+
$timeout.flush();
38+
39+
expect(liveEl.textContent).toBe('Hey Google');
40+
expect(liveEl.getAttribute('aria-live')).toBe('polite');
41+
});
42+
43+
it('should have proper aria attributes to be detected', function() {
44+
expect(liveEl.getAttribute('aria-atomic')).toBe('true');
45+
expect(liveEl.getAttribute('role')).toBe('status');
46+
});
47+
48+
});

0 commit comments

Comments
 (0)