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