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.

1661 lines
56 KiB

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