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