You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1510 lines
49 KiB

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