1 | /*!
|
---|
2 | * AngularJS Material Design
|
---|
3 | * https://github.com/angular/material
|
---|
4 | * @license MIT
|
---|
5 | * v1.2.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 |
|
---|
24 |
|
---|
25 | MdAutocompleteCtrl['$inject'] = ["$scope", "$element", "$mdUtil", "$mdConstant", "$mdTheming", "$window", "$animate", "$rootElement", "$attrs", "$q", "$log", "$mdLiveAnnouncer"];angular
|
---|
26 | .module('material.components.autocomplete')
|
---|
27 | .controller('MdAutocompleteCtrl', MdAutocompleteCtrl);
|
---|
28 |
|
---|
29 | var ITEM_HEIGHT = 48,
|
---|
30 | MAX_ITEMS = 5,
|
---|
31 | MENU_PADDING = 8,
|
---|
32 | INPUT_PADDING = 2, // Padding provided by `md-input-container`
|
---|
33 | MODE_STANDARD = 'standard',
|
---|
34 | MODE_VIRTUAL = 'virtual';
|
---|
35 |
|
---|
36 | function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, $window,
|
---|
37 | $animate, $rootElement, $attrs, $q, $log, $mdLiveAnnouncer) {
|
---|
38 |
|
---|
39 | // Internal Variables.
|
---|
40 | var ctrl = this,
|
---|
41 | itemParts = $scope.itemsExpr.split(/ in /i),
|
---|
42 | itemExpr = itemParts[ 1 ],
|
---|
43 | elements = null,
|
---|
44 | cache = {},
|
---|
45 | noBlur = false,
|
---|
46 | selectedItemWatchers = [],
|
---|
47 | hasFocus = false,
|
---|
48 | fetchesInProgress = 0,
|
---|
49 | enableWrapScroll = null,
|
---|
50 | inputModelCtrl = null,
|
---|
51 | debouncedOnResize = $mdUtil.debounce(onWindowResize),
|
---|
52 | mode = MODE_VIRTUAL; // default
|
---|
53 |
|
---|
54 | /**
|
---|
55 | * The root document element. This is used for attaching a top-level click handler to
|
---|
56 | * close the options panel when a click outside said panel occurs. We use `documentElement`
|
---|
57 | * instead of body because, when scrolling is disabled, some browsers consider the body element
|
---|
58 | * to be completely off the screen and propagate events directly to the html element.
|
---|
59 | * @type {!Object} angular.JQLite
|
---|
60 | */
|
---|
61 | ctrl.documentElement = angular.element(document.documentElement);
|
---|
62 |
|
---|
63 | // Public Exported Variables with handlers
|
---|
64 | defineProperty('hidden', handleHiddenChange, true);
|
---|
65 |
|
---|
66 | // Public Exported Variables
|
---|
67 | ctrl.scope = $scope;
|
---|
68 | ctrl.parent = $scope.$parent;
|
---|
69 | ctrl.itemName = itemParts[0];
|
---|
70 | ctrl.matches = [];
|
---|
71 | ctrl.loading = false;
|
---|
72 | ctrl.hidden = true;
|
---|
73 | ctrl.index = -1;
|
---|
74 | ctrl.activeOption = null;
|
---|
75 | ctrl.id = $mdUtil.nextUid();
|
---|
76 | ctrl.isDisabled = null;
|
---|
77 | ctrl.isRequired = null;
|
---|
78 | ctrl.isReadonly = null;
|
---|
79 | ctrl.hasNotFound = false;
|
---|
80 | ctrl.selectedMessage = $scope.selectedMessage || 'selected';
|
---|
81 | ctrl.noMatchMessage = $scope.noMatchMessage || 'There are no matches available.';
|
---|
82 | ctrl.singleMatchMessage = $scope.singleMatchMessage || 'There is 1 match available.';
|
---|
83 | ctrl.multipleMatchStartMessage = $scope.multipleMatchStartMessage || 'There are ';
|
---|
84 | ctrl.multipleMatchEndMessage = $scope.multipleMatchEndMessage || ' matches available.';
|
---|
85 | ctrl.defaultEscapeOptions = 'clear';
|
---|
86 |
|
---|
87 | // Public Exported Methods
|
---|
88 | ctrl.keydown = keydown;
|
---|
89 | ctrl.blur = blur;
|
---|
90 | ctrl.focus = focus;
|
---|
91 | ctrl.clear = clearValue;
|
---|
92 | ctrl.select = select;
|
---|
93 | ctrl.listEnter = onListEnter;
|
---|
94 | ctrl.listLeave = onListLeave;
|
---|
95 | ctrl.focusInput = focusInputElement;
|
---|
96 | ctrl.getCurrentDisplayValue = getCurrentDisplayValue;
|
---|
97 | ctrl.registerSelectedItemWatcher = registerSelectedItemWatcher;
|
---|
98 | ctrl.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher;
|
---|
99 | ctrl.notFoundVisible = notFoundVisible;
|
---|
100 | ctrl.loadingIsVisible = loadingIsVisible;
|
---|
101 | ctrl.positionDropdown = positionDropdown;
|
---|
102 |
|
---|
103 | /**
|
---|
104 | * Report types to be used for the $mdLiveAnnouncer
|
---|
105 | * @enum {number} Unique flag id.
|
---|
106 | */
|
---|
107 | var ReportType = {
|
---|
108 | Count: 1,
|
---|
109 | Selected: 2
|
---|
110 | };
|
---|
111 |
|
---|
112 | return init();
|
---|
113 |
|
---|
114 | // initialization methods
|
---|
115 |
|
---|
116 | /**
|
---|
117 | * Initialize the controller, setup watchers, gather elements
|
---|
118 | */
|
---|
119 | function init () {
|
---|
120 |
|
---|
121 | $mdUtil.initOptionalProperties($scope, $attrs, {
|
---|
122 | searchText: '',
|
---|
123 | selectedItem: null,
|
---|
124 | clearButton: false,
|
---|
125 | disableVirtualRepeat: false,
|
---|
126 | });
|
---|
127 |
|
---|
128 | $mdTheming($element);
|
---|
129 | configureWatchers();
|
---|
130 | $mdUtil.nextTick(function () {
|
---|
131 |
|
---|
132 | gatherElements();
|
---|
133 | moveDropdown();
|
---|
134 |
|
---|
135 | // Touch devices often do not send a click event on tap. We still want to focus the input
|
---|
136 | // and open the options pop-up in these cases.
|
---|
137 | $element.on('touchstart', focusInputElement);
|
---|
138 |
|
---|
139 | // Forward all focus events to the input element when autofocus is enabled
|
---|
140 | if ($scope.autofocus) {
|
---|
141 | $element.on('focus', focusInputElement);
|
---|
142 | }
|
---|
143 | if ($scope.inputAriaDescribedBy) {
|
---|
144 | elements.input.setAttribute('aria-describedby', $scope.inputAriaDescribedBy);
|
---|
145 | }
|
---|
146 | if (!$scope.floatingLabel) {
|
---|
147 | if ($scope.inputAriaLabel) {
|
---|
148 | elements.input.setAttribute('aria-label', $scope.inputAriaLabel);
|
---|
149 | } else if ($scope.inputAriaLabelledBy) {
|
---|
150 | elements.input.setAttribute('aria-labelledby', $scope.inputAriaLabelledBy);
|
---|
151 | } else if ($scope.placeholder) {
|
---|
152 | // If no aria-label or aria-labelledby references are defined, then just label using the
|
---|
153 | // placeholder.
|
---|
154 | elements.input.setAttribute('aria-label', $scope.placeholder);
|
---|
155 | }
|
---|
156 | }
|
---|
157 | });
|
---|
158 | }
|
---|
159 |
|
---|
160 | function updateModelValidators() {
|
---|
161 | if (!$scope.requireMatch || !inputModelCtrl) return;
|
---|
162 |
|
---|
163 | inputModelCtrl.$setValidity('md-require-match', !!$scope.selectedItem || !$scope.searchText);
|
---|
164 | }
|
---|
165 |
|
---|
166 | /**
|
---|
167 | * Calculates the dropdown's position and applies the new styles to the menu element
|
---|
168 | * @returns {*}
|
---|
169 | */
|
---|
170 | function positionDropdown () {
|
---|
171 | if (!elements) {
|
---|
172 | return $mdUtil.nextTick(positionDropdown, false, $scope);
|
---|
173 | }
|
---|
174 |
|
---|
175 | var dropdownHeight = ($scope.dropdownItems || MAX_ITEMS) * ITEM_HEIGHT;
|
---|
176 | var hrect = elements.wrap.getBoundingClientRect(),
|
---|
177 | vrect = elements.snap.getBoundingClientRect(),
|
---|
178 | root = elements.root.getBoundingClientRect(),
|
---|
179 | top = vrect.bottom - root.top,
|
---|
180 | bot = root.bottom - vrect.top,
|
---|
181 | left = hrect.left - root.left,
|
---|
182 | width = hrect.width,
|
---|
183 | offset = getVerticalOffset(),
|
---|
184 | position = $scope.dropdownPosition,
|
---|
185 | styles, enoughBottomSpace, enoughTopSpace;
|
---|
186 | var bottomSpace = root.bottom - vrect.bottom - MENU_PADDING + $mdUtil.getViewportTop();
|
---|
187 | var topSpace = vrect.top - MENU_PADDING;
|
---|
188 |
|
---|
189 | // Automatically determine dropdown placement based on available space in viewport.
|
---|
190 | if (!position) {
|
---|
191 | enoughTopSpace = topSpace > dropdownHeight;
|
---|
192 | enoughBottomSpace = bottomSpace > dropdownHeight;
|
---|
193 | if (enoughBottomSpace) {
|
---|
194 | position = 'bottom';
|
---|
195 | } else if (enoughTopSpace) {
|
---|
196 | position = 'top';
|
---|
197 | } else {
|
---|
198 | position = topSpace > bottomSpace ? 'top' : 'bottom';
|
---|
199 | }
|
---|
200 | }
|
---|
201 | // Adjust the width to account for the padding provided by `md-input-container`
|
---|
202 | if ($attrs.mdFloatingLabel) {
|
---|
203 | left += INPUT_PADDING;
|
---|
204 | width -= INPUT_PADDING * 2;
|
---|
205 | }
|
---|
206 | styles = {
|
---|
207 | left: left + 'px',
|
---|
208 | minWidth: width + 'px',
|
---|
209 | maxWidth: Math.max(hrect.right - root.left, root.right - hrect.left) - MENU_PADDING + 'px'
|
---|
210 | };
|
---|
211 |
|
---|
212 | if (position === 'top') {
|
---|
213 | styles.top = 'auto';
|
---|
214 | styles.bottom = bot + 'px';
|
---|
215 | styles.maxHeight = Math.min(dropdownHeight, topSpace) + 'px';
|
---|
216 | } else {
|
---|
217 | bottomSpace = root.bottom - hrect.bottom - MENU_PADDING + $mdUtil.getViewportTop();
|
---|
218 |
|
---|
219 | styles.top = (top - offset) + 'px';
|
---|
220 | styles.bottom = 'auto';
|
---|
221 | styles.maxHeight = Math.min(dropdownHeight, bottomSpace) + 'px';
|
---|
222 | }
|
---|
223 |
|
---|
224 | elements.$.scrollContainer.css(styles);
|
---|
225 | $mdUtil.nextTick(correctHorizontalAlignment, false, $scope);
|
---|
226 |
|
---|
227 | /**
|
---|
228 | * Calculates the vertical offset for floating label examples to account for ngMessages
|
---|
229 | * @returns {number}
|
---|
230 | */
|
---|
231 | function getVerticalOffset () {
|
---|
232 | var offset = 0;
|
---|
233 | var inputContainer = $element.find('md-input-container');
|
---|
234 | if (inputContainer.length) {
|
---|
235 | var input = inputContainer.find('input');
|
---|
236 | offset = inputContainer.prop('offsetHeight');
|
---|
237 | offset -= input.prop('offsetTop');
|
---|
238 | offset -= input.prop('offsetHeight');
|
---|
239 | // add in the height left up top for the floating label text
|
---|
240 | offset += inputContainer.prop('offsetTop');
|
---|
241 | }
|
---|
242 | return offset;
|
---|
243 | }
|
---|
244 |
|
---|
245 | /**
|
---|
246 | * Makes sure that the menu doesn't go off of the screen on either side.
|
---|
247 | */
|
---|
248 | function correctHorizontalAlignment () {
|
---|
249 | var dropdown = elements.scrollContainer.getBoundingClientRect(),
|
---|
250 | styles = {};
|
---|
251 | if (dropdown.right > root.right) {
|
---|
252 | styles.left = (hrect.right - dropdown.width) + 'px';
|
---|
253 | }
|
---|
254 | elements.$.scrollContainer.css(styles);
|
---|
255 | }
|
---|
256 | }
|
---|
257 |
|
---|
258 | /**
|
---|
259 | * Moves the dropdown menu to the body tag in order to avoid z-index and overflow issues.
|
---|
260 | */
|
---|
261 | function moveDropdown () {
|
---|
262 | if (!elements.$.root.length) return;
|
---|
263 | $mdTheming(elements.$.scrollContainer);
|
---|
264 | elements.$.scrollContainer.detach();
|
---|
265 | elements.$.root.append(elements.$.scrollContainer);
|
---|
266 | if ($animate.pin) $animate.pin(elements.$.scrollContainer, $rootElement);
|
---|
267 | }
|
---|
268 |
|
---|
269 | /**
|
---|
270 | * Sends focus to the input element.
|
---|
271 | */
|
---|
272 | function focusInputElement () {
|
---|
273 | elements.input.focus();
|
---|
274 | }
|
---|
275 |
|
---|
276 | /**
|
---|
277 | * Update the activeOption based on the selected item in the listbox.
|
---|
278 | * The activeOption is used in the template to set the aria-activedescendant attribute, which
|
---|
279 | * enables screen readers to properly handle visual focus within the listbox and announce the
|
---|
280 | * item's place in the list. I.e. "List item 3 of 50". Anytime that `ctrl.index` changes, this
|
---|
281 | * function needs to be called to update the activeOption.
|
---|
282 | */
|
---|
283 | function updateActiveOption() {
|
---|
284 | var selectedOption = elements.scroller.querySelector('.selected');
|
---|
285 | if (selectedOption) {
|
---|
286 | ctrl.activeOption = selectedOption.id;
|
---|
287 | } else {
|
---|
288 | ctrl.activeOption = null;
|
---|
289 | }
|
---|
290 | }
|
---|
291 |
|
---|
292 | /**
|
---|
293 | * Sets up any watchers used by autocomplete
|
---|
294 | */
|
---|
295 | function configureWatchers () {
|
---|
296 | var wait = parseInt($scope.delay, 10) || 0;
|
---|
297 |
|
---|
298 | $attrs.$observe('disabled', function (value) { ctrl.isDisabled = $mdUtil.parseAttributeBoolean(value, false); });
|
---|
299 | $attrs.$observe('required', function (value) { ctrl.isRequired = $mdUtil.parseAttributeBoolean(value, false); });
|
---|
300 | $attrs.$observe('readonly', function (value) { ctrl.isReadonly = $mdUtil.parseAttributeBoolean(value, false); });
|
---|
301 |
|
---|
302 | $scope.$watch('searchText', wait ? $mdUtil.debounce(handleSearchText, wait) : handleSearchText);
|
---|
303 | $scope.$watch('selectedItem', selectedItemChange);
|
---|
304 |
|
---|
305 | angular.element($window).on('resize', debouncedOnResize);
|
---|
306 |
|
---|
307 | $scope.$on('$destroy', cleanup);
|
---|
308 | }
|
---|
309 |
|
---|
310 | /**
|
---|
311 | * Removes any events or leftover elements created by this controller
|
---|
312 | */
|
---|
313 | function cleanup () {
|
---|
314 | if (!ctrl.hidden) {
|
---|
315 | $mdUtil.enableScrolling();
|
---|
316 | }
|
---|
317 |
|
---|
318 | angular.element($window).off('resize', debouncedOnResize);
|
---|
319 |
|
---|
320 | if (elements){
|
---|
321 | var items = ['ul', 'scroller', 'scrollContainer', 'input'];
|
---|
322 | angular.forEach(items, function(key){
|
---|
323 | elements.$[key].remove();
|
---|
324 | });
|
---|
325 | }
|
---|
326 | }
|
---|
327 |
|
---|
328 | /**
|
---|
329 | * Event handler to be called whenever the window resizes.
|
---|
330 | */
|
---|
331 | function onWindowResize() {
|
---|
332 | if (!ctrl.hidden) {
|
---|
333 | positionDropdown();
|
---|
334 | }
|
---|
335 | }
|
---|
336 |
|
---|
337 | /**
|
---|
338 | * Gathers all of the elements needed for this controller
|
---|
339 | */
|
---|
340 | function gatherElements () {
|
---|
341 |
|
---|
342 | var snapWrap = gatherSnapWrap();
|
---|
343 |
|
---|
344 | elements = {
|
---|
345 | main: $element[0],
|
---|
346 | scrollContainer: $element[0].querySelector('.md-virtual-repeat-container, .md-standard-list-container'),
|
---|
347 | scroller: $element[0].querySelector('.md-virtual-repeat-scroller, .md-standard-list-scroller'),
|
---|
348 | ul: $element.find('ul')[0],
|
---|
349 | input: $element.find('input')[0],
|
---|
350 | wrap: snapWrap.wrap,
|
---|
351 | snap: snapWrap.snap,
|
---|
352 | root: document.body,
|
---|
353 | };
|
---|
354 |
|
---|
355 | elements.li = elements.ul.getElementsByTagName('li');
|
---|
356 | elements.$ = getAngularElements(elements);
|
---|
357 | mode = elements.scrollContainer.classList.contains('md-standard-list-container') ? MODE_STANDARD : MODE_VIRTUAL;
|
---|
358 | inputModelCtrl = elements.$.input.controller('ngModel');
|
---|
359 | }
|
---|
360 |
|
---|
361 | /**
|
---|
362 | * Gathers the snap and wrap elements
|
---|
363 | *
|
---|
364 | */
|
---|
365 | function gatherSnapWrap() {
|
---|
366 | var element;
|
---|
367 | var value;
|
---|
368 | for (element = $element; element.length; element = element.parent()) {
|
---|
369 | value = element.attr('md-autocomplete-snap');
|
---|
370 | if (angular.isDefined(value)) break;
|
---|
371 | }
|
---|
372 |
|
---|
373 | if (element.length) {
|
---|
374 | return {
|
---|
375 | snap: element[0],
|
---|
376 | wrap: (value.toLowerCase() === 'width') ? element[0] : $element.find('md-autocomplete-wrap')[0]
|
---|
377 | };
|
---|
378 | }
|
---|
379 |
|
---|
380 | var wrap = $element.find('md-autocomplete-wrap')[0];
|
---|
381 | return {
|
---|
382 | snap: wrap,
|
---|
383 | wrap: wrap
|
---|
384 | };
|
---|
385 | }
|
---|
386 |
|
---|
387 | /**
|
---|
388 | * Gathers angular-wrapped versions of each element
|
---|
389 | * @param elements
|
---|
390 | * @returns {{}}
|
---|
391 | */
|
---|
392 | function getAngularElements (elements) {
|
---|
393 | var obj = {};
|
---|
394 | for (var key in elements) {
|
---|
395 | if (elements.hasOwnProperty(key)) obj[ key ] = angular.element(elements[ key ]);
|
---|
396 | }
|
---|
397 | return obj;
|
---|
398 | }
|
---|
399 |
|
---|
400 | // event/change handlers
|
---|
401 |
|
---|
402 | /**
|
---|
403 | * @param {Event} $event
|
---|
404 | */
|
---|
405 | function preventDefault($event) {
|
---|
406 | $event.preventDefault();
|
---|
407 | }
|
---|
408 |
|
---|
409 | /**
|
---|
410 | * @param {Event} $event
|
---|
411 | */
|
---|
412 | function stopPropagation($event) {
|
---|
413 | $event.stopPropagation();
|
---|
414 | }
|
---|
415 |
|
---|
416 | /**
|
---|
417 | * Handles changes to the `hidden` property.
|
---|
418 | * @param {boolean} hidden true to hide the options pop-up, false to show it.
|
---|
419 | * @param {boolean} oldHidden the previous value of hidden
|
---|
420 | */
|
---|
421 | function handleHiddenChange (hidden, oldHidden) {
|
---|
422 | var scrollContainerElement;
|
---|
423 |
|
---|
424 | if (elements) {
|
---|
425 | scrollContainerElement = angular.element(elements.scrollContainer);
|
---|
426 | }
|
---|
427 | if (!hidden && oldHidden) {
|
---|
428 | positionDropdown();
|
---|
429 |
|
---|
430 | // Report in polite mode, because the screen reader should finish the default description of
|
---|
431 | // the input element.
|
---|
432 | reportMessages(true, ReportType.Count | ReportType.Selected);
|
---|
433 |
|
---|
434 | if (elements) {
|
---|
435 | $mdUtil.disableScrollAround(elements.scrollContainer);
|
---|
436 | enableWrapScroll = disableElementScrollEvents(elements.wrap);
|
---|
437 | if ($mdUtil.isIos) {
|
---|
438 | ctrl.documentElement.on('touchend', handleTouchOutsidePanel);
|
---|
439 | if (scrollContainerElement) {
|
---|
440 | scrollContainerElement.on('touchstart touchmove touchend', stopPropagation);
|
---|
441 | }
|
---|
442 | }
|
---|
443 | ctrl.index = getDefaultIndex();
|
---|
444 | $mdUtil.nextTick(function() {
|
---|
445 | updateActiveOption();
|
---|
446 | updateScroll();
|
---|
447 | });
|
---|
448 | }
|
---|
449 | } else if (hidden && !oldHidden) {
|
---|
450 | if ($mdUtil.isIos) {
|
---|
451 | ctrl.documentElement.off('touchend', handleTouchOutsidePanel);
|
---|
452 | if (scrollContainerElement) {
|
---|
453 | scrollContainerElement.off('touchstart touchmove touchend', stopPropagation);
|
---|
454 | }
|
---|
455 | }
|
---|
456 | $mdUtil.enableScrolling();
|
---|
457 |
|
---|
458 | if (enableWrapScroll) {
|
---|
459 | enableWrapScroll();
|
---|
460 | enableWrapScroll = null;
|
---|
461 | }
|
---|
462 | }
|
---|
463 | }
|
---|
464 |
|
---|
465 | /**
|
---|
466 | * Handling touch events that bubble up to the document is required for closing the dropdown
|
---|
467 | * panel on touch outside of the options pop-up panel on iOS.
|
---|
468 | * @param {Event} $event
|
---|
469 | */
|
---|
470 | function handleTouchOutsidePanel($event) {
|
---|
471 | ctrl.hidden = true;
|
---|
472 | // iOS does not blur the pop-up for touches on the scroll mask, so we have to do it.
|
---|
473 | doBlur(true);
|
---|
474 | }
|
---|
475 |
|
---|
476 | /**
|
---|
477 | * Disables scrolling for a specific element.
|
---|
478 | * @param {!string|!DOMElement} element to disable scrolling
|
---|
479 | * @return {Function} function to call to re-enable scrolling for the element
|
---|
480 | */
|
---|
481 | function disableElementScrollEvents(element) {
|
---|
482 | var elementToDisable = angular.element(element);
|
---|
483 | elementToDisable.on('wheel touchmove', preventDefault);
|
---|
484 |
|
---|
485 | return function() {
|
---|
486 | elementToDisable.off('wheel touchmove', preventDefault);
|
---|
487 | };
|
---|
488 | }
|
---|
489 |
|
---|
490 | /**
|
---|
491 | * When the user mouses over the dropdown menu, ignore blur events.
|
---|
492 | */
|
---|
493 | function onListEnter () {
|
---|
494 | noBlur = true;
|
---|
495 | }
|
---|
496 |
|
---|
497 | /**
|
---|
498 | * When the user's mouse leaves the menu, blur events may hide the menu again.
|
---|
499 | */
|
---|
500 | function onListLeave () {
|
---|
501 | if (!hasFocus && !ctrl.hidden) elements.input.focus();
|
---|
502 | noBlur = false;
|
---|
503 | ctrl.hidden = shouldHide();
|
---|
504 | }
|
---|
505 |
|
---|
506 | /**
|
---|
507 | * Handles changes to the selected item.
|
---|
508 | * @param selectedItem
|
---|
509 | * @param previousSelectedItem
|
---|
510 | */
|
---|
511 | function selectedItemChange (selectedItem, previousSelectedItem) {
|
---|
512 |
|
---|
513 | updateModelValidators();
|
---|
514 |
|
---|
515 | if (selectedItem) {
|
---|
516 | getDisplayValue(selectedItem).then(function (val) {
|
---|
517 | $scope.searchText = val;
|
---|
518 | handleSelectedItemChange(selectedItem, previousSelectedItem);
|
---|
519 | });
|
---|
520 | } else if (previousSelectedItem && $scope.searchText) {
|
---|
521 | getDisplayValue(previousSelectedItem).then(function(displayValue) {
|
---|
522 | // Clear the searchText, when the selectedItem is set to null.
|
---|
523 | // Do not clear the searchText, when the searchText isn't matching with the previous
|
---|
524 | // selected item.
|
---|
525 | if (angular.isString($scope.searchText)
|
---|
526 | && displayValue.toString().toLowerCase() === $scope.searchText.toLowerCase()) {
|
---|
527 | $scope.searchText = '';
|
---|
528 | }
|
---|
529 | });
|
---|
530 | }
|
---|
531 |
|
---|
532 | if (selectedItem !== previousSelectedItem) {
|
---|
533 | announceItemChange();
|
---|
534 | }
|
---|
535 | }
|
---|
536 |
|
---|
537 | /**
|
---|
538 | * Use the user-defined expression to announce changes each time a new item is selected
|
---|
539 | */
|
---|
540 | function announceItemChange () {
|
---|
541 | angular.isFunction($scope.itemChange) &&
|
---|
542 | $scope.itemChange(getItemAsNameVal($scope.selectedItem));
|
---|
543 | }
|
---|
544 |
|
---|
545 | /**
|
---|
546 | * Use the user-defined expression to announce changes each time the search text is changed
|
---|
547 | */
|
---|
548 | function announceTextChange () {
|
---|
549 | angular.isFunction($scope.textChange) && $scope.textChange();
|
---|
550 | }
|
---|
551 |
|
---|
552 | /**
|
---|
553 | * Calls any external watchers listening for the selected item. Used in conjunction with
|
---|
554 | * `registerSelectedItemWatcher`.
|
---|
555 | * @param selectedItem
|
---|
556 | * @param previousSelectedItem
|
---|
557 | */
|
---|
558 | function handleSelectedItemChange (selectedItem, previousSelectedItem) {
|
---|
559 | selectedItemWatchers.forEach(function (watcher) {
|
---|
560 | watcher(selectedItem, previousSelectedItem);
|
---|
561 | });
|
---|
562 | }
|
---|
563 |
|
---|
564 | /**
|
---|
565 | * Register a function to be called when the selected item changes.
|
---|
566 | * @param cb
|
---|
567 | */
|
---|
568 | function registerSelectedItemWatcher (cb) {
|
---|
569 | if (selectedItemWatchers.indexOf(cb) === -1) {
|
---|
570 | selectedItemWatchers.push(cb);
|
---|
571 | }
|
---|
572 | }
|
---|
573 |
|
---|
574 | /**
|
---|
575 | * Unregister a function previously registered for selected item changes.
|
---|
576 | * @param cb
|
---|
577 | */
|
---|
578 | function unregisterSelectedItemWatcher (cb) {
|
---|
579 | var i = selectedItemWatchers.indexOf(cb);
|
---|
580 | if (i !== -1) {
|
---|
581 | selectedItemWatchers.splice(i, 1);
|
---|
582 | }
|
---|
583 | }
|
---|
584 |
|
---|
585 | /**
|
---|
586 | * Handles changes to the searchText property.
|
---|
587 | * @param {string} searchText
|
---|
588 | * @param {string} previousSearchText
|
---|
589 | */
|
---|
590 | function handleSearchText (searchText, previousSearchText) {
|
---|
591 | ctrl.index = getDefaultIndex();
|
---|
592 |
|
---|
593 | // do nothing on init
|
---|
594 | if (searchText === previousSearchText) return;
|
---|
595 |
|
---|
596 | updateModelValidators();
|
---|
597 |
|
---|
598 | getDisplayValue($scope.selectedItem).then(function (val) {
|
---|
599 | // clear selected item if search text no longer matches it
|
---|
600 | if (searchText !== val) {
|
---|
601 | $scope.selectedItem = null;
|
---|
602 |
|
---|
603 | // trigger change event if available
|
---|
604 | if (searchText !== previousSearchText) {
|
---|
605 | announceTextChange();
|
---|
606 | }
|
---|
607 |
|
---|
608 | // cancel results if search text is not long enough
|
---|
609 | if (!isMinLengthMet()) {
|
---|
610 | ctrl.matches = [];
|
---|
611 |
|
---|
612 | setLoading(false);
|
---|
613 | reportMessages(true, ReportType.Count);
|
---|
614 |
|
---|
615 | } else {
|
---|
616 | handleQuery();
|
---|
617 | }
|
---|
618 | }
|
---|
619 | });
|
---|
620 |
|
---|
621 | }
|
---|
622 |
|
---|
623 | /**
|
---|
624 | * Handles input blur event, determines if the dropdown should hide.
|
---|
625 | * @param {Event=} $event
|
---|
626 | */
|
---|
627 | function blur($event) {
|
---|
628 | hasFocus = false;
|
---|
629 |
|
---|
630 | if (!noBlur) {
|
---|
631 | ctrl.hidden = shouldHide();
|
---|
632 | evalAttr('ngBlur', { $event: $event });
|
---|
633 | } else if (angular.isObject($event)) {
|
---|
634 | $event.stopImmediatePropagation();
|
---|
635 | }
|
---|
636 | }
|
---|
637 |
|
---|
638 | /**
|
---|
639 | * Force blur on input element
|
---|
640 | * @param {boolean} forceBlur
|
---|
641 | */
|
---|
642 | function doBlur(forceBlur) {
|
---|
643 | if (forceBlur) {
|
---|
644 | noBlur = false;
|
---|
645 | hasFocus = false;
|
---|
646 | }
|
---|
647 | elements.input.blur();
|
---|
648 | }
|
---|
649 |
|
---|
650 | /**
|
---|
651 | * Handles input focus event, determines if the dropdown should show.
|
---|
652 | */
|
---|
653 | function focus($event) {
|
---|
654 | hasFocus = true;
|
---|
655 |
|
---|
656 | if (isSearchable() && isMinLengthMet()) {
|
---|
657 | handleQuery();
|
---|
658 | }
|
---|
659 |
|
---|
660 | ctrl.hidden = shouldHide();
|
---|
661 |
|
---|
662 | evalAttr('ngFocus', { $event: $event });
|
---|
663 | }
|
---|
664 |
|
---|
665 | /**
|
---|
666 | * Handles keyboard input.
|
---|
667 | * @param event
|
---|
668 | */
|
---|
669 | function keydown (event) {
|
---|
670 | switch (event.keyCode) {
|
---|
671 | case $mdConstant.KEY_CODE.DOWN_ARROW:
|
---|
672 | if (ctrl.loading || hasSelection()) return;
|
---|
673 | event.stopPropagation();
|
---|
674 | event.preventDefault();
|
---|
675 | ctrl.index = ctrl.index + 1 > ctrl.matches.length - 1 ? 0 : Math.min(ctrl.index + 1, ctrl.matches.length - 1);
|
---|
676 | $mdUtil.nextTick(updateActiveOption);
|
---|
677 | updateScroll();
|
---|
678 | break;
|
---|
679 | case $mdConstant.KEY_CODE.UP_ARROW:
|
---|
680 | if (ctrl.loading || hasSelection()) return;
|
---|
681 | event.stopPropagation();
|
---|
682 | event.preventDefault();
|
---|
683 | ctrl.index = ctrl.index - 1 < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1);
|
---|
684 | $mdUtil.nextTick(updateActiveOption);
|
---|
685 | updateScroll();
|
---|
686 | break;
|
---|
687 | case $mdConstant.KEY_CODE.TAB:
|
---|
688 | // If we hit tab, assume that we've left the list so it will close
|
---|
689 | onListLeave();
|
---|
690 |
|
---|
691 | if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
|
---|
692 | select(ctrl.index);
|
---|
693 | break;
|
---|
694 | case $mdConstant.KEY_CODE.ENTER:
|
---|
695 | if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
|
---|
696 | if (hasSelection()) return;
|
---|
697 | event.stopImmediatePropagation();
|
---|
698 | event.preventDefault();
|
---|
699 | select(ctrl.index);
|
---|
700 | break;
|
---|
701 | case $mdConstant.KEY_CODE.ESCAPE:
|
---|
702 | event.preventDefault(); // Prevent browser from always clearing input
|
---|
703 | if (!shouldProcessEscape()) return;
|
---|
704 | event.stopPropagation();
|
---|
705 |
|
---|
706 | clearSelectedItem();
|
---|
707 | if ($scope.searchText && hasEscapeOption('clear')) {
|
---|
708 | clearSearchText();
|
---|
709 | }
|
---|
710 |
|
---|
711 | // Manually hide (needed for mdNotFound support)
|
---|
712 | ctrl.hidden = true;
|
---|
713 |
|
---|
714 | if (hasEscapeOption('blur')) {
|
---|
715 | // Force the component to blur if they hit escape
|
---|
716 | doBlur(true);
|
---|
717 | }
|
---|
718 |
|
---|
719 | break;
|
---|
720 | default:
|
---|
721 | }
|
---|
722 | }
|
---|
723 |
|
---|
724 | // getters
|
---|
725 |
|
---|
726 | /**
|
---|
727 | * Returns the minimum length needed to display the dropdown.
|
---|
728 | * @returns {*}
|
---|
729 | */
|
---|
730 | function getMinLength () {
|
---|
731 | return angular.isNumber($scope.minLength) ? $scope.minLength : 1;
|
---|
732 | }
|
---|
733 |
|
---|
734 | /**
|
---|
735 | * Returns the display value for an item.
|
---|
736 | * @param {*} item
|
---|
737 | * @returns {*}
|
---|
738 | */
|
---|
739 | function getDisplayValue (item) {
|
---|
740 | return $q.when(getItemText(item) || item).then(function(itemText) {
|
---|
741 | if (itemText && !angular.isString(itemText)) {
|
---|
742 | $log.warn('md-autocomplete: Could not resolve display value to a string. ' +
|
---|
743 | 'Please check the `md-item-text` attribute.');
|
---|
744 | }
|
---|
745 |
|
---|
746 | return itemText;
|
---|
747 | });
|
---|
748 |
|
---|
749 | /**
|
---|
750 | * Getter function to invoke user-defined expression (in the directive)
|
---|
751 | * to convert your object to a single string.
|
---|
752 | * @param {*} item
|
---|
753 | * @returns {string|null}
|
---|
754 | */
|
---|
755 | function getItemText (item) {
|
---|
756 | return (item && $scope.itemText) ? $scope.itemText(getItemAsNameVal(item)) : null;
|
---|
757 | }
|
---|
758 | }
|
---|
759 |
|
---|
760 | /**
|
---|
761 | * Returns the locals object for compiling item templates.
|
---|
762 | * @param {*} item
|
---|
763 | * @returns {Object|undefined}
|
---|
764 | */
|
---|
765 | function getItemAsNameVal (item) {
|
---|
766 | if (!item) {
|
---|
767 | return undefined;
|
---|
768 | }
|
---|
769 |
|
---|
770 | var locals = {};
|
---|
771 | if (ctrl.itemName) {
|
---|
772 | locals[ ctrl.itemName ] = item;
|
---|
773 | }
|
---|
774 |
|
---|
775 | return locals;
|
---|
776 | }
|
---|
777 |
|
---|
778 | /**
|
---|
779 | * Returns the default index based on whether or not autoselect is enabled.
|
---|
780 | * @returns {number} 0 if autoselect is enabled, -1 if not.
|
---|
781 | */
|
---|
782 | function getDefaultIndex () {
|
---|
783 | return $scope.autoselect ? 0 : -1;
|
---|
784 | }
|
---|
785 |
|
---|
786 | /**
|
---|
787 | * Sets the loading parameter and updates the hidden state.
|
---|
788 | * @param value {boolean} Whether or not the component is currently loading.
|
---|
789 | */
|
---|
790 | function setLoading(value) {
|
---|
791 | if (ctrl.loading !== value) {
|
---|
792 | ctrl.loading = value;
|
---|
793 | }
|
---|
794 |
|
---|
795 | // Always refresh the hidden variable as something else might have changed
|
---|
796 | ctrl.hidden = shouldHide();
|
---|
797 | }
|
---|
798 |
|
---|
799 | /**
|
---|
800 | * Determines if the menu should be hidden.
|
---|
801 | * @returns {boolean} true if the menu should be hidden
|
---|
802 | */
|
---|
803 | function shouldHide () {
|
---|
804 | return !shouldShow();
|
---|
805 | }
|
---|
806 |
|
---|
807 | /**
|
---|
808 | * Determines whether the autocomplete is able to query within the current state.
|
---|
809 | * @returns {boolean} true if the query can be run
|
---|
810 | */
|
---|
811 | function isSearchable() {
|
---|
812 | if (ctrl.loading && !hasMatches()) {
|
---|
813 | // No query when query is in progress.
|
---|
814 | return false;
|
---|
815 | } else if (hasSelection()) {
|
---|
816 | // No query if there is already a selection
|
---|
817 | return false;
|
---|
818 | }
|
---|
819 | else if (!hasFocus) {
|
---|
820 | // No query if the input does not have focus
|
---|
821 | return false;
|
---|
822 | }
|
---|
823 | return true;
|
---|
824 | }
|
---|
825 |
|
---|
826 | /**
|
---|
827 | * @returns {boolean} if the escape keydown should be processed, return true.
|
---|
828 | * Otherwise return false.
|
---|
829 | */
|
---|
830 | function shouldProcessEscape() {
|
---|
831 | return hasEscapeOption('blur') || !ctrl.hidden || ctrl.loading || hasEscapeOption('clear') && $scope.searchText;
|
---|
832 | }
|
---|
833 |
|
---|
834 | /**
|
---|
835 | * @param {string} option check if this option is set
|
---|
836 | * @returns {boolean} if the specified escape option is set, return true. Return false otherwise.
|
---|
837 | */
|
---|
838 | function hasEscapeOption(option) {
|
---|
839 | if (!angular.isString($scope.escapeOptions)) {
|
---|
840 | return ctrl.defaultEscapeOptions.indexOf(option) !== -1;
|
---|
841 | } else {
|
---|
842 | return $scope.escapeOptions.toLowerCase().indexOf(option) !== -1;
|
---|
843 | }
|
---|
844 | }
|
---|
845 |
|
---|
846 | /**
|
---|
847 | * Determines if the menu should be shown.
|
---|
848 | * @returns {boolean} true if the menu should be shown
|
---|
849 | */
|
---|
850 | function shouldShow() {
|
---|
851 | if (ctrl.isReadonly) {
|
---|
852 | // Don't show if read only is set
|
---|
853 | return false;
|
---|
854 | } else if (!isSearchable()) {
|
---|
855 | // Don't show if a query is in progress, there is already a selection,
|
---|
856 | // or the input is not focused.
|
---|
857 | return false;
|
---|
858 | }
|
---|
859 | return (isMinLengthMet() && hasMatches()) || notFoundVisible();
|
---|
860 | }
|
---|
861 |
|
---|
862 | /**
|
---|
863 | * @returns {boolean} true if the search text has matches.
|
---|
864 | */
|
---|
865 | function hasMatches() {
|
---|
866 | return ctrl.matches.length ? true : false;
|
---|
867 | }
|
---|
868 |
|
---|
869 | /**
|
---|
870 | * @returns {boolean} true if the autocomplete has a valid selection.
|
---|
871 | */
|
---|
872 | function hasSelection() {
|
---|
873 | return ctrl.scope.selectedItem ? true : false;
|
---|
874 | }
|
---|
875 |
|
---|
876 | /**
|
---|
877 | * @returns {boolean} true if the loading indicator is, or should be, visible.
|
---|
878 | */
|
---|
879 | function loadingIsVisible() {
|
---|
880 | return ctrl.loading && !hasSelection();
|
---|
881 | }
|
---|
882 |
|
---|
883 | /**
|
---|
884 | * @returns {*} the display value of the current item.
|
---|
885 | */
|
---|
886 | function getCurrentDisplayValue () {
|
---|
887 | return getDisplayValue(ctrl.matches[ ctrl.index ]);
|
---|
888 | }
|
---|
889 |
|
---|
890 | /**
|
---|
891 | * Determines if the minimum length is met by the search text.
|
---|
892 | * @returns {*} true if the minimum length is met by the search text
|
---|
893 | */
|
---|
894 | function isMinLengthMet () {
|
---|
895 | return ($scope.searchText || '').length >= getMinLength();
|
---|
896 | }
|
---|
897 |
|
---|
898 | // actions
|
---|
899 |
|
---|
900 | /**
|
---|
901 | * Defines a public property with a handler and a default value.
|
---|
902 | * @param {string} key
|
---|
903 | * @param {Function} handler function
|
---|
904 | * @param {*} defaultValue default value
|
---|
905 | */
|
---|
906 | function defineProperty (key, handler, defaultValue) {
|
---|
907 | Object.defineProperty(ctrl, key, {
|
---|
908 | get: function () { return defaultValue; },
|
---|
909 | set: function (newValue) {
|
---|
910 | var oldValue = defaultValue;
|
---|
911 | defaultValue = newValue;
|
---|
912 | handler(newValue, oldValue);
|
---|
913 | }
|
---|
914 | });
|
---|
915 | }
|
---|
916 |
|
---|
917 | /**
|
---|
918 | * Selects the item at the given index.
|
---|
919 | * @param {number} index to select
|
---|
920 | */
|
---|
921 | function select (index) {
|
---|
922 | // force form to update state for validation
|
---|
923 | $mdUtil.nextTick(function () {
|
---|
924 | getDisplayValue(ctrl.matches[ index ]).then(function (val) {
|
---|
925 | var ngModel = elements.$.input.controller('ngModel');
|
---|
926 | $mdLiveAnnouncer.announce(val + ' ' + ctrl.selectedMessage, 'assertive');
|
---|
927 | ngModel.$setViewValue(val);
|
---|
928 | ngModel.$render();
|
---|
929 | }).finally(function () {
|
---|
930 | $scope.selectedItem = ctrl.matches[ index ];
|
---|
931 | setLoading(false);
|
---|
932 | });
|
---|
933 | }, false);
|
---|
934 | }
|
---|
935 |
|
---|
936 | /**
|
---|
937 | * Clears the searchText value and selected item.
|
---|
938 | * @param {Event} $event
|
---|
939 | */
|
---|
940 | function clearValue ($event) {
|
---|
941 | if ($event) {
|
---|
942 | $event.stopPropagation();
|
---|
943 | }
|
---|
944 | clearSelectedItem();
|
---|
945 | clearSearchText();
|
---|
946 | }
|
---|
947 |
|
---|
948 | /**
|
---|
949 | * Clears the selected item
|
---|
950 | */
|
---|
951 | function clearSelectedItem () {
|
---|
952 | // Reset our variables
|
---|
953 | ctrl.index = -1;
|
---|
954 | $mdUtil.nextTick(updateActiveOption);
|
---|
955 | ctrl.matches = [];
|
---|
956 | }
|
---|
957 |
|
---|
958 | /**
|
---|
959 | * Clears the searchText value
|
---|
960 | */
|
---|
961 | function clearSearchText () {
|
---|
962 | // Set the loading to true so we don't see flashes of content.
|
---|
963 | // The flashing will only occur when an async request is running.
|
---|
964 | // So the loading process will stop when the results had been retrieved.
|
---|
965 | setLoading(true);
|
---|
966 |
|
---|
967 | $scope.searchText = '';
|
---|
968 |
|
---|
969 | // Normally, triggering the change / input event is unnecessary, because the browser detects it properly.
|
---|
970 | // But some browsers are not detecting it properly, which means that we have to trigger the event.
|
---|
971 | // Using the `input` is not working properly, because for example IE11 is not supporting the `input` event.
|
---|
972 | // The `change` event is a good alternative and is supported by all supported browsers.
|
---|
973 | var eventObj = document.createEvent('CustomEvent');
|
---|
974 | eventObj.initCustomEvent('change', true, true, { value: '' });
|
---|
975 | elements.input.dispatchEvent(eventObj);
|
---|
976 |
|
---|
977 | // For some reason, firing the above event resets the value of $scope.searchText if
|
---|
978 | // $scope.searchText has a space character at the end, so we blank it one more time and then
|
---|
979 | // focus.
|
---|
980 | elements.input.blur();
|
---|
981 | $scope.searchText = '';
|
---|
982 | elements.input.focus();
|
---|
983 | }
|
---|
984 |
|
---|
985 | /**
|
---|
986 | * Fetches the results for the provided search text.
|
---|
987 | * @param searchText
|
---|
988 | */
|
---|
989 | function fetchResults (searchText) {
|
---|
990 | var items = $scope.$parent.$eval(itemExpr),
|
---|
991 | term = searchText.toLowerCase(),
|
---|
992 | isList = angular.isArray(items),
|
---|
993 | isPromise = !!items.then; // Every promise should contain a `then` property
|
---|
994 |
|
---|
995 | if (isList) onResultsRetrieved(items);
|
---|
996 | else if (isPromise) handleAsyncResults(items);
|
---|
997 |
|
---|
998 | function handleAsyncResults(items) {
|
---|
999 | if (!items) return;
|
---|
1000 |
|
---|
1001 | items = $q.when(items);
|
---|
1002 | fetchesInProgress++;
|
---|
1003 | setLoading(true);
|
---|
1004 |
|
---|
1005 | $mdUtil.nextTick(function () {
|
---|
1006 | items
|
---|
1007 | .then(onResultsRetrieved)
|
---|
1008 | .finally(function(){
|
---|
1009 | if (--fetchesInProgress === 0) {
|
---|
1010 | setLoading(false);
|
---|
1011 | }
|
---|
1012 | });
|
---|
1013 | },true, $scope);
|
---|
1014 | }
|
---|
1015 |
|
---|
1016 | function onResultsRetrieved(matches) {
|
---|
1017 | cache[term] = matches;
|
---|
1018 |
|
---|
1019 | // Just cache the results if the request is now outdated.
|
---|
1020 | // The request becomes outdated, when the new searchText has changed during the result fetching.
|
---|
1021 | if ((searchText || '') !== ($scope.searchText || '')) {
|
---|
1022 | return;
|
---|
1023 | }
|
---|
1024 |
|
---|
1025 | handleResults(matches);
|
---|
1026 | }
|
---|
1027 | }
|
---|
1028 |
|
---|
1029 |
|
---|
1030 | /**
|
---|
1031 | * Reports given message types to supported screen readers.
|
---|
1032 | * @param {boolean} isPolite Whether the announcement should be polite.
|
---|
1033 | * @param {!number} types Message flags to be reported to the screen reader.
|
---|
1034 | */
|
---|
1035 | function reportMessages(isPolite, types) {
|
---|
1036 | var politeness = isPolite ? 'polite' : 'assertive';
|
---|
1037 | var messages = [];
|
---|
1038 |
|
---|
1039 | if (types & ReportType.Selected && ctrl.index !== -1) {
|
---|
1040 | messages.push(getCurrentDisplayValue());
|
---|
1041 | }
|
---|
1042 |
|
---|
1043 | if (types & ReportType.Count) {
|
---|
1044 | messages.push($q.resolve(getCountMessage()));
|
---|
1045 | }
|
---|
1046 |
|
---|
1047 | $q.all(messages).then(function(data) {
|
---|
1048 | $mdLiveAnnouncer.announce(data.join(' '), politeness);
|
---|
1049 | });
|
---|
1050 | }
|
---|
1051 |
|
---|
1052 | /**
|
---|
1053 | * @returns {string} the ARIA message for how many results match the current query.
|
---|
1054 | */
|
---|
1055 | function getCountMessage () {
|
---|
1056 | switch (ctrl.matches.length) {
|
---|
1057 | case 0:
|
---|
1058 | return ctrl.noMatchMessage;
|
---|
1059 | case 1:
|
---|
1060 | return ctrl.singleMatchMessage;
|
---|
1061 | default:
|
---|
1062 | return ctrl.multipleMatchStartMessage + ctrl.matches.length + ctrl.multipleMatchEndMessage;
|
---|
1063 | }
|
---|
1064 | }
|
---|
1065 |
|
---|
1066 | /**
|
---|
1067 | * Makes sure that the focused element is within view.
|
---|
1068 | */
|
---|
1069 | function updateScroll () {
|
---|
1070 | if (!elements.li[0]) return;
|
---|
1071 | if (mode === MODE_STANDARD) {
|
---|
1072 | updateStandardScroll();
|
---|
1073 | } else {
|
---|
1074 | updateVirtualScroll();
|
---|
1075 | }
|
---|
1076 | }
|
---|
1077 |
|
---|
1078 | function updateVirtualScroll() {
|
---|
1079 | // elements in virtual scroll have consistent heights
|
---|
1080 | var optionHeight = elements.li[0].offsetHeight,
|
---|
1081 | top = optionHeight * Math.max(0, ctrl.index),
|
---|
1082 | bottom = top + optionHeight,
|
---|
1083 | containerHeight = elements.scroller.clientHeight,
|
---|
1084 | scrollTop = elements.scroller.scrollTop;
|
---|
1085 |
|
---|
1086 | if (top < scrollTop) {
|
---|
1087 | scrollTo(top);
|
---|
1088 | } else if (bottom > scrollTop + containerHeight) {
|
---|
1089 | scrollTo(bottom - containerHeight);
|
---|
1090 | }
|
---|
1091 | }
|
---|
1092 |
|
---|
1093 | function updateStandardScroll() {
|
---|
1094 | // elements in standard scroll have variable heights
|
---|
1095 | var selected = elements.li[Math.max(0, ctrl.index)];
|
---|
1096 | var containerHeight = elements.scrollContainer.offsetHeight,
|
---|
1097 | top = selected && selected.offsetTop || 0,
|
---|
1098 | bottom = top + selected.clientHeight,
|
---|
1099 | scrollTop = elements.scrollContainer.scrollTop;
|
---|
1100 |
|
---|
1101 | if (top < scrollTop) {
|
---|
1102 | scrollTo(top);
|
---|
1103 | } else if (bottom > scrollTop + containerHeight) {
|
---|
1104 | scrollTo(bottom - containerHeight);
|
---|
1105 | }
|
---|
1106 | }
|
---|
1107 |
|
---|
1108 | function isPromiseFetching() {
|
---|
1109 | return fetchesInProgress !== 0;
|
---|
1110 | }
|
---|
1111 |
|
---|
1112 | function scrollTo (offset) {
|
---|
1113 | if (mode === MODE_STANDARD) {
|
---|
1114 | elements.scrollContainer.scrollTop = offset;
|
---|
1115 | } else {
|
---|
1116 | elements.$.scrollContainer.controller('mdVirtualRepeatContainer').scrollTo(offset);
|
---|
1117 | }
|
---|
1118 | }
|
---|
1119 |
|
---|
1120 | function notFoundVisible () {
|
---|
1121 | var textLength = (ctrl.scope.searchText || '').length;
|
---|
1122 |
|
---|
1123 | return ctrl.hasNotFound && !hasMatches() && (!ctrl.loading || isPromiseFetching()) && textLength >= getMinLength() && (hasFocus || noBlur) && !hasSelection();
|
---|
1124 | }
|
---|
1125 |
|
---|
1126 | /**
|
---|
1127 | * Starts the query to gather the results for the current searchText. Attempts to return cached
|
---|
1128 | * results first, then forwards the process to `fetchResults` if necessary.
|
---|
1129 | */
|
---|
1130 | function handleQuery () {
|
---|
1131 | var searchText = $scope.searchText || '';
|
---|
1132 | var term = searchText.toLowerCase();
|
---|
1133 |
|
---|
1134 | // If caching is enabled and the current searchText is stored in the cache
|
---|
1135 | if (!$scope.noCache && cache[term]) {
|
---|
1136 | // The results should be handled as same as a normal un-cached request does.
|
---|
1137 | handleResults(cache[term]);
|
---|
1138 | } else {
|
---|
1139 | fetchResults(searchText);
|
---|
1140 | }
|
---|
1141 |
|
---|
1142 | ctrl.hidden = shouldHide();
|
---|
1143 | }
|
---|
1144 |
|
---|
1145 | /**
|
---|
1146 | * Handles the retrieved results by showing them in the autocompletes dropdown.
|
---|
1147 | * @param results Retrieved results
|
---|
1148 | */
|
---|
1149 | function handleResults(results) {
|
---|
1150 | ctrl.matches = results;
|
---|
1151 | ctrl.hidden = shouldHide();
|
---|
1152 |
|
---|
1153 | // If loading is in progress, then we'll end the progress. This is needed for example,
|
---|
1154 | // when the `clear` button was clicked, because there we always show the loading process, to prevent flashing.
|
---|
1155 | if (ctrl.loading) setLoading(false);
|
---|
1156 |
|
---|
1157 | if ($scope.selectOnMatch) selectItemOnMatch();
|
---|
1158 |
|
---|
1159 | positionDropdown();
|
---|
1160 | reportMessages(true, ReportType.Count);
|
---|
1161 | }
|
---|
1162 |
|
---|
1163 | /**
|
---|
1164 | * If there is only one matching item and the search text matches its display value exactly,
|
---|
1165 | * automatically select that item. Note: This function is only called if the user uses the
|
---|
1166 | * `md-select-on-match` flag.
|
---|
1167 | */
|
---|
1168 | function selectItemOnMatch () {
|
---|
1169 | var searchText = $scope.searchText,
|
---|
1170 | matches = ctrl.matches,
|
---|
1171 | item = matches[ 0 ];
|
---|
1172 | if (matches.length === 1) getDisplayValue(item).then(function (displayValue) {
|
---|
1173 | var isMatching = searchText === displayValue;
|
---|
1174 | if ($scope.matchInsensitive && !isMatching) {
|
---|
1175 | isMatching = searchText.toLowerCase() === displayValue.toLowerCase();
|
---|
1176 | }
|
---|
1177 |
|
---|
1178 | if (isMatching) {
|
---|
1179 | select(0);
|
---|
1180 | }
|
---|
1181 | });
|
---|
1182 | }
|
---|
1183 |
|
---|
1184 | /**
|
---|
1185 | * Evaluates an attribute expression against the parent scope.
|
---|
1186 | * @param {String} attr Name of the attribute to be evaluated.
|
---|
1187 | * @param {Object?} locals Properties to be injected into the evaluation context.
|
---|
1188 | */
|
---|
1189 | function evalAttr(attr, locals) {
|
---|
1190 | if ($attrs[attr]) {
|
---|
1191 | $scope.$parent.$eval($attrs[attr], locals || {});
|
---|
1192 | }
|
---|
1193 | }
|
---|
1194 |
|
---|
1195 | }
|
---|
1196 |
|
---|
1197 |
|
---|
1198 | MdAutocomplete['$inject'] = ["$$mdSvgRegistry"];angular
|
---|
1199 | .module('material.components.autocomplete')
|
---|
1200 | .directive('mdAutocomplete', MdAutocomplete);
|
---|
1201 |
|
---|
1202 | /**
|
---|
1203 | * @ngdoc directive
|
---|
1204 | * @name mdAutocomplete
|
---|
1205 | * @module material.components.autocomplete
|
---|
1206 | *
|
---|
1207 | * @description
|
---|
1208 | * `<md-autocomplete>` is a special input component with a drop-down of all possible matches to a
|
---|
1209 | * custom query. This component allows you to provide real-time suggestions as the user types
|
---|
1210 | * in the input area.
|
---|
1211 | *
|
---|
1212 | * To start, you will need to specify the required parameters and provide a template for your
|
---|
1213 | * results. The content inside `md-autocomplete` will be treated as a template.
|
---|
1214 | *
|
---|
1215 | * In more complex cases, you may want to include other content such as a message to display when
|
---|
1216 | * no matches were found. You can do this by wrapping your template in `md-item-template` and
|
---|
1217 | * adding a tag for `md-not-found`. An example of this is shown below.
|
---|
1218 | *
|
---|
1219 | * To reset the displayed value you must clear both values for `md-search-text` and
|
---|
1220 | * `md-selected-item`.
|
---|
1221 | *
|
---|
1222 | * ### Validation
|
---|
1223 | *
|
---|
1224 | * You can use `ng-messages` to include validation the same way that you would normally validate;
|
---|
1225 | * however, if you want to replicate a standard input with a floating label, you will have to
|
---|
1226 | * do the following:
|
---|
1227 | *
|
---|
1228 | * - Make sure that your template is wrapped in `md-item-template`
|
---|
1229 | * - Add your `ng-messages` code inside of `md-autocomplete`
|
---|
1230 | * - Add your validation properties to `md-autocomplete` (ie. `required`)
|
---|
1231 | * - Add a `name` to `md-autocomplete` (to be used on the generated `input`)
|
---|
1232 | *
|
---|
1233 | * There is an example below of how this should look.
|
---|
1234 | *
|
---|
1235 | * ### Snapping Drop-Down
|
---|
1236 | *
|
---|
1237 | * You can cause the autocomplete drop-down to snap to an ancestor element by applying the
|
---|
1238 | * `md-autocomplete-snap` attribute to that element. You can also snap to the width of
|
---|
1239 | * the `md-autocomplete-snap` element by setting the attribute's value to `width`
|
---|
1240 | * (ie. `md-autocomplete-snap="width"`).
|
---|
1241 | *
|
---|
1242 | * ### Notes
|
---|
1243 | *
|
---|
1244 | * **Autocomplete Dropdown Items Rendering**
|
---|
1245 | *
|
---|
1246 | * The `md-autocomplete` uses the the <a ng-href="api/directive/mdVirtualRepeat">
|
---|
1247 | * mdVirtualRepeat</a> directive for displaying the results inside of the dropdown.<br/>
|
---|
1248 | *
|
---|
1249 | * > When encountering issues regarding the item template please take a look at the
|
---|
1250 | * <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeatContainer</a> documentation.
|
---|
1251 | *
|
---|
1252 | * **Autocomplete inside of a Virtual Repeat**
|
---|
1253 | *
|
---|
1254 | * When using the `md-autocomplete` directive inside of a
|
---|
1255 | * <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeatContainer</a> the dropdown items
|
---|
1256 | * might not update properly, because caching of the results is enabled by default.
|
---|
1257 | *
|
---|
1258 | * The autocomplete will then show invalid dropdown items, because the Virtual Repeat only updates
|
---|
1259 | * the scope bindings rather than re-creating the `md-autocomplete`. This means that the previous
|
---|
1260 | * cached results will be used.
|
---|
1261 | *
|
---|
1262 | * > To avoid such problems, ensure that the autocomplete does not cache any results via
|
---|
1263 | * `md-no-cache="true"`:
|
---|
1264 | *
|
---|
1265 | * <hljs lang="html">
|
---|
1266 | * <md-autocomplete
|
---|
1267 | * md-no-cache="true"
|
---|
1268 | * md-selected-item="selectedItem"
|
---|
1269 | * md-items="item in items"
|
---|
1270 | * md-search-text="searchText"
|
---|
1271 | * md-item-text="item.display">
|
---|
1272 | * <span>{{ item.display }}</span>
|
---|
1273 | * </md-autocomplete>
|
---|
1274 | * </hljs>
|
---|
1275 | *
|
---|
1276 | *
|
---|
1277 | * @param {expression} md-items An expression in the format of `item in results` to iterate over
|
---|
1278 | * matches for your search.<br/><br/>
|
---|
1279 | * The `results` expression can be also a function, which returns the results synchronously
|
---|
1280 | * or asynchronously (per Promise).
|
---|
1281 | * @param {expression=} md-selected-item-change An expression to be run each time a new item is
|
---|
1282 | * selected.
|
---|
1283 | * @param {expression=} md-search-text-change An expression to be run each time the search text
|
---|
1284 | * updates.
|
---|
1285 | * @param {expression=} md-search-text A model to bind the search query text to.
|
---|
1286 | * @param {object=} md-selected-item A model to bind the selected item to.
|
---|
1287 | * @param {expression=} md-item-text An expression that will convert your object to a single string.
|
---|
1288 | * @param {string=} placeholder Placeholder text that will be forwarded to the input.
|
---|
1289 | * @param {boolean=} md-no-cache Disables the internal caching that happens in autocomplete.
|
---|
1290 | * @param {boolean=} ng-disabled Determines whether or not to disable the input field.
|
---|
1291 | * @param {boolean=} md-require-match When set to true, the autocomplete will add a validator,
|
---|
1292 | * which will evaluate to false, when no item is currently selected.
|
---|
1293 | * @param {number=} md-min-length Specifies the minimum length of text before autocomplete will
|
---|
1294 | * make suggestions.
|
---|
1295 | * @param {number=} md-delay Specifies the amount of time (in milliseconds) to wait before looking
|
---|
1296 | * for results.
|
---|
1297 | * @param {boolean=} md-clear-button Whether the clear button for the autocomplete input should show
|
---|
1298 | * up or not. When `md-floating-label` is set, defaults to false, defaults to true otherwise.
|
---|
1299 | * @param {boolean=} md-autofocus If true, the autocomplete will be automatically focused when a
|
---|
1300 | * `$mdDialog`, `$mdBottomsheet` or `$mdSidenav`, which contains the autocomplete, is opening.
|
---|
1301 | * <br/><br/>
|
---|
1302 | * Also the autocomplete will immediately focus the input element.
|
---|
1303 | * @param {boolean=} md-no-asterisk When present, asterisk will not be appended to the floating
|
---|
1304 | * label.
|
---|
1305 | * @param {boolean=} md-autoselect If set to true, the first item will be automatically selected
|
---|
1306 | * in the dropdown upon open.
|
---|
1307 | * @param {string=} md-input-name The name attribute given to the input element to be used with
|
---|
1308 | * FormController.
|
---|
1309 | * @param {string=} md-menu-class This class will be applied to the dropdown menu for styling.
|
---|
1310 | * @param {string=} md-menu-container-class This class will be applied to the parent container
|
---|
1311 | * of the dropdown panel.
|
---|
1312 | * @param {string=} md-input-class This will be applied to the input for styling. This attribute
|
---|
1313 | * is only valid when a `md-floating-label` is defined.
|
---|
1314 | * @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in
|
---|
1315 | * `md-input-container`.
|
---|
1316 | * @param {string=} md-select-on-focus When present the input's text will be automatically selected
|
---|
1317 | * on focus.
|
---|
1318 | * @param {string=} md-input-id An ID to be added to the input element.
|
---|
1319 | * @param {number=} md-input-minlength The minimum length for the input's value for validation.
|
---|
1320 | * @param {number=} md-input-maxlength The maximum length for the input's value for validation.
|
---|
1321 | * @param {boolean=} md-select-on-match When set, autocomplete will automatically select
|
---|
1322 | * the item if the search text is an exact match. <br/><br/>
|
---|
1323 | * An exact match is when only one match is displayed.
|
---|
1324 | * @param {boolean=} md-match-case-insensitive When set and using `md-select-on-match`, autocomplete
|
---|
1325 | * will select on case-insensitive match.
|
---|
1326 | * @param {string=} md-escape-options Override escape key logic. Default is `clear`.<br/>
|
---|
1327 | * Options: `blur`, `clear`, `none`.
|
---|
1328 | * @param {string=} md-dropdown-items Specifies the maximum amount of items to be shown in
|
---|
1329 | * the dropdown.<br/><br/>
|
---|
1330 | * When the dropdown doesn't fit into the viewport, the dropdown will shrink
|
---|
1331 | * as much as possible.
|
---|
1332 | * @param {string=} md-dropdown-position Overrides the default dropdown position. Options: `top`,
|
---|
1333 | * `bottom`.
|
---|
1334 | * @param {string=} input-aria-describedby A space-separated list of element IDs. This should
|
---|
1335 | * contain the IDs of any elements that describe this autocomplete. Screen readers will read the
|
---|
1336 | * content of these elements at the end of announcing that the autocomplete has been selected
|
---|
1337 | * and describing its current state. The descriptive elements do not need to be visible on the
|
---|
1338 | * page.
|
---|
1339 | * @param {string=} input-aria-labelledby A space-separated list of element IDs. The ideal use case
|
---|
1340 | * is that this would contain the ID of a `<label>` element that is associated with this
|
---|
1341 | * autocomplete. This will only have affect when `md-floating-label` is not defined.<br><br>
|
---|
1342 | * For `<label id="state">US State</label>`, you would set this to
|
---|
1343 | * `input-aria-labelledby="state"`.
|
---|
1344 | * @param {string=} input-aria-label A label that will be applied to the autocomplete's input.
|
---|
1345 | * This will be announced by screen readers before the placeholder.
|
---|
1346 | * This will only have affect when `md-floating-label` is not defined. If you define both
|
---|
1347 | * `input-aria-label` and `input-aria-labelledby`, then `input-aria-label` will take precedence.
|
---|
1348 | * @param {string=} md-selected-message Attribute to specify the text that the screen reader will
|
---|
1349 | * announce after a value is selected. Default is: "selected". If `Alaska` is selected in the
|
---|
1350 | * options panel, it will read "Alaska selected". You will want to override this when your app
|
---|
1351 | * runs in a non-English locale.
|
---|
1352 | * @param {string=} md-no-match-message Attribute to specify the text that the screen reader will
|
---|
1353 | * announce after a query returns no matching results.
|
---|
1354 | * Default is: "There are no matches available.". You will want to override this when your app
|
---|
1355 | * runs in a non-English locale.
|
---|
1356 | * @param {string=} md-single-match-message Attribute to specify the text that the screen reader
|
---|
1357 | * will announce after a query returns a single matching result.
|
---|
1358 | * Default is: "There is 1 match available.". You will want to override this when your app
|
---|
1359 | * runs in a non-English locale.
|
---|
1360 | * @param {string=} md-multiple-match-start-message Attribute to specify the text that the screen
|
---|
1361 | * reader will announce after a query returns multiple matching results. The number of matching
|
---|
1362 | * results will be read after this text. Default is: "There are ". You will want to override this
|
---|
1363 | * when your app runs in a non-English locale.
|
---|
1364 | * @param {string=} md-multiple-match-end-message Attribute to specify the text that the screen
|
---|
1365 | * reader will announce after a query returns multiple matching results. The number of matching
|
---|
1366 | * results will be read before this text. Default is: " matches available.". You will want to
|
---|
1367 | * override this when your app runs in a non-English locale.
|
---|
1368 | * @param {boolean=} ng-trim If set to false, the search text will be not trimmed automatically.
|
---|
1369 | * Defaults to true.
|
---|
1370 | * @param {string=} ng-pattern Adds the pattern validator to the ngModel of the search text.
|
---|
1371 | * See the [ngPattern Directive](https://docs.angularjs.org/api/ng/directive/ngPattern)
|
---|
1372 | * for more details.
|
---|
1373 | * @param {string=} md-mode Specify the repeat mode for suggestion lists. Acceptable values include
|
---|
1374 | * `virtual` (md-virtual-repeat) and `standard` (ng-repeat). See the
|
---|
1375 | * `Specifying Repeat Mode` example for mode details. Default is `virtual`.
|
---|
1376 | *
|
---|
1377 | * @usage
|
---|
1378 | * ### Basic Example
|
---|
1379 | * <hljs lang="html">
|
---|
1380 | * <md-autocomplete
|
---|
1381 | * md-selected-item="selectedItem"
|
---|
1382 | * md-search-text="searchText"
|
---|
1383 | * md-items="item in getMatches(searchText)"
|
---|
1384 | * md-item-text="item.display">
|
---|
1385 | * <span md-highlight-text="searchText">{{item.display}}</span>
|
---|
1386 | * </md-autocomplete>
|
---|
1387 | * </hljs>
|
---|
1388 | *
|
---|
1389 | * ### Example with "not found" message
|
---|
1390 | * <hljs lang="html">
|
---|
1391 | * <md-autocomplete
|
---|
1392 | * md-selected-item="selectedItem"
|
---|
1393 | * md-search-text="searchText"
|
---|
1394 | * md-items="item in getMatches(searchText)"
|
---|
1395 | * md-item-text="item.display">
|
---|
1396 | * <md-item-template>
|
---|
1397 | * <span md-highlight-text="searchText">{{item.display}}</span>
|
---|
1398 | * </md-item-template>
|
---|
1399 | * <md-not-found>
|
---|
1400 | * No matches found.
|
---|
1401 | * </md-not-found>
|
---|
1402 | * </md-autocomplete>
|
---|
1403 | * </hljs>
|
---|
1404 | *
|
---|
1405 | * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the
|
---|
1406 | * different parts that make up our component.
|
---|
1407 | *
|
---|
1408 | * ### Clear button for the input
|
---|
1409 | * By default, the clear button is displayed when there is input. This aligns with the spec's
|
---|
1410 | * [Search Pattern](https://material.io/archive/guidelines/patterns/search.html#search-in-app-search).
|
---|
1411 | * In floating label mode, when `md-floating-label="My Label"` is applied, the clear button is not
|
---|
1412 | * displayed by default (see the spec's
|
---|
1413 | * [Autocomplete Text Field](https://material.io/archive/guidelines/components/text-fields.html#text-fields-layout)).
|
---|
1414 | *
|
---|
1415 | * Nevertheless, developers are able to explicitly toggle the clear button for all autocomplete
|
---|
1416 | * components with `md-clear-button`.
|
---|
1417 | *
|
---|
1418 | * <hljs lang="html">
|
---|
1419 | * <md-autocomplete ... md-clear-button="true"></md-autocomplete>
|
---|
1420 | * <md-autocomplete ... md-clear-button="false"></md-autocomplete>
|
---|
1421 | * </hljs>
|
---|
1422 | *
|
---|
1423 | * In previous versions, the clear button was always hidden when the component was disabled.
|
---|
1424 | * This changed in `1.1.5` to give the developer control of this behavior. This example
|
---|
1425 | * will hide the clear button only when the component is disabled.
|
---|
1426 | *
|
---|
1427 | * <hljs lang="html">
|
---|
1428 | * <md-autocomplete ... ng-disabled="disabled" md-clear-button="!disabled"></md-autocomplete>
|
---|
1429 | * </hljs>
|
---|
1430 | *
|
---|
1431 | * ### Example with validation
|
---|
1432 | * <hljs lang="html">
|
---|
1433 | * <form name="autocompleteForm">
|
---|
1434 | * <md-autocomplete
|
---|
1435 | * required
|
---|
1436 | * md-input-name="autocomplete"
|
---|
1437 | * md-selected-item="selectedItem"
|
---|
1438 | * md-search-text="searchText"
|
---|
1439 | * md-items="item in getMatches(searchText)"
|
---|
1440 | * md-item-text="item.display">
|
---|
1441 | * <md-item-template>
|
---|
1442 | * <span md-highlight-text="searchText">{{item.display}}</span>
|
---|
1443 | * </md-item-template>
|
---|
1444 | * <div ng-messages="autocompleteForm.autocomplete.$error">
|
---|
1445 | * <div ng-message="required">This field is required</div>
|
---|
1446 | * </div>
|
---|
1447 | * </md-autocomplete>
|
---|
1448 | * </form>
|
---|
1449 | * </hljs>
|
---|
1450 | *
|
---|
1451 | * In this example, our code utilizes `md-item-template` and `ng-messages` to specify
|
---|
1452 | * input validation for the field.
|
---|
1453 | *
|
---|
1454 | * ### Asynchronous Results
|
---|
1455 | * The autocomplete items expression also supports promises, which will resolve with the query
|
---|
1456 | * results.
|
---|
1457 | *
|
---|
1458 | * <hljs lang="js">
|
---|
1459 | * function AppController($scope, $http) {
|
---|
1460 | * $scope.query = function(searchText) {
|
---|
1461 | * return $http
|
---|
1462 | * .get(BACKEND_URL + '/items/' + searchText)
|
---|
1463 | * .then(function(data) {
|
---|
1464 | * // Map the response object to the data object.
|
---|
1465 | * return data;
|
---|
1466 | * });
|
---|
1467 | * };
|
---|
1468 | * }
|
---|
1469 | * </hljs>
|
---|
1470 | *
|
---|
1471 | * <hljs lang="html">
|
---|
1472 | * <md-autocomplete
|
---|
1473 | * md-selected-item="selectedItem"
|
---|
1474 | * md-search-text="searchText"
|
---|
1475 | * md-items="item in query(searchText)">
|
---|
1476 | * <md-item-template>
|
---|
1477 | * <span md-highlight-text="searchText">{{item}}</span>
|
---|
1478 | * </md-item-template>
|
---|
1479 | * </md-autocomplete>
|
---|
1480 | * </hljs>
|
---|
1481 | *
|
---|
1482 | * ### Specifying Repeat Mode
|
---|
1483 | * You can use `md-mode` to specify whether to use standard or virtual lists for
|
---|
1484 | * rendering autocomplete options.
|
---|
1485 | * The `md-mode` accepts two values:
|
---|
1486 | * - `virtual` (default) Uses `md-virtual-repeat` to render list items. Virtual
|
---|
1487 | * mode requires you to have consistent heights for all suggestions.
|
---|
1488 | * - `standard` uses `ng-repeat` to render list items. This allows you to have
|
---|
1489 | * options of varying heights.
|
---|
1490 | *
|
---|
1491 | * Note that using 'standard' mode will require you to address any list
|
---|
1492 | * performance issues (e.g. pagination) separately within your application.
|
---|
1493 | *
|
---|
1494 | * <hljs lang="html">
|
---|
1495 | * <md-autocomplete
|
---|
1496 | * md-selected-item="selectedItem"
|
---|
1497 | * md-search-text="searchText"
|
---|
1498 | * md-items="item in getMatches(searchText)"
|
---|
1499 | * md-item-text="item.display"
|
---|
1500 | * md-mode="standard">
|
---|
1501 | * <span md-highlight-text="searchText">{{item.display}}</span>
|
---|
1502 | * </md-autocomplete>
|
---|
1503 | * </hljs>
|
---|
1504 | */
|
---|
1505 | function MdAutocomplete ($$mdSvgRegistry) {
|
---|
1506 | var REPEAT_STANDARD = 'standard';
|
---|
1507 | var REPEAT_VIRTUAL = 'virtual';
|
---|
1508 | var REPEAT_MODES = [REPEAT_STANDARD, REPEAT_VIRTUAL];
|
---|
1509 |
|
---|
1510 | /** get a valid repeat mode from an md-mode attribute string. */
|
---|
1511 | function getRepeatMode(modeStr) {
|
---|
1512 | if (!modeStr) { return REPEAT_VIRTUAL; }
|
---|
1513 | modeStr = modeStr.toLowerCase();
|
---|
1514 | return REPEAT_MODES.indexOf(modeStr) > -1 ? modeStr : REPEAT_VIRTUAL;
|
---|
1515 | }
|
---|
1516 |
|
---|
1517 | return {
|
---|
1518 | controller: 'MdAutocompleteCtrl',
|
---|
1519 | controllerAs: '$mdAutocompleteCtrl',
|
---|
1520 | scope: {
|
---|
1521 | inputName: '@mdInputName',
|
---|
1522 | inputMinlength: '@mdInputMinlength',
|
---|
1523 | inputMaxlength: '@mdInputMaxlength',
|
---|
1524 | searchText: '=?mdSearchText',
|
---|
1525 | selectedItem: '=?mdSelectedItem',
|
---|
1526 | itemsExpr: '@mdItems',
|
---|
1527 | itemText: '&mdItemText',
|
---|
1528 | placeholder: '@placeholder',
|
---|
1529 | inputAriaDescribedBy: '@?inputAriaDescribedby',
|
---|
1530 | inputAriaLabelledBy: '@?inputAriaLabelledby',
|
---|
1531 | inputAriaLabel: '@?inputAriaLabel',
|
---|
1532 | noCache: '=?mdNoCache',
|
---|
1533 | requireMatch: '=?mdRequireMatch',
|
---|
1534 | selectOnMatch: '=?mdSelectOnMatch',
|
---|
1535 | matchInsensitive: '=?mdMatchCaseInsensitive',
|
---|
1536 | itemChange: '&?mdSelectedItemChange',
|
---|
1537 | textChange: '&?mdSearchTextChange',
|
---|
1538 | minLength: '=?mdMinLength',
|
---|
1539 | delay: '=?mdDelay',
|
---|
1540 | autofocus: '=?mdAutofocus',
|
---|
1541 | floatingLabel: '@?mdFloatingLabel',
|
---|
1542 | autoselect: '=?mdAutoselect',
|
---|
1543 | menuClass: '@?mdMenuClass',
|
---|
1544 | menuContainerClass: '@?mdMenuContainerClass',
|
---|
1545 | inputClass: '@?mdInputClass',
|
---|
1546 | inputId: '@?mdInputId',
|
---|
1547 | escapeOptions: '@?mdEscapeOptions',
|
---|
1548 | dropdownItems: '=?mdDropdownItems',
|
---|
1549 | dropdownPosition: '@?mdDropdownPosition',
|
---|
1550 | clearButton: '=?mdClearButton',
|
---|
1551 | selectedMessage: '@?mdSelectedMessage',
|
---|
1552 | noMatchMessage: '@?mdNoMatchMessage',
|
---|
1553 | singleMatchMessage: '@?mdSingleMatchMessage',
|
---|
1554 | multipleMatchStartMessage: '@?mdMultipleMatchStartMessage',
|
---|
1555 | multipleMatchEndMessage: '@?mdMultipleMatchEndMessage',
|
---|
1556 | mdMode: '=?mdMode'
|
---|
1557 | },
|
---|
1558 | compile: function(tElement, tAttrs) {
|
---|
1559 | var attributes = ['md-select-on-focus', 'md-no-asterisk', 'ng-trim', 'ng-pattern'];
|
---|
1560 | var input = tElement.find('input');
|
---|
1561 |
|
---|
1562 | attributes.forEach(function(attribute) {
|
---|
1563 | var attrValue = tAttrs[tAttrs.$normalize(attribute)];
|
---|
1564 |
|
---|
1565 | if (attrValue !== null) {
|
---|
1566 | input.attr(attribute, attrValue);
|
---|
1567 | }
|
---|
1568 | });
|
---|
1569 |
|
---|
1570 | return function(scope, element, attrs, ctrl) {
|
---|
1571 | // Retrieve the state of using a md-not-found template by using our attribute, which will
|
---|
1572 | // be added to the element in the template function.
|
---|
1573 | ctrl.hasNotFound = !!element.attr('md-has-not-found');
|
---|
1574 |
|
---|
1575 | // By default the inset autocomplete should show the clear button when not explicitly
|
---|
1576 | // overwritten or in floating label mode.
|
---|
1577 | if (!angular.isDefined(attrs.mdClearButton) && !scope.floatingLabel) {
|
---|
1578 | scope.clearButton = true;
|
---|
1579 | }
|
---|
1580 |
|
---|
1581 | scope.mdMode = getRepeatMode(attrs.mdMode);
|
---|
1582 |
|
---|
1583 | // Stop click events from bubbling up to the document and triggering a flicker of the
|
---|
1584 | // options panel while still supporting ng-click to be placed on md-autocomplete.
|
---|
1585 | element.on('click touchstart touchend', function(event) {
|
---|
1586 | event.stopPropagation();
|
---|
1587 | });
|
---|
1588 | };
|
---|
1589 | },
|
---|
1590 | template: function (element, attr) {
|
---|
1591 | var noItemsTemplate = getNoItemsTemplate(),
|
---|
1592 | itemTemplate = getItemTemplate(),
|
---|
1593 | leftover = element.html(),
|
---|
1594 | tabindex = attr.tabindex;
|
---|
1595 |
|
---|
1596 | // Set our attribute for the link function above which runs later.
|
---|
1597 | // We will set an attribute, because otherwise the stored variables will be trashed when
|
---|
1598 | // removing the element is hidden while retrieving the template. For example when using ngIf.
|
---|
1599 | if (noItemsTemplate) element.attr('md-has-not-found', true);
|
---|
1600 |
|
---|
1601 | // Always set our tabindex of the autocomplete directive to -1, because our input
|
---|
1602 | // will hold the actual tabindex.
|
---|
1603 | element.attr('tabindex', '-1');
|
---|
1604 |
|
---|
1605 | return '\
|
---|
1606 | <md-autocomplete-wrap\
|
---|
1607 | ng-class="{ \'md-whiteframe-z1\': !floatingLabel, \
|
---|
1608 | \'md-menu-showing\': !$mdAutocompleteCtrl.hidden, \
|
---|
1609 | \'md-show-clear-button\': !!clearButton }">\
|
---|
1610 | ' + getInputElement() + '\
|
---|
1611 | ' + getClearButton() + '\
|
---|
1612 | <md-progress-linear\
|
---|
1613 | class="' + (attr.mdFloatingLabel ? 'md-inline' : '') + '"\
|
---|
1614 | ng-if="$mdAutocompleteCtrl.loadingIsVisible()"\
|
---|
1615 | md-mode="indeterminate"></md-progress-linear>\
|
---|
1616 | ' + getContainer(attr.mdMenuContainerClass, attr.mdMode) + '\
|
---|
1617 | <ul class="md-autocomplete-suggestions"\
|
---|
1618 | ng-class="::menuClass"\
|
---|
1619 | id="ul-{{$mdAutocompleteCtrl.id}}"\
|
---|
1620 | ng-mouseup="$mdAutocompleteCtrl.focusInput()"\
|
---|
1621 | role="listbox">\
|
---|
1622 | <li class="md-autocomplete-suggestion" ' + getRepeatType(attr.mdMode) + ' ="item in $mdAutocompleteCtrl.matches"\
|
---|
1623 | ng-class="{ selected: $index === $mdAutocompleteCtrl.index }"\
|
---|
1624 | ng-attr-id="{{\'md-option-\' + $mdAutocompleteCtrl.id + \'-\' + $index}}"\
|
---|
1625 | ng-click="$mdAutocompleteCtrl.select($index)"\
|
---|
1626 | role="option"\
|
---|
1627 | aria-setsize="{{$mdAutocompleteCtrl.matches.length}}"\
|
---|
1628 | aria-posinset="{{$index+1}}"\
|
---|
1629 | aria-selected="{{$index === $mdAutocompleteCtrl.index ? true : false}}" \
|
---|
1630 | md-extra-name="$mdAutocompleteCtrl.itemName">\
|
---|
1631 | ' + itemTemplate + '\
|
---|
1632 | </li>' + noItemsTemplate + '\
|
---|
1633 | </ul>\
|
---|
1634 | ' + getContainerClosingTags(attr.mdMode) + '\
|
---|
1635 | </md-autocomplete-wrap>';
|
---|
1636 |
|
---|
1637 | function getItemTemplate() {
|
---|
1638 | var templateTag = element.find('md-item-template').detach(),
|
---|
1639 | html = templateTag.length ? templateTag.html() : element.html();
|
---|
1640 | if (!templateTag.length) element.empty();
|
---|
1641 | return '<md-autocomplete-parent-scope md-autocomplete-replace>' + html +
|
---|
1642 | '</md-autocomplete-parent-scope>';
|
---|
1643 | }
|
---|
1644 |
|
---|
1645 | function getNoItemsTemplate() {
|
---|
1646 | var templateTag = element.find('md-not-found').detach(),
|
---|
1647 | template = templateTag.length ? templateTag.html() : '';
|
---|
1648 | return template
|
---|
1649 | ? '<li ng-if="$mdAutocompleteCtrl.notFoundVisible()" class="md-autocomplete-suggestion"\
|
---|
1650 | md-autocomplete-parent-scope>' + template + '</li>'
|
---|
1651 | : '';
|
---|
1652 | }
|
---|
1653 |
|
---|
1654 | function getContainer(menuContainerClass, repeatMode) {
|
---|
1655 | // prepend a space if needed
|
---|
1656 | menuContainerClass = menuContainerClass ? ' ' + menuContainerClass : '';
|
---|
1657 |
|
---|
1658 | if (isVirtualRepeatDisabled(repeatMode)) {
|
---|
1659 | return '\
|
---|
1660 | <div \
|
---|
1661 | ng-hide="$mdAutocompleteCtrl.hidden"\
|
---|
1662 | class="md-standard-list-container md-autocomplete-suggestions-container md-whiteframe-z1' + menuContainerClass + '"\
|
---|
1663 | ng-class="{ \'md-not-found\': $mdAutocompleteCtrl.notFoundVisible() }"\
|
---|
1664 | ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
|
---|
1665 | ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
|
---|
1666 | role="presentation">\
|
---|
1667 | <div class="md-standard-list-scroller" role="presentation">';
|
---|
1668 | }
|
---|
1669 |
|
---|
1670 | return '\
|
---|
1671 | <md-virtual-repeat-container\
|
---|
1672 | md-auto-shrink\
|
---|
1673 | md-auto-shrink-min="1"\
|
---|
1674 | ng-hide="$mdAutocompleteCtrl.hidden"\
|
---|
1675 | class="md-virtual-repeat-container md-autocomplete-suggestions-container md-whiteframe-z1' + menuContainerClass + '"\
|
---|
1676 | ng-class="{ \'md-not-found\': $mdAutocompleteCtrl.notFoundVisible() }"\
|
---|
1677 | ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
|
---|
1678 | ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
|
---|
1679 | role="presentation">';
|
---|
1680 | }
|
---|
1681 |
|
---|
1682 | function getContainerClosingTags(repeatMode) {
|
---|
1683 | return isVirtualRepeatDisabled(repeatMode) ?
|
---|
1684 | ' </div>\
|
---|
1685 | </div>\
|
---|
1686 | </div>' : '</md-virtual-repeat-container>';
|
---|
1687 | }
|
---|
1688 |
|
---|
1689 | function getRepeatType(repeatMode) {
|
---|
1690 | return isVirtualRepeatDisabled(repeatMode) ?
|
---|
1691 | 'ng-repeat' : 'md-virtual-repeat';
|
---|
1692 | }
|
---|
1693 |
|
---|
1694 | function isVirtualRepeatDisabled(repeatMode) {
|
---|
1695 | // ensure we have a valid repeat mode
|
---|
1696 | var correctedRepeatMode = getRepeatMode(repeatMode);
|
---|
1697 | return correctedRepeatMode !== REPEAT_VIRTUAL;
|
---|
1698 | }
|
---|
1699 |
|
---|
1700 | function getInputElement () {
|
---|
1701 | if (attr.mdFloatingLabel) {
|
---|
1702 | return '\
|
---|
1703 | <md-input-container ng-if="floatingLabel">\
|
---|
1704 | <label>{{floatingLabel}}</label>\
|
---|
1705 | <input type="text"\
|
---|
1706 | ' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
|
---|
1707 | id="{{inputId || \'fl-input-\' + $mdAutocompleteCtrl.id}}"\
|
---|
1708 | name="{{inputName || \'fl-input-\' + $mdAutocompleteCtrl.id }}"\
|
---|
1709 | ng-class="::inputClass"\
|
---|
1710 | autocomplete="off"\
|
---|
1711 | ng-required="$mdAutocompleteCtrl.isRequired"\
|
---|
1712 | ng-readonly="$mdAutocompleteCtrl.isReadonly"\
|
---|
1713 | ng-minlength="inputMinlength"\
|
---|
1714 | ng-maxlength="inputMaxlength"\
|
---|
1715 | ng-disabled="$mdAutocompleteCtrl.isDisabled"\
|
---|
1716 | ng-model="$mdAutocompleteCtrl.scope.searchText"\
|
---|
1717 | ng-model-options="{ allowInvalid: true }"\
|
---|
1718 | ng-mousedown="$mdAutocompleteCtrl.focusInput()"\
|
---|
1719 | ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
|
---|
1720 | ng-blur="$mdAutocompleteCtrl.blur($event)"\
|
---|
1721 | ng-focus="$mdAutocompleteCtrl.focus($event)"\
|
---|
1722 | aria-label="{{floatingLabel}}"\
|
---|
1723 | ng-attr-aria-autocomplete="{{$mdAutocompleteCtrl.isDisabled ? undefined : \'list\'}}"\
|
---|
1724 | ng-attr-role="{{$mdAutocompleteCtrl.isDisabled ? undefined : \'combobox\'}}"\
|
---|
1725 | aria-haspopup="{{!$mdAutocompleteCtrl.isDisabled}}"\
|
---|
1726 | aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"\
|
---|
1727 | ng-attr-aria-owns="{{$mdAutocompleteCtrl.hidden || $mdAutocompleteCtrl.isDisabled ? undefined : \'ul-\' + $mdAutocompleteCtrl.id}}"\
|
---|
1728 | ng-attr-aria-activedescendant="{{!$mdAutocompleteCtrl.hidden && $mdAutocompleteCtrl.activeOption ? $mdAutocompleteCtrl.activeOption : undefined}}">\
|
---|
1729 | <div md-autocomplete-parent-scope md-autocomplete-replace>' + leftover + '</div>\
|
---|
1730 | </md-input-container>';
|
---|
1731 | } else {
|
---|
1732 | return '\
|
---|
1733 | <input type="text"\
|
---|
1734 | ' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
|
---|
1735 | id="{{inputId || \'input-\' + $mdAutocompleteCtrl.id}}"\
|
---|
1736 | name="{{inputName || \'input-\' + $mdAutocompleteCtrl.id }}"\
|
---|
1737 | ng-class="::inputClass"\
|
---|
1738 | ng-if="!floatingLabel"\
|
---|
1739 | autocomplete="off"\
|
---|
1740 | ng-required="$mdAutocompleteCtrl.isRequired"\
|
---|
1741 | ng-disabled="$mdAutocompleteCtrl.isDisabled"\
|
---|
1742 | ng-readonly="$mdAutocompleteCtrl.isReadonly"\
|
---|
1743 | ng-minlength="inputMinlength"\
|
---|
1744 | ng-maxlength="inputMaxlength"\
|
---|
1745 | ng-model="$mdAutocompleteCtrl.scope.searchText"\
|
---|
1746 | ng-mousedown="$mdAutocompleteCtrl.focusInput()"\
|
---|
1747 | ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
|
---|
1748 | ng-blur="$mdAutocompleteCtrl.blur($event)"\
|
---|
1749 | ng-focus="$mdAutocompleteCtrl.focus($event)"\
|
---|
1750 | placeholder="{{placeholder}}"\
|
---|
1751 | aria-label="{{placeholder}}"\
|
---|
1752 | ng-attr-aria-autocomplete="{{$mdAutocompleteCtrl.isDisabled ? undefined : \'list\'}}"\
|
---|
1753 | ng-attr-role="{{$mdAutocompleteCtrl.isDisabled ? undefined : \'combobox\'}}"\
|
---|
1754 | aria-haspopup="{{!$mdAutocompleteCtrl.isDisabled}}"\
|
---|
1755 | aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"\
|
---|
1756 | ng-attr-aria-owns="{{$mdAutocompleteCtrl.hidden || $mdAutocompleteCtrl.isDisabled ? undefined : \'ul-\' + $mdAutocompleteCtrl.id}}"\
|
---|
1757 | ng-attr-aria-activedescendant="{{!$mdAutocompleteCtrl.hidden && $mdAutocompleteCtrl.activeOption ? $mdAutocompleteCtrl.activeOption : undefined}}">';
|
---|
1758 | }
|
---|
1759 | }
|
---|
1760 |
|
---|
1761 | function getClearButton() {
|
---|
1762 | return '' +
|
---|
1763 | '<button ' +
|
---|
1764 | 'type="button" ' +
|
---|
1765 | 'aria-label="Clear Input" ' +
|
---|
1766 | 'tabindex="0" ' +
|
---|
1767 | 'ng-if="clearButton && $mdAutocompleteCtrl.scope.searchText" ' +
|
---|
1768 | 'ng-click="$mdAutocompleteCtrl.clear($event)">' +
|
---|
1769 | '<md-icon md-svg-src="' + $$mdSvgRegistry.mdClose + '"></md-icon>' +
|
---|
1770 | '</button>';
|
---|
1771 | }
|
---|
1772 | }
|
---|
1773 | };
|
---|
1774 | }
|
---|
1775 |
|
---|
1776 |
|
---|
1777 | MdAutocompleteItemScopeDirective['$inject'] = ["$compile", "$mdUtil"];angular
|
---|
1778 | .module('material.components.autocomplete')
|
---|
1779 | .directive('mdAutocompleteParentScope', MdAutocompleteItemScopeDirective);
|
---|
1780 |
|
---|
1781 | function MdAutocompleteItemScopeDirective($compile, $mdUtil) {
|
---|
1782 | return {
|
---|
1783 | restrict: 'AE',
|
---|
1784 | compile: compile,
|
---|
1785 | terminal: true,
|
---|
1786 | transclude: 'element'
|
---|
1787 | };
|
---|
1788 |
|
---|
1789 | function compile(tElement, tAttr, transclude) {
|
---|
1790 | return function postLink(scope, element, attr) {
|
---|
1791 | var ctrl = scope.$mdAutocompleteCtrl;
|
---|
1792 | var newScope = ctrl.parent.$new();
|
---|
1793 | var itemName = ctrl.itemName;
|
---|
1794 |
|
---|
1795 | // Watch for changes to our scope's variables and copy them to the new scope
|
---|
1796 | watchVariable('$index', '$index');
|
---|
1797 | watchVariable('item', itemName);
|
---|
1798 |
|
---|
1799 | // Ensure that $digest calls on our scope trigger $digest on newScope.
|
---|
1800 | connectScopes();
|
---|
1801 |
|
---|
1802 | // Link the element against newScope.
|
---|
1803 | transclude(newScope, function(clone) {
|
---|
1804 | element.after(clone);
|
---|
1805 | });
|
---|
1806 |
|
---|
1807 | /**
|
---|
1808 | * Creates a watcher for variables that are copied from the parent scope
|
---|
1809 | * @param variable
|
---|
1810 | * @param alias
|
---|
1811 | */
|
---|
1812 | function watchVariable(variable, alias) {
|
---|
1813 | newScope[alias] = scope[variable];
|
---|
1814 |
|
---|
1815 | scope.$watch(variable, function(value) {
|
---|
1816 | $mdUtil.nextTick(function() {
|
---|
1817 | newScope[alias] = value;
|
---|
1818 | });
|
---|
1819 | });
|
---|
1820 | }
|
---|
1821 |
|
---|
1822 | /**
|
---|
1823 | * Creates watchers on scope and newScope that ensure that for any
|
---|
1824 | * $digest of scope, newScope is also $digested.
|
---|
1825 | */
|
---|
1826 | function connectScopes() {
|
---|
1827 | var scopeDigesting = false;
|
---|
1828 | var newScopeDigesting = false;
|
---|
1829 |
|
---|
1830 | scope.$watch(function() {
|
---|
1831 | if (newScopeDigesting || scopeDigesting) {
|
---|
1832 | return;
|
---|
1833 | }
|
---|
1834 |
|
---|
1835 | scopeDigesting = true;
|
---|
1836 | scope.$$postDigest(function() {
|
---|
1837 | if (!newScopeDigesting) {
|
---|
1838 | newScope.$digest();
|
---|
1839 | }
|
---|
1840 |
|
---|
1841 | scopeDigesting = newScopeDigesting = false;
|
---|
1842 | });
|
---|
1843 | });
|
---|
1844 |
|
---|
1845 | newScope.$watch(function() {
|
---|
1846 | newScopeDigesting = true;
|
---|
1847 | });
|
---|
1848 | }
|
---|
1849 | };
|
---|
1850 | }
|
---|
1851 | }
|
---|
1852 |
|
---|
1853 | MdHighlightCtrl['$inject'] = ["$scope", "$element", "$attrs", "$mdUtil"];angular
|
---|
1854 | .module('material.components.autocomplete')
|
---|
1855 | .controller('MdHighlightCtrl', MdHighlightCtrl);
|
---|
1856 |
|
---|
1857 | function MdHighlightCtrl ($scope, $element, $attrs, $mdUtil) {
|
---|
1858 | this.$scope = $scope;
|
---|
1859 | this.$element = $element;
|
---|
1860 | this.$attrs = $attrs;
|
---|
1861 | this.$mdUtil = $mdUtil;
|
---|
1862 |
|
---|
1863 | // Cache the Regex to avoid rebuilding each time.
|
---|
1864 | this.regex = null;
|
---|
1865 | }
|
---|
1866 |
|
---|
1867 | MdHighlightCtrl.prototype.init = function(unsafeTermFn, unsafeContentFn) {
|
---|
1868 |
|
---|
1869 | this.flags = this.$attrs.mdHighlightFlags || '';
|
---|
1870 |
|
---|
1871 | this.unregisterFn = this.$scope.$watch(function($scope) {
|
---|
1872 | return {
|
---|
1873 | term: unsafeTermFn($scope),
|
---|
1874 | contentText: unsafeContentFn($scope)
|
---|
1875 | };
|
---|
1876 | }.bind(this), this.onRender.bind(this), true);
|
---|
1877 |
|
---|
1878 | this.$element.on('$destroy', this.unregisterFn);
|
---|
1879 | };
|
---|
1880 |
|
---|
1881 | /**
|
---|
1882 | * Triggered once a new change has been recognized and the highlighted
|
---|
1883 | * text needs to be updated.
|
---|
1884 | */
|
---|
1885 | MdHighlightCtrl.prototype.onRender = function(state, prevState) {
|
---|
1886 |
|
---|
1887 | var contentText = state.contentText;
|
---|
1888 |
|
---|
1889 | /* Update the regex if it's outdated, because we don't want to rebuilt it constantly. */
|
---|
1890 | if (this.regex === null || state.term !== prevState.term) {
|
---|
1891 | this.regex = this.createRegex(state.term, this.flags);
|
---|
1892 | }
|
---|
1893 |
|
---|
1894 | /* If a term is available apply the regex to the content */
|
---|
1895 | if (state.term) {
|
---|
1896 | this.applyRegex(contentText);
|
---|
1897 | } else {
|
---|
1898 | this.$element.text(contentText);
|
---|
1899 | }
|
---|
1900 |
|
---|
1901 | };
|
---|
1902 |
|
---|
1903 | /**
|
---|
1904 | * Decomposes the specified text into different tokens (whether match or not).
|
---|
1905 | * Breaking down the string guarantees proper XSS protection due to the native browser
|
---|
1906 | * escaping of unsafe text.
|
---|
1907 | */
|
---|
1908 | MdHighlightCtrl.prototype.applyRegex = function(text) {
|
---|
1909 | var tokens = this.resolveTokens(text);
|
---|
1910 |
|
---|
1911 | this.$element.empty();
|
---|
1912 |
|
---|
1913 | tokens.forEach(function (token) {
|
---|
1914 |
|
---|
1915 | if (token.isMatch) {
|
---|
1916 | var tokenEl = angular.element('<span class="highlight">').text(token.text);
|
---|
1917 |
|
---|
1918 | this.$element.append(tokenEl);
|
---|
1919 | } else {
|
---|
1920 | this.$element.append(document.createTextNode(token));
|
---|
1921 | }
|
---|
1922 |
|
---|
1923 | }.bind(this));
|
---|
1924 |
|
---|
1925 | };
|
---|
1926 |
|
---|
1927 | /**
|
---|
1928 | * Decomposes the specified text into different tokens by running the regex against the text.
|
---|
1929 | */
|
---|
1930 | MdHighlightCtrl.prototype.resolveTokens = function(string) {
|
---|
1931 | var tokens = [];
|
---|
1932 | var lastIndex = 0;
|
---|
1933 |
|
---|
1934 | // Use replace here, because it supports global and single regular expressions at same time.
|
---|
1935 | string.replace(this.regex, function(match, index) {
|
---|
1936 | appendToken(lastIndex, index);
|
---|
1937 |
|
---|
1938 | tokens.push({
|
---|
1939 | text: match,
|
---|
1940 | isMatch: true
|
---|
1941 | });
|
---|
1942 |
|
---|
1943 | lastIndex = index + match.length;
|
---|
1944 | });
|
---|
1945 |
|
---|
1946 | // Append the missing text as a token.
|
---|
1947 | appendToken(lastIndex);
|
---|
1948 |
|
---|
1949 | return tokens;
|
---|
1950 |
|
---|
1951 | function appendToken(from, to) {
|
---|
1952 | var targetText = string.slice(from, to);
|
---|
1953 | targetText && tokens.push(targetText);
|
---|
1954 | }
|
---|
1955 | };
|
---|
1956 |
|
---|
1957 | /** Creates a regex for the specified text with the given flags. */
|
---|
1958 | MdHighlightCtrl.prototype.createRegex = function(term, flags) {
|
---|
1959 | var startFlag = '', endFlag = '';
|
---|
1960 | var regexTerm = this.$mdUtil.sanitize(term);
|
---|
1961 |
|
---|
1962 | if (flags.indexOf('^') >= 0) startFlag = '^';
|
---|
1963 | if (flags.indexOf('$') >= 0) endFlag = '$';
|
---|
1964 |
|
---|
1965 | return new RegExp(startFlag + regexTerm + endFlag, flags.replace(/[$^]/g, ''));
|
---|
1966 | };
|
---|
1967 |
|
---|
1968 |
|
---|
1969 | MdHighlight['$inject'] = ["$interpolate", "$parse"];angular
|
---|
1970 | .module('material.components.autocomplete')
|
---|
1971 | .directive('mdHighlightText', MdHighlight);
|
---|
1972 |
|
---|
1973 | /**
|
---|
1974 | * @ngdoc directive
|
---|
1975 | * @name mdHighlightText
|
---|
1976 | * @module material.components.autocomplete
|
---|
1977 | *
|
---|
1978 | * @description
|
---|
1979 | * The `md-highlight-text` directive allows you to specify text that should be highlighted within
|
---|
1980 | * an element. Highlighted text will be wrapped in `<span class="highlight"></span>` which can
|
---|
1981 | * be styled through CSS. Please note that child elements may not be used with this directive.
|
---|
1982 | *
|
---|
1983 | * @param {string} md-highlight-text A model to be searched for
|
---|
1984 | * @param {string=} md-highlight-flags A list of flags (loosely based on JavaScript RexExp flags).
|
---|
1985 | * #### **Supported flags**:
|
---|
1986 | * - `g`: Find all matches within the provided text
|
---|
1987 | * - `i`: Ignore case when searching for matches
|
---|
1988 | * - `$`: Only match if the text ends with the search term
|
---|
1989 | * - `^`: Only match if the text begins with the search term
|
---|
1990 | *
|
---|
1991 | * @usage
|
---|
1992 | * <hljs lang="html">
|
---|
1993 | * <input placeholder="Enter a search term..." ng-model="searchTerm" type="text" />
|
---|
1994 | * <ul>
|
---|
1995 | * <li ng-repeat="result in results" md-highlight-text="searchTerm" md-highlight-flags="i">
|
---|
1996 | * {{result.text}}
|
---|
1997 | * </li>
|
---|
1998 | * </ul>
|
---|
1999 | * </hljs>
|
---|
2000 | */
|
---|
2001 |
|
---|
2002 | function MdHighlight ($interpolate, $parse) {
|
---|
2003 | return {
|
---|
2004 | terminal: true,
|
---|
2005 | controller: 'MdHighlightCtrl',
|
---|
2006 | compile: function mdHighlightCompile(tElement, tAttr) {
|
---|
2007 | var termExpr = $parse(tAttr.mdHighlightText);
|
---|
2008 | var unsafeContentExpr = $interpolate(tElement.html());
|
---|
2009 |
|
---|
2010 | return function mdHighlightLink(scope, element, attr, ctrl) {
|
---|
2011 | ctrl.init(termExpr, unsafeContentExpr);
|
---|
2012 | };
|
---|
2013 | }
|
---|
2014 | };
|
---|
2015 | }
|
---|
2016 |
|
---|
2017 | ngmaterial.components.autocomplete = angular.module("material.components.autocomplete"); |
---|