/*!
|
|
* Angular Material Design
|
|
* https://github.com/angular/material
|
|
* @license MIT
|
|
* v1.1.1
|
|
*/
|
|
goog.provide('ngmaterial.components.autocomplete');
|
|
goog.require('ngmaterial.components.icon');
|
|
goog.require('ngmaterial.components.virtualRepeat');
|
|
goog.require('ngmaterial.core');
|
|
/**
|
|
* @ngdoc module
|
|
* @name material.components.autocomplete
|
|
*/
|
|
/*
|
|
* @see js folder for autocomplete implementation
|
|
*/
|
|
angular.module('material.components.autocomplete', [
|
|
'material.core',
|
|
'material.components.icon',
|
|
'material.components.virtualRepeat'
|
|
]);
|
|
|
|
|
|
MdAutocompleteCtrl.$inject = ["$scope", "$element", "$mdUtil", "$mdConstant", "$mdTheming", "$window", "$animate", "$rootElement", "$attrs", "$q", "$log"];angular
|
|
.module('material.components.autocomplete')
|
|
.controller('MdAutocompleteCtrl', MdAutocompleteCtrl);
|
|
|
|
var ITEM_HEIGHT = 41,
|
|
MAX_HEIGHT = 5.5 * ITEM_HEIGHT,
|
|
MENU_PADDING = 8,
|
|
INPUT_PADDING = 2; // Padding provided by `md-input-container`
|
|
|
|
function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, $window,
|
|
$animate, $rootElement, $attrs, $q, $log) {
|
|
|
|
// Internal Variables.
|
|
var ctrl = this,
|
|
itemParts = $scope.itemsExpr.split(/ in /i),
|
|
itemExpr = itemParts[ 1 ],
|
|
elements = null,
|
|
cache = {},
|
|
noBlur = false,
|
|
selectedItemWatchers = [],
|
|
hasFocus = false,
|
|
lastCount = 0,
|
|
fetchesInProgress = 0,
|
|
enableWrapScroll = null,
|
|
inputModelCtrl = null;
|
|
|
|
// Public Exported Variables with handlers
|
|
defineProperty('hidden', handleHiddenChange, true);
|
|
|
|
// Public Exported Variables
|
|
ctrl.scope = $scope;
|
|
ctrl.parent = $scope.$parent;
|
|
ctrl.itemName = itemParts[ 0 ];
|
|
ctrl.matches = [];
|
|
ctrl.loading = false;
|
|
ctrl.hidden = true;
|
|
ctrl.index = null;
|
|
ctrl.messages = [];
|
|
ctrl.id = $mdUtil.nextUid();
|
|
ctrl.isDisabled = null;
|
|
ctrl.isRequired = null;
|
|
ctrl.isReadonly = null;
|
|
ctrl.hasNotFound = false;
|
|
|
|
// Public Exported Methods
|
|
ctrl.keydown = keydown;
|
|
ctrl.blur = blur;
|
|
ctrl.focus = focus;
|
|
ctrl.clear = clearValue;
|
|
ctrl.select = select;
|
|
ctrl.listEnter = onListEnter;
|
|
ctrl.listLeave = onListLeave;
|
|
ctrl.mouseUp = onMouseup;
|
|
ctrl.getCurrentDisplayValue = getCurrentDisplayValue;
|
|
ctrl.registerSelectedItemWatcher = registerSelectedItemWatcher;
|
|
ctrl.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher;
|
|
ctrl.notFoundVisible = notFoundVisible;
|
|
ctrl.loadingIsVisible = loadingIsVisible;
|
|
ctrl.positionDropdown = positionDropdown;
|
|
|
|
return init();
|
|
|
|
//-- initialization methods
|
|
|
|
/**
|
|
* Initialize the controller, setup watchers, gather elements
|
|
*/
|
|
function init () {
|
|
$mdUtil.initOptionalProperties($scope, $attrs, { searchText: '', selectedItem: null });
|
|
$mdTheming($element);
|
|
configureWatchers();
|
|
$mdUtil.nextTick(function () {
|
|
|
|
gatherElements();
|
|
moveDropdown();
|
|
|
|
// Forward all focus events to the input element when autofocus is enabled
|
|
if ($scope.autofocus) {
|
|
$element.on('focus', focusInputElement);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateModelValidators() {
|
|
if (!$scope.requireMatch || !inputModelCtrl) return;
|
|
|
|
inputModelCtrl.$setValidity('md-require-match', !!$scope.selectedItem);
|
|
}
|
|
|
|
/**
|
|
* Calculates the dropdown's position and applies the new styles to the menu element
|
|
* @returns {*}
|
|
*/
|
|
function positionDropdown () {
|
|
if (!elements) return $mdUtil.nextTick(positionDropdown, false, $scope);
|
|
var hrect = elements.wrap.getBoundingClientRect(),
|
|
vrect = elements.snap.getBoundingClientRect(),
|
|
root = elements.root.getBoundingClientRect(),
|
|
top = vrect.bottom - root.top,
|
|
bot = root.bottom - vrect.top,
|
|
left = hrect.left - root.left,
|
|
width = hrect.width,
|
|
offset = getVerticalOffset(),
|
|
styles;
|
|
// Adjust the width to account for the padding provided by `md-input-container`
|
|
if ($attrs.mdFloatingLabel) {
|
|
left += INPUT_PADDING;
|
|
width -= INPUT_PADDING * 2;
|
|
}
|
|
styles = {
|
|
left: left + 'px',
|
|
minWidth: width + 'px',
|
|
maxWidth: Math.max(hrect.right - root.left, root.right - hrect.left) - MENU_PADDING + 'px'
|
|
};
|
|
if (top > bot && root.height - hrect.bottom - MENU_PADDING < MAX_HEIGHT) {
|
|
styles.top = 'auto';
|
|
styles.bottom = bot + 'px';
|
|
styles.maxHeight = Math.min(MAX_HEIGHT, hrect.top - root.top - MENU_PADDING) + 'px';
|
|
} else {
|
|
styles.top = (top - offset) + 'px';
|
|
styles.bottom = 'auto';
|
|
styles.maxHeight = Math.min(MAX_HEIGHT, root.bottom + $mdUtil.scrollTop() - hrect.bottom - MENU_PADDING) + 'px';
|
|
}
|
|
|
|
elements.$.scrollContainer.css(styles);
|
|
$mdUtil.nextTick(correctHorizontalAlignment, false);
|
|
|
|
/**
|
|
* Calculates the vertical offset for floating label examples to account for ngMessages
|
|
* @returns {number}
|
|
*/
|
|
function getVerticalOffset () {
|
|
var offset = 0;
|
|
var inputContainer = $element.find('md-input-container');
|
|
if (inputContainer.length) {
|
|
var input = inputContainer.find('input');
|
|
offset = inputContainer.prop('offsetHeight');
|
|
offset -= input.prop('offsetTop');
|
|
offset -= input.prop('offsetHeight');
|
|
// add in the height left up top for the floating label text
|
|
offset += inputContainer.prop('offsetTop');
|
|
}
|
|
return offset;
|
|
}
|
|
|
|
/**
|
|
* Makes sure that the menu doesn't go off of the screen on either side.
|
|
*/
|
|
function correctHorizontalAlignment () {
|
|
var dropdown = elements.scrollContainer.getBoundingClientRect(),
|
|
styles = {};
|
|
if (dropdown.right > root.right - MENU_PADDING) {
|
|
styles.left = (hrect.right - dropdown.width) + 'px';
|
|
}
|
|
elements.$.scrollContainer.css(styles);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the dropdown menu to the body tag in order to avoid z-index and overflow issues.
|
|
*/
|
|
function moveDropdown () {
|
|
if (!elements.$.root.length) return;
|
|
$mdTheming(elements.$.scrollContainer);
|
|
elements.$.scrollContainer.detach();
|
|
elements.$.root.append(elements.$.scrollContainer);
|
|
if ($animate.pin) $animate.pin(elements.$.scrollContainer, $rootElement);
|
|
}
|
|
|
|
/**
|
|
* Sends focus to the input element.
|
|
*/
|
|
function focusInputElement () {
|
|
elements.input.focus();
|
|
}
|
|
|
|
/**
|
|
* Sets up any watchers used by autocomplete
|
|
*/
|
|
function configureWatchers () {
|
|
var wait = parseInt($scope.delay, 10) || 0;
|
|
$attrs.$observe('disabled', function (value) { ctrl.isDisabled = $mdUtil.parseAttributeBoolean(value, false); });
|
|
$attrs.$observe('required', function (value) { ctrl.isRequired = $mdUtil.parseAttributeBoolean(value, false); });
|
|
$attrs.$observe('readonly', function (value) { ctrl.isReadonly = $mdUtil.parseAttributeBoolean(value, false); });
|
|
$scope.$watch('searchText', wait ? $mdUtil.debounce(handleSearchText, wait) : handleSearchText);
|
|
$scope.$watch('selectedItem', selectedItemChange);
|
|
angular.element($window).on('resize', positionDropdown);
|
|
$scope.$on('$destroy', cleanup);
|
|
}
|
|
|
|
/**
|
|
* Removes any events or leftover elements created by this controller
|
|
*/
|
|
function cleanup () {
|
|
if (!ctrl.hidden) {
|
|
$mdUtil.enableScrolling();
|
|
}
|
|
|
|
angular.element($window).off('resize', positionDropdown);
|
|
if ( elements ){
|
|
var items = ['ul', 'scroller', 'scrollContainer', 'input'];
|
|
angular.forEach(items, function(key){
|
|
elements.$[key].remove();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gathers all of the elements needed for this controller
|
|
*/
|
|
function gatherElements () {
|
|
elements = {
|
|
main: $element[0],
|
|
scrollContainer: $element[0].querySelector('.md-virtual-repeat-container'),
|
|
scroller: $element[0].querySelector('.md-virtual-repeat-scroller'),
|
|
ul: $element.find('ul')[0],
|
|
input: $element.find('input')[0],
|
|
wrap: $element.find('md-autocomplete-wrap')[0],
|
|
root: document.body
|
|
};
|
|
|
|
elements.li = elements.ul.getElementsByTagName('li');
|
|
elements.snap = getSnapTarget();
|
|
elements.$ = getAngularElements(elements);
|
|
|
|
inputModelCtrl = elements.$.input.controller('ngModel');
|
|
}
|
|
|
|
/**
|
|
* Finds the element that the menu will base its position on
|
|
* @returns {*}
|
|
*/
|
|
function getSnapTarget () {
|
|
for (var element = $element; element.length; element = element.parent()) {
|
|
if (angular.isDefined(element.attr('md-autocomplete-snap'))) return element[ 0 ];
|
|
}
|
|
return elements.wrap;
|
|
}
|
|
|
|
/**
|
|
* Gathers angular-wrapped versions of each element
|
|
* @param elements
|
|
* @returns {{}}
|
|
*/
|
|
function getAngularElements (elements) {
|
|
var obj = {};
|
|
for (var key in elements) {
|
|
if (elements.hasOwnProperty(key)) obj[ key ] = angular.element(elements[ key ]);
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
//-- event/change handlers
|
|
|
|
/**
|
|
* Handles changes to the `hidden` property.
|
|
* @param hidden
|
|
* @param oldHidden
|
|
*/
|
|
function handleHiddenChange (hidden, oldHidden) {
|
|
if (!hidden && oldHidden) {
|
|
positionDropdown();
|
|
|
|
if (elements) {
|
|
$mdUtil.disableScrollAround(elements.ul);
|
|
enableWrapScroll = disableElementScrollEvents(angular.element(elements.wrap));
|
|
}
|
|
} else if (hidden && !oldHidden) {
|
|
$mdUtil.enableScrolling();
|
|
|
|
if (enableWrapScroll) {
|
|
enableWrapScroll();
|
|
enableWrapScroll = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disables scrolling for a specific element
|
|
*/
|
|
function disableElementScrollEvents(element) {
|
|
|
|
function preventDefault(e) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
element.on('wheel', preventDefault);
|
|
element.on('touchmove', preventDefault);
|
|
|
|
return function() {
|
|
element.off('wheel', preventDefault);
|
|
element.off('touchmove', preventDefault);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* When the user mouses over the dropdown menu, ignore blur events.
|
|
*/
|
|
function onListEnter () {
|
|
noBlur = true;
|
|
}
|
|
|
|
/**
|
|
* When the user's mouse leaves the menu, blur events may hide the menu again.
|
|
*/
|
|
function onListLeave () {
|
|
if (!hasFocus && !ctrl.hidden) elements.input.focus();
|
|
noBlur = false;
|
|
ctrl.hidden = shouldHide();
|
|
}
|
|
|
|
/**
|
|
* When the mouse button is released, send focus back to the input field.
|
|
*/
|
|
function onMouseup () {
|
|
elements.input.focus();
|
|
}
|
|
|
|
/**
|
|
* Handles changes to the selected item.
|
|
* @param selectedItem
|
|
* @param previousSelectedItem
|
|
*/
|
|
function selectedItemChange (selectedItem, previousSelectedItem) {
|
|
|
|
updateModelValidators();
|
|
|
|
if (selectedItem) {
|
|
getDisplayValue(selectedItem).then(function (val) {
|
|
$scope.searchText = val;
|
|
handleSelectedItemChange(selectedItem, previousSelectedItem);
|
|
});
|
|
} else if (previousSelectedItem && $scope.searchText) {
|
|
getDisplayValue(previousSelectedItem).then(function(displayValue) {
|
|
// Clear the searchText, when the selectedItem is set to null.
|
|
// Do not clear the searchText, when the searchText isn't matching with the previous
|
|
// selected item.
|
|
if (displayValue.toString().toLowerCase() === $scope.searchText.toLowerCase()) {
|
|
$scope.searchText = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (selectedItem !== previousSelectedItem) announceItemChange();
|
|
}
|
|
|
|
/**
|
|
* Use the user-defined expression to announce changes each time a new item is selected
|
|
*/
|
|
function announceItemChange () {
|
|
angular.isFunction($scope.itemChange) && $scope.itemChange(getItemAsNameVal($scope.selectedItem));
|
|
}
|
|
|
|
/**
|
|
* Use the user-defined expression to announce changes each time the search text is changed
|
|
*/
|
|
function announceTextChange () {
|
|
angular.isFunction($scope.textChange) && $scope.textChange();
|
|
}
|
|
|
|
/**
|
|
* Calls any external watchers listening for the selected item. Used in conjunction with
|
|
* `registerSelectedItemWatcher`.
|
|
* @param selectedItem
|
|
* @param previousSelectedItem
|
|
*/
|
|
function handleSelectedItemChange (selectedItem, previousSelectedItem) {
|
|
selectedItemWatchers.forEach(function (watcher) { watcher(selectedItem, previousSelectedItem); });
|
|
}
|
|
|
|
/**
|
|
* Register a function to be called when the selected item changes.
|
|
* @param cb
|
|
*/
|
|
function registerSelectedItemWatcher (cb) {
|
|
if (selectedItemWatchers.indexOf(cb) == -1) {
|
|
selectedItemWatchers.push(cb);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unregister a function previously registered for selected item changes.
|
|
* @param cb
|
|
*/
|
|
function unregisterSelectedItemWatcher (cb) {
|
|
var i = selectedItemWatchers.indexOf(cb);
|
|
if (i != -1) {
|
|
selectedItemWatchers.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles changes to the searchText property.
|
|
* @param searchText
|
|
* @param previousSearchText
|
|
*/
|
|
function handleSearchText (searchText, previousSearchText) {
|
|
ctrl.index = getDefaultIndex();
|
|
|
|
// do nothing on init
|
|
if (searchText === previousSearchText) return;
|
|
|
|
updateModelValidators();
|
|
|
|
getDisplayValue($scope.selectedItem).then(function (val) {
|
|
// clear selected item if search text no longer matches it
|
|
if (searchText !== val) {
|
|
$scope.selectedItem = null;
|
|
|
|
// trigger change event if available
|
|
if (searchText !== previousSearchText) announceTextChange();
|
|
|
|
// cancel results if search text is not long enough
|
|
if (!isMinLengthMet()) {
|
|
ctrl.matches = [];
|
|
setLoading(false);
|
|
updateMessages();
|
|
} else {
|
|
handleQuery();
|
|
}
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
* Handles input blur event, determines if the dropdown should hide.
|
|
*/
|
|
function blur($event) {
|
|
hasFocus = false;
|
|
|
|
if (!noBlur) {
|
|
ctrl.hidden = shouldHide();
|
|
evalAttr('ngBlur', { $event: $event });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force blur on input element
|
|
* @param forceBlur
|
|
*/
|
|
function doBlur(forceBlur) {
|
|
if (forceBlur) {
|
|
noBlur = false;
|
|
hasFocus = false;
|
|
}
|
|
elements.input.blur();
|
|
}
|
|
|
|
/**
|
|
* Handles input focus event, determines if the dropdown should show.
|
|
*/
|
|
function focus($event) {
|
|
hasFocus = true;
|
|
|
|
if (isSearchable() && isMinLengthMet()) {
|
|
handleQuery();
|
|
}
|
|
|
|
ctrl.hidden = shouldHide();
|
|
|
|
evalAttr('ngFocus', { $event: $event });
|
|
}
|
|
|
|
/**
|
|
* Handles keyboard input.
|
|
* @param event
|
|
*/
|
|
function keydown (event) {
|
|
switch (event.keyCode) {
|
|
case $mdConstant.KEY_CODE.DOWN_ARROW:
|
|
if (ctrl.loading) return;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
ctrl.index = Math.min(ctrl.index + 1, ctrl.matches.length - 1);
|
|
updateScroll();
|
|
updateMessages();
|
|
break;
|
|
case $mdConstant.KEY_CODE.UP_ARROW:
|
|
if (ctrl.loading) return;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
ctrl.index = ctrl.index < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1);
|
|
updateScroll();
|
|
updateMessages();
|
|
break;
|
|
case $mdConstant.KEY_CODE.TAB:
|
|
// If we hit tab, assume that we've left the list so it will close
|
|
onListLeave();
|
|
|
|
if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
|
|
select(ctrl.index);
|
|
break;
|
|
case $mdConstant.KEY_CODE.ENTER:
|
|
if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return;
|
|
if (hasSelection()) return;
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
select(ctrl.index);
|
|
break;
|
|
case $mdConstant.KEY_CODE.ESCAPE:
|
|
event.preventDefault(); // Prevent browser from always clearing input
|
|
if (!shouldProcessEscape()) return;
|
|
event.stopPropagation();
|
|
|
|
clearSelectedItem();
|
|
if ($scope.searchText && hasEscapeOption('clear')) {
|
|
clearSearchText();
|
|
}
|
|
|
|
// Manually hide (needed for mdNotFound support)
|
|
ctrl.hidden = true;
|
|
|
|
if (hasEscapeOption('blur')) {
|
|
// Force the component to blur if they hit escape
|
|
doBlur(true);
|
|
}
|
|
|
|
break;
|
|
default:
|
|
}
|
|
}
|
|
|
|
//-- getters
|
|
|
|
/**
|
|
* Returns the minimum length needed to display the dropdown.
|
|
* @returns {*}
|
|
*/
|
|
function getMinLength () {
|
|
return angular.isNumber($scope.minLength) ? $scope.minLength : 1;
|
|
}
|
|
|
|
/**
|
|
* Returns the display value for an item.
|
|
* @param item
|
|
* @returns {*}
|
|
*/
|
|
function getDisplayValue (item) {
|
|
return $q.when(getItemText(item) || item).then(function(itemText) {
|
|
if (itemText && !angular.isString(itemText)) {
|
|
$log.warn('md-autocomplete: Could not resolve display value to a string. ' +
|
|
'Please check the `md-item-text` attribute.');
|
|
}
|
|
|
|
return itemText;
|
|
});
|
|
|
|
/**
|
|
* Getter function to invoke user-defined expression (in the directive)
|
|
* to convert your object to a single string.
|
|
*/
|
|
function getItemText (item) {
|
|
return (item && $scope.itemText) ? $scope.itemText(getItemAsNameVal(item)) : null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the locals object for compiling item templates.
|
|
* @param item
|
|
* @returns {{}}
|
|
*/
|
|
function getItemAsNameVal (item) {
|
|
if (!item) return undefined;
|
|
|
|
var locals = {};
|
|
if (ctrl.itemName) locals[ ctrl.itemName ] = item;
|
|
|
|
return locals;
|
|
}
|
|
|
|
/**
|
|
* Returns the default index based on whether or not autoselect is enabled.
|
|
* @returns {number}
|
|
*/
|
|
function getDefaultIndex () {
|
|
return $scope.autoselect ? 0 : -1;
|
|
}
|
|
|
|
/**
|
|
* Sets the loading parameter and updates the hidden state.
|
|
* @param value {boolean} Whether or not the component is currently loading.
|
|
*/
|
|
function setLoading(value) {
|
|
if (ctrl.loading != value) {
|
|
ctrl.loading = value;
|
|
}
|
|
|
|
// Always refresh the hidden variable as something else might have changed
|
|
ctrl.hidden = shouldHide();
|
|
}
|
|
|
|
/**
|
|
* Determines if the menu should be hidden.
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldHide () {
|
|
if (!isSearchable()) return true; // Hide when not able to query
|
|
else return !shouldShow(); // Hide when the dropdown is not able to show.
|
|
}
|
|
|
|
/**
|
|
* Determines whether the autocomplete is able to query within the current state.
|
|
* @returns {boolean}
|
|
*/
|
|
function isSearchable() {
|
|
if (ctrl.loading && !hasMatches()) return false; // No query when query is in progress.
|
|
else if (hasSelection()) return false; // No query if there is already a selection
|
|
else if (!hasFocus) return false; // No query if the input does not have focus
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Determines if the escape keydown should be processed
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldProcessEscape() {
|
|
return hasEscapeOption('blur') || !ctrl.hidden || ctrl.loading || hasEscapeOption('clear') && $scope.searchText;
|
|
}
|
|
|
|
/**
|
|
* Determines if an escape option is set
|
|
* @returns {boolean}
|
|
*/
|
|
function hasEscapeOption(option) {
|
|
return !$scope.escapeOptions || $scope.escapeOptions.toLowerCase().indexOf(option) !== -1;
|
|
}
|
|
|
|
/**
|
|
* Determines if the menu should be shown.
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldShow() {
|
|
return (isMinLengthMet() && hasMatches()) || notFoundVisible();
|
|
}
|
|
|
|
/**
|
|
* Returns true if the search text has matches.
|
|
* @returns {boolean}
|
|
*/
|
|
function hasMatches() {
|
|
return ctrl.matches.length ? true : false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the autocomplete has a valid selection.
|
|
* @returns {boolean}
|
|
*/
|
|
function hasSelection() {
|
|
return ctrl.scope.selectedItem ? true : false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the loading indicator is, or should be, visible.
|
|
* @returns {boolean}
|
|
*/
|
|
function loadingIsVisible() {
|
|
return ctrl.loading && !hasSelection();
|
|
}
|
|
|
|
/**
|
|
* Returns the display value of the current item.
|
|
* @returns {*}
|
|
*/
|
|
function getCurrentDisplayValue () {
|
|
return getDisplayValue(ctrl.matches[ ctrl.index ]);
|
|
}
|
|
|
|
/**
|
|
* Determines if the minimum length is met by the search text.
|
|
* @returns {*}
|
|
*/
|
|
function isMinLengthMet () {
|
|
return ($scope.searchText || '').length >= getMinLength();
|
|
}
|
|
|
|
//-- actions
|
|
|
|
/**
|
|
* Defines a public property with a handler and a default value.
|
|
* @param key
|
|
* @param handler
|
|
* @param value
|
|
*/
|
|
function defineProperty (key, handler, value) {
|
|
Object.defineProperty(ctrl, key, {
|
|
get: function () { return value; },
|
|
set: function (newValue) {
|
|
var oldValue = value;
|
|
value = newValue;
|
|
handler(newValue, oldValue);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Selects the item at the given index.
|
|
* @param index
|
|
*/
|
|
function select (index) {
|
|
//-- force form to update state for validation
|
|
$mdUtil.nextTick(function () {
|
|
getDisplayValue(ctrl.matches[ index ]).then(function (val) {
|
|
var ngModel = elements.$.input.controller('ngModel');
|
|
ngModel.$setViewValue(val);
|
|
ngModel.$render();
|
|
}).finally(function () {
|
|
$scope.selectedItem = ctrl.matches[ index ];
|
|
setLoading(false);
|
|
});
|
|
}, false);
|
|
}
|
|
|
|
/**
|
|
* Clears the searchText value and selected item.
|
|
*/
|
|
function clearValue () {
|
|
clearSelectedItem();
|
|
clearSearchText();
|
|
}
|
|
|
|
/**
|
|
* Clears the selected item
|
|
*/
|
|
function clearSelectedItem () {
|
|
// Reset our variables
|
|
ctrl.index = 0;
|
|
ctrl.matches = [];
|
|
}
|
|
|
|
/**
|
|
* Clears the searchText value
|
|
*/
|
|
function clearSearchText () {
|
|
// Set the loading to true so we don't see flashes of content.
|
|
// The flashing will only occur when an async request is running.
|
|
// So the loading process will stop when the results had been retrieved.
|
|
setLoading(true);
|
|
|
|
$scope.searchText = '';
|
|
|
|
// Normally, triggering the change / input event is unnecessary, because the browser detects it properly.
|
|
// But some browsers are not detecting it properly, which means that we have to trigger the event.
|
|
// Using the `input` is not working properly, because for example IE11 is not supporting the `input` event.
|
|
// The `change` event is a good alternative and is supported by all supported browsers.
|
|
var eventObj = document.createEvent('CustomEvent');
|
|
eventObj.initCustomEvent('change', true, true, { value: '' });
|
|
elements.input.dispatchEvent(eventObj);
|
|
|
|
// For some reason, firing the above event resets the value of $scope.searchText if
|
|
// $scope.searchText has a space character at the end, so we blank it one more time and then
|
|
// focus.
|
|
elements.input.blur();
|
|
$scope.searchText = '';
|
|
elements.input.focus();
|
|
}
|
|
|
|
/**
|
|
* Fetches the results for the provided search text.
|
|
* @param searchText
|
|
*/
|
|
function fetchResults (searchText) {
|
|
var items = $scope.$parent.$eval(itemExpr),
|
|
term = searchText.toLowerCase(),
|
|
isList = angular.isArray(items),
|
|
isPromise = !!items.then; // Every promise should contain a `then` property
|
|
|
|
if (isList) onResultsRetrieved(items);
|
|
else if (isPromise) handleAsyncResults(items);
|
|
|
|
function handleAsyncResults(items) {
|
|
if ( !items ) return;
|
|
|
|
items = $q.when(items);
|
|
fetchesInProgress++;
|
|
setLoading(true);
|
|
|
|
$mdUtil.nextTick(function () {
|
|
items
|
|
.then(onResultsRetrieved)
|
|
.finally(function(){
|
|
if (--fetchesInProgress === 0) {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
},true, $scope);
|
|
}
|
|
|
|
function onResultsRetrieved(matches) {
|
|
cache[term] = matches;
|
|
|
|
// Just cache the results if the request is now outdated.
|
|
// The request becomes outdated, when the new searchText has changed during the result fetching.
|
|
if ((searchText || '') !== ($scope.searchText || '')) {
|
|
return;
|
|
}
|
|
|
|
handleResults(matches);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the ARIA messages
|
|
*/
|
|
function updateMessages () {
|
|
getCurrentDisplayValue().then(function (msg) {
|
|
ctrl.messages = [ getCountMessage(), msg ];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the ARIA message for how many results match the current query.
|
|
* @returns {*}
|
|
*/
|
|
function getCountMessage () {
|
|
if (lastCount === ctrl.matches.length) return '';
|
|
lastCount = ctrl.matches.length;
|
|
switch (ctrl.matches.length) {
|
|
case 0:
|
|
return 'There are no matches available.';
|
|
case 1:
|
|
return 'There is 1 match available.';
|
|
default:
|
|
return 'There are ' + ctrl.matches.length + ' matches available.';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes sure that the focused element is within view.
|
|
*/
|
|
function updateScroll () {
|
|
if (!elements.li[0]) return;
|
|
var height = elements.li[0].offsetHeight,
|
|
top = height * ctrl.index,
|
|
bot = top + height,
|
|
hgt = elements.scroller.clientHeight,
|
|
scrollTop = elements.scroller.scrollTop;
|
|
if (top < scrollTop) {
|
|
scrollTo(top);
|
|
} else if (bot > scrollTop + hgt) {
|
|
scrollTo(bot - hgt);
|
|
}
|
|
}
|
|
|
|
function isPromiseFetching() {
|
|
return fetchesInProgress !== 0;
|
|
}
|
|
|
|
function scrollTo (offset) {
|
|
elements.$.scrollContainer.controller('mdVirtualRepeatContainer').scrollTo(offset);
|
|
}
|
|
|
|
function notFoundVisible () {
|
|
var textLength = (ctrl.scope.searchText || '').length;
|
|
|
|
return ctrl.hasNotFound && !hasMatches() && (!ctrl.loading || isPromiseFetching()) && textLength >= getMinLength() && (hasFocus || noBlur) && !hasSelection();
|
|
}
|
|
|
|
/**
|
|
* Starts the query to gather the results for the current searchText. Attempts to return cached
|
|
* results first, then forwards the process to `fetchResults` if necessary.
|
|
*/
|
|
function handleQuery () {
|
|
var searchText = $scope.searchText || '';
|
|
var term = searchText.toLowerCase();
|
|
|
|
// If caching is enabled and the current searchText is stored in the cache
|
|
if (!$scope.noCache && cache[term]) {
|
|
// The results should be handled as same as a normal un-cached request does.
|
|
handleResults(cache[term]);
|
|
} else {
|
|
fetchResults(searchText);
|
|
}
|
|
|
|
ctrl.hidden = shouldHide();
|
|
}
|
|
|
|
/**
|
|
* Handles the retrieved results by showing them in the autocompletes dropdown.
|
|
* @param results Retrieved results
|
|
*/
|
|
function handleResults(results) {
|
|
ctrl.matches = results;
|
|
ctrl.hidden = shouldHide();
|
|
|
|
// If loading is in progress, then we'll end the progress. This is needed for example,
|
|
// when the `clear` button was clicked, because there we always show the loading process, to prevent flashing.
|
|
if (ctrl.loading) setLoading(false);
|
|
|
|
if ($scope.selectOnMatch) selectItemOnMatch();
|
|
|
|
updateMessages();
|
|
positionDropdown();
|
|
}
|
|
|
|
/**
|
|
* If there is only one matching item and the search text matches its display value exactly,
|
|
* automatically select that item. Note: This function is only called if the user uses the
|
|
* `md-select-on-match` flag.
|
|
*/
|
|
function selectItemOnMatch () {
|
|
var searchText = $scope.searchText,
|
|
matches = ctrl.matches,
|
|
item = matches[ 0 ];
|
|
if (matches.length === 1) getDisplayValue(item).then(function (displayValue) {
|
|
var isMatching = searchText == displayValue;
|
|
if ($scope.matchInsensitive && !isMatching) {
|
|
isMatching = searchText.toLowerCase() == displayValue.toLowerCase();
|
|
}
|
|
|
|
if (isMatching) select(0);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Evaluates an attribute expression against the parent scope.
|
|
* @param {String} attr Name of the attribute to be evaluated.
|
|
* @param {Object?} locals Properties to be injected into the evaluation context.
|
|
*/
|
|
function evalAttr(attr, locals) {
|
|
if ($attrs[attr]) {
|
|
$scope.$parent.$eval($attrs[attr], locals || {});
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
MdAutocomplete.$inject = ["$$mdSvgRegistry"];angular
|
|
.module('material.components.autocomplete')
|
|
.directive('mdAutocomplete', MdAutocomplete);
|
|
|
|
/**
|
|
* @ngdoc directive
|
|
* @name mdAutocomplete
|
|
* @module material.components.autocomplete
|
|
*
|
|
* @description
|
|
* `<md-autocomplete>` is a special input component with a drop-down of all possible matches to a
|
|
* custom query. This component allows you to provide real-time suggestions as the user types
|
|
* in the input area.
|
|
*
|
|
* To start, you will need to specify the required parameters and provide a template for your
|
|
* results. The content inside `md-autocomplete` will be treated as a template.
|
|
*
|
|
* In more complex cases, you may want to include other content such as a message to display when
|
|
* no matches were found. You can do this by wrapping your template in `md-item-template` and
|
|
* adding a tag for `md-not-found`. An example of this is shown below.
|
|
*
|
|
* To reset the displayed value you must clear both values for `md-search-text` and `md-selected-item`.
|
|
*
|
|
* ### Validation
|
|
*
|
|
* You can use `ng-messages` to include validation the same way that you would normally validate;
|
|
* however, if you want to replicate a standard input with a floating label, you will have to
|
|
* do the following:
|
|
*
|
|
* - Make sure that your template is wrapped in `md-item-template`
|
|
* - Add your `ng-messages` code inside of `md-autocomplete`
|
|
* - Add your validation properties to `md-autocomplete` (ie. `required`)
|
|
* - Add a `name` to `md-autocomplete` (to be used on the generated `input`)
|
|
*
|
|
* There is an example below of how this should look.
|
|
*
|
|
* ### Notes
|
|
* The `md-autocomplete` uses the the <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeat</a>
|
|
* directive for displaying the results inside of the dropdown.<br/>
|
|
* > When encountering issues regarding the item template please take a look at the
|
|
* <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeatContainer</a> documentation.
|
|
*
|
|
*
|
|
* @param {expression} md-items An expression in the format of `item in items` to iterate over
|
|
* matches for your search.
|
|
* @param {expression=} md-selected-item-change An expression to be run each time a new item is
|
|
* selected
|
|
* @param {expression=} md-search-text-change An expression to be run each time the search text
|
|
* updates
|
|
* @param {expression=} md-search-text A model to bind the search query text to
|
|
* @param {object=} md-selected-item A model to bind the selected item to
|
|
* @param {expression=} md-item-text An expression that will convert your object to a single string.
|
|
* @param {string=} placeholder Placeholder text that will be forwarded to the input.
|
|
* @param {boolean=} md-no-cache Disables the internal caching that happens in autocomplete
|
|
* @param {boolean=} ng-disabled Determines whether or not to disable the input field
|
|
* @param {boolean=} md-require-match When set to true, the autocomplete will add a validator,
|
|
* which will evaluate to false, when no item is currently selected.
|
|
* @param {number=} md-min-length Specifies the minimum length of text before autocomplete will
|
|
* make suggestions
|
|
* @param {number=} md-delay Specifies the amount of time (in milliseconds) to wait before looking
|
|
* for results
|
|
* @param {boolean=} md-autofocus If true, the autocomplete will be automatically focused when a `$mdDialog`,
|
|
* `$mdBottomsheet` or `$mdSidenav`, which contains the autocomplete, is opening. <br/><br/>
|
|
* Also the autocomplete will immediately focus the input element.
|
|
* @param {boolean=} md-no-asterisk When present, asterisk will not be appended to the floating label
|
|
* @param {boolean=} md-autoselect If set to true, the first item will be automatically selected
|
|
* in the dropdown upon open.
|
|
* @param {string=} md-menu-class This will be applied to the dropdown menu for styling
|
|
* @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in
|
|
* `md-input-container`
|
|
* @param {string=} md-input-name The name attribute given to the input element to be used with
|
|
* FormController
|
|
* @param {string=} md-select-on-focus When present the inputs text will be automatically selected
|
|
* on focus.
|
|
* @param {string=} md-input-id An ID to be added to the input element
|
|
* @param {number=} md-input-minlength The minimum length for the input's value for validation
|
|
* @param {number=} md-input-maxlength The maximum length for the input's value for validation
|
|
* @param {boolean=} md-select-on-match When set, autocomplete will automatically select exact
|
|
* the item if the search text is an exact match. <br/><br/>
|
|
* Exact match means that there is only one match showing up.
|
|
* @param {boolean=} md-match-case-insensitive When set and using `md-select-on-match`, autocomplete
|
|
* will select on case-insensitive match
|
|
* @param {string=} md-escape-options Override escape key logic. Default is `blur clear`.<br/>
|
|
* Options: `blur | clear`, `none`
|
|
*
|
|
* @usage
|
|
* ### Basic Example
|
|
* <hljs lang="html">
|
|
* <md-autocomplete
|
|
* md-selected-item="selectedItem"
|
|
* md-search-text="searchText"
|
|
* md-items="item in getMatches(searchText)"
|
|
* md-item-text="item.display">
|
|
* <span md-highlight-text="searchText">{{item.display}}</span>
|
|
* </md-autocomplete>
|
|
* </hljs>
|
|
*
|
|
* ### Example with "not found" message
|
|
* <hljs lang="html">
|
|
* <md-autocomplete
|
|
* md-selected-item="selectedItem"
|
|
* md-search-text="searchText"
|
|
* md-items="item in getMatches(searchText)"
|
|
* md-item-text="item.display">
|
|
* <md-item-template>
|
|
* <span md-highlight-text="searchText">{{item.display}}</span>
|
|
* </md-item-template>
|
|
* <md-not-found>
|
|
* No matches found.
|
|
* </md-not-found>
|
|
* </md-autocomplete>
|
|
* </hljs>
|
|
*
|
|
* In this example, our code utilizes `md-item-template` and `md-not-found` to specify the
|
|
* different parts that make up our component.
|
|
*
|
|
* ### Example with validation
|
|
* <hljs lang="html">
|
|
* <form name="autocompleteForm">
|
|
* <md-autocomplete
|
|
* required
|
|
* md-input-name="autocomplete"
|
|
* md-selected-item="selectedItem"
|
|
* md-search-text="searchText"
|
|
* md-items="item in getMatches(searchText)"
|
|
* md-item-text="item.display">
|
|
* <md-item-template>
|
|
* <span md-highlight-text="searchText">{{item.display}}</span>
|
|
* </md-item-template>
|
|
* <div ng-messages="autocompleteForm.autocomplete.$error">
|
|
* <div ng-message="required">This field is required</div>
|
|
* </div>
|
|
* </md-autocomplete>
|
|
* </form>
|
|
* </hljs>
|
|
*
|
|
* In this example, our code utilizes `md-item-template` and `ng-messages` to specify
|
|
* input validation for the field.
|
|
*/
|
|
|
|
function MdAutocomplete ($$mdSvgRegistry) {
|
|
|
|
return {
|
|
controller: 'MdAutocompleteCtrl',
|
|
controllerAs: '$mdAutocompleteCtrl',
|
|
scope: {
|
|
inputName: '@mdInputName',
|
|
inputMinlength: '@mdInputMinlength',
|
|
inputMaxlength: '@mdInputMaxlength',
|
|
searchText: '=?mdSearchText',
|
|
selectedItem: '=?mdSelectedItem',
|
|
itemsExpr: '@mdItems',
|
|
itemText: '&mdItemText',
|
|
placeholder: '@placeholder',
|
|
noCache: '=?mdNoCache',
|
|
requireMatch: '=?mdRequireMatch',
|
|
selectOnMatch: '=?mdSelectOnMatch',
|
|
matchInsensitive: '=?mdMatchCaseInsensitive',
|
|
itemChange: '&?mdSelectedItemChange',
|
|
textChange: '&?mdSearchTextChange',
|
|
minLength: '=?mdMinLength',
|
|
delay: '=?mdDelay',
|
|
autofocus: '=?mdAutofocus',
|
|
floatingLabel: '@?mdFloatingLabel',
|
|
autoselect: '=?mdAutoselect',
|
|
menuClass: '@?mdMenuClass',
|
|
inputId: '@?mdInputId',
|
|
escapeOptions: '@?mdEscapeOptions'
|
|
},
|
|
link: function(scope, element, attrs, controller) {
|
|
// Retrieve the state of using a md-not-found template by using our attribute, which will
|
|
// be added to the element in the template function.
|
|
controller.hasNotFound = !!element.attr('md-has-not-found');
|
|
},
|
|
template: function (element, attr) {
|
|
var noItemsTemplate = getNoItemsTemplate(),
|
|
itemTemplate = getItemTemplate(),
|
|
leftover = element.html(),
|
|
tabindex = attr.tabindex;
|
|
|
|
// Set our attribute for the link function above which runs later.
|
|
// We will set an attribute, because otherwise the stored variables will be trashed when
|
|
// removing the element is hidden while retrieving the template. For example when using ngIf.
|
|
if (noItemsTemplate) element.attr('md-has-not-found', true);
|
|
|
|
// Always set our tabindex of the autocomplete directive to -1, because our input
|
|
// will hold the actual tabindex.
|
|
element.attr('tabindex', '-1');
|
|
|
|
return '\
|
|
<md-autocomplete-wrap\
|
|
ng-class="{ \'md-whiteframe-z1\': !floatingLabel, \'md-menu-showing\': !$mdAutocompleteCtrl.hidden }">\
|
|
' + getInputElement() + '\
|
|
<md-progress-linear\
|
|
class="' + (attr.mdFloatingLabel ? 'md-inline' : '') + '"\
|
|
ng-if="$mdAutocompleteCtrl.loadingIsVisible()"\
|
|
md-mode="indeterminate"></md-progress-linear>\
|
|
<md-virtual-repeat-container\
|
|
md-auto-shrink\
|
|
md-auto-shrink-min="1"\
|
|
ng-mouseenter="$mdAutocompleteCtrl.listEnter()"\
|
|
ng-mouseleave="$mdAutocompleteCtrl.listLeave()"\
|
|
ng-mouseup="$mdAutocompleteCtrl.mouseUp()"\
|
|
ng-hide="$mdAutocompleteCtrl.hidden"\
|
|
class="md-autocomplete-suggestions-container md-whiteframe-z1"\
|
|
ng-class="{ \'md-not-found\': $mdAutocompleteCtrl.notFoundVisible() }"\
|
|
role="presentation">\
|
|
<ul class="md-autocomplete-suggestions"\
|
|
ng-class="::menuClass"\
|
|
id="ul-{{$mdAutocompleteCtrl.id}}">\
|
|
<li md-virtual-repeat="item in $mdAutocompleteCtrl.matches"\
|
|
ng-class="{ selected: $index === $mdAutocompleteCtrl.index }"\
|
|
ng-click="$mdAutocompleteCtrl.select($index)"\
|
|
md-extra-name="$mdAutocompleteCtrl.itemName">\
|
|
' + itemTemplate + '\
|
|
</li>' + noItemsTemplate + '\
|
|
</ul>\
|
|
</md-virtual-repeat-container>\
|
|
</md-autocomplete-wrap>\
|
|
<aria-status\
|
|
class="md-visually-hidden"\
|
|
role="status"\
|
|
aria-live="assertive">\
|
|
<p ng-repeat="message in $mdAutocompleteCtrl.messages track by $index" ng-if="message">{{message}}</p>\
|
|
</aria-status>';
|
|
|
|
function getItemTemplate() {
|
|
var templateTag = element.find('md-item-template').detach(),
|
|
html = templateTag.length ? templateTag.html() : element.html();
|
|
if (!templateTag.length) element.empty();
|
|
return '<md-autocomplete-parent-scope md-autocomplete-replace>' + html + '</md-autocomplete-parent-scope>';
|
|
}
|
|
|
|
function getNoItemsTemplate() {
|
|
var templateTag = element.find('md-not-found').detach(),
|
|
template = templateTag.length ? templateTag.html() : '';
|
|
return template
|
|
? '<li ng-if="$mdAutocompleteCtrl.notFoundVisible()"\
|
|
md-autocomplete-parent-scope>' + template + '</li>'
|
|
: '';
|
|
|
|
}
|
|
|
|
function getInputElement () {
|
|
if (attr.mdFloatingLabel) {
|
|
return '\
|
|
<md-input-container ng-if="floatingLabel">\
|
|
<label>{{floatingLabel}}</label>\
|
|
<input type="search"\
|
|
' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
|
|
id="{{ inputId || \'fl-input-\' + $mdAutocompleteCtrl.id }}"\
|
|
name="{{inputName}}"\
|
|
autocomplete="off"\
|
|
ng-required="$mdAutocompleteCtrl.isRequired"\
|
|
ng-readonly="$mdAutocompleteCtrl.isReadonly"\
|
|
ng-minlength="inputMinlength"\
|
|
ng-maxlength="inputMaxlength"\
|
|
ng-disabled="$mdAutocompleteCtrl.isDisabled"\
|
|
ng-model="$mdAutocompleteCtrl.scope.searchText"\
|
|
ng-model-options="{ allowInvalid: true }"\
|
|
ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
|
|
ng-blur="$mdAutocompleteCtrl.blur($event)"\
|
|
ng-focus="$mdAutocompleteCtrl.focus($event)"\
|
|
aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
|
|
' + (attr.mdNoAsterisk != null ? 'md-no-asterisk="' + attr.mdNoAsterisk + '"' : '') + '\
|
|
' + (attr.mdSelectOnFocus != null ? 'md-select-on-focus=""' : '') + '\
|
|
aria-label="{{floatingLabel}}"\
|
|
aria-autocomplete="list"\
|
|
role="combobox"\
|
|
aria-haspopup="true"\
|
|
aria-activedescendant=""\
|
|
aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
|
|
<div md-autocomplete-parent-scope md-autocomplete-replace>' + leftover + '</div>\
|
|
</md-input-container>';
|
|
} else {
|
|
return '\
|
|
<input type="search"\
|
|
' + (tabindex != null ? 'tabindex="' + tabindex + '"' : '') + '\
|
|
id="{{ inputId || \'input-\' + $mdAutocompleteCtrl.id }}"\
|
|
name="{{inputName}}"\
|
|
ng-if="!floatingLabel"\
|
|
autocomplete="off"\
|
|
ng-required="$mdAutocompleteCtrl.isRequired"\
|
|
ng-disabled="$mdAutocompleteCtrl.isDisabled"\
|
|
ng-readonly="$mdAutocompleteCtrl.isReadonly"\
|
|
ng-model="$mdAutocompleteCtrl.scope.searchText"\
|
|
ng-keydown="$mdAutocompleteCtrl.keydown($event)"\
|
|
ng-blur="$mdAutocompleteCtrl.blur($event)"\
|
|
ng-focus="$mdAutocompleteCtrl.focus($event)"\
|
|
placeholder="{{placeholder}}"\
|
|
aria-owns="ul-{{$mdAutocompleteCtrl.id}}"\
|
|
' + (attr.mdSelectOnFocus != null ? 'md-select-on-focus=""' : '') + '\
|
|
aria-label="{{placeholder}}"\
|
|
aria-autocomplete="list"\
|
|
role="combobox"\
|
|
aria-haspopup="true"\
|
|
aria-activedescendant=""\
|
|
aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>\
|
|
<button\
|
|
type="button"\
|
|
tabindex="-1"\
|
|
ng-if="$mdAutocompleteCtrl.scope.searchText && !$mdAutocompleteCtrl.isDisabled"\
|
|
ng-click="$mdAutocompleteCtrl.clear($event)">\
|
|
<md-icon md-svg-src="' + $$mdSvgRegistry.mdClose + '"></md-icon>\
|
|
<span class="md-visually-hidden">Clear</span>\
|
|
</button>\
|
|
';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
|
|
MdAutocompleteItemScopeDirective.$inject = ["$compile", "$mdUtil"];angular
|
|
.module('material.components.autocomplete')
|
|
.directive('mdAutocompleteParentScope', MdAutocompleteItemScopeDirective);
|
|
|
|
function MdAutocompleteItemScopeDirective($compile, $mdUtil) {
|
|
return {
|
|
restrict: 'AE',
|
|
compile: compile,
|
|
terminal: true,
|
|
transclude: 'element'
|
|
};
|
|
|
|
function compile(tElement, tAttr, transclude) {
|
|
return function postLink(scope, element, attr) {
|
|
var ctrl = scope.$mdAutocompleteCtrl;
|
|
var newScope = ctrl.parent.$new();
|
|
var itemName = ctrl.itemName;
|
|
|
|
// Watch for changes to our scope's variables and copy them to the new scope
|
|
watchVariable('$index', '$index');
|
|
watchVariable('item', itemName);
|
|
|
|
// Ensure that $digest calls on our scope trigger $digest on newScope.
|
|
connectScopes();
|
|
|
|
// Link the element against newScope.
|
|
transclude(newScope, function(clone) {
|
|
element.after(clone);
|
|
});
|
|
|
|
/**
|
|
* Creates a watcher for variables that are copied from the parent scope
|
|
* @param variable
|
|
* @param alias
|
|
*/
|
|
function watchVariable(variable, alias) {
|
|
newScope[alias] = scope[variable];
|
|
|
|
scope.$watch(variable, function(value) {
|
|
$mdUtil.nextTick(function() {
|
|
newScope[alias] = value;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates watchers on scope and newScope that ensure that for any
|
|
* $digest of scope, newScope is also $digested.
|
|
*/
|
|
function connectScopes() {
|
|
var scopeDigesting = false;
|
|
var newScopeDigesting = false;
|
|
|
|
scope.$watch(function() {
|
|
if (newScopeDigesting || scopeDigesting) {
|
|
return;
|
|
}
|
|
|
|
scopeDigesting = true;
|
|
scope.$$postDigest(function() {
|
|
if (!newScopeDigesting) {
|
|
newScope.$digest();
|
|
}
|
|
|
|
scopeDigesting = newScopeDigesting = false;
|
|
});
|
|
});
|
|
|
|
newScope.$watch(function() {
|
|
newScopeDigesting = true;
|
|
});
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
MdHighlightCtrl.$inject = ["$scope", "$element", "$attrs"];angular
|
|
.module('material.components.autocomplete')
|
|
.controller('MdHighlightCtrl', MdHighlightCtrl);
|
|
|
|
function MdHighlightCtrl ($scope, $element, $attrs) {
|
|
this.$scope = $scope;
|
|
this.$element = $element;
|
|
this.$attrs = $attrs;
|
|
|
|
// Cache the Regex to avoid rebuilding each time.
|
|
this.regex = null;
|
|
}
|
|
|
|
MdHighlightCtrl.prototype.init = function(unsafeTermFn, unsafeContentFn) {
|
|
|
|
this.flags = this.$attrs.mdHighlightFlags || '';
|
|
|
|
this.unregisterFn = this.$scope.$watch(function($scope) {
|
|
return {
|
|
term: unsafeTermFn($scope),
|
|
contentText: unsafeContentFn($scope)
|
|
};
|
|
}.bind(this), this.onRender.bind(this), true);
|
|
|
|
this.$element.on('$destroy', this.unregisterFn);
|
|
};
|
|
|
|
/**
|
|
* Triggered once a new change has been recognized and the highlighted
|
|
* text needs to be updated.
|
|
*/
|
|
MdHighlightCtrl.prototype.onRender = function(state, prevState) {
|
|
|
|
var contentText = state.contentText;
|
|
|
|
/* Update the regex if it's outdated, because we don't want to rebuilt it constantly. */
|
|
if (this.regex === null || state.term !== prevState.term) {
|
|
this.regex = this.createRegex(state.term, this.flags);
|
|
}
|
|
|
|
/* If a term is available apply the regex to the content */
|
|
if (state.term) {
|
|
this.applyRegex(contentText);
|
|
} else {
|
|
this.$element.text(contentText);
|
|
}
|
|
|
|
};
|
|
|
|
/**
|
|
* Decomposes the specified text into different tokens (whether match or not).
|
|
* Breaking down the string guarantees proper XSS protection due to the native browser
|
|
* escaping of unsafe text.
|
|
*/
|
|
MdHighlightCtrl.prototype.applyRegex = function(text) {
|
|
var tokens = this.resolveTokens(text);
|
|
|
|
this.$element.empty();
|
|
|
|
tokens.forEach(function (token) {
|
|
|
|
if (token.isMatch) {
|
|
var tokenEl = angular.element('<span class="highlight">').text(token.text);
|
|
|
|
this.$element.append(tokenEl);
|
|
} else {
|
|
this.$element.append(document.createTextNode(token));
|
|
}
|
|
|
|
}.bind(this));
|
|
|
|
};
|
|
|
|
/**
|
|
* Decomposes the specified text into different tokens by running the regex against the text.
|
|
*/
|
|
MdHighlightCtrl.prototype.resolveTokens = function(string) {
|
|
var tokens = [];
|
|
var lastIndex = 0;
|
|
|
|
// Use replace here, because it supports global and single regular expressions at same time.
|
|
string.replace(this.regex, function(match, index) {
|
|
appendToken(lastIndex, index);
|
|
|
|
tokens.push({
|
|
text: match,
|
|
isMatch: true
|
|
});
|
|
|
|
lastIndex = index + match.length;
|
|
});
|
|
|
|
// Append the missing text as a token.
|
|
appendToken(lastIndex);
|
|
|
|
return tokens;
|
|
|
|
function appendToken(from, to) {
|
|
var targetText = string.slice(from, to);
|
|
targetText && tokens.push(targetText);
|
|
}
|
|
};
|
|
|
|
/** Creates a regex for the specified text with the given flags. */
|
|
MdHighlightCtrl.prototype.createRegex = function(term, flags) {
|
|
var startFlag = '', endFlag = '';
|
|
var regexTerm = this.sanitizeRegex(term);
|
|
|
|
if (flags.indexOf('^') >= 0) startFlag = '^';
|
|
if (flags.indexOf('$') >= 0) endFlag = '$';
|
|
|
|
return new RegExp(startFlag + regexTerm + endFlag, flags.replace(/[$\^]/g, ''));
|
|
};
|
|
|
|
/** Sanitizes a regex by removing all common RegExp identifiers */
|
|
MdHighlightCtrl.prototype.sanitizeRegex = function(term) {
|
|
return term && term.toString().replace(/[\\\^\$\*\+\?\.\(\)\|\{}\[\]]/g, '\\$&');
|
|
};
|
|
|
|
|
|
MdHighlight.$inject = ["$interpolate", "$parse"];angular
|
|
.module('material.components.autocomplete')
|
|
.directive('mdHighlightText', MdHighlight);
|
|
|
|
/**
|
|
* @ngdoc directive
|
|
* @name mdHighlightText
|
|
* @module material.components.autocomplete
|
|
*
|
|
* @description
|
|
* The `md-highlight-text` directive allows you to specify text that should be highlighted within
|
|
* an element. Highlighted text will be wrapped in `<span class="highlight"></span>` which can
|
|
* be styled through CSS. Please note that child elements may not be used with this directive.
|
|
*
|
|
* @param {string} md-highlight-text A model to be searched for
|
|
* @param {string=} md-highlight-flags A list of flags (loosely based on JavaScript RexExp flags).
|
|
* #### **Supported flags**:
|
|
* - `g`: Find all matches within the provided text
|
|
* - `i`: Ignore case when searching for matches
|
|
* - `$`: Only match if the text ends with the search term
|
|
* - `^`: Only match if the text begins with the search term
|
|
*
|
|
* @usage
|
|
* <hljs lang="html">
|
|
* <input placeholder="Enter a search term..." ng-model="searchTerm" type="text" />
|
|
* <ul>
|
|
* <li ng-repeat="result in results" md-highlight-text="searchTerm">
|
|
* {{result.text}}
|
|
* </li>
|
|
* </ul>
|
|
* </hljs>
|
|
*/
|
|
|
|
function MdHighlight ($interpolate, $parse) {
|
|
return {
|
|
terminal: true,
|
|
controller: 'MdHighlightCtrl',
|
|
compile: function mdHighlightCompile(tElement, tAttr) {
|
|
var termExpr = $parse(tAttr.mdHighlightText);
|
|
var unsafeContentExpr = $interpolate(tElement.html());
|
|
|
|
return function mdHighlightLink(scope, element, attr, ctrl) {
|
|
ctrl.init(termExpr, unsafeContentExpr);
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
ngmaterial.components.autocomplete = angular.module("material.components.autocomplete");
|