commonroutesApp/www/lib/angular-material/modules/closure/select/select.js
www/lib/angular-material/modules/closure/select/select.jsRaw
1/*!
2 * Angular Material Design
3 * https://github.com/angular/material
4 * @license MIT
5 * v1.1.1
6 */
7goog.provide('ngmaterial.components.select');
8goog.require('ngmaterial.components.backdrop');
9goog.require('ngmaterial.core');
10/**
11 * @ngdoc module
12 * @name material.components.select
13 */
14
15/***************************************************
16
17 ### TODO - POST RC1 ###
18 - [ ] Abstract placement logic in $mdSelect service to $mdMenu service
19
20 ***************************************************/
21
22SelectDirective.$inject = ["$mdSelect", "$mdUtil", "$mdConstant", "$mdTheming", "$mdAria", "$compile", "$parse"];
23SelectMenuDirective.$inject = ["$parse", "$mdUtil", "$mdConstant", "$mdTheming"];
24OptionDirective.$inject = ["$mdButtonInkRipple", "$mdUtil"];
25SelectProvider.$inject = ["$$interimElementProvider"];
26var SELECT_EDGE_MARGIN = 8;
27var selectNextId = 0;
28var CHECKBOX_SELECTION_INDICATOR =
29 angular.element('<div class="md-container"><div class="md-icon"></div></div>');
30
31angular.module('material.components.select', [
32 'material.core',
33 'material.components.backdrop'
34 ])
35 .directive('mdSelect', SelectDirective)
36 .directive('mdSelectMenu', SelectMenuDirective)
37 .directive('mdOption', OptionDirective)
38 .directive('mdOptgroup', OptgroupDirective)
39 .directive('mdSelectHeader', SelectHeaderDirective)
40 .provider('$mdSelect', SelectProvider);
41
42/**
43 * @ngdoc directive
44 * @name mdSelect
45 * @restrict E
46 * @module material.components.select
47 *
48 * @description Displays a select box, bound to an ng-model.
49 *
50 * When the select is required and uses a floating label, then the label will automatically contain
51 * an asterisk (`*`). This behavior can be disabled by using the `md-no-asterisk` attribute.
52 *
53 * By default, the select will display with an underline to match other form elements. This can be
54 * disabled by applying the `md-no-underline` CSS class.
55 *
56 * ### Option Params
57 *
58 * When applied, `md-option-empty` will mark the option as "empty" allowing the option to clear the
59 * select and put it back in it's default state. You may supply this attribute on any option you
60 * wish, however, it is automatically applied to an option whose `value` or `ng-value` are not
61 * defined.
62 *
63 * **Automatically Applied**
64 *
65 * - `<md-option>`
66 * - `<md-option value>`
67 * - `<md-option value="">`
68 * - `<md-option ng-value>`
69 * - `<md-option ng-value="">`
70 *
71 * **NOT Automatically Applied**
72 *
73 * - `<md-option ng-value="1">`
74 * - `<md-option ng-value="''">`
75 * - `<md-option ng-value="undefined">`
76 * - `<md-option value="undefined">` (this evaluates to the string `"undefined"`)
77 * - <code ng-non-bindable><md-option ng-value="{{someValueThatMightBeUndefined}}"></code>
78 *
79 * **Note:** A value of `undefined` ***is considered a valid value*** (and does not auto-apply this
80 * attribute) since you may wish this to be your "Not Available" or "None" option.
81 *
82 * **Note:** Using the `value` attribute (as opposed to `ng-value`) always evaluates to a string, so
83 * `value="null"` will require the test `ng-if="myValue != 'null'"` rather than `ng-if="!myValue"`.
84 *
85 * @param {expression} ng-model The model!
86 * @param {boolean=} multiple Whether it's multiple.
87 * @param {expression=} md-on-close Expression to be evaluated when the select is closed.
88 * @param {expression=} md-on-open Expression to be evaluated when opening the select.
89 * Will hide the select options and show a spinner until the evaluated promise resolves.
90 * @param {expression=} md-selected-text Expression to be evaluated that will return a string
91 * to be displayed as a placeholder in the select input box when it is closed.
92 * @param {string=} placeholder Placeholder hint text.
93 * @param md-no-asterisk {boolean=} When set to true, an asterisk will not be appended to the
94 * floating label. **Note:** This attribute is only evaluated once; it is not watched.
95 * @param {string=} aria-label Optional label for accessibility. Only necessary if no placeholder or
96 * explicit label is present.
97 * @param {string=} md-container-class Class list to get applied to the `.md-select-menu-container`
98 * element (for custom styling).
99 *
100 * @usage
101 * With a placeholder (label and aria-label are added dynamically)
102 * <hljs lang="html">
103 * <md-input-container>
104 * <md-select
105 * ng-model="someModel"
106 * placeholder="Select a state">
107 * <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
108 * </md-select>
109 * </md-input-container>
110 * </hljs>
111 *
112 * With an explicit label
113 * <hljs lang="html">
114 * <md-input-container>
115 * <label>State</label>
116 * <md-select
117 * ng-model="someModel">
118 * <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
119 * </md-select>
120 * </md-input-container>
121 * </hljs>
122 *
123 * With a select-header
124 *
125 * When a developer needs to put more than just a text label in the
126 * md-select-menu, they should use the md-select-header.
127 * The user can put custom HTML inside of the header and style it to their liking.
128 * One common use case of this would be a sticky search bar.
129 *
130 * When using the md-select-header the labels that would previously be added to the
131 * OptGroupDirective are ignored.
132 *
133 * <hljs lang="html">
134 * <md-input-container>
135 * <md-select ng-model="someModel">
136 * <md-select-header>
137 * <span> Neighborhoods - </span>
138 * </md-select-header>
139 * <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
140 * </md-select>
141 * </md-input-container>
142 * </hljs>
143 *
144 * ## Selects and object equality
145 * When using a `md-select` to pick from a list of objects, it is important to realize how javascript handles
146 * equality. Consider the following example:
147 * <hljs lang="js">
148 * angular.controller('MyCtrl', function($scope) {
149 * $scope.users = [
150 * { id: 1, name: 'Bob' },
151 * { id: 2, name: 'Alice' },
152 * { id: 3, name: 'Steve' }
153 * ];
154 * $scope.selectedUser = { id: 1, name: 'Bob' };
155 * });
156 * </hljs>
157 * <hljs lang="html">
158 * <div ng-controller="MyCtrl">
159 * <md-select ng-model="selectedUser">
160 * <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
161 * </md-select>
162 * </div>
163 * </hljs>
164 *
165 * At first one might expect that the select should be populated with "Bob" as the selected user. However,
166 * this is not true. To determine whether something is selected,
167 * `ngModelController` is looking at whether `$scope.selectedUser == (any user in $scope.users);`;
168 *
169 * Javascript's `==` operator does not check for deep equality (ie. that all properties
170 * on the object are the same), but instead whether the objects are *the same object in memory*.
171 * In this case, we have two instances of identical objects, but they exist in memory as unique
172 * entities. Because of this, the select will have no value populated for a selected user.
173 *
174 * To get around this, `ngModelController` provides a `track by` option that allows us to specify a different
175 * expression which will be used for the equality operator. As such, we can update our `html` to
176 * make use of this by specifying the `ng-model-options="{trackBy: '$value.id'}"` on the `md-select`
177 * element. This converts our equality expression to be
178 * `$scope.selectedUser.id == (any id in $scope.users.map(function(u) { return u.id; }));`
179 * which results in Bob being selected as desired.
180 *
181 * Working HTML:
182 * <hljs lang="html">
183 * <div ng-controller="MyCtrl">
184 * <md-select ng-model="selectedUser" ng-model-options="{trackBy: '$value.id'}">
185 * <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
186 * </md-select>
187 * </div>
188 * </hljs>
189 */
190function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $compile, $parse) {
191 var keyCodes = $mdConstant.KEY_CODE;
192 var NAVIGATION_KEYS = [keyCodes.SPACE, keyCodes.ENTER, keyCodes.UP_ARROW, keyCodes.DOWN_ARROW];
193
194 return {
195 restrict: 'E',
196 require: ['^?mdInputContainer', 'mdSelect', 'ngModel', '?^form'],
197 compile: compile,
198 controller: function() {
199 } // empty placeholder controller to be initialized in link
200 };
201
202 function compile(element, attr) {
203 // add the select value that will hold our placeholder or selected option value
204 var valueEl = angular.element('<md-select-value><span></span></md-select-value>');
205 valueEl.append('<span class="md-select-icon" aria-hidden="true"></span>');
206 valueEl.addClass('md-select-value');
207 if (!valueEl[0].hasAttribute('id')) {
208 valueEl.attr('id', 'select_value_label_' + $mdUtil.nextUid());
209 }
210
211 // There's got to be an md-content inside. If there's not one, let's add it.
212 if (!element.find('md-content').length) {
213 element.append(angular.element('<md-content>').append(element.contents()));
214 }
215
216
217 // Add progress spinner for md-options-loading
218 if (attr.mdOnOpen) {
219
220 // Show progress indicator while loading async
221 // Use ng-hide for `display:none` so the indicator does not interfere with the options list
222 element
223 .find('md-content')
224 .prepend(angular.element(
225 '<div>' +
226 ' <md-progress-circular md-mode="indeterminate" ng-if="$$loadingAsyncDone === false" md-diameter="25px"></md-progress-circular>' +
227 '</div>'
228 ));
229
230 // Hide list [of item options] while loading async
231 element
232 .find('md-option')
233 .attr('ng-show', '$$loadingAsyncDone');
234 }
235
236 if (attr.name) {
237 var autofillClone = angular.element('<select class="md-visually-hidden">');
238 autofillClone.attr({
239 'name': attr.name,
240 'aria-hidden': 'true',
241 'tabindex': '-1'
242 });
243 var opts = element.find('md-option');
244 angular.forEach(opts, function(el) {
245 var newEl = angular.element('<option>' + el.innerHTML + '</option>');
246 if (el.hasAttribute('ng-value')) newEl.attr('ng-value', el.getAttribute('ng-value'));
247 else if (el.hasAttribute('value')) newEl.attr('value', el.getAttribute('value'));
248 autofillClone.append(newEl);
249 });
250
251 // Adds an extra option that will hold the selected value for the
252 // cases where the select is a part of a non-angular form. This can be done with a ng-model,
253 // however if the `md-option` is being `ng-repeat`-ed, Angular seems to insert a similar
254 // `option` node, but with a value of `? string: <value> ?` which would then get submitted.
255 // This also goes around having to prepend a dot to the name attribute.
256 autofillClone.append(
257 '<option ng-value="' + attr.ngModel + '" selected></option>'
258 );
259
260 element.parent().append(autofillClone);
261 }
262
263 var isMultiple = $mdUtil.parseAttributeBoolean(attr.multiple);
264
265 // Use everything that's left inside element.contents() as the contents of the menu
266 var multipleContent = isMultiple ? 'multiple' : '';
267 var selectTemplate = '' +
268 '<div class="md-select-menu-container" aria-hidden="true">' +
269 '<md-select-menu {0}>{1}</md-select-menu>' +
270 '</div>';
271
272 selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, element.html()]);
273 element.empty().append(valueEl);
274 element.append(selectTemplate);
275
276 if(!attr.tabindex){
277 attr.$set('tabindex', 0);
278 }
279
280 return function postLink(scope, element, attr, ctrls) {
281 var untouched = true;
282 var isDisabled, ariaLabelBase;
283
284 var containerCtrl = ctrls[0];
285 var mdSelectCtrl = ctrls[1];
286 var ngModelCtrl = ctrls[2];
287 var formCtrl = ctrls[3];
288 // grab a reference to the select menu value label
289 var valueEl = element.find('md-select-value');
290 var isReadonly = angular.isDefined(attr.readonly);
291 var disableAsterisk = $mdUtil.parseAttributeBoolean(attr.mdNoAsterisk);
292
293 if (disableAsterisk) {
294 element.addClass('md-no-asterisk');
295 }
296
297 if (containerCtrl) {
298 var isErrorGetter = containerCtrl.isErrorGetter || function() {
299 return ngModelCtrl.$invalid && (ngModelCtrl.$touched || (formCtrl && formCtrl.$submitted));
300 };
301
302 if (containerCtrl.input) {
303 // We ignore inputs that are in the md-select-header (one
304 // case where this might be useful would be adding as searchbox)
305 if (element.find('md-select-header').find('input')[0] !== containerCtrl.input[0]) {
306 throw new Error("<md-input-container> can only have *one* child <input>, <textarea> or <select> element!");
307 }
308 }
309
310 containerCtrl.input = element;
311 if (!containerCtrl.label) {
312 $mdAria.expect(element, 'aria-label', element.attr('placeholder'));
313 }
314
315 scope.$watch(isErrorGetter, containerCtrl.setInvalid);
316 }
317
318 var selectContainer, selectScope, selectMenuCtrl;
319
320 findSelectContainer();
321 $mdTheming(element);
322
323 if (formCtrl && angular.isDefined(attr.multiple)) {
324 $mdUtil.nextTick(function() {
325 var hasModelValue = ngModelCtrl.$modelValue || ngModelCtrl.$viewValue;
326 if (hasModelValue) {
327 formCtrl.$setPristine();
328 }
329 });
330 }
331
332 var originalRender = ngModelCtrl.$render;
333 ngModelCtrl.$render = function() {
334 originalRender();
335 syncLabelText();
336 syncAriaLabel();
337 inputCheckValue();
338 };
339
340 attr.$observe('placeholder', ngModelCtrl.$render);
341
342 if (containerCtrl && containerCtrl.label) {
343 attr.$observe('required', function (value) {
344 // Toggle the md-required class on the input containers label, because the input container is automatically
345 // applying the asterisk indicator on the label.
346 containerCtrl.label.toggleClass('md-required', value && !disableAsterisk);
347 });
348 }
349
350 mdSelectCtrl.setLabelText = function(text) {
351 mdSelectCtrl.setIsPlaceholder(!text);
352
353 if (attr.mdSelectedText) {
354 text = $parse(attr.mdSelectedText)(scope);
355 } else {
356 // Use placeholder attribute, otherwise fallback to the md-input-container label
357 var tmpPlaceholder = attr.placeholder ||
358 (containerCtrl && containerCtrl.label ? containerCtrl.label.text() : '');
359 text = text || tmpPlaceholder || '';
360 }
361
362 var target = valueEl.children().eq(0);
363 target.html(text);
364 };
365
366 mdSelectCtrl.setIsPlaceholder = function(isPlaceholder) {
367 if (isPlaceholder) {
368 valueEl.addClass('md-select-placeholder');
369 if (containerCtrl && containerCtrl.label) {
370 containerCtrl.label.addClass('md-placeholder');
371 }
372 } else {
373 valueEl.removeClass('md-select-placeholder');
374 if (containerCtrl && containerCtrl.label) {
375 containerCtrl.label.removeClass('md-placeholder');
376 }
377 }
378 };
379
380 if (!isReadonly) {
381 element
382 .on('focus', function(ev) {
383 // Always focus the container (if we have one) so floating labels and other styles are
384 // applied properly
385 containerCtrl && containerCtrl.setFocused(true);
386 });
387
388 // Attach before ngModel's blur listener to stop propagation of blur event
389 // to prevent from setting $touched.
390 element.on('blur', function(event) {
391 if (untouched) {
392 untouched = false;
393 if (selectScope._mdSelectIsOpen) {
394 event.stopImmediatePropagation();
395 }
396 }
397
398 if (selectScope._mdSelectIsOpen) return;
399 containerCtrl && containerCtrl.setFocused(false);
400 inputCheckValue();
401 });
402 }
403
404 mdSelectCtrl.triggerClose = function() {
405 $parse(attr.mdOnClose)(scope);
406 };
407
408 scope.$$postDigest(function() {
409 initAriaLabel();
410 syncLabelText();
411 syncAriaLabel();
412 });
413
414 function initAriaLabel() {
415 var labelText = element.attr('aria-label') || element.attr('placeholder');
416 if (!labelText && containerCtrl && containerCtrl.label) {
417 labelText = containerCtrl.label.text();
418 }
419 ariaLabelBase = labelText;
420 $mdAria.expect(element, 'aria-label', labelText);
421 }
422
423 scope.$watch(function() {
424 return selectMenuCtrl.selectedLabels();
425 }, syncLabelText);
426
427 function syncLabelText() {
428 if (selectContainer) {
429 selectMenuCtrl = selectMenuCtrl || selectContainer.find('md-select-menu').controller('mdSelectMenu');
430 mdSelectCtrl.setLabelText(selectMenuCtrl.selectedLabels());
431 }
432 }
433
434 function syncAriaLabel() {
435 if (!ariaLabelBase) return;
436 var ariaLabels = selectMenuCtrl.selectedLabels({mode: 'aria'});
437 element.attr('aria-label', ariaLabels.length ? ariaLabelBase + ': ' + ariaLabels : ariaLabelBase);
438 }
439
440 var deregisterWatcher;
441 attr.$observe('ngMultiple', function(val) {
442 if (deregisterWatcher) deregisterWatcher();
443 var parser = $parse(val);
444 deregisterWatcher = scope.$watch(function() {
445 return parser(scope);
446 }, function(multiple, prevVal) {
447 if (multiple === undefined && prevVal === undefined) return; // assume compiler did a good job
448 if (multiple) {
449 element.attr('multiple', 'multiple');
450 } else {
451 element.removeAttr('multiple');
452 }
453 element.attr('aria-multiselectable', multiple ? 'true' : 'false');
454 if (selectContainer) {
455 selectMenuCtrl.setMultiple(multiple);
456 originalRender = ngModelCtrl.$render;
457 ngModelCtrl.$render = function() {
458 originalRender();
459 syncLabelText();
460 syncAriaLabel();
461 inputCheckValue();
462 };
463 ngModelCtrl.$render();
464 }
465 });
466 });
467
468 attr.$observe('disabled', function(disabled) {
469 if (angular.isString(disabled)) {
470 disabled = true;
471 }
472 // Prevent click event being registered twice
473 if (isDisabled !== undefined && isDisabled === disabled) {
474 return;
475 }
476 isDisabled = disabled;
477 if (disabled) {
478 element
479 .attr({'aria-disabled': 'true'})
480 .removeAttr('tabindex')
481 .off('click', openSelect)
482 .off('keydown', handleKeypress);
483 } else {
484 element
485 .attr({'tabindex': attr.tabindex, 'aria-disabled': 'false'})
486 .on('click', openSelect)
487 .on('keydown', handleKeypress);
488 }
489 });
490
491 if (!attr.hasOwnProperty('disabled') && !attr.hasOwnProperty('ngDisabled')) {
492 element.attr({'aria-disabled': 'false'});
493 element.on('click', openSelect);
494 element.on('keydown', handleKeypress);
495 }
496
497 var ariaAttrs = {
498 role: 'listbox',
499 'aria-expanded': 'false',
500 'aria-multiselectable': isMultiple && !attr.ngMultiple ? 'true' : 'false'
501 };
502
503 if (!element[0].hasAttribute('id')) {
504 ariaAttrs.id = 'select_' + $mdUtil.nextUid();
505 }
506
507 var containerId = 'select_container_' + $mdUtil.nextUid();
508 selectContainer.attr('id', containerId);
509 ariaAttrs['aria-owns'] = containerId;
510 element.attr(ariaAttrs);
511
512 scope.$on('$destroy', function() {
513 $mdSelect
514 .destroy()
515 .finally(function() {
516 if (containerCtrl) {
517 containerCtrl.setFocused(false);
518 containerCtrl.setHasValue(false);
519 containerCtrl.input = null;
520 }
521 ngModelCtrl.$setTouched();
522 });
523 });
524
525
526
527 function inputCheckValue() {
528 // The select counts as having a value if one or more options are selected,
529 // or if the input's validity state says it has bad input (eg string in a number input)
530 containerCtrl && containerCtrl.setHasValue(selectMenuCtrl.selectedLabels().length > 0 || (element[0].validity || {}).badInput);
531 }
532
533 function findSelectContainer() {
534 selectContainer = angular.element(
535 element[0].querySelector('.md-select-menu-container')
536 );
537 selectScope = scope;
538 if (attr.mdContainerClass) {
539 var value = selectContainer[0].getAttribute('class') + ' ' + attr.mdContainerClass;
540 selectContainer[0].setAttribute('class', value);
541 }
542 selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
543 selectMenuCtrl.init(ngModelCtrl, attr.ngModel);
544 element.on('$destroy', function() {
545 selectContainer.remove();
546 });
547 }
548
549 function handleKeypress(e) {
550 if ($mdConstant.isNavigationKey(e)) {
551 // prevent page scrolling on interaction
552 e.preventDefault();
553 openSelect(e);
554 } else {
555 if ($mdConstant.isInputKey(e) || $mdConstant.isNumPadKey(e)) {
556 e.preventDefault();
557
558 var node = selectMenuCtrl.optNodeForKeyboardSearch(e);
559 if (!node || node.hasAttribute('disabled')) return;
560 var optionCtrl = angular.element(node).controller('mdOption');
561 if (!selectMenuCtrl.isMultiple) {
562 selectMenuCtrl.deselect(Object.keys(selectMenuCtrl.selected)[0]);
563 }
564 selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value);
565 selectMenuCtrl.refreshViewValue();
566 }
567 }
568 }
569
570 function openSelect() {
571 selectScope._mdSelectIsOpen = true;
572 element.attr('aria-expanded', 'true');
573
574 $mdSelect.show({
575 scope: selectScope,
576 preserveScope: true,
577 skipCompile: true,
578 element: selectContainer,
579 target: element[0],
580 selectCtrl: mdSelectCtrl,
581 preserveElement: true,
582 hasBackdrop: true,
583 loadingAsync: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) || true : false
584 }).finally(function() {
585 selectScope._mdSelectIsOpen = false;
586 element.focus();
587 element.attr('aria-expanded', 'false');
588 ngModelCtrl.$setTouched();
589 });
590 }
591
592 };
593 }
594}
595
596function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) {
597 // We want the scope to be set to 'false' so an isolated scope is not created
598 // which would interfere with the md-select-header's access to the
599 // parent scope.
600 SelectMenuController.$inject = ["$scope", "$attrs", "$element"];
601 return {
602 restrict: 'E',
603 require: ['mdSelectMenu'],
604 scope: false,
605 controller: SelectMenuController,
606 link: {pre: preLink}
607 };
608
609 // We use preLink instead of postLink to ensure that the select is initialized before
610 // its child options run postLink.
611 function preLink(scope, element, attr, ctrls) {
612 var selectCtrl = ctrls[0];
613
614 element.addClass('_md'); // private md component indicator for styling
615
616 $mdTheming(element);
617 element.on('click', clickListener);
618 element.on('keypress', keyListener);
619
620 function keyListener(e) {
621 if (e.keyCode == 13 || e.keyCode == 32) {
622 clickListener(e);
623 }
624 }
625
626 function clickListener(ev) {
627 var option = $mdUtil.getClosest(ev.target, 'md-option');
628 var optionCtrl = option && angular.element(option).data('$mdOptionController');
629 if (!option || !optionCtrl) return;
630 if (option.hasAttribute('disabled')) {
631 ev.stopImmediatePropagation();
632 return false;
633 }
634
635 var optionHashKey = selectCtrl.hashGetter(optionCtrl.value);
636 var isSelected = angular.isDefined(selectCtrl.selected[optionHashKey]);
637
638 scope.$apply(function() {
639 if (selectCtrl.isMultiple) {
640 if (isSelected) {
641 selectCtrl.deselect(optionHashKey);
642 } else {
643 selectCtrl.select(optionHashKey, optionCtrl.value);
644 }
645 } else {
646 if (!isSelected) {
647 selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]);
648 selectCtrl.select(optionHashKey, optionCtrl.value);
649 }
650 }
651 selectCtrl.refreshViewValue();
652 });
653 }
654 }
655
656 function SelectMenuController($scope, $attrs, $element) {
657 var self = this;
658 self.isMultiple = angular.isDefined($attrs.multiple);
659 // selected is an object with keys matching all of the selected options' hashed values
660 self.selected = {};
661 // options is an object with keys matching every option's hash value,
662 // and values matching every option's controller.
663 self.options = {};
664
665 $scope.$watchCollection(function() {
666 return self.options;
667 }, function() {
668 self.ngModel.$render();
669 });
670
671 var deregisterCollectionWatch;
672 var defaultIsEmpty;
673 self.setMultiple = function(isMultiple) {
674 var ngModel = self.ngModel;
675 defaultIsEmpty = defaultIsEmpty || ngModel.$isEmpty;
676
677 self.isMultiple = isMultiple;
678 if (deregisterCollectionWatch) deregisterCollectionWatch();
679
680 if (self.isMultiple) {
681 ngModel.$validators['md-multiple'] = validateArray;
682 ngModel.$render = renderMultiple;
683
684 // watchCollection on the model because by default ngModel only watches the model's
685 // reference. This allowed the developer to also push and pop from their array.
686 $scope.$watchCollection(self.modelBinding, function(value) {
687 if (validateArray(value)) renderMultiple(value);
688 self.ngModel.$setPristine();
689 });
690
691 ngModel.$isEmpty = function(value) {
692 return !value || value.length === 0;
693 };
694 } else {
695 delete ngModel.$validators['md-multiple'];
696 ngModel.$render = renderSingular;
697 }
698
699 function validateArray(modelValue, viewValue) {
700 // If a value is truthy but not an array, reject it.
701 // If value is undefined/falsy, accept that it's an empty array.
702 return angular.isArray(modelValue || viewValue || []);
703 }
704 };
705
706 var searchStr = '';
707 var clearSearchTimeout, optNodes, optText;
708 var CLEAR_SEARCH_AFTER = 300;
709
710 self.optNodeForKeyboardSearch = function(e) {
711 clearSearchTimeout && clearTimeout(clearSearchTimeout);
712 clearSearchTimeout = setTimeout(function() {
713 clearSearchTimeout = undefined;
714 searchStr = '';
715 optText = undefined;
716 optNodes = undefined;
717 }, CLEAR_SEARCH_AFTER);
718
719 // Support 1-9 on numpad
720 var keyCode = e.keyCode - ($mdConstant.isNumPadKey(e) ? 48 : 0);
721
722 searchStr += String.fromCharCode(keyCode);
723 var search = new RegExp('^' + searchStr, 'i');
724 if (!optNodes) {
725 optNodes = $element.find('md-option');
726 optText = new Array(optNodes.length);
727 angular.forEach(optNodes, function(el, i) {
728 optText[i] = el.textContent.trim();
729 });
730 }
731 for (var i = 0; i < optText.length; ++i) {
732 if (search.test(optText[i])) {
733 return optNodes[i];
734 }
735 }
736 };
737
738 self.init = function(ngModel, binding) {
739 self.ngModel = ngModel;
740 self.modelBinding = binding;
741
742 // Setup a more robust version of isEmpty to ensure value is a valid option
743 self.ngModel.$isEmpty = function($viewValue) {
744 // We have to transform the viewValue into the hashKey, because otherwise the
745 // OptionCtrl may not exist. Developers may have specified a trackBy function.
746 return !self.options[self.hashGetter($viewValue)];
747 };
748
749 // Allow users to provide `ng-model="foo" ng-model-options="{trackBy: 'foo.id'}"` so
750 // that we can properly compare objects set on the model to the available options
751 if (ngModel.$options && ngModel.$options.trackBy) {
752 var trackByLocals = {};
753 var trackByParsed = $parse(ngModel.$options.trackBy);
754 self.hashGetter = function(value, valueScope) {
755 trackByLocals.$value = value;
756 return trackByParsed(valueScope || $scope, trackByLocals);
757 };
758 // If the user doesn't provide a trackBy, we automatically generate an id for every
759 // value passed in
760 } else {
761 self.hashGetter = function getHashValue(value) {
762 if (angular.isObject(value)) {
763 return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
764 }
765 return value;
766 };
767 }
768 self.setMultiple(self.isMultiple);
769 };
770
771 self.selectedLabels = function(opts) {
772 opts = opts || {};
773 var mode = opts.mode || 'html';
774 var selectedOptionEls = $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]'));
775 if (selectedOptionEls.length) {
776 var mapFn;
777
778 if (mode == 'html') {
779 // Map the given element to its innerHTML string. If the element has a child ripple
780 // container remove it from the HTML string, before returning the string.
781 mapFn = function(el) {
782 // If we do not have a `value` or `ng-value`, assume it is an empty option which clears the select
783 if (el.hasAttribute('md-option-empty')) {
784 return '';
785 }
786
787 var html = el.innerHTML;
788
789 // Remove the ripple container from the selected option, copying it would cause a CSP violation.
790 var rippleContainer = el.querySelector('.md-ripple-container');
791 if (rippleContainer) {
792 html = html.replace(rippleContainer.outerHTML, '');
793 }
794
795 // Remove the checkbox container, because it will cause the label to wrap inside of the placeholder.
796 // It should be not displayed inside of the label element.
797 var checkboxContainer = el.querySelector('.md-container');
798 if (checkboxContainer) {
799 html = html.replace(checkboxContainer.outerHTML, '');
800 }
801
802 return html;
803 };
804 } else if (mode == 'aria') {
805 mapFn = function(el) { return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent; };
806 }
807 return selectedOptionEls.map(mapFn).join(', ');
808 } else {
809 return '';
810 }
811 };
812
813 self.select = function(hashKey, hashedValue) {
814 var option = self.options[hashKey];
815 option && option.setSelected(true);
816 self.selected[hashKey] = hashedValue;
817 };
818 self.deselect = function(hashKey) {
819 var option = self.options[hashKey];
820 option && option.setSelected(false);
821 delete self.selected[hashKey];
822 };
823
824 self.addOption = function(hashKey, optionCtrl) {
825 if (angular.isDefined(self.options[hashKey])) {
826 throw new Error('Duplicate md-option values are not allowed in a select. ' +
827 'Duplicate value "' + optionCtrl.value + '" found.');
828 }
829
830 self.options[hashKey] = optionCtrl;
831
832 // If this option's value was already in our ngModel, go ahead and select it.
833 if (angular.isDefined(self.selected[hashKey])) {
834 self.select(hashKey, optionCtrl.value);
835
836 // When the current $modelValue of the ngModel Controller is using the same hash as
837 // the current option, which will be added, then we can be sure, that the validation
838 // of the option has occurred before the option was added properly.
839 // This means, that we have to manually trigger a new validation of the current option.
840 if (angular.isDefined(self.ngModel.$modelValue) && self.hashGetter(self.ngModel.$modelValue) === hashKey) {
841 self.ngModel.$validate();
842 }
843
844 self.refreshViewValue();
845 }
846 };
847 self.removeOption = function(hashKey) {
848 delete self.options[hashKey];
849 // Don't deselect an option when it's removed - the user's ngModel should be allowed
850 // to have values that do not match a currently available option.
851 };
852
853 self.refreshViewValue = function() {
854 var values = [];
855 var option;
856 for (var hashKey in self.selected) {
857 // If this hashKey has an associated option, push that option's value to the model.
858 if ((option = self.options[hashKey])) {
859 values.push(option.value);
860 } else {
861 // Otherwise, the given hashKey has no associated option, and we got it
862 // from an ngModel value at an earlier time. Push the unhashed value of
863 // this hashKey to the model.
864 // This allows the developer to put a value in the model that doesn't yet have
865 // an associated option.
866 values.push(self.selected[hashKey]);
867 }
868 }
869 var usingTrackBy = self.ngModel.$options && self.ngModel.$options.trackBy;
870
871 var newVal = self.isMultiple ? values : values[0];
872 var prevVal = self.ngModel.$modelValue;
873
874 if (usingTrackBy ? !angular.equals(prevVal, newVal) : prevVal != newVal) {
875 self.ngModel.$setViewValue(newVal);
876 self.ngModel.$render();
877 }
878 };
879
880 function renderMultiple() {
881 var newSelectedValues = self.ngModel.$modelValue || self.ngModel.$viewValue || [];
882 if (!angular.isArray(newSelectedValues)) return;
883
884 var oldSelected = Object.keys(self.selected);
885
886 var newSelectedHashes = newSelectedValues.map(self.hashGetter);
887 var deselected = oldSelected.filter(function(hash) {
888 return newSelectedHashes.indexOf(hash) === -1;
889 });
890
891 deselected.forEach(self.deselect);
892 newSelectedHashes.forEach(function(hashKey, i) {
893 self.select(hashKey, newSelectedValues[i]);
894 });
895 }
896
897 function renderSingular() {
898 var value = self.ngModel.$viewValue || self.ngModel.$modelValue;
899 Object.keys(self.selected).forEach(self.deselect);
900 self.select(self.hashGetter(value), value);
901 }
902 }
903
904}
905
906function OptionDirective($mdButtonInkRipple, $mdUtil) {
907
908 OptionController.$inject = ["$element"];
909 return {
910 restrict: 'E',
911 require: ['mdOption', '^^mdSelectMenu'],
912 controller: OptionController,
913 compile: compile
914 };
915
916 function compile(element, attr) {
917 // Manual transclusion to avoid the extra inner <span> that ng-transclude generates
918 element.append(angular.element('<div class="md-text">').append(element.contents()));
919
920 element.attr('tabindex', attr.tabindex || '0');
921
922 if (!hasDefinedValue(attr)) {
923 element.attr('md-option-empty', '');
924 }
925
926 return postLink;
927 }
928
929 function hasDefinedValue(attr) {
930 var value = attr.value;
931 var ngValue = attr.ngValue;
932
933 return value || ngValue;
934 }
935
936 function postLink(scope, element, attr, ctrls) {
937 var optionCtrl = ctrls[0];
938 var selectCtrl = ctrls[1];
939
940 if (selectCtrl.isMultiple) {
941 element.addClass('md-checkbox-enabled');
942 element.prepend(CHECKBOX_SELECTION_INDICATOR.clone());
943 }
944
945 if (angular.isDefined(attr.ngValue)) {
946 scope.$watch(attr.ngValue, setOptionValue);
947 } else if (angular.isDefined(attr.value)) {
948 setOptionValue(attr.value);
949 } else {
950 scope.$watch(function() {
951 return element.text().trim();
952 }, setOptionValue);
953 }
954
955 attr.$observe('disabled', function(disabled) {
956 if (disabled) {
957 element.attr('tabindex', '-1');
958 } else {
959 element.attr('tabindex', '0');
960 }
961 });
962
963 scope.$$postDigest(function() {
964 attr.$observe('selected', function(selected) {
965 if (!angular.isDefined(selected)) return;
966 if (typeof selected == 'string') selected = true;
967 if (selected) {
968 if (!selectCtrl.isMultiple) {
969 selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]);
970 }
971 selectCtrl.select(optionCtrl.hashKey, optionCtrl.value);
972 } else {
973 selectCtrl.deselect(optionCtrl.hashKey);
974 }
975 selectCtrl.refreshViewValue();
976 });
977 });
978
979 $mdButtonInkRipple.attach(scope, element);
980 configureAria();
981
982 function setOptionValue(newValue, oldValue, prevAttempt) {
983 if (!selectCtrl.hashGetter) {
984 if (!prevAttempt) {
985 scope.$$postDigest(function() {
986 setOptionValue(newValue, oldValue, true);
987 });
988 }
989 return;
990 }
991 var oldHashKey = selectCtrl.hashGetter(oldValue, scope);
992 var newHashKey = selectCtrl.hashGetter(newValue, scope);
993
994 optionCtrl.hashKey = newHashKey;
995 optionCtrl.value = newValue;
996
997 selectCtrl.removeOption(oldHashKey, optionCtrl);
998 selectCtrl.addOption(newHashKey, optionCtrl);
999 }
1000
1001 scope.$on('$destroy', function() {
1002 selectCtrl.removeOption(optionCtrl.hashKey, optionCtrl);
1003 });
1004
1005 function configureAria() {
1006 var ariaAttrs = {
1007 'role': 'option',
1008 'aria-selected': 'false'
1009 };
1010
1011 if (!element[0].hasAttribute('id')) {
1012 ariaAttrs.id = 'select_option_' + $mdUtil.nextUid();
1013 }
1014 element.attr(ariaAttrs);
1015 }
1016 }
1017
1018 function OptionController($element) {
1019 this.selected = false;
1020 this.setSelected = function(isSelected) {
1021 if (isSelected && !this.selected) {
1022 $element.attr({
1023 'selected': 'selected',
1024 'aria-selected': 'true'
1025 });
1026 } else if (!isSelected && this.selected) {
1027 $element.removeAttr('selected');
1028 $element.attr('aria-selected', 'false');
1029 }
1030 this.selected = isSelected;
1031 };
1032 }
1033
1034}
1035
1036function OptgroupDirective() {
1037 return {
1038 restrict: 'E',
1039 compile: compile
1040 };
1041 function compile(el, attrs) {
1042 // If we have a select header element, we don't want to add the normal label
1043 // header.
1044 if (!hasSelectHeader()) {
1045 setupLabelElement();
1046 }
1047
1048 function hasSelectHeader() {
1049 return el.parent().find('md-select-header').length;
1050 }
1051
1052 function setupLabelElement() {
1053 var labelElement = el.find('label');
1054 if (!labelElement.length) {
1055 labelElement = angular.element('<label>');
1056 el.prepend(labelElement);
1057 }
1058 labelElement.addClass('md-container-ignore');
1059 if (attrs.label) labelElement.text(attrs.label);
1060 }
1061 }
1062}
1063
1064function SelectHeaderDirective() {
1065 return {
1066 restrict: 'E',
1067 };
1068}
1069
1070function SelectProvider($$interimElementProvider) {
1071 selectDefaultOptions.$inject = ["$mdSelect", "$mdConstant", "$mdUtil", "$window", "$q", "$$rAF", "$animateCss", "$animate", "$document"];
1072 return $$interimElementProvider('$mdSelect')
1073 .setDefaults({
1074 methods: ['target'],
1075 options: selectDefaultOptions
1076 });
1077
1078 /* ngInject */
1079 function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate, $document) {
1080 var ERROR_TARGET_EXPECTED = "$mdSelect.show() expected a target element in options.target but got '{0}'!";
1081 var animator = $mdUtil.dom.animator;
1082 var keyCodes = $mdConstant.KEY_CODE;
1083
1084 return {
1085 parent: 'body',
1086 themable: true,
1087 onShow: onShow,
1088 onRemove: onRemove,
1089 hasBackdrop: true,
1090 disableParentScroll: true
1091 };
1092
1093 /**
1094 * Interim-element onRemove logic....
1095 */
1096 function onRemove(scope, element, opts) {
1097 opts = opts || { };
1098 opts.cleanupInteraction();
1099 opts.cleanupResizing();
1100 opts.hideBackdrop();
1101
1102 // For navigation $destroy events, do a quick, non-animated removal,
1103 // but for normal closes (from clicks, etc) animate the removal
1104
1105 return (opts.$destroy === true) ? cleanElement() : animateRemoval().then( cleanElement );
1106
1107 /**
1108 * For normal closes (eg clicks), animate the removal.
1109 * For forced closes (like $destroy events from navigation),
1110 * skip the animations
1111 */
1112 function animateRemoval() {
1113 return $animateCss(element, {addClass: 'md-leave'}).start();
1114 }
1115
1116 /**
1117 * Restore the element to a closed state
1118 */
1119 function cleanElement() {
1120
1121 element.removeClass('md-active');
1122 element.attr('aria-hidden', 'true');
1123 element[0].style.display = 'none';
1124
1125 announceClosed(opts);
1126
1127 if (!opts.$destroy && opts.restoreFocus) {
1128 opts.target.focus();
1129 }
1130 }
1131
1132 }
1133
1134 /**
1135 * Interim-element onShow logic....
1136 */
1137 function onShow(scope, element, opts) {
1138
1139 watchAsyncLoad();
1140 sanitizeAndConfigure(scope, opts);
1141
1142 opts.hideBackdrop = showBackdrop(scope, element, opts);
1143
1144 return showDropDown(scope, element, opts)
1145 .then(function(response) {
1146 element.attr('aria-hidden', 'false');
1147 opts.alreadyOpen = true;
1148 opts.cleanupInteraction = activateInteraction();
1149 opts.cleanupResizing = activateResizing();
1150
1151 return response;
1152 }, opts.hideBackdrop);
1153
1154 // ************************************
1155 // Closure Functions
1156 // ************************************
1157
1158 /**
1159 * Attach the select DOM element(s) and animate to the correct positions
1160 * and scalings...
1161 */
1162 function showDropDown(scope, element, opts) {
1163 opts.parent.append(element);
1164
1165 return $q(function(resolve, reject) {
1166
1167 try {
1168
1169 $animateCss(element, {removeClass: 'md-leave', duration: 0})
1170 .start()
1171 .then(positionAndFocusMenu)
1172 .then(resolve);
1173
1174 } catch (e) {
1175 reject(e);
1176 }
1177
1178 });
1179 }
1180
1181 /**
1182 * Initialize container and dropDown menu positions/scale, then animate
1183 * to show... and autoFocus.
1184 */
1185 function positionAndFocusMenu() {
1186 return $q(function(resolve) {
1187 if (opts.isRemoved) return $q.reject(false);
1188
1189 var info = calculateMenuPositions(scope, element, opts);
1190
1191 info.container.element.css(animator.toCss(info.container.styles));
1192 info.dropDown.element.css(animator.toCss(info.dropDown.styles));
1193
1194 $$rAF(function() {
1195 element.addClass('md-active');
1196 info.dropDown.element.css(animator.toCss({transform: ''}));
1197
1198 autoFocus(opts.focusedNode);
1199 resolve();
1200 });
1201
1202 });
1203 }
1204
1205 /**
1206 * Show modal backdrop element...
1207 */
1208 function showBackdrop(scope, element, options) {
1209
1210 // If we are not within a dialog...
1211 if (options.disableParentScroll && !$mdUtil.getClosest(options.target, 'MD-DIALOG')) {
1212 // !! DO this before creating the backdrop; since disableScrollAround()
1213 // configures the scroll offset; which is used by mdBackDrop postLink()
1214 options.restoreScroll = $mdUtil.disableScrollAround(options.element, options.parent);
1215 } else {
1216 options.disableParentScroll = false;
1217 }
1218
1219 if (options.hasBackdrop) {
1220 // Override duration to immediately show invisible backdrop
1221 options.backdrop = $mdUtil.createBackdrop(scope, "md-select-backdrop md-click-catcher");
1222 $animate.enter(options.backdrop, $document[0].body, null, {duration: 0});
1223 }
1224
1225 /**
1226 * Hide modal backdrop element...
1227 */
1228 return function hideBackdrop() {
1229 if (options.backdrop) options.backdrop.remove();
1230 if (options.disableParentScroll) options.restoreScroll();
1231
1232 delete options.restoreScroll;
1233 };
1234 }
1235
1236 /**
1237 *
1238 */
1239 function autoFocus(focusedNode) {
1240 if (focusedNode && !focusedNode.hasAttribute('disabled')) {
1241 focusedNode.focus();
1242 }
1243 }
1244
1245 /**
1246 * Check for valid opts and set some sane defaults
1247 */
1248 function sanitizeAndConfigure(scope, options) {
1249 var selectEl = element.find('md-select-menu');
1250
1251 if (!options.target) {
1252 throw new Error($mdUtil.supplant(ERROR_TARGET_EXPECTED, [options.target]));
1253 }
1254
1255 angular.extend(options, {
1256 isRemoved: false,
1257 target: angular.element(options.target), //make sure it's not a naked dom node
1258 parent: angular.element(options.parent),
1259 selectEl: selectEl,
1260 contentEl: element.find('md-content'),
1261 optionNodes: selectEl[0].getElementsByTagName('md-option')
1262 });
1263 }
1264
1265 /**
1266 * Configure various resize listeners for screen changes
1267 */
1268 function activateResizing() {
1269 var debouncedOnResize = (function(scope, target, options) {
1270
1271 return function() {
1272 if (options.isRemoved) return;
1273
1274 var updates = calculateMenuPositions(scope, target, options);
1275 var container = updates.container;
1276 var dropDown = updates.dropDown;
1277
1278 container.element.css(animator.toCss(container.styles));
1279 dropDown.element.css(animator.toCss(dropDown.styles));
1280 };
1281
1282 })(scope, element, opts);
1283
1284 var window = angular.element($window);
1285 window.on('resize', debouncedOnResize);
1286 window.on('orientationchange', debouncedOnResize);
1287
1288 // Publish deactivation closure...
1289 return function deactivateResizing() {
1290
1291 // Disable resizing handlers
1292 window.off('resize', debouncedOnResize);
1293 window.off('orientationchange', debouncedOnResize);
1294 };
1295 }
1296
1297 /**
1298 * If asynchronously loading, watch and update internal
1299 * '$$loadingAsyncDone' flag
1300 */
1301 function watchAsyncLoad() {
1302 if (opts.loadingAsync && !opts.isRemoved) {
1303 scope.$$loadingAsyncDone = false;
1304
1305 $q.when(opts.loadingAsync)
1306 .then(function() {
1307 scope.$$loadingAsyncDone = true;
1308 delete opts.loadingAsync;
1309 }).then(function() {
1310 $$rAF(positionAndFocusMenu);
1311 });
1312 }
1313 }
1314
1315 /**
1316 *
1317 */
1318 function activateInteraction() {
1319 if (opts.isRemoved) return;
1320
1321 var dropDown = opts.selectEl;
1322 var selectCtrl = dropDown.controller('mdSelectMenu') || {};
1323
1324 element.addClass('md-clickable');
1325
1326 // Close on backdrop click
1327 opts.backdrop && opts.backdrop.on('click', onBackdropClick);
1328
1329 // Escape to close
1330 // Cycling of options, and closing on enter
1331 dropDown.on('keydown', onMenuKeyDown);
1332 dropDown.on('click', checkCloseMenu);
1333
1334 return function cleanupInteraction() {
1335 opts.backdrop && opts.backdrop.off('click', onBackdropClick);
1336 dropDown.off('keydown', onMenuKeyDown);
1337 dropDown.off('click', checkCloseMenu);
1338
1339 element.removeClass('md-clickable');
1340 opts.isRemoved = true;
1341 };
1342
1343 // ************************************
1344 // Closure Functions
1345 // ************************************
1346
1347 function onBackdropClick(e) {
1348 e.preventDefault();
1349 e.stopPropagation();
1350 opts.restoreFocus = false;
1351 $mdUtil.nextTick($mdSelect.hide, true);
1352 }
1353
1354 function onMenuKeyDown(ev) {
1355 ev.preventDefault();
1356 ev.stopPropagation();
1357
1358 switch (ev.keyCode) {
1359 case keyCodes.UP_ARROW:
1360 return focusPrevOption();
1361 case keyCodes.DOWN_ARROW:
1362 return focusNextOption();
1363 case keyCodes.SPACE:
1364 case keyCodes.ENTER:
1365 var option = $mdUtil.getClosest(ev.target, 'md-option');
1366 if (option) {
1367 dropDown.triggerHandler({
1368 type: 'click',
1369 target: option
1370 });
1371 ev.preventDefault();
1372 }
1373 checkCloseMenu(ev);
1374 break;
1375 case keyCodes.TAB:
1376 case keyCodes.ESCAPE:
1377 ev.stopPropagation();
1378 ev.preventDefault();
1379 opts.restoreFocus = true;
1380 $mdUtil.nextTick($mdSelect.hide, true);
1381 break;
1382 default:
1383 if ($mdConstant.isInputKey(ev) || $mdConstant.isNumPadKey(ev)) {
1384 var optNode = dropDown.controller('mdSelectMenu').optNodeForKeyboardSearch(ev);
1385 opts.focusedNode = optNode || opts.focusedNode;
1386 optNode && optNode.focus();
1387 }
1388 }
1389 }
1390
1391 function focusOption(direction) {
1392 var optionsArray = $mdUtil.nodesToArray(opts.optionNodes);
1393 var index = optionsArray.indexOf(opts.focusedNode);
1394
1395 var newOption;
1396
1397 do {
1398 if (index === -1) {
1399 // We lost the previously focused element, reset to first option
1400 index = 0;
1401 } else if (direction === 'next' && index < optionsArray.length - 1) {
1402 index++;
1403 } else if (direction === 'prev' && index > 0) {
1404 index--;
1405 }
1406 newOption = optionsArray[index];
1407 if (newOption.hasAttribute('disabled')) newOption = undefined;
1408 } while (!newOption && index < optionsArray.length - 1 && index > 0);
1409
1410 newOption && newOption.focus();
1411 opts.focusedNode = newOption;
1412 }
1413
1414 function focusNextOption() {
1415 focusOption('next');
1416 }
1417
1418 function focusPrevOption() {
1419 focusOption('prev');
1420 }
1421
1422 function checkCloseMenu(ev) {
1423 if (ev && ( ev.type == 'click') && (ev.currentTarget != dropDown[0])) return;
1424 if ( mouseOnScrollbar() ) return;
1425
1426 var option = $mdUtil.getClosest(ev.target, 'md-option');
1427 if (option && option.hasAttribute && !option.hasAttribute('disabled')) {
1428 ev.preventDefault();
1429 ev.stopPropagation();
1430 if (!selectCtrl.isMultiple) {
1431 opts.restoreFocus = true;
1432
1433 $mdUtil.nextTick(function () {
1434 $mdSelect.hide(selectCtrl.ngModel.$viewValue);
1435 }, true);
1436 }
1437 }
1438 /**
1439 * check if the mouseup event was on a scrollbar
1440 */
1441 function mouseOnScrollbar() {
1442 var clickOnScrollbar = false;
1443 if (ev && (ev.currentTarget.children.length > 0)) {
1444 var child = ev.currentTarget.children[0];
1445 var hasScrollbar = child.scrollHeight > child.clientHeight;
1446 if (hasScrollbar && child.children.length > 0) {
1447 var relPosX = ev.pageX - ev.currentTarget.getBoundingClientRect().left;
1448 if (relPosX > child.querySelector('md-option').offsetWidth)
1449 clickOnScrollbar = true;
1450 }
1451 }
1452 return clickOnScrollbar;
1453 }
1454 }
1455 }
1456
1457 }
1458
1459 /**
1460 * To notify listeners that the Select menu has closed,
1461 * trigger the [optional] user-defined expression
1462 */
1463 function announceClosed(opts) {
1464 var mdSelect = opts.selectCtrl;
1465 if (mdSelect) {
1466 var menuController = opts.selectEl.controller('mdSelectMenu');
1467 mdSelect.setLabelText(menuController ? menuController.selectedLabels() : '');
1468 mdSelect.triggerClose();
1469 }
1470 }
1471
1472
1473 /**
1474 * Calculate the
1475 */
1476 function calculateMenuPositions(scope, element, opts) {
1477 var
1478 containerNode = element[0],
1479 targetNode = opts.target[0].children[0], // target the label
1480 parentNode = $document[0].body,
1481 selectNode = opts.selectEl[0],
1482 contentNode = opts.contentEl[0],
1483 parentRect = parentNode.getBoundingClientRect(),
1484 targetRect = targetNode.getBoundingClientRect(),
1485 shouldOpenAroundTarget = false,
1486 bounds = {
1487 left: parentRect.left + SELECT_EDGE_MARGIN,
1488 top: SELECT_EDGE_MARGIN,
1489 bottom: parentRect.height - SELECT_EDGE_MARGIN,
1490 right: parentRect.width - SELECT_EDGE_MARGIN - ($mdUtil.floatingScrollbars() ? 16 : 0)
1491 },
1492 spaceAvailable = {
1493 top: targetRect.top - bounds.top,
1494 left: targetRect.left - bounds.left,
1495 right: bounds.right - (targetRect.left + targetRect.width),
1496 bottom: bounds.bottom - (targetRect.top + targetRect.height)
1497 },
1498 maxWidth = parentRect.width - SELECT_EDGE_MARGIN * 2,
1499 selectedNode = selectNode.querySelector('md-option[selected]'),
1500 optionNodes = selectNode.getElementsByTagName('md-option'),
1501 optgroupNodes = selectNode.getElementsByTagName('md-optgroup'),
1502 isScrollable = calculateScrollable(element, contentNode),
1503 centeredNode;
1504
1505 var loading = isPromiseLike(opts.loadingAsync);
1506 if (!loading) {
1507 // If a selected node, center around that
1508 if (selectedNode) {
1509 centeredNode = selectedNode;
1510 // If there are option groups, center around the first option group
1511 } else if (optgroupNodes.length) {
1512 centeredNode = optgroupNodes[0];
1513 // Otherwise - if we are not loading async - center around the first optionNode
1514 } else if (optionNodes.length) {
1515 centeredNode = optionNodes[0];
1516 // In case there are no options, center on whatever's in there... (eg progress indicator)
1517 } else {
1518 centeredNode = contentNode.firstElementChild || contentNode;
1519 }
1520 } else {
1521 // If loading, center on progress indicator
1522 centeredNode = contentNode.firstElementChild || contentNode;
1523 }
1524
1525 if (contentNode.offsetWidth > maxWidth) {
1526 contentNode.style['max-width'] = maxWidth + 'px';
1527 } else {
1528 contentNode.style.maxWidth = null;
1529 }
1530 if (shouldOpenAroundTarget) {
1531 contentNode.style['min-width'] = targetRect.width + 'px';
1532 }
1533
1534 // Remove padding before we compute the position of the menu
1535 if (isScrollable) {
1536 selectNode.classList.add('md-overflow');
1537 }
1538
1539 var focusedNode = centeredNode;
1540 if ((focusedNode.tagName || '').toUpperCase() === 'MD-OPTGROUP') {
1541 focusedNode = optionNodes[0] || contentNode.firstElementChild || contentNode;
1542 centeredNode = focusedNode;
1543 }
1544 // Cache for autoFocus()
1545 opts.focusedNode = focusedNode;
1546
1547 // Get the selectMenuRect *after* max-width is possibly set above
1548 containerNode.style.display = 'block';
1549 var selectMenuRect = selectNode.getBoundingClientRect();
1550 var centeredRect = getOffsetRect(centeredNode);
1551
1552 if (centeredNode) {
1553 var centeredStyle = $window.getComputedStyle(centeredNode);
1554 centeredRect.paddingLeft = parseInt(centeredStyle.paddingLeft, 10) || 0;
1555 centeredRect.paddingRight = parseInt(centeredStyle.paddingRight, 10) || 0;
1556 }
1557
1558 if (isScrollable) {
1559 var scrollBuffer = contentNode.offsetHeight / 2;
1560 contentNode.scrollTop = centeredRect.top + centeredRect.height / 2 - scrollBuffer;
1561
1562 if (spaceAvailable.top < scrollBuffer) {
1563 contentNode.scrollTop = Math.min(
1564 centeredRect.top,
1565 contentNode.scrollTop + scrollBuffer - spaceAvailable.top
1566 );
1567 } else if (spaceAvailable.bottom < scrollBuffer) {
1568 contentNode.scrollTop = Math.max(
1569 centeredRect.top + centeredRect.height - selectMenuRect.height,
1570 contentNode.scrollTop - scrollBuffer + spaceAvailable.bottom
1571 );
1572 }
1573 }
1574
1575 var left, top, transformOrigin, minWidth, fontSize;
1576 if (shouldOpenAroundTarget) {
1577 left = targetRect.left;
1578 top = targetRect.top + targetRect.height;
1579 transformOrigin = '50% 0';
1580 if (top + selectMenuRect.height > bounds.bottom) {
1581 top = targetRect.top - selectMenuRect.height;
1582 transformOrigin = '50% 100%';
1583 }
1584 } else {
1585 left = (targetRect.left + centeredRect.left - centeredRect.paddingLeft) + 2;
1586 top = Math.floor(targetRect.top + targetRect.height / 2 - centeredRect.height / 2 -
1587 centeredRect.top + contentNode.scrollTop) + 2;
1588
1589 transformOrigin = (centeredRect.left + targetRect.width / 2) + 'px ' +
1590 (centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop) + 'px 0px';
1591
1592 minWidth = Math.min(targetRect.width + centeredRect.paddingLeft + centeredRect.paddingRight, maxWidth);
1593
1594 fontSize = window.getComputedStyle(targetNode)['font-size'];
1595 }
1596
1597 // Keep left and top within the window
1598 var containerRect = containerNode.getBoundingClientRect();
1599 var scaleX = Math.round(100 * Math.min(targetRect.width / selectMenuRect.width, 1.0)) / 100;
1600 var scaleY = Math.round(100 * Math.min(targetRect.height / selectMenuRect.height, 1.0)) / 100;
1601
1602 return {
1603 container: {
1604 element: angular.element(containerNode),
1605 styles: {
1606 left: Math.floor(clamp(bounds.left, left, bounds.right - containerRect.width)),
1607 top: Math.floor(clamp(bounds.top, top, bounds.bottom - containerRect.height)),
1608 'min-width': minWidth,
1609 'font-size': fontSize
1610 }
1611 },
1612 dropDown: {
1613 element: angular.element(selectNode),
1614 styles: {
1615 transformOrigin: transformOrigin,
1616 transform: !opts.alreadyOpen ? $mdUtil.supplant('scale({0},{1})', [scaleX, scaleY]) : ""
1617 }
1618 }
1619 };
1620
1621 }
1622
1623 }
1624
1625 function isPromiseLike(obj) {
1626 return obj && angular.isFunction(obj.then);
1627 }
1628
1629 function clamp(min, n, max) {
1630 return Math.max(min, Math.min(n, max));
1631 }
1632
1633 function getOffsetRect(node) {
1634 return node ? {
1635 left: node.offsetLeft,
1636 top: node.offsetTop,
1637 width: node.offsetWidth,
1638 height: node.offsetHeight
1639 } : {left: 0, top: 0, width: 0, height: 0};
1640 }
1641
1642 function calculateScrollable(element, contentNode) {
1643 var isScrollable = false;
1644
1645 try {
1646 var oldDisplay = element[0].style.display;
1647
1648 // Set the element's display to block so that this calculation is correct
1649 element[0].style.display = 'block';
1650
1651 isScrollable = contentNode.scrollHeight > contentNode.offsetHeight;
1652
1653 // Reset it back afterwards
1654 element[0].style.display = oldDisplay;
1655 } finally {
1656 // Nothing to do
1657 }
1658 return isScrollable;
1659 }
1660}
1661
1662ngmaterial.components.select = angular.module("material.components.select");