2 * Angular Material Design
3 * https://github.com/angular/material
7 goog.provide('ng.material.components.autocomplete');
8 goog.require('ng.material.components.icon');
9 goog.require('ng.material.core');
12 * @name material.components.autocomplete
15 * @see js folder for autocomplete implementation
17 angular.module('material.components.autocomplete', [
19 'material.components.icon'
23 .module('material.components.autocomplete')
24 .controller('MdAutocompleteCtrl', MdAutocompleteCtrl);
27 MAX_HEIGHT = 5.5 * ITEM_HEIGHT,
30 function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $mdTheming, $window, $animate, $rootElement) {
32 //-- private variables
35 itemParts = $scope.itemsExpr.split(/ in /i),
36 itemExpr = itemParts[1],
41 selectedItemWatchers = [],
48 self.parent = $scope.$parent;
49 self.itemName = itemParts[0];
55 self.id = $mdUtil.nextUid();
59 self.keydown = keydown;
62 self.clear = clearValue;
64 self.getCurrentDisplayValue = getCurrentDisplayValue;
65 self.registerSelectedItemWatcher = registerSelectedItemWatcher;
66 self.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher;
68 self.listEnter = function () { noBlur = true; };
69 self.listLeave = function () {
71 if (!hasFocus) self.hidden = true;
73 self.mouseUp = function () { elements.input.focus(); };
77 //-- initialization methods
81 $timeout(function () {
88 function positionDropdown () {
89 if (!elements) return $timeout(positionDropdown, 0, false);
90 var hrect = elements.wrap.getBoundingClientRect(),
91 vrect = elements.snap.getBoundingClientRect(),
92 root = elements.root.getBoundingClientRect(),
93 top = vrect.bottom - root.top,
94 bot = root.bottom - vrect.top,
95 left = hrect.left - root.left,
99 minWidth: width + 'px',
100 maxWidth: Math.max(hrect.right - root.left, root.right - hrect.left) - MENU_PADDING + 'px'
102 if (top > bot && root.height - hrect.bottom - MENU_PADDING < MAX_HEIGHT) {
104 styles.bottom = bot + 'px';
105 styles.maxHeight = Math.min(MAX_HEIGHT, hrect.top - root.top - MENU_PADDING) + 'px';
107 styles.top = top + 'px';
108 styles.bottom = 'auto';
109 styles.maxHeight = Math.min(MAX_HEIGHT, root.bottom - hrect.bottom - MENU_PADDING) + 'px';
111 elements.$.ul.css(styles);
112 $timeout(correctHorizontalAlignment, 0, false);
114 function correctHorizontalAlignment () {
115 var dropdown = elements.ul.getBoundingClientRect(),
117 if (dropdown.right > root.right - MENU_PADDING) {
118 styles.left = (hrect.right - dropdown.width) + 'px';
120 elements.$.ul.css(styles);
124 function moveDropdown () {
125 if (!elements.$.root.length) return;
126 $mdTheming(elements.$.ul);
127 elements.$.ul.detach();
128 elements.$.root.append(elements.$.ul);
129 if ($animate.pin) $animate.pin(elements.$.ul, $rootElement);
132 function focusElement () {
133 if ($scope.autofocus) elements.input.focus();
136 function configureWatchers () {
137 var wait = parseInt($scope.delay, 10) || 0;
138 $scope.$watch('searchText', wait
139 ? $mdUtil.debounce(handleSearchText, wait)
141 registerSelectedItemWatcher(selectedItemChange);
142 $scope.$watch('selectedItem', handleSelectedItemChange);
143 $scope.$watch('$mdAutocompleteCtrl.hidden', function (hidden, oldHidden) {
144 if (!hidden && oldHidden) positionDropdown();
146 angular.element($window).on('resize', positionDropdown);
147 $scope.$on('$destroy', cleanup);
150 function cleanup () {
151 elements.$.ul.remove();
154 function gatherElements () {
157 ul: $element.find('ul')[0],
158 input: $element.find('input')[0],
159 wrap: $element.find('md-autocomplete-wrap')[0],
162 elements.li = elements.ul.getElementsByTagName('li');
163 elements.snap = getSnapTarget();
164 elements.$ = getAngularElements(elements);
167 function getSnapTarget () {
168 for (var element = $element; element.length; element = element.parent()) {
169 if (angular.isDefined(element.attr('md-autocomplete-snap'))) return element[0];
171 return elements.wrap;
174 function getAngularElements (elements) {
176 for (var key in elements) {
177 obj[key] = angular.element(elements[key]);
182 //-- event/change handlers
184 function selectedItemChange (selectedItem, previousSelectedItem) {
186 $scope.searchText = getDisplayValue(selectedItem);
188 if ($scope.itemChange && selectedItem !== previousSelectedItem)
189 $scope.itemChange(getItemScope(selectedItem));
192 function handleSelectedItemChange(selectedItem, previousSelectedItem) {
193 for (var i = 0; i < selectedItemWatchers.length; ++i) {
194 selectedItemWatchers[i](selectedItem, previousSelectedItem);
199 * Register a function to be called when the selected item changes.
202 function registerSelectedItemWatcher(cb) {
203 if (selectedItemWatchers.indexOf(cb) == -1) {
204 selectedItemWatchers.push(cb);
209 * Unregister a function previously registered for selected item changes.
212 function unregisterSelectedItemWatcher(cb) {
213 var i = selectedItemWatchers.indexOf(cb);
215 selectedItemWatchers.splice(i, 1);
219 function handleSearchText (searchText, previousSearchText) {
220 self.index = getDefaultIndex();
221 //-- do nothing on init
222 if (searchText === previousSearchText) return;
223 //-- clear selected item if search text no longer matches it
224 if (searchText !== getDisplayValue($scope.selectedItem)) $scope.selectedItem = null;
226 //-- trigger change event if available
227 if ($scope.textChange && searchText !== previousSearchText)
228 $scope.textChange(getItemScope($scope.selectedItem));
229 //-- cancel results if search text is not long enough
230 if (!isMinLengthMet()) {
231 self.loading = false;
233 self.hidden = shouldHide();
242 if (!noBlur) self.hidden = true;
247 //-- if searchText is null, let's force it to be a string
248 if (!angular.isString($scope.searchText)) $scope.searchText = '';
249 if ($scope.minLength > 0) return;
250 self.hidden = shouldHide();
251 if (!self.hidden) handleQuery();
254 function keydown (event) {
255 switch (event.keyCode) {
256 case $mdConstant.KEY_CODE.DOWN_ARROW:
257 if (self.loading) return;
258 event.preventDefault();
259 self.index = Math.min(self.index + 1, self.matches.length - 1);
263 case $mdConstant.KEY_CODE.UP_ARROW:
264 if (self.loading) return;
265 event.preventDefault();
266 self.index = self.index < 0 ? self.matches.length - 1 : Math.max(0, self.index - 1);
270 case $mdConstant.KEY_CODE.TAB:
271 case $mdConstant.KEY_CODE.ENTER:
272 if (self.hidden || self.loading || self.index < 0 || self.matches.length < 1) return;
273 event.preventDefault();
276 case $mdConstant.KEY_CODE.ESCAPE:
279 self.index = getDefaultIndex();
287 function getMinLength () {
288 return angular.isNumber($scope.minLength) ? $scope.minLength : 1;
291 function getDisplayValue (item) {
292 return (item && $scope.itemText) ? $scope.itemText(getItemScope(item)) : item;
295 function getItemScope (item) {
298 if (self.itemName) locals[self.itemName] = item;
302 function getDefaultIndex () {
303 return $scope.autoselect ? 0 : -1;
306 function shouldHide () {
307 if (!isMinLengthMet()) return true;
310 function getCurrentDisplayValue () {
311 return getDisplayValue(self.matches[self.index]);
314 function isMinLengthMet () {
315 return $scope.searchText && $scope.searchText.length >= getMinLength();
320 function select (index) {
321 $scope.selectedItem = self.matches[index];
325 //-- force form to update state for validation
326 $timeout(function () {
327 elements.$.input.controller('ngModel').$setViewValue(getDisplayValue($scope.selectedItem) || $scope.searchText);
332 function clearValue () {
333 $scope.searchText = '';
336 // Per http://www.w3schools.com/jsref/event_oninput.asp
337 var eventObj = document.createEvent('CustomEvent');
338 eventObj.initCustomEvent('input', true, true, {value: $scope.searchText});
339 elements.input.dispatchEvent(eventObj);
341 elements.input.focus();
344 function fetchResults (searchText) {
345 var items = $scope.$parent.$eval(itemExpr),
346 term = searchText.toLowerCase();
347 if (angular.isArray(items)) {
348 handleResults(items);
351 if (items.success) items.success(handleResults);
352 if (items.then) items.then(handleResults);
353 if (items.error) items.error(function () { self.loading = false; });
355 function handleResults (matches) {
356 cache[term] = matches;
357 if (searchText !== $scope.searchText) return; //-- just cache the results if old request
358 self.loading = false;
360 self.matches = matches;
361 self.hidden = shouldHide();
367 function updateMessages () {
368 self.messages = [ getCountMessage(), getCurrentDisplayValue() ];
371 function getCountMessage () {
372 if (lastCount === self.matches.length) return '';
373 lastCount = self.matches.length;
374 switch (self.matches.length) {
375 case 0: return 'There are no matches available.';
376 case 1: return 'There is 1 match available.';
377 default: return 'There are ' + self.matches.length + ' matches available.';
381 function updateScroll () {
382 if (!elements.li[self.index]) return;
383 var li = elements.li[self.index],
385 bot = top + li.offsetHeight,
386 hgt = elements.ul.clientHeight;
387 if (top < elements.ul.scrollTop) {
388 elements.ul.scrollTop = top;
389 } else if (bot > elements.ul.scrollTop + hgt) {
390 elements.ul.scrollTop = bot - hgt;
394 function handleQuery () {
395 var searchText = $scope.searchText,
396 term = searchText.toLowerCase();
397 //-- cancel promise if a promise is in progress
398 if (promise && promise.cancel) {
402 //-- if results are cached, pull in cached results
403 if (!$scope.noCache && cache[term]) {
404 self.matches = cache[term];
407 fetchResults(searchText);
409 if (hasFocus) self.hidden = shouldHide();
413 MdAutocompleteCtrl.$inject = ["$scope", "$element", "$mdUtil", "$mdConstant", "$timeout", "$mdTheming", "$window", "$animate", "$rootElement"];
416 .module('material.components.autocomplete')
417 .directive('mdAutocomplete', MdAutocomplete);
421 * @name mdAutocomplete
422 * @module material.components.autocomplete
425 * `<md-autocomplete>` is a special input component with a drop-down of all possible matches to a custom query.
426 * This component allows you to provide real-time suggestions as the user types in the input area.
428 * To start, you will need to specify the required parameters and provide a template for your results.
429 * The content inside `md-autocomplete` will be treated as a template.
431 * In more complex cases, you may want to include other content such as a message to display when
432 * no matches were found. You can do this by wrapping your template in `md-item-template` and adding
433 * a tag for `md-not-found`. An example of this is shown below.
436 * You can use `ng-messages` to include validation the same way that you would normally validate;
437 * however, if you want to replicate a standard input with a floating label, you will have to do the
440 * - Make sure that your template is wrapped in `md-item-template`
441 * - Add your `ng-messages` code inside of `md-autocomplete`
442 * - Add your validation properties to `md-autocomplete` (ie. `required`)
443 * - Add a `name` to `md-autocomplete` (to be used on the generated `input`)
445 * There is an example below of how this should look.
448 * @param {expression} md-items An expression in the format of `item in items` to iterate over matches for your search.
449 * @param {expression=} md-selected-item-change An expression to be run each time a new item is selected
450 * @param {expression=} md-search-text-change An expression to be run each time the search text updates
451 * @param {string=} md-search-text A model to bind the search query text to
452 * @param {object=} md-selected-item A model to bind the selected item to
453 * @param {string=} md-item-text An expression that will convert your object to a single string.
454 * @param {string=} placeholder Placeholder text that will be forwarded to the input.
455 * @param {boolean=} md-no-cache Disables the internal caching that happens in autocomplete
456 * @param {boolean=} ng-disabled Determines whether or not to disable the input field
457 * @param {number=} md-min-length Specifies the minimum length of text before autocomplete will make suggestions
458 * @param {number=} md-delay Specifies the amount of time (in milliseconds) to wait before looking for results
459 * @param {boolean=} md-autofocus If true, will immediately focus the input element
460 * @param {boolean=} md-autoselect If true, the first item will be selected by default
461 * @param {string=} md-menu-class This will be applied to the dropdown menu for styling
462 * @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in `md-input-container`
468 * md-selected-item="selectedItem"
469 * md-search-text="searchText"
470 * md-items="item in getMatches(searchText)"
471 * md-item-text="item.display">
472 * <span md-highlight-text="searchText">{{item.display}}</span>
476 * ###Example with "not found" message
479 * md-selected-item="selectedItem"
480 * md-search-text="searchText"
481 * md-items="item in getMatches(searchText)"
482 * md-item-text="item.display">
484 * <span md-highlight-text="searchText">{{item.display}}</span>
485 * </md-item-template>
492 * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the different
493 * parts that make up our component.
495 * ### Example with validation
497 * <form name="autocompleteForm">
500 * input-name="autocomplete"
501 * md-selected-item="selectedItem"
502 * md-search-text="searchText"
503 * md-items="item in getMatches(searchText)"
504 * md-item-text="item.display">
506 * <span md-highlight-text="searchText">{{item.display}}</span>
507 * </md-item-template>
508 * <div ng-messages="autocompleteForm.autocomplete.$error">
509 * <div ng-message="required">This field is required</div>
515 * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the different
516 * parts that make up our component.
519 function MdAutocomplete ($mdTheming, $mdUtil) {
521 controller: 'MdAutocompleteCtrl',
522 controllerAs: '$mdAutocompleteCtrl',
525 inputName: '@mdInputName',
526 inputMinlength: '@mdInputMinlength',
527 inputMaxlength: '@mdInputMaxlength',
528 searchText: '=?mdSearchText',
529 selectedItem: '=?mdSelectedItem',
530 itemsExpr: '@mdItems',
531 itemText: '&mdItemText',
532 placeholder: '@placeholder',
533 noCache: '=?mdNoCache',
534 itemChange: '&?mdSelectedItemChange',
535 textChange: '&?mdSearchTextChange',
536 minLength: '=?mdMinLength',
538 autofocus: '=?mdAutofocus',
539 floatingLabel: '@?mdFloatingLabel',
540 autoselect: '=?mdAutoselect',
541 menuClass: '@?mdMenuClass'
543 template: function (element, attr) {
544 var noItemsTemplate = getNoItemsTemplate(),
545 itemTemplate = getItemTemplate(),
546 leftover = element.html();
548 <md-autocomplete-wrap\
550 ng-class="{ \'md-whiteframe-z1\': !floatingLabel }"\
552 ' + getInputElement() + '\
554 ng-if="$mdAutocompleteCtrl.loading"\
555 md-mode="indeterminate"></md-progress-linear>\
556 <ul role="presentation"\
557 class="md-autocomplete-suggestions md-whiteframe-z1 {{menuClass || \'\'}}"\
558 id="ul-{{$mdAutocompleteCtrl.id}}"\
559 ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
560 ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
561 ng-mouseup="$mdAutocompleteCtrl.mouseUp()">\
562 <li ng-repeat="(index, item) in $mdAutocompleteCtrl.matches"\
563 ng-class="{ selected: index === $mdAutocompleteCtrl.index }"\
564 ng-hide="$mdAutocompleteCtrl.hidden"\
565 ng-click="$mdAutocompleteCtrl.select(index)"\
566 md-autocomplete-list-item="$mdAutocompleteCtrl.itemName">\
567 ' + itemTemplate + '\
569 ' + noItemsTemplate + '\
571 </md-autocomplete-wrap>\
573 class="md-visually-hidden"\
575 aria-live="assertive">\
576 <p ng-repeat="message in $mdAutocompleteCtrl.messages" ng-if="message">{{message}}</p>\
579 function getItemTemplate() {
580 var templateTag = element.find('md-item-template').remove(),
581 html = templateTag.length ? templateTag.html() : element.html();
582 if (!templateTag.length) element.empty();
586 function getNoItemsTemplate() {
587 var templateTag = element.find('md-not-found').remove(),
588 template = templateTag.length ? templateTag.html() : '';
590 ? '<li ng-if="!$mdAutocompleteCtrl.matches.length && !$mdAutocompleteCtrl.loading\
591 && !$mdAutocompleteCtrl.hidden"\
592 ng-hide="$mdAutocompleteCtrl.hidden"\
593 md-autocomplete-parent-scope>' + template + '</li>'
598 function getInputElement() {
599 if (attr.mdFloatingLabel) {
601 <md-input-container flex ng-if="floatingLabel">\
602 <label>{{floatingLabel}}</label>\
603 <input type="search"\
604 id="fl-input-{{$mdAutocompleteCtrl.id}}"\
605 name="{{inputName}}"\
607 ng-required="isRequired"\
608 ng-minlength="inputMinlength"\
609 ng-maxlength="inputMaxlength"\
610 ng-disabled="isDisabled"\
611 ng-model="$mdAutocompleteCtrl.scope.searchText"\
612 ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
613 ng-blur="$mdAutocompleteCtrl.blur()"\
614 ng-focus="$mdAutocompleteCtrl.focus()"\
615 aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
616 aria-label="{{floatingLabel}}"\
617 aria-autocomplete="list"\
618 aria-haspopup="true"\
619 aria-activedescendant=""\
620 aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
621 <div md-autocomplete-parent-scope md-autocomplete-replace>' + leftover + '</div>\
622 </md-input-container>';
625 <input flex type="search"\
626 id="input-{{$mdAutocompleteCtrl.id}}"\
627 name="{{inputName}}"\
628 ng-if="!floatingLabel"\
630 ng-required="isRequired"\
631 ng-disabled="isDisabled"\
632 ng-model="$mdAutocompleteCtrl.scope.searchText"\
633 ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
634 ng-blur="$mdAutocompleteCtrl.blur()"\
635 ng-focus="$mdAutocompleteCtrl.focus()"\
636 placeholder="{{placeholder}}"\
637 aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
638 aria-label="{{placeholder}}"\
639 aria-autocomplete="list"\
640 aria-haspopup="true"\
641 aria-activedescendant=""\
642 aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
646 ng-if="$mdAutocompleteCtrl.scope.searchText && !isDisabled"\
647 ng-click="$mdAutocompleteCtrl.clear()">\
648 <md-icon md-svg-icon="md-close"></md-icon>\
649 <span class="md-visually-hidden">Clear</span>\
657 function link (scope, element, attr) {
658 attr.$observe('disabled', function (value) { scope.isDisabled = value; });
659 attr.$observe('required', function (value) { scope.isRequired = value !== null; });
661 $mdUtil.initOptionalProperties(scope, attr, {searchText:null, selectedItem:null} );
666 MdAutocomplete.$inject = ["$mdTheming", "$mdUtil"];
669 .module('material.components.autocomplete')
670 .controller('MdHighlightCtrl', MdHighlightCtrl);
672 function MdHighlightCtrl ($scope, $element, $interpolate) {
677 function init (term) {
678 var unsafeText = $interpolate($element.html())($scope),
679 text = angular.element('<div>').text(unsafeText).html(),
680 flags = $element.attr('md-highlight-flags') || '',
681 watcher = $scope.$watch(term, function (term) {
682 var regex = getRegExp(term, flags),
683 html = text.replace(regex, '<span class="highlight">$&</span>');
686 $element.on('$destroy', function () { watcher(); });
689 function sanitize (term) {
690 if (!term) return term;
691 return term.replace(/[\\\^\$\*\+\?\.\(\)\|\{\}\[\]]/g, '\\$&');
694 function getRegExp (text, flags) {
696 if (flags.indexOf('^') >= 1) str += '^';
698 if (flags.indexOf('$') >= 1) str += '$';
699 return new RegExp(sanitize(str), flags.replace(/[\$\^]/g, ''));
702 MdHighlightCtrl.$inject = ["$scope", "$element", "$interpolate"];
705 .module('material.components.autocomplete')
706 .directive('mdHighlightText', MdHighlight);
710 * @name mdHighlightText
711 * @module material.components.autocomplete
714 * The `md-highlight-text` directive allows you to specify text that should be highlighted within
715 * an element. Highlighted text will be wrapped in `<span class="highlight"></span>` which can
716 * be styled through CSS. Please note that child elements may not be used with this directive.
718 * @param {string} md-highlight-text A model to be searched for
719 * @param {string=} md-highlight-flags A list of flags (loosely based on JavaScript RexExp flags).
720 * #### **Supported flags**:
721 * - `g`: Find all matches within the provided text
722 * - `i`: Ignore case when searching for matches
723 * - `$`: Only match if the text ends with the search term
724 * - `^`: Only match if the text begins with the search term
728 * <input placeholder="Enter a search term..." ng-model="searchTerm" type="text" />
730 * <li ng-repeat="result in results" md-highlight-text="searchTerm">
737 function MdHighlight () {
741 controller: 'MdHighlightCtrl',
742 link: function (scope, element, attr, ctrl) {
743 ctrl.init(attr.mdHighlightText);
749 .module('material.components.autocomplete')
750 .directive('mdAutocompleteListItem', MdAutocompleteListItem);
752 function MdAutocompleteListItem ($compile, $mdUtil) {
758 function postLink (scope, element, attr) {
759 var ctrl = scope.$parent.$mdAutocompleteCtrl,
760 newScope = ctrl.parent.$new(false, ctrl.parent),
761 itemName = ctrl.scope.$eval(attr.mdAutocompleteListItem);
762 newScope[itemName] = scope.item;
763 $compile(element.contents())(newScope);
766 id: 'item_' + $mdUtil.nextUid()
770 MdAutocompleteListItem.$inject = ["$compile", "$mdUtil"];
773 .module('material.components.autocomplete')
774 .directive('mdAutocompleteParentScope', MdAutocompleteParentScope);
776 function MdAutocompleteParentScope ($compile, $mdUtil) {
783 function postLink (scope, element, attr) {
784 var ctrl = scope.$parent.$mdAutocompleteCtrl;
785 $compile(element.contents())(ctrl.parent);
786 if (attr.hasOwnProperty('mdAutocompleteReplace')) {
787 element.after(element.contents());
792 MdAutocompleteParentScope.$inject = ["$compile", "$mdUtil"];
794 ng.material.components.autocomplete = angular.module("material.components.autocomplete");