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.

1697 lines
55 KiB

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