You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1693 lines
58 KiB

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