autocomplete.js 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706
  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.autocomplete
  12. */
  13. /*
  14. * @see js folder for autocomplete implementation
  15. */
  16. angular.module('material.components.autocomplete', [
  17. 'material.core',
  18. 'material.components.icon',
  19. 'material.components.virtualRepeat'
  20. ]);
  21. MdAutocompleteCtrl['$inject'] = ["$scope", "$element", "$mdUtil", "$mdConstant", "$mdTheming", "$window", "$animate", "$rootElement", "$attrs", "$q", "$log", "$mdLiveAnnouncer"];angular
  22. .module('material.components.autocomplete')
  23. .controller('MdAutocompleteCtrl', MdAutocompleteCtrl);
  24. var ITEM_HEIGHT = 48,
  25. MAX_ITEMS = 5,
  26. MENU_PADDING = 8,
  27. INPUT_PADDING = 2; // Padding provided by `md-input-container`
  28. function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, $window,
  29. $animate, $rootElement, $attrs, $q, $log, $mdLiveAnnouncer) {
  30. // Internal Variables.
  31. var ctrl = this,
  32. itemParts = $scope.itemsExpr.split(/ in /i),
  33. itemExpr = itemParts[ 1 ],
  34. elements = null,
  35. cache = {},
  36. noBlur = false,
  37. selectedItemWatchers = [],
  38. hasFocus = false,
  39. fetchesInProgress = 0,
  40. enableWrapScroll = null,
  41. inputModelCtrl = null,
  42. debouncedOnResize = $mdUtil.debounce(onWindowResize);
  43. // Public Exported Variables with handlers
  44. defineProperty('hidden', handleHiddenChange, true);
  45. // Public Exported Variables
  46. ctrl.scope = $scope;
  47. ctrl.parent = $scope.$parent;
  48. ctrl.itemName = itemParts[ 0 ];
  49. ctrl.matches = [];
  50. ctrl.loading = false;
  51. ctrl.hidden = true;
  52. ctrl.index = null;
  53. ctrl.id = $mdUtil.nextUid();
  54. ctrl.isDisabled = null;
  55. ctrl.isRequired = null;
  56. ctrl.isReadonly = null;
  57. ctrl.hasNotFound = false;
  58. // Public Exported Methods
  59. ctrl.keydown = keydown;
  60. ctrl.blur = blur;
  61. ctrl.focus = focus;
  62. ctrl.clear = clearValue;
  63. ctrl.select = select;
  64. ctrl.listEnter = onListEnter;
  65. ctrl.listLeave = onListLeave;
  66. ctrl.mouseUp = onMouseup;
  67. ctrl.getCurrentDisplayValue = getCurrentDisplayValue;
  68. ctrl.registerSelectedItemWatcher = registerSelectedItemWatcher;
  69. ctrl.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher;
  70. ctrl.notFoundVisible = notFoundVisible;
  71. ctrl.loadingIsVisible = loadingIsVisible;
  72. ctrl.positionDropdown = positionDropdown;
  73. /**
  74. * Report types to be used for the $mdLiveAnnouncer
  75. * @enum {number} Unique flag id.
  76. */
  77. var ReportType = {
  78. Count: 1,
  79. Selected: 2
  80. };
  81. return init();
  82. //-- initialization methods
  83. /**
  84. * Initialize the controller, setup watchers, gather elements
  85. */
  86. function init () {
  87. $mdUtil.initOptionalProperties($scope, $attrs, {
  88. searchText: '',
  89. selectedItem: null,
  90. clearButton: false
  91. });
  92. $mdTheming($element);
  93. configureWatchers();
  94. $mdUtil.nextTick(function () {
  95. gatherElements();
  96. moveDropdown();
  97. // Forward all focus events to the input element when autofocus is enabled
  98. if ($scope.autofocus) {
  99. $element.on('focus', focusInputElement);
  100. }
  101. });
  102. }
  103. function updateModelValidators() {
  104. if (!$scope.requireMatch || !inputModelCtrl) return;
  105. inputModelCtrl.$setValidity('md-require-match', !!$scope.selectedItem || !$scope.searchText);
  106. }
  107. /**
  108. * Calculates the dropdown's position and applies the new styles to the menu element
  109. * @returns {*}
  110. */
  111. function positionDropdown () {
  112. if (!elements) {
  113. return $mdUtil.nextTick(positionDropdown, false, $scope);
  114. }
  115. var dropdownHeight = ($scope.dropdownItems || MAX_ITEMS) * ITEM_HEIGHT;
  116. var hrect = elements.wrap.getBoundingClientRect(),
  117. vrect = elements.snap.getBoundingClientRect(),
  118. root = elements.root.getBoundingClientRect(),
  119. top = vrect.bottom - root.top,
  120. bot = root.bottom - vrect.top,
  121. left = hrect.left - root.left,
  122. width = hrect.width,
  123. offset = getVerticalOffset(),
  124. position = $scope.dropdownPosition,
  125. styles;
  126. // Automatically determine dropdown placement based on available space in viewport.
  127. if (!position) {
  128. position = (top > bot && root.height - top - MENU_PADDING < dropdownHeight) ? 'top' : 'bottom';
  129. }
  130. // Adjust the width to account for the padding provided by `md-input-container`
  131. if ($attrs.mdFloatingLabel) {
  132. left += INPUT_PADDING;
  133. width -= INPUT_PADDING * 2;
  134. }
  135. styles = {
  136. left: left + 'px',
  137. minWidth: width + 'px',
  138. maxWidth: Math.max(hrect.right - root.left, root.right - hrect.left) - MENU_PADDING + 'px'
  139. };
  140. if (position === 'top') {
  141. styles.top = 'auto';
  142. styles.bottom = bot + 'px';
  143. styles.maxHeight = Math.min(dropdownHeight, hrect.top - root.top - MENU_PADDING) + 'px';
  144. } else {
  145. var bottomSpace = root.bottom - hrect.bottom - MENU_PADDING + $mdUtil.getViewportTop();
  146. styles.top = (top - offset) + 'px';
  147. styles.bottom = 'auto';
  148. styles.maxHeight = Math.min(dropdownHeight, bottomSpace) + 'px';
  149. }
  150. elements.$.scrollContainer.css(styles);
  151. $mdUtil.nextTick(correctHorizontalAlignment, false);
  152. /**
  153. * Calculates the vertical offset for floating label examples to account for ngMessages
  154. * @returns {number}
  155. */
  156. function getVerticalOffset () {
  157. var offset = 0;
  158. var inputContainer = $element.find('md-input-container');
  159. if (inputContainer.length) {
  160. var input = inputContainer.find('input');
  161. offset = inputContainer.prop('offsetHeight');
  162. offset -= input.prop('offsetTop');
  163. offset -= input.prop('offsetHeight');
  164. // add in the height left up top for the floating label text
  165. offset += inputContainer.prop('offsetTop');
  166. }
  167. return offset;
  168. }
  169. /**
  170. * Makes sure that the menu doesn't go off of the screen on either side.
  171. */
  172. function correctHorizontalAlignment () {
  173. var dropdown = elements.scrollContainer.getBoundingClientRect(),
  174. styles = {};
  175. if (dropdown.right > root.right - MENU_PADDING) {
  176. styles.left = (hrect.right - dropdown.width) + 'px';
  177. }
  178. elements.$.scrollContainer.css(styles);
  179. }
  180. }
  181. /**
  182. * Moves the dropdown menu to the body tag in order to avoid z-index and overflow issues.
  183. */
  184. function moveDropdown () {
  185. if (!elements.$.root.length) return;
  186. $mdTheming(elements.$.scrollContainer);
  187. elements.$.scrollContainer.detach();
  188. elements.$.root.append(elements.$.scrollContainer);
  189. if ($animate.pin) $animate.pin(elements.$.scrollContainer, $rootElement);
  190. }
  191. /**
  192. * Sends focus to the input element.
  193. */
  194. function focusInputElement () {
  195. elements.input.focus();
  196. }
  197. /**
  198. * Sets up any watchers used by autocomplete
  199. */
  200. function configureWatchers () {
  201. var wait = parseInt($scope.delay, 10) || 0;
  202. $attrs.$observe('disabled', function (value) { ctrl.isDisabled = $mdUtil.parseAttributeBoolean(value, false); });
  203. $attrs.$observe('required', function (value) { ctrl.isRequired = $mdUtil.parseAttributeBoolean(value, false); });
  204. $attrs.$observe('readonly', function (value) { ctrl.isReadonly = $mdUtil.parseAttributeBoolean(value, false); });
  205. $scope.$watch('searchText', wait ? $mdUtil.debounce(handleSearchText, wait) : handleSearchText);
  206. $scope.$watch('selectedItem', selectedItemChange);
  207. angular.element($window).on('resize', debouncedOnResize);
  208. $scope.$on('$destroy', cleanup);
  209. }
  210. /**
  211. * Removes any events or leftover elements created by this controller
  212. */
  213. function cleanup () {
  214. if (!ctrl.hidden) {
  215. $mdUtil.enableScrolling();
  216. }
  217. angular.element($window).off('resize', debouncedOnResize);
  218. if ( elements ){
  219. var items = ['ul', 'scroller', 'scrollContainer', 'input'];
  220. angular.forEach(items, function(key){
  221. elements.$[key].remove();
  222. });
  223. }
  224. }
  225. /**
  226. * Event handler to be called whenever the window resizes.
  227. */
  228. function onWindowResize() {
  229. if (!ctrl.hidden) {
  230. positionDropdown();
  231. }
  232. }
  233. /**
  234. * Gathers all of the elements needed for this controller
  235. */
  236. function gatherElements () {
  237. var snapWrap = gatherSnapWrap();
  238. elements = {
  239. main: $element[0],
  240. scrollContainer: $element[0].querySelector('.md-virtual-repeat-container'),
  241. scroller: $element[0].querySelector('.md-virtual-repeat-scroller'),
  242. ul: $element.find('ul')[0],
  243. input: $element.find('input')[0],
  244. wrap: snapWrap.wrap,
  245. snap: snapWrap.snap,
  246. root: document.body
  247. };
  248. elements.li = elements.ul.getElementsByTagName('li');
  249. elements.$ = getAngularElements(elements);
  250. inputModelCtrl = elements.$.input.controller('ngModel');
  251. }
  252. /**
  253. * Gathers the snap and wrap elements
  254. *
  255. */
  256. function gatherSnapWrap() {
  257. var element;
  258. var value;
  259. for (element = $element; element.length; element = element.parent()) {
  260. value = element.attr('md-autocomplete-snap');
  261. if (angular.isDefined(value)) break;
  262. }
  263. if (element.length) {
  264. return {
  265. snap: element[0],
  266. wrap: (value.toLowerCase() === 'width') ? element[0] : $element.find('md-autocomplete-wrap')[0]
  267. };
  268. }
  269. var wrap = $element.find('md-autocomplete-wrap')[0];
  270. return {
  271. snap: wrap,
  272. wrap: wrap
  273. };
  274. }
  275. /**
  276. * Gathers angular-wrapped versions of each element
  277. * @param elements
  278. * @returns {{}}
  279. */
  280. function getAngularElements (elements) {
  281. var obj = {};
  282. for (var key in elements) {
  283. if (elements.hasOwnProperty(key)) obj[ key ] = angular.element(elements[ key ]);
  284. }
  285. return obj;
  286. }
  287. //-- event/change handlers
  288. /**
  289. * Handles changes to the `hidden` property.
  290. * @param hidden
  291. * @param oldHidden
  292. */
  293. function handleHiddenChange (hidden, oldHidden) {
  294. if (!hidden && oldHidden) {
  295. positionDropdown();
  296. // Report in polite mode, because the screenreader should finish the default description of
  297. // the input. element.
  298. reportMessages(true, ReportType.Count | ReportType.Selected);
  299. if (elements) {
  300. $mdUtil.disableScrollAround(elements.ul);
  301. enableWrapScroll = disableElementScrollEvents(angular.element(elements.wrap));
  302. }
  303. } else if (hidden && !oldHidden) {
  304. $mdUtil.enableScrolling();
  305. if (enableWrapScroll) {
  306. enableWrapScroll();
  307. enableWrapScroll = null;
  308. }
  309. }
  310. }
  311. /**
  312. * Disables scrolling for a specific element
  313. */
  314. function disableElementScrollEvents(element) {
  315. function preventDefault(e) {
  316. e.preventDefault();
  317. }
  318. element.on('wheel', preventDefault);
  319. element.on('touchmove', preventDefault);
  320. return function() {
  321. element.off('wheel', preventDefault);
  322. element.off('touchmove', preventDefault);
  323. };
  324. }
  325. /**
  326. * When the user mouses over the dropdown menu, ignore blur events.
  327. */
  328. function onListEnter () {
  329. noBlur = true;
  330. }
  331. /**
  332. * When the user's mouse leaves the menu, blur events may hide the menu again.
  333. */
  334. function onListLeave () {
  335. if (!hasFocus && !ctrl.hidden) elements.input.focus();
  336. noBlur = false;
  337. ctrl.hidden = shouldHide();
  338. }
  339. /**
  340. * When the mouse button is released, send focus back to the input field.
  341. */
  342. function onMouseup () {
  343. elements.input.focus();
  344. }
  345. /**
  346. * Handles changes to the selected item.
  347. * @param selectedItem
  348. * @param previousSelectedItem
  349. */
  350. function selectedItemChange (selectedItem, previousSelectedItem) {
  351. updateModelValidators();
  352. if (selectedItem) {
  353. getDisplayValue(selectedItem).then(function (val) {
  354. $scope.searchText = val;
  355. handleSelectedItemChange(selectedItem, previousSelectedItem);
  356. });
  357. } else if (previousSelectedItem && $scope.searchText) {
  358. getDisplayValue(previousSelectedItem).then(function(displayValue) {
  359. // Clear the searchText, when the selectedItem is set to null.
  360. // Do not clear the searchText, when the searchText isn't matching with the previous
  361. // selected item.
  362. if (angular.isString($scope.searchText)
  363. && displayValue.toString().toLowerCase() === $scope.searchText.toLowerCase()) {
  364. $scope.searchText = '';
  365. }
  366. });
  367. }
  368. if (selectedItem !== previousSelectedItem) announceItemChange();
  369. }
  370. /**
  371. * Use the user-defined expression to announce changes each time a new item is selected
  372. */
  373. function announceItemChange () {
  374. angular.isFunction($scope.itemChange) && $scope.itemChange(getItemAsNameVal($scope.selectedItem));
  375. }
  376. /**
  377. * Use the user-defined expression to announce changes each time the search text is changed
  378. */
  379. function announceTextChange () {
  380. angular.isFunction($scope.textChange) && $scope.textChange();
  381. }
  382. /**
  383. * Calls any external watchers listening for the selected item. Used in conjunction with
  384. * `registerSelectedItemWatcher`.
  385. * @param selectedItem
  386. * @param previousSelectedItem
  387. */
  388. function handleSelectedItemChange (selectedItem, previousSelectedItem) {
  389. selectedItemWatchers.forEach(function (watcher) { watcher(selectedItem, previousSelectedItem); });
  390. }
  391. /**
  392. * Register a function to be called when the selected item changes.
  393. * @param cb
  394. */
  395. function registerSelectedItemWatcher (cb) {
  396. if (selectedItemWatchers.indexOf(cb) == -1) {
  397. selectedItemWatchers.push(cb);
  398. }
  399. }
  400. /**
  401. * Unregister a function previously registered for selected item changes.
  402. * @param cb
  403. */
  404. function unregisterSelectedItemWatcher (cb) {
  405. var i = selectedItemWatchers.indexOf(cb);
  406. if (i != -1) {
  407. selectedItemWatchers.splice(i, 1);
  408. }
  409. }
  410. /**
  411. * Handles changes to the searchText property.
  412. * @param searchText
  413. * @param previousSearchText
  414. */
  415. function handleSearchText (searchText, previousSearchText) {
  416. ctrl.index = getDefaultIndex();
  417. // do nothing on init
  418. if (searchText === previousSearchText) return;
  419. updateModelValidators();
  420. getDisplayValue($scope.selectedItem).then(function (val) {
  421. // clear selected item if search text no longer matches it
  422. if (searchText !== val) {
  423. $scope.selectedItem = null;
  424. // trigger change event if available
  425. if (searchText !== previousSearchText) announceTextChange();
  426. // cancel results if search text is not long enough
  427. if (!isMinLengthMet()) {
  428. ctrl.matches = [];
  429. setLoading(false);
  430. reportMessages(false, ReportType.Count);
  431. } else {
  432. handleQuery();
  433. }
  434. }
  435. });
  436. }
  437. /**
  438. * Handles input blur event, determines if the dropdown should hide.
  439. */
  440. function blur($event) {
  441. hasFocus = false;
  442. if (!noBlur) {
  443. ctrl.hidden = shouldHide();
  444. evalAttr('ngBlur', { $event: $event });
  445. }
  446. }
  447. /**
  448. * Force blur on input element
  449. * @param forceBlur
  450. */
  451. function doBlur(forceBlur) {
  452. if (forceBlur) {
  453. noBlur = false;
  454. hasFocus = false;
  455. }
  456. elements.input.blur();
  457. }
  458. /**
  459. * Handles input focus event, determines if the dropdown should show.
  460. */
  461. function focus($event) {
  462. hasFocus = true;
  463. if (isSearchable() && isMinLengthMet()) {
  464. handleQuery();
  465. }
  466. ctrl.hidden = shouldHide();
  467. evalAttr('ngFocus', { $event: $event });
  468. }
  469. /**
  470. * Handles keyboard input.
  471. * @param event
  472. */
  473. function keydown (event) {
  474. switch (event.keyCode) {
  475. case $mdConstant.KEY_CODE.DOWN_ARROW:
  476. if (ctrl.loading) return;
  477. event.stopPropagation();
  478. event.preventDefault();
  479. ctrl.index = Math.min(ctrl.index + 1, ctrl.matches.length - 1);
  480. updateScroll();
  481. reportMessages(false, ReportType.Selected);
  482. break;
  483. case $mdConstant.KEY_CODE.UP_ARROW:
  484. if (ctrl.loading) return;
  485. event.stopPropagation();
  486. event.preventDefault();
  487. ctrl.index = ctrl.index < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1);
  488. updateScroll();
  489. reportMessages(false, ReportType.Selected);
  490. break;
  491. case $mdConstant.KEY_CODE.TAB:
  492. // If we hit tab, assume that we've left the list so it will close
  493. onListLeave();
  494. if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
  495. select(ctrl.index);
  496. break;
  497. case $mdConstant.KEY_CODE.ENTER:
  498. if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
  499. if (hasSelection()) return;
  500. event.stopPropagation();
  501. event.preventDefault();
  502. select(ctrl.index);
  503. break;
  504. case $mdConstant.KEY_CODE.ESCAPE:
  505. event.preventDefault(); // Prevent browser from always clearing input
  506. if (!shouldProcessEscape()) return;
  507. event.stopPropagation();
  508. clearSelectedItem();
  509. if ($scope.searchText && hasEscapeOption('clear')) {
  510. clearSearchText();
  511. }
  512. // Manually hide (needed for mdNotFound support)
  513. ctrl.hidden = true;
  514. if (hasEscapeOption('blur')) {
  515. // Force the component to blur if they hit escape
  516. doBlur(true);
  517. }
  518. break;
  519. default:
  520. }
  521. }
  522. //-- getters
  523. /**
  524. * Returns the minimum length needed to display the dropdown.
  525. * @returns {*}
  526. */
  527. function getMinLength () {
  528. return angular.isNumber($scope.minLength) ? $scope.minLength : 1;
  529. }
  530. /**
  531. * Returns the display value for an item.
  532. * @param item
  533. * @returns {*}
  534. */
  535. function getDisplayValue (item) {
  536. return $q.when(getItemText(item) || item).then(function(itemText) {
  537. if (itemText && !angular.isString(itemText)) {
  538. $log.warn('md-autocomplete: Could not resolve display value to a string. ' +
  539. 'Please check the `md-item-text` attribute.');
  540. }
  541. return itemText;
  542. });
  543. /**
  544. * Getter function to invoke user-defined expression (in the directive)
  545. * to convert your object to a single string.
  546. */
  547. function getItemText (item) {
  548. return (item && $scope.itemText) ? $scope.itemText(getItemAsNameVal(item)) : null;
  549. }
  550. }
  551. /**
  552. * Returns the locals object for compiling item templates.
  553. * @param item
  554. * @returns {{}}
  555. */
  556. function getItemAsNameVal (item) {
  557. if (!item) return undefined;
  558. var locals = {};
  559. if (ctrl.itemName) locals[ ctrl.itemName ] = item;
  560. return locals;
  561. }
  562. /**
  563. * Returns the default index based on whether or not autoselect is enabled.
  564. * @returns {number}
  565. */
  566. function getDefaultIndex () {
  567. return $scope.autoselect ? 0 : -1;
  568. }
  569. /**
  570. * Sets the loading parameter and updates the hidden state.
  571. * @param value {boolean} Whether or not the component is currently loading.
  572. */
  573. function setLoading(value) {
  574. if (ctrl.loading != value) {
  575. ctrl.loading = value;
  576. }
  577. // Always refresh the hidden variable as something else might have changed
  578. ctrl.hidden = shouldHide();
  579. }
  580. /**
  581. * Determines if the menu should be hidden.
  582. * @returns {boolean}
  583. */
  584. function shouldHide () {
  585. if (!isSearchable()) return true; // Hide when not able to query
  586. else return !shouldShow(); // Hide when the dropdown is not able to show.
  587. }
  588. /**
  589. * Determines whether the autocomplete is able to query within the current state.
  590. * @returns {boolean}
  591. */
  592. function isSearchable() {
  593. if (ctrl.loading && !hasMatches()) return false; // No query when query is in progress.
  594. else if (hasSelection()) return false; // No query if there is already a selection
  595. else if (!hasFocus) return false; // No query if the input does not have focus
  596. return true;
  597. }
  598. /**
  599. * Determines if the escape keydown should be processed
  600. * @returns {boolean}
  601. */
  602. function shouldProcessEscape() {
  603. return hasEscapeOption('blur') || !ctrl.hidden || ctrl.loading || hasEscapeOption('clear') && $scope.searchText;
  604. }
  605. /**
  606. * Determines if an escape option is set
  607. * @returns {boolean}
  608. */
  609. function hasEscapeOption(option) {
  610. return !$scope.escapeOptions || $scope.escapeOptions.toLowerCase().indexOf(option) !== -1;
  611. }
  612. /**
  613. * Determines if the menu should be shown.
  614. * @returns {boolean}
  615. */
  616. function shouldShow() {
  617. return (isMinLengthMet() && hasMatches()) || notFoundVisible();
  618. }
  619. /**
  620. * Returns true if the search text has matches.
  621. * @returns {boolean}
  622. */
  623. function hasMatches() {
  624. return ctrl.matches.length ? true : false;
  625. }
  626. /**
  627. * Returns true if the autocomplete has a valid selection.
  628. * @returns {boolean}
  629. */
  630. function hasSelection() {
  631. return ctrl.scope.selectedItem ? true : false;
  632. }
  633. /**
  634. * Returns true if the loading indicator is, or should be, visible.
  635. * @returns {boolean}
  636. */
  637. function loadingIsVisible() {
  638. return ctrl.loading && !hasSelection();
  639. }
  640. /**
  641. * Returns the display value of the current item.
  642. * @returns {*}
  643. */
  644. function getCurrentDisplayValue () {
  645. return getDisplayValue(ctrl.matches[ ctrl.index ]);
  646. }
  647. /**
  648. * Determines if the minimum length is met by the search text.
  649. * @returns {*}
  650. */
  651. function isMinLengthMet () {
  652. return ($scope.searchText || '').length >= getMinLength();
  653. }
  654. //-- actions
  655. /**
  656. * Defines a public property with a handler and a default value.
  657. * @param key
  658. * @param handler
  659. * @param value
  660. */
  661. function defineProperty (key, handler, value) {
  662. Object.defineProperty(ctrl, key, {
  663. get: function () { return value; },
  664. set: function (newValue) {
  665. var oldValue = value;
  666. value = newValue;
  667. handler(newValue, oldValue);
  668. }
  669. });
  670. }
  671. /**
  672. * Selects the item at the given index.
  673. * @param index
  674. */
  675. function select (index) {
  676. //-- force form to update state for validation
  677. $mdUtil.nextTick(function () {
  678. getDisplayValue(ctrl.matches[ index ]).then(function (val) {
  679. var ngModel = elements.$.input.controller('ngModel');
  680. ngModel.$setViewValue(val);
  681. ngModel.$render();
  682. }).finally(function () {
  683. $scope.selectedItem = ctrl.matches[ index ];
  684. setLoading(false);
  685. });
  686. }, false);
  687. }
  688. /**
  689. * Clears the searchText value and selected item.
  690. */
  691. function clearValue () {
  692. clearSelectedItem();
  693. clearSearchText();
  694. }
  695. /**
  696. * Clears the selected item
  697. */
  698. function clearSelectedItem () {
  699. // Reset our variables
  700. ctrl.index = 0;
  701. ctrl.matches = [];
  702. }
  703. /**
  704. * Clears the searchText value
  705. */
  706. function clearSearchText () {
  707. // Set the loading to true so we don't see flashes of content.
  708. // The flashing will only occur when an async request is running.
  709. // So the loading process will stop when the results had been retrieved.
  710. setLoading(true);
  711. $scope.searchText = '';
  712. // Normally, triggering the change / input event is unnecessary, because the browser detects it properly.
  713. // But some browsers are not detecting it properly, which means that we have to trigger the event.
  714. // Using the `input` is not working properly, because for example IE11 is not supporting the `input` event.
  715. // The `change` event is a good alternative and is supported by all supported browsers.
  716. var eventObj = document.createEvent('CustomEvent');
  717. eventObj.initCustomEvent('change', true, true, { value: '' });
  718. elements.input.dispatchEvent(eventObj);
  719. // For some reason, firing the above event resets the value of $scope.searchText if
  720. // $scope.searchText has a space character at the end, so we blank it one more time and then
  721. // focus.
  722. elements.input.blur();
  723. $scope.searchText = '';
  724. elements.input.focus();
  725. }
  726. /**
  727. * Fetches the results for the provided search text.
  728. * @param searchText
  729. */
  730. function fetchResults (searchText) {
  731. var items = $scope.$parent.$eval(itemExpr),
  732. term = searchText.toLowerCase(),
  733. isList = angular.isArray(items),
  734. isPromise = !!items.then; // Every promise should contain a `then` property
  735. if (isList) onResultsRetrieved(items);
  736. else if (isPromise) handleAsyncResults(items);
  737. function handleAsyncResults(items) {
  738. if ( !items ) return;
  739. items = $q.when(items);
  740. fetchesInProgress++;
  741. setLoading(true);
  742. $mdUtil.nextTick(function () {
  743. items
  744. .then(onResultsRetrieved)
  745. .finally(function(){
  746. if (--fetchesInProgress === 0) {
  747. setLoading(false);
  748. }
  749. });
  750. },true, $scope);
  751. }
  752. function onResultsRetrieved(matches) {
  753. cache[term] = matches;
  754. // Just cache the results if the request is now outdated.
  755. // The request becomes outdated, when the new searchText has changed during the result fetching.
  756. if ((searchText || '') !== ($scope.searchText || '')) {
  757. return;
  758. }
  759. handleResults(matches);
  760. }
  761. }
  762. /**
  763. * Reports given message types to supported screenreaders.
  764. * @param {boolean} isPolite Whether the announcement should be polite.
  765. * @param {!number} types Message flags to be reported to the screenreader.
  766. */
  767. function reportMessages(isPolite, types) {
  768. var politeness = isPolite ? 'polite' : 'assertive';
  769. var messages = [];
  770. if (types & ReportType.Selected && ctrl.index !== -1) {
  771. messages.push(getCurrentDisplayValue());
  772. }
  773. if (types & ReportType.Count) {
  774. messages.push($q.resolve(getCountMessage()));
  775. }
  776. $q.all(messages).then(function(data) {
  777. $mdLiveAnnouncer.announce(data.join(' '), politeness);
  778. });
  779. }
  780. /**
  781. * Returns the ARIA message for how many results match the current query.
  782. * @returns {*}
  783. */
  784. function getCountMessage () {
  785. switch (ctrl.matches.length) {
  786. case 0:
  787. return 'There are no matches available.';
  788. case 1:
  789. return 'There is 1 match available.';
  790. default:
  791. return 'There are ' + ctrl.matches.length + ' matches available.';
  792. }
  793. }
  794. /**
  795. * Makes sure that the focused element is within view.
  796. */
  797. function updateScroll () {
  798. if (!elements.li[0]) return;
  799. var height = elements.li[0].offsetHeight,
  800. top = height * ctrl.index,
  801. bot = top + height,
  802. hgt = elements.scroller.clientHeight,
  803. scrollTop = elements.scroller.scrollTop;
  804. if (top < scrollTop) {
  805. scrollTo(top);
  806. } else if (bot > scrollTop + hgt) {
  807. scrollTo(bot - hgt);
  808. }
  809. }
  810. function isPromiseFetching() {
  811. return fetchesInProgress !== 0;
  812. }
  813. function scrollTo (offset) {
  814. elements.$.scrollContainer.controller('mdVirtualRepeatContainer').scrollTo(offset);
  815. }
  816. function notFoundVisible () {
  817. var textLength = (ctrl.scope.searchText || '').length;
  818. return ctrl.hasNotFound && !hasMatches() && (!ctrl.loading || isPromiseFetching()) && textLength >= getMinLength() && (hasFocus || noBlur) && !hasSelection();
  819. }
  820. /**
  821. * Starts the query to gather the results for the current searchText. Attempts to return cached
  822. * results first, then forwards the process to `fetchResults` if necessary.
  823. */
  824. function handleQuery () {
  825. var searchText = $scope.searchText || '';
  826. var term = searchText.toLowerCase();
  827. // If caching is enabled and the current searchText is stored in the cache
  828. if (!$scope.noCache && cache[term]) {
  829. // The results should be handled as same as a normal un-cached request does.
  830. handleResults(cache[term]);
  831. } else {
  832. fetchResults(searchText);
  833. }
  834. ctrl.hidden = shouldHide();
  835. }
  836. /**
  837. * Handles the retrieved results by showing them in the autocompletes dropdown.
  838. * @param results Retrieved results
  839. */
  840. function handleResults(results) {
  841. ctrl.matches = results;
  842. ctrl.hidden = shouldHide();
  843. // If loading is in progress, then we'll end the progress. This is needed for example,
  844. // when the `clear` button was clicked, because there we always show the loading process, to prevent flashing.
  845. if (ctrl.loading) setLoading(false);
  846. if ($scope.selectOnMatch) selectItemOnMatch();
  847. positionDropdown();
  848. reportMessages(true, ReportType.Count);
  849. }
  850. /**
  851. * If there is only one matching item and the search text matches its display value exactly,
  852. * automatically select that item. Note: This function is only called if the user uses the
  853. * `md-select-on-match` flag.
  854. */
  855. function selectItemOnMatch () {
  856. var searchText = $scope.searchText,
  857. matches = ctrl.matches,
  858. item = matches[ 0 ];
  859. if (matches.length === 1) getDisplayValue(item).then(function (displayValue) {
  860. var isMatching = searchText == displayValue;
  861. if ($scope.matchInsensitive && !isMatching) {
  862. isMatching = searchText.toLowerCase() == displayValue.toLowerCase();
  863. }
  864. if (isMatching) select(0);
  865. });
  866. }
  867. /**
  868. * Evaluates an attribute expression against the parent scope.
  869. * @param {String} attr Name of the attribute to be evaluated.
  870. * @param {Object?} locals Properties to be injected into the evaluation context.
  871. */
  872. function evalAttr(attr, locals) {
  873. if ($attrs[attr]) {
  874. $scope.$parent.$eval($attrs[attr], locals || {});
  875. }
  876. }
  877. }
  878. MdAutocomplete['$inject'] = ["$$mdSvgRegistry"];angular
  879. .module('material.components.autocomplete')
  880. .directive('mdAutocomplete', MdAutocomplete);
  881. /**
  882. * @ngdoc directive
  883. * @name mdAutocomplete
  884. * @module material.components.autocomplete
  885. *
  886. * @description
  887. * `<md-autocomplete>` is a special input component with a drop-down of all possible matches to a
  888. * custom query. This component allows you to provide real-time suggestions as the user types
  889. * in the input area.
  890. *
  891. * To start, you will need to specify the required parameters and provide a template for your
  892. * results. The content inside `md-autocomplete` will be treated as a template.
  893. *
  894. * In more complex cases, you may want to include other content such as a message to display when
  895. * no matches were found. You can do this by wrapping your template in `md-item-template` and
  896. * adding a tag for `md-not-found`. An example of this is shown below.
  897. *
  898. * To reset the displayed value you must clear both values for `md-search-text` and `md-selected-item`.
  899. *
  900. * ### Validation
  901. *
  902. * You can use `ng-messages` to include validation the same way that you would normally validate;
  903. * however, if you want to replicate a standard input with a floating label, you will have to
  904. * do the following:
  905. *
  906. * - Make sure that your template is wrapped in `md-item-template`
  907. * - Add your `ng-messages` code inside of `md-autocomplete`
  908. * - Add your validation properties to `md-autocomplete` (ie. `required`)
  909. * - Add a `name` to `md-autocomplete` (to be used on the generated `input`)
  910. *
  911. * There is an example below of how this should look.
  912. *
  913. * ### Snapping Drop-Down
  914. *
  915. * You can cause the autocomplete drop-down to snap to an ancestor element by applying the
  916. * `md-autocomplete-snap` attribute to that element. You can also snap to the width of
  917. * the `md-autocomplete-snap` element by setting the attribute's value to `width`
  918. * (ie. `md-autocomplete-snap="width"`).
  919. *
  920. * ### Notes
  921. *
  922. * **Autocomplete Dropdown Items Rendering**
  923. *
  924. * The `md-autocomplete` uses the the <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeat</a>
  925. * directive for displaying the results inside of the dropdown.<br/>
  926. *
  927. * > When encountering issues regarding the item template please take a look at the
  928. * <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeatContainer</a> documentation.
  929. *
  930. * **Autocomplete inside of a Virtual Repeat**
  931. *
  932. * When using the `md-autocomplete` directive inside of a
  933. * <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeatContainer</a> the dropdown items might
  934. * not update properly, because caching of the results is enabled by default.
  935. *
  936. * The autocomplete will then show invalid dropdown items, because the VirtualRepeat only updates the
  937. * scope bindings, rather than re-creating the `md-autocomplete` and the previous cached results will be used.
  938. *
  939. * > To avoid such problems ensure that the autocomplete does not cache any results.
  940. *
  941. * <hljs lang="html">
  942. * <md-autocomplete
  943. * md-no-cache="true"
  944. * md-selected-item="selectedItem"
  945. * md-items="item in items"
  946. * md-search-text="searchText"
  947. * md-item-text="item.display">
  948. * <span>{{ item.display }}</span>
  949. * </md-autocomplete>
  950. * </hljs>
  951. *
  952. *
  953. * @param {expression} md-items An expression in the format of `item in results` to iterate over
  954. * matches for your search.<br/><br/>
  955. * The `results` expression can be also a function, which returns the results synchronously
  956. * or asynchronously (per Promise).
  957. * @param {expression=} md-selected-item-change An expression to be run each time a new item is
  958. * selected.
  959. * @param {expression=} md-search-text-change An expression to be run each time the search text
  960. * updates.
  961. * @param {expression=} md-search-text A model to bind the search query text to.
  962. * @param {object=} md-selected-item A model to bind the selected item to.
  963. * @param {expression=} md-item-text An expression that will convert your object to a single string.
  964. * @param {string=} placeholder Placeholder text that will be forwarded to the input.
  965. * @param {boolean=} md-no-cache Disables the internal caching that happens in autocomplete.
  966. * @param {boolean=} ng-disabled Determines whether or not to disable the input field.
  967. * @param {boolean=} md-require-match When set to true, the autocomplete will add a validator,
  968. * which will evaluate to false, when no item is currently selected.
  969. * @param {number=} md-min-length Specifies the minimum length of text before autocomplete will
  970. * make suggestions.
  971. * @param {number=} md-delay Specifies the amount of time (in milliseconds) to wait before looking
  972. * for results.
  973. * @param {boolean=} md-clear-button Whether the clear button for the autocomplete input should show up or not.
  974. * @param {boolean=} md-autofocus If true, the autocomplete will be automatically focused when a `$mdDialog`,
  975. * `$mdBottomsheet` or `$mdSidenav`, which contains the autocomplete, is opening. <br/><br/>
  976. * Also the autocomplete will immediately focus the input element.
  977. * @param {boolean=} md-no-asterisk When present, asterisk will not be appended to the floating label.
  978. * @param {boolean=} md-autoselect If set to true, the first item will be automatically selected
  979. * in the dropdown upon open.
  980. * @param {string=} md-input-name The name attribute given to the input element to be used with
  981. * FormController.
  982. * @param {string=} md-menu-class This class will be applied to the dropdown menu for styling.
  983. * @param {string=} md-menu-container-class This class will be applied to the parent container
  984. * of the dropdown panel.
  985. * @param {string=} md-input-class This will be applied to the input for styling. This attribute is only valid when a `md-floating-label` is defined
  986. * @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in
  987. * `md-input-container`
  988. * @param {string=} md-input-name The name attribute given to the input element to be used with
  989. * FormController
  990. * @param {string=} md-select-on-focus When present the inputs text will be automatically selected
  991. * on focus.
  992. * @param {string=} md-input-id An ID to be added to the input element.
  993. * @param {number=} md-input-minlength The minimum length for the input's value for validation.
  994. * @param {number=} md-input-maxlength The maximum length for the input's value for validation.
  995. * @param {boolean=} md-select-on-match When set, autocomplete will automatically select
  996. * the item if the search text is an exact match. <br/><br/>
  997. * An exact match is when only one match is displayed.
  998. * @param {boolean=} md-match-case-insensitive When set and using `md-select-on-match`, autocomplete
  999. * will select on case-insensitive match.
  1000. * @param {string=} md-escape-options Override escape key logic. Default is `blur clear`.<br/>
  1001. * Options: `blur`, `clear`, `none`.
  1002. * @param {string=} md-dropdown-items Specifies the maximum amount of items to be shown in
  1003. * the dropdown.<br/><br/>
  1004. * When the dropdown doesn't fit into the viewport, the dropdown will shrink
  1005. * as much as possible.
  1006. * @param {string=} md-dropdown-position Overrides the default dropdown position. Options: `top`, `bottom`.
  1007. * @param {string=} ng-trim If set to false, the search text will be not trimmed automatically.
  1008. * Defaults to true.
  1009. * @param {string=} ng-pattern Adds the pattern validator to the ngModel of the search text.
  1010. * See the [ngPattern Directive](https://docs.angularjs.org/api/ng/directive/ngPattern)
  1011. * for more details.
  1012. *
  1013. * @usage
  1014. * ### Basic Example
  1015. * <hljs lang="html">
  1016. * <md-autocomplete
  1017. * md-selected-item="selectedItem"
  1018. * md-search-text="searchText"
  1019. * md-items="item in getMatches(searchText)"
  1020. * md-item-text="item.display">
  1021. * <span md-highlight-text="searchText">{{item.display}}</span>
  1022. * </md-autocomplete>
  1023. * </hljs>
  1024. *
  1025. * ### Example with "not found" message
  1026. * <hljs lang="html">
  1027. * <md-autocomplete
  1028. * md-selected-item="selectedItem"
  1029. * md-search-text="searchText"
  1030. * md-items="item in getMatches(searchText)"
  1031. * md-item-text="item.display">
  1032. * <md-item-template>
  1033. * <span md-highlight-text="searchText">{{item.display}}</span>
  1034. * </md-item-template>
  1035. * <md-not-found>
  1036. * No matches found.
  1037. * </md-not-found>
  1038. * </md-autocomplete>
  1039. * </hljs>
  1040. *
  1041. * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the
  1042. * different parts that make up our component.
  1043. *
  1044. * ### Clear button for the input
  1045. * By default, for floating label autocomplete's the clear button is not showing up
  1046. * ([See specs](https://material.google.com/components/text-fields.html#text-fields-auto-complete-text-field))
  1047. *
  1048. * Nevertheless, developers are able to explicitly toggle the clear button for all types of autocomplete's.
  1049. *
  1050. * <hljs lang="html">
  1051. * <md-autocomplete ... md-clear-button="true"></md-autocomplete>
  1052. * <md-autocomplete ... md-clear-button="false"></md-autocomplete>
  1053. * </hljs>
  1054. *
  1055. * ### Example with validation
  1056. * <hljs lang="html">
  1057. * <form name="autocompleteForm">
  1058. * <md-autocomplete
  1059. * required
  1060. * md-input-name="autocomplete"
  1061. * md-selected-item="selectedItem"
  1062. * md-search-text="searchText"
  1063. * md-items="item in getMatches(searchText)"
  1064. * md-item-text="item.display">
  1065. * <md-item-template>
  1066. * <span md-highlight-text="searchText">{{item.display}}</span>
  1067. * </md-item-template>
  1068. * <div ng-messages="autocompleteForm.autocomplete.$error">
  1069. * <div ng-message="required">This field is required</div>
  1070. * </div>
  1071. * </md-autocomplete>
  1072. * </form>
  1073. * </hljs>
  1074. *
  1075. * In this example, our code utilizes `md-item-template` and `ng-messages` to specify
  1076. * input validation for the field.
  1077. *
  1078. * ### Asynchronous Results
  1079. * The autocomplete items expression also supports promises, which will resolve with the query results.
  1080. *
  1081. * <hljs lang="js">
  1082. * function AppController($scope, $http) {
  1083. * $scope.query = function(searchText) {
  1084. * return $http
  1085. * .get(BACKEND_URL + '/items/' + searchText)
  1086. * .then(function(data) {
  1087. * // Map the response object to the data object.
  1088. * return data;
  1089. * });
  1090. * };
  1091. * }
  1092. * </hljs>
  1093. *
  1094. * <hljs lang="html">
  1095. * <md-autocomplete
  1096. * md-selected-item="selectedItem"
  1097. * md-search-text="searchText"
  1098. * md-items="item in query(searchText)">
  1099. * <md-item-template>
  1100. * <span md-highlight-text="searchText">{{item}}</span>
  1101. * </md-item-template>
  1102. * </md-autocomplete>
  1103. * </hljs>
  1104. *
  1105. */
  1106. function MdAutocomplete ($$mdSvgRegistry) {
  1107. return {
  1108. controller: 'MdAutocompleteCtrl',
  1109. controllerAs: '$mdAutocompleteCtrl',
  1110. scope: {
  1111. inputName: '@mdInputName',
  1112. inputMinlength: '@mdInputMinlength',
  1113. inputMaxlength: '@mdInputMaxlength',
  1114. searchText: '=?mdSearchText',
  1115. selectedItem: '=?mdSelectedItem',
  1116. itemsExpr: '@mdItems',
  1117. itemText: '&mdItemText',
  1118. placeholder: '@placeholder',
  1119. noCache: '=?mdNoCache',
  1120. requireMatch: '=?mdRequireMatch',
  1121. selectOnMatch: '=?mdSelectOnMatch',
  1122. matchInsensitive: '=?mdMatchCaseInsensitive',
  1123. itemChange: '&?mdSelectedItemChange',
  1124. textChange: '&?mdSearchTextChange',
  1125. minLength: '=?mdMinLength',
  1126. delay: '=?mdDelay',
  1127. autofocus: '=?mdAutofocus',
  1128. floatingLabel: '@?mdFloatingLabel',
  1129. autoselect: '=?mdAutoselect',
  1130. menuClass: '@?mdMenuClass',
  1131. menuContainerClass: '@?mdMenuContainerClass',
  1132. inputClass: '@?mdInputClass',
  1133. inputId: '@?mdInputId',
  1134. escapeOptions: '@?mdEscapeOptions',
  1135. dropdownItems: '=?mdDropdownItems',
  1136. dropdownPosition: '@?mdDropdownPosition',
  1137. clearButton: '=?mdClearButton'
  1138. },
  1139. compile: function(tElement, tAttrs) {
  1140. var attributes = ['md-select-on-focus', 'md-no-asterisk', 'ng-trim', 'ng-pattern'];
  1141. var input = tElement.find('input');
  1142. attributes.forEach(function(attribute) {
  1143. var attrValue = tAttrs[tAttrs.$normalize(attribute)];
  1144. if (attrValue !== null) {
  1145. input.attr(attribute, attrValue);
  1146. }
  1147. });
  1148. return function(scope, element, attrs, ctrl) {
  1149. // Retrieve the state of using a md-not-found template by using our attribute, which will
  1150. // be added to the element in the template function.
  1151. ctrl.hasNotFound = !!element.attr('md-has-not-found');
  1152. // By default the inset autocomplete should show the clear button when not explicitly overwritten.
  1153. if (!angular.isDefined(attrs.mdClearButton) && !scope.floatingLabel) {
  1154. scope.clearButton = true;
  1155. }
  1156. };
  1157. },
  1158. template: function (element, attr) {
  1159. var noItemsTemplate = getNoItemsTemplate(),
  1160. itemTemplate = getItemTemplate(),
  1161. leftover = element.html(),
  1162. tabindex = attr.tabindex;
  1163. var menuContainerClass = attr.mdMenuContainerClass ? ' ' + attr.mdMenuContainerClass : '';
  1164. // Set our attribute for the link function above which runs later.
  1165. // We will set an attribute, because otherwise the stored variables will be trashed when
  1166. // removing the element is hidden while retrieving the template. For example when using ngIf.
  1167. if (noItemsTemplate) element.attr('md-has-not-found', true);
  1168. // Always set our tabindex of the autocomplete directive to -1, because our input
  1169. // will hold the actual tabindex.
  1170. element.attr('tabindex', '-1');
  1171. return '\
  1172. <md-autocomplete-wrap\
  1173. ng-class="{ \'md-whiteframe-z1\': !floatingLabel, \
  1174. \'md-menu-showing\': !$mdAutocompleteCtrl.hidden, \
  1175. \'md-show-clear-button\': !!clearButton }">\
  1176. ' + getInputElement() + '\
  1177. ' + getClearButton() + '\
  1178. <md-progress-linear\
  1179. class="' + (attr.mdFloatingLabel ? 'md-inline' : '') + '"\
  1180. ng-if="$mdAutocompleteCtrl.loadingIsVisible()"\
  1181. md-mode="indeterminate"></md-progress-linear>\
  1182. <md-virtual-repeat-container\
  1183. md-auto-shrink\
  1184. md-auto-shrink-min="1"\
  1185. ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
  1186. ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
  1187. ng-mouseup="$mdAutocompleteCtrl.mouseUp()"\
  1188. ng-hide="$mdAutocompleteCtrl.hidden"\
  1189. class="md-autocomplete-suggestions-container md-whiteframe-z1' + menuContainerClass + '"\
  1190. ng-class="{ \'md-not-found\': $mdAutocompleteCtrl.notFoundVisible() }"\
  1191. role="presentation">\
  1192. <ul class="md-autocomplete-suggestions"\
  1193. ng-class="::menuClass"\
  1194. id="ul-{{$mdAutocompleteCtrl.id}}">\
  1195. <li md-virtual-repeat="item in $mdAutocompleteCtrl.matches"\
  1196. ng-class="{ selected: $index === $mdAutocompleteCtrl.index }"\
  1197. ng-click="$mdAutocompleteCtrl.select($index)"\
  1198. md-extra-name="$mdAutocompleteCtrl.itemName">\
  1199. ' + itemTemplate + '\
  1200. </li>' + noItemsTemplate + '\
  1201. </ul>\
  1202. </md-virtual-repeat-container>\
  1203. </md-autocomplete-wrap>';
  1204. function getItemTemplate() {
  1205. var templateTag = element.find('md-item-template').detach(),
  1206. html = templateTag.length ? templateTag.html() : element.html();
  1207. if (!templateTag.length) element.empty();
  1208. return '<md-autocomplete-parent-scope md-autocomplete-replace>' + html + '</md-autocomplete-parent-scope>';
  1209. }
  1210. function getNoItemsTemplate() {
  1211. var templateTag = element.find('md-not-found').detach(),
  1212. template = templateTag.length ? templateTag.html() : '';
  1213. return template
  1214. ? '<li ng-if="$mdAutocompleteCtrl.notFoundVisible()"\
  1215. md-autocomplete-parent-scope>' + template + '</li>'
  1216. : '';
  1217. }
  1218. function getInputElement () {
  1219. if (attr.mdFloatingLabel) {
  1220. return '\
  1221. <md-input-container ng-if="floatingLabel">\
  1222. <label>{{floatingLabel}}</label>\
  1223. <input type="search"\
  1224. ' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
  1225. id="{{ inputId || \'fl-input-\' + $mdAutocompleteCtrl.id }}"\
  1226. name="{{inputName}}"\
  1227. ng-class="::inputClass"\
  1228. autocomplete="off"\
  1229. ng-required="$mdAutocompleteCtrl.isRequired"\
  1230. ng-readonly="$mdAutocompleteCtrl.isReadonly"\
  1231. ng-minlength="inputMinlength"\
  1232. ng-maxlength="inputMaxlength"\
  1233. ng-disabled="$mdAutocompleteCtrl.isDisabled"\
  1234. ng-model="$mdAutocompleteCtrl.scope.searchText"\
  1235. ng-model-options="{ allowInvalid: true }"\
  1236. ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
  1237. ng-blur="$mdAutocompleteCtrl.blur($event)"\
  1238. ng-focus="$mdAutocompleteCtrl.focus($event)"\
  1239. aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
  1240. aria-label="{{floatingLabel}}"\
  1241. aria-autocomplete="list"\
  1242. role="combobox"\
  1243. aria-haspopup="true"\
  1244. aria-activedescendant=""\
  1245. aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
  1246. <div md-autocomplete-parent-scope md-autocomplete-replace>' + leftover + '</div>\
  1247. </md-input-container>';
  1248. } else {
  1249. return '\
  1250. <input type="search"\
  1251. ' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
  1252. id="{{ inputId || \'input-\' + $mdAutocompleteCtrl.id }}"\
  1253. name="{{inputName}}"\
  1254. ng-class="::inputClass"\
  1255. ng-if="!floatingLabel"\
  1256. autocomplete="off"\
  1257. ng-required="$mdAutocompleteCtrl.isRequired"\
  1258. ng-disabled="$mdAutocompleteCtrl.isDisabled"\
  1259. ng-readonly="$mdAutocompleteCtrl.isReadonly"\
  1260. ng-minlength="inputMinlength"\
  1261. ng-maxlength="inputMaxlength"\
  1262. ng-model="$mdAutocompleteCtrl.scope.searchText"\
  1263. ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
  1264. ng-blur="$mdAutocompleteCtrl.blur($event)"\
  1265. ng-focus="$mdAutocompleteCtrl.focus($event)"\
  1266. placeholder="{{placeholder}}"\
  1267. aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
  1268. aria-label="{{placeholder}}"\
  1269. aria-autocomplete="list"\
  1270. role="combobox"\
  1271. aria-haspopup="true"\
  1272. aria-activedescendant=""\
  1273. aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>';
  1274. }
  1275. }
  1276. function getClearButton() {
  1277. return '' +
  1278. '<button ' +
  1279. 'type="button" ' +
  1280. 'aria-label="Clear Input" ' +
  1281. 'tabindex="-1" ' +
  1282. 'ng-if="clearButton && $mdAutocompleteCtrl.scope.searchText" ' +
  1283. 'ng-click="$mdAutocompleteCtrl.clear($event)">' +
  1284. '<md-icon md-svg-src="' + $$mdSvgRegistry.mdClose + '"></md-icon>' +
  1285. '</button>';
  1286. }
  1287. }
  1288. };
  1289. }
  1290. MdAutocompleteItemScopeDirective['$inject'] = ["$compile", "$mdUtil"];angular
  1291. .module('material.components.autocomplete')
  1292. .directive('mdAutocompleteParentScope', MdAutocompleteItemScopeDirective);
  1293. function MdAutocompleteItemScopeDirective($compile, $mdUtil) {
  1294. return {
  1295. restrict: 'AE',
  1296. compile: compile,
  1297. terminal: true,
  1298. transclude: 'element'
  1299. };
  1300. function compile(tElement, tAttr, transclude) {
  1301. return function postLink(scope, element, attr) {
  1302. var ctrl = scope.$mdAutocompleteCtrl;
  1303. var newScope = ctrl.parent.$new();
  1304. var itemName = ctrl.itemName;
  1305. // Watch for changes to our scope's variables and copy them to the new scope
  1306. watchVariable('$index', '$index');
  1307. watchVariable('item', itemName);
  1308. // Ensure that $digest calls on our scope trigger $digest on newScope.
  1309. connectScopes();
  1310. // Link the element against newScope.
  1311. transclude(newScope, function(clone) {
  1312. element.after(clone);
  1313. });
  1314. /**
  1315. * Creates a watcher for variables that are copied from the parent scope
  1316. * @param variable
  1317. * @param alias
  1318. */
  1319. function watchVariable(variable, alias) {
  1320. newScope[alias] = scope[variable];
  1321. scope.$watch(variable, function(value) {
  1322. $mdUtil.nextTick(function() {
  1323. newScope[alias] = value;
  1324. });
  1325. });
  1326. }
  1327. /**
  1328. * Creates watchers on scope and newScope that ensure that for any
  1329. * $digest of scope, newScope is also $digested.
  1330. */
  1331. function connectScopes() {
  1332. var scopeDigesting = false;
  1333. var newScopeDigesting = false;
  1334. scope.$watch(function() {
  1335. if (newScopeDigesting || scopeDigesting) {
  1336. return;
  1337. }
  1338. scopeDigesting = true;
  1339. scope.$$postDigest(function() {
  1340. if (!newScopeDigesting) {
  1341. newScope.$digest();
  1342. }
  1343. scopeDigesting = newScopeDigesting = false;
  1344. });
  1345. });
  1346. newScope.$watch(function() {
  1347. newScopeDigesting = true;
  1348. });
  1349. }
  1350. };
  1351. }
  1352. }
  1353. MdHighlightCtrl['$inject'] = ["$scope", "$element", "$attrs"];angular
  1354. .module('material.components.autocomplete')
  1355. .controller('MdHighlightCtrl', MdHighlightCtrl);
  1356. function MdHighlightCtrl ($scope, $element, $attrs) {
  1357. this.$scope = $scope;
  1358. this.$element = $element;
  1359. this.$attrs = $attrs;
  1360. // Cache the Regex to avoid rebuilding each time.
  1361. this.regex = null;
  1362. }
  1363. MdHighlightCtrl.prototype.init = function(unsafeTermFn, unsafeContentFn) {
  1364. this.flags = this.$attrs.mdHighlightFlags || '';
  1365. this.unregisterFn = this.$scope.$watch(function($scope) {
  1366. return {
  1367. term: unsafeTermFn($scope),
  1368. contentText: unsafeContentFn($scope)
  1369. };
  1370. }.bind(this), this.onRender.bind(this), true);
  1371. this.$element.on('$destroy', this.unregisterFn);
  1372. };
  1373. /**
  1374. * Triggered once a new change has been recognized and the highlighted
  1375. * text needs to be updated.
  1376. */
  1377. MdHighlightCtrl.prototype.onRender = function(state, prevState) {
  1378. var contentText = state.contentText;
  1379. /* Update the regex if it's outdated, because we don't want to rebuilt it constantly. */
  1380. if (this.regex === null || state.term !== prevState.term) {
  1381. this.regex = this.createRegex(state.term, this.flags);
  1382. }
  1383. /* If a term is available apply the regex to the content */
  1384. if (state.term) {
  1385. this.applyRegex(contentText);
  1386. } else {
  1387. this.$element.text(contentText);
  1388. }
  1389. };
  1390. /**
  1391. * Decomposes the specified text into different tokens (whether match or not).
  1392. * Breaking down the string guarantees proper XSS protection due to the native browser
  1393. * escaping of unsafe text.
  1394. */
  1395. MdHighlightCtrl.prototype.applyRegex = function(text) {
  1396. var tokens = this.resolveTokens(text);
  1397. this.$element.empty();
  1398. tokens.forEach(function (token) {
  1399. if (token.isMatch) {
  1400. var tokenEl = angular.element('<span class="highlight">').text(token.text);
  1401. this.$element.append(tokenEl);
  1402. } else {
  1403. this.$element.append(document.createTextNode(token));
  1404. }
  1405. }.bind(this));
  1406. };
  1407. /**
  1408. * Decomposes the specified text into different tokens by running the regex against the text.
  1409. */
  1410. MdHighlightCtrl.prototype.resolveTokens = function(string) {
  1411. var tokens = [];
  1412. var lastIndex = 0;
  1413. // Use replace here, because it supports global and single regular expressions at same time.
  1414. string.replace(this.regex, function(match, index) {
  1415. appendToken(lastIndex, index);
  1416. tokens.push({
  1417. text: match,
  1418. isMatch: true
  1419. });
  1420. lastIndex = index + match.length;
  1421. });
  1422. // Append the missing text as a token.
  1423. appendToken(lastIndex);
  1424. return tokens;
  1425. function appendToken(from, to) {
  1426. var targetText = string.slice(from, to);
  1427. targetText && tokens.push(targetText);
  1428. }
  1429. };
  1430. /** Creates a regex for the specified text with the given flags. */
  1431. MdHighlightCtrl.prototype.createRegex = function(term, flags) {
  1432. var startFlag = '', endFlag = '';
  1433. var regexTerm = this.sanitizeRegex(term);
  1434. if (flags.indexOf('^') >= 0) startFlag = '^';
  1435. if (flags.indexOf('$') >= 0) endFlag = '$';
  1436. return new RegExp(startFlag + regexTerm + endFlag, flags.replace(/[$^]/g, ''));
  1437. };
  1438. /** Sanitizes a regex by removing all common RegExp identifiers */
  1439. MdHighlightCtrl.prototype.sanitizeRegex = function(term) {
  1440. return term && term.toString().replace(/[\\^$*+?.()|{}[\]]/g, '\\$&');
  1441. };
  1442. MdHighlight['$inject'] = ["$interpolate", "$parse"];angular
  1443. .module('material.components.autocomplete')
  1444. .directive('mdHighlightText', MdHighlight);
  1445. /**
  1446. * @ngdoc directive
  1447. * @name mdHighlightText
  1448. * @module material.components.autocomplete
  1449. *
  1450. * @description
  1451. * The `md-highlight-text` directive allows you to specify text that should be highlighted within
  1452. * an element. Highlighted text will be wrapped in `<span class="highlight"></span>` which can
  1453. * be styled through CSS. Please note that child elements may not be used with this directive.
  1454. *
  1455. * @param {string} md-highlight-text A model to be searched for
  1456. * @param {string=} md-highlight-flags A list of flags (loosely based on JavaScript RexExp flags).
  1457. * #### **Supported flags**:
  1458. * - `g`: Find all matches within the provided text
  1459. * - `i`: Ignore case when searching for matches
  1460. * - `$`: Only match if the text ends with the search term
  1461. * - `^`: Only match if the text begins with the search term
  1462. *
  1463. * @usage
  1464. * <hljs lang="html">
  1465. * <input placeholder="Enter a search term..." ng-model="searchTerm" type="text" />
  1466. * <ul>
  1467. * <li ng-repeat="result in results" md-highlight-text="searchTerm">
  1468. * {{result.text}}
  1469. * </li>
  1470. * </ul>
  1471. * </hljs>
  1472. */
  1473. function MdHighlight ($interpolate, $parse) {
  1474. return {
  1475. terminal: true,
  1476. controller: 'MdHighlightCtrl',
  1477. compile: function mdHighlightCompile(tElement, tAttr) {
  1478. var termExpr = $parse(tAttr.mdHighlightText);
  1479. var unsafeContentExpr = $interpolate(tElement.html());
  1480. return function mdHighlightLink(scope, element, attr, ctrl) {
  1481. ctrl.init(termExpr, unsafeContentExpr);
  1482. };
  1483. }
  1484. };
  1485. }
  1486. })(window, window.angular);