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 {
@@ -1040,27 +1178,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);
@@ -1068,49 +1196,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;
- };
- }
-
+ }
+ };
}
/**
@@ -1166,7 +1309,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()) {
@@ -1174,18 +1317,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('');
- el.prepend(labelElement);
+ element.prepend(labelElement);
}
labelElement.addClass('md-container-ignore');
labelElement.attr('aria-hidden', 'true');
- if (attrs.label) labelElement.text(attrs.label);
+ if (attrs.label) {
+ labelElement.text(attrs.label);
+ }
}
}
}
@@ -1242,7 +1387,7 @@ function SelectProvider($$interimElementProvider) {
/**
* For normal closes (eg clicks), animate the removal.
* For forced closes (like $destroy events from navigation),
- * skip the animations
+ * skip the animations.
*/
function animateRemoval() {
animationRunner = $animateCss(element, {addClass: 'md-leave'});
@@ -1271,15 +1416,21 @@ function SelectProvider($$interimElementProvider) {
announceClosed(opts);
- if (!opts.$destroy && opts.restoreFocus) {
- opts.target.focus();
+ if (!opts.$destroy) {
+ if (opts.restoreFocus) {
+ opts.target.focus();
+ } else {
+ // Make sure that the container's md-input-focused is removed on backdrop click.
+ $mdUtil.nextTick(function() {
+ opts.target.triggerHandler('blur');
+ }, true);
+ }
}
}
-
}
/**
- * Interim-element onShow logic....
+ * Interim-element onShow logic.
*/
function onShow(scope, element, opts) {
@@ -1294,7 +1445,7 @@ function SelectProvider($$interimElementProvider) {
opts.alreadyOpen = true;
opts.cleanupInteraction = activateInteraction();
opts.cleanupResizing = activateResizing();
- autoFocus(opts.focusedNode);
+ opts.contentEl[0].focus();
return response;
}, opts.hideBackdrop);
@@ -1304,21 +1455,18 @@ function SelectProvider($$interimElementProvider) {
// ************************************
/**
- * Attach the select DOM element(s) and animate to the correct positions
- * and scalings...
+ * Attach the select DOM element(s) and animate to the correct positions and scale.
*/
function showDropDown(scope, element, opts) {
if (opts.parent !== element.parent()) {
- element.parent().attr('aria-owns', element.attr('id'));
+ element.parent().attr('aria-owns', element.find('md-content').attr('id'));
}
element.parent().find('md-select-value').attr('aria-hidden', 'true');
opts.parent.append(element);
return $q(function(resolve, reject) {
-
try {
-
$animateCss(element, {removeClass: 'md-leave', duration: 0})
.start()
.then(positionAndFocusMenu)
@@ -1327,13 +1475,11 @@ function SelectProvider($$interimElementProvider) {
} catch (e) {
reject(e);
}
-
});
}
/**
- * Initialize container and dropDown menu positions/scale, then animate
- * to show.
+ * Initialize container and dropDown menu positions/scale, then animate to show.
*/
function positionAndFocusMenu() {
return $q(function(resolve) {
@@ -1356,7 +1502,7 @@ function SelectProvider($$interimElementProvider) {
}
/**
- * Show modal backdrop element...
+ * Show modal backdrop element.
*/
function showBackdrop(scope, element, options) {
@@ -1387,11 +1533,56 @@ function SelectProvider($$interimElementProvider) {
}
/**
- *
+ * @param {DOMElement|HTMLElement|null=} previousNode
+ * @param {DOMElement|HTMLElement} node
+ * @param {SelectMenuController|Function} menuController SelectMenuController instance
+ * @param {Function|*} selectController SelectController instance
+ */
+ function focusOptionNode(previousNode, node, menuController, selectController) {
+ var previousOptionElement, optionElement, previousOptionController, optionController;
+ var listboxContentNode = opts.contentEl[0];
+
+ if (node) {
+ if (previousNode) {
+ previousOptionElement = angular.element(previousNode);
+ previousOptionController = previousOptionElement.controller('mdOption');
+ if (previousOptionController && !menuController.isMultiple) {
+ menuController.deselect(previousOptionController.hashKey);
+ }
+ previousOptionElement.removeClass('md-focused');
+ }
+
+ optionElement = angular.element(node);
+ optionController = optionElement.controller('mdOption');
+ if (optionController && !menuController.isMultiple) {
+ menuController.select(optionController.hashKey, optionController.value);
+ }
+ optionElement.addClass('md-focused');
+ menuController.setActiveDescendant(node.id);
+
+ // Scroll the node into view if needed.
+ if (listboxContentNode.scrollHeight > listboxContentNode.clientHeight) {
+ var scrollBottom = listboxContentNode.clientHeight + listboxContentNode.scrollTop;
+ var nodeBottom = node.offsetTop + node.offsetHeight;
+ if (nodeBottom > scrollBottom) {
+ listboxContentNode.scrollTop = nodeBottom - listboxContentNode.clientHeight;
+ } else if (node.offsetTop < listboxContentNode.scrollTop) {
+ listboxContentNode.scrollTop = node.offsetTop;
+ }
+ }
+ opts.focusedNode = node;
+ menuController.refreshViewValue();
+ }
+ }
+
+ /**
+ * @param {DOMElement|HTMLElement} nodeToFocus
*/
- function autoFocus(focusedNode) {
- if (focusedNode && !focusedNode.hasAttribute('disabled')) {
- focusedNode.focus();
+ function autoFocus(nodeToFocus) {
+ var selectMenuController;
+ if (nodeToFocus && !nodeToFocus.hasAttribute('disabled')) {
+ selectMenuController = opts.selectEl.controller('mdSelectMenu');
+ focusOptionNode(null, nodeToFocus, selectMenuController, opts.selectCtrl);
}
}
@@ -1399,7 +1590,7 @@ function SelectProvider($$interimElementProvider) {
* Check for valid opts and set some sane defaults
*/
function sanitizeAndConfigure(scope, options) {
- var selectEl = element.find('md-select-menu');
+ var selectMenuElement = element.find('md-select-menu');
if (!options.target) {
throw new Error($mdUtil.supplant(ERROR_TARGET_EXPECTED, [options.target]));
@@ -1409,9 +1600,9 @@ function SelectProvider($$interimElementProvider) {
isRemoved: false,
target: angular.element(options.target), // make sure it's not a naked DOM node
parent: angular.element(options.parent),
- selectEl: selectEl,
+ selectEl: selectMenuElement,
contentEl: element.find('md-content'),
- optionNodes: selectEl[0].getElementsByTagName('md-option')
+ optionNodes: selectMenuElement[0].getElementsByTagName('md-option')
});
}
@@ -1448,8 +1639,7 @@ function SelectProvider($$interimElementProvider) {
}
/**
- * If asynchronously loading, watch and update internal
- * '$$loadingAsyncDone' flag
+ * If asynchronously loading, watch and update internal '$$loadingAsyncDone' flag.
*/
function watchAsyncLoad() {
if (opts.loadingAsync && !opts.isRemoved) {
@@ -1465,14 +1655,14 @@ function SelectProvider($$interimElementProvider) {
}
}
- /**
- *
- */
function activateInteraction() {
- if (opts.isRemoved) return;
+ if (opts.isRemoved) {
+ return;
+ }
var dropDown = opts.selectEl;
- var selectCtrl = dropDown.controller('mdSelectMenu') || {};
+ var selectMenuController = dropDown.controller('mdSelectMenu') || {};
+ var listbox = opts.contentEl;
element.addClass('md-clickable');
@@ -1515,11 +1705,10 @@ function SelectProvider($$interimElementProvider) {
return focusNextOption();
case keyCodes.SPACE:
case keyCodes.ENTER:
- var option = $mdUtil.getClosest(ev.target, 'md-option');
- if (option) {
+ if (opts.focusedNode) {
dropDown.triggerHandler({
type: 'click',
- target: option
+ target: opts.focusedNode
});
ev.preventDefault();
}
@@ -1534,17 +1723,24 @@ function SelectProvider($$interimElementProvider) {
break;
default:
if (shouldHandleKey(ev, $mdConstant)) {
- var optNode = dropDown.controller('mdSelectMenu').optNodeForKeyboardSearch(ev);
- opts.focusedNode = optNode || opts.focusedNode;
- optNode && optNode.focus();
+ var optNode = selectMenuController.optNodeForKeyboardSearch(ev);
+ if (optNode && !optNode.hasAttribute('disabled')) {
+ focusOptionNode(opts.focusedNode, optNode, selectMenuController, opts.selectCtrl);
+ }
}
}
}
+ /**
+ * Change the focus to another option. If there is no focused option, focus the first
+ * option. If there is a focused option, then use the direction to determine if we should
+ * focus the previous or next option in the list.
+ * @param {'next'|'prev'} direction
+ */
function focusOption(direction) {
var optionsArray = $mdUtil.nodesToArray(opts.optionNodes);
var index = optionsArray.indexOf(opts.focusedNode);
-
+ var prevOption = optionsArray[index];
var newOption;
do {
@@ -1557,11 +1753,12 @@ function SelectProvider($$interimElementProvider) {
index--;
}
newOption = optionsArray[index];
- if (newOption.hasAttribute('disabled')) newOption = undefined;
+ if (newOption.hasAttribute('disabled')) {
+ newOption = null;
+ }
} while (!newOption && index < optionsArray.length - 1 && index > 0);
- newOption && newOption.focus();
- opts.focusedNode = newOption;
+ focusOptionNode(prevOption, newOption, selectMenuController, opts.selectCtrl);
}
function focusNextOption() {
@@ -1573,18 +1770,22 @@ function SelectProvider($$interimElementProvider) {
}
function checkCloseMenu(ev) {
- if (ev && (ev.type == 'click') && (ev.currentTarget != dropDown[0])) return;
- if (mouseOnScrollbar()) 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')) {
+ if (opts.focusedNode && opts.focusedNode.hasAttribute &&
+ !opts.focusedNode.hasAttribute('disabled')) {
ev.preventDefault();
ev.stopPropagation();
- if (!selectCtrl.isMultiple) {
+ if (!selectMenuController.isMultiple) {
opts.restoreFocus = true;
$mdUtil.nextTick(function () {
- $mdSelect.hide(selectCtrl.ngModel.$viewValue);
+ $mdSelect.hide(selectMenuController.ngModel.$viewValue);
}, true);
}
}
@@ -1606,7 +1807,6 @@ function SelectProvider($$interimElementProvider) {
}
}
}
-
}
/**
@@ -1617,14 +1817,19 @@ function SelectProvider($$interimElementProvider) {
var mdSelect = opts.selectCtrl;
if (mdSelect) {
var menuController = opts.selectEl.controller('mdSelectMenu');
- mdSelect.setLabelText(menuController ? menuController.selectedLabels() : '');
+ mdSelect.setSelectValueText(menuController ? menuController.getSelectedLabels() : '');
mdSelect.triggerClose();
}
}
/**
- * Calculate the
+ * Calculate the menu positions after an event like options changing, screen resizing, or
+ * animations finishing.
+ * @param {Object} scope
+ * @param element
+ * @param opts
+ * @return {{container: {styles: {top: number, left: number, 'font-size': *, 'min-width': number}, element: Object}, dropDown: {styles: {transform: string, transformOrigin: string}, element: Object}}}
*/
function calculateMenuPositions(scope, element, opts) {
var
@@ -1770,9 +1975,7 @@ function SelectProvider($$interimElementProvider) {
}
}
};
-
}
-
}
function isPromiseLike(obj) {
@@ -1810,7 +2013,6 @@ function SelectProvider($$interimElementProvider) {
}
return isScrollable;
}
-
}
function shouldHandleKey(ev, $mdConstant) {
diff --git a/src/components/select/select.spec.js b/src/components/select/select.spec.js
index 31a9444d178..d80fd5a8604 100755
--- a/src/components/select/select.spec.js
+++ b/src/components/select/select.spec.js
@@ -119,13 +119,14 @@ describe('', function() {
expect(ownsId).toBeFalsy();
});
- it('sets aria-owns between the select and the container if element moved outside parent', function() {
+ it('sets aria-owns between the select and the listbox if element moved outside parent', function() {
var select = setupSelect('ng-model="val"').find('md-select');
openSelect(select);
var ownsId = select.attr('aria-owns');
expect(ownsId).toBeTruthy();
- var containerId = $document[0].querySelector('.md-select-menu-container').getAttribute('id');
- expect(ownsId).toBe(containerId);
+ var listboxContentId =
+ $document[0].querySelector('.md-select-menu-container md-content').getAttribute('id');
+ expect(ownsId).toBe(listboxContentId);
});
it('calls md-on-close when the select menu closes', function() {
@@ -1342,29 +1343,29 @@ describe('', function() {
}));
it('sets up the aria-expanded attribute', function() {
-
- expect(el.attr('aria-expanded')).toBe('false');
+ expect(el.attr('aria-expanded')).toBe(undefined);
openSelect(el);
expect(el.attr('aria-expanded')).toBe('true');
closeSelect(el);
$material.flushInterimElement();
- expect(el.attr('aria-expanded')).toBe('false');
+ expect(el.attr('aria-expanded')).toBe(undefined);
});
it('sets up the aria-multiselectable attribute', function() {
- $rootScope.model = [1,3];
- var el = setupSelectMultiple('ng-model="$root.model"', [1,2,3]).find('md-select');
+ $rootScope.model = [1, 3];
+ var el = setupSelectMultiple('ng-model="$root.model"', [1, 2, 3]).find('md-select');
+ var listbox = el.find('md-content');
- expect(el.attr('aria-multiselectable')).toBe('true');
+ expect(listbox.attr('aria-multiselectable')).toBe('true');
});
it('sets up the aria-selected attribute', function() {
- var el = setupSelect('ng-model="$root.model"', [1,2,3]);
+ var el = setupSelect('ng-model="$root.model"', [1, 2, 3]);
var options = el.find('md-option');
openSelect(el);
- expect(options.eq(2).attr('aria-selected')).toBe('false');
+ expect(options.eq(2).attr('aria-selected')).toBe(undefined);
clickOption(el, 2);
expect(options.eq(2).attr('aria-selected')).toBe('true');
});
@@ -1372,7 +1373,6 @@ describe('', function() {
describe('keyboard controls', function() {
-
afterEach(function() {
var selectMenus = $document.find('md-select-menu');
selectMenus.remove();
@@ -1633,7 +1633,7 @@ describe('', function() {
var menu = angular.element($document[0].querySelector('.md-select-menu-container'));
if (menu.length) {
- if (menu.hasClass('md-active') || menu.attr('aria-hidden') == 'false') {
+ if (menu.hasClass('md-active') || menu.attr('aria-hidden') === 'false') {
throw Error('Expected select to be closed');
}
}
@@ -1642,7 +1642,7 @@ describe('', function() {
function expectSelectOpen() {
var menu = angular.element($document[0].querySelector('.md-select-menu-container'));
- if (!(menu.hasClass('md-active') && menu.attr('aria-hidden') == 'false')) {
+ if (!(menu.hasClass('md-active') && menu.attr('aria-hidden') === 'false')) {
throw Error('Expected select to be open');
}
}
diff --git a/src/core/util/util.js b/src/core/util/util.js
index d36e418ec22..44d4c4268ac 100644
--- a/src/core/util/util.js
+++ b/src/core/util/util.js
@@ -170,12 +170,16 @@ function UtilFactory($document, $timeout, $compile, $rootScope, $$mdAnimate, $in
return $mdUtil.clientRect(element, offsetParent, true);
},
- // Annoying method to copy nodes to an array, thanks to IE
+ /**
+ * Annoying method to copy nodes to an array, thanks to IE.
+ * @param nodes
+ * @return {Array}
+ */
nodesToArray: function(nodes) {
+ var results = [], i;
nodes = nodes || [];
- var results = [];
- for (var i = 0; i < nodes.length; ++i) {
+ for (i = 0; i < nodes.length; ++i) {
results.push(nodes.item(i));
}
return results;
@@ -894,9 +898,8 @@ function UtilFactory($document, $timeout, $compile, $rootScope, $$mdAnimate, $in
*
* $mdUtil.uniq(myArray) => [1, 2, 3, 4]
*
- * @param {array} array The array whose unique values should be returned.
- *
- * @returns {array} A copy of the array containing only unique values.
+ * @param {Array} array The array whose unique values should be returned.
+ * @returns {Array|void} A copy of the array containing only unique values.
*/
uniq: function(array) {
if (!array) { return; }