autocomplete.js 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  1. /*!
  2. * Angular Material Design
  3. * https://github.com/angular/material
  4. * @license MIT
  5. * v0.11.4
  6. */
  7. goog.provide('ng.material.components.autocomplete');
  8. goog.require('ng.material.components.icon');
  9. goog.require('ng.material.components.virtualRepeat');
  10. goog.require('ng.material.core');
  11. /**
  12. * @ngdoc module
  13. * @name material.components.autocomplete
  14. */
  15. /*
  16. * @see js folder for autocomplete implementation
  17. */
  18. angular.module('material.components.autocomplete', [
  19. 'material.core',
  20. 'material.components.icon',
  21. 'material.components.virtualRepeat'
  22. ]);
  23. angular
  24. .module('material.components.autocomplete')
  25. .controller('MdAutocompleteCtrl', MdAutocompleteCtrl);
  26. var ITEM_HEIGHT = 41,
  27. MAX_HEIGHT = 5.5 * ITEM_HEIGHT,
  28. MENU_PADDING = 8;
  29. function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, $window,
  30. $animate, $rootElement, $attrs, $q) {
  31. //-- private variables
  32. var ctrl = this,
  33. itemParts = $scope.itemsExpr.split(/ in /i),
  34. itemExpr = itemParts[ 1 ],
  35. elements = null,
  36. cache = {},
  37. noBlur = false,
  38. selectedItemWatchers = [],
  39. hasFocus = false,
  40. lastCount = 0;
  41. //-- public variables with handlers
  42. defineProperty('hidden', handleHiddenChange, true);
  43. //-- public variables
  44. ctrl.scope = $scope;
  45. ctrl.parent = $scope.$parent;
  46. ctrl.itemName = itemParts[ 0 ];
  47. ctrl.matches = [];
  48. ctrl.loading = false;
  49. ctrl.hidden = true;
  50. ctrl.index = null;
  51. ctrl.messages = [];
  52. ctrl.id = $mdUtil.nextUid();
  53. ctrl.isDisabled = null;
  54. ctrl.isRequired = null;
  55. ctrl.hasNotFound = false;
  56. //-- public methods
  57. ctrl.keydown = keydown;
  58. ctrl.blur = blur;
  59. ctrl.focus = focus;
  60. ctrl.clear = clearValue;
  61. ctrl.select = select;
  62. ctrl.listEnter = onListEnter;
  63. ctrl.listLeave = onListLeave;
  64. ctrl.mouseUp = onMouseup;
  65. ctrl.getCurrentDisplayValue = getCurrentDisplayValue;
  66. ctrl.registerSelectedItemWatcher = registerSelectedItemWatcher;
  67. ctrl.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher;
  68. ctrl.notFoundVisible = notFoundVisible;
  69. ctrl.loadingIsVisible = loadingIsVisible;
  70. return init();
  71. //-- initialization methods
  72. /**
  73. * Initialize the controller, setup watchers, gather elements
  74. */
  75. function init () {
  76. $mdUtil.initOptionalProperties($scope, $attrs, { searchText: null, selectedItem: null });
  77. $mdTheming($element);
  78. configureWatchers();
  79. $mdUtil.nextTick(function () {
  80. gatherElements();
  81. moveDropdown();
  82. focusElement();
  83. $element.on('focus', focusElement);
  84. });
  85. }
  86. /**
  87. * Calculates the dropdown's position and applies the new styles to the menu element
  88. * @returns {*}
  89. */
  90. function positionDropdown () {
  91. if (!elements) return $mdUtil.nextTick(positionDropdown, false, $scope);
  92. var hrect = elements.wrap.getBoundingClientRect(),
  93. vrect = elements.snap.getBoundingClientRect(),
  94. root = elements.root.getBoundingClientRect(),
  95. top = vrect.bottom - root.top,
  96. bot = root.bottom - vrect.top,
  97. left = hrect.left - root.left,
  98. width = hrect.width,
  99. styles = {
  100. left: left + 'px',
  101. minWidth: width + 'px',
  102. maxWidth: Math.max(hrect.right - root.left, root.right - hrect.left) - MENU_PADDING + 'px'
  103. };
  104. if (top > bot && root.height - hrect.bottom - MENU_PADDING < MAX_HEIGHT) {
  105. styles.top = 'auto';
  106. styles.bottom = bot + 'px';
  107. styles.maxHeight = Math.min(MAX_HEIGHT, hrect.top - root.top - MENU_PADDING) + 'px';
  108. } else {
  109. styles.top = top + 'px';
  110. styles.bottom = 'auto';
  111. styles.maxHeight = Math.min(MAX_HEIGHT, root.bottom - hrect.bottom - MENU_PADDING) + 'px';
  112. }
  113. elements.$.scrollContainer.css(styles);
  114. $mdUtil.nextTick(correctHorizontalAlignment, false);
  115. /**
  116. * Makes sure that the menu doesn't go off of the screen on either side.
  117. */
  118. function correctHorizontalAlignment () {
  119. var dropdown = elements.scrollContainer.getBoundingClientRect(),
  120. styles = {};
  121. if (dropdown.right > root.right - MENU_PADDING) {
  122. styles.left = (hrect.right - dropdown.width) + 'px';
  123. }
  124. elements.$.scrollContainer.css(styles);
  125. }
  126. }
  127. /**
  128. * Moves the dropdown menu to the body tag in order to avoid z-index and overflow issues.
  129. */
  130. function moveDropdown () {
  131. if (!elements.$.root.length) return;
  132. $mdTheming(elements.$.scrollContainer);
  133. elements.$.scrollContainer.detach();
  134. elements.$.root.append(elements.$.scrollContainer);
  135. if ($animate.pin) $animate.pin(elements.$.scrollContainer, $rootElement);
  136. }
  137. /**
  138. * Sends focus to the input element.
  139. */
  140. function focusElement () {
  141. if ($scope.autofocus) elements.input.focus();
  142. }
  143. /**
  144. * Sets up any watchers used by autocomplete
  145. */
  146. function configureWatchers () {
  147. var wait = parseInt($scope.delay, 10) || 0;
  148. $attrs.$observe('disabled', function (value) { ctrl.isDisabled = value; });
  149. $attrs.$observe('required', function (value) { ctrl.isRequired = value !== null; });
  150. $scope.$watch('searchText', wait ? $mdUtil.debounce(handleSearchText, wait) : handleSearchText);
  151. $scope.$watch('selectedItem', selectedItemChange);
  152. angular.element($window).on('resize', positionDropdown);
  153. $scope.$on('$destroy', cleanup);
  154. }
  155. /**
  156. * Removes any events or leftover elements created by this controller
  157. */
  158. function cleanup () {
  159. angular.element($window).off('resize', positionDropdown);
  160. if ( elements ){
  161. var items = 'ul scroller scrollContainer input'.split(' ');
  162. angular.forEach(items, function(key){
  163. elements.$[key].remove();
  164. });
  165. }
  166. }
  167. /**
  168. * Gathers all of the elements needed for this controller
  169. */
  170. function gatherElements () {
  171. elements = {
  172. main: $element[0],
  173. scrollContainer: $element[0].getElementsByClassName('md-virtual-repeat-container')[0],
  174. scroller: $element[0].getElementsByClassName('md-virtual-repeat-scroller')[0],
  175. ul: $element.find('ul')[0],
  176. input: $element.find('input')[0],
  177. wrap: $element.find('md-autocomplete-wrap')[0],
  178. root: document.body
  179. };
  180. elements.li = elements.ul.getElementsByTagName('li');
  181. elements.snap = getSnapTarget();
  182. elements.$ = getAngularElements(elements);
  183. }
  184. /**
  185. * Finds the element that the menu will base its position on
  186. * @returns {*}
  187. */
  188. function getSnapTarget () {
  189. for (var element = $element; element.length; element = element.parent()) {
  190. if (angular.isDefined(element.attr('md-autocomplete-snap'))) return element[ 0 ];
  191. }
  192. return elements.wrap;
  193. }
  194. /**
  195. * Gathers angular-wrapped versions of each element
  196. * @param elements
  197. * @returns {{}}
  198. */
  199. function getAngularElements (elements) {
  200. var obj = {};
  201. for (var key in elements) {
  202. if (elements.hasOwnProperty(key)) obj[ key ] = angular.element(elements[ key ]);
  203. }
  204. return obj;
  205. }
  206. //-- event/change handlers
  207. /**
  208. * Handles changes to the `hidden` property.
  209. * @param hidden
  210. * @param oldHidden
  211. */
  212. function handleHiddenChange (hidden, oldHidden) {
  213. if (!hidden && oldHidden) {
  214. positionDropdown();
  215. if (elements) {
  216. $mdUtil.nextTick(function () {
  217. $mdUtil.disableScrollAround(elements.ul);
  218. }, false, $scope);
  219. }
  220. } else if (hidden && !oldHidden) {
  221. $mdUtil.nextTick(function () {
  222. $mdUtil.enableScrolling();
  223. }, false, $scope);
  224. }
  225. }
  226. /**
  227. * When the user mouses over the dropdown menu, ignore blur events.
  228. */
  229. function onListEnter () {
  230. noBlur = true;
  231. }
  232. /**
  233. * When the user's mouse leaves the menu, blur events may hide the menu again.
  234. */
  235. function onListLeave () {
  236. noBlur = false;
  237. ctrl.hidden = shouldHide();
  238. }
  239. /**
  240. * When the mouse button is released, send focus back to the input field.
  241. */
  242. function onMouseup () {
  243. elements.input.focus();
  244. }
  245. /**
  246. * Handles changes to the selected item.
  247. * @param selectedItem
  248. * @param previousSelectedItem
  249. */
  250. function selectedItemChange (selectedItem, previousSelectedItem) {
  251. if (selectedItem) {
  252. getDisplayValue(selectedItem).then(function (val) {
  253. $scope.searchText = val;
  254. handleSelectedItemChange(selectedItem, previousSelectedItem);
  255. });
  256. }
  257. if (selectedItem !== previousSelectedItem) announceItemChange();
  258. }
  259. /**
  260. * Use the user-defined expression to announce changes each time a new item is selected
  261. */
  262. function announceItemChange () {
  263. angular.isFunction($scope.itemChange) && $scope.itemChange(getItemAsNameVal($scope.selectedItem));
  264. }
  265. /**
  266. * Use the user-defined expression to announce changes each time the search text is changed
  267. */
  268. function announceTextChange () {
  269. angular.isFunction($scope.textChange) && $scope.textChange();
  270. }
  271. /**
  272. * Calls any external watchers listening for the selected item. Used in conjunction with
  273. * `registerSelectedItemWatcher`.
  274. * @param selectedItem
  275. * @param previousSelectedItem
  276. */
  277. function handleSelectedItemChange (selectedItem, previousSelectedItem) {
  278. selectedItemWatchers.forEach(function (watcher) { watcher(selectedItem, previousSelectedItem); });
  279. }
  280. /**
  281. * Register a function to be called when the selected item changes.
  282. * @param cb
  283. */
  284. function registerSelectedItemWatcher (cb) {
  285. if (selectedItemWatchers.indexOf(cb) == -1) {
  286. selectedItemWatchers.push(cb);
  287. }
  288. }
  289. /**
  290. * Unregister a function previously registered for selected item changes.
  291. * @param cb
  292. */
  293. function unregisterSelectedItemWatcher (cb) {
  294. var i = selectedItemWatchers.indexOf(cb);
  295. if (i != -1) {
  296. selectedItemWatchers.splice(i, 1);
  297. }
  298. }
  299. /**
  300. * Handles changes to the searchText property.
  301. * @param searchText
  302. * @param previousSearchText
  303. */
  304. function handleSearchText (searchText, previousSearchText) {
  305. ctrl.index = getDefaultIndex();
  306. // do nothing on init
  307. if (searchText === previousSearchText) return;
  308. getDisplayValue($scope.selectedItem).then(function (val) {
  309. // clear selected item if search text no longer matches it
  310. if (searchText !== val) {
  311. $scope.selectedItem = null;
  312. // trigger change event if available
  313. if (searchText !== previousSearchText) announceTextChange();
  314. // cancel results if search text is not long enough
  315. if (!isMinLengthMet()) {
  316. ctrl.matches = [];
  317. setLoading(false);
  318. updateMessages();
  319. } else {
  320. handleQuery();
  321. }
  322. }
  323. });
  324. }
  325. /**
  326. * Handles input blur event, determines if the dropdown should hide.
  327. */
  328. function blur () {
  329. if (!noBlur) {
  330. hasFocus = false;
  331. ctrl.hidden = shouldHide();
  332. }
  333. }
  334. function doBlur(forceBlur) {
  335. if (forceBlur) {
  336. noBlur = false;
  337. }
  338. elements.input.blur();
  339. }
  340. /**
  341. * Handles input focus event, determines if the dropdown should show.
  342. */
  343. function focus () {
  344. hasFocus = true;
  345. //-- if searchText is null, let's force it to be a string
  346. if (!angular.isString($scope.searchText)) $scope.searchText = '';
  347. ctrl.hidden = shouldHide();
  348. if (!ctrl.hidden) handleQuery();
  349. }
  350. /**
  351. * Handles keyboard input.
  352. * @param event
  353. */
  354. function keydown (event) {
  355. switch (event.keyCode) {
  356. case $mdConstant.KEY_CODE.DOWN_ARROW:
  357. if (ctrl.loading) return;
  358. event.stopPropagation();
  359. event.preventDefault();
  360. ctrl.index = Math.min(ctrl.index + 1, ctrl.matches.length - 1);
  361. updateScroll();
  362. updateMessages();
  363. break;
  364. case $mdConstant.KEY_CODE.UP_ARROW:
  365. if (ctrl.loading) return;
  366. event.stopPropagation();
  367. event.preventDefault();
  368. ctrl.index = ctrl.index < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1);
  369. updateScroll();
  370. updateMessages();
  371. break;
  372. case $mdConstant.KEY_CODE.TAB:
  373. case $mdConstant.KEY_CODE.ENTER:
  374. if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
  375. event.stopPropagation();
  376. event.preventDefault();
  377. select(ctrl.index);
  378. break;
  379. case $mdConstant.KEY_CODE.ESCAPE:
  380. event.stopPropagation();
  381. event.preventDefault();
  382. clearValue();
  383. // Force the component to blur if they hit escape
  384. doBlur(true);
  385. break;
  386. default:
  387. }
  388. }
  389. //-- getters
  390. /**
  391. * Returns the minimum length needed to display the dropdown.
  392. * @returns {*}
  393. */
  394. function getMinLength () {
  395. return angular.isNumber($scope.minLength) ? $scope.minLength : 1;
  396. }
  397. /**
  398. * Returns the display value for an item.
  399. * @param item
  400. * @returns {*}
  401. */
  402. function getDisplayValue (item) {
  403. return $q.when(getItemText(item) || item);
  404. /**
  405. * Getter function to invoke user-defined expression (in the directive)
  406. * to convert your object to a single string.
  407. */
  408. function getItemText (item) {
  409. return (item && $scope.itemText) ? $scope.itemText(getItemAsNameVal(item)) : null;
  410. }
  411. }
  412. /**
  413. * Returns the locals object for compiling item templates.
  414. * @param item
  415. * @returns {{}}
  416. */
  417. function getItemAsNameVal (item) {
  418. if (!item) return undefined;
  419. var locals = {};
  420. if (ctrl.itemName) locals[ ctrl.itemName ] = item;
  421. return locals;
  422. }
  423. /**
  424. * Returns the default index based on whether or not autoselect is enabled.
  425. * @returns {number}
  426. */
  427. function getDefaultIndex () {
  428. return $scope.autoselect ? 0 : -1;
  429. }
  430. /**
  431. * Sets the loading parameter and updates the hidden state.
  432. * @param value {boolean} Whether or not the component is currently loading.
  433. */
  434. function setLoading(value) {
  435. if (ctrl.loading != value) {
  436. ctrl.loading = value;
  437. }
  438. // Always refresh the hidden variable as something else might have changed
  439. ctrl.hidden = shouldHide();
  440. }
  441. /**
  442. * Determines if the menu should be hidden.
  443. * @returns {boolean}
  444. */
  445. function shouldHide () {
  446. if ((ctrl.loading && !hasMatches()) || hasSelection() || !hasFocus) {
  447. return true;
  448. }
  449. return !shouldShow();
  450. }
  451. /**
  452. * Determines if the menu should be shown.
  453. * @returns {boolean}
  454. */
  455. function shouldShow() {
  456. return (isMinLengthMet() && hasMatches()) || notFoundVisible();
  457. }
  458. /**
  459. * Returns true if the search text has matches.
  460. * @returns {boolean}
  461. */
  462. function hasMatches() {
  463. return ctrl.matches.length ? true : false;
  464. }
  465. /**
  466. * Returns true if the autocomplete has a valid selection.
  467. * @returns {boolean}
  468. */
  469. function hasSelection() {
  470. return ctrl.scope.selectedItem ? true : false;
  471. }
  472. /**
  473. * Returns true if the loading indicator is, or should be, visible.
  474. * @returns {boolean}
  475. */
  476. function loadingIsVisible() {
  477. return ctrl.loading && !hasSelection();
  478. }
  479. /**
  480. * Returns the display value of the current item.
  481. * @returns {*}
  482. */
  483. function getCurrentDisplayValue () {
  484. return getDisplayValue(ctrl.matches[ ctrl.index ]);
  485. }
  486. /**
  487. * Determines if the minimum length is met by the search text.
  488. * @returns {*}
  489. */
  490. function isMinLengthMet () {
  491. return ($scope.searchText || '').length >= getMinLength();
  492. }
  493. //-- actions
  494. /**
  495. * Defines a public property with a handler and a default value.
  496. * @param key
  497. * @param handler
  498. * @param value
  499. */
  500. function defineProperty (key, handler, value) {
  501. Object.defineProperty(ctrl, key, {
  502. get: function () { return value; },
  503. set: function (newValue) {
  504. var oldValue = value;
  505. value = newValue;
  506. handler(newValue, oldValue);
  507. }
  508. });
  509. }
  510. /**
  511. * Selects the item at the given index.
  512. * @param index
  513. */
  514. function select (index) {
  515. //-- force form to update state for validation
  516. $mdUtil.nextTick(function () {
  517. getDisplayValue(ctrl.matches[ index ]).then(function (val) {
  518. var ngModel = elements.$.input.controller('ngModel');
  519. ngModel.$setViewValue(val);
  520. ngModel.$render();
  521. }).finally(function () {
  522. $scope.selectedItem = ctrl.matches[ index ];
  523. setLoading(false);
  524. });
  525. }, false);
  526. }
  527. /**
  528. * Clears the searchText value and selected item.
  529. */
  530. function clearValue () {
  531. // Set the loading to true so we don't see flashes of content
  532. setLoading(true);
  533. // Reset our variables
  534. ctrl.index = 0;
  535. ctrl.matches = [];
  536. $scope.searchText = '';
  537. // Tell the select to fire and select nothing
  538. select(-1);
  539. // Per http://www.w3schools.com/jsref/event_oninput.asp
  540. var eventObj = document.createEvent('CustomEvent');
  541. eventObj.initCustomEvent('input', true, true, { value: $scope.searchText });
  542. elements.input.dispatchEvent(eventObj);
  543. elements.input.focus();
  544. }
  545. /**
  546. * Fetches the results for the provided search text.
  547. * @param searchText
  548. */
  549. function fetchResults (searchText) {
  550. var items = $scope.$parent.$eval(itemExpr),
  551. term = searchText.toLowerCase();
  552. if (angular.isArray(items)) {
  553. handleResults(items);
  554. } else if (items) {
  555. setLoading(true);
  556. $mdUtil.nextTick(function () {
  557. if (items.success) items.success(handleResults);
  558. if (items.then) items.then(handleResults);
  559. if (items.finally) items.finally(function () {
  560. setLoading(false);
  561. });
  562. },true, $scope);
  563. }
  564. function handleResults (matches) {
  565. cache[ term ] = matches;
  566. if ((searchText || '') !== ($scope.searchText || '')) return; //-- just cache the results if old request
  567. ctrl.matches = matches;
  568. ctrl.hidden = shouldHide();
  569. if ($scope.selectOnMatch) selectItemOnMatch();
  570. updateMessages();
  571. positionDropdown();
  572. }
  573. }
  574. /**
  575. * Updates the ARIA messages
  576. */
  577. function updateMessages () {
  578. getCurrentDisplayValue().then(function (msg) {
  579. ctrl.messages = [ getCountMessage(), msg ];
  580. });
  581. }
  582. /**
  583. * Returns the ARIA message for how many results match the current query.
  584. * @returns {*}
  585. */
  586. function getCountMessage () {
  587. if (lastCount === ctrl.matches.length) return '';
  588. lastCount = ctrl.matches.length;
  589. switch (ctrl.matches.length) {
  590. case 0:
  591. return 'There are no matches available.';
  592. case 1:
  593. return 'There is 1 match available.';
  594. default:
  595. return 'There are ' + ctrl.matches.length + ' matches available.';
  596. }
  597. }
  598. /**
  599. * Makes sure that the focused element is within view.
  600. */
  601. function updateScroll () {
  602. if (!elements.li[0]) return;
  603. var height = elements.li[0].offsetHeight,
  604. top = height * ctrl.index,
  605. bot = top + height,
  606. hgt = elements.scroller.clientHeight,
  607. scrollTop = elements.scroller.scrollTop;
  608. if (top < scrollTop) {
  609. scrollTo(top);
  610. } else if (bot > scrollTop + hgt) {
  611. scrollTo(bot - hgt);
  612. }
  613. }
  614. function scrollTo (offset) {
  615. elements.$.scrollContainer.controller('mdVirtualRepeatContainer').scrollTo(offset);
  616. }
  617. function notFoundVisible () {
  618. var textLength = (ctrl.scope.searchText || '').length;
  619. return ctrl.hasNotFound && !hasMatches() && !ctrl.loading && textLength >= getMinLength() && hasFocus && !hasSelection();
  620. }
  621. /**
  622. * Starts the query to gather the results for the current searchText. Attempts to return cached
  623. * results first, then forwards the process to `fetchResults` if necessary.
  624. */
  625. function handleQuery () {
  626. var searchText = $scope.searchText,
  627. term = searchText.toLowerCase();
  628. //-- if results are cached, pull in cached results
  629. if (!$scope.noCache && cache[ term ]) {
  630. ctrl.matches = cache[ term ];
  631. updateMessages();
  632. } else {
  633. fetchResults(searchText);
  634. }
  635. ctrl.hidden = shouldHide();
  636. }
  637. /**
  638. * If there is only one matching item and the search text matches its display value exactly,
  639. * automatically select that item. Note: This function is only called if the user uses the
  640. * `md-select-on-match` flag.
  641. */
  642. function selectItemOnMatch () {
  643. var searchText = $scope.searchText,
  644. matches = ctrl.matches,
  645. item = matches[ 0 ];
  646. if (matches.length === 1) getDisplayValue(item).then(function (displayValue) {
  647. if (searchText == displayValue) select(0);
  648. });
  649. }
  650. }
  651. MdAutocompleteCtrl.$inject = ["$scope", "$element", "$mdUtil", "$mdConstant", "$mdTheming", "$window", "$animate", "$rootElement", "$attrs", "$q"];
  652. angular
  653. .module('material.components.autocomplete')
  654. .directive('mdAutocomplete', MdAutocomplete);
  655. /**
  656. * @ngdoc directive
  657. * @name mdAutocomplete
  658. * @module material.components.autocomplete
  659. *
  660. * @description
  661. * `<md-autocomplete>` is a special input component with a drop-down of all possible matches to a
  662. * custom query. This component allows you to provide real-time suggestions as the user types
  663. * in the input area.
  664. *
  665. * To start, you will need to specify the required parameters and provide a template for your
  666. * results. The content inside `md-autocomplete` will be treated as a template.
  667. *
  668. * In more complex cases, you may want to include other content such as a message to display when
  669. * no matches were found. You can do this by wrapping your template in `md-item-template` and
  670. * adding a tag for `md-not-found`. An example of this is shown below.
  671. *
  672. * ### Validation
  673. *
  674. * You can use `ng-messages` to include validation the same way that you would normally validate;
  675. * however, if you want to replicate a standard input with a floating label, you will have to
  676. * do the following:
  677. *
  678. * - Make sure that your template is wrapped in `md-item-template`
  679. * - Add your `ng-messages` code inside of `md-autocomplete`
  680. * - Add your validation properties to `md-autocomplete` (ie. `required`)
  681. * - Add a `name` to `md-autocomplete` (to be used on the generated `input`)
  682. *
  683. * There is an example below of how this should look.
  684. *
  685. *
  686. * @param {expression} md-items An expression in the format of `item in items` to iterate over
  687. * matches for your search.
  688. * @param {expression=} md-selected-item-change An expression to be run each time a new item is
  689. * selected
  690. * @param {expression=} md-search-text-change An expression to be run each time the search text
  691. * updates
  692. * @param {expression=} md-search-text A model to bind the search query text to
  693. * @param {object=} md-selected-item A model to bind the selected item to
  694. * @param {expression=} md-item-text An expression that will convert your object to a single string.
  695. * @param {string=} placeholder Placeholder text that will be forwarded to the input.
  696. * @param {boolean=} md-no-cache Disables the internal caching that happens in autocomplete
  697. * @param {boolean=} ng-disabled Determines whether or not to disable the input field
  698. * @param {number=} md-min-length Specifies the minimum length of text before autocomplete will
  699. * make suggestions
  700. * @param {number=} md-delay Specifies the amount of time (in milliseconds) to wait before looking
  701. * for results
  702. * @param {boolean=} md-autofocus If true, will immediately focus the input element
  703. * @param {boolean=} md-autoselect If true, the first item will be selected by default
  704. * @param {string=} md-menu-class This will be applied to the dropdown menu for styling
  705. * @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in
  706. * `md-input-container`
  707. * @param {string=} md-input-name The name attribute given to the input element to be used with
  708. * FormController
  709. * @param {string=} md-input-id An ID to be added to the input element
  710. * @param {number=} md-input-minlength The minimum length for the input's value for validation
  711. * @param {number=} md-input-maxlength The maximum length for the input's value for validation
  712. * @param {boolean=} md-select-on-match When set, autocomplete will automatically select exact
  713. * the item if the search text is an exact match
  714. *
  715. * @usage
  716. * ### Basic Example
  717. * <hljs lang="html">
  718. * <md-autocomplete
  719. * md-selected-item="selectedItem"
  720. * md-search-text="searchText"
  721. * md-items="item in getMatches(searchText)"
  722. * md-item-text="item.display">
  723. * <span md-highlight-text="searchText">{{item.display}}</span>
  724. * </md-autocomplete>
  725. * </hljs>
  726. *
  727. * ### Example with "not found" message
  728. * <hljs lang="html">
  729. * <md-autocomplete
  730. * md-selected-item="selectedItem"
  731. * md-search-text="searchText"
  732. * md-items="item in getMatches(searchText)"
  733. * md-item-text="item.display">
  734. * <md-item-template>
  735. * <span md-highlight-text="searchText">{{item.display}}</span>
  736. * </md-item-template>
  737. * <md-not-found>
  738. * No matches found.
  739. * </md-not-found>
  740. * </md-autocomplete>
  741. * </hljs>
  742. *
  743. * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the
  744. * different parts that make up our component.
  745. *
  746. * ### Example with validation
  747. * <hljs lang="html">
  748. * <form name="autocompleteForm">
  749. * <md-autocomplete
  750. * required
  751. * md-input-name="autocomplete"
  752. * md-selected-item="selectedItem"
  753. * md-search-text="searchText"
  754. * md-items="item in getMatches(searchText)"
  755. * md-item-text="item.display">
  756. * <md-item-template>
  757. * <span md-highlight-text="searchText">{{item.display}}</span>
  758. * </md-item-template>
  759. * <div ng-messages="autocompleteForm.autocomplete.$error">
  760. * <div ng-message="required">This field is required</div>
  761. * </div>
  762. * </md-autocomplete>
  763. * </form>
  764. * </hljs>
  765. *
  766. * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the
  767. * different parts that make up our component.
  768. */
  769. function MdAutocomplete () {
  770. var hasNotFoundTemplate = false;
  771. return {
  772. controller: 'MdAutocompleteCtrl',
  773. controllerAs: '$mdAutocompleteCtrl',
  774. scope: {
  775. inputName: '@mdInputName',
  776. inputMinlength: '@mdInputMinlength',
  777. inputMaxlength: '@mdInputMaxlength',
  778. searchText: '=?mdSearchText',
  779. selectedItem: '=?mdSelectedItem',
  780. itemsExpr: '@mdItems',
  781. itemText: '&mdItemText',
  782. placeholder: '@placeholder',
  783. noCache: '=?mdNoCache',
  784. selectOnMatch: '=?mdSelectOnMatch',
  785. itemChange: '&?mdSelectedItemChange',
  786. textChange: '&?mdSearchTextChange',
  787. minLength: '=?mdMinLength',
  788. delay: '=?mdDelay',
  789. autofocus: '=?mdAutofocus',
  790. floatingLabel: '@?mdFloatingLabel',
  791. autoselect: '=?mdAutoselect',
  792. menuClass: '@?mdMenuClass',
  793. inputId: '@?mdInputId'
  794. },
  795. link: function(scope, element, attrs, controller) {
  796. controller.hasNotFound = hasNotFoundTemplate;
  797. },
  798. template: function (element, attr) {
  799. var noItemsTemplate = getNoItemsTemplate(),
  800. itemTemplate = getItemTemplate(),
  801. leftover = element.html(),
  802. tabindex = attr.tabindex;
  803. if (noItemsTemplate) {
  804. hasNotFoundTemplate = true;
  805. }
  806. if (attr.hasOwnProperty('tabindex')) {
  807. element.attr('tabindex', '-1');
  808. }
  809. return '\
  810. <md-autocomplete-wrap\
  811. layout="row"\
  812. ng-class="{ \'md-whiteframe-z1\': !floatingLabel, \'md-menu-showing\': !$mdAutocompleteCtrl.hidden }"\
  813. role="listbox">\
  814. ' + getInputElement() + '\
  815. <md-progress-linear\
  816. ng-if="$mdAutocompleteCtrl.loadingIsVisible()"\
  817. md-mode="indeterminate"></md-progress-linear>\
  818. <md-virtual-repeat-container\
  819. md-auto-shrink\
  820. md-auto-shrink-min="1"\
  821. ng-hide="$mdAutocompleteCtrl.hidden"\
  822. class="md-autocomplete-suggestions-container md-whiteframe-z1"\
  823. role="presentation">\
  824. <ul class="md-autocomplete-suggestions"\
  825. ng-class="::menuClass"\
  826. id="ul-{{$mdAutocompleteCtrl.id}}"\
  827. ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
  828. ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
  829. ng-mouseup="$mdAutocompleteCtrl.mouseUp()">\
  830. <li md-virtual-repeat="item in $mdAutocompleteCtrl.matches"\
  831. ng-class="{ selected: $index === $mdAutocompleteCtrl.index }"\
  832. ng-click="$mdAutocompleteCtrl.select($index)"\
  833. md-extra-name="$mdAutocompleteCtrl.itemName">\
  834. ' + itemTemplate + '\
  835. </li>' + noItemsTemplate + '\
  836. </ul>\
  837. </md-virtual-repeat-container>\
  838. </md-autocomplete-wrap>\
  839. <aria-status\
  840. class="md-visually-hidden"\
  841. role="status"\
  842. aria-live="assertive">\
  843. <p ng-repeat="message in $mdAutocompleteCtrl.messages track by $index" ng-if="message">{{message}}</p>\
  844. </aria-status>';
  845. function getItemTemplate() {
  846. var templateTag = element.find('md-item-template').detach(),
  847. html = templateTag.length ? templateTag.html() : element.html();
  848. if (!templateTag.length) element.empty();
  849. return '<md-autocomplete-parent-scope md-autocomplete-replace>' + html + '</md-autocomplete-parent-scope>';
  850. }
  851. function getNoItemsTemplate() {
  852. var templateTag = element.find('md-not-found').detach(),
  853. template = templateTag.length ? templateTag.html() : '';
  854. return template
  855. ? '<li ng-if="$mdAutocompleteCtrl.notFoundVisible()"\
  856. md-autocomplete-parent-scope>' + template + '</li>'
  857. : '';
  858. }
  859. function getInputElement () {
  860. if (attr.mdFloatingLabel) {
  861. return '\
  862. <md-input-container flex ng-if="floatingLabel">\
  863. <label>{{floatingLabel}}</label>\
  864. <input type="search"\
  865. ' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
  866. id="{{ inputId || \'fl-input-\' + $mdAutocompleteCtrl.id }}"\
  867. name="{{inputName}}"\
  868. autocomplete="off"\
  869. ng-required="$mdAutocompleteCtrl.isRequired"\
  870. ng-minlength="inputMinlength"\
  871. ng-maxlength="inputMaxlength"\
  872. ng-disabled="$mdAutocompleteCtrl.isDisabled"\
  873. ng-model="$mdAutocompleteCtrl.scope.searchText"\
  874. ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
  875. ng-blur="$mdAutocompleteCtrl.blur()"\
  876. ng-focus="$mdAutocompleteCtrl.focus()"\
  877. aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
  878. aria-label="{{floatingLabel}}"\
  879. aria-autocomplete="list"\
  880. aria-haspopup="true"\
  881. aria-activedescendant=""\
  882. aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
  883. <div md-autocomplete-parent-scope md-autocomplete-replace>' + leftover + '</div>\
  884. </md-input-container>';
  885. } else {
  886. return '\
  887. <input flex type="search"\
  888. ' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
  889. id="{{ inputId || \'input-\' + $mdAutocompleteCtrl.id }}"\
  890. name="{{inputName}}"\
  891. ng-if="!floatingLabel"\
  892. autocomplete="off"\
  893. ng-required="$mdAutocompleteCtrl.isRequired"\
  894. ng-disabled="$mdAutocompleteCtrl.isDisabled"\
  895. ng-model="$mdAutocompleteCtrl.scope.searchText"\
  896. ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
  897. ng-blur="$mdAutocompleteCtrl.blur()"\
  898. ng-focus="$mdAutocompleteCtrl.focus()"\
  899. placeholder="{{placeholder}}"\
  900. aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
  901. aria-label="{{placeholder}}"\
  902. aria-autocomplete="list"\
  903. aria-haspopup="true"\
  904. aria-activedescendant=""\
  905. aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
  906. <button\
  907. type="button"\
  908. tabindex="-1"\
  909. ng-if="$mdAutocompleteCtrl.scope.searchText && !$mdAutocompleteCtrl.isDisabled"\
  910. ng-click="$mdAutocompleteCtrl.clear()">\
  911. <md-icon md-svg-icon="md-close"></md-icon>\
  912. <span class="md-visually-hidden">Clear</span>\
  913. </button>\
  914. ';
  915. }
  916. }
  917. }
  918. };
  919. }
  920. angular
  921. .module('material.components.autocomplete')
  922. .directive('mdAutocompleteParentScope', MdAutocompleteItemScopeDirective);
  923. function MdAutocompleteItemScopeDirective($compile, $mdUtil) {
  924. return {
  925. restrict: 'AE',
  926. link: postLink,
  927. terminal: true
  928. };
  929. function postLink(scope, element, attr) {
  930. var ctrl = scope.$mdAutocompleteCtrl;
  931. var newScope = ctrl.parent.$new();
  932. var itemName = ctrl.itemName;
  933. // Watch for changes to our scope's variables and copy them to the new scope
  934. watchVariable('$index', '$index');
  935. watchVariable('item', itemName);
  936. // Recompile the contents with the new/modified scope
  937. $compile(element.contents())(newScope);
  938. // Replace it if required
  939. if (attr.hasOwnProperty('mdAutocompleteReplace')) {
  940. element.after(element.contents());
  941. element.remove();
  942. }
  943. /**
  944. * Creates a watcher for variables that are copied from the parent scope
  945. * @param variable
  946. * @param alias
  947. */
  948. function watchVariable(variable, alias) {
  949. newScope[alias] = scope[variable];
  950. scope.$watch(variable, function(value) {
  951. $mdUtil.nextTick(function() {
  952. newScope[alias] = value;
  953. });
  954. });
  955. }
  956. }
  957. }
  958. MdAutocompleteItemScopeDirective.$inject = ["$compile", "$mdUtil"];
  959. angular
  960. .module('material.components.autocomplete')
  961. .controller('MdHighlightCtrl', MdHighlightCtrl);
  962. function MdHighlightCtrl ($scope, $element, $attrs) {
  963. this.init = init;
  964. function init (termExpr, unsafeTextExpr) {
  965. var text = null,
  966. regex = null,
  967. flags = $attrs.mdHighlightFlags || '',
  968. watcher = $scope.$watch(function($scope) {
  969. return {
  970. term: termExpr($scope),
  971. unsafeText: unsafeTextExpr($scope)
  972. };
  973. }, function (state, prevState) {
  974. if (text === null || state.unsafeText !== prevState.unsafeText) {
  975. text = angular.element('<div>').text(state.unsafeText).html()
  976. }
  977. if (regex === null || state.term !== prevState.term) {
  978. regex = getRegExp(state.term, flags);
  979. }
  980. $element.html(text.replace(regex, '<span class="highlight">$&</span>'));
  981. }, true);
  982. $element.on('$destroy', function () { watcher(); });
  983. }
  984. function sanitize (term) {
  985. return term && term.replace(/[\\\^\$\*\+\?\.\(\)\|\{}\[\]]/g, '\\$&');
  986. }
  987. function getRegExp (text, flags) {
  988. var str = '';
  989. if (flags.indexOf('^') >= 1) str += '^';
  990. str += text;
  991. if (flags.indexOf('$') >= 1) str += '$';
  992. return new RegExp(sanitize(str), flags.replace(/[\$\^]/g, ''));
  993. }
  994. }
  995. MdHighlightCtrl.$inject = ["$scope", "$element", "$attrs"];
  996. angular
  997. .module('material.components.autocomplete')
  998. .directive('mdHighlightText', MdHighlight);
  999. /**
  1000. * @ngdoc directive
  1001. * @name mdHighlightText
  1002. * @module material.components.autocomplete
  1003. *
  1004. * @description
  1005. * The `md-highlight-text` directive allows you to specify text that should be highlighted within
  1006. * an element. Highlighted text will be wrapped in `<span class="highlight"></span>` which can
  1007. * be styled through CSS. Please note that child elements may not be used with this directive.
  1008. *
  1009. * @param {string} md-highlight-text A model to be searched for
  1010. * @param {string=} md-highlight-flags A list of flags (loosely based on JavaScript RexExp flags).
  1011. * #### **Supported flags**:
  1012. * - `g`: Find all matches within the provided text
  1013. * - `i`: Ignore case when searching for matches
  1014. * - `$`: Only match if the text ends with the search term
  1015. * - `^`: Only match if the text begins with the search term
  1016. *
  1017. * @usage
  1018. * <hljs lang="html">
  1019. * <input placeholder="Enter a search term..." ng-model="searchTerm" type="text" />
  1020. * <ul>
  1021. * <li ng-repeat="result in results" md-highlight-text="searchTerm">
  1022. * {{result.text}}
  1023. * </li>
  1024. * </ul>
  1025. * </hljs>
  1026. */
  1027. function MdHighlight ($interpolate, $parse) {
  1028. return {
  1029. terminal: true,
  1030. controller: 'MdHighlightCtrl',
  1031. compile: function mdHighlightCompile(tElement, tAttr) {
  1032. var termExpr = $parse(tAttr.mdHighlightText);
  1033. var unsafeTextExpr = $interpolate(tElement.html());
  1034. return function mdHighlightLink(scope, element, attr, ctrl) {
  1035. ctrl.init(termExpr, unsafeTextExpr);
  1036. };
  1037. }
  1038. };
  1039. }
  1040. MdHighlight.$inject = ["$interpolate", "$parse"];
  1041. ng.material.components.autocomplete = angular.module("material.components.autocomplete");