1 | /*!
|
---|
2 | * AngularJS Material Design
|
---|
3 | * https://github.com/angular/material
|
---|
4 | * @license MIT
|
---|
5 | * v1.2.3
|
---|
6 | */
|
---|
7 | goog.provide('ngmaterial.components.select');
|
---|
8 | goog.require('ngmaterial.components.backdrop');
|
---|
9 | goog.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 |
|
---|
22 | SelectDirective['$inject'] = ["$mdSelect", "$mdUtil", "$mdConstant", "$mdTheming", "$mdAria", "$parse", "$sce"];
|
---|
23 | SelectMenuDirective['$inject'] = ["$parse", "$mdUtil", "$mdConstant", "$mdTheming"];
|
---|
24 | OptionDirective['$inject'] = ["$mdButtonInkRipple", "$mdUtil", "$mdTheming"];
|
---|
25 | SelectProvider['$inject'] = ["$$interimElementProvider"];
|
---|
26 | OptionController['$inject'] = ["$element"];
|
---|
27 | var SELECT_EDGE_MARGIN = 8;
|
---|
28 | var selectNextId = 0;
|
---|
29 | var CHECKBOX_SELECTION_INDICATOR =
|
---|
30 | angular.element('<div class="md-container"><div class="md-icon"></div></div>');
|
---|
31 |
|
---|
32 | angular.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 | */
|
---|
174 | function 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 |
|
---|
687 | function 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><md-option ng-value="{{someValueThatMightBeUndefined}}"></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 | */
|
---|
1241 | function 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 | */
|
---|
1384 | function 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 | */
|
---|
1458 | function 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 |
|
---|
1491 | function SelectHeaderDirective() {
|
---|
1492 | return {
|
---|
1493 | restrict: 'E',
|
---|
1494 | };
|
---|
1495 | }
|
---|
1496 |
|
---|
1497 | function 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 |
|
---|
2167 | function 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 |
|
---|
2175 | ngmaterial.components.select = angular.module("material.components.select"); |
---|