From 2dcc43c6cb2d41d0958be5f9ef6623704e9a2e35 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. - 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. BREAKING CHANGES 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. --- src/components/chips/chips.spec.js | 80 +++++++++- .../chips/demoBasicUsage/index.html | 2 +- src/components/chips/js/chipDirective.js | 45 +++--- src/components/chips/js/chipsController.js | 142 ++++++++++++++++-- src/components/chips/js/chipsDirective.js | 24 ++- 5 files changed, 246 insertions(+), 47 deletions(-) diff --git a/src/components/chips/chips.spec.js b/src/components/chips/chips.spec.js index 7322a203f49..bd09498d450 100755 --- a/src/components/chips/chips.spec.js +++ b/src/components/chips/chips.spec.js @@ -363,7 +363,7 @@ describe('', 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() { @@ -1330,6 +1330,84 @@ describe('', 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/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 @@

Use a custom chip template.

+ md-removable="ctrl.removable" md-max-chips="5" placeholder="Enter a fruit..."> {{$chip}} (fruit) diff --git a/src/components/chips/js/chipDirective.js b/src/components/chips/js/chipDirective.js index 4d45102d3e8..d09c3c61e2f 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,35 @@ var DELETE_HINT_TEMPLATE = '\ * @param $mdUtil * @ngInject */ -function MdChip($mdTheming, $mdUtil) { - var hintTemplate = $mdUtil.processTemplate(DELETE_HINT_TEMPLATE); +function MdChip($mdTheming, $mdUtil, $compile) { + 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(); + }); + } } } diff --git a/src/components/chips/js/chipsController.js b/src/components/chips/js/chipsController.js index 89c1303b685..051a127e53e 100644 --- a/src/components/chips/js/chipsController.js +++ b/src/components/chips/js/chipsController.js @@ -62,6 +62,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 +114,65 @@ 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; + + this.init(); } +/** + * Initializes variables and sets up watchers + */ +MdChipsCtrl.prototype.init = function() { + var ctrl = this, + wrapper = ctrl.$element.find('md-chips-wrap'); + + // 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.$watch('$mdChipsCtrl.items.length', function() { + if (ctrl.items && ctrl.items.length) { + // Dynamically add the listbox role + wrapper.attr('role', 'listbox'); + + // Generate some random IDs for each chip + ctrl.contentIds = ctrl.items.map(function() { + return ctrl.wrapperId + '-chip-' + ctrl.$mdUtil.nextUid(); + }); + + // Use the contentIDs above to generate the aria-owns attribute + wrapper.attr('aria-owns', ctrl.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 appends * the buffer to the chip list, while backspace removes the last chip in the @@ -230,7 +297,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 +334,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 +360,7 @@ MdChipsCtrl.prototype.removeAndSelectAdjacentChip = function(index) { */ MdChipsCtrl.prototype.resetSelectedChip = function() { this.selectedChip = -1; + this.ariaTabIndex = null; }; /** @@ -455,18 +541,34 @@ 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 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(); + } } - if (index === this.items.length) return this.onFocus(); + index = Math.max(index, 0); index = Math.min(index, this.items.length - 1); + this.selectChip(index); this.focusChip(index); }; +MdChipsCtrl.prototype.focusInput = function() { + this.selectChip(-1); + this.onFocus(); +}; + /** * Marks the chip at the given index as selected. * @param index @@ -477,7 +579,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 +601,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(); }; /** @@ -602,3 +708,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..e75e8e3a259 100644 --- a/src/components/chips/js/chipsDirective.js +++ b/src/components/chips/js/chipsDirective.js @@ -112,6 +112,9 @@ * @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 @@ -146,25 +149,33 @@ * */ - var MD_CHIPS_TEMPLATE = '\ \ + \ + {{$mdChipsCtrl.containerHint}}\ + \ \ \
\
\
\ @@ -176,8 +187,8 @@