diff --git a/docs/config/template/index.template.html b/docs/config/template/index.template.html index c98b2c51b41..c3429ad9f71 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 f8c1475b556..f0920923e7b 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) { @@ -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 }); @@ -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() { @@ -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() { @@ -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() { diff --git a/src/components/chips/contact-chips.spec.js b/src/components/chips/contact-chips.spec.js index 43422d0dded..77c22ee9ead 100644 --- a/src/components/chips/contact-chips.spec.js +++ b/src/components/chips/contact-chips.spec.js @@ -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>'; @@ -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]); diff --git a/src/components/chips/demoBasicUsage/index.html b/src/components/chips/demoBasicUsage/index.html index fdf7219aa00..d5cb4279835 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..bd76a4cd7a2 --- /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. 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> \ 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 3b4c46fabe0..9f34dcf2cdc 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,8 @@ MdChipsCtrl.prototype.inputKeydown = function(event) { this.appendChip(chipBuffer.trim()); this.resetChipBuffer(); + + return false; } }; @@ -230,7 +351,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 +388,22 @@ 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); + + // 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. + // + // TODO: Investigate calling from within chip $scope.$on('$destroy') to reduce/remove timeouts + self.$timeout(function() { + self.$timeout(function() { + self.selectAndFocusChipSafe(selIndex); + }); + }); }; /** @@ -275,6 +411,7 @@ MdChipsCtrl.prototype.removeAndSelectAdjacentChip = function(index) { */ MdChipsCtrl.prototype.resetSelectedChip = function() { this.selectedChip = -1; + this.ariaTabIndex = null; }; /** @@ -299,6 +436,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}); @@ -321,7 +459,8 @@ MdChipsCtrl.prototype.appendChip = function(newChip) { if (newChip == null || this.items.indexOf(newChip) + 1) return; // Append the new chip onto our list - var index = this.items.push(newChip); + var length = this.items.push(newChip); + var index = length - 1; // Update model validation this.ngModelCtrl.$setDirty(); @@ -458,18 +597,46 @@ 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.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(); +}; + /** * Marks the chip at the given index as selected. * @param index @@ -480,7 +647,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.'); @@ -502,7 +669,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(); }; /** @@ -528,6 +699,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(); }; @@ -613,3 +789,7 @@ MdChipsCtrl.prototype.shouldAddOnBlur = function() { 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..a2726f8056e 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,21 @@ * </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 + * order to achieve this, we modified the chips behavior to select newly appended chips for + * `300ms` before re-focusing the input and allowing the user to type. + * + * For most users, this delay is small enough that it will not be noticeable but allows certain + * screen readers to function properly (JAWS and NVDA in particular). + * + * We introduced a new `md-chip-append-delay` option to allow developers to better control this + * behavior. + * + * 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 +126,28 @@ * @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 select + * a newly appended chip 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 +177,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 +216,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 +232,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 +272,13 @@ onAdd: '&mdOnAdd', onRemove: '&mdOnRemove', onSelect: '&mdOnSelect', + inputAriaLabel: '@', + containerHint: '@', deleteHint: '@', deleteButtonLabel: '@', separatorKeys: '=?mdSeparatorKeys', - requireMatch: '=?mdRequireMatch' + requireMatch: '=?mdRequireMatch', + chipAppendDelayString: '@?mdChipAppendDelay' } }; @@ -323,7 +365,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 66be4efb109..20ef2480dfb 100644 --- a/src/components/chips/js/contactChipsDirective.js +++ b/src/components/chips/js/contactChipsDirective.js @@ -32,9 +32,10 @@ angular * @param {number=} md-min-length Specifies the minimum length of text before autocomplete will * make suggestions * - * * @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.* * * * @@ -57,6 +58,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"\ @@ -122,17 +124,23 @@ function MdContactChips($mdTheming, $mdUtil) { contacts: '=ngModel', requireMatch: '=?mdRequireMatch', minLength: '=?mdMinLength', - 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; + }); }; } }