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