select.js 59 KB

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