diff --git a/src/components/select/select.js b/src/components/select/select.js index d52aa8016c3..d269e93741c 100755 --- a/src/components/select/select.js +++ b/src/components/select/select.js @@ -132,6 +132,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par element.append(angular.element('').append(element.contents())); } + // Add progress spinner for md-options-loading if (attr.mdOnOpen) { @@ -173,17 +174,18 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par // Use everything that's left inside element.contents() as the contents of the menu var multiple = angular.isDefined(attr.multiple) ? 'multiple' : ''; var selectTemplate = '' + - '
' + + ''; selectTemplate = $mdUtil.supplant(selectTemplate, [multiple, element.html()]); element.empty().append(valueEl); + element.append(selectTemplate); attr.tabindex = attr.tabindex || '0'; return function postLink(scope, element, attr, ctrls) { - var isDisabled; + var isDisabled, ariaLabelBase; // Remove event ngModel's blur listener for touched and untouched // we will do it ourself. @@ -218,7 +220,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par var selectContainer, selectScope, selectMenuCtrl; - createSelect(); + getSelect(); $mdTheming(element); if (attr.name && formCtrl) { @@ -241,6 +243,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par ngModelCtrl.$render = function() { originalRender(); syncLabelText(); + syncAriaLabel(); inputCheckValue(); }; @@ -290,15 +293,17 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par }; scope.$$postDigest(function() { - setAriaLabel(); + initAriaLabel(); syncLabelText(); + syncAriaLabel(); }); - function setAriaLabel() { - var labelText = element.attr('placeholder'); + function initAriaLabel() { + var labelText = element.attr('aria-label') || element.attr('placeholder'); if (!labelText && containerCtrl && containerCtrl.label) { labelText = containerCtrl.label.text(); } + ariaLabelBase = labelText; $mdAria.expect(element, 'aria-label', labelText); } @@ -311,6 +316,11 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par } } + function syncAriaLabel() { + if (!ariaLabelBase) return; + element.attr('aria-label', ariaLabelBase + ': ' + selectMenuCtrl.selectedLabels({mode: 'aria'})); + } + var deregisterWatcher; attr.$observe('ngMultiple', function(val) { if (deregisterWatcher) deregisterWatcher(); @@ -324,12 +334,14 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par } else { element.removeAttr('multiple'); } + element.attr('aria-multiselectable', multiple ? 'true' : 'false'); if (selectContainer) { selectMenuCtrl.setMultiple(multiple); originalRender = ngModelCtrl.$render; ngModelCtrl.$render = function() { originalRender(); syncLabelText(); + syncAriaLabel(); inputCheckValue(); }; ngModelCtrl.$render(); @@ -365,9 +377,11 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par var ariaAttrs = { - role: 'combobox', - 'aria-expanded': 'false' + role: 'listbox', + 'aria-expanded': 'false', + 'aria-multiselectable': attr.multiple !== undefined && !attr.ngMultiple ? 'true' : 'false' }; + if (!element[0].hasAttribute('id')) { ariaAttrs.id = 'select_' + $mdUtil.nextUid(); } @@ -377,10 +391,6 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par $mdSelect .destroy() .finally(function() { - if ( selectContainer ) { - selectContainer.remove(); - } - if (containerCtrl) { containerCtrl.setFocused(false); containerCtrl.setHasValue(false); @@ -398,18 +408,15 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par } // Create a fake select to find out the label value - function createSelect() { - selectContainer = angular.element(selectTemplate); - var selectEl = selectContainer.find('md-select-menu'); - selectEl.data('$ngModelController', ngModelCtrl); - selectEl.data('$mdSelectController', mdSelectCtrl); - selectScope = scope.$new(); - $mdTheming.inherit(selectContainer, element); + function getSelect() { + selectContainer = angular.element( + element[0].querySelector('.md-select-menu-container') + ); + selectScope = selectContainer.scope(); if (element.attr('md-container-class')) { var value = selectContainer[0].getAttribute('class') + ' ' + element.attr('md-container-class'); selectContainer[0].setAttribute('class', value); } - selectContainer = $compile(selectContainer)(selectScope); selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu'); } @@ -434,8 +441,9 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par } } - function openSelect() { + function openSelect(e) { selectScope.isOpen = true; + element.attr('aria-expanded', 'true'); $mdSelect.show({ scope: selectScope, @@ -449,6 +457,7 @@ function SelectDirective($mdSelect, $mdUtil, $mdTheming, $mdAria, $compile, $par loadingAsync: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) || true : false }).finally(function() { selectScope.isOpen = false; + element.attr('aria-expanded', 'false'); ngModelCtrl.$setTouched(); }); } @@ -460,7 +469,8 @@ function SelectMenuDirective($parse, $mdUtil, $mdTheming) { return { restrict: 'E', - require: ['mdSelectMenu', '?ngModel'], + require: ['mdSelectMenu', '^ngModel'], + scope: true, controller: SelectMenuController, link: {pre: preLink} }; @@ -475,15 +485,6 @@ function SelectMenuDirective($parse, $mdUtil, $mdTheming) { element.on('click', clickListener); element.on('keypress', keyListener); if (ngModel) selectCtrl.init(ngModel); - configureAria(); - - function configureAria() { - element.attr({ - 'id': 'select_menu_' + $mdUtil.nextUid(), - 'role': 'listbox', - 'aria-multiselectable': (selectCtrl.isMultiple ? 'true' : 'false') - }); - } function keyListener(e) { if (e.keyCode == 13 || e.keyCode == 32) { @@ -623,12 +624,19 @@ function SelectMenuDirective($parse, $mdUtil, $mdTheming) { self.setMultiple(self.isMultiple); }; - self.selectedLabels = function() { + self.selectedLabels = function(opts) { + opts = opts || {}; + var mode = opts.mode || 'html'; var selectedOptionEls = $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]')); if (selectedOptionEls.length) { - return selectedOptionEls.map(function(el) { - return el.innerHTML; - }).join(', '); + var mapFn; + + if (mode == 'html') { + mapFn = function(el) { return el.innerHTML; }; + } else if (mode == 'aria') { + mapFn = function(el) { return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent; }; + } + return selectedOptionEls.map(mapFn).join(', '); } else { return ''; } @@ -844,7 +852,7 @@ function SelectProvider($$interimElementProvider) { }); /* @ngInject */ - function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate) { + function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate, $document) { var ERRROR_TARGET_EXPECTED = "$mdSelect.show() expected a target element in options.target but got '{0}'!"; var animator = $mdUtil.dom.animator; @@ -869,7 +877,7 @@ function SelectProvider($$interimElementProvider) { // For navigation $destroy events, do a quick, non-animated removal, // but for normal closes (from clicks, etc) animate the removal - return (opts.$destroy === true) ? detachAndClean() : animateRemoval().then( detachAndClean ); + return (opts.$destroy === true) ? cleanElement() : animateRemoval().then( cleanElement ); /** * For normal closes (eg clicks), animate the removal. @@ -881,14 +889,14 @@ function SelectProvider($$interimElementProvider) { } /** - * Detach the element and cleanup prior changes + * Clean the element up to a closed state */ - function detachAndClean() { - configureAria(opts.target, false); + function cleanElement() { element.attr('opacity', 0); element.removeClass('md-active'); - detachElement(element, opts); + element.attr('aria-hidden', 'true'); + element[0].style.display = 'none'; announceClosed(opts); @@ -906,12 +914,12 @@ function SelectProvider($$interimElementProvider) { watchAsyncLoad(); sanitizeAndConfigure(scope, opts); - configureAria(opts.target); opts.hideBackdrop = showBackdrop(scope, element, opts); return showDropDown(scope, element, opts) .then(function(response) { + element.attr('aria-hidden', 'false'); opts.alreadyOpen = true; opts.cleanupInteraction = activateInteraction(); opts.cleanupResizing = activateResizing(); @@ -998,7 +1006,7 @@ function SelectProvider($$interimElementProvider) { if (options.disableParentScroll) options.restoreScroll(); delete options.restoreScroll; - } + }; } /** @@ -1059,7 +1067,7 @@ function SelectProvider($$interimElementProvider) { // Disable resizing handlers window.off('resize', debouncedOnResize); window.off('orientationchange', debouncedOnResize); - } + }; } /** @@ -1078,7 +1086,7 @@ function SelectProvider($$interimElementProvider) { delete opts.loadingAsync; }).then(function() { $$rAF(positionAndFocusMenu); - }) + }); } } @@ -1099,12 +1107,12 @@ function SelectProvider($$interimElementProvider) { // Escape to close // Cycling of options, and closing on enter dropDown.on('keydown', onMenuKeyDown); - dropDown.on('mouseup', checkCloseMenu); + dropDown.on('click', checkCloseMenu); return function cleanupInteraction() { opts.backdrop && opts.backdrop.off('click', onBackdropClick); dropDown.off('keydown', onMenuKeyDown); - dropDown.off('mouseup', checkCloseMenu); + dropDown.off('click', checkCloseMenu); element.removeClass('md-clickable'); opts.isRemoved = true; @@ -1123,14 +1131,14 @@ function SelectProvider($$interimElementProvider) { function onMenuKeyDown(ev) { var keyCodes = $mdConstant.KEY_CODE; + ev.preventDefault(); + ev.stopPropagation(); switch (ev.keyCode) { case keyCodes.UP_ARROW: return focusPrevOption(); - break; case keyCodes.DOWN_ARROW: return focusNextOption(); - break; case keyCodes.SPACE: case keyCodes.ENTER: var option = $mdUtil.getClosest(ev.target, 'md-option'); @@ -1190,11 +1198,13 @@ function SelectProvider($$interimElementProvider) { } function checkCloseMenu(ev) { - if (ev && ( ev.type == 'mouseup') && (ev.currentTarget != dropDown[0])) return; + if (ev && ( ev.type == 'click') && (ev.currentTarget != dropDown[0])) return; if ( mouseOnScrollbar() ) return; var option = $mdUtil.getClosest(ev.target, 'md-option'); if (option && option.hasAttribute && !option.hasAttribute('disabled')) { + ev.preventDefault(); + ev.stopPropagation(); if (!selectCtrl.isMultiple) { opts.restoreFocus = true; @@ -1224,14 +1234,6 @@ function SelectProvider($$interimElementProvider) { } - /** - * - */ - function configureAria(element, isExpanded) { - isExpanded = angular.isUndefined(isExpanded) ? 'true' : 'false'; - element && element.attr('aria-expanded', isExpanded); - } - /** * To notify listeners that the Select menu has closed, * trigger the [optional] user-defined expression @@ -1245,23 +1247,15 @@ function SelectProvider($$interimElementProvider) { } } - /** - * Use browser to remove this element without triggering a $destroy event - */ - function detachElement(element, opts) { - if (element[0].parentNode === opts.parent[0]) { - opts.parent[0].removeChild(element[0]); - } - } /** * Calculate the */ function calculateMenuPositions(scope, element, opts) { - var optionNodes, + var containerNode = element[0], - targetNode = opts.target[0].firstElementChild, // target the label - parentNode = opts.parent[0], + targetNode = opts.target[0].children[1], // target the label + parentNode = $document[0].body, selectNode = opts.selectEl[0], contentNode = opts.contentEl[0], parentRect = parentNode.getBoundingClientRect(), @@ -1329,6 +1323,7 @@ function SelectProvider($$interimElementProvider) { opts.focusedNode = focusedNode; // Get the selectMenuRect *after* max-width is possibly set above + containerNode.style.display = 'block'; var selectMenuRect = selectNode.getBoundingClientRect(); var centeredRect = getOffsetRect(centeredNode); @@ -1367,7 +1362,7 @@ function SelectProvider($$interimElementProvider) { } else { left = (targetRect.left + centeredRect.left - centeredRect.paddingLeft) + 2; top = Math.floor(targetRect.top + targetRect.height / 2 - centeredRect.height / 2 - - centeredRect.top + contentNode.scrollTop) + 2; + centeredRect.top + contentNode.scrollTop) + 5; transformOrigin = (centeredRect.left + targetRect.width / 2) + 'px ' + (centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop) + 'px 0px'; diff --git a/src/components/select/select.scss b/src/components/select/select.scss index ccc94be9c56..974714bec57 100755 --- a/src/components/select/select.scss +++ b/src/components/select/select.scss @@ -11,6 +11,7 @@ $select-max-visible-options: 5; top: 0; z-index: $z-index-select; opacity: 0; + display: none; // Don't let the user select a new choice while it's animating &:not(.md-clickable) { @@ -25,6 +26,7 @@ $select-max-visible-options: 5; // enter: md-select scales in, then options fade in. &.md-active { + display: block; opacity: 1; md-select-menu { transition: $swift-ease-out;