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