diff --git a/docs/app/css/style.css b/docs/app/css/style.css index ab958eaa86d..44abd67bb08 100644 --- a/docs/app/css/style.css +++ b/docs/app/css/style.css @@ -155,13 +155,13 @@ ul { margin: 0; padding: 0; } -ul li:not(.md-nav-item) { +ul:not(.md-autocomplete-suggestions) li:not(.md-nav-item) { margin-left: 16px; padding: 0; margin-top: 3px; list-style-position: inside; } -ul li:not(.md-nav-item):first-child { +ul:not(.md-autocomplete-suggestions) li:not(.md-nav-item):first-child { margin-top: 0; } /************ diff --git a/src/components/select/demoBasicUsage/index.html b/src/components/select/demoBasicUsage/index.html index 165e0b2bb20..31942136bff 100755 --- a/src/components/select/demoBasicUsage/index.html +++ b/src/components/select/demoBasicUsage/index.html @@ -30,7 +30,8 @@ None - + {{state.abbrev}} @@ -60,7 +61,8 @@
What armor do you wear? - + Cloth Leather Chainmail diff --git a/src/components/select/demoOptionGroups/index.html b/src/components/select/demoOptionGroups/index.html index b3df40ddd1a..8f3ac30f8d4 100644 --- a/src/components/select/demoOptionGroups/index.html +++ b/src/components/select/demoOptionGroups/index.html @@ -9,7 +9,7 @@

Pick your pizza below

- + {{topping.name}} diff --git a/src/components/select/select-theme.scss b/src/components/select/select-theme.scss index 7d4024247da..dead7751b7a 100644 --- a/src/components/select/select-theme.scss +++ b/src/components/select/select-theme.scss @@ -154,19 +154,22 @@ md-select-menu.md-THEME_NAME-theme { &:not([disabled]) { &:focus, - &:hover { + &:hover, + &.md-focused { background-color: '{{background-500-0.18}}' } } &[selected] { color: '{{primary-500}}'; - &:focus { + &:focus, + &.md-focused { color: '{{primary-600}}'; } &.md-accent { color: '{{accent-color}}'; - &:focus { + &:focus, + &.md-focused { color: '{{accent-A700}}'; } } diff --git a/src/components/select/select.js b/src/components/select/select.js index 50ff0d51a7e..d89f3423d74 100755 --- a/src/components/select/select.js +++ b/src/components/select/select.js @@ -49,21 +49,21 @@ angular.module('material.components.select', [ * once; it is not watched. * @param {expression=} md-on-close Expression to be evaluated when the select is closed. * @param {expression=} md-on-open Expression to be evaluated when opening the select. - * Will hide the select options and show a spinner until the evaluated promise resolves. + * Will hide the select options and show a spinner until the evaluated promise resolves. * @param {expression=} md-selected-text Expression to be evaluated that will return a string - * to be displayed as a placeholder in the select input box when it is closed. The value - * will be treated as *text* (not html). + * to be displayed as a placeholder in the select input box when it is closed. The value + * will be treated as *text* (not html). * @param {expression=} md-selected-html Expression to be evaluated that will return a string - * to be displayed as a placeholder in the select input box when it is closed. The value - * will be treated as *html*. The value must either be explicitly marked as trustedHtml or - * the ngSanitize module must be loaded. + * to be displayed as a placeholder in the select input box when it is closed. The value + * will be treated as *html*. The value must either be explicitly marked as trustedHtml or + * the ngSanitize module must be loaded. * @param {string=} placeholder Placeholder hint text. * @param {boolean=} md-no-asterisk When set to true, an asterisk will not be appended to the - * floating label. **Note:** This attribute is only evaluated once; it is not watched. - * @param {string=} aria-label Optional label for accessibility. Only necessary if no placeholder or - * explicit label is present. + * floating label. **Note:** This attribute is only evaluated once; it is not watched. + * @param {string=} aria-label Optional label for accessibility. Only necessary if no explicit label + * is present. * @param {string=} md-container-class Class list to get applied to the `.md-select-menu-container` - * element (for custom styling). + * element (for custom styling). * * @usage * With a placeholder (label and aria-label are added dynamically) @@ -168,7 +168,8 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ } // empty placeholder controller to be initialized in link }; - function compile(element, attr) { + function compile(tElement, tAttrs) { + var isMultiple = $mdUtil.parseAttributeBoolean(tAttrs.multiple); // add the select value that will hold our placeholder or selected option value var valueEl = angular.element(''); valueEl.append(''); @@ -178,89 +179,102 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ } // There's got to be an md-content inside. If there's not one, let's add it. - var mdContentEl = element.find('md-content'); + var mdContentEl = tElement.find('md-content'); if (!mdContentEl.length) { - element.append(angular.element('').append(element.contents())); + tElement.append(angular.element('').append(tElement.contents())); + mdContentEl = tElement.find('md-content'); } - mdContentEl.attr('role', 'presentation'); + mdContentEl.attr('role', 'listbox'); + mdContentEl.attr('tabindex', '-1'); + if (isMultiple) { + mdContentEl.attr('aria-multiselectable', 'true'); + } else { + mdContentEl.attr('aria-multiselectable', 'false'); + } // Add progress spinner for md-options-loading - if (attr.mdOnOpen) { + if (tAttrs.mdOnOpen) { // Show progress indicator while loading async // Use ng-hide for `display:none` so the indicator does not interfere with the options list - element + tElement .find('md-content') .prepend(angular.element( '
' + - ' ' + + ' ' + '
' )); // Hide list [of item options] while loading async - element + tElement .find('md-option') .attr('ng-show', '$$loadingAsyncDone'); } - if (attr.name) { + if (tAttrs.name) { var autofillClone = angular.element(''); autofillClone.attr({ - 'name': attr.name, + 'name': tAttrs.name, 'aria-hidden': 'true', 'tabindex': '-1' }); - var opts = element.find('md-option'); + var opts = tElement.find('md-option'); angular.forEach(opts, function(el) { var newEl = angular.element(''); - if (el.hasAttribute('ng-value')) newEl.attr('ng-value', el.getAttribute('ng-value')); - else if (el.hasAttribute('value')) newEl.attr('value', el.getAttribute('value')); + if (el.hasAttribute('ng-value')) { + newEl.attr('ng-value', el.getAttribute('ng-value')); + } + else if (el.hasAttribute('value')) { + newEl.attr('value', el.getAttribute('value')); + } autofillClone.append(newEl); }); // Adds an extra option that will hold the selected value for the - // cases where the select is a part of a non-angular form. This can be done with a ng-model, + // cases where the select is a part of a non-AngularJS form. This can be done with a ng-model, // however if the `md-option` is being `ng-repeat`-ed, AngularJS seems to insert a similar // `option` node, but with a value of `? string: ?` which would then get submitted. // This also goes around having to prepend a dot to the name attribute. autofillClone.append( - '' + '' ); - element.parent().append(autofillClone); + tElement.parent().append(autofillClone); } - var isMultiple = $mdUtil.parseAttributeBoolean(attr.multiple); - // Use everything that's left inside element.contents() as the contents of the menu var multipleContent = isMultiple ? 'multiple' : ''; - var ngModelOptions = attr.ngModelOptions ? $mdUtil.supplant('ng-model-options="{0}"', [attr.ngModelOptions]) : ''; + var ngModelOptions = tAttrs.ngModelOptions ? $mdUtil.supplant('ng-model-options="{0}"', [tAttrs.ngModelOptions]) : ''; var selectTemplate = '' + ''; - selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, ngModelOptions, element.html()]); - element.empty().append(valueEl); - element.append(selectTemplate); + selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, ngModelOptions, tElement.html()]); + tElement.empty().append(valueEl); + tElement.append(selectTemplate); - if (!attr.tabindex){ - attr.$set('tabindex', 0); + if (!tAttrs.tabindex) { + tAttrs.$set('tabindex', 0); } - return function postLink(scope, element, attr, ctrls) { + return function postLink(scope, element, attrs, ctrls) { var untouched = true; - var isDisabled, ariaLabelBase; + var isDisabled; var containerCtrl = ctrls[0]; var mdSelectCtrl = ctrls[1]; var ngModelCtrl = ctrls[2]; var formCtrl = ctrls[3]; // grab a reference to the select menu value label - var valueEl = element.find('md-select-value'); - var isReadonly = angular.isDefined(attr.readonly); - var disableAsterisk = $mdUtil.parseAttributeBoolean(attr.mdNoAsterisk); + var selectValueElement = element.find('md-select-value'); + var isReadonly = angular.isDefined(attrs.readonly); + var disableAsterisk = $mdUtil.parseAttributeBoolean(attrs.mdNoAsterisk); + var stopNgMultipleWatch; + var userDefinedLabelledby = angular.isDefined(attrs.ariaLabelledby); + var listboxContentElement = element.find('md-content'); if (disableAsterisk) { element.addClass('md-no-asterisk'); @@ -282,80 +296,119 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ containerCtrl.input = element; if (!containerCtrl.label) { $mdAria.expect(element, 'aria-label', element.attr('placeholder')); + var selectLabel = element.attr('aria-label'); + if (!selectLabel) { + selectLabel = element.attr('placeholder'); + } + listboxContentElement.attr('aria-label', selectLabel); + } else { + containerCtrl.label.attr('aria-hidden', 'true'); + listboxContentElement.attr('aria-label', containerCtrl.label.text()); } - scope.$watch(isErrorGetter, containerCtrl.setInvalid); + var stopInvalidWatch = scope.$watch(isErrorGetter, containerCtrl.setInvalid); } var selectContainer, selectScope, selectMenuCtrl; - findSelectContainer(); + selectContainer = findSelectContainer(); $mdTheming(element); var originalRender = ngModelCtrl.$render; ngModelCtrl.$render = function() { originalRender(); - syncLabelText(); - syncAriaLabel(); + syncSelectValueText(); inputCheckValue(); }; - attr.$observe('placeholder', ngModelCtrl.$render); + var stopPlaceholderObserver = attrs.$observe('placeholder', ngModelCtrl.$render); - if (containerCtrl && containerCtrl.label) { - attr.$observe('required', function (value) { - // Toggle the md-required class on the input containers label, because the input container is automatically - // applying the asterisk indicator on the label. + var stopRequiredObserver = attrs.$observe('required', function (value) { + if (containerCtrl && containerCtrl.label) { + // Toggle the md-required class on the input containers label, because the input container + // is automatically applying the asterisk indicator on the label. containerCtrl.label.toggleClass('md-required', value && !disableAsterisk); - }); - } - - mdSelectCtrl.setLabelText = function(text) { - mdSelectCtrl.setIsPlaceholder(!text); + } + element.removeAttr('aria-required'); + if (value) { + listboxContentElement.attr('aria-required', 'true'); + } else { + listboxContentElement.removeAttr('aria-required'); + } + }); + /** + * Set the contents of the md-select-value element. This element's contents are announced by + * screen readers and used for displaying the value of the select in both single and multiple + * selection modes. + * @param {string=} text A sanitized and trusted HTML string or a pure text string from user + * input. + */ + mdSelectCtrl.setSelectValueText = function(text) { + var useDefaultText = text === undefined || text === ''; // Whether the select label has been given via user content rather than the internal // template of var isSelectLabelFromUser = false; - if (attr.mdSelectedText && attr.mdSelectedHtml) { + mdSelectCtrl.setIsPlaceholder(!text); + + if (attrs.mdSelectedText && attrs.mdSelectedHtml) { throw Error('md-select cannot have both `md-selected-text` and `md-selected-html`'); } - if (attr.mdSelectedText || attr.mdSelectedHtml) { - text = $parse(attr.mdSelectedText || attr.mdSelectedHtml)(scope); + if (attrs.mdSelectedText || attrs.mdSelectedHtml) { + text = $parse(attrs.mdSelectedText || attrs.mdSelectedHtml)(scope); isSelectLabelFromUser = true; - } else if (!text) { + } else if (useDefaultText) { // Use placeholder attribute, otherwise fallback to the md-input-container label - var tmpPlaceholder = attr.placeholder || + var tmpPlaceholder = attrs.placeholder || (containerCtrl && containerCtrl.label ? containerCtrl.label.text() : ''); text = tmpPlaceholder || ''; isSelectLabelFromUser = true; } - var target = valueEl.children().eq(0); + var target = selectValueElement.children().eq(0); - if (attr.mdSelectedHtml) { - // Using getTrustedHtml will run the content through $sanitize if it is not already - // explicitly trusted. If the ngSanitize module is not loaded, this will - // *correctly* throw an sce error. - target.html($sce.getTrustedHtml(text)); + if (attrs.mdSelectedHtml) { + // Using getTrustedHtml will run the content through $sanitize if it is not already + // explicitly trusted. If the ngSanitize module is not loaded, this will + // *correctly* throw an sce error. + target.html($sce.getTrustedHtml(text)); } else if (isSelectLabelFromUser) { target.text(text); } else { // If we've reached this point, the text is not user-provided. target.html(text); } + + if (useDefaultText) { + // Avoid screen readers double announcing the label name when no value has been selected + selectValueElement.attr('aria-hidden', 'true'); + if (!userDefinedLabelledby) { + element.removeAttr('aria-labelledby'); + } + } else { + selectValueElement.removeAttr('aria-hidden'); + if (!userDefinedLabelledby) { + element.attr('aria-labelledby', element[0].id + ' ' + selectValueElement[0].id) + } + } }; + /** + * @param {boolean} isPlaceholder true to mark the md-select-value element and + * input container, if one exists, with classes for styling when a placeholder is present. + * false to remove those classes. + */ mdSelectCtrl.setIsPlaceholder = function(isPlaceholder) { if (isPlaceholder) { - valueEl.addClass('md-select-placeholder'); + selectValueElement.addClass('md-select-placeholder'); if (containerCtrl && containerCtrl.label) { containerCtrl.label.addClass('md-placeholder'); } } else { - valueEl.removeClass('md-select-placeholder'); + selectValueElement.removeClass('md-select-placeholder'); if (containerCtrl && containerCtrl.label) { containerCtrl.label.removeClass('md-placeholder'); } @@ -387,13 +440,12 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ } mdSelectCtrl.triggerClose = function() { - $parse(attr.mdOnClose)(scope); + $parse(attrs.mdOnClose)(scope); }; scope.$$postDigest(function() { initAriaLabel(); - syncLabelText(); - syncAriaLabel(); + syncSelectValueText(); }); function initAriaLabel() { @@ -401,48 +453,51 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ if (!labelText && containerCtrl && containerCtrl.label) { labelText = containerCtrl.label.text(); } - ariaLabelBase = labelText; $mdAria.expect(element, 'aria-label', labelText); } - scope.$watch(function() { - return selectMenuCtrl.selectedLabels(); - }, syncLabelText); - - function syncLabelText() { - if (selectContainer) { - selectMenuCtrl = selectMenuCtrl || selectContainer.find('md-select-menu').controller('mdSelectMenu'); - mdSelectCtrl.setLabelText(selectMenuCtrl.selectedLabels()); - } - } + var stopSelectedLabelsWatcher = scope.$watch(function() { + return selectMenuCtrl.getSelectedLabels(); + }, syncSelectValueText); - function syncAriaLabel() { - if (!ariaLabelBase) return; - var ariaLabels = selectMenuCtrl.selectedLabels({mode: 'aria'}); - element.attr('aria-label', ariaLabels.length ? ariaLabelBase + ': ' + ariaLabels : ariaLabelBase); + function syncSelectValueText() { + selectMenuCtrl = selectMenuCtrl || + selectContainer.find('md-select-menu').controller('mdSelectMenu'); + mdSelectCtrl.setSelectValueText(selectMenuCtrl.getSelectedLabels()); } - var deregisterWatcher; - attr.$observe('ngMultiple', function(val) { - if (deregisterWatcher) deregisterWatcher(); + // TODO add tests for ngMultiple + // TODO add docs for ngMultiple + // TODO in 1.2.0 rename this to mdMultiple + var stopNgMultipleObserver = attrs.$observe('ngMultiple', function(val) { + if (stopNgMultipleWatch) { + stopNgMultipleWatch(); + } var parser = $parse(val); - deregisterWatcher = scope.$watch(function() { + stopNgMultipleWatch = scope.$watch(function() { return parser(scope); }, function(multiple, prevVal) { - if (multiple === undefined && prevVal === undefined) return; // assume compiler did a good job + var selectMenu = selectContainer.find('md-select-menu'); + // assume compiler did a good job + if (multiple === undefined && prevVal === undefined) { + return; + } if (multiple) { - element.attr('multiple', 'multiple'); + var setMultipleAttrs = {'multiple': 'multiple'}; + element.attr(setMultipleAttrs); + selectMenu.attr(setMultipleAttrs); } else { element.removeAttr('multiple'); + selectMenu.removeAttr('multiple'); } - element.attr('aria-multiselectable', multiple ? 'true' : 'false'); + element.find('md-content').attr('aria-multiselectable', multiple ? 'true' : 'false'); + if (selectContainer) { - selectMenuCtrl.setMultiple(multiple); + selectMenuCtrl.setMultiple(Boolean(multiple)); originalRender = ngModelCtrl.$render; ngModelCtrl.$render = function() { originalRender(); - syncLabelText(); - syncAriaLabel(); + syncSelectValueText(); inputCheckValue(); }; ngModelCtrl.$render(); @@ -450,7 +505,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ }); }); - attr.$observe('disabled', function(disabled) { + var stopDisabledObserver = attrs.$observe('disabled', function(disabled) { if (angular.isString(disabled)) { disabled = true; } @@ -463,26 +518,31 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ element .attr({'aria-disabled': 'true'}) .removeAttr('tabindex') + .removeAttr('aria-expanded') + .removeAttr('aria-haspopup') .off('click', openSelect) .off('keydown', handleKeypress); } else { element - .attr({'tabindex': attr.tabindex, 'aria-disabled': 'false'}) + .attr({ + 'tabindex': attrs.tabindex, + 'aria-haspopup': 'listbox' + }) + .removeAttr('aria-disabled') .on('click', openSelect) .on('keydown', handleKeypress); } }); - if (!attr.hasOwnProperty('disabled') && !attr.hasOwnProperty('ngDisabled')) { + if (!attrs.hasOwnProperty('disabled') && !attrs.hasOwnProperty('ngDisabled')) { element.attr({'aria-disabled': 'false'}); element.on('click', openSelect); element.on('keydown', handleKeypress); } var ariaAttrs = { - role: 'listbox', - 'aria-expanded': 'false', - 'aria-multiselectable': isMultiple && !attr.ngMultiple ? 'true' : 'false' + role: 'button', + 'aria-haspopup': 'listbox' }; if (!element[0].hasAttribute('id')) { @@ -491,13 +551,26 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ var containerId = 'select_container_' + $mdUtil.nextUid(); selectContainer.attr('id', containerId); + var listboxContentId = 'select_listbox_' + $mdUtil.nextUid(); + selectContainer.find('md-content').attr('id', listboxContentId); // Only add aria-owns if element ownership is NOT represented in the DOM. if (!element.find('md-select-menu').length) { - ariaAttrs['aria-owns'] = containerId; + ariaAttrs['aria-owns'] = listboxContentId; } element.attr(ariaAttrs); scope.$on('$destroy', function() { + stopRequiredObserver && stopRequiredObserver(); + stopDisabledObserver && stopDisabledObserver(); + stopNgMultipleWatch && stopNgMultipleWatch(); + stopNgMultipleObserver && stopNgMultipleObserver(); + stopSelectedLabelsWatcher && stopSelectedLabelsWatcher(); + stopPlaceholderObserver && stopPlaceholderObserver(); + stopInvalidWatch && stopInvalidWatch(); + + element.off('focus'); + element.off('blur'); + $mdSelect .destroy() .finally(function() { @@ -510,32 +583,38 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ }); }); - function inputCheckValue() { // The select counts as having a value if one or more options are selected, // or if the input's validity state says it has bad input (eg string in a number input) // we must do this on nextTick as the $render is sometimes invoked on nextTick. $mdUtil.nextTick(function () { - containerCtrl && containerCtrl.setHasValue(selectMenuCtrl.selectedLabels().length > 0 || (element[0].validity || {}).badInput); + containerCtrl && containerCtrl.setHasValue( + selectMenuCtrl.getSelectedLabels().length > 0 || (element[0].validity || {}).badInput); }); } function findSelectContainer() { - selectContainer = angular.element( + var selectContainer = angular.element( element[0].querySelector('.md-select-menu-container') ); selectScope = scope; - if (attr.mdContainerClass) { - var value = selectContainer[0].getAttribute('class') + ' ' + attr.mdContainerClass; + if (attrs.mdContainerClass) { + var value = selectContainer[0].getAttribute('class') + ' ' + attrs.mdContainerClass; selectContainer[0].setAttribute('class', value); } selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu'); - selectMenuCtrl.init(ngModelCtrl, attr.ngModel); + selectMenuCtrl.init(ngModelCtrl, attrs.ngModel); element.on('$destroy', function() { selectContainer.remove(); }); + return selectContainer; } + /** + * Determine if the select menu should be opened or an option in the select menu should be + * selected. + * @param {KeyboardEvent} e keyboard event to handle + */ function handleKeypress(e) { if ($mdConstant.isNavigationKey(e)) { // prevent page scrolling on interaction @@ -546,10 +625,14 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ e.preventDefault(); var node = selectMenuCtrl.optNodeForKeyboardSearch(e); - if (!node || node.hasAttribute('disabled')) return; + if (!node || node.hasAttribute('disabled')) { + return; + } var optionCtrl = angular.element(node).controller('mdOption'); if (!selectMenuCtrl.isMultiple) { - selectMenuCtrl.deselect(Object.keys(selectMenuCtrl.selected)[0]); + angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) { + selectMenuCtrl.deselect(key); + }); } selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value); selectMenuCtrl.refreshViewValue(); @@ -570,10 +653,11 @@ function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $ selectCtrl: mdSelectCtrl, preserveElement: true, hasBackdrop: true, - loadingAsync: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) || true : false + loadingAsync: attrs.mdOnOpen ? scope.$eval(attrs.mdOnOpen) || true : false }).finally(function() { selectScope._mdSelectIsOpen = false; - element.attr('aria-expanded', 'false'); + element.removeAttr('aria-expanded'); + element.removeAttr('aria-activedescendant'); ngModelCtrl.$setTouched(); }); } @@ -596,8 +680,8 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { // We use preLink instead of postLink to ensure that the select is initialized before // its child options run postLink. - function preLink(scope, element, attr, ctrls) { - var selectCtrl = ctrls[0]; + function preLink(scope, element, attrs, ctrls) { + var selectMenuCtrl = ctrls[0]; element.addClass('_md'); // private md component indicator for styling @@ -620,34 +704,41 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { return false; } - var optionHashKey = selectCtrl.hashGetter(optionCtrl.value); - var isSelected = angular.isDefined(selectCtrl.selected[optionHashKey]); + var optionHashKey = selectMenuCtrl.hashGetter(optionCtrl.value); + var isSelected = angular.isDefined(selectMenuCtrl.selected[optionHashKey]); scope.$apply(function() { - if (selectCtrl.isMultiple) { + if (selectMenuCtrl.isMultiple) { if (isSelected) { - selectCtrl.deselect(optionHashKey); + selectMenuCtrl.deselect(optionHashKey); } else { - selectCtrl.select(optionHashKey, optionCtrl.value); + selectMenuCtrl.select(optionHashKey, optionCtrl.value); } } else { if (!isSelected) { - selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]); - selectCtrl.select(optionHashKey, optionCtrl.value); + angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) { + selectMenuCtrl.deselect(key); + }); + selectMenuCtrl.select(optionHashKey, optionCtrl.value); } } - selectCtrl.refreshViewValue(); + selectMenuCtrl.refreshViewValue(); }); } } function SelectMenuController($scope, $attrs, $element) { var self = this; + var defaultIsEmpty; + var searchStr = ''; + var clearSearchTimeout, optNodes, optText; + var CLEAR_SEARCH_AFTER = 300; + self.isMultiple = angular.isDefined($attrs.multiple); // selected is an object with keys matching all of the selected options' hashed values self.selected = {}; // options is an object with keys matching every option's hash value, - // and values matching every option's controller. + // and values containing an instance of every option's controller. self.options = {}; $scope.$watchCollection(function() { @@ -656,14 +747,13 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { self.ngModel.$render(); }); - var deregisterCollectionWatch; - var defaultIsEmpty; + /** + * @param {boolean} isMultiple + */ self.setMultiple = function(isMultiple) { var ngModel = self.ngModel; defaultIsEmpty = defaultIsEmpty || ngModel.$isEmpty; - self.isMultiple = isMultiple; - if (deregisterCollectionWatch) deregisterCollectionWatch(); if (self.isMultiple) { // We want to delay the render method so that the directive has a chance to load before @@ -685,7 +775,9 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { // watchCollection on the model because by default ngModel only watches the model's // reference. This allows the developer to also push and pop from their array. $scope.$watchCollection(self.modelBinding, function(value) { - if (validateArray(value)) delayedRender(value); + if (validateArray(value)) { + delayedRender(value); + } }); ngModel.$isEmpty = function(value) { @@ -703,11 +795,12 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { } }; - var searchStr = ''; - var clearSearchTimeout, optNodes, optText; - var CLEAR_SEARCH_AFTER = 300; - + /** + * @param {KeyboardEvent} e keyboard event to handle + * @return {DOMElement|HTMLElement|undefined} + */ self.optNodeForKeyboardSearch = function(e) { + var search, i; clearSearchTimeout && clearTimeout(clearSearchTimeout); clearSearchTimeout = setTimeout(function() { clearSearchTimeout = undefined; @@ -717,7 +810,7 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { }, CLEAR_SEARCH_AFTER); searchStr += e.key; - var search = new RegExp('^' + $mdUtil.sanitize(searchStr), 'i'); + search = new RegExp('^' + $mdUtil.sanitize(searchStr), 'i'); if (!optNodes) { optNodes = $element.find('md-option'); optText = new Array(optNodes.length); @@ -725,7 +818,7 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { optText[i] = el.textContent.trim(); }); } - for (var i = 0; i < optText.length; ++i) { + for (i = 0; i < optText.length; ++i) { if (search.test(optText[i])) { return optNodes[i]; } @@ -780,32 +873,51 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { } }; - self.selectedLabels = function(opts) { + /** + * @param {string=} id + */ + self.setActiveDescendant = function(id) { + if (angular.isDefined(id)) { + $element.find('md-content').attr('aria-activedescendant', id); + } else { + $element.find('md-content').removeAttr('aria-activedescendant'); + } + }; + + /** + * @param {{mode: string}=} opts options object to allow specifying html (default) or aria mode. + * @return {string} comma separated set of selected values + */ + self.getSelectedLabels = function(opts) { opts = opts || {}; var mode = opts.mode || 'html'; - var selectedOptionEls = $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]')); + var selectedOptionEls = + $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]')); + if (selectedOptionEls.length) { var mapFn; - if (mode == 'html') { + if (mode === 'html') { // Map the given element to its innerHTML string. If the element has a child ripple // container remove it from the HTML string, before returning the string. mapFn = function(el) { - // If we do not have a `value` or `ng-value`, assume it is an empty option which clears the select + // If we do not have a `value` or `ng-value`, assume it is an empty option which clears + // the select. if (el.hasAttribute('md-option-empty')) { return ''; } var html = el.innerHTML; - // Remove the ripple container from the selected option, copying it would cause a CSP violation. + // Remove the ripple container from the selected option, copying it would cause a CSP + // violation. var rippleContainer = el.querySelector('.md-ripple-container'); if (rippleContainer) { html = html.replace(rippleContainer.outerHTML, ''); } - // Remove the checkbox container, because it will cause the label to wrap inside of the placeholder. - // It should be not displayed inside of the label element. + // Remove the checkbox container, because it will cause the label to wrap inside of the + // placeholder. It should be not displayed inside of the label element. var checkboxContainer = el.querySelector('.md-container'); if (checkboxContainer) { html = html.replace(checkboxContainer.outerHTML, ''); @@ -813,8 +925,10 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { return html; }; - } else if (mode == 'aria') { - mapFn = function(el) { return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent; }; + } else if (mode === 'aria') { + mapFn = function(el) { + return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent; + }; } // Ensure there are no duplicates; see https://github.com/angular/material/issues/9442 @@ -824,17 +938,35 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { } }; + /** + * Mark an option as selected + * @param {string} hashKey key within the SelectMenuController.options object, which is an + * instance of OptionController. + * @param {OptionController} hashedValue value to associate with the key + */ self.select = function(hashKey, hashedValue) { var option = self.options[hashKey]; - option && option.setSelected(true); + option && option.setSelected(true, self.isMultiple); self.selected[hashKey] = hashedValue; }; + + /** + * Mark an option as not selected + * @param {string} hashKey key within the SelectMenuController.options object, which is an + * instance of OptionController. + */ self.deselect = function(hashKey) { var option = self.options[hashKey]; - option && option.setSelected(false); + option && option.setSelected(false, self.isMultiple); delete self.selected[hashKey]; }; + /** + * Add an option to the select + * @param {string} hashKey key within the SelectMenuController.options object, which is an + * instance of OptionController. + * @param {OptionController} optionCtrl instance to associate with the key + */ self.addOption = function(hashKey, optionCtrl) { if (angular.isDefined(self.options[hashKey])) { throw new Error('Duplicate md-option values are not allowed in a select. ' + @@ -859,6 +991,12 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { self.refreshViewValue(); } }; + + /** + * Remove an option from the select + * @param {string} hashKey key within the SelectMenuController.options object, which is an + * instance of OptionController. + */ self.removeOption = function(hashKey) { delete self.options[hashKey]; // Don't deselect an option when it's removed - the user's ngModel should be allowed @@ -941,7 +1079,6 @@ function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) { self.select(self.hashGetter(value), value); } } - } /** @@ -1029,48 +1166,56 @@ function OptionDirective($mdButtonInkRipple, $mdUtil, $mdTheming) { compile: compile }; - function compile(element, attr) { + function compile(element, attrs) { // Manual transclusion to avoid the extra inner that ng-transclude generates element.append(angular.element('
').append(element.contents())); - element.attr('tabindex', attr.tabindex || '0'); + element.attr('tabindex', attrs.tabindex || '0'); - if (!hasDefinedValue(attr)) { + if (!hasDefinedValue(attrs)) { element.attr('md-option-empty', ''); } return postLink; } - function hasDefinedValue(attr) { - var value = attr.value; - var ngValue = attr.ngValue; + /** + * @param {Object} attrs list of attributes from the compile function + * @return {string|undefined|null} if defined and non-empty, return the value of the option's + * value attribute, otherwise return the value of the option's ng-value attribute. + */ + function hasDefinedValue(attrs) { + var value = attrs.value; + var ngValue = attrs.ngValue; return value || ngValue; } - function postLink(scope, element, attr, ctrls) { + function postLink(scope, element, attrs, ctrls) { var optionCtrl = ctrls[0]; - var selectCtrl = ctrls[1]; + var selectMenuCtrl = ctrls[1]; $mdTheming(element); - if (selectCtrl.isMultiple) { + if (selectMenuCtrl.isMultiple) { element.addClass('md-checkbox-enabled'); element.prepend(CHECKBOX_SELECTION_INDICATOR.clone()); } - if (angular.isDefined(attr.ngValue)) { - scope.$watch(attr.ngValue, setOptionValue); - } else if (angular.isDefined(attr.value)) { - setOptionValue(attr.value); + if (angular.isDefined(attrs.ngValue)) { + scope.$watch(attrs.ngValue, function (newValue, oldValue) { + setOptionValue(newValue, oldValue); + element.removeAttr('aria-checked'); + }); + } else if (angular.isDefined(attrs.value)) { + setOptionValue(attrs.value); } else { scope.$watch(function() { return element.text().trim(); }, setOptionValue); } - attr.$observe('disabled', function(disabled) { + attrs.$observe('disabled', function(disabled) { if (disabled) { element.attr('tabindex', '-1'); } else { @@ -1078,27 +1223,17 @@ function OptionDirective($mdButtonInkRipple, $mdUtil, $mdTheming) { } }); - scope.$$postDigest(function() { - attr.$observe('selected', function(selected) { - if (!angular.isDefined(selected)) return; - if (typeof selected == 'string') selected = true; - if (selected) { - if (!selectCtrl.isMultiple) { - selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]); - } - selectCtrl.select(optionCtrl.hashKey, optionCtrl.value); - } else { - selectCtrl.deselect(optionCtrl.hashKey); - } - selectCtrl.refreshViewValue(); - }); - }); - $mdButtonInkRipple.attach(scope, element); configureAria(); + /** + * @param {*} newValue the option's new value + * @param {*=} oldValue the option's previous value + * @param {boolean=} prevAttempt true if this had to be attempted again due to an undefined + * hashGetter on the selectCtrl, undefined otherwise. + */ function setOptionValue(newValue, oldValue, prevAttempt) { - if (!selectCtrl.hashGetter) { + if (!selectMenuCtrl.hashGetter) { if (!prevAttempt) { scope.$$postDigest(function() { setOptionValue(newValue, oldValue, true); @@ -1106,49 +1241,64 @@ function OptionDirective($mdButtonInkRipple, $mdUtil, $mdTheming) { } return; } - var oldHashKey = selectCtrl.hashGetter(oldValue, scope); - var newHashKey = selectCtrl.hashGetter(newValue, scope); + var oldHashKey = selectMenuCtrl.hashGetter(oldValue, scope); + var newHashKey = selectMenuCtrl.hashGetter(newValue, scope); optionCtrl.hashKey = newHashKey; optionCtrl.value = newValue; - selectCtrl.removeOption(oldHashKey, optionCtrl); - selectCtrl.addOption(newHashKey, optionCtrl); + selectMenuCtrl.removeOption(oldHashKey, optionCtrl); + selectMenuCtrl.addOption(newHashKey, optionCtrl); } scope.$on('$destroy', function() { - selectCtrl.removeOption(optionCtrl.hashKey, optionCtrl); + selectMenuCtrl.removeOption(optionCtrl.hashKey, optionCtrl); }); function configureAria() { var ariaAttrs = { - 'role': 'option', - 'aria-selected': 'false' + 'role': 'option' }; + // We explicitly omit the `aria-selected` attribute from single-selection, unselected + // options. Including the `aria-selected="false"` attributes adds a significant amount of + // noise to screen-reader users without providing useful information. + if (selectMenuCtrl.isMultiple) { + ariaAttrs['aria-selected'] = 'false'; + } + if (!element[0].hasAttribute('id')) { ariaAttrs.id = 'select_option_' + $mdUtil.nextUid(); } element.attr(ariaAttrs); } } +} - function OptionController($element) { - this.selected = false; - this.setSelected = function(isSelected) { - if (isSelected && !this.selected) { - $element.attr({ - 'selected': 'selected', - 'aria-selected': 'true' - }); - } else if (!isSelected && this.selected) { - $element.removeAttr('selected'); +function OptionController($element) { + /** + * @param {boolean} isSelected + * @param {boolean=} isMultiple + */ + this.setSelected = function(isSelected, isMultiple) { + if (isSelected) { + $element.attr({ + 'selected': 'true', + 'aria-selected': 'true' + }); + } else if (!isSelected) { + $element.removeAttr('selected'); + + if (isMultiple) { $element.attr('aria-selected', 'false'); + } else { + // We explicitly omit the `aria-selected` attribute from single-selection, unselected + // options. Including the `aria-selected="false"` attributes adds a significant amount of + // noise to screen-reader users without providing useful information. + $element.removeAttr('aria-selected'); } - this.selected = isSelected; - }; - } - + } + }; } /** @@ -1204,7 +1354,7 @@ function OptgroupDirective() { restrict: 'E', compile: compile }; - function compile(el, attrs) { + function compile(element, attrs) { // If we have a select header element, we don't want to add the normal label // header. if (!hasSelectHeader()) { @@ -1212,18 +1362,20 @@ function OptgroupDirective() { } function hasSelectHeader() { - return el.parent().find('md-select-header').length; + return element.parent().find('md-select-header').length; } function setupLabelElement() { - var labelElement = el.find('label'); + var labelElement = element.find('label'); if (!labelElement.length) { labelElement = angular.element('