Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});

});