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

Commit

Permalink
fix(chips): Add basic accessibility support.
Browse files Browse the repository at this point in the history
Previously, chips had no, or very broken, support for accessibility.

Make many updates to fix broken functionality and add required features.

 - Remove existing `aria-hidden` attributes.

 - Dynamically apply required `role` and `aria-owns` attributes.

 - Ensure `tabindex` is set properly on all elements. Particularly,
   you can now tab to, and navigate through, a set of chips in
   readonly mode.

 - Provide new `input-aria-label` option to identify the inputs.

 - Provide new `container-hint` option used when the element is in
   readonly mode.

 - Provide new `md-chip-append-delay` option which controls the delay
   (in ms) before a user can add a new chip (fixes screen readers).

 - Fix issue with `delete-hint` never being read by screen readers
   because it was outside of the chip.

 - Fix keyboard navigation to properly wrap when moving both left
   or right.

 - Fix keyboard navigation when in readonly mode.

 - Fix issue where the wrong chip may be focused because the old
   chip was still in the DOM.

 - Fix issue where `onAdd` callback passed incorrect `$index` (it
   was 1-based instead of 0-based).

Additionally update the Chips docs with new Accessibility info and
a few other minor changes to the docs/demos.

 - Remove the "WORK IN PROGRESS" notice.

 - Fix mispelling of "maximum" on first chips demo.

 - Fix Contact Chips demo filtering.

 - Fix Contact Chips demo images to use new/faster service.

BREAKING CHANGES

**MAJOR**

In order to make the chips fully accessible and work properly across
a wide variety of screen readers, we have added a 300ms delay after
appending a chip before we re-focus the input.

This is necessary because some screen readers change operational modes
when the enter key is pressed inside of an input and this causes the
screen reader to move into "navigation" mode and no longer apply
keystrokes to the input.

Additionally, this ensures that the newly added chip is properly
announced to the screen readers.

You **may** alter this value with the new `md-chip-append-delay`
attribute, however using a value less than `300` can cause issues
on JAWS and NVDA and will make your application inaccessible to those
users.

Note 1: This issue only seems to affect chips appended when using the
`enter` key. If you override the `md-separator-keys` and disable the
`enter` key (and enable something like `,` or `;`), you may be able
to reduce this delay to `0` and achieve past functionality.

Note 2: This issue does not appear to affect VoiceOver or ChromeVox,
so if you are only targeting those users, you may be able to reduce
the delay to `0`.

**MINOR**

We consider the following to be minor breaking changes since we never
expected these attributes/elements to be utilized by developers.

Nonetheless, we want to ensure that they are documented.

 - The `role` of the `.md-chip-content` has been modified from `button`
   to `option` so that it works with the new `listbox` role of it's
   parent.

   If you rely on this role being `button`, you should update your code
   accordingly.

- The delete hint on removable chips now resides inside of the
  `div.md-chip-content` rather than the parent `md-chip` element where
  it could not be read by screen readers.

  If you interact with this element (in CSS or JS) please update your
  selectors/code to account for the new DOM hierarchy.

Fixes #9391. Fixes #9556. Fixes #8897. Fixes #8867. Fixes #9649.
  • Loading branch information
topherfangio committed Jan 4, 2017
1 parent 085c5fd commit 7de90c0
Show file tree
Hide file tree
Showing 11 changed files with 460 additions and 75 deletions.
4 changes: 2 additions & 2 deletions docs/config/template/index.template.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html ng-app="docsApp" ng-controller="DocsCtrl" lang="en" ng-strict-di >
<html ng-app="docsApp" ng-controller="DocsCtrl" lang="en" ng-strict-di>
<head>
<base href="/">
<title ng-bind="'Angular Material - ' + menu.currentSection.name +
Expand All @@ -12,7 +12,7 @@
<link rel="stylesheet" href="angular-material.min.css">
<link rel="stylesheet" href="docs.css">
</head>
<body class="docs-body" layout="row" ng-cloak>
<body class="docs-body" layout="row" ng-cloak aria-label="Angular Material Docs">

<md-sidenav class="site-sidenav md-sidenav-left md-whiteframe-z2"
md-component-id="left" hide-print
Expand Down
128 changes: 126 additions & 2 deletions src/components/chips/chips.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ describe('<md-chips>', function() {
'</md-chips>';
var CHIP_NOT_REMOVABLE_TEMPLATE =
'<md-chips ng-model="items" readonly="true" md-removable="false"></md-chips>';
var CHIP_APPEND_DELAY_TEMPLATE =
'<md-chips ng-model="items" md-chip-append-delay="800"></md-chips>';

afterEach(function() {
attachedElements.forEach(function(element) {
Expand Down Expand Up @@ -170,7 +172,7 @@ describe('<md-chips>', function() {

expect(scope.addChip).toHaveBeenCalled();
expect(scope.addChip.calls.mostRecent().args[0]).toBe('Grape'); // Chip
expect(scope.addChip.calls.mostRecent().args[1]).toBe(4); // Index
expect(scope.addChip.calls.mostRecent().args[1]).toBe(3); // Index
});


Expand Down Expand Up @@ -426,7 +428,7 @@ describe('<md-chips>', function() {

var updatedChips = getChipElements(element);

expect(chips.length).not.toBe(updatedChips.length);
expect(updatedChips.length).toBe(chips.length - 1);
}));

it('should set removable to true by default', function() {
Expand Down Expand Up @@ -825,6 +827,50 @@ describe('<md-chips>', function() {

});

it('utilizes the default chip append delay of 300ms', inject(function($timeout) {
var element = buildChips(BASIC_CHIP_TEMPLATE);
var ctrl = element.controller('mdChips');

// Append element to body
angular.element(document.body).append(element);

// Append a new chips which will fire the delay
ctrl.appendChip('test');

// Before 300ms timeout, focus should be on the chip (i.e. the chip content)
$timeout.flush(299);
expect(document.activeElement).toHaveClass('md-chip-content');

// At/after 300ms timeout, focus should be on the input
$timeout.flush(1);
expect(document.activeElement.tagName.toUpperCase()).toEqual('INPUT');

// cleanup
element.remove();
}));

it('utilizes a custom chip append delay', inject(function($timeout) {
var element = buildChips(CHIP_APPEND_DELAY_TEMPLATE);
var ctrl = element.controller('mdChips');

// Append element to body
angular.element(document.body).append(element);

// Append a new chips which will fire the delay
ctrl.appendChip('test');

// Before custom timeout, focus should be on the chip (i.e. the chip content)
$timeout.flush(ctrl.chipAppendDelay - 1);
expect(document.activeElement).toHaveClass('md-chip-content');

// At/after custom timeout, focus should be on the input
$timeout.flush(1);
expect(document.activeElement.tagName.toUpperCase()).toEqual('INPUT');

// cleanup
element.remove();
}));

});

describe('custom inputs', function() {
Expand Down Expand Up @@ -1404,6 +1450,84 @@ describe('<md-chips>', function() {

});
});

describe('keyboard navigation', function() {
var leftEvent, rightEvent;

beforeEach(inject(function($mdConstant) {
leftEvent = {
type: 'keydown',
keyCode: $mdConstant.KEY_CODE.LEFT_ARROW,
which: $mdConstant.KEY_CODE.LEFT_ARROW
};
rightEvent = {
type: 'keydown',
keyCode: $mdConstant.KEY_CODE.RIGHT_ARROW,
which: $mdConstant.KEY_CODE.RIGHT_ARROW
};
}));

describe('when readonly', function() {
// TODO: Add readonly specific tests
});

describe('when we have an input', function() {
it('clears the selected chip when the input is focused', inject(function($timeout) {
var element = buildChips(BASIC_CHIP_TEMPLATE);
var ctrl = element.controller('mdChips');

// Focus the input
ctrl.focusInput();
$timeout.flush();

// Expect no chip to be selected
expect(ctrl.selectedChip).toBe(-1);
}));

it('selects the previous chip', inject(function($timeout) {
var element = buildChips(BASIC_CHIP_TEMPLATE);
var ctrl = element.controller('mdChips');
var chips = getChipElements(element);

// Select the second chip
ctrl.selectAndFocusChipSafe(1);
$timeout.flush();

expect(ctrl.selectedChip).toBe(1);

// Select the 1st chip
element.find('md-chips-wrap').triggerHandler(angular.copy(leftEvent));
$timeout.flush();

expect(ctrl.selectedChip).toBe(0);
}));

it('and the first chip is selected, selects the input', inject(function($timeout) {
var element = buildChips(BASIC_CHIP_TEMPLATE);
var ctrl = element.controller('mdChips');
var chips = getChipElements(element);

// Append so we can focus the input
angular.element(document.body).append(element);

// Select the second chip
ctrl.selectAndFocusChipSafe(0);
$timeout.flush();

expect(ctrl.selectedChip).toBe(0);

// Selecting past the first should wrap back to the input
element.find('md-chips-wrap').triggerHandler(angular.copy(leftEvent));
$timeout.flush();

expect(ctrl.selectedChip).toBe(-1);
expect(document.activeElement).toBe(element.find('input')[0]);

// Cleanup after ourselves
element.remove();
}));
});
});
});

describe('with $interpolate.start/endSymbol override', function() {
Expand Down
8 changes: 8 additions & 0 deletions src/components/chips/contact-chips.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('<md-contact-chips>', function() {
md-contact-email="email"\
md-highlight-flags="i"\
md-min-length="1"\
md-chip-append-delay="2000"\
placeholder="To">\
</md-contact-chips>';

Expand Down Expand Up @@ -64,6 +65,13 @@ describe('<md-contact-chips>', function() {
expect(ctrl.highlightFlags).toEqual('i');
});

it('forwards the md-chips-append-delay attribute to the md-chips', function() {
var element = buildChips(CONTACT_CHIPS_TEMPLATE);
var chipsCtrl = element.find('md-chips').controller('mdChips');

expect(chipsCtrl.chipAppendDelay).toEqual(2000);
});

it('renders an image element for contacts with an image property', function() {
scope.contacts.push(scope.allContacts[2]);

Expand Down
2 changes: 1 addition & 1 deletion src/components/chips/demoBasicUsage/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ <h2 class="md-title">Use a custom chip template.</h2>

<form name="fruitForm">
<md-chips ng-model="ctrl.roFruitNames" name="fruitName" readonly="ctrl.readonly"
md-removable="ctrl.removable" md-max-chips="5">
md-removable="ctrl.removable" md-max-chips="5" placeholder="Enter a fruit...">
<md-chip-template>
<strong>{{$chip}}</strong>
<em>(fruit)</em>
Expand Down
10 changes: 10 additions & 0 deletions src/components/chips/demoBasicUsage/readme.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<p>
<b>Note:</b> Version 1.1.2 drastically improves keyboard and screen reader accessibility for the
<code>md-chips</code> component. In order to achieve this, the behavior has changed to also select
and highlight the newly appended chip for <code>300ms</code> before re-focusing the text input.
</p>

<p>
Please see the <a href="api/directive/mdChips">documentation</a> for more information and for
the new <code>md-chip-append-delay</code> option which allows you to customize this delay.
</p>
26 changes: 18 additions & 8 deletions src/components/chips/demoContactChips/script.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
(function () {
'use strict';

// If we do not have CryptoJS defined; import it
if (typeof CryptoJS == 'undefined') {
var cryptoSrc = '//cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/md5.js';
var scriptTag = document.createElement('script');
scriptTag.setAttribute('src', cryptoSrc);
document.body.appendChild(scriptTag);
}

angular
.module('contactChipsDemo', ['ngMaterial'])
.controller('ContactChipDemoCtrl', DemoCtrl);

function DemoCtrl ($q, $timeout) {
var self = this;
var pendingSearch, cancelSearch = angular.noop;
var cachedQuery, lastSearch;
var lastSearch;

self.allContacts = loadContacts();
self.contacts = [self.allContacts[0]];
Expand All @@ -21,16 +30,14 @@
* Search for contacts; use a random delay to simulate a remote call
*/
function querySearch (criteria) {
cachedQuery = cachedQuery || criteria;
return cachedQuery ? self.allContacts.filter(createFilterFor(cachedQuery)) : [];
return criteria ? self.allContacts.filter(createFilterFor(criteria)) : [];
}

/**
* Async search for contacts
* Also debounce the queries; since the md-contact-chips does not support this
*/
function delayedQuerySearch(criteria) {
cachedQuery = criteria;
if ( !pendingSearch || !debounceSearch() ) {
cancelSearch();

Expand All @@ -39,7 +46,7 @@
cancelSearch = reject;
$timeout(function() {

resolve( self.querySearch() );
resolve( self.querySearch(criteria) );

refreshDebounce();
}, Math.random() * 500, true)
Expand Down Expand Up @@ -72,7 +79,7 @@
var lowercaseQuery = angular.lowercase(query);

return function filterFn(contact) {
return (contact._lowername.indexOf(lowercaseQuery) != -1);;
return (contact._lowername.indexOf(lowercaseQuery) != -1);
};

}
Expand All @@ -92,10 +99,13 @@

return contacts.map(function (c, index) {
var cParts = c.split(' ');
var email = cParts[0][0].toLowerCase() + '.' + cParts[1].toLowerCase() + '@example.com';
var hash = CryptoJS.MD5(email);

var contact = {
name: c,
email: cParts[0][0].toLowerCase() + '.' + cParts[1].toLowerCase() + '@example.com',
image: 'http://lorempixel.com/50/50/people?' + index
email: email,
image: '//www.gravatar.com/avatar/' + hash + '?s=50&d=retro'
};
contact._lowername = contact.name.toLowerCase();
return contact;
Expand Down
54 changes: 32 additions & 22 deletions src/components/chips/js/chipDirective.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
angular
.module('material.components.chips')
.directive('mdChip', MdChip);
.module('material.components.chips')
.directive('mdChip', MdChip);

/**
* @ngdoc directive
Expand Down Expand Up @@ -33,36 +33,46 @@ var DELETE_HINT_TEMPLATE = '\
* @param $mdUtil
* @ngInject
*/
function MdChip($mdTheming, $mdUtil) {
var hintTemplate = $mdUtil.processTemplate(DELETE_HINT_TEMPLATE);
function MdChip($mdTheming, $mdUtil, $compile, $timeout) {
var deleteHintTemplate = $mdUtil.processTemplate(DELETE_HINT_TEMPLATE);

return {
restrict: 'E',
require: ['^?mdChips', 'mdChip'],
compile: compile,
link: postLink,
controller: 'MdChipCtrl'
};

function compile(element, attr) {
// Append the delete template
element.append($mdUtil.processTemplate(hintTemplate));
function postLink(scope, element, attr, ctrls) {
var chipsController = ctrls.shift();
var chipController = ctrls.shift();
var chipContentElement = angular.element(element[0].querySelector('.md-chip-content'));

return function postLink(scope, element, attr, ctrls) {
var chipsController = ctrls.shift();
var chipController = ctrls.shift();
$mdTheming(element);
$mdTheming(element);

if (chipsController) {
chipController.init(chipsController);
if (chipsController) {
chipController.init(chipsController);

angular
.element(element[0]
.querySelector('.md-chip-content'))
.on('blur', function () {
chipsController.resetSelectedChip();
chipsController.$scope.$applyAsync();
});
// Append our delete hint to the div.md-chip-content (which does not exist at compile time)
chipContentElement.append($compile(deleteHintTemplate)(scope));

// When a chip is blurred, make sure to unset (or reset) the selected chip so that tabbing
// through elements works properly
chipContentElement.on('blur', function() {
chipsController.resetSelectedChip();
chipsController.$scope.$applyAsync();
});
}

// Use $timeout to ensure we run AFTER the element has been added to the DOM so we can focus it.
$timeout(function() {
if (!chipsController) {
return;
}

if (chipsController.shouldFocusLastChip) {
chipsController.focusLastChipThenInput();
}
};
});
}
}
Loading

0 comments on commit 7de90c0

Please sign in to comment.