source: trip-planner-front/node_modules/angular-material/modules/closure/select/select.js@ 6a3a178

Last change on this file since 6a3a178 was 6a3a178, checked in by Ema <ema_spirova@…>, 3 years ago

initial commit

  • Property mode set to 100644
File size: 77.8 KB
Line 
1/*!
2 * AngularJS Material Design
3 * https://github.com/angular/material
4 * @license MIT
5 * v1.2.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 ###
18 - [ ] Abstract placement logic in $mdSelect service to $mdMenu service
19
20 ***************************************************/
21
22SelectDirective['$inject'] = ["$mdSelect", "$mdUtil", "$mdConstant", "$mdTheming", "$mdAria", "$parse", "$sce"];
23SelectMenuDirective['$inject'] = ["$parse", "$mdUtil", "$mdConstant", "$mdTheming"];
24OptionDirective['$inject'] = ["$mdButtonInkRipple", "$mdUtil", "$mdTheming"];
25SelectProvider['$inject'] = ["$$interimElementProvider"];
26OptionController['$inject'] = ["$element"];
27var SELECT_EDGE_MARGIN = 8;
28var selectNextId = 0;
29var CHECKBOX_SELECTION_INDICATOR =
30 angular.element('<div class="md-container"><div class="md-icon"></div></div>');
31
32angular.module('material.components.select', [
33 'material.core',
34 'material.components.backdrop'
35 ])
36 .directive('mdSelect', SelectDirective)
37 .directive('mdSelectMenu', SelectMenuDirective)
38 .directive('mdOption', OptionDirective)
39 .directive('mdOptgroup', OptgroupDirective)
40 .directive('mdSelectHeader', SelectHeaderDirective)
41 .provider('$mdSelect', SelectProvider);
42
43/**
44 * @ngdoc directive
45 * @name mdSelect
46 * @restrict E
47 * @module material.components.select
48 *
49 * @description Displays a select box, bound to an `ng-model`. Selectable options are defined using
50 * the <a ng-href="api/directive/mdOption">md-option</a> element directive. Options can be grouped
51 * using the <a ng-href="api/directive/mdOptgroup">md-optgroup</a> element directive.
52 *
53 * When the select is required and uses a floating label, then the label will automatically contain
54 * an asterisk (`*`). This behavior can be disabled by using the `md-no-asterisk` attribute.
55 *
56 * By default, the select will display with an underline to match other form elements. This can be
57 * disabled by applying the `md-no-underline` CSS class.
58 *
59 * @param {expression} ng-model Assignable angular expression to data-bind to.
60 * @param {expression=} ng-change Expression to be executed when the model value changes.
61 * @param {boolean=} multiple When present, allows for more than one option to be selected.
62 * The model is an array with the selected choices. **Note:** This attribute is only evaluated
63 * once; it is not watched.
64 * @param {expression=} md-on-close Expression to be evaluated when the select is closed.
65 * @param {expression=} md-on-open Expression to be evaluated when opening the select.
66 * Will hide the select options and show a spinner until the evaluated promise resolves.
67 * @param {expression=} md-selected-text Expression to be evaluated that will return a string
68 * to be displayed as a placeholder in the select input box when it is closed. The value
69 * will be treated as *text* (not html).
70 * @param {expression=} md-selected-html Expression to be evaluated that will return a string
71 * to be displayed as a placeholder in the select input box when it is closed. The value
72 * will be treated as *html*. The value must either be explicitly marked as trustedHtml or
73 * the ngSanitize module must be loaded.
74 * @param {string=} placeholder Placeholder hint text.
75 * @param {boolean=} md-no-asterisk When set to true, an asterisk will not be appended to the
76 * floating label. **Note:** This attribute is only evaluated once; it is not watched.
77 * @param {string=} aria-label Optional label for accessibility. Only necessary if no explicit label
78 * is present.
79 * @param {string=} md-container-class Class list to get applied to the `.md-select-menu-container`
80 * element (for custom styling).
81 * @param {string=} md-select-only-option If specified, a `<md-select>` will automatically select
82 * it's first option, if it only has one.
83 *
84 * @usage
85 * With a placeholder (label and aria-label are added dynamically)
86 * <hljs lang="html">
87 * <md-input-container>
88 * <md-select
89 * ng-model="someModel"
90 * placeholder="Select a state">
91 * <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
92 * </md-select>
93 * </md-input-container>
94 * </hljs>
95 *
96 * With an explicit label
97 * <hljs lang="html">
98 * <md-input-container>
99 * <label>State</label>
100 * <md-select
101 * ng-model="someModel">
102 * <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
103 * </md-select>
104 * </md-input-container>
105 * </hljs>
106 *
107 * Using the `md-select-header` element directive
108 *
109 * When a developer needs to put more than just a text label in the `md-select-menu`, they should
110 * use one or more `md-select-header`s. These elements can contain custom HTML which can be styled
111 * as desired. Use cases for this element include a sticky search bar and custom option group
112 * labels.
113 *
114 * <hljs lang="html">
115 * <md-input-container>
116 * <md-select ng-model="someModel">
117 * <md-select-header>
118 * <span> Neighborhoods - </span>
119 * </md-select-header>
120 * <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
121 * </md-select>
122 * </md-input-container>
123 * </hljs>
124 *
125 * ## Selects and object equality
126 * When using a `md-select` to pick from a list of objects, it is important to realize how javascript handles
127 * equality. Consider the following example:
128 * <hljs lang="js">
129 * angular.controller('MyCtrl', function($scope) {
130 * $scope.users = [
131 * { id: 1, name: 'Bob' },
132 * { id: 2, name: 'Alice' },
133 * { id: 3, name: 'Steve' }
134 * ];
135 * $scope.selectedUser = { id: 1, name: 'Bob' };
136 * });
137 * </hljs>
138 * <hljs lang="html">
139 * <div ng-controller="MyCtrl">
140 * <md-select ng-model="selectedUser">
141 * <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
142 * </md-select>
143 * </div>
144 * </hljs>
145 *
146 * At first one might expect that the select should be populated with "Bob" as the selected user.
147 * However, this is not true. To determine whether something is selected,
148 * `ngModelController` is looking at whether `$scope.selectedUser == (any user in $scope.users);`;
149 *
150 * Javascript's `==` operator does not check for deep equality (ie. that all properties
151 * on the object are the same), but instead whether the objects are *the same object in memory*.
152 * In this case, we have two instances of identical objects, but they exist in memory as unique
153 * entities. Because of this, the select will have no value populated for a selected user.
154 *
155 * To get around this, `ngModelController` provides a `track by` option that allows us to specify a
156 * different expression which will be used for the equality operator. As such, we can update our
157 * `html` to make use of this by specifying the `ng-model-options="{trackBy: '$value.id'}"` on the
158 * `md-select` element. This converts our equality expression to be
159 * `$scope.selectedUser.id == (any id in $scope.users.map(function(u) { return u.id; }));`
160 * which results in Bob being selected as desired.
161 *
162 * **Note:** We do not support AngularJS's `track by` syntax. For instance
163 * `ng-options="user in users track by user.id"` will not work with `md-select`.
164 *
165 * Working HTML:
166 * <hljs lang="html">
167 * <div ng-controller="MyCtrl">
168 * <md-select ng-model="selectedUser" ng-model-options="{trackBy: '$value.id'}">
169 * <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
170 * </md-select>
171 * </div>
172 * </hljs>
173 */
174function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $parse, $sce) {
175 return {
176 restrict: 'E',
177 require: ['^?mdInputContainer', 'mdSelect', 'ngModel', '?^form'],
178 compile: compile,
179 controller: function() {
180 } // empty placeholder controller to be initialized in link
181 };
182
183 /**
184 * @param {JQLite} tElement
185 * @param {IAttributes} tAttrs
186 * @return {postLink}
187 */
188 function compile(tElement, tAttrs) {
189 var isMultiple = $mdUtil.parseAttributeBoolean(tAttrs.multiple);
190 tElement.addClass('md-auto-horizontal-margin');
191
192 // add the select value that will hold our placeholder or selected option value
193 var valueEl = angular.element('<md-select-value><span></span></md-select-value>');
194 valueEl.append('<span class="md-select-icon" aria-hidden="true"></span>');
195 valueEl.addClass('md-select-value');
196 if (!valueEl[0].hasAttribute('id')) {
197 valueEl.attr('id', 'select_value_label_' + $mdUtil.nextUid());
198 }
199
200 // There's got to be an md-content inside. If there's not one, let's add it.
201 var mdContentEl = tElement.find('md-content');
202 if (!mdContentEl.length) {
203 tElement.append(angular.element('<md-content>').append(tElement.contents()));
204 mdContentEl = tElement.find('md-content');
205 }
206 mdContentEl.attr('role', 'listbox');
207 mdContentEl.attr('tabindex', '-1');
208
209 if (isMultiple) {
210 mdContentEl.attr('aria-multiselectable', 'true');
211 } else {
212 mdContentEl.attr('aria-multiselectable', 'false');
213 }
214
215 // Add progress spinner for md-options-loading
216 if (tAttrs.mdOnOpen) {
217
218 // Show progress indicator while loading async
219 // Use ng-hide for `display:none` so the indicator does not interfere with the options list
220 tElement
221 .find('md-content')
222 .prepend(angular.element(
223 '<div>' +
224 ' <md-progress-circular md-mode="indeterminate" ng-if="$$loadingAsyncDone === false"' +
225 ' md-diameter="25px"></md-progress-circular>' +
226 '</div>'
227 ));
228
229 // Hide list [of item options] while loading async
230 tElement
231 .find('md-option')
232 .attr('ng-show', '$$loadingAsyncDone');
233 }
234
235 if (tAttrs.name) {
236 var autofillClone = angular.element('<select class="md-visually-hidden"></select>');
237 autofillClone.attr({
238 'name': tAttrs.name,
239 'aria-hidden': 'true',
240 'tabindex': '-1'
241 });
242 var opts = tElement.find('md-option');
243 angular.forEach(opts, function(el) {
244 var newEl = angular.element('<option>' + el.innerHTML + '</option>');
245 if (el.hasAttribute('ng-value')) {
246 newEl.attr('ng-value', el.getAttribute('ng-value'));
247 }
248 else if (el.hasAttribute('value')) {
249 newEl.attr('value', el.getAttribute('value'));
250 }
251 autofillClone.append(newEl);
252 });
253
254 // Adds an extra option that will hold the selected value for the
255 // cases where the select is a part of a non-AngularJS form. This can be done with a ng-model,
256 // however if the `md-option` is being `ng-repeat`-ed, AngularJS seems to insert a similar
257 // `option` node, but with a value of `? string: <value> ?` which would then get submitted.
258 // This also goes around having to prepend a dot to the name attribute.
259 autofillClone.append(
260 '<option ng-value="' + tAttrs.ngModel + '" selected></option>'
261 );
262
263 tElement.parent().append(autofillClone);
264 }
265
266 // Use everything that's left inside element.contents() as the contents of the menu
267 var multipleContent = isMultiple ? 'multiple' : '';
268 var ngModelOptions = tAttrs.ngModelOptions ? $mdUtil.supplant('ng-model-options="{0}"', [tAttrs.ngModelOptions]) : '';
269 var selectTemplate = '' +
270 '<div class="md-select-menu-container" aria-hidden="true" role="presentation">' +
271 ' <md-select-menu role="presentation" {0} {1}>{2}</md-select-menu>' +
272 '</div>';
273
274 selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, ngModelOptions, tElement.html()]);
275 tElement.empty().append(valueEl);
276 tElement.append(selectTemplate);
277
278 if (!tAttrs.tabindex) {
279 tAttrs.$set('tabindex', 0);
280 }
281
282 return function postLink(scope, element, attrs, ctrls) {
283 var untouched = true;
284 var isDisabled;
285
286 var containerCtrl = ctrls[0];
287 var mdSelectCtrl = ctrls[1];
288 var ngModelCtrl = ctrls[2];
289 var formCtrl = ctrls[3];
290 // grab a reference to the select menu value label
291 var selectValueElement = element.find('md-select-value');
292 var isReadonly = angular.isDefined(attrs.readonly);
293 var disableAsterisk = $mdUtil.parseAttributeBoolean(attrs.mdNoAsterisk);
294 var stopMdMultipleWatch;
295 var userDefinedLabelledby = angular.isDefined(attrs.ariaLabelledby);
296 var listboxContentElement = element.find('md-content');
297 var initialPlaceholder = element.attr('placeholder');
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.
310 // One 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', initialPlaceholder);
319 var selectLabel = element.attr('aria-label');
320 if (!selectLabel) {
321 selectLabel = initialPlaceholder;
322 }
323 listboxContentElement.attr('aria-label', selectLabel);
324 } else {
325 containerCtrl.label.attr('aria-hidden', 'true');
326 listboxContentElement.attr('aria-label', containerCtrl.label.text());
327 containerCtrl.setHasPlaceholder(!!initialPlaceholder);
328 }
329
330 var stopInvalidWatch = scope.$watch(isErrorGetter, containerCtrl.setInvalid);
331 }
332
333 var selectContainer, selectScope, selectMenuCtrl;
334
335 selectContainer = findSelectContainer();
336 $mdTheming(element);
337
338 var originalRender = ngModelCtrl.$render;
339 ngModelCtrl.$render = function() {
340 originalRender();
341 syncSelectValueText();
342 inputCheckValue();
343 };
344
345 var stopPlaceholderObserver = attrs.$observe('placeholder', ngModelCtrl.$render);
346
347 var stopRequiredObserver = attrs.$observe('required', function (value) {
348 if (containerCtrl && containerCtrl.label) {
349 // Toggle the md-required class on the input containers label, because the input container
350 // is automatically applying the asterisk indicator on the label.
351 containerCtrl.label.toggleClass('md-required', value && !disableAsterisk);
352 }
353 element.removeAttr('aria-required');
354 if (value) {
355 listboxContentElement.attr('aria-required', 'true');
356 } else {
357 listboxContentElement.removeAttr('aria-required');
358 }
359 });
360
361 /**
362 * Set the contents of the md-select-value element. This element's contents are announced by
363 * screen readers and used for displaying the value of the select in both single and multiple
364 * selection modes.
365 * @param {string=} text A sanitized and trusted HTML string or a pure text string from user
366 * input.
367 */
368 mdSelectCtrl.setSelectValueText = function(text) {
369 var useDefaultText = text === undefined || text === '';
370 // Whether the select label has been given via user content rather than the internal
371 // template of <md-option>
372 var isSelectLabelFromUser = false;
373
374 mdSelectCtrl.setIsPlaceholder(!text);
375
376 if (attrs.mdSelectedText && attrs.mdSelectedHtml) {
377 throw Error('md-select cannot have both `md-selected-text` and `md-selected-html`');
378 }
379
380 if (attrs.mdSelectedText || attrs.mdSelectedHtml) {
381 text = $parse(attrs.mdSelectedText || attrs.mdSelectedHtml)(scope);
382 isSelectLabelFromUser = true;
383 } else if (useDefaultText) {
384 // Use placeholder attribute, otherwise fallback to the md-input-container label
385 var tmpPlaceholder = attrs.placeholder ||
386 (containerCtrl && containerCtrl.label ? containerCtrl.label.text() : '');
387
388 text = tmpPlaceholder || '';
389 isSelectLabelFromUser = true;
390 }
391
392 var target = selectValueElement.children().eq(0);
393
394 if (attrs.mdSelectedHtml) {
395 // Using getTrustedHtml will run the content through $sanitize if it is not already
396 // explicitly trusted. If the ngSanitize module is not loaded, this will
397 // *correctly* throw an sce error.
398 target.html($sce.getTrustedHtml(text));
399 } else if (isSelectLabelFromUser) {
400 target.text(text);
401 } else {
402 // If we've reached this point, the text is not user-provided.
403 target.html(text);
404 }
405
406 if (useDefaultText) {
407 // Avoid screen readers double announcing the label name when no value has been selected
408 selectValueElement.attr('aria-hidden', 'true');
409 if (!userDefinedLabelledby) {
410 element.removeAttr('aria-labelledby');
411 }
412 } else {
413 selectValueElement.removeAttr('aria-hidden');
414 if (!userDefinedLabelledby) {
415 element.attr('aria-labelledby', element[0].id + ' ' + selectValueElement[0].id);
416 }
417 }
418 };
419
420 /**
421 * @param {boolean} isPlaceholder true to mark the md-select-value element and
422 * input container, if one exists, with classes for styling when a placeholder is present.
423 * false to remove those classes.
424 */
425 mdSelectCtrl.setIsPlaceholder = function(isPlaceholder) {
426 if (isPlaceholder) {
427 selectValueElement.addClass('md-select-placeholder');
428 // Don't hide the floating label if the md-select has a placeholder.
429 if (containerCtrl && containerCtrl.label && !element.attr('placeholder')) {
430 containerCtrl.label.addClass('md-placeholder');
431 }
432 } else {
433 selectValueElement.removeClass('md-select-placeholder');
434 if (containerCtrl && containerCtrl.label && !element.attr('placeholder')) {
435 containerCtrl.label.removeClass('md-placeholder');
436 }
437 }
438 };
439
440 if (!isReadonly) {
441 var handleBlur = function(event) {
442 // Attach before ngModel's blur listener to stop propagation of blur event
443 // and prevent setting $touched.
444 if (untouched) {
445 untouched = false;
446 if (selectScope._mdSelectIsOpen) {
447 event.stopImmediatePropagation();
448 }
449 }
450
451 containerCtrl && containerCtrl.setFocused(false);
452 inputCheckValue();
453 };
454 var handleFocus = function() {
455 // Always focus the container (if we have one) so floating labels and other styles are
456 // applied properly
457 containerCtrl && containerCtrl.setFocused(true);
458 };
459
460 element.on('focus', handleFocus);
461 element.on('blur', handleBlur);
462 }
463
464 mdSelectCtrl.triggerClose = function() {
465 $parse(attrs.mdOnClose)(scope);
466 };
467
468 scope.$$postDigest(function() {
469 initAriaLabel();
470 syncSelectValueText();
471 });
472
473 function initAriaLabel() {
474 var labelText = element.attr('aria-label') || element.attr('placeholder');
475 if (!labelText && containerCtrl && containerCtrl.label) {
476 labelText = containerCtrl.label.text();
477 }
478 $mdAria.expect(element, 'aria-label', labelText);
479 }
480
481 var stopSelectedLabelsWatcher = scope.$watch(function() {
482 return selectMenuCtrl.getSelectedLabels();
483 }, syncSelectValueText);
484
485 function syncSelectValueText() {
486 selectMenuCtrl = selectMenuCtrl ||
487 selectContainer.find('md-select-menu').controller('mdSelectMenu');
488 mdSelectCtrl.setSelectValueText(selectMenuCtrl.getSelectedLabels());
489 }
490
491 // TODO add tests for mdMultiple
492 // TODO add docs for mdMultiple
493 var stopMdMultipleObserver = attrs.$observe('mdMultiple', function(val) {
494 if (stopMdMultipleWatch) {
495 stopMdMultipleWatch();
496 }
497 var parser = $parse(val);
498 stopMdMultipleWatch = scope.$watch(function() {
499 return parser(scope);
500 }, function(multiple, prevVal) {
501 var selectMenu = selectContainer.find('md-select-menu');
502 // assume compiler did a good job
503 if (multiple === undefined && prevVal === undefined) {
504 return;
505 }
506 if (multiple) {
507 var setMultipleAttrs = {'multiple': 'multiple'};
508 element.attr(setMultipleAttrs);
509 selectMenu.attr(setMultipleAttrs);
510 } else {
511 element.removeAttr('multiple');
512 selectMenu.removeAttr('multiple');
513 }
514 element.find('md-content').attr('aria-multiselectable', multiple ? 'true' : 'false');
515
516 if (selectContainer) {
517 selectMenuCtrl.setMultiple(Boolean(multiple));
518 originalRender = ngModelCtrl.$render;
519 ngModelCtrl.$render = function() {
520 originalRender();
521 syncSelectValueText();
522 inputCheckValue();
523 };
524 ngModelCtrl.$render();
525 }
526 });
527 });
528
529 var stopDisabledObserver = attrs.$observe('disabled', function(disabled) {
530 if (angular.isString(disabled)) {
531 disabled = true;
532 }
533 // Prevent click event being registered twice
534 if (isDisabled !== undefined && isDisabled === disabled) {
535 return;
536 }
537 isDisabled = disabled;
538 if (disabled) {
539 element
540 .attr({'aria-disabled': 'true'})
541 .removeAttr('tabindex')
542 .removeAttr('aria-expanded')
543 .removeAttr('aria-haspopup')
544 .off('click', openSelect)
545 .off('keydown', handleKeypress);
546 } else {
547 element
548 .attr({
549 'tabindex': attrs.tabindex,
550 'aria-haspopup': 'listbox'
551 })
552 .removeAttr('aria-disabled')
553 .on('click', openSelect)
554 .on('keydown', handleKeypress);
555 }
556 });
557
558 if (!attrs.hasOwnProperty('disabled') && !attrs.hasOwnProperty('ngDisabled')) {
559 element.attr({'aria-disabled': 'false'});
560 element.on('click', openSelect);
561 element.on('keydown', handleKeypress);
562 }
563
564 var ariaAttrs = {
565 role: 'button',
566 'aria-haspopup': 'listbox'
567 };
568
569 if (!element[0].hasAttribute('id')) {
570 ariaAttrs.id = 'select_' + $mdUtil.nextUid();
571 }
572
573 var containerId = 'select_container_' + $mdUtil.nextUid();
574 selectContainer.attr('id', containerId);
575 var listboxContentId = 'select_listbox_' + $mdUtil.nextUid();
576 selectContainer.find('md-content').attr('id', listboxContentId);
577 // Only add aria-owns if element ownership is NOT represented in the DOM.
578 if (!element.find('md-select-menu').length) {
579 ariaAttrs['aria-owns'] = listboxContentId;
580 }
581 element.attr(ariaAttrs);
582
583 scope.$on('$destroy', function() {
584 stopRequiredObserver && stopRequiredObserver();
585 stopDisabledObserver && stopDisabledObserver();
586 stopMdMultipleWatch && stopMdMultipleWatch();
587 stopMdMultipleObserver && stopMdMultipleObserver();
588 stopSelectedLabelsWatcher && stopSelectedLabelsWatcher();
589 stopPlaceholderObserver && stopPlaceholderObserver();
590 stopInvalidWatch && stopInvalidWatch();
591
592 element.off('focus');
593 element.off('blur');
594
595 $mdSelect
596 .destroy()
597 .finally(function() {
598 if (containerCtrl) {
599 containerCtrl.setFocused(false);
600 containerCtrl.setHasValue(false);
601 containerCtrl.input = null;
602 }
603 ngModelCtrl.$setTouched();
604 });
605 });
606
607 function inputCheckValue() {
608 // The select counts as having a value if one or more options are selected,
609 // or if the input's validity state says it has bad input (eg: string in a number input).
610 // We must do this on nextTick as the $render is sometimes invoked on nextTick.
611 $mdUtil.nextTick(function () {
612 containerCtrl && containerCtrl.setHasValue(
613 selectMenuCtrl.getSelectedLabels().length > 0 || (element[0].validity || {}).badInput);
614 });
615 }
616
617 function findSelectContainer() {
618 var selectContainer = angular.element(
619 element[0].querySelector('.md-select-menu-container')
620 );
621 selectScope = scope;
622 attrs.mdContainerClass && selectContainer.addClass(attrs.mdContainerClass);
623 selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
624 selectMenuCtrl.init(ngModelCtrl, attrs);
625 element.on('$destroy', function() {
626 selectContainer.remove();
627 });
628 return selectContainer;
629 }
630
631 /**
632 * Determine if the select menu should be opened or an option in the select menu should be
633 * selected.
634 * @param {KeyboardEvent} e keyboard event to handle
635 */
636 function handleKeypress(e) {
637 if ($mdConstant.isNavigationKey(e)) {
638 // prevent page scrolling on interaction
639 e.preventDefault();
640 openSelect(e);
641 } else {
642 if (shouldHandleKey(e, $mdConstant)) {
643 e.preventDefault();
644
645 var node = selectMenuCtrl.optNodeForKeyboardSearch(e);
646 if (!node || node.hasAttribute('disabled')) {
647 return;
648 }
649 var optionCtrl = angular.element(node).controller('mdOption');
650 if (!selectMenuCtrl.isMultiple) {
651 angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) {
652 selectMenuCtrl.deselect(key);
653 });
654 }
655 selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value);
656 selectMenuCtrl.refreshViewValue();
657 }
658 }
659 }
660
661 function openSelect() {
662 selectScope._mdSelectIsOpen = true;
663 element.attr('aria-expanded', 'true');
664
665 $mdSelect.show({
666 scope: selectScope,
667 preserveScope: true,
668 skipCompile: true,
669 element: selectContainer,
670 target: element[0],
671 selectCtrl: mdSelectCtrl,
672 preserveElement: true,
673 hasBackdrop: true,
674 loadingAsync: attrs.mdOnOpen ? scope.$eval(attrs.mdOnOpen) || true : false
675 }).finally(function() {
676 selectScope._mdSelectIsOpen = false;
677 element.removeAttr('aria-expanded');
678 element.removeAttr('aria-activedescendant');
679 ngModelCtrl.$setTouched();
680 });
681 }
682
683 };
684 }
685}
686
687function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) {
688 // We want the scope to be set to 'false' so an isolated scope is not created
689 // which would interfere with the md-select-header's access to the
690 // parent scope.
691 SelectMenuController['$inject'] = ["$scope", "$attrs", "$element"];
692 return {
693 restrict: 'E',
694 require: ['mdSelectMenu'],
695 scope: false,
696 controller: SelectMenuController,
697 link: {pre: preLink}
698 };
699
700 // We use preLink instead of postLink to ensure that the select is initialized before
701 // its child options run postLink.
702 function preLink(scope, element, attrs, ctrls) {
703 var selectMenuCtrl = ctrls[0];
704
705 element.addClass('_md'); // private md component indicator for styling
706
707 $mdTheming(element);
708 element.on('click', clickListener);
709 element.on('keypress', keyListener);
710
711 /**
712 * @param {KeyboardEvent} keyboardEvent
713 */
714 function keyListener(keyboardEvent) {
715 if (keyboardEvent.keyCode === 13 || keyboardEvent.keyCode === 32) {
716 clickListener(keyboardEvent);
717 }
718 }
719
720 /**
721 * @param {Event} mouseEvent
722 * @return {void}
723 */
724 function clickListener(mouseEvent) {
725 var option = $mdUtil.getClosest(mouseEvent.target, 'md-option');
726 var optionCtrl = option && angular.element(option).data('$mdOptionController');
727
728 if (!option || !optionCtrl) {
729 // Avoid closing the menu when the select header's input is clicked
730 if (mouseEvent.target && mouseEvent.target.parentNode &&
731 mouseEvent.target.parentNode.tagName === 'MD-SELECT-HEADER') {
732 mouseEvent.stopImmediatePropagation();
733 }
734 return;
735 } else if (option.hasAttribute('disabled')) {
736 mouseEvent.stopImmediatePropagation();
737 return;
738 }
739
740 var optionHashKey = selectMenuCtrl.hashGetter(optionCtrl.value);
741 var isSelected = angular.isDefined(selectMenuCtrl.selected[optionHashKey]);
742
743 scope.$apply(function() {
744 if (selectMenuCtrl.isMultiple) {
745 if (isSelected) {
746 selectMenuCtrl.deselect(optionHashKey);
747 } else {
748 selectMenuCtrl.select(optionHashKey, optionCtrl.value);
749 }
750 } else {
751 if (!isSelected) {
752 angular.forEach(Object.keys(selectMenuCtrl.selected), function (key) {
753 selectMenuCtrl.deselect(key);
754 });
755 selectMenuCtrl.select(optionHashKey, optionCtrl.value);
756 }
757 }
758 selectMenuCtrl.refreshViewValue();
759 });
760 }
761 }
762
763 function SelectMenuController($scope, $attrs, $element) {
764 var self = this;
765 var defaultIsEmpty;
766 var searchStr = '';
767 var clearSearchTimeout, optNodes, optText;
768 var CLEAR_SEARCH_AFTER = 300;
769
770 self.isMultiple = angular.isDefined($attrs.multiple);
771 // selected is an object with keys matching all of the selected options' hashed values
772 self.selected = {};
773 // options is an object with keys matching every option's hash value,
774 // and values containing an instance of every option's controller.
775 self.options = {};
776
777 $scope.$watchCollection(function() {
778 return self.options;
779 }, function() {
780 self.ngModel.$render();
781 updateOptionSetSizeAndPosition();
782 });
783
784 /**
785 * @param {boolean} isMultiple
786 */
787 self.setMultiple = function(isMultiple) {
788 var ngModel = self.ngModel;
789 defaultIsEmpty = defaultIsEmpty || ngModel.$isEmpty;
790 self.isMultiple = isMultiple;
791
792 if (self.isMultiple) {
793 // We want to delay the render method so that the directive has a chance to load before
794 // rendering, this prevents the control being marked as dirty onload.
795 var loaded = false;
796 var delayedRender = function(val) {
797 if (!loaded) {
798 $mdUtil.nextTick(function () {
799 renderMultiple(val);
800 loaded = true;
801 });
802 } else {
803 renderMultiple(val);
804 }
805 };
806 ngModel.$validators['md-multiple'] = validateArray;
807 ngModel.$render = delayedRender;
808
809 // watchCollection on the model because by default ngModel only watches the model's
810 // reference. This allows the developer to also push and pop from their array.
811 $scope.$watchCollection(self.modelBinding, function(value) {
812 if (validateArray(value)) {
813 delayedRender(value);
814 }
815 });
816
817 ngModel.$isEmpty = function(value) {
818 return !value || value.length === 0;
819 };
820 } else {
821 delete ngModel.$validators['md-multiple'];
822 ngModel.$render = renderSingular;
823 }
824
825 function validateArray(modelValue, viewValue) {
826 // If a value is truthy but not an array, reject it.
827 // If value is undefined/falsy, accept that it's an empty array.
828 return angular.isArray(modelValue || viewValue || []);
829 }
830 };
831
832 /**
833 * @param {KeyboardEvent} keyboardEvent keyboard event to handle
834 * @return {Element|HTMLElement|undefined}
835 */
836 self.optNodeForKeyboardSearch = function(keyboardEvent) {
837 var search, i;
838 clearSearchTimeout && clearTimeout(clearSearchTimeout);
839 clearSearchTimeout = setTimeout(function() {
840 clearSearchTimeout = undefined;
841 searchStr = '';
842 optText = undefined;
843 optNodes = undefined;
844 }, CLEAR_SEARCH_AFTER);
845
846 searchStr += keyboardEvent.key;
847 search = new RegExp('^' + $mdUtil.sanitize(searchStr), 'i');
848 if (!optNodes) {
849 optNodes = $element.find('md-option');
850 optText = new Array(optNodes.length);
851 angular.forEach(optNodes, function(el, i) {
852 optText[i] = el.textContent.trim();
853 });
854 }
855
856 for (i = 0; i < optText.length; ++i) {
857 if (search.test(optText[i])) {
858 return optNodes[i];
859 }
860 }
861 };
862
863 self.init = function(ngModel, parentAttrs) {
864 self.ngModel = ngModel;
865 self.modelBinding = parentAttrs.ngModel;
866
867 // Setup a more robust version of isEmpty to ensure value is a valid option
868 self.ngModel.$isEmpty = function($viewValue) {
869 // We have to transform the viewValue into the hashKey, because otherwise the
870 // OptionCtrl may not exist. Developers may have specified a trackBy function.
871 var hashedValue = self.options[self.hashGetter($viewValue)] ? self.options[self.hashGetter($viewValue)].value : null;
872 // Base this check on the default AngularJS $isEmpty() function.
873 // eslint-disable-next-line no-self-compare
874 return !angular.isDefined(hashedValue) || hashedValue === null || hashedValue === '' || hashedValue !== hashedValue;
875 };
876
877 // Allow users to provide `ng-model="foo" ng-model-options="{trackBy: '$value.id'}"` so
878 // that we can properly compare objects set on the model to the available options
879 //
880 // If the user doesn't provide a trackBy, we automatically generate an id for every
881 // value passed in with the getId function
882 if ($attrs.ngModelOptions) {
883 self.hashGetter = function(value) {
884 var ngModelOptions = $parse($attrs.ngModelOptions)($scope);
885 var trackByOption = ngModelOptions && ngModelOptions.trackBy;
886
887 if (trackByOption) {
888 return $parse(trackByOption)($scope, { $value: value });
889 } else if (angular.isObject(value)) {
890 return getId(value);
891 }
892 return value;
893 };
894 } else {
895 self.hashGetter = getId;
896 }
897 self.setMultiple(self.isMultiple);
898
899 /**
900 * If the value is an object, get the unique, incremental id of the value.
901 * If it's not an object, the value will be converted to a string and then returned.
902 * @param value
903 * @returns {string}
904 */
905 function getId(value) {
906 if (angular.isObject(value) && !angular.isArray(value)) {
907 return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
908 }
909 return value + '';
910 }
911
912 if (parentAttrs.hasOwnProperty('mdSelectOnlyOption')) {
913 $mdUtil.nextTick(function() {
914 var optionKeys = Object.keys(self.options);
915
916 if (optionKeys.length === 1) {
917 var option = self.options[optionKeys[0]];
918
919 self.deselect(Object.keys(self.selected)[0]);
920 self.select(self.hashGetter(option.value), option.value);
921 self.refreshViewValue();
922 self.ngModel.$setPristine();
923 }
924 }, false);
925 }
926 };
927
928 /**
929 * @param {string=} id
930 */
931 self.setActiveDescendant = function(id) {
932 if (angular.isDefined(id)) {
933 $element.find('md-content').attr('aria-activedescendant', id);
934 } else {
935 $element.find('md-content').removeAttr('aria-activedescendant');
936 }
937 };
938
939 /**
940 * @param {{mode: string}=} opts options object to allow specifying html (default) or aria mode.
941 * @return {string} comma separated set of selected values
942 */
943 self.getSelectedLabels = function(opts) {
944 opts = opts || {};
945 var mode = opts.mode || 'html';
946 var selectedOptionEls =
947 $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]'));
948
949 if (selectedOptionEls.length) {
950 var mapFn;
951
952 if (mode === 'html') {
953 // Map the given element to its innerHTML string. If the element has a child ripple
954 // container remove it from the HTML string, before returning the string.
955 mapFn = function(el) {
956 // If we do not have a `value` or `ng-value`, assume it is an empty option which clears
957 // the select.
958 if (el.hasAttribute('md-option-empty')) {
959 return '';
960 }
961
962 var html = el.innerHTML;
963
964 // Remove the ripple container from the selected option, copying it would cause a CSP
965 // violation.
966 var rippleContainer = el.querySelector('.md-ripple-container');
967 if (rippleContainer) {
968 html = html.replace(rippleContainer.outerHTML, '');
969 }
970
971 // Remove the checkbox container, because it will cause the label to wrap inside of the
972 // placeholder. It should be not displayed inside of the label element.
973 var checkboxContainer = el.querySelector('.md-container');
974 if (checkboxContainer) {
975 html = html.replace(checkboxContainer.outerHTML, '');
976 }
977
978 return html;
979 };
980 } else if (mode === 'aria') {
981 mapFn = function(el) {
982 return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent;
983 };
984 }
985
986 // Ensure there are no duplicates; see https://github.com/angular/material/issues/9442
987 return $mdUtil.uniq(selectedOptionEls.map(mapFn)).join(', ');
988 } else {
989 return '';
990 }
991 };
992
993 /**
994 * Mark an option as selected
995 * @param {string} hashKey key within the SelectMenuController.options object, which is an
996 * instance of OptionController.
997 * @param {OptionController} hashedValue value to associate with the key
998 */
999 self.select = function(hashKey, hashedValue) {
1000 var option = self.options[hashKey];
1001 option && option.setSelected(true, self.isMultiple);
1002 self.selected[hashKey] = hashedValue;
1003 };
1004
1005 /**
1006 * Mark an option as not selected
1007 * @param {string} hashKey key within the SelectMenuController.options object, which is an
1008 * instance of OptionController.
1009 */
1010 self.deselect = function(hashKey) {
1011 var option = self.options[hashKey];
1012 option && option.setSelected(false, self.isMultiple);
1013 delete self.selected[hashKey];
1014 };
1015
1016 /**
1017 * Add an option to the select
1018 * @param {string} hashKey key within the SelectMenuController.options object, which is an
1019 * instance of OptionController.
1020 * @param {OptionController} optionCtrl instance to associate with the key
1021 */
1022 self.addOption = function(hashKey, optionCtrl) {
1023 if (angular.isDefined(self.options[hashKey])) {
1024 throw new Error('Duplicate md-option values are not allowed in a select. ' +
1025 'Duplicate value "' + optionCtrl.value + '" found.');
1026 }
1027
1028 self.options[hashKey] = optionCtrl;
1029
1030 // If this option's value was already in our ngModel, go ahead and select it.
1031 if (angular.isDefined(self.selected[hashKey])) {
1032 self.select(hashKey, optionCtrl.value);
1033
1034 // When the current $modelValue of the ngModel Controller is using the same hash as
1035 // the current option, which will be added, then we can be sure, that the validation
1036 // of the option has occurred before the option was added properly.
1037 // This means, that we have to manually trigger a new validation of the current option.
1038 if (angular.isDefined(self.ngModel.$$rawModelValue) &&
1039 self.hashGetter(self.ngModel.$$rawModelValue) === hashKey) {
1040 self.ngModel.$validate();
1041 }
1042
1043 self.refreshViewValue();
1044 }
1045 };
1046
1047 /**
1048 * Remove an option from the select
1049 * @param {string} hashKey key within the SelectMenuController.options object, which is an
1050 * instance of OptionController.
1051 */
1052 self.removeOption = function(hashKey) {
1053 delete self.options[hashKey];
1054 // Don't deselect an option when it's removed - the user's ngModel should be allowed
1055 // to have values that do not match a currently available option.
1056 };
1057
1058 self.refreshViewValue = function() {
1059 var values = [];
1060 var option;
1061 for (var hashKey in self.selected) {
1062 // If this hashKey has an associated option, push that option's value to the model.
1063 if ((option = self.options[hashKey])) {
1064 values.push(option.value);
1065 } else {
1066 // Otherwise, the given hashKey has no associated option, and we got it
1067 // from an ngModel value at an earlier time. Push the unhashed value of
1068 // this hashKey to the model.
1069 // This allows the developer to put a value in the model that doesn't yet have
1070 // an associated option.
1071 values.push(self.selected[hashKey]);
1072 }
1073 }
1074
1075 var newVal = self.isMultiple ? values : values[0];
1076 var prevVal = self.ngModel.$modelValue;
1077
1078 if (!equals(prevVal, newVal)) {
1079 self.ngModel.$setViewValue(newVal);
1080 self.ngModel.$render();
1081 }
1082
1083 function equals(prevVal, newVal) {
1084 if (self.isMultiple) {
1085 if (!angular.isArray(prevVal)) {
1086 // newVal is always an array when self.isMultiple is true
1087 // thus, if prevVal is not an array they are different
1088 return false;
1089 } else if (prevVal.length !== newVal.length) {
1090 // they are different if they have different length
1091 return false;
1092 } else {
1093 // if they have the same length, then they are different
1094 // if an item in the newVal array can't be found in the prevVal
1095 var prevValHashes = prevVal.map(function(prevValItem) {
1096 return self.hashGetter(prevValItem);
1097 });
1098 return newVal.every(function(newValItem) {
1099 var newValItemHash = self.hashGetter(newValItem);
1100 return prevValHashes.some(function(prevValHash) {
1101 return prevValHash === newValItemHash;
1102 });
1103 });
1104 }
1105 } else {
1106 return self.hashGetter(prevVal) === self.hashGetter(newVal);
1107 }
1108 }
1109 };
1110
1111 /**
1112 * If the options include md-optgroups, then we need to apply aria-setsize and aria-posinset
1113 * to help screen readers understand the indexes. When md-optgroups are not used, we save on
1114 * perf and extra attributes by not applying these attributes as they are not needed by screen
1115 * readers.
1116 */
1117 function updateOptionSetSizeAndPosition() {
1118 var i, options;
1119 var hasOptGroup = $element.find('md-optgroup');
1120 if (!hasOptGroup.length) {
1121 return;
1122 }
1123
1124 options = $element.find('md-option');
1125
1126 for (i = 0; i < options.length; i++) {
1127 options[i].setAttribute('aria-setsize', options.length);
1128 options[i].setAttribute('aria-posinset', i + 1);
1129 }
1130 }
1131
1132 function renderMultiple() {
1133 var newSelectedValues = self.ngModel.$modelValue || self.ngModel.$viewValue || [];
1134 if (!angular.isArray(newSelectedValues)) {
1135 return;
1136 }
1137
1138 var oldSelected = Object.keys(self.selected);
1139
1140 var newSelectedHashes = newSelectedValues.map(self.hashGetter);
1141 var deselected = oldSelected.filter(function(hash) {
1142 return newSelectedHashes.indexOf(hash) === -1;
1143 });
1144
1145 deselected.forEach(self.deselect);
1146 newSelectedHashes.forEach(function(hashKey, i) {
1147 self.select(hashKey, newSelectedValues[i]);
1148 });
1149 }
1150
1151 function renderSingular() {
1152 var value = self.ngModel.$viewValue || self.ngModel.$modelValue;
1153 Object.keys(self.selected).forEach(self.deselect);
1154 self.select(self.hashGetter(value), value);
1155 }
1156 }
1157}
1158
1159/**
1160 * @ngdoc directive
1161 * @name mdOption
1162 * @restrict E
1163 * @module material.components.select
1164 *
1165 * @description Displays an option in a <a ng-href="api/directive/mdSelect">md-select</a> box's
1166 * dropdown menu. Options can be grouped using
1167 * <a ng-href="api/directive/mdOptgroup">md-optgroup</a> element directives.
1168 *
1169 * ### Option Params
1170 *
1171 * When applied, `md-option-empty` will mark the option as "empty" allowing the option to clear the
1172 * select and put it back in it's default state. You may supply this attribute on any option you
1173 * wish, however, it is automatically applied to an option whose `value` or `ng-value` are not
1174 * defined.
1175 *
1176 * **Automatically Applied**
1177 *
1178 * - `<md-option>`
1179 * - `<md-option value>`
1180 * - `<md-option value="">`
1181 * - `<md-option ng-value>`
1182 * - `<md-option ng-value="">`
1183 *
1184 * **NOT Automatically Applied**
1185 *
1186 * - `<md-option ng-value="1">`
1187 * - `<md-option ng-value="''">`
1188 * - `<md-option ng-value="undefined">`
1189 * - `<md-option value="undefined">` (this evaluates to the string `"undefined"`)
1190 * - <code ng-non-bindable>&lt;md-option ng-value="{{someValueThatMightBeUndefined}}"&gt;</code>
1191 *
1192 * **Note:** A value of `undefined` ***is considered a valid value*** (and does not auto-apply this
1193 * attribute) since you may wish this to be your "Not Available" or "None" option.
1194 *
1195 * **Note:** Using the
1196 * <a ng-href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/option#Attributes">value</a>
1197 * attribute from the `<option>` element (as opposed to the `<md-option>` element's
1198 * <a ng-href="https://docs.angularjs.org/api/ng/directive/ngValue">ng-value</a>) always evaluates
1199 * to a `string`. This means that `value="null"` will cause a check against `myValue != "null"`
1200 * rather than `!myValue` or `myValue != null`.
1201 * Importantly, this also applies to `number` values. `value="1"` will not match up with an
1202 * `ng-model` like `$scope.selectedValue = 1`. Use `ng-value="1"` in this case and other cases where
1203 * you have values that are not strings.
1204 *
1205 * **Note:** Please see our <a ng-href="api/directive/mdSelect#selects-and-object-equality">docs on
1206 * using objects with `md-select`</a> for additional guidance on using the `trackBy` option with
1207 * `ng-model-options`.
1208 *
1209 * @param {expression=} ng-value Binds the given expression to the value of the option.
1210 * @param {string=} value Attribute to set the value of the option.
1211 * @param {expression=} ng-repeat <a ng-href="https://docs.angularjs.org/api/ng/directive/ngRepeat">
1212 * AngularJS directive</a> that instantiates a template once per item from a collection.
1213 * @param {expression=} ng-selected <a ng-href="https://docs.angularjs.org/api/ng/directive/ngSelected">
1214 * AngularJS directive</a> that adds the `selected` attribute to the option when the expression
1215 * evaluates as truthy.
1216 *
1217 * **Note:** Unlike native `option` elements used with AngularJS, `md-option` elements
1218 * watch their `selected` attributes for changes and trigger model value changes on `md-select`.
1219 * @param {boolean=} md-option-empty If the attribute exists, mark the option as "empty" allowing
1220 * the option to clear the select and put it back in it's default state. You may supply this
1221 * attribute on any option you wish, however, it is automatically applied to an option whose `value`
1222 * or `ng-value` are not defined.
1223 * @param {number=} tabindex The `tabindex` of the option. Defaults to `0`.
1224 *
1225 * @usage
1226 * <hljs lang="html">
1227 * <md-select ng-model="currentState" placeholder="Select a state">
1228 * <md-option ng-value="AL">Alabama</md-option>
1229 * <md-option ng-value="AK">Alaska</md-option>
1230 * <md-option ng-value="FL">Florida</md-option>
1231 * </md-select>
1232 * </hljs>
1233 *
1234 * With `ng-repeat`:
1235 * <hljs lang="html">
1236 * <md-select ng-model="currentState" placeholder="Select a state">
1237 * <md-option ng-value="state" ng-repeat="state in states">{{ state }}</md-option>
1238 * </md-select>
1239 * </hljs>
1240 */
1241function OptionDirective($mdButtonInkRipple, $mdUtil, $mdTheming) {
1242
1243 return {
1244 restrict: 'E',
1245 require: ['mdOption', '^^mdSelectMenu'],
1246 controller: OptionController,
1247 compile: compile
1248 };
1249
1250 /**
1251 * @param {JQLite} element
1252 * @param {IAttributes} attrs
1253 * @return {postLink}
1254 */
1255 function compile(element, attrs) {
1256 // Manual transclusion to avoid the extra inner <span> that ng-transclude generates
1257 element.append(angular.element('<div class="md-text">').append(element.contents()));
1258
1259 element.attr('tabindex', attrs.tabindex || '0');
1260
1261 if (!hasDefinedValue(attrs)) {
1262 element.attr('md-option-empty', '');
1263 }
1264
1265 return postLink;
1266 }
1267
1268 /**
1269 * @param {Object} attrs list of attributes from the compile function
1270 * @return {string|undefined|null} if defined and non-empty, return the value of the option's
1271 * value attribute, otherwise return the value of the option's ng-value attribute.
1272 */
1273 function hasDefinedValue(attrs) {
1274 var value = attrs.value;
1275 var ngValue = attrs.ngValue;
1276
1277 return value || ngValue;
1278 }
1279
1280 function postLink(scope, element, attrs, ctrls) {
1281 var optionCtrl = ctrls[0];
1282 var selectMenuCtrl = ctrls[1];
1283
1284 $mdTheming(element);
1285
1286 if (selectMenuCtrl.isMultiple) {
1287 element.addClass('md-checkbox-enabled');
1288 element.prepend(CHECKBOX_SELECTION_INDICATOR.clone());
1289 }
1290
1291 if (angular.isDefined(attrs.ngValue)) {
1292 scope.$watch(attrs.ngValue, function (newValue, oldValue) {
1293 setOptionValue(newValue, oldValue);
1294 element.removeAttr('aria-checked');
1295 });
1296 } else if (angular.isDefined(attrs.value)) {
1297 setOptionValue(attrs.value);
1298 } else {
1299 scope.$watch(function() {
1300 return element.text().trim();
1301 }, setOptionValue);
1302 }
1303
1304 attrs.$observe('disabled', function(disabled) {
1305 if (disabled) {
1306 element.attr('tabindex', '-1');
1307 } else {
1308 element.attr('tabindex', '0');
1309 }
1310 });
1311
1312 scope.$$postDigest(function() {
1313 attrs.$observe('selected', function(selected) {
1314 if (!angular.isDefined(selected)) return;
1315 if (typeof selected == 'string') selected = true;
1316 if (selected) {
1317 if (!selectMenuCtrl.isMultiple) {
1318 selectMenuCtrl.deselect(Object.keys(selectMenuCtrl.selected)[0]);
1319 }
1320 selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value);
1321 } else {
1322 selectMenuCtrl.deselect(optionCtrl.hashKey);
1323 }
1324 selectMenuCtrl.refreshViewValue();
1325 });
1326 });
1327
1328 $mdButtonInkRipple.attach(scope, element);
1329 configureAria();
1330
1331 /**
1332 * @param {*} newValue the option's new value
1333 * @param {*=} oldValue the option's previous value
1334 * @param {boolean=} prevAttempt true if this had to be attempted again due to an undefined
1335 * hashGetter on the selectMenuCtrl, undefined otherwise.
1336 */
1337 function setOptionValue(newValue, oldValue, prevAttempt) {
1338 if (!selectMenuCtrl.hashGetter) {
1339 if (!prevAttempt) {
1340 scope.$$postDigest(function() {
1341 setOptionValue(newValue, oldValue, true);
1342 });
1343 }
1344 return;
1345 }
1346 var oldHashKey = selectMenuCtrl.hashGetter(oldValue, scope);
1347 var newHashKey = selectMenuCtrl.hashGetter(newValue, scope);
1348
1349 optionCtrl.hashKey = newHashKey;
1350 optionCtrl.value = newValue;
1351
1352 selectMenuCtrl.removeOption(oldHashKey, optionCtrl);
1353 selectMenuCtrl.addOption(newHashKey, optionCtrl);
1354 }
1355
1356 scope.$on('$destroy', function() {
1357 selectMenuCtrl.removeOption(optionCtrl.hashKey, optionCtrl);
1358 });
1359
1360 function configureAria() {
1361 var ariaAttrs = {
1362 'role': 'option'
1363 };
1364
1365 // We explicitly omit the `aria-selected` attribute from single-selection, unselected
1366 // options. Including the `aria-selected="false"` attributes adds a significant amount of
1367 // noise to screen-reader users without providing useful information.
1368 if (selectMenuCtrl.isMultiple) {
1369 ariaAttrs['aria-selected'] = 'false';
1370 }
1371
1372 if (!element[0].hasAttribute('id')) {
1373 ariaAttrs.id = 'select_option_' + $mdUtil.nextUid();
1374 }
1375 element.attr(ariaAttrs);
1376 }
1377 }
1378}
1379
1380/**
1381 * @param {JQLite} $element
1382 * @constructor
1383 */
1384function OptionController($element) {
1385 /**
1386 * @param {boolean} isSelected
1387 * @param {boolean=} isMultiple
1388 */
1389 this.setSelected = function(isSelected, isMultiple) {
1390 if (isSelected) {
1391 $element.attr({
1392 'selected': 'true',
1393 'aria-selected': 'true'
1394 });
1395 } else if (!isSelected) {
1396 $element.removeAttr('selected');
1397
1398 if (isMultiple) {
1399 $element.attr('aria-selected', 'false');
1400 } else {
1401 // We explicitly omit the `aria-selected` attribute from single-selection, unselected
1402 // options. Including the `aria-selected="false"` attributes adds a significant amount of
1403 // noise to screen-reader users without providing useful information.
1404 $element.removeAttr('aria-selected');
1405 }
1406 }
1407 };
1408}
1409
1410/**
1411 * @ngdoc directive
1412 * @name mdOptgroup
1413 * @restrict E
1414 * @module material.components.select
1415 *
1416 * @description Displays a label separating groups of
1417 * <a ng-href="api/directive/mdOption">md-option</a> element directives in a
1418 * <a ng-href="api/directive/mdSelect">md-select</a> box's dropdown menu.
1419 *
1420 * **Note:** When using `md-select-header` element directives within a `md-select`, the labels that
1421 * would normally be added to the <a ng-href="api/directive/mdOptgroup">md-optgroup</a> directives
1422 * are omitted, allowing the `md-select-header` to represent the option group label
1423 * (and possibly more).
1424 *
1425 * @usage
1426 * With label attributes
1427 * <hljs lang="html">
1428 * <md-select ng-model="currentState" placeholder="Select a state">
1429 * <md-optgroup label="Southern">
1430 * <md-option ng-value="AL">Alabama</md-option>
1431 * <md-option ng-value="FL">Florida</md-option>
1432 * </md-optgroup>
1433 * <md-optgroup label="Northern">
1434 * <md-option ng-value="AK">Alaska</md-option>
1435 * <md-option ng-value="MA">Massachusetts</md-option>
1436 * </md-optgroup>
1437 * </md-select>
1438 * </hljs>
1439 *
1440 * With label elements
1441 * <hljs lang="html">
1442 * <md-select ng-model="currentState" placeholder="Select a state">
1443 * <md-optgroup>
1444 * <label>Southern</label>
1445 * <md-option ng-value="AL">Alabama</md-option>
1446 * <md-option ng-value="FL">Florida</md-option>
1447 * </md-optgroup>
1448 * <md-optgroup>
1449 * <label>Northern</label>
1450 * <md-option ng-value="AK">Alaska</md-option>
1451 * <md-option ng-value="MA">Massachusetts</md-option>
1452 * </md-optgroup>
1453 * </md-select>
1454 * </hljs>
1455 *
1456 * @param {string=} label The option group's label.
1457 */
1458function OptgroupDirective() {
1459 return {
1460 restrict: 'E',
1461 compile: compile
1462 };
1463 function compile(element, attrs) {
1464 // If we have a select header element, we don't want to add the normal label
1465 // header.
1466 if (!hasSelectHeader()) {
1467 setupLabelElement();
1468 }
1469 element.attr('role', 'group');
1470
1471 function hasSelectHeader() {
1472 return element.parent().find('md-select-header').length;
1473 }
1474
1475 function setupLabelElement() {
1476 var labelElement = element.find('label');
1477 if (!labelElement.length) {
1478 labelElement = angular.element('<label>');
1479 element.prepend(labelElement);
1480 }
1481 labelElement.addClass('md-container-ignore');
1482 labelElement.attr('aria-hidden', 'true');
1483 if (attrs.label) {
1484 labelElement.text(attrs.label);
1485 }
1486 element.attr('aria-label', labelElement.text());
1487 }
1488 }
1489}
1490
1491function SelectHeaderDirective() {
1492 return {
1493 restrict: 'E',
1494 };
1495}
1496
1497function SelectProvider($$interimElementProvider) {
1498 selectDefaultOptions['$inject'] = ["$mdSelect", "$mdConstant", "$mdUtil", "$window", "$q", "$$rAF", "$animateCss", "$animate", "$document"];
1499 return $$interimElementProvider('$mdSelect')
1500 .setDefaults({
1501 methods: ['target'],
1502 options: selectDefaultOptions
1503 });
1504
1505 /* ngInject */
1506 function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate, $document) {
1507 var ERROR_TARGET_EXPECTED = "$mdSelect.show() expected a target element in options.target but got '{0}'!";
1508 var animator = $mdUtil.dom.animator;
1509 var keyCodes = $mdConstant.KEY_CODE;
1510
1511 return {
1512 parent: 'body',
1513 themable: true,
1514 onShow: onShow,
1515 onRemove: onRemove,
1516 hasBackdrop: true,
1517 disableParentScroll: true
1518 };
1519
1520 /**
1521 * Interim-element onRemove logic....
1522 */
1523 function onRemove(scope, element, opts) {
1524 var animationRunner = null;
1525 var destroyListener = scope.$on('$destroy', function() {
1526 // Listen for the case where the element was destroyed while there was an
1527 // ongoing close animation. If this happens, we need to end the animation
1528 // manually.
1529 animationRunner.end();
1530 });
1531
1532 opts = opts || { };
1533 opts.cleanupInteraction();
1534 opts.cleanupResizing();
1535 opts.hideBackdrop();
1536
1537 // For navigation $destroy events, do a quick, non-animated removal,
1538 // but for normal closes (from clicks, etc) animate the removal
1539 return (opts.$destroy === true) ? cleanElement() : animateRemoval().then(cleanElement);
1540
1541 /**
1542 * For normal closes (eg clicks), animate the removal.
1543 * For forced closes (like $destroy events from navigation),
1544 * skip the animations.
1545 */
1546 function animateRemoval() {
1547 animationRunner = $animateCss(element, {addClass: 'md-leave'});
1548 return animationRunner.start();
1549 }
1550
1551 /**
1552 * Restore the element to a closed state
1553 */
1554 function cleanElement() {
1555 destroyListener();
1556
1557 element
1558 .removeClass('md-active')
1559 .attr('aria-hidden', 'true')
1560 .css({
1561 'display': 'none',
1562 'top': '',
1563 'right': '',
1564 'bottom': '',
1565 'left': '',
1566 'font-size': '',
1567 'min-width': ''
1568 });
1569
1570 announceClosed(opts);
1571
1572 if (!opts.$destroy) {
1573 if (opts.restoreFocus) {
1574 opts.target.focus();
1575 } else {
1576 // Make sure that the container's md-input-focused is removed on backdrop click.
1577 $mdUtil.nextTick(function() {
1578 opts.target.triggerHandler('blur');
1579 }, true);
1580 }
1581 }
1582 }
1583 }
1584
1585 /**
1586 * Interim-element onShow logic.
1587 */
1588 function onShow(scope, element, opts) {
1589
1590 watchAsyncLoad();
1591 sanitizeAndConfigure(scope, opts);
1592
1593 opts.hideBackdrop = showBackdrop(scope, element, opts);
1594
1595 return showDropDown(scope, element, opts)
1596 .then(function(response) {
1597 element.attr('aria-hidden', 'false');
1598 opts.alreadyOpen = true;
1599 opts.cleanupInteraction = activateInteraction();
1600 opts.cleanupResizing = activateResizing();
1601 opts.contentEl[0].focus();
1602
1603 return response;
1604 }, opts.hideBackdrop);
1605
1606 // ************************************
1607 // Closure Functions
1608 // ************************************
1609
1610 /**
1611 * Attach the select DOM element(s) and animate to the correct positions and scale.
1612 */
1613 function showDropDown(scope, element, opts) {
1614 if (opts.parent !== element.parent()) {
1615 element.parent().attr('aria-owns', element.find('md-content').attr('id'));
1616 }
1617
1618 opts.parent.append(element);
1619
1620 return $q(function(resolve, reject) {
1621 try {
1622 $animateCss(element, {removeClass: 'md-leave', duration: 0})
1623 .start()
1624 .then(positionAndFocusMenu)
1625 .then(resolve);
1626
1627 } catch (e) {
1628 reject(e);
1629 }
1630 });
1631 }
1632
1633 /**
1634 * Initialize container and dropDown menu positions/scale, then animate to show.
1635 * @return {*} a Promise that resolves after the menu is animated in and an item is focused
1636 */
1637 function positionAndFocusMenu() {
1638 return $q(function(resolve) {
1639 if (opts.isRemoved) return $q.reject(false);
1640
1641 var info = calculateMenuPositions(scope, element, opts);
1642
1643 info.container.element.css(animator.toCss(info.container.styles));
1644 info.dropDown.element.css(animator.toCss(info.dropDown.styles));
1645
1646 $$rAF(function() {
1647 element.addClass('md-active');
1648 info.dropDown.element.css(animator.toCss({transform: ''}));
1649 autoFocus(opts.focusedNode);
1650
1651 resolve();
1652 });
1653
1654 });
1655 }
1656
1657 /**
1658 * Show modal backdrop element.
1659 */
1660 function showBackdrop(scope, element, options) {
1661
1662 // If we are not within a dialog...
1663 if (options.disableParentScroll && !$mdUtil.getClosest(options.target, 'MD-DIALOG')) {
1664 // !! DO this before creating the backdrop; since disableScrollAround()
1665 // configures the scroll offset; which is used by mdBackDrop postLink()
1666 options.restoreScroll = $mdUtil.disableScrollAround(options.element, options.parent);
1667 } else {
1668 options.disableParentScroll = false;
1669 }
1670
1671 if (options.hasBackdrop) {
1672 // Override duration to immediately show invisible backdrop
1673 options.backdrop = $mdUtil.createBackdrop(scope, "md-select-backdrop md-click-catcher");
1674 $animate.enter(options.backdrop, $document[0].body, null, {duration: 0});
1675 }
1676
1677 /**
1678 * Hide modal backdrop element...
1679 */
1680 return function hideBackdrop() {
1681 if (options.backdrop) options.backdrop.remove();
1682 if (options.disableParentScroll) options.restoreScroll();
1683
1684 delete options.restoreScroll;
1685 };
1686 }
1687
1688 /**
1689 * @param {Element|HTMLElement|null=} previousNode
1690 * @param {Element|HTMLElement} node
1691 * @param {SelectMenuController|Function|object=} menuController SelectMenuController instance
1692 */
1693 function focusOptionNode(previousNode, node, menuController) {
1694 var listboxContentNode = opts.contentEl[0];
1695
1696 if (node) {
1697 if (previousNode) {
1698 previousNode.classList.remove('md-focused');
1699 }
1700
1701 node.classList.add('md-focused');
1702 if (menuController && menuController.setActiveDescendant) {
1703 menuController.setActiveDescendant(node.id);
1704 }
1705
1706 // Scroll the node into view if needed.
1707 if (listboxContentNode.scrollHeight > listboxContentNode.clientHeight) {
1708 var scrollBottom = listboxContentNode.clientHeight + listboxContentNode.scrollTop;
1709 var nodeBottom = node.offsetTop + node.offsetHeight;
1710 if (nodeBottom > scrollBottom) {
1711 listboxContentNode.scrollTop = nodeBottom - listboxContentNode.clientHeight;
1712 } else if (node.offsetTop < listboxContentNode.scrollTop) {
1713 listboxContentNode.scrollTop = node.offsetTop;
1714 }
1715 }
1716 opts.focusedNode = node;
1717 if (menuController && menuController.refreshViewValue) {
1718 menuController.refreshViewValue();
1719 }
1720 }
1721 }
1722
1723 /**
1724 * @param {Element|HTMLElement} nodeToFocus
1725 */
1726 function autoFocus(nodeToFocus) {
1727 var selectMenuController;
1728 if (nodeToFocus && !nodeToFocus.hasAttribute('disabled')) {
1729 selectMenuController = opts.selectEl.controller('mdSelectMenu');
1730 focusOptionNode(null, nodeToFocus, selectMenuController);
1731 }
1732 }
1733
1734 /**
1735 * Check for valid opts and set some useful defaults
1736 */
1737 function sanitizeAndConfigure(scope, options) {
1738 var selectMenuElement = element.find('md-select-menu');
1739
1740 if (!options.target) {
1741 throw new Error($mdUtil.supplant(ERROR_TARGET_EXPECTED, [options.target]));
1742 }
1743
1744 angular.extend(options, {
1745 isRemoved: false,
1746 target: angular.element(options.target), // make sure it's not a naked DOM node
1747 parent: angular.element(options.parent),
1748 selectEl: selectMenuElement,
1749 contentEl: element.find('md-content'),
1750 optionNodes: selectMenuElement[0].getElementsByTagName('md-option')
1751 });
1752 }
1753
1754 /**
1755 * Configure various resize listeners for screen changes
1756 */
1757 function activateResizing() {
1758 var debouncedOnResize = (function(scope, target, options) {
1759
1760 return function() {
1761 if (options.isRemoved) return;
1762
1763 var updates = calculateMenuPositions(scope, target, options);
1764 var container = updates.container;
1765 var dropDown = updates.dropDown;
1766
1767 container.element.css(animator.toCss(container.styles));
1768 dropDown.element.css(animator.toCss(dropDown.styles));
1769 };
1770
1771 })(scope, element, opts);
1772
1773 var window = angular.element($window);
1774 window.on('resize', debouncedOnResize);
1775 window.on('orientationchange', debouncedOnResize);
1776
1777 // Publish deactivation closure...
1778 return function deactivateResizing() {
1779
1780 // Disable resizing handlers
1781 window.off('resize', debouncedOnResize);
1782 window.off('orientationchange', debouncedOnResize);
1783 };
1784 }
1785
1786 /**
1787 * If asynchronously loading, watch and update internal '$$loadingAsyncDone' flag.
1788 */
1789 function watchAsyncLoad() {
1790 if (opts.loadingAsync && !opts.isRemoved) {
1791 scope.$$loadingAsyncDone = false;
1792
1793 $q.when(opts.loadingAsync)
1794 .then(function() {
1795 scope.$$loadingAsyncDone = true;
1796 delete opts.loadingAsync;
1797 }).then(function() {
1798 $$rAF(positionAndFocusMenu);
1799 });
1800 }
1801 }
1802
1803 function activateInteraction() {
1804 if (opts.isRemoved) {
1805 return;
1806 }
1807
1808 var dropDown = opts.selectEl;
1809 var selectMenuController = dropDown.controller('mdSelectMenu') || {};
1810
1811 element.addClass('md-clickable');
1812
1813 // Close on backdrop click
1814 opts.backdrop && opts.backdrop.on('click', onBackdropClick);
1815
1816 // Escape to close
1817 // Cycling of options, and closing on enter
1818 dropDown.on('keydown', onMenuKeyDown);
1819 dropDown.on('click', checkCloseMenu);
1820
1821 return function cleanupInteraction() {
1822 opts.backdrop && opts.backdrop.off('click', onBackdropClick);
1823 dropDown.off('keydown', onMenuKeyDown);
1824 dropDown.off('click', checkCloseMenu);
1825
1826 element.removeClass('md-clickable');
1827 opts.isRemoved = true;
1828 };
1829
1830 // ************************************
1831 // Closure Functions
1832 // ************************************
1833
1834 function onBackdropClick(e) {
1835 e.preventDefault();
1836 e.stopPropagation();
1837 opts.restoreFocus = false;
1838 $mdUtil.nextTick($mdSelect.hide, true);
1839 }
1840
1841 function onMenuKeyDown(ev) {
1842 ev.preventDefault();
1843 ev.stopPropagation();
1844
1845 switch (ev.keyCode) {
1846 case keyCodes.UP_ARROW:
1847 return focusPrevOption();
1848 case keyCodes.DOWN_ARROW:
1849 return focusNextOption();
1850 case keyCodes.SPACE:
1851 case keyCodes.ENTER:
1852 if (opts.focusedNode) {
1853 dropDown.triggerHandler({
1854 type: 'click',
1855 target: opts.focusedNode
1856 });
1857 ev.preventDefault();
1858 }
1859 checkCloseMenu(ev);
1860 break;
1861 case keyCodes.TAB:
1862 case keyCodes.ESCAPE:
1863 ev.stopPropagation();
1864 ev.preventDefault();
1865 opts.restoreFocus = true;
1866 $mdUtil.nextTick($mdSelect.hide, true);
1867 break;
1868 default:
1869 if (shouldHandleKey(ev, $mdConstant)) {
1870 var optNode = selectMenuController.optNodeForKeyboardSearch(ev);
1871 if (optNode && !optNode.hasAttribute('disabled')) {
1872 focusOptionNode(opts.focusedNode, optNode, selectMenuController);
1873 }
1874 }
1875 }
1876 }
1877
1878 /**
1879 * Change the focus to another option. If there is no focused option, focus the first
1880 * option. If there is a focused option, then use the direction to determine if we should
1881 * focus the previous or next option in the list.
1882 * @param {'next'|'prev'} direction
1883 */
1884 function focusOption(direction) {
1885 var optionsArray = $mdUtil.nodesToArray(opts.optionNodes);
1886 var index = optionsArray.indexOf(opts.focusedNode);
1887 var prevOption = optionsArray[index];
1888 var newOption;
1889
1890 do {
1891 if (index === -1) {
1892 // We lost the previously focused element, reset to first option
1893 index = 0;
1894 } else if (direction === 'next' && index < optionsArray.length - 1) {
1895 index++;
1896 } else if (direction === 'prev' && index > 0) {
1897 index--;
1898 }
1899 newOption = optionsArray[index];
1900 if (newOption.hasAttribute('disabled')) {
1901 newOption = null;
1902 }
1903 } while (!newOption && index < optionsArray.length - 1 && index > 0);
1904
1905 focusOptionNode(prevOption, newOption, selectMenuController);
1906 }
1907
1908 function focusNextOption() {
1909 focusOption('next');
1910 }
1911
1912 function focusPrevOption() {
1913 focusOption('prev');
1914 }
1915
1916 /**
1917 * @param {KeyboardEvent|MouseEvent} event
1918 */
1919 function checkCloseMenu(event) {
1920 if (event && (event.type === 'click') && (event.currentTarget !== dropDown[0])) {
1921 return;
1922 }
1923 if (mouseOnScrollbar()) {
1924 return;
1925 }
1926
1927 if (opts.focusedNode && opts.focusedNode.hasAttribute &&
1928 !opts.focusedNode.hasAttribute('disabled')) {
1929 event.preventDefault();
1930 event.stopPropagation();
1931 if (!selectMenuController.isMultiple) {
1932 opts.restoreFocus = true;
1933
1934 $mdUtil.nextTick(function () {
1935 $mdSelect.hide(selectMenuController.ngModel.$viewValue);
1936 opts.focusedNode.classList.remove('md-focused');
1937 }, true);
1938 }
1939 }
1940
1941 /**
1942 * check if the mouseup event was on a scrollbar
1943 */
1944 function mouseOnScrollbar() {
1945 var clickOnScrollbar = false;
1946 if (event && (event.currentTarget.children.length > 0)) {
1947 var child = event.currentTarget.children[0];
1948 var hasScrollbar = child.scrollHeight > child.clientHeight;
1949 if (hasScrollbar && child.children.length > 0) {
1950 var relPosX = event.pageX - event.currentTarget.getBoundingClientRect().left;
1951 if (relPosX > child.querySelector('md-option').offsetWidth)
1952 clickOnScrollbar = true;
1953 }
1954 }
1955 return clickOnScrollbar;
1956 }
1957 }
1958 }
1959 }
1960
1961 /**
1962 * To notify listeners that the Select menu has closed,
1963 * trigger the [optional] user-defined expression
1964 */
1965 function announceClosed(opts) {
1966 var mdSelect = opts.selectCtrl;
1967 if (mdSelect) {
1968 var menuController = opts.selectEl.controller('mdSelectMenu');
1969 mdSelect.setSelectValueText(menuController ? menuController.getSelectedLabels() : '');
1970 mdSelect.triggerClose();
1971 }
1972 }
1973
1974
1975 /**
1976 * Calculate the menu positions after an event like options changing, screen resizing, or
1977 * animations finishing.
1978 * @param {Object} scope
1979 * @param element
1980 * @param opts
1981 * @return {{container: {styles: {top: number, left: number, 'font-size': *, 'min-width': number}, element: Object}, dropDown: {styles: {transform: string, transformOrigin: string}, element: Object}}}
1982 */
1983 function calculateMenuPositions(scope, element, opts) {
1984 var
1985 containerNode = element[0],
1986 targetNode = opts.target[0].children[0], // target the label
1987 parentNode = $document[0].body,
1988 selectNode = opts.selectEl[0],
1989 contentNode = opts.contentEl[0],
1990 parentRect = parentNode.getBoundingClientRect(),
1991 targetRect = targetNode.getBoundingClientRect(),
1992 shouldOpenAroundTarget = false,
1993 bounds = {
1994 left: parentRect.left + SELECT_EDGE_MARGIN,
1995 top: SELECT_EDGE_MARGIN,
1996 bottom: parentRect.height - SELECT_EDGE_MARGIN,
1997 right: parentRect.width - SELECT_EDGE_MARGIN - ($mdUtil.floatingScrollbars() ? 16 : 0)
1998 },
1999 spaceAvailable = {
2000 top: targetRect.top - bounds.top,
2001 left: targetRect.left - bounds.left,
2002 right: bounds.right - (targetRect.left + targetRect.width),
2003 bottom: bounds.bottom - (targetRect.top + targetRect.height)
2004 },
2005 maxWidth = parentRect.width - SELECT_EDGE_MARGIN * 2,
2006 selectedNode = selectNode.querySelector('md-option[selected]'),
2007 optionNodes = selectNode.getElementsByTagName('md-option'),
2008 optgroupNodes = selectNode.getElementsByTagName('md-optgroup'),
2009 isScrollable = calculateScrollable(element, contentNode),
2010 centeredNode;
2011
2012 var loading = isPromiseLike(opts.loadingAsync);
2013 if (!loading) {
2014 // If a selected node, center around that
2015 if (selectedNode) {
2016 centeredNode = selectedNode;
2017 // If there are option groups, center around the first option group
2018 } else if (optgroupNodes.length) {
2019 centeredNode = optgroupNodes[0];
2020 // Otherwise - if we are not loading async - center around the first optionNode
2021 } else if (optionNodes.length) {
2022 centeredNode = optionNodes[0];
2023 // In case there are no options, center on whatever's in there... (eg progress indicator)
2024 } else {
2025 centeredNode = contentNode.firstElementChild || contentNode;
2026 }
2027 } else {
2028 // If loading, center on progress indicator
2029 centeredNode = contentNode.firstElementChild || contentNode;
2030 }
2031
2032 if (contentNode.offsetWidth > maxWidth) {
2033 contentNode.style['max-width'] = maxWidth + 'px';
2034 } else {
2035 contentNode.style.maxWidth = null;
2036 }
2037 if (shouldOpenAroundTarget) {
2038 contentNode.style['min-width'] = targetRect.width + 'px';
2039 }
2040
2041 // Remove padding before we compute the position of the menu
2042 if (isScrollable) {
2043 selectNode.classList.add('md-overflow');
2044 }
2045
2046 var focusedNode = centeredNode;
2047 if ((focusedNode.tagName || '').toUpperCase() === 'MD-OPTGROUP') {
2048 focusedNode = optionNodes[0] || contentNode.firstElementChild || contentNode;
2049 centeredNode = focusedNode;
2050 }
2051 // Cache for autoFocus()
2052 opts.focusedNode = focusedNode;
2053
2054 // Get the selectMenuRect *after* max-width is possibly set above
2055 containerNode.style.display = 'block';
2056 var selectMenuRect = selectNode.getBoundingClientRect();
2057 var centeredRect = getOffsetRect(centeredNode);
2058
2059 if (centeredNode) {
2060 var centeredStyle = $window.getComputedStyle(centeredNode);
2061 centeredRect.paddingLeft = parseInt(centeredStyle.paddingLeft, 10) || 0;
2062 centeredRect.paddingRight = parseInt(centeredStyle.paddingRight, 10) || 0;
2063 }
2064
2065 if (isScrollable) {
2066 var scrollBuffer = contentNode.offsetHeight / 2;
2067 contentNode.scrollTop = centeredRect.top + centeredRect.height / 2 - scrollBuffer;
2068
2069 if (spaceAvailable.top < scrollBuffer) {
2070 contentNode.scrollTop = Math.min(
2071 centeredRect.top,
2072 contentNode.scrollTop + scrollBuffer - spaceAvailable.top
2073 );
2074 } else if (spaceAvailable.bottom < scrollBuffer) {
2075 contentNode.scrollTop = Math.max(
2076 centeredRect.top + centeredRect.height - selectMenuRect.height,
2077 contentNode.scrollTop - scrollBuffer + spaceAvailable.bottom
2078 );
2079 }
2080 }
2081
2082 var left, top, transformOrigin, minWidth, fontSize;
2083 if (shouldOpenAroundTarget) {
2084 left = targetRect.left;
2085 top = targetRect.top + targetRect.height;
2086 transformOrigin = '50% 0';
2087 if (top + selectMenuRect.height > bounds.bottom) {
2088 top = targetRect.top - selectMenuRect.height;
2089 transformOrigin = '50% 100%';
2090 }
2091 } else {
2092 left = (targetRect.left + centeredRect.left - centeredRect.paddingLeft);
2093 top = Math.floor(targetRect.top + targetRect.height / 2 - centeredRect.height / 2 -
2094 centeredRect.top + contentNode.scrollTop) + 2;
2095
2096 transformOrigin = (centeredRect.left + targetRect.width / 2) + 'px ' +
2097 (centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop) + 'px 0px';
2098
2099 minWidth = Math.min(targetRect.width + centeredRect.paddingLeft + centeredRect.paddingRight, maxWidth);
2100
2101 fontSize = window.getComputedStyle(targetNode)['font-size'];
2102 }
2103
2104 // Keep left and top within the window
2105 var containerRect = containerNode.getBoundingClientRect();
2106 var scaleX = Math.round(100 * Math.min(targetRect.width / selectMenuRect.width, 1.0)) / 100;
2107 var scaleY = Math.round(100 * Math.min(targetRect.height / selectMenuRect.height, 1.0)) / 100;
2108
2109 return {
2110 container: {
2111 element: angular.element(containerNode),
2112 styles: {
2113 left: Math.floor(clamp(bounds.left, left, bounds.right - minWidth)),
2114 top: Math.floor(clamp(bounds.top, top, bounds.bottom - containerRect.height)),
2115 'min-width': minWidth,
2116 'font-size': fontSize
2117 }
2118 },
2119 dropDown: {
2120 element: angular.element(selectNode),
2121 styles: {
2122 transformOrigin: transformOrigin,
2123 transform: !opts.alreadyOpen ? $mdUtil.supplant('scale({0},{1})', [scaleX, scaleY]) : ""
2124 }
2125 }
2126 };
2127 }
2128 }
2129
2130 function isPromiseLike(obj) {
2131 return obj && angular.isFunction(obj.then);
2132 }
2133
2134 function clamp(min, n, max) {
2135 return Math.max(min, Math.min(n, max));
2136 }
2137
2138 function getOffsetRect(node) {
2139 return node ? {
2140 left: node.offsetLeft,
2141 top: node.offsetTop,
2142 width: node.offsetWidth,
2143 height: node.offsetHeight
2144 } : {left: 0, top: 0, width: 0, height: 0};
2145 }
2146
2147 function calculateScrollable(element, contentNode) {
2148 var isScrollable = false;
2149
2150 try {
2151 var oldDisplay = element[0].style.display;
2152
2153 // Set the element's display to block so that this calculation is correct
2154 element[0].style.display = 'block';
2155
2156 isScrollable = contentNode.scrollHeight > contentNode.offsetHeight;
2157
2158 // Reset it back afterwards
2159 element[0].style.display = oldDisplay;
2160 } finally {
2161 // Nothing to do
2162 }
2163 return isScrollable;
2164 }
2165}
2166
2167function shouldHandleKey(ev, $mdConstant) {
2168 var char = String.fromCharCode(ev.keyCode);
2169 var isNonUsefulKey = (ev.keyCode <= 31);
2170
2171 return (char && char.length && !isNonUsefulKey &&
2172 !$mdConstant.isMetaKey(ev) && !$mdConstant.isFnLockKey(ev) && !$mdConstant.hasModifierKey(ev));
2173}
2174
2175ngmaterial.components.select = angular.module("material.components.select");
Note: See TracBrowser for help on using the repository browser.