/*!
|
|
* Angular Material Design
|
|
* https://github.com/angular/material
|
|
* @license MIT
|
|
* v1.1.1
|
|
*/
|
|
(function( window, angular, undefined ){
|
|
"use strict";
|
|
|
|
/**
|
|
* @ngdoc module
|
|
* @name material.components.select
|
|
*/
|
|
|
|
/***************************************************
|
|
|
|
### TODO - POST RC1 ###
|
|
- [ ] Abstract placement logic in $mdSelect service to $mdMenu service
|
|
|
|
***************************************************/
|
|
|
|
SelectDirective.$inject = ["$mdSelect", "$mdUtil", "$mdConstant", "$mdTheming", "$mdAria", "$compile", "$parse"];
|
|
SelectMenuDirective.$inject = ["$parse", "$mdUtil", "$mdConstant", "$mdTheming"];
|
|
OptionDirective.$inject = ["$mdButtonInkRipple", "$mdUtil"];
|
|
SelectProvider.$inject = ["$$interimElementProvider"];
|
|
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.
|
|
*
|
|
* 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.
|
|
*
|
|
* ### 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 `value` attribute (as opposed to `ng-value`) always evaluates to a string, so
|
|
* `value="null"` will require the test `ng-if="myValue != 'null'"` rather than `ng-if="!myValue"`.
|
|
*
|
|
* @param {expression} ng-model The model!
|
|
* @param {boolean=} multiple Whether it's multiple.
|
|
* @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.
|
|
* @param {string=} placeholder Placeholder hint text.
|
|
* @param md-no-asterisk {boolean=} 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.
|
|
* @param {string=} md-container-class Class list to get applied to the `.md-select-menu-container`
|
|
* element (for custom styling).
|
|
*
|
|
* @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>
|
|
*
|
|
* With a select-header
|
|
*
|
|
* When a developer needs to put more than just a text label in the
|
|
* md-select-menu, they should use the md-select-header.
|
|
* The user can put custom HTML inside of the header and style it to their liking.
|
|
* One common use case of this would be a sticky search bar.
|
|
*
|
|
* When using the md-select-header the labels that would previously be added to the
|
|
* OptGroupDirective are ignored.
|
|
*
|
|
* <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.
|
|
*
|
|
* 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, $compile, $parse) {
|
|
var keyCodes = $mdConstant.KEY_CODE;
|
|
var NAVIGATION_KEYS = [keyCodes.SPACE, keyCodes.ENTER, keyCodes.UP_ARROW, keyCodes.DOWN_ARROW];
|
|
|
|
return {
|
|
restrict: 'E',
|
|
require: ['^?mdInputContainer', 'mdSelect', 'ngModel', '?^form'],
|
|
compile: compile,
|
|
controller: function() {
|
|
} // empty placeholder controller to be initialized in link
|
|
};
|
|
|
|
function compile(element, attr) {
|
|
// 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.
|
|
if (!element.find('md-content').length) {
|
|
element.append(angular.element('<md-content>').append(element.contents()));
|
|
}
|
|
|
|
|
|
// Add progress spinner for md-options-loading
|
|
if (attr.mdOnOpen) {
|
|
|
|
// Show progress indicator while loading async
|
|
// Use ng-hide for `display:none` so the indicator does not interfere with the options list
|
|
element
|
|
.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
|
|
element
|
|
.find('md-option')
|
|
.attr('ng-show', '$$loadingAsyncDone');
|
|
}
|
|
|
|
if (attr.name) {
|
|
var autofillClone = angular.element('<select class="md-visually-hidden">');
|
|
autofillClone.attr({
|
|
'name': attr.name,
|
|
'aria-hidden': 'true',
|
|
'tabindex': '-1'
|
|
});
|
|
var opts = element.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-angular form. This can be done with a ng-model,
|
|
// however if the `md-option` is being `ng-repeat`-ed, Angular 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="' + attr.ngModel + '" selected></option>'
|
|
);
|
|
|
|
element.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 selectTemplate = '' +
|
|
'<div class="md-select-menu-container" aria-hidden="true">' +
|
|
'<md-select-menu {0}>{1}</md-select-menu>' +
|
|
'</div>';
|
|
|
|
selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, element.html()]);
|
|
element.empty().append(valueEl);
|
|
element.append(selectTemplate);
|
|
|
|
if(!attr.tabindex){
|
|
attr.$set('tabindex', 0);
|
|
}
|
|
|
|
return function postLink(scope, element, attr, ctrls) {
|
|
var untouched = true;
|
|
var isDisabled, ariaLabelBase;
|
|
|
|
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);
|
|
|
|
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'));
|
|
}
|
|
|
|
scope.$watch(isErrorGetter, containerCtrl.setInvalid);
|
|
}
|
|
|
|
var selectContainer, selectScope, selectMenuCtrl;
|
|
|
|
findSelectContainer();
|
|
$mdTheming(element);
|
|
|
|
if (formCtrl && angular.isDefined(attr.multiple)) {
|
|
$mdUtil.nextTick(function() {
|
|
var hasModelValue = ngModelCtrl.$modelValue || ngModelCtrl.$viewValue;
|
|
if (hasModelValue) {
|
|
formCtrl.$setPristine();
|
|
}
|
|
});
|
|
}
|
|
|
|
var originalRender = ngModelCtrl.$render;
|
|
ngModelCtrl.$render = function() {
|
|
originalRender();
|
|
syncLabelText();
|
|
syncAriaLabel();
|
|
inputCheckValue();
|
|
};
|
|
|
|
attr.$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.
|
|
containerCtrl.label.toggleClass('md-required', value && !disableAsterisk);
|
|
});
|
|
}
|
|
|
|
mdSelectCtrl.setLabelText = function(text) {
|
|
mdSelectCtrl.setIsPlaceholder(!text);
|
|
|
|
if (attr.mdSelectedText) {
|
|
text = $parse(attr.mdSelectedText)(scope);
|
|
} else {
|
|
// Use placeholder attribute, otherwise fallback to the md-input-container label
|
|
var tmpPlaceholder = attr.placeholder ||
|
|
(containerCtrl && containerCtrl.label ? containerCtrl.label.text() : '');
|
|
text = text || tmpPlaceholder || '';
|
|
}
|
|
|
|
var target = valueEl.children().eq(0);
|
|
target.html(text);
|
|
};
|
|
|
|
mdSelectCtrl.setIsPlaceholder = function(isPlaceholder) {
|
|
if (isPlaceholder) {
|
|
valueEl.addClass('md-select-placeholder');
|
|
if (containerCtrl && containerCtrl.label) {
|
|
containerCtrl.label.addClass('md-placeholder');
|
|
}
|
|
} else {
|
|
valueEl.removeClass('md-select-placeholder');
|
|
if (containerCtrl && containerCtrl.label) {
|
|
containerCtrl.label.removeClass('md-placeholder');
|
|
}
|
|
}
|
|
};
|
|
|
|
if (!isReadonly) {
|
|
element
|
|
.on('focus', function(ev) {
|
|
// Always focus the container (if we have one) so floating labels and other styles are
|
|
// applied properly
|
|
containerCtrl && containerCtrl.setFocused(true);
|
|
});
|
|
|
|
// Attach before ngModel's blur listener to stop propagation of blur event
|
|
// to prevent from setting $touched.
|
|
element.on('blur', function(event) {
|
|
if (untouched) {
|
|
untouched = false;
|
|
if (selectScope._mdSelectIsOpen) {
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}
|
|
|
|
if (selectScope._mdSelectIsOpen) return;
|
|
containerCtrl && containerCtrl.setFocused(false);
|
|
inputCheckValue();
|
|
});
|
|
}
|
|
|
|
mdSelectCtrl.triggerClose = function() {
|
|
$parse(attr.mdOnClose)(scope);
|
|
};
|
|
|
|
scope.$$postDigest(function() {
|
|
initAriaLabel();
|
|
syncLabelText();
|
|
syncAriaLabel();
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
scope.$watch(function() {
|
|
return selectMenuCtrl.selectedLabels();
|
|
}, syncLabelText);
|
|
|
|
function syncLabelText() {
|
|
if (selectContainer) {
|
|
selectMenuCtrl = selectMenuCtrl || selectContainer.find('md-select-menu').controller('mdSelectMenu');
|
|
mdSelectCtrl.setLabelText(selectMenuCtrl.selectedLabels());
|
|
}
|
|
}
|
|
|
|
function syncAriaLabel() {
|
|
if (!ariaLabelBase) return;
|
|
var ariaLabels = selectMenuCtrl.selectedLabels({mode: 'aria'});
|
|
element.attr('aria-label', ariaLabels.length ? ariaLabelBase + ': ' + ariaLabels : ariaLabelBase);
|
|
}
|
|
|
|
var deregisterWatcher;
|
|
attr.$observe('ngMultiple', function(val) {
|
|
if (deregisterWatcher) deregisterWatcher();
|
|
var parser = $parse(val);
|
|
deregisterWatcher = scope.$watch(function() {
|
|
return parser(scope);
|
|
}, function(multiple, prevVal) {
|
|
if (multiple === undefined && prevVal === undefined) return; // assume compiler did a good job
|
|
if (multiple) {
|
|
element.attr('multiple', 'multiple');
|
|
} 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();
|
|
}
|
|
});
|
|
});
|
|
|
|
attr.$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')
|
|
.off('click', openSelect)
|
|
.off('keydown', handleKeypress);
|
|
} else {
|
|
element
|
|
.attr({'tabindex': attr.tabindex, 'aria-disabled': 'false'})
|
|
.on('click', openSelect)
|
|
.on('keydown', handleKeypress);
|
|
}
|
|
});
|
|
|
|
if (!attr.hasOwnProperty('disabled') && !attr.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'
|
|
};
|
|
|
|
if (!element[0].hasAttribute('id')) {
|
|
ariaAttrs.id = 'select_' + $mdUtil.nextUid();
|
|
}
|
|
|
|
var containerId = 'select_container_' + $mdUtil.nextUid();
|
|
selectContainer.attr('id', containerId);
|
|
ariaAttrs['aria-owns'] = containerId;
|
|
element.attr(ariaAttrs);
|
|
|
|
scope.$on('$destroy', function() {
|
|
$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)
|
|
containerCtrl && containerCtrl.setHasValue(selectMenuCtrl.selectedLabels().length > 0 || (element[0].validity || {}).badInput);
|
|
}
|
|
|
|
function findSelectContainer() {
|
|
selectContainer = angular.element(
|
|
element[0].querySelector('.md-select-menu-container')
|
|
);
|
|
selectScope = scope;
|
|
if (attr.mdContainerClass) {
|
|
var value = selectContainer[0].getAttribute('class') + ' ' + attr.mdContainerClass;
|
|
selectContainer[0].setAttribute('class', value);
|
|
}
|
|
selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
|
|
selectMenuCtrl.init(ngModelCtrl, attr.ngModel);
|
|
element.on('$destroy', function() {
|
|
selectContainer.remove();
|
|
});
|
|
}
|
|
|
|
function handleKeypress(e) {
|
|
if ($mdConstant.isNavigationKey(e)) {
|
|
// prevent page scrolling on interaction
|
|
e.preventDefault();
|
|
openSelect(e);
|
|
} else {
|
|
if ($mdConstant.isInputKey(e) || $mdConstant.isNumPadKey(e)) {
|
|
e.preventDefault();
|
|
|
|
var node = selectMenuCtrl.optNodeForKeyboardSearch(e);
|
|
if (!node || node.hasAttribute('disabled')) return;
|
|
var optionCtrl = angular.element(node).controller('mdOption');
|
|
if (!selectMenuCtrl.isMultiple) {
|
|
selectMenuCtrl.deselect(Object.keys(selectMenuCtrl.selected)[0]);
|
|
}
|
|
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: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) || true : false
|
|
}).finally(function() {
|
|
selectScope._mdSelectIsOpen = false;
|
|
element.focus();
|
|
element.attr('aria-expanded', 'false');
|
|
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, attr, ctrls) {
|
|
var selectCtrl = ctrls[0];
|
|
|
|
element.addClass('_md'); // private md component indicator for styling
|
|
|
|
$mdTheming(element);
|
|
element.on('click', clickListener);
|
|
element.on('keypress', keyListener);
|
|
|
|
function keyListener(e) {
|
|
if (e.keyCode == 13 || e.keyCode == 32) {
|
|
clickListener(e);
|
|
}
|
|
}
|
|
|
|
function clickListener(ev) {
|
|
var option = $mdUtil.getClosest(ev.target, 'md-option');
|
|
var optionCtrl = option && angular.element(option).data('$mdOptionController');
|
|
if (!option || !optionCtrl) return;
|
|
if (option.hasAttribute('disabled')) {
|
|
ev.stopImmediatePropagation();
|
|
return false;
|
|
}
|
|
|
|
var optionHashKey = selectCtrl.hashGetter(optionCtrl.value);
|
|
var isSelected = angular.isDefined(selectCtrl.selected[optionHashKey]);
|
|
|
|
scope.$apply(function() {
|
|
if (selectCtrl.isMultiple) {
|
|
if (isSelected) {
|
|
selectCtrl.deselect(optionHashKey);
|
|
} else {
|
|
selectCtrl.select(optionHashKey, optionCtrl.value);
|
|
}
|
|
} else {
|
|
if (!isSelected) {
|
|
selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]);
|
|
selectCtrl.select(optionHashKey, optionCtrl.value);
|
|
}
|
|
}
|
|
selectCtrl.refreshViewValue();
|
|
});
|
|
}
|
|
}
|
|
|
|
function SelectMenuController($scope, $attrs, $element) {
|
|
var self = this;
|
|
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.
|
|
self.options = {};
|
|
|
|
$scope.$watchCollection(function() {
|
|
return self.options;
|
|
}, function() {
|
|
self.ngModel.$render();
|
|
});
|
|
|
|
var deregisterCollectionWatch;
|
|
var defaultIsEmpty;
|
|
self.setMultiple = function(isMultiple) {
|
|
var ngModel = self.ngModel;
|
|
defaultIsEmpty = defaultIsEmpty || ngModel.$isEmpty;
|
|
|
|
self.isMultiple = isMultiple;
|
|
if (deregisterCollectionWatch) deregisterCollectionWatch();
|
|
|
|
if (self.isMultiple) {
|
|
ngModel.$validators['md-multiple'] = validateArray;
|
|
ngModel.$render = renderMultiple;
|
|
|
|
// watchCollection on the model because by default ngModel only watches the model's
|
|
// reference. This allowed the developer to also push and pop from their array.
|
|
$scope.$watchCollection(self.modelBinding, function(value) {
|
|
if (validateArray(value)) renderMultiple(value);
|
|
self.ngModel.$setPristine();
|
|
});
|
|
|
|
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 || []);
|
|
}
|
|
};
|
|
|
|
var searchStr = '';
|
|
var clearSearchTimeout, optNodes, optText;
|
|
var CLEAR_SEARCH_AFTER = 300;
|
|
|
|
self.optNodeForKeyboardSearch = function(e) {
|
|
clearSearchTimeout && clearTimeout(clearSearchTimeout);
|
|
clearSearchTimeout = setTimeout(function() {
|
|
clearSearchTimeout = undefined;
|
|
searchStr = '';
|
|
optText = undefined;
|
|
optNodes = undefined;
|
|
}, CLEAR_SEARCH_AFTER);
|
|
|
|
// Support 1-9 on numpad
|
|
var keyCode = e.keyCode - ($mdConstant.isNumPadKey(e) ? 48 : 0);
|
|
|
|
searchStr += String.fromCharCode(keyCode);
|
|
var search = new RegExp('^' + 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 (var i = 0; i < optText.length; ++i) {
|
|
if (search.test(optText[i])) {
|
|
return optNodes[i];
|
|
}
|
|
}
|
|
};
|
|
|
|
self.init = function(ngModel, binding) {
|
|
self.ngModel = ngModel;
|
|
self.modelBinding = binding;
|
|
|
|
// 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: 'foo.id'}"` so
|
|
// that we can properly compare objects set on the model to the available options
|
|
if (ngModel.$options && ngModel.$options.trackBy) {
|
|
var trackByLocals = {};
|
|
var trackByParsed = $parse(ngModel.$options.trackBy);
|
|
self.hashGetter = function(value, valueScope) {
|
|
trackByLocals.$value = value;
|
|
return trackByParsed(valueScope || $scope, trackByLocals);
|
|
};
|
|
// If the user doesn't provide a trackBy, we automatically generate an id for every
|
|
// value passed in
|
|
} else {
|
|
self.hashGetter = function getHashValue(value) {
|
|
if (angular.isObject(value)) {
|
|
return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
|
|
}
|
|
return value;
|
|
};
|
|
}
|
|
self.setMultiple(self.isMultiple);
|
|
};
|
|
|
|
self.selectedLabels = 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; };
|
|
}
|
|
return selectedOptionEls.map(mapFn).join(', ');
|
|
} else {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
self.select = function(hashKey, hashedValue) {
|
|
var option = self.options[hashKey];
|
|
option && option.setSelected(true);
|
|
self.selected[hashKey] = hashedValue;
|
|
};
|
|
self.deselect = function(hashKey) {
|
|
var option = self.options[hashKey];
|
|
option && option.setSelected(false);
|
|
delete self.selected[hashKey];
|
|
};
|
|
|
|
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.$modelValue) && self.hashGetter(self.ngModel.$modelValue) === hashKey) {
|
|
self.ngModel.$validate();
|
|
}
|
|
|
|
self.refreshViewValue();
|
|
}
|
|
};
|
|
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 usingTrackBy = self.ngModel.$options && self.ngModel.$options.trackBy;
|
|
|
|
var newVal = self.isMultiple ? values : values[0];
|
|
var prevVal = self.ngModel.$modelValue;
|
|
|
|
if (usingTrackBy ? !angular.equals(prevVal, newVal) : prevVal != newVal) {
|
|
self.ngModel.$setViewValue(newVal);
|
|
self.ngModel.$render();
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
function OptionDirective($mdButtonInkRipple, $mdUtil) {
|
|
|
|
OptionController.$inject = ["$element"];
|
|
return {
|
|
restrict: 'E',
|
|
require: ['mdOption', '^^mdSelectMenu'],
|
|
controller: OptionController,
|
|
compile: compile
|
|
};
|
|
|
|
function compile(element, attr) {
|
|
// 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', attr.tabindex || '0');
|
|
|
|
if (!hasDefinedValue(attr)) {
|
|
element.attr('md-option-empty', '');
|
|
}
|
|
|
|
return postLink;
|
|
}
|
|
|
|
function hasDefinedValue(attr) {
|
|
var value = attr.value;
|
|
var ngValue = attr.ngValue;
|
|
|
|
return value || ngValue;
|
|
}
|
|
|
|
function postLink(scope, element, attr, ctrls) {
|
|
var optionCtrl = ctrls[0];
|
|
var selectCtrl = ctrls[1];
|
|
|
|
if (selectCtrl.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);
|
|
} else {
|
|
scope.$watch(function() {
|
|
return element.text().trim();
|
|
}, setOptionValue);
|
|
}
|
|
|
|
attr.$observe('disabled', function(disabled) {
|
|
if (disabled) {
|
|
element.attr('tabindex', '-1');
|
|
} else {
|
|
element.attr('tabindex', '0');
|
|
}
|
|
});
|
|
|
|
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();
|
|
|
|
function setOptionValue(newValue, oldValue, prevAttempt) {
|
|
if (!selectCtrl.hashGetter) {
|
|
if (!prevAttempt) {
|
|
scope.$$postDigest(function() {
|
|
setOptionValue(newValue, oldValue, true);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
var oldHashKey = selectCtrl.hashGetter(oldValue, scope);
|
|
var newHashKey = selectCtrl.hashGetter(newValue, scope);
|
|
|
|
optionCtrl.hashKey = newHashKey;
|
|
optionCtrl.value = newValue;
|
|
|
|
selectCtrl.removeOption(oldHashKey, optionCtrl);
|
|
selectCtrl.addOption(newHashKey, optionCtrl);
|
|
}
|
|
|
|
scope.$on('$destroy', function() {
|
|
selectCtrl.removeOption(optionCtrl.hashKey, optionCtrl);
|
|
});
|
|
|
|
function configureAria() {
|
|
var ariaAttrs = {
|
|
'role': 'option',
|
|
'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');
|
|
$element.attr('aria-selected', 'false');
|
|
}
|
|
this.selected = isSelected;
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
function OptgroupDirective() {
|
|
return {
|
|
restrict: 'E',
|
|
compile: compile
|
|
};
|
|
function compile(el, attrs) {
|
|
// If we have a select header element, we don't want to add the normal label
|
|
// header.
|
|
if (!hasSelectHeader()) {
|
|
setupLabelElement();
|
|
}
|
|
|
|
function hasSelectHeader() {
|
|
return el.parent().find('md-select-header').length;
|
|
}
|
|
|
|
function setupLabelElement() {
|
|
var labelElement = el.find('label');
|
|
if (!labelElement.length) {
|
|
labelElement = angular.element('<label>');
|
|
el.prepend(labelElement);
|
|
}
|
|
labelElement.addClass('md-container-ignore');
|
|
if (attrs.label) labelElement.text(attrs.label);
|
|
}
|
|
}
|
|
}
|
|
|
|
function SelectHeaderDirective() {
|
|
return {
|
|
restrict: 'E',
|
|
};
|
|
}
|
|
|
|
function SelectProvider($$interimElementProvider) {
|
|
selectDefaultOptions.$inject = ["$mdSelect", "$mdConstant", "$mdUtil", "$window", "$q", "$$rAF", "$animateCss", "$animate", "$document"];
|
|
return $$interimElementProvider('$mdSelect')
|
|
.setDefaults({
|
|
methods: ['target'],
|
|
options: selectDefaultOptions
|
|
});
|
|
|
|
/* ngInject */
|
|
function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate, $document) {
|
|
var ERROR_TARGET_EXPECTED = "$mdSelect.show() expected a target element in options.target but got '{0}'!";
|
|
var animator = $mdUtil.dom.animator;
|
|
var keyCodes = $mdConstant.KEY_CODE;
|
|
|
|
return {
|
|
parent: 'body',
|
|
themable: true,
|
|
onShow: onShow,
|
|
onRemove: onRemove,
|
|
hasBackdrop: true,
|
|
disableParentScroll: true
|
|
};
|
|
|
|
/**
|
|
* Interim-element onRemove logic....
|
|
*/
|
|
function onRemove(scope, element, opts) {
|
|
opts = opts || { };
|
|
opts.cleanupInteraction();
|
|
opts.cleanupResizing();
|
|
opts.hideBackdrop();
|
|
|
|
// For navigation $destroy events, do a quick, non-animated removal,
|
|
// but for normal closes (from clicks, etc) animate the removal
|
|
|
|
return (opts.$destroy === true) ? cleanElement() : animateRemoval().then( cleanElement );
|
|
|
|
/**
|
|
* For normal closes (eg clicks), animate the removal.
|
|
* For forced closes (like $destroy events from navigation),
|
|
* skip the animations
|
|
*/
|
|
function animateRemoval() {
|
|
return $animateCss(element, {addClass: 'md-leave'}).start();
|
|
}
|
|
|
|
/**
|
|
* Restore the element to a closed state
|
|
*/
|
|
function cleanElement() {
|
|
|
|
element.removeClass('md-active');
|
|
element.attr('aria-hidden', 'true');
|
|
element[0].style.display = 'none';
|
|
|
|
announceClosed(opts);
|
|
|
|
if (!opts.$destroy && opts.restoreFocus) {
|
|
opts.target.focus();
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Interim-element onShow logic....
|
|
*/
|
|
function onShow(scope, element, opts) {
|
|
|
|
watchAsyncLoad();
|
|
sanitizeAndConfigure(scope, opts);
|
|
|
|
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();
|
|
|
|
return response;
|
|
}, opts.hideBackdrop);
|
|
|
|
// ************************************
|
|
// Closure Functions
|
|
// ************************************
|
|
|
|
/**
|
|
* Attach the select DOM element(s) and animate to the correct positions
|
|
* and scalings...
|
|
*/
|
|
function showDropDown(scope, element, opts) {
|
|
opts.parent.append(element);
|
|
|
|
return $q(function(resolve, reject) {
|
|
|
|
try {
|
|
|
|
$animateCss(element, {removeClass: 'md-leave', duration: 0})
|
|
.start()
|
|
.then(positionAndFocusMenu)
|
|
.then(resolve);
|
|
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize container and dropDown menu positions/scale, then animate
|
|
* to show... and autoFocus.
|
|
*/
|
|
function positionAndFocusMenu() {
|
|
return $q(function(resolve) {
|
|
if (opts.isRemoved) return $q.reject(false);
|
|
|
|
var info = calculateMenuPositions(scope, element, opts);
|
|
|
|
info.container.element.css(animator.toCss(info.container.styles));
|
|
info.dropDown.element.css(animator.toCss(info.dropDown.styles));
|
|
|
|
$$rAF(function() {
|
|
element.addClass('md-active');
|
|
info.dropDown.element.css(animator.toCss({transform: ''}));
|
|
|
|
autoFocus(opts.focusedNode);
|
|
resolve();
|
|
});
|
|
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show modal backdrop element...
|
|
*/
|
|
function showBackdrop(scope, element, options) {
|
|
|
|
// If we are not within a dialog...
|
|
if (options.disableParentScroll && !$mdUtil.getClosest(options.target, 'MD-DIALOG')) {
|
|
// !! DO this before creating the backdrop; since disableScrollAround()
|
|
// configures the scroll offset; which is used by mdBackDrop postLink()
|
|
options.restoreScroll = $mdUtil.disableScrollAround(options.element, options.parent);
|
|
} else {
|
|
options.disableParentScroll = false;
|
|
}
|
|
|
|
if (options.hasBackdrop) {
|
|
// Override duration to immediately show invisible backdrop
|
|
options.backdrop = $mdUtil.createBackdrop(scope, "md-select-backdrop md-click-catcher");
|
|
$animate.enter(options.backdrop, $document[0].body, null, {duration: 0});
|
|
}
|
|
|
|
/**
|
|
* Hide modal backdrop element...
|
|
*/
|
|
return function hideBackdrop() {
|
|
if (options.backdrop) options.backdrop.remove();
|
|
if (options.disableParentScroll) options.restoreScroll();
|
|
|
|
delete options.restoreScroll;
|
|
};
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
function autoFocus(focusedNode) {
|
|
if (focusedNode && !focusedNode.hasAttribute('disabled')) {
|
|
focusedNode.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for valid opts and set some sane defaults
|
|
*/
|
|
function sanitizeAndConfigure(scope, options) {
|
|
var selectEl = element.find('md-select-menu');
|
|
|
|
if (!options.target) {
|
|
throw new Error($mdUtil.supplant(ERROR_TARGET_EXPECTED, [options.target]));
|
|
}
|
|
|
|
angular.extend(options, {
|
|
isRemoved: false,
|
|
target: angular.element(options.target), //make sure it's not a naked dom node
|
|
parent: angular.element(options.parent),
|
|
selectEl: selectEl,
|
|
contentEl: element.find('md-content'),
|
|
optionNodes: selectEl[0].getElementsByTagName('md-option')
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Configure various resize listeners for screen changes
|
|
*/
|
|
function activateResizing() {
|
|
var debouncedOnResize = (function(scope, target, options) {
|
|
|
|
return function() {
|
|
if (options.isRemoved) return;
|
|
|
|
var updates = calculateMenuPositions(scope, target, options);
|
|
var container = updates.container;
|
|
var dropDown = updates.dropDown;
|
|
|
|
container.element.css(animator.toCss(container.styles));
|
|
dropDown.element.css(animator.toCss(dropDown.styles));
|
|
};
|
|
|
|
})(scope, element, opts);
|
|
|
|
var window = angular.element($window);
|
|
window.on('resize', debouncedOnResize);
|
|
window.on('orientationchange', debouncedOnResize);
|
|
|
|
// Publish deactivation closure...
|
|
return function deactivateResizing() {
|
|
|
|
// Disable resizing handlers
|
|
window.off('resize', debouncedOnResize);
|
|
window.off('orientationchange', debouncedOnResize);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* If asynchronously loading, watch and update internal
|
|
* '$$loadingAsyncDone' flag
|
|
*/
|
|
function watchAsyncLoad() {
|
|
if (opts.loadingAsync && !opts.isRemoved) {
|
|
scope.$$loadingAsyncDone = false;
|
|
|
|
$q.when(opts.loadingAsync)
|
|
.then(function() {
|
|
scope.$$loadingAsyncDone = true;
|
|
delete opts.loadingAsync;
|
|
}).then(function() {
|
|
$$rAF(positionAndFocusMenu);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
function activateInteraction() {
|
|
if (opts.isRemoved) return;
|
|
|
|
var dropDown = opts.selectEl;
|
|
var selectCtrl = dropDown.controller('mdSelectMenu') || {};
|
|
|
|
element.addClass('md-clickable');
|
|
|
|
// Close on backdrop click
|
|
opts.backdrop && opts.backdrop.on('click', onBackdropClick);
|
|
|
|
// Escape to close
|
|
// Cycling of options, and closing on enter
|
|
dropDown.on('keydown', onMenuKeyDown);
|
|
dropDown.on('click', checkCloseMenu);
|
|
|
|
return function cleanupInteraction() {
|
|
opts.backdrop && opts.backdrop.off('click', onBackdropClick);
|
|
dropDown.off('keydown', onMenuKeyDown);
|
|
dropDown.off('click', checkCloseMenu);
|
|
|
|
element.removeClass('md-clickable');
|
|
opts.isRemoved = true;
|
|
};
|
|
|
|
// ************************************
|
|
// Closure Functions
|
|
// ************************************
|
|
|
|
function onBackdropClick(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
opts.restoreFocus = false;
|
|
$mdUtil.nextTick($mdSelect.hide, true);
|
|
}
|
|
|
|
function onMenuKeyDown(ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
|
|
switch (ev.keyCode) {
|
|
case keyCodes.UP_ARROW:
|
|
return focusPrevOption();
|
|
case keyCodes.DOWN_ARROW:
|
|
return focusNextOption();
|
|
case keyCodes.SPACE:
|
|
case keyCodes.ENTER:
|
|
var option = $mdUtil.getClosest(ev.target, 'md-option');
|
|
if (option) {
|
|
dropDown.triggerHandler({
|
|
type: 'click',
|
|
target: option
|
|
});
|
|
ev.preventDefault();
|
|
}
|
|
checkCloseMenu(ev);
|
|
break;
|
|
case keyCodes.TAB:
|
|
case keyCodes.ESCAPE:
|
|
ev.stopPropagation();
|
|
ev.preventDefault();
|
|
opts.restoreFocus = true;
|
|
$mdUtil.nextTick($mdSelect.hide, true);
|
|
break;
|
|
default:
|
|
if ($mdConstant.isInputKey(ev) || $mdConstant.isNumPadKey(ev)) {
|
|
var optNode = dropDown.controller('mdSelectMenu').optNodeForKeyboardSearch(ev);
|
|
opts.focusedNode = optNode || opts.focusedNode;
|
|
optNode && optNode.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
function focusOption(direction) {
|
|
var optionsArray = $mdUtil.nodesToArray(opts.optionNodes);
|
|
var index = optionsArray.indexOf(opts.focusedNode);
|
|
|
|
var newOption;
|
|
|
|
do {
|
|
if (index === -1) {
|
|
// We lost the previously focused element, reset to first option
|
|
index = 0;
|
|
} else if (direction === 'next' && index < optionsArray.length - 1) {
|
|
index++;
|
|
} else if (direction === 'prev' && index > 0) {
|
|
index--;
|
|
}
|
|
newOption = optionsArray[index];
|
|
if (newOption.hasAttribute('disabled')) newOption = undefined;
|
|
} while (!newOption && index < optionsArray.length - 1 && index > 0);
|
|
|
|
newOption && newOption.focus();
|
|
opts.focusedNode = newOption;
|
|
}
|
|
|
|
function focusNextOption() {
|
|
focusOption('next');
|
|
}
|
|
|
|
function focusPrevOption() {
|
|
focusOption('prev');
|
|
}
|
|
|
|
function checkCloseMenu(ev) {
|
|
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;
|
|
|
|
$mdUtil.nextTick(function () {
|
|
$mdSelect.hide(selectCtrl.ngModel.$viewValue);
|
|
}, true);
|
|
}
|
|
}
|
|
/**
|
|
* check if the mouseup event was on a scrollbar
|
|
*/
|
|
function mouseOnScrollbar() {
|
|
var clickOnScrollbar = false;
|
|
if (ev && (ev.currentTarget.children.length > 0)) {
|
|
var child = ev.currentTarget.children[0];
|
|
var hasScrollbar = child.scrollHeight > child.clientHeight;
|
|
if (hasScrollbar && child.children.length > 0) {
|
|
var relPosX = ev.pageX - ev.currentTarget.getBoundingClientRect().left;
|
|
if (relPosX > child.querySelector('md-option').offsetWidth)
|
|
clickOnScrollbar = true;
|
|
}
|
|
}
|
|
return clickOnScrollbar;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* To notify listeners that the Select menu has closed,
|
|
* trigger the [optional] user-defined expression
|
|
*/
|
|
function announceClosed(opts) {
|
|
var mdSelect = opts.selectCtrl;
|
|
if (mdSelect) {
|
|
var menuController = opts.selectEl.controller('mdSelectMenu');
|
|
mdSelect.setLabelText(menuController ? menuController.selectedLabels() : '');
|
|
mdSelect.triggerClose();
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Calculate the
|
|
*/
|
|
function calculateMenuPositions(scope, element, opts) {
|
|
var
|
|
containerNode = element[0],
|
|
targetNode = opts.target[0].children[0], // target the label
|
|
parentNode = $document[0].body,
|
|
selectNode = opts.selectEl[0],
|
|
contentNode = opts.contentEl[0],
|
|
parentRect = parentNode.getBoundingClientRect(),
|
|
targetRect = targetNode.getBoundingClientRect(),
|
|
shouldOpenAroundTarget = false,
|
|
bounds = {
|
|
left: parentRect.left + SELECT_EDGE_MARGIN,
|
|
top: SELECT_EDGE_MARGIN,
|
|
bottom: parentRect.height - SELECT_EDGE_MARGIN,
|
|
right: parentRect.width - SELECT_EDGE_MARGIN - ($mdUtil.floatingScrollbars() ? 16 : 0)
|
|
},
|
|
spaceAvailable = {
|
|
top: targetRect.top - bounds.top,
|
|
left: targetRect.left - bounds.left,
|
|
right: bounds.right - (targetRect.left + targetRect.width),
|
|
bottom: bounds.bottom - (targetRect.top + targetRect.height)
|
|
},
|
|
maxWidth = parentRect.width - SELECT_EDGE_MARGIN * 2,
|
|
selectedNode = selectNode.querySelector('md-option[selected]'),
|
|
optionNodes = selectNode.getElementsByTagName('md-option'),
|
|
optgroupNodes = selectNode.getElementsByTagName('md-optgroup'),
|
|
isScrollable = calculateScrollable(element, contentNode),
|
|
centeredNode;
|
|
|
|
var loading = isPromiseLike(opts.loadingAsync);
|
|
if (!loading) {
|
|
// If a selected node, center around that
|
|
if (selectedNode) {
|
|
centeredNode = selectedNode;
|
|
// If there are option groups, center around the first option group
|
|
} else if (optgroupNodes.length) {
|
|
centeredNode = optgroupNodes[0];
|
|
// Otherwise - if we are not loading async - center around the first optionNode
|
|
} else if (optionNodes.length) {
|
|
centeredNode = optionNodes[0];
|
|
// In case there are no options, center on whatever's in there... (eg progress indicator)
|
|
} else {
|
|
centeredNode = contentNode.firstElementChild || contentNode;
|
|
}
|
|
} else {
|
|
// If loading, center on progress indicator
|
|
centeredNode = contentNode.firstElementChild || contentNode;
|
|
}
|
|
|
|
if (contentNode.offsetWidth > maxWidth) {
|
|
contentNode.style['max-width'] = maxWidth + 'px';
|
|
} else {
|
|
contentNode.style.maxWidth = null;
|
|
}
|
|
if (shouldOpenAroundTarget) {
|
|
contentNode.style['min-width'] = targetRect.width + 'px';
|
|
}
|
|
|
|
// Remove padding before we compute the position of the menu
|
|
if (isScrollable) {
|
|
selectNode.classList.add('md-overflow');
|
|
}
|
|
|
|
var focusedNode = centeredNode;
|
|
if ((focusedNode.tagName || '').toUpperCase() === 'MD-OPTGROUP') {
|
|
focusedNode = optionNodes[0] || contentNode.firstElementChild || contentNode;
|
|
centeredNode = focusedNode;
|
|
}
|
|
// Cache for autoFocus()
|
|
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);
|
|
|
|
if (centeredNode) {
|
|
var centeredStyle = $window.getComputedStyle(centeredNode);
|
|
centeredRect.paddingLeft = parseInt(centeredStyle.paddingLeft, 10) || 0;
|
|
centeredRect.paddingRight = parseInt(centeredStyle.paddingRight, 10) || 0;
|
|
}
|
|
|
|
if (isScrollable) {
|
|
var scrollBuffer = contentNode.offsetHeight / 2;
|
|
contentNode.scrollTop = centeredRect.top + centeredRect.height / 2 - scrollBuffer;
|
|
|
|
if (spaceAvailable.top < scrollBuffer) {
|
|
contentNode.scrollTop = Math.min(
|
|
centeredRect.top,
|
|
contentNode.scrollTop + scrollBuffer - spaceAvailable.top
|
|
);
|
|
} else if (spaceAvailable.bottom < scrollBuffer) {
|
|
contentNode.scrollTop = Math.max(
|
|
centeredRect.top + centeredRect.height - selectMenuRect.height,
|
|
contentNode.scrollTop - scrollBuffer + spaceAvailable.bottom
|
|
);
|
|
}
|
|
}
|
|
|
|
var left, top, transformOrigin, minWidth, fontSize;
|
|
if (shouldOpenAroundTarget) {
|
|
left = targetRect.left;
|
|
top = targetRect.top + targetRect.height;
|
|
transformOrigin = '50% 0';
|
|
if (top + selectMenuRect.height > bounds.bottom) {
|
|
top = targetRect.top - selectMenuRect.height;
|
|
transformOrigin = '50% 100%';
|
|
}
|
|
} 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;
|
|
|
|
transformOrigin = (centeredRect.left + targetRect.width / 2) + 'px ' +
|
|
(centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop) + 'px 0px';
|
|
|
|
minWidth = Math.min(targetRect.width + centeredRect.paddingLeft + centeredRect.paddingRight, maxWidth);
|
|
|
|
fontSize = window.getComputedStyle(targetNode)['font-size'];
|
|
}
|
|
|
|
// Keep left and top within the window
|
|
var containerRect = containerNode.getBoundingClientRect();
|
|
var scaleX = Math.round(100 * Math.min(targetRect.width / selectMenuRect.width, 1.0)) / 100;
|
|
var scaleY = Math.round(100 * Math.min(targetRect.height / selectMenuRect.height, 1.0)) / 100;
|
|
|
|
return {
|
|
container: {
|
|
element: angular.element(containerNode),
|
|
styles: {
|
|
left: Math.floor(clamp(bounds.left, left, bounds.right - containerRect.width)),
|
|
top: Math.floor(clamp(bounds.top, top, bounds.bottom - containerRect.height)),
|
|
'min-width': minWidth,
|
|
'font-size': fontSize
|
|
}
|
|
},
|
|
dropDown: {
|
|
element: angular.element(selectNode),
|
|
styles: {
|
|
transformOrigin: transformOrigin,
|
|
transform: !opts.alreadyOpen ? $mdUtil.supplant('scale({0},{1})', [scaleX, scaleY]) : ""
|
|
}
|
|
}
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function isPromiseLike(obj) {
|
|
return obj && angular.isFunction(obj.then);
|
|
}
|
|
|
|
function clamp(min, n, max) {
|
|
return Math.max(min, Math.min(n, max));
|
|
}
|
|
|
|
function getOffsetRect(node) {
|
|
return node ? {
|
|
left: node.offsetLeft,
|
|
top: node.offsetTop,
|
|
width: node.offsetWidth,
|
|
height: node.offsetHeight
|
|
} : {left: 0, top: 0, width: 0, height: 0};
|
|
}
|
|
|
|
function calculateScrollable(element, contentNode) {
|
|
var isScrollable = false;
|
|
|
|
try {
|
|
var oldDisplay = element[0].style.display;
|
|
|
|
// Set the element's display to block so that this calculation is correct
|
|
element[0].style.display = 'block';
|
|
|
|
isScrollable = contentNode.scrollHeight > contentNode.offsetHeight;
|
|
|
|
// Reset it back afterwards
|
|
element[0].style.display = oldDisplay;
|
|
} finally {
|
|
// Nothing to do
|
|
}
|
|
return isScrollable;
|
|
}
|
|
}
|
|
|
|
})(window, window.angular);
|