From 184467c2fe2c323b7c76e2f15a15ce4b7f4526d4 Mon Sep 17 00:00:00 2001 From: Topher Fangio Date: Thu, 15 Sep 2016 11:45:24 -0500 Subject: [PATCH] fix(chips): Add basic accessibility support. 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. Additionally update the Chips docs with new Accessibility info and a few other minor changes like removing the "WORK IN PROGRESS" notice. 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. --- docs/config/template/index.template.html | 4 +- src/components/chips/chips.spec.js | 126 +++++++++- src/components/chips/contact-chips.spec.js | 8 + .../chips/demoBasicUsage/index.html | 2 +- .../chips/demoBasicUsage/readme.html | 10 + .../chips/demoContactChips/script.js | 26 +- src/components/chips/js/chipDirective.js | 54 ++-- src/components/chips/js/chipsController.js | 232 ++++++++++++++++-- src/components/chips/js/chipsDirective.js | 63 ++++- .../chips/js/contactChipsController.js | 9 +- .../chips/js/contactChipsDirective.js | 14 +- 11 files changed, 475 insertions(+), 73 deletions(-) create mode 100644 src/components/chips/demoBasicUsage/readme.html diff --git a/docs/config/template/index.template.html b/docs/config/template/index.template.html index a844c0f1893..49a6f0a6c20 100644 --- a/docs/config/template/index.template.html +++ b/docs/config/template/index.template.html @@ -1,5 +1,5 @@ - + <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 diff --git a/src/components/chips/chips.spec.js b/src/components/chips/chips.spec.js index 6936c9b451d..1e674eda987 100755 --- a/src/components/chips/chips.spec.js +++ b/src/components/chips/chips.spec.js @@ -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) { @@ -366,7 +368,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() { @@ -765,6 +767,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() { @@ -1331,6 +1377,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() { diff --git a/src/components/chips/contact-chips.spec.js b/src/components/chips/contact-chips.spec.js index fc064bd609a..3f110f65b86 100644 --- a/src/components/chips/contact-chips.spec.js +++ b/src/components/chips/contact-chips.spec.js @@ -8,6 +8,7 @@ describe('<md-contact-chips>', function() { md-contact-image="image"\ md-contact-email="email"\ md-highlight-flags="i"\ + md-chip-append-delay="2000"\ placeholder="To">\ </md-contact-chips>'; @@ -63,6 +64,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]); diff --git a/src/components/chips/demoBasicUsage/index.html b/src/components/chips/demoBasicUsage/index.html index 7744edfe1ee..75e3d638562 100644 --- a/src/components/chips/demoBasicUsage/index.html +++ b/src/components/chips/demoBasicUsage/index.html @@ -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> diff --git a/src/components/chips/demoBasicUsage/readme.html b/src/components/chips/demoBasicUsage/readme.html new file mode 100644 index 00000000000..9ee6ae8752c --- /dev/null +++ b/src/components/chips/demoBasicUsage/readme.html @@ -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, but also adds a <code>300ms</code> delay after appending a chip + in order to achieve this. +</p> + +<p> + Please see the <a href="api/directive/mdChips">documentation</a> for more information and for + the new option <code>md-chip-append-delay</code> which allows you to customize this delay. +</p> \ No newline at end of file diff --git a/src/components/chips/demoContactChips/script.js b/src/components/chips/demoContactChips/script.js index 491bf35ba3c..c176f507a36 100644 --- a/src/components/chips/demoContactChips/script.js +++ b/src/components/chips/demoContactChips/script.js @@ -1,5 +1,14 @@ (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); @@ -7,7 +16,7 @@ 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]]; @@ -21,8 +30,7 @@ * 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)) : []; } /** @@ -30,7 +38,6 @@ * Also debounce the queries; since the md-contact-chips does not support this */ function delayedQuerySearch(criteria) { - cachedQuery = criteria; if ( !pendingSearch || !debounceSearch() ) { cancelSearch(); @@ -39,7 +46,7 @@ cancelSearch = reject; $timeout(function() { - resolve( self.querySearch() ); + resolve( self.querySearch(criteria) ); refreshDebounce(); }, Math.random() * 500, true) @@ -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); }; } @@ -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; diff --git a/src/components/chips/js/chipDirective.js b/src/components/chips/js/chipDirective.js index 4d45102d3e8..ba246502fed 100644 --- a/src/components/chips/js/chipDirective.js +++ b/src/components/chips/js/chipDirective.js @@ -1,6 +1,6 @@ angular - .module('material.components.chips') - .directive('mdChip', MdChip); + .module('material.components.chips') + .directive('mdChip', MdChip); /** * @ngdoc directive @@ -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(); } - }; + }); } } diff --git a/src/components/chips/js/chipsController.js b/src/components/chips/js/chipsController.js index 89c1303b685..a6e7da0acf0 100644 --- a/src/components/chips/js/chipsController.js +++ b/src/components/chips/js/chipsController.js @@ -1,3 +1,10 @@ +/** + * The default chip append delay. + * + * @type {number} + */ +var DEFAULT_CHIP_APPEND_DELAY = 300; + angular .module('material.components.chips') .controller('MdChipsCtrl', MdChipsCtrl); @@ -38,6 +45,9 @@ function MdChipsCtrl ($scope, $attrs, $mdConstant, $log, $element, $timeout, $md /** @type {$element} */ this.$element = $element; + /** @type {$attrs} */ + this.$attrs = $attrs; + /** @type {angular.NgModelController} */ this.ngModelCtrl = null; @@ -62,6 +72,20 @@ function MdChipsCtrl ($scope, $attrs, $mdConstant, $log, $element, $timeout, $md /** @type {string} */ this.addOnBlur = $mdUtil.parseAttributeBoolean($attrs.mdAddOnBlur); + /** + * The text to be used as the aria-label for the input. + * @type {string} + */ + this.inputAriaLabel = 'Chips input.'; + + /** + * Hidden hint text to describe the chips container. Used to give context to screen readers when + * the chips are readonly and the input cannot be selected. + * + * @type {string} + */ + this.containerHint = 'Chips container. Use arrow keys to select chips.'; + /** * Hidden hint text for how to delete a chip. Used to give context to screen readers. * @type {string} @@ -100,12 +124,107 @@ function MdChipsCtrl ($scope, $attrs, $mdConstant, $log, $element, $timeout, $md this.useOnRemove = false; /** - * Whether to use the onSelect expression to notify the component's user - * after selecting a chip from the list. - * @type {boolean} + * The ID of the chips wrapper which is used to build unique IDs for the chips and the aria-owns + * attribute. + * + * Defaults to '_md-chips-wrapper-' plus a unique number. + * + * @type {string} + */ + this.wrapperId = ''; + + /** + * Array of unique numbers which will be auto-generated any time the items change, and is used to + * create unique IDs for the aria-owns attribute. + * + * @type {Array} + */ + this.contentIds = []; + + /** + * The index of the chip that should have it's tabindex property set to 0 so it is selectable + * via the keyboard. + * + * @type {int} */ + this.ariaTabIndex = null; + + /** + * After appending a chip, the chip will be focused for this number of milliseconds before the + * input is refocused. + * + * **Note:** This is **required** for compatibility with certain screen readers in order for + * them to properly allow keyboard access. + * + * @type {number} + */ + this.chipAppendDelay = DEFAULT_CHIP_APPEND_DELAY; + + this.init(); } +/** + * Initializes variables and sets up watchers + */ +MdChipsCtrl.prototype.init = function() { + var ctrl = this; + + // Set the wrapper ID + ctrl.wrapperId = '_md-chips-wrapper-' + ctrl.$mdUtil.nextUid(); + + // Setup a watcher which manages the role and aria-owns attributes + ctrl.$scope.$watchCollection('$mdChipsCtrl.items', function() { + // Make sure our input and wrapper have the correct ARIA attributes + ctrl.setupInputAria(); + ctrl.setupWrapperAria(); + }); + + ctrl.$attrs.$observe('mdChipAppendDelay', function(newValue) { + ctrl.chipAppendDelay = parseInt(newValue) || DEFAULT_CHIP_APPEND_DELAY; + }); +}; + +/** + * If we have an input, ensure it has the appropriate ARIA attributes. + */ +MdChipsCtrl.prototype.setupInputAria = function() { + var input = this.$element.find('input'); + + // If we have no input, just return + if (!input) { + return; + } + + input.attr('role', 'textbox'); + input.attr('aria-multiline', true); +}; + +/** + * Ensure our wrapper has the appropriate ARIA attributes. + */ +MdChipsCtrl.prototype.setupWrapperAria = function() { + var ctrl = this, + wrapper = this.$element.find('md-chips-wrap'); + + if (this.items && this.items.length) { + // Dynamically add the listbox role on every change because it must be removed when there are + // no items. + wrapper.attr('role', 'listbox'); + + // Generate some random (but unique) IDs for each chip + this.contentIds = this.items.map(function() { + return ctrl.wrapperId + '-chip-' + ctrl.$mdUtil.nextUid(); + }); + + // Use the contentIDs above to generate the aria-owns attribute + wrapper.attr('aria-owns', this.contentIds.join(' ')); + } else { + // If we have no items, then the role and aria-owns attributes MUST be removed + wrapper.removeAttr('role'); + wrapper.removeAttr('aria-owns'); + } +}; + /** * Handles the keydown event on the input element: by default <enter> appends * the buffer to the chip list, while backspace removes the last chip in the @@ -152,6 +271,21 @@ MdChipsCtrl.prototype.inputKeydown = function(event) { this.appendChip(chipBuffer.trim()); this.resetChipBuffer(); + + var ctrl = this; + + // Attempt to focus the input after clearing the chip buffer + /* + ctrl.$timeout(function() { + ctrl.selectAndFocusChipSafe(ctrl.items.length - 1); + + ctrl.$timeout(function() { + ctrl.focusInput(); + }, 250); + }, 500); + */ + + return false; } }; @@ -230,7 +364,11 @@ MdChipsCtrl.prototype.chipKeydown = function (event) { break; case this.$mdConstant.KEY_CODE.LEFT_ARROW: event.preventDefault(); - if (this.selectedChip < 0) this.selectedChip = this.items.length; + // By default, allow selection of -1 which will focus the input; if we're readonly, don't go + // below 0 + if (this.selectedChip < 0 || (this.readonly && this.selectedChip == 0)) { + this.selectedChip = this.items.length; + } if (this.items.length) this.selectAndFocusChipSafe(this.selectedChip - 1); break; case this.$mdConstant.KEY_CODE.RIGHT_ARROW: @@ -263,11 +401,25 @@ MdChipsCtrl.prototype.getPlaceholder = function() { * @param index */ MdChipsCtrl.prototype.removeAndSelectAdjacentChip = function(index) { - var selIndex = this.getAdjacentChipIndex(index); - this.removeChip(index); - this.$timeout(angular.bind(this, function () { - this.selectAndFocusChipSafe(selIndex); - })); + var self = this; + var selIndex = self.getAdjacentChipIndex(index); + var wrap = this.$element[0].querySelector('md-chips-wrap'); + var chip = this.$element[0].querySelector('md-chip[index="' + index + '"]'); + + self.removeChip(index); + + // TODO: Remove extra timeout (tried $mdUtil.nextTick and MuatationObservers with no luck) + // + // The dobule-timeout is currently necessary to ensure that the DOM has finalized and the select() + // will find the proper chip since the selection is index-based. + // + // After talking with Elad, we may be able to remove this by having each chip register/deregister + // upon addition/removal which we can then monitor and call this function. + self.$timeout(function() { + self.$timeout(function() { + self.selectAndFocusChipSafe(selIndex); + }); + }); }; /** @@ -275,6 +427,7 @@ MdChipsCtrl.prototype.removeAndSelectAdjacentChip = function(index) { */ MdChipsCtrl.prototype.resetSelectedChip = function() { this.selectedChip = -1; + this.ariaTabIndex = null; }; /** @@ -299,6 +452,7 @@ MdChipsCtrl.prototype.getAdjacentChipIndex = function(index) { * @param newChip */ MdChipsCtrl.prototype.appendChip = function(newChip) { + this.shouldFocusLastChip = true; if (this.useTransformChip && this.transformChip) { var transformedChip = this.transformChip({'$chip': newChip}); @@ -455,18 +609,51 @@ MdChipsCtrl.prototype.removeChipAndFocusInput = function (index) { * @param index */ MdChipsCtrl.prototype.selectAndFocusChipSafe = function(index) { - if (!this.items.length) { - this.selectChip(-1); - this.onFocus(); - return; + // If we have no chips, or are asked to select a chip before the first, just focus the input + if (!this.items.length || index === -1) { + return this.focusInput(); } - if (index === this.items.length) return this.onFocus(); + + // If we are asked to select a chip greater than the number of chips... + if (index >= this.items.length) { + if (this.readonly) { + // If we are readonly, jump back to the start (because we have no input) + index = 0; + } else { + // If we are not readonly, we should attempt to focus the input + return this.onFocus(); + } + } + index = Math.max(index, 0); index = Math.min(index, this.items.length - 1); + this.selectChip(index); this.focusChip(index); }; +MdChipsCtrl.prototype.focusLastChipThenInput = function() { + var ctrl = this; + + ctrl.shouldFocusLastChip = false; + + ctrl.focusChip(this.items.length - 1); + + ctrl.$timeout(function() { + ctrl.focusInput(); + }, ctrl.chipAppendDelay); +}; + +MdChipsCtrl.prototype.focusInput = function() { + this.selectChip(-1); + this.onFocus(); +}; + +MdChipsCtrl.prototype.blurInput = function() { + var input = this.$element[0].querySelector('input'); + input && input.blur(); +}; + /** * Marks the chip at the given index as selected. * @param index @@ -477,7 +664,7 @@ MdChipsCtrl.prototype.selectChip = function(index) { // Fire the onSelect if provided if (this.useOnSelect && this.onSelect) { - this.onSelect({'$chip': this.items[this.selectedChip] }); + this.onSelect({'$chip': this.items[index] }); } } else { this.$log.warn('Selected Chip index out of bounds; ignoring.'); @@ -499,7 +686,11 @@ MdChipsCtrl.prototype.selectAndFocusChip = function(index) { * Call `focus()` on the chip at `index` */ MdChipsCtrl.prototype.focusChip = function(index) { - this.$element[0].querySelector('md-chip[index="' + index + '"] .md-chip-content').focus(); + var chipContent = this.$element[0].querySelector('md-chip[index="' + index + '"] .md-chip-content'); + + this.ariaTabIndex = index; + + chipContent.focus(); }; /** @@ -525,6 +716,11 @@ MdChipsCtrl.prototype.onFocus = function () { MdChipsCtrl.prototype.onInputFocus = function () { this.inputHasFocus = true; + + // Make sure we have the appropriate ARIA attributes + this.setupInputAria(); + + // Make sure we don't have any chips selected this.resetSelectedChip(); }; @@ -602,3 +798,7 @@ MdChipsCtrl.prototype.configureAutocomplete = function(ctrl) { MdChipsCtrl.prototype.hasFocus = function () { return this.inputHasFocus || this.selectedChip >= 0; }; + +MdChipsCtrl.prototype.contentIdFor = function(index) { + return this.contentIds[index]; +}; diff --git a/src/components/chips/js/chipsDirective.js b/src/components/chips/js/chipsDirective.js index d38e2ef6fe4..186badd8ab2 100644 --- a/src/components/chips/js/chipsDirective.js +++ b/src/components/chips/js/chipsDirective.js @@ -26,6 +26,9 @@ * is also placed as a sibling to the chip content (on which there are also click listeners) to * avoid a nested ng-click situation. * + * <!-- Note: We no longer want to include this in the site docs; but it should remain here for + * future developers and those looking at the documentation. + * * <h3> Pending Features </h3> * <ul style="padding-left:20px;"> * @@ -56,10 +59,7 @@ * </ul> * </ul> * - * <span style="font-size:.8em;text-align:center"> - * Warning: This component is a WORK IN PROGRESS. If you use it now, - * it will probably break on you in the future. - * </span> + * //--> * * Sometimes developers want to limit the amount of possible chips.<br/> * You can specify the maximum amount of chips by using the following markup. @@ -82,7 +82,17 @@ * </md-chips> * </hljs> * - * @param {string=|object=} ng-model A model to bind the list of items to + * ### Accessibility + * + * The `md-chips` component supports keyboard and screen reader users since version 1.1.2. In this + * version, we also introduced a new `md-chip-append-delay` option which defaults to 300ms in + * order to fix some accessibility issues with major screen readers (JAWS and NVDA in particular). + * + * This value is customizable, but any number less than `300` will cause issues in JAWS and NVDA. + * + * Please refer to the documentation of this option (below) for more information. + * + * @param {string=|object=} ng-model A model to which the list of items will be bound. * @param {string=} placeholder Placeholder text that will be forwarded to the input. * @param {string=} secondary-placeholder Placeholder text that will be forwarded to the input, * displayed when there is at least one item in the list @@ -112,11 +122,29 @@ * @param {expression=} md-on-select An expression which will be called when a chip is selected. * @param {boolean} md-require-match If true, and the chips template contains an autocomplete, * only allow selection of pre-defined chips (i.e. you cannot add new ones). + * @param {string=} input-aria-label A string read by screen readers to identify the input. + * @param {string=} container-hint A string read by screen readers informing users of how to + * navigate the chips. Used in readonly mode. * @param {string=} delete-hint A string read by screen readers instructing users that pressing * the delete key will remove the chip. * @param {string=} delete-button-label A label for the delete button. Also hidden and read by * screen readers. * @param {expression=} md-separator-keys An array of key codes used to separate chips. + * @param {string=} md-chip-append-delay The number of milliseconds that the component will delay + * after appending a chip and before allowing a user to type into the input. This is + * **necessary** for keyboard accessibility for screen readers. It defaults to 300ms and any + * number less than 300 can cause issues with screen readers (particularly JAWS and sometimes + * NVDA). + * + * _Available since Version 1.1.2._ + * + * **Note:** You can safely set this to `0` in one of the following two instances: + * + * 1. You are targeting an iOS or Safari-only application (where users would use VoiceOver) or + * only ChromeVox users. + * + * 2. If you have utilized the `md-separator-keys` to disable the `enter` keystroke in + * favor of another one (such as `,` or `;`). * * @usage * <hljs lang="html"> @@ -146,25 +174,34 @@ * */ - var MD_CHIPS_TEMPLATE = '\ <md-chips-wrap\ + id="{{$mdChipsCtrl.wrapperId}}"\ + tabindex="{{$mdChipsCtrl.readonly ? 0 : -1}}"\ ng-keydown="$mdChipsCtrl.chipKeydown($event)"\ ng-class="{ \'md-focused\': $mdChipsCtrl.hasFocus(), \ \'md-readonly\': !$mdChipsCtrl.ngModelCtrl || $mdChipsCtrl.readonly,\ \'md-removable\': $mdChipsCtrl.isRemovable() }"\ + aria-setsize="{{$mdChipsCtrl.items.length}}"\ class="md-chips">\ + <span ng-if="$mdChipsCtrl.readonly" class="md-visually-hidden">\ + {{$mdChipsCtrl.containerHint}}\ + </span>\ <md-chip ng-repeat="$chip in $mdChipsCtrl.items"\ index="{{$index}}"\ ng-class="{\'md-focused\': $mdChipsCtrl.selectedChip == $index, \'md-readonly\': !$mdChipsCtrl.ngModelCtrl || $mdChipsCtrl.readonly}">\ <div class="md-chip-content"\ - tabindex="-1"\ - aria-hidden="true"\ + tabindex="{{$mdChipsCtrl.ariaTabIndex == $index ? 0 : -1}}"\ + id="{{$mdChipsCtrl.contentIdFor($index)}}"\ + role="option"\ + aria-selected="{{$mdChipsCtrl.selectedChip == $index}}" \ + aria-posinset="{{$index}}"\ ng-click="!$mdChipsCtrl.readonly && $mdChipsCtrl.focusChip($index)"\ ng-focus="!$mdChipsCtrl.readonly && $mdChipsCtrl.selectChip($index)"\ md-chip-transclude="$mdChipsCtrl.chipContentsTemplate"></div>\ <div ng-if="$mdChipsCtrl.isRemovable()"\ class="md-chip-remove-container"\ + tabindex="-1"\ md-chip-transclude="$mdChipsCtrl.chipRemoveTemplate"></div>\ </md-chip>\ <div class="md-chip-input-container" ng-if="!$mdChipsCtrl.readonly && $mdChipsCtrl.ngModelCtrl">\ @@ -176,8 +213,8 @@ <input\ class="md-input"\ tabindex="0"\ + aria-label="{{$mdChipsCtrl.inputAriaLabel}}" \ placeholder="{{$mdChipsCtrl.getPlaceholder()}}"\ - aria-label="{{$mdChipsCtrl.getPlaceholder()}}"\ ng-model="$mdChipsCtrl.chipBuffer"\ ng-focus="$mdChipsCtrl.onInputFocus()"\ ng-blur="$mdChipsCtrl.onInputBlur()"\ @@ -192,7 +229,6 @@ ng-if="$mdChipsCtrl.isRemovable()"\ ng-click="$mdChipsCtrl.removeChipAndFocusInput($$replacedScope.$index)"\ type="button"\ - aria-hidden="true"\ tabindex="-1">\ <md-icon md-svg-src="{{ $mdChipsCtrl.mdCloseIcon }}"></md-icon>\ <span class="md-visually-hidden">\ @@ -233,10 +269,13 @@ onAdd: '&mdOnAdd', onRemove: '&mdOnRemove', onSelect: '&mdOnSelect', + inputAriaLabel: '@', + containerHint: '@', deleteHint: '@', deleteButtonLabel: '@', separatorKeys: '=?mdSeparatorKeys', - requireMatch: '=?mdRequireMatch' + requireMatch: '=?mdRequireMatch', + chipAppendDelayString: '@?mdChipAppendDelay' } }; @@ -323,7 +362,7 @@ mdChipsCtrl.mdCloseIcon = $$mdSvgRegistry.mdClose; element - .attr({ 'aria-hidden': true, tabindex: -1 }) + .attr({ tabindex: -1 }) .on('focus', function () { mdChipsCtrl.onFocus(); }); if (attr.ngModel) { diff --git a/src/components/chips/js/contactChipsController.js b/src/components/chips/js/contactChipsController.js index 980fc377cd6..8ef7000c196 100644 --- a/src/components/chips/js/contactChipsController.js +++ b/src/components/chips/js/contactChipsController.js @@ -18,17 +18,10 @@ function MdContactChipsCtrl () { MdContactChipsCtrl.prototype.queryContact = function(searchText) { - var results = this.contactQuery({'$query': searchText}); - return this.filterSelected ? - results.filter(angular.bind(this, this.filterSelectedContacts)) : results; + return this.contactQuery({'$query': searchText}); }; MdContactChipsCtrl.prototype.itemName = function(item) { return item[this.contactName]; }; - - -MdContactChipsCtrl.prototype.filterSelectedContacts = function(contact) { - return this.contacts.indexOf(contact) == -1; -}; diff --git a/src/components/chips/js/contactChipsDirective.js b/src/components/chips/js/contactChipsDirective.js index d316d736f8e..560985ec9aa 100644 --- a/src/components/chips/js/contactChipsDirective.js +++ b/src/components/chips/js/contactChipsDirective.js @@ -30,9 +30,10 @@ angular * @param {string} md-contact-image The field name of the contact object representing the * contact's image. * - * * @param {expression=} filter-selected Whether to filter selected contacts from the list of - * suggestions shown in the autocomplete. This attribute has been removed but may come back. + * suggestions shown in the autocomplete. + * + * ***Note:** This attribute has been removed but may come back.* * * * @@ -55,6 +56,7 @@ var MD_CONTACT_CHIPS_TEMPLATE = '\ <md-chips class="md-contact-chips"\ ng-model="$mdContactChipsCtrl.contacts"\ md-require-match="$mdContactChipsCtrl.requireMatch"\ + md-chip-append-delay="{{$mdContactChipsCtrl.chipAppendDelay}}" \ md-autocomplete-snap>\ <md-autocomplete\ md-menu-class="md-contact-chips-suggestions"\ @@ -118,17 +120,23 @@ function MdContactChips($mdTheming, $mdUtil) { contactEmail: '@mdContactEmail', contacts: '=ngModel', requireMatch: '=?mdRequireMatch', - highlightFlags: '@?mdHighlightFlags' + highlightFlags: '@?mdHighlightFlags', + chipAppendDelay: '@?mdChipAppendDelay' } }; function compile(element, attr) { return function postLink(scope, element, attrs, controllers) { + var contactChipsController = controllers; $mdUtil.initOptionalProperties(scope, attr); $mdTheming(element); element.attr('tabindex', '-1'); + + attrs.$observe('mdChipAppendDelay', function(newValue) { + contactChipsController.chipAppendDelay = newValue; + }); }; } }