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.

1509 lines
49 KiB

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