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.

1696 lines
54 KiB

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