angular-material
Version:
This repository publishes the AngularJS Material v1.x library and localized installs using `npm`. You can find the component source-code for this library in the [AngularJS Material repository](https://github.com/angular/material).
1,314 lines (1,178 loc) • 79 kB
JavaScript
/*!
* AngularJS Material Design
* https://github.com/angular/material
* @license MIT
* v1.2.1
*/
goog.provide('ngmaterial.components.select');
goog.require('ngmaterial.components.backdrop');
goog.require('ngmaterial.core');
/**
* @ngdoc module
* @name material.components.select
*/
/***************************************************
### TODO ###
- [ ] Abstract placement logic in $mdSelect service to $mdMenu service
***************************************************/
SelectDirective['$inject'] = ["$mdSelect", "$mdUtil", "$mdConstant", "$mdTheming", "$mdAria", "$parse", "$sce"];
SelectMenuDirective['$inject'] = ["$parse", "$mdUtil", "$mdConstant", "$mdTheming"];
OptionDirective['$inject'] = ["$mdButtonInkRipple", "$mdUtil", "$mdTheming"];
SelectProvider['$inject'] = ["$$interimElementProvider"];
OptionController['$inject'] = ["$element"];
var SELECT_EDGE_MARGIN = 8;
var selectNextId = 0;
var CHECKBOX_SELECTION_INDICATOR =
angular.element('<div class="md-container"><div class="md-icon"></div></div>');
angular.module('material.components.select', [
'material.core',
'material.components.backdrop'
])
.directive('mdSelect', SelectDirective)
.directive('mdSelectMenu', SelectMenuDirective)
.directive('mdOption', OptionDirective)
.directive('mdOptgroup', OptgroupDirective)
.directive('mdSelectHeader', SelectHeaderDirective)
.provider('$mdSelect', SelectProvider);
/**
* @ngdoc directive
* @name mdSelect
* @restrict E
* @module material.components.select
*
* @description Displays a select box, bound to an `ng-model`. Selectable options are defined using
* the <a ng-href="api/directive/mdOption">md-option</a> element directive. Options can be grouped
* using the <a ng-href="api/directive/mdOptgroup">md-optgroup</a> element directive.
*
* When the select is required and uses a floating label, then the label will automatically contain
* an asterisk (`*`). This behavior can be disabled by using the `md-no-asterisk` attribute.
*
* By default, the select will display with an underline to match other form elements. This can be
* disabled by applying the `md-no-underline` CSS class.
*
* @param {expression} ng-model Assignable angular expression to data-bind to.
* @param {expression=} ng-change Expression to be executed when the model value changes.
* @param {boolean=} multiple When present, allows for more than one option to be selected.
* The model is an array with the selected choices. **Note:** This attribute is only evaluated
* 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.
* @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).
* @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.
* @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 explicit label
* is present.
* @param {string=} md-container-class Class list to get applied to the `.md-select-menu-container`
* element (for custom styling).
* @param {string=} md-select-only-option If specified, a `<md-select>` will automatically select
* it's first option, if it only has one.
*
* @usage
* With a placeholder (label and aria-label are added dynamically)
* <hljs lang="html">
* <md-input-container>
* <md-select
* ng-model="someModel"
* placeholder="Select a state">
* <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
* </md-select>
* </md-input-container>
* </hljs>
*
* With an explicit label
* <hljs lang="html">
* <md-input-container>
* <label>State</label>
* <md-select
* ng-model="someModel">
* <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
* </md-select>
* </md-input-container>
* </hljs>
*
* Using the `md-select-header` element directive
*
* When a developer needs to put more than just a text label in the `md-select-menu`, they should
* use one or more `md-select-header`s. These elements can contain custom HTML which can be styled
* as desired. Use cases for this element include a sticky search bar and custom option group
* labels.
*
* <hljs lang="html">
* <md-input-container>
* <md-select ng-model="someModel">
* <md-select-header>
* <span> Neighborhoods - </span>
* </md-select-header>
* <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
* </md-select>
* </md-input-container>
* </hljs>
*
* ## Selects and object equality
* When using a `md-select` to pick from a list of objects, it is important to realize how javascript handles
* equality. Consider the following example:
* <hljs lang="js">
* angular.controller('MyCtrl', function($scope) {
* $scope.users = [
* { id: 1, name: 'Bob' },
* { id: 2, name: 'Alice' },
* { id: 3, name: 'Steve' }
* ];
* $scope.selectedUser = { id: 1, name: 'Bob' };
* });
* </hljs>
* <hljs lang="html">
* <div ng-controller="MyCtrl">
* <md-select ng-model="selectedUser">
* <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
* </md-select>
* </div>
* </hljs>
*
* At first one might expect that the select should be populated with "Bob" as the selected user.
* However, this is not true. To determine whether something is selected,
* `ngModelController` is looking at whether `$scope.selectedUser == (any user in $scope.users);`;
*
* Javascript's `==` operator does not check for deep equality (ie. that all properties
* on the object are the same), but instead whether the objects are *the same object in memory*.
* In this case, we have two instances of identical objects, but they exist in memory as unique
* entities. Because of this, the select will have no value populated for a selected user.
*
* To get around this, `ngModelController` provides a `track by` option that allows us to specify a
* different expression which will be used for the equality operator. As such, we can update our
* `html` to make use of this by specifying the `ng-model-options="{trackBy: '$value.id'}"` on the
* `md-select` element. This converts our equality expression to be
* `$scope.selectedUser.id == (any id in $scope.users.map(function(u) { return u.id; }));`
* which results in Bob being selected as desired.
*
* **Note:** We do not support AngularJS's `track by` syntax. For instance
* `ng-options="user in users track by user.id"` will not work with `md-select`.
*
* Working HTML:
* <hljs lang="html">
* <div ng-controller="MyCtrl">
* <md-select ng-model="selectedUser" ng-model-options="{trackBy: '$value.id'}">
* <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
* </md-select>
* </div>
* </hljs>
*/
function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $parse, $sce) {
return {
restrict: 'E',
require: ['^?mdInputContainer', 'mdSelect', 'ngModel', '?^form'],
compile: compile,
controller: function() {
} // empty placeholder controller to be initialized in link
};
/**
* @param {JQLite} tElement
* @param {IAttributes} tAttrs
* @return {postLink}
*/
function compile(tElement, tAttrs) {
var isMultiple = $mdUtil.parseAttributeBoolean(tAttrs.multiple);
tElement.addClass('md-auto-horizontal-margin');
// add the select value that will hold our placeholder or selected option value
var valueEl = angular.element('<md-select-value><span></span></md-select-value>');
valueEl.append('<span class="md-select-icon" aria-hidden="true"></span>');
valueEl.addClass('md-select-value');
if (!valueEl[0].hasAttribute('id')) {
valueEl.attr('id', 'select_value_label_' + $mdUtil.nextUid());
}
// There's got to be an md-content inside. If there's not one, let's add it.
var mdContentEl = tElement.find('md-content');
if (!mdContentEl.length) {
tElement.append(angular.element('<md-content>').append(tElement.contents()));
mdContentEl = tElement.find('md-content');
}
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 (tAttrs.mdOnOpen) {
// Show progress indicator while loading async
// Use ng-hide for `display:none` so the indicator does not interfere with the options list
tElement
.find('md-content')
.prepend(angular.element(
'<div>' +
' <md-progress-circular md-mode="indeterminate" ng-if="$$loadingAsyncDone === false"' +
' md-diameter="25px"></md-progress-circular>' +
'</div>'
));
// Hide list [of item options] while loading async
tElement
.find('md-option')
.attr('ng-show', '$$loadingAsyncDone');
}
if (tAttrs.name) {
var autofillClone = angular.element('<select class="md-visually-hidden"></select>');
autofillClone.attr({
'name': tAttrs.name,
'aria-hidden': 'true',
'tabindex': '-1'
});
var opts = tElement.find('md-option');
angular.forEach(opts, function(el) {
var newEl = angular.element('<option>' + el.innerHTML + '</option>');
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-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: <value> ?` which would then get submitted.
// This also goes around having to prepend a dot to the name attribute.
autofillClone.append(
'<option ng-value="' + tAttrs.ngModel + '" selected></option>'
);
tElement.parent().append(autofillClone);
}
// Use everything that's left inside element.contents() as the contents of the menu
var multipleContent = isMultiple ? 'multiple' : '';
var ngModelOptions = tAttrs.ngModelOptions ? $mdUtil.supplant('ng-model-options="{0}"', [tAttrs.ngModelOptions]) : '';
var selectTemplate = '' +
'<div class="md-select-menu-container" aria-hidden="true" role="presentation">' +
' <md-select-menu role="presentation" {0} {1}>{2}</md-select-menu>' +
'</div>';
selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, ngModelOptions, tElement.html()]);
tElement.empty().append(valueEl);
tElement.append(selectTemplate);
if (!tAttrs.tabindex) {
tAttrs.$set('tabindex', 0);
}
return function postLink(scope, element, attrs, ctrls) {
var untouched = true;
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 selectValueElement = element.find('md-select-value');
var isReadonly = angular.isDefined(attrs.readonly);
var disableAsterisk = $mdUtil.parseAttributeBoolean(attrs.mdNoAsterisk);
var stopMdMultipleWatch;
var userDefinedLabelledby = angular.isDefined(attrs.ariaLabelledby);
var listboxContentElement = element.find('md-content');
if (disableAsterisk) {
element.addClass('md-no-asterisk');
}
if (containerCtrl) {
var isErrorGetter = containerCtrl.isErrorGetter || function() {
return ngModelCtrl.$invalid && (ngModelCtrl.$touched || (formCtrl && formCtrl.$submitted));
};
if (containerCtrl.input) {
// We ignore inputs that are in the md-select-header (one
// case where this might be useful would be adding as searchbox)
if (element.find('md-select-header').find('input')[0] !== containerCtrl.input[0]) {
throw new Error("<md-input-container> can only have *one* child <input>, <textarea> or <select> element!");
}
}
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());
}
var stopInvalidWatch = scope.$watch(isErrorGetter, containerCtrl.setInvalid);
}
var selectContainer, selectScope, selectMenuCtrl;
selectContainer = findSelectContainer();
$mdTheming(element);
var originalRender = ngModelCtrl.$render;
ngModelCtrl.$render = function() {
originalRender();
syncSelectValueText();
inputCheckValue();
};
var stopPlaceholderObserver = attrs.$observe('placeholder', ngModelCtrl.$render);
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);
}
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 <md-option>
var isSelectLabelFromUser = false;
mdSelectCtrl.setIsPlaceholder(!text);
if (attrs.mdSelectedText && attrs.mdSelectedHtml) {
throw Error('md-select cannot have both `md-selected-text` and `md-selected-html`');
}
if (attrs.mdSelectedText || attrs.mdSelectedHtml) {
text = $parse(attrs.mdSelectedText || attrs.mdSelectedHtml)(scope);
isSelectLabelFromUser = true;
} else if (useDefaultText) {
// Use placeholder attribute, otherwise fallback to the md-input-container label
var tmpPlaceholder = attrs.placeholder ||
(containerCtrl && containerCtrl.label ? containerCtrl.label.text() : '');
text = tmpPlaceholder || '';
isSelectLabelFromUser = true;
}
var target = selectValueElement.children().eq(0);
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) {
selectValueElement.addClass('md-select-placeholder');
if (containerCtrl && containerCtrl.label) {
containerCtrl.label.addClass('md-placeholder');
}
} else {
selectValueElement.removeClass('md-select-placeholder');
if (containerCtrl && containerCtrl.label) {
containerCtrl.label.removeClass('md-placeholder');
}
}
};
if (!isReadonly) {
var handleBlur = function(event) {
// Attach before ngModel's blur listener to stop propagation of blur event
// and prevent setting $touched.
if (untouched) {
untouched = false;
if (selectScope._mdSelectIsOpen) {
event.stopImmediatePropagation();
}
}
containerCtrl && containerCtrl.setFocused(false);
inputCheckValue();
};
var handleFocus = function() {
// Always focus the container (if we have one) so floating labels and other styles are
// applied properly
containerCtrl && containerCtrl.setFocused(true);
};
element.on('focus', handleFocus);
element.on('blur', handleBlur);
}
mdSelectCtrl.triggerClose = function() {
$parse(attrs.mdOnClose)(scope);
};
scope.$$postDigest(function() {
initAriaLabel();
syncSelectValueText();
});
function initAriaLabel() {
var labelText = element.attr('aria-label') || element.attr('placeholder');
if (!labelText && containerCtrl && containerCtrl.label) {
labelText = containerCtrl.label.text();
}
$mdAria.expect(element, 'aria-label', labelText);
}
var stopSelectedLabelsWatcher = scope.$watch(function() {
return selectMenuCtrl.getSelectedLabels();
}, syncSelectValueText);
function syncSelectValueText() {
selectMenuCtrl = selectMenuCtrl ||
selectContainer.find('md-select-menu').controller('mdSelectMenu');
mdSelectCtrl.setSelectValueText(selectMenuCtrl.getSelectedLabels());
}
// TODO add tests for mdMultiple
// TODO add docs for mdMultiple
var stopMdMultipleObserver = attrs.$observe('mdMultiple', function(val) {
if (stopMdMultipleWatch) {
stopMdMultipleWatch();
}
var parser = $parse(val);
stopMdMultipleWatch = scope.$watch(function() {
return parser(scope);
}, function(multiple, prevVal) {
var selectMenu = selectContainer.find('md-select-menu');
// assume compiler did a good job
if (multiple === undefined && prevVal === undefined) {
return;
}
if (multiple) {
var setMultipleAttrs = {'multiple': 'multiple'};
element.attr(setMultipleAttrs);
selectMenu.attr(setMultipleAttrs);
} else {
element.removeAttr('multiple');
selectMenu.removeAttr('multiple');
}
element.find('md-content').attr('aria-multiselectable', multiple ? 'true' : 'false');
if (selectContainer) {
selectMenuCtrl.setMultiple(Boolean(multiple));
originalRender = ngModelCtrl.$render;
ngModelCtrl.$render = function() {
originalRender();
syncSelectValueText();
inputCheckValue();
};
ngModelCtrl.$render();
}
});
});
var stopDisabledObserver = attrs.$observe('disabled', function(disabled) {
if (angular.isString(disabled)) {
disabled = true;
}
// Prevent click event being registered twice
if (isDisabled !== undefined && isDisabled === disabled) {
return;
}
isDisabled = disabled;
if (disabled) {
element
.attr({'aria-disabled': 'true'})
.removeAttr('tabindex')
.removeAttr('aria-expanded')
.removeAttr('aria-haspopup')
.off('click', openSelect)
.off('keydown', handleKeypress);
} else {
element
.attr({
'tabindex': attrs.tabindex,
'aria-haspopup': 'listbox'
})
.removeAttr('aria-disabled')
.on('click', openSelect)
.on('keydown', handleKeypress);
}
});
if (!attrs.hasOwnProperty('disabled') && !attrs.hasOwnProperty('ngDisabled')) {
element.attr({'aria-disabled': 'false'});
element.on('click', openSelect);
element.on('keydown', handleKeypress);
}
var ariaAttrs = {
role: 'button',
'aria-haspopup': 'listbox'
};
if (!element[0].hasAttribute('id')) {
ariaAttrs.id = 'select_' + $mdUtil.nextUid();
}
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'] = listboxContentId;
}
element.attr(ariaAttrs);
scope.$on('$destroy', function() {
stopRequiredObserver && stopRequiredObserver();
stopDisabledObserver && stopDisabledObserver();
stopMdMultipleWatch && stopMdMultipleWatch();
stopMdMultipleObserver && stopMdMultipleObserver();
stopSelectedLabelsWatcher && stopSelectedLabelsWatcher();
stopPlaceholderObserver && stopPlaceholderObserver();
stopInvalidWatch && stopInvalidWatch();
element.off('focus');
element.off('blur');
$mdSelect
.destroy()
.finally(function() {
if (containerCtrl) {
containerCtrl.setFocused(false);
containerCtrl.setHasValue(false);
containerCtrl.input = null;
}
ngModelCtrl.$setTouched();
});
});
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.getSelectedLabels().length > 0 || (element[0].validity || {}).badInput);
});
}
function findSelectContainer() {
var selectContainer = angular.element(
element[0].querySelector('.md-select-menu-container')
);
selectScope = scope;
attrs.mdContainerClass && selectContainer.addClass(attrs.mdContainerClass);
selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
selectMenuCtrl.init(ngModelCtrl, attrs);
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
e.preventDefault();
openSelect(e);
} else {
if (shouldHandleKey(e, $mdConstant)) {
e.preventDefault();
var node = selectMenuCtrl.optNodeForKeyboardSearch(e);
if (!node || node.hasAttribute('disabled')) {
return;
}
var optionCtrl = angular.element(node).controller('mdOption');
if (!selectMenuCtrl.isMultiple) {
angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) {
selectMenuCtrl.deselect(key);
});
}
selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value);
selectMenuCtrl.refreshViewValue();
}
}
}
function openSelect() {
selectScope._mdSelectIsOpen = true;
element.attr('aria-expanded', 'true');
$mdSelect.show({
scope: selectScope,
preserveScope: true,
skipCompile: true,
element: selectContainer,
target: element[0],
selectCtrl: mdSelectCtrl,
preserveElement: true,
hasBackdrop: true,
loadingAsync: attrs.mdOnOpen ? scope.$eval(attrs.mdOnOpen) || true : false
}).finally(function() {
selectScope._mdSelectIsOpen = false;
element.removeAttr('aria-expanded');
element.removeAttr('aria-activedescendant');
ngModelCtrl.$setTouched();
});
}
};
}
}
function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) {
// We want the scope to be set to 'false' so an isolated scope is not created
// which would interfere with the md-select-header's access to the
// parent scope.
SelectMenuController['$inject'] = ["$scope", "$attrs", "$element"];
return {
restrict: 'E',
require: ['mdSelectMenu'],
scope: false,
controller: SelectMenuController,
link: {pre: preLink}
};
// We use preLink instead of postLink to ensure that the select is initialized before
// its child options run postLink.
function preLink(scope, element, attrs, ctrls) {
var selectMenuCtrl = ctrls[0];
element.addClass('_md'); // private md component indicator for styling
$mdTheming(element);
element.on('click', clickListener);
element.on('keypress', keyListener);
/**
* @param {KeyboardEvent} keyboardEvent
*/
function keyListener(keyboardEvent) {
if (keyboardEvent.keyCode === 13 || keyboardEvent.keyCode === 32) {
clickListener(keyboardEvent);
}
}
/**
* @param {Event} mouseEvent
* @return {void}
*/
function clickListener(mouseEvent) {
var option = $mdUtil.getClosest(mouseEvent.target, 'md-option');
var optionCtrl = option && angular.element(option).data('$mdOptionController');
if (!option || !optionCtrl) {
// Avoid closing the menu when the select header's input is clicked
if (mouseEvent.target && mouseEvent.target.parentNode &&
mouseEvent.target.parentNode.tagName === 'MD-SELECT-HEADER') {
mouseEvent.stopImmediatePropagation();
}
return;
} else if (option.hasAttribute('disabled')) {
mouseEvent.stopImmediatePropagation();
return;
}
var optionHashKey = selectMenuCtrl.hashGetter(optionCtrl.value);
var isSelected = angular.isDefined(selectMenuCtrl.selected[optionHashKey]);
scope.$apply(function() {
if (selectMenuCtrl.isMultiple) {
if (isSelected) {
selectMenuCtrl.deselect(optionHashKey);
} else {
selectMenuCtrl.select(optionHashKey, optionCtrl.value);
}
} else {
if (!isSelected) {
angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) {
selectMenuCtrl.deselect(key);
});
selectMenuCtrl.select(optionHashKey, optionCtrl.value);
}
}
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 containing an instance of every option's controller.
self.options = {};
$scope.$watchCollection(function() {
return self.options;
}, function() {
self.ngModel.$render();
updateOptionSetSizeAndPosition();
});
/**
* @param {boolean} isMultiple
*/
self.setMultiple = function(isMultiple) {
var ngModel = self.ngModel;
defaultIsEmpty = defaultIsEmpty || ngModel.$isEmpty;
self.isMultiple = isMultiple;
if (self.isMultiple) {
// We want to delay the render method so that the directive has a chance to load before
// rendering, this prevents the control being marked as dirty onload.
var loaded = false;
var delayedRender = function(val) {
if (!loaded) {
$mdUtil.nextTick(function () {
renderMultiple(val);
loaded = true;
});
} else {
renderMultiple(val);
}
};
ngModel.$validators['md-multiple'] = validateArray;
ngModel.$render = delayedRender;
// 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);
}
});
ngModel.$isEmpty = function(value) {
return !value || value.length === 0;
};
} else {
delete ngModel.$validators['md-multiple'];
ngModel.$render = renderSingular;
}
function validateArray(modelValue, viewValue) {
// If a value is truthy but not an array, reject it.
// If value is undefined/falsy, accept that it's an empty array.
return angular.isArray(modelValue || viewValue || []);
}
};
/**
* @param {KeyboardEvent} keyboardEvent keyboard event to handle
* @return {Element|HTMLElement|undefined}
*/
self.optNodeForKeyboardSearch = function(keyboardEvent) {
var search, i;
clearSearchTimeout && clearTimeout(clearSearchTimeout);
clearSearchTimeout = setTimeout(function() {
clearSearchTimeout = undefined;
searchStr = '';
optText = undefined;
optNodes = undefined;
}, CLEAR_SEARCH_AFTER);
searchStr += keyboardEvent.key;
search = new RegExp('^' + $mdUtil.sanitize(searchStr), 'i');
if (!optNodes) {
optNodes = $element.find('md-option');
optText = new Array(optNodes.length);
angular.forEach(optNodes, function(el, i) {
optText[i] = el.textContent.trim();
});
}
for (i = 0; i < optText.length; ++i) {
if (search.test(optText[i])) {
return optNodes[i];
}
}
};
self.init = function(ngModel, parentAttrs) {
self.ngModel = ngModel;
self.modelBinding = parentAttrs.ngModel;
// Setup a more robust version of isEmpty to ensure value is a valid option
self.ngModel.$isEmpty = function($viewValue) {
// We have to transform the viewValue into the hashKey, because otherwise the
// OptionCtrl may not exist. Developers may have specified a trackBy function.
return !self.options[self.hashGetter($viewValue)];
};
// Allow users to provide `ng-model="foo" ng-model-options="{trackBy: '$value.id'}"` so
// that we can properly compare objects set on the model to the available options
//
// If the user doesn't provide a trackBy, we automatically generate an id for every
// value passed in with the getId function
if ($attrs.ngModelOptions) {
self.hashGetter = function(value) {
var ngModelOptions = $parse($attrs.ngModelOptions)($scope);
var trackByOption = ngModelOptions && ngModelOptions.trackBy;
if (trackByOption) {
return $parse(trackByOption)($scope, { $value: value });
} else if (angular.isObject(value)) {
return getId(value);
}
return value;
};
} else {
self.hashGetter = getId;
}
self.setMultiple(self.isMultiple);
/**
* If the value is an object, get the unique, incremental id of the value.
* If it's not an object, the value will be converted to a string and then returned.
* @param value
* @returns {string}
*/
function getId(value) {
if (angular.isObject(value) && !angular.isArray(value)) {
return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
}
return value + '';
}
if (parentAttrs.hasOwnProperty('mdSelectOnlyOption')) {
$mdUtil.nextTick(function() {
var optionKeys = Object.keys(self.options);
if (optionKeys.length === 1) {
var option = self.options[optionKeys[0]];
self.deselect(Object.keys(self.selected)[0]);
self.select(self.hashGetter(option.value), option.value);
self.refreshViewValue();
self.ngModel.$setPristine();
}
}, false);
}
};
/**
* @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]'));
if (selectedOptionEls.length) {
var mapFn;
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 (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.
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.
var checkboxContainer = el.querySelector('.md-container');
if (checkboxContainer) {
html = html.replace(checkboxContainer.outerHTML, '');
}
return html;
};
} 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
return $mdUtil.uniq(selectedOptionEls.map(mapFn)).join(', ');
} else {
return '';
}
};
/**
* 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, 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, 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. ' +
'Duplicate value "' + optionCtrl.value + '" found.');
}
self.options[hashKey] = optionCtrl;
// If this option's value was already in our ngModel, go ahead and select it.
if (angular.isDefined(self.selected[hashKey])) {
self.select(hashKey, optionCtrl.value);
// When the current $modelValue of the ngModel Controller is using the same hash as
// the current option, which will be added, then we can be sure, that the validation
// of the option has occurred before the option was added properly.
// This means, that we have to manually trigger a new validation of the current option.
if (angular.isDefined(self.ngModel.$$rawModelValue) &&
self.hashGetter(self.ngModel.$$rawModelValue) === hashKey) {
self.ngModel.$validate();
}
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
// to have values that do not match a currently available option.
};
self.refreshViewValue = function() {
var values = [];
var option;
for (var hashKey in self.selected) {
// If this hashKey has an associated option, push that option's value to the model.
if ((option = self.options[hashKey])) {
values.push(option.value);
} else {
// Otherwise, the given hashKey has no associated option, and we got it
// from an ngModel value at an earlier time. Push the unhashed value of
// this hashKey to the model.
// This allows the developer to put a value in the model that doesn't yet have
// an associated option.
values.push(self.selected[hashKey]);
}
}
var newVal = self.isMultiple ? values : values[0];
var prevVal = self.ngModel.$modelValue;
if (!equals(prevVal, newVal)) {
self.ngModel.$setViewValue(newVal);
self.ngModel.$render();
}
function equals(prevVal, newVal) {
if (self.isMultiple) {
if (!angular.isArray(prevVal)) {
// newVal is always an array when self.isMultiple is true
// thus, if prevVal is not an array they are different
return false;
} else if (prevVal.length !== newVal.length) {
// they are different if they have different length
return false;
} else {
// if they have the same length, then they are different
// if an item in the newVal array can't be found in the prevVal
var prevValHashes = prevVal.map(function(prevValItem) {
return self.hashGetter(prevValItem);
});
return newVal.every(function(newValItem) {
var newValItemHash = self.hashGetter(newValItem);
return prevValHashes.some(function(prevValHash) {
return prevValHash === newValItemHash;
});
});
}
} else {
return self.hashGetter(prevVal) === self.hashGetter(newVal);
}
}
};
/**
* If the options include md-optgroups, then we need to apply aria-setsize and aria-posinset
* to help screen readers understand the indexes. When md-optgroups are not used, we save on
* perf and extra attributes by not applying these attributes as they are not needed by screen
* readers.
*/
function updateOptionSetSizeAndPosition() {
var i, options;
var hasOptGroup = $element.find('md-optgroup');
if (!hasOptGroup.length) {
return;
}
options = $element.find('md-option');
for (i = 0; i < options.length; i++) {
options[i].setAttribute('aria-setsize', options.length);
options[i].setAttribute('aria-posinset', i + 1);
}
}
function renderMultiple() {
var newSelectedValues = self.ngModel.$modelValue || self.ngModel.$viewValue || [];
if (!angular.isArray(newSelectedValues)) {
return;
}
var oldSelected = Object.keys(self.selected);
var newSelectedHashes = newSelectedValues.map(self.hashGetter);
var deselected = oldSelected.filter(function(hash) {
return newSelectedHashes.indexOf(hash) === -1;
});
deselected.forEach(self.deselect);
newSelectedHashes.forEach(function(hashKey, i) {
self.select(hashKey, newSelectedValues[i]);
});
}
function renderSingular() {
var value = self.ngModel.$viewValue || self.ngModel.$modelValue;
Object.keys(self.selected).forEach(self.deselect);
self.select(self.hashGetter(value), value);
}
}
}
/**
* @ngdoc directive
* @name mdOption
* @restrict E
* @module material.components.select
*
* @description Displays an option in a <a ng-href="api/directive/mdSelect">md-select</a> box's
* dropdown menu. Options can be grouped using
* <a ng-href="api/directive/mdOptgroup">md-optgroup</a> element directives.
*
* ### Option Params
*
* When applied, `md-option-empty` will mark the option as "empty" allowing the option to clear the
* select and put it back in it's default state. You may supply this attribute on any option you
* wish, however, it is automatically applied to an option whose `value` or `ng-value` are not
* defined.
*
* **Automatically Applied**
*
* - `<md-option>`
* - `<md-option value>`
* - `<md-option value="">`
* - `<md-option ng-value>`
* - `<md-option ng-value="">`
*
* **NOT Automatically Applied**
*
* - `<md-option ng-value="1">`
* - `<md-option ng-value="''">`
* - `<md-option ng-value="undefined">`
* - `<md-option value="undefined">` (this evaluates to the string `"undefined"`)
* - <code ng-non-bindable><md-option ng-value="{{someValueThatMightBeUndefined}}"></code>
*
* **Note:** A value of `undefined` ***is considered a valid value*** (and does not auto-apply this
* attribute) since you may wish this to be your "Not Available" or "None" option.
*
* **Note:** Using the
* <a ng-href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option#Attributes">value</a>
* attribute from the `<option>` element (as opposed to the `<md-option>` element's
* <a ng-href="https://docs.angularjs.org/api/ng/directive/ngValue">ng-value</a>) always evaluates
* to a `string`. This means that `value="null"` will cause a check against `myValue != "null"`
* rather than `!myValue` or `myValue != null`.
* Importantly, this also applies to `number` values. `value="1"` will not match up with an
* `ng-model` like `$scope.selectedValue = 1`. Use `ng-value="1"` in this case and other cases where
* you have values that are not strings.
*
* **Note:** Please see our <a ng-href="api/directive/mdSelect#selects-and-object-equality">docs on
* using objects with `md-select`</a> for additional guidance on using the `trackBy` option with
* `ng-model-options`.
*
* @param {expression=} ng-value Binds the given expression to the value of the option.
* @param {string=} value Attribute to set the value of the option.
* @param {expression=} ng-repeat <a ng-href="https://docs.angularjs.org/api/ng/directive/ngRepeat">
* AngularJS directive</a> that instantiates a template once per item from a collection.
* @param {expression=} ng-selected <a ng-href="https://docs.angularjs.org/api/ng/directive/ngSelected">
* AngularJS directive</a> that adds the `selected` attribute to the option when the expression
* evaluates as truthy.
*
* **Note:** Unlike native `option` elements used with AngularJS, `md-option` elements
* watch their `selected` attributes for changes and trigger model value changes on `md-select`.
* @param {boolean=} md-option-empty If the attribute exists, mark the option as "empty" allowing
* the option to clear the select and put it back in it's default state. You may supply this
* attribute on any option you wish, however, it is automatically applied to an option whose `value`
* or `ng-value` are not defined.
* @param {number=} tabindex The `tabindex` of the option. Defaults to `0`.
*
* @usage
* <hljs lang="html">
* <md-select ng-model="currentState" placeholder="Select a state">
* <md-option ng-value="AL">Alabama</md-option>
* <md-option ng-value="AK">Alaska</md-option>
* <md-option ng-value="FL">Florida</md-option>
* </md-select>
* </hljs>
*
* With `ng-repeat`:
* <hljs lang="html">
* <md-select ng-model="currentState" placeholder="Select a state">
* <md-option ng-value="state" ng-repeat="state in states">{{ state }}</md-option>
* </md-select>
* </hljs>
*/
function OptionDirective($mdButtonInkRipple, $mdUtil, $mdTheming) {
return {
restrict: 'E',
require: ['mdOption', '^^mdSelectMenu'],
controller: OptionController,
compile: compile
};
/**
* @param {JQLite} element
* @param {IAttributes} attrs
* @return {postLink}
*/
function compile(element, attrs) {
// Manual transclusion to avoid the extra inner <span> that ng-transclude generates
element.append(angular.element('<div class="md-text">').append(element.contents()));
element.attr('tabindex', attrs.tabindex || '0');
if (!hasDefinedValue(attrs)) {
element.attr('md-option-empty', '');
}
return postLink;
}
/**
* @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, attrs, ctrls) {
var optionCtrl = ctrls[0];
var selectMenuCtrl = ctrls[1];
$mdTheming(element);
if (selectMenuCtrl.isMultiple) {
element.addClass('md-checkbox-enabled');
element.prepend(CHECKBOX_SELECTION_INDICATOR.clone());
}
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);
}
attrs.$observe('disabled', function(disabled) {
if (disabled) {
element.attr('tabindex', '-1');
} else {
element.attr('tabindex', '0');
}
});
scope.$$postDigest(function() {
attrs.$observe('selected', function(selected) {
if (!angular.isDefined(selected)) return;
if (typeof selected == 'string') selected = true;
if (selected) {
if (!selectMenuCtrl.isMultiple) {
selectMenuCtrl.deselect(Object.keys(selectMenuCtrl.selected)[0]);
}
selectMenuCtrl.select(optionCtrl.hashKey, optionC