// The default item height is also specified in the JavaScript.
$md-autocomplete-item-height: 48px !default;
$md-autocomplete-clear-size: 30px !default;
$md-autocomplete-input-offset: 20px !default;
md-autocomplete {
border-radius: 2px;
display: block;
height: 40px;
position: relative;
overflow: visible;
min-width: 190px;
&[disabled] {
input {
cursor: default;
}
}
&[md-floating-label] {
border-radius: 0;
background: transparent;
height: auto;
md-input-container {
padding-bottom: 0;
}
md-autocomplete-wrap {
height: auto;
}
.md-show-clear-button {
button {
display: block;
position: absolute;
right: 0;
top: $md-autocomplete-input-offset;
width: $md-autocomplete-clear-size;
height: $md-autocomplete-clear-size;
}
input {
// Add padding to the end of the input to avoid overlapping with the clear button.
@include rtl-prop(padding-right, padding-left, $md-autocomplete-clear-size, 0);
}
}
}
md-autocomplete-wrap {
// Layout [layout='row']
display: flex;
flex-direction: row;
box-sizing: border-box;
position: relative;
overflow: visible;
height: 40px;
&.md-menu-showing {
z-index: $z-index-backdrop + 1;
}
md-input-container, input {
// Layout [flex]
flex: 1 1 0%;
box-sizing: border-box;
min-width : 0;
}
md-progress-linear {
position: absolute;
bottom: -2px;
left: 0;
// When `md-inline` is present, we adjust the offset to go over the `ng-message` space
&.md-inline {
bottom: 40px;
right: 2px;
left: 2px;
width: auto;
}
.md-mode-indeterminate {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
transition: none;
.md-container {
transition: none;
height: 3px;
}
&.ng-enter {
transition: opacity 0.15s linear;
&.ng-enter-active {
opacity: 1;
}
}
&.ng-leave {
transition: opacity 0.15s linear;
&.ng-leave-active {
opacity: 0;
}
}
}
}
}
input:not(.md-input) {
@include md-flat-input();
width: 100%;
padding: 0 15px;
line-height: 40px;
height: 40px;
}
.md-show-clear-button button {
position: relative;
line-height: 20px;
text-align: center;
width: $md-autocomplete-clear-size;
height: $md-autocomplete-clear-size;
cursor: pointer;
border: none;
border-radius: 50%;
padding: 0;
font-size: 12px;
background: transparent;
margin: auto 5px;
&:after {
content: '';
position: absolute;
top: -6px;
right: -6px;
bottom: -6px;
left: -6px;
border-radius: 50%;
transform: scale(0);
opacity: 0;
transition: $swift-ease-out;
}
&:focus {
outline: none;
&:after {
transform: scale(1);
opacity: 1;
}
}
md-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0) scale(0.9);
path {
stroke-width: 0;
}
}
&.ng-enter {
transform: scale(0);
transition: transform 0.15s ease-out;
&.ng-enter-active {
transform: scale(1);
}
}
&.ng-leave {
transition: transform 0.15s ease-out;
&.ng-leave-active {
transform: scale(0);
}
}
}
// IE Only
@media screen and (-ms-high-contrast: active) {
$border-color: #fff;
input {
border: 1px solid $border-color;
}
li:focus {
color: #fff;
}
}
}
.md-virtual-repeat-container.md-autocomplete-suggestions-container {
position: absolute;
box-shadow: 0 2px 5px rgba(black, 0.25);
z-index: $z-index-tooltip;
// Expand the virtualRepeatContainer as much as the max-height from the JavaScript allows.
// This is necessary for the virtualRepeatContainer to be able to grow back.
height: 100%;
.highlight {
font-weight: bold;
}
}
.md-virtual-repeat-container.md-not-found {
height: $md-autocomplete-item-height;
}
.md-autocomplete-suggestions {
margin: 0;
list-style: none;
padding: 0;
li {
font-size: 14px;
overflow: hidden;
padding: 0 15px;
line-height: $md-autocomplete-item-height;
height: $md-autocomplete-item-height;
transition: background 0.15s linear;
margin: 0;
white-space: nowrap;
text-overflow: ellipsis;
&:focus {
outline: none;
}
&:not(.md-not-found-wrapper) {
cursor: pointer;
}
}
}
// IE Only
@media screen and (-ms-high-contrast: active) {
md-autocomplete,
.md-autocomplete-suggestions {
border: 1px solid #fff;
}
}
@mixin md-autocomplete-input($input-color) {
md-input-container.md-input-focused {
.md-input {
border-color: $input-color;
}
label,
md-icon {
color: $input-color;
}
}
}
@mixin md-autocomplete-progress($container-color, $bar-color) {
md-progress-linear {
.md-container {
background-color: $container-color;
}
.md-bar {
background-color: $bar-color;
}
}
}
md-autocomplete.md-THEME_NAME-theme {
background: '{{background-hue-1}}';
&[disabled]:not([md-floating-label]) {
background: '{{background-hue-2}}';
}
button {
md-icon {
path {
fill: '{{background-600}}';
}
}
&:after {
background: '{{background-600-0.3}}';
}
}
input {
color: '{{foreground-1}}';
}
&.md-accent {
@include md-autocomplete-input('{{accent-color}}');
@include md-autocomplete-progress('{{accent-100}}', '{{accent-color}}');
}
&.md-warn {
@include md-autocomplete-input('{{warn-A700}}');
@include md-autocomplete-progress('{{warn-100}}', '{{warn-color}}');
}
}
.md-autocomplete-suggestions-container.md-THEME_NAME-theme {
background: '{{background-hue-1}}';
li {
color: '{{foreground-1}}';
&:hover,
&.selected {
background: '{{background-500-0.18}}';
}
}
}
angular
.module('material.components.autocomplete')
.controller('MdAutocompleteCtrl', MdAutocompleteCtrl);
var ITEM_HEIGHT = 48,
MAX_ITEMS = 5,
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, $mdLiveAnnouncer) {
// Internal Variables.
var ctrl = this,
itemParts = $scope.itemsExpr.split(/ in /i),
itemExpr = itemParts[ 1 ],
elements = null,
cache = {},
noBlur = false,
selectedItemWatchers = [],
hasFocus = false,
fetchesInProgress = 0,
enableWrapScroll = null,
inputModelCtrl = null,
debouncedOnResize = $mdUtil.debounce(onWindowResize);
// 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.id = $mdUtil.nextUid();
ctrl.isDisabled = null;
ctrl.isRequired = null;
ctrl.isReadonly = null;
ctrl.hasNotFound = false;
ctrl.selectedMessage = $scope.selectedMessage || 'selected';
// 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;
/**
* Report types to be used for the $mdLiveAnnouncer
* @enum {number} Unique flag id.
*/
var ReportType = {
Count: 1,
Selected: 2
};
return init();
//-- initialization methods
/**
* Initialize the controller, setup watchers, gather elements
*/
function init () {
$mdUtil.initOptionalProperties($scope, $attrs, {
searchText: '',
selectedItem: null,
clearButton: false
});
$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);
}
if ($scope.ariaDescribedBy) {
elements.input.setAttribute('aria-describedby', $scope.ariaDescribedBy);
}
});
}
function updateModelValidators() {
if (!$scope.requireMatch || !inputModelCtrl) return;
inputModelCtrl.$setValidity('md-require-match', !!$scope.selectedItem || !$scope.searchText);
}
/**
* 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 dropdownHeight = ($scope.dropdownItems || MAX_ITEMS) * ITEM_HEIGHT;
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(),
position = $scope.dropdownPosition,
styles;
// Automatically determine dropdown placement based on available space in viewport.
if (!position) {
position = (top > bot && root.height - top - MENU_PADDING < dropdownHeight) ? 'top' : 'bottom';
}
// 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 (position === 'top') {
styles.top = 'auto';
styles.bottom = bot + 'px';
styles.maxHeight = Math.min(dropdownHeight, hrect.top - root.top - MENU_PADDING) + 'px';
} else {
var bottomSpace = root.bottom - hrect.bottom - MENU_PADDING + $mdUtil.getViewportTop();
styles.top = (top - offset) + 'px';
styles.bottom = 'auto';
styles.maxHeight = Math.min(dropdownHeight, bottomSpace) + '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', debouncedOnResize);
$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', debouncedOnResize);
if ( elements ){
var items = ['ul', 'scroller', 'scrollContainer', 'input'];
angular.forEach(items, function(key){
elements.$[key].remove();
});
}
}
/**
* Event handler to be called whenever the window resizes.
*/
function onWindowResize() {
if (!ctrl.hidden) {
positionDropdown();
}
}
/**
* Gathers all of the elements needed for this controller
*/
function gatherElements () {
var snapWrap = gatherSnapWrap();
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: snapWrap.wrap,
snap: snapWrap.snap,
root: document.body
};
elements.li = elements.ul.getElementsByTagName('li');
elements.$ = getAngularElements(elements);
inputModelCtrl = elements.$.input.controller('ngModel');
}
/**
* Gathers the snap and wrap elements
*
*/
function gatherSnapWrap() {
var element;
var value;
for (element = $element; element.length; element = element.parent()) {
value = element.attr('md-autocomplete-snap');
if (angular.isDefined(value)) break;
}
if (element.length) {
return {
snap: element[0],
wrap: (value.toLowerCase() === 'width') ? element[0] : $element.find('md-autocomplete-wrap')[0]
};
}
var wrap = $element.find('md-autocomplete-wrap')[0];
return {
snap: wrap,
wrap: 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();
// Report in polite mode, because the screen reader should finish the default description of
// the input element.
reportMessages(true, ReportType.Count | ReportType.Selected);
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 (angular.isString($scope.searchText)
&& 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);
reportMessages(false, ReportType.Count);
} 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();
reportMessages(false, ReportType.Selected);
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();
reportMessages(false, ReportType.Selected);
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.
* @param item
* @returns {string|null}
*/
function getItemText (item) {
return (item && $scope.itemText) ? $scope.itemText(getItemAsNameVal(item)) : null;
}
}
/**
* Returns the locals object for compiling item templates.
* @param item
* @returns {Object|undefined}
*/
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} 0 if autoselect is enabled, -1 if not.
*/
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} true if the menu should be hidden
*/
function shouldHide () {
return !shouldShow();
}
/**
* Determines whether the autocomplete is able to query within the current state.
* @returns {boolean} true if the query can be run
*/
function isSearchable() {
if (ctrl.loading && !hasMatches()) {
// No query when query is in progress.
return false;
} else if (hasSelection()) {
// No query if there is already a selection
return false;
}
else if (!hasFocus) {
// No query if the input does not have focus
return false;
}
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} true if the menu should be shown
*/
function shouldShow() {
if (ctrl.isReadonly) {
// Don't show if read only is set
return false;
} else if (!isSearchable()) {
// Don't show if a query is in progress, there is already a selection,
// or the input is not focused.
return false;
}
return (isMinLengthMet() && hasMatches()) || notFoundVisible();
}
/**
* @returns {boolean} true if the search text has matches.
*/
function hasMatches() {
return ctrl.matches.length ? true : false;
}
/**
* @returns {boolean} true if the autocomplete has a valid selection.
*/
function hasSelection() {
return ctrl.scope.selectedItem ? true : false;
}
/**
* @returns {boolean} true if the loading indicator is, or should be, visible.
*/
function loadingIsVisible() {
return ctrl.loading && !hasSelection();
}
/**
* @returns {*} the display value of the current item.
*/
function getCurrentDisplayValue () {
return getDisplayValue(ctrl.matches[ ctrl.index ]);
}
/**
* Determines if the minimum length is met by the search text.
* @returns {*} true if the minimum length is met by the search text
*/
function isMinLengthMet () {
return ($scope.searchText || '').length >= getMinLength();
}
//-- actions
/**
* Defines a public property with a handler and a default value.
* @param {string} key
* @param {Function} handler function
* @param {*} value default 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 {number} index to select
*/
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');
$mdLiveAnnouncer.announce(val + ' ' + ctrl.selectedMessage, 'assertive');
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);
}
}
/**
* Reports given message types to supported screen readers.
* @param {boolean} isPolite Whether the announcement should be polite.
* @param {!number} types Message flags to be reported to the screen reader.
*/
function reportMessages(isPolite, types) {
var politeness = isPolite ? 'polite' : 'assertive';
var messages = [];
if (types & ReportType.Selected && ctrl.index !== -1) {
messages.push(getCurrentDisplayValue());
}
if (types & ReportType.Count) {
messages.push($q.resolve(getCountMessage()));
}
$q.all(messages).then(function(data) {
$mdLiveAnnouncer.announce(data.join(' '), politeness);
});
}
/**
* @returns {string} the ARIA message for how many results match the current query.
*/
function getCountMessage () {
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();
positionDropdown();
reportMessages(true, ReportType.Count);
}
/**
* 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 || {});
}
}
}
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.
*
* ### Snapping Drop-Down
*
* You can cause the autocomplete drop-down to snap to an ancestor element by applying the
* `md-autocomplete-snap` attribute to that element. You can also snap to the width of
* the `md-autocomplete-snap` element by setting the attribute's value to `width`
* (ie. `md-autocomplete-snap="width"`).
*
* ### Notes
*
* **Autocomplete Dropdown Items Rendering**
*
* The `md-autocomplete` uses the the <a ng-href="api/directive/mdVirtualRepeat">
* mdVirtualRepeat</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.
*
* **Autocomplete inside of a Virtual Repeat**
*
* When using the `md-autocomplete` directive inside of a
* <a ng-href="api/directive/mdVirtualRepeatContainer">VirtualRepeatContainer</a> the dropdown items
* might not update properly, because caching of the results is enabled by default.
*
* The autocomplete will then show invalid dropdown items, because the Virtual Repeat only updates
* the scope bindings rather than re-creating the `md-autocomplete`. This means that the previous
* cached results will be used.
*
* > To avoid such problems, ensure that the autocomplete does not cache any results via
* `md-no-cache="true"`:
*
* <hljs lang="html">
* <md-autocomplete
* md-no-cache="true"
* md-selected-item="selectedItem"
* md-items="item in items"
* md-search-text="searchText"
* md-item-text="item.display">
* <span>{{ item.display }}</span>
* </md-autocomplete>
* </hljs>
*
*
* @param {expression} md-items An expression in the format of `item in results` to iterate over
* matches for your search.<br/><br/>
* The `results` expression can be also a function, which returns the results synchronously
* or asynchronously (per Promise).
* @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-clear-button Whether the clear button for the autocomplete input should show up or not.
* @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-input-name The name attribute given to the input element to be used with
* FormController.
* @param {string=} md-menu-class This class will be applied to the dropdown menu for styling.
* @param {string=} md-menu-container-class This class will be applied to the parent container
* of the dropdown panel.
* @param {string=} md-input-class This will be applied to the input for styling. This attribute
* is only valid when a `md-floating-label` is defined.
* @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in
* `md-input-container`
* @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
* the item if the search text is an exact match. <br/><br/>
* An exact match is when only one match is displayed.
* @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`.
* @param {string=} md-dropdown-items Specifies the maximum amount of items to be shown in
* the dropdown.<br/><br/>
* When the dropdown doesn't fit into the viewport, the dropdown will shrink
* as much as possible.
* @param {string=} md-dropdown-position Overrides the default dropdown position. Options: `top`,
* `bottom`.
* @param {string=} aria-describedby A space-separated list of element IDs. This should contain the
* IDs of any elements that describe this autocomplete. Screen readers will read the content of
* these elements at the end of announcing that the autocomplete has been selected and
* describing its current state. The descriptive elements do not need to be visible on the page.
* @param {string=} md-selected-message Attribute to specify the text that the screen reader will
* announce after a value is selected. Default is: "selected". If `Alaska` is selected in the
* options panel, it will read "Alaska selected". You will want to override this when your app
* is running in a non-English locale.
* @param {boolean=} ng-trim If set to false, the search text will be not trimmed automatically.
* Defaults to true.
* @param {string=} ng-pattern Adds the pattern validator to the ngModel of the search text.
* See the [ngPattern Directive](https://docs.angularjs.org/api/ng/directive/ngPattern)
* for more details.
*
* @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.
*
* ### Clear button for the input
* By default, the clear button is displayed when there is input. This aligns with the spec's
* [Search Pattern](https://material.io/archive/guidelines/patterns/search.html#search-in-app-search).
* In floating label mode, when `md-floating-label="My Label"` is applied, the clear button is not displayed
* by default (see the spec's
* [Autocomplete Text Field](https://material.io/archive/guidelines/components/text-fields.html#text-fields-layout)).
*
* Nevertheless, developers are able to explicitly toggle the clear button for all autocomplete components
* with `md-clear-button`.
*
* <hljs lang="html">
* <md-autocomplete ... md-clear-button="true"></md-autocomplete>
* <md-autocomplete ... md-clear-button="false"></md-autocomplete>
* </hljs>
*
* In previous versions, the clear button was always hidden when the component was disabled.
* This changed in `1.1.5` to give the developer control of this behavior. This example
* will hide the clear button only when the component is disabled.
*
* <hljs lang="html">
* <md-autocomplete ... ng-disabled="disabled" md-clear-button="!disabled"></md-autocomplete>
* </hljs>
*
* ### 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.
*
* ### Asynchronous Results
* The autocomplete items expression also supports promises, which will resolve with the query results.
*
* <hljs lang="js">
* function AppController($scope, $http) {
* $scope.query = function(searchText) {
* return $http
* .get(BACKEND_URL + '/items/' + searchText)
* .then(function(data) {
* // Map the response object to the data object.
* return data;
* });
* };
* }
* </hljs>
*
* <hljs lang="html">
* <md-autocomplete
* md-selected-item="selectedItem"
* md-search-text="searchText"
* md-items="item in query(searchText)">
* <md-item-template>
* <span md-highlight-text="searchText">{{item}}</span>
* </md-item-template>
* </md-autocomplete>
* </hljs>
*
*/
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',
ariaDescribedBy: '@?ariaDescribedby',
noCache: '=?mdNoCache',
requireMatch: '=?mdRequireMatch',
selectOnMatch: '=?mdSelectOnMatch',
matchInsensitive: '=?mdMatchCaseInsensitive',
itemChange: '&?mdSelectedItemChange',
textChange: '&?mdSearchTextChange',
minLength: '=?mdMinLength',
delay: '=?mdDelay',
autofocus: '=?mdAutofocus',
floatingLabel: '@?mdFloatingLabel',
autoselect: '=?mdAutoselect',
menuClass: '@?mdMenuClass',
menuContainerClass: '@?mdMenuContainerClass',
inputClass: '@?mdInputClass',
inputId: '@?mdInputId',
escapeOptions: '@?mdEscapeOptions',
dropdownItems: '=?mdDropdownItems',
dropdownPosition: '@?mdDropdownPosition',
clearButton: '=?mdClearButton',
selectedMessage: '@?mdSelectedMessage'
},
compile: function(tElement, tAttrs) {
var attributes = ['md-select-on-focus', 'md-no-asterisk', 'ng-trim', 'ng-pattern'];
var input = tElement.find('input');
attributes.forEach(function(attribute) {
var attrValue = tAttrs[tAttrs.$normalize(attribute)];
if (attrValue !== null) {
input.attr(attribute, attrValue);
}
});
return function(scope, element, attrs, ctrl) {
// 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.
ctrl.hasNotFound = !!element.attr('md-has-not-found');
// By default the inset autocomplete should show the clear button when not explicitly overwritten
// or in floating label mode.
if (!angular.isDefined(attrs.mdClearButton) && !scope.floatingLabel) {
scope.clearButton = true;
}
};
},
template: function (element, attr) {
var noItemsTemplate = getNoItemsTemplate(),
itemTemplate = getItemTemplate(),
leftover = element.html(),
tabindex = attr.tabindex;
var menuContainerClass = attr.mdMenuContainerClass ? ' ' + attr.mdMenuContainerClass : '';
// 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, \
\'md-show-clear-button\': !!clearButton }">\
' + getInputElement() + '\
' + getClearButton() + '\
<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' + menuContainerClass + '"\
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>\
<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>';
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}}"\
ng-class="::inputClass"\
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}}"\
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-class="::inputClass"\
ng-if="!floatingLabel"\
autocomplete="off"\
ng-required="$mdAutocompleteCtrl.isRequired"\
ng-disabled="$mdAutocompleteCtrl.isDisabled"\
ng-readonly="$mdAutocompleteCtrl.isReadonly"\
ng-minlength="inputMinlength"\
ng-maxlength="inputMaxlength"\
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}}"\
aria-label="{{placeholder}}"\
aria-autocomplete="list"\
role="combobox"\
aria-haspopup="true"\
aria-activedescendant=""\
aria-expanded="{{!$mdAutocompleteCtrl.hidden}}"/>';
}
}
function getClearButton() {
return '' +
'<button ' +
'type="button" ' +
'aria-label="Clear Input" ' +
'tabindex="-1" ' +
'ng-if="clearButton && $mdAutocompleteCtrl.scope.searchText" ' +
'ng-click="$mdAutocompleteCtrl.clear($event)">' +
'<md-icon md-svg-src="' + $$mdSvgRegistry.mdClose + '"></md-icon>' +
'</button>';
}
}
};
}
/**
* @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'
]);
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;
});
}
};
}
}
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" md-highlight-flags="i">
* {{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);
};
}
};
}
describe('<md-autocomplete>', function() {
var element, scope;
beforeEach(module('material.components.autocomplete'));
afterEach(function() {
scope && scope.$destroy();
});
function compile(template, scope) {
inject(function($compile) {
element = $compile(template)(scope);
scope.$apply();
});
return element;
}
function createScope(items, scopeData, matchLowercase) {
items = items || ['foo', 'bar', 'baz'].map(function(item) {
return { display: item };
});
inject(function($rootScope, $timeout) {
scope = $rootScope.$new();
scope.match = function(term) {
return items.filter(function(item) {
return item.display.indexOf(matchLowercase ? term.toLowerCase() : term) === 0;
});
};
scope.asyncMatch = function(term) {
return $timeout(function() {
return scope.match(term);
}, 1000);
};
scope.searchText = '';
scope.selectedItem = null;
scope.items = items;
angular.forEach(scopeData, function(value, key) {
scope[key] = value;
});
});
return scope;
}
function keydownEvent(keyCode) {
return {
keyCode: keyCode,
stopPropagation: angular.noop,
preventDefault: angular.noop
};
}
function waitForVirtualRepeat() {
// Because the autocomplete does not make the suggestions menu visible
// off the bat, the virtual repeat needs a couple more iterations to
// figure out how tall it is and then how tall the repeated items are.
// Using md-item-size would reduce this to a single flush, but given that
// autocomplete allows for custom row templates, it's better to measure
// rather than assuming a given size.
inject(function($material, $timeout) {
$material.flushOutstandingAnimations();
$timeout.flush();
});
}
describe('basic functionality', function() {
it('updates selected item and search text', inject(function($timeout, $mdConstant, $material) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
$material.flushInterimElement();
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
// Focus the input
ctrl.focus();
// Update the scope
element.scope().searchText = 'fo';
waitForVirtualRepeat(element);
// Check expectations
expect(scope.searchText).toBe('fo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(ul.find('li').length).toBe(1);
// Run our key events
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
// Check expectations again
expect(scope.searchText).toBe('foo');
expect(scope.selectedItem).toBe(scope.match(scope.searchText)[0]);
element.remove();
}));
it('should clear the searchText when the selectedItem manually got cleared',
inject(function($timeout, $material, $mdConstant) {
var scope = createScope();
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'placeholder="placeholder"> ' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
$material.flushInterimElement();
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
ctrl.focus();
scope.$apply('searchText = "fo"');
waitForVirtualRepeat(element);
expect(scope.searchText).toBe('fo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(ul.find('li').length).toBe(1);
// Run our key events to trigger a select action
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
expect(scope.searchText).toBe('foo');
expect(scope.selectedItem).toBe(scope.match(scope.searchText)[0]);
// Reset / Clear the current selected item.
scope.$apply('selectedItem = null');
waitForVirtualRepeat(element);
// Run our key events to trigger a select action
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
// The autocomplete automatically clears the searchText when the selectedItem was cleared.
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBeFalsy();
element.remove();
}));
it('should should not clear the searchText when clearing the selected item from the input',
inject(function($timeout, $material, $mdConstant) {
var scope = createScope();
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'placeholder="placeholder"> ' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var input = element.find('input');
var ul = element.find('ul');
$material.flushInterimElement();
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
ctrl.focus();
scope.$apply('searchText = "fo"');
waitForVirtualRepeat(element);
expect(scope.searchText).toBe('fo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(ul.find('li').length).toBe(1);
// Run our key events to trigger a select action
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
expect(scope.searchText).toBe('foo');
expect(scope.selectedItem).toBe(scope.match(scope.searchText)[0]);
scope.$apply('searchText = "food"');
$timeout.flush();
// The autocomplete automatically clears the searchText when the selectedItem was cleared.
expect(scope.searchText).toBe('food');
expect(scope.selectedItem).toBeFalsy();
element.remove();
}));
it('allows you to set an input id without floating label', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template = '\
<md-autocomplete\
md-input-id="{{inputId}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('id')).toBe(scope.inputId);
element.remove();
}));
it('should allow you to set a class to the md-virtual-repeat-container element', inject(function() {
var scope = createScope(null, {menuContainerClass: 'custom-menu-container-class'});
var template = '\
<md-autocomplete\
md-menu-container-class="{{menuContainerClass}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var repeatContainer = element.find('md-virtual-repeat-container');
expect(repeatContainer.attr('class')).toContain(scope.menuContainerClass);
element.remove();
}));
it('allows using ng-readonly', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template = '\
<md-autocomplete\
md-input-id="{{inputId}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
ng-readonly="readonly">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
scope.readonly = true;
scope.$digest();
expect(input.attr('readonly')).toBe('readonly');
scope.readonly = false;
scope.$digest();
expect(input.attr('readonly')).toBeUndefined();
element.remove();
}));
it('does not open panel when ng-readonly is true', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template = '\
<md-autocomplete\
md-input-id="{{inputId}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
md-min-length="0"\
ng-readonly="readonly">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var input = element.find('input');
scope.readonly = false;
scope.$digest();
ctrl.focus();
waitForVirtualRepeat();
expect(input.attr('readonly')).toBeUndefined();
expect(ctrl.hidden).toBe(false);
ctrl.blur();
scope.readonly = true;
scope.$digest();
expect(ctrl.hidden).toBe(true);
ctrl.focus();
waitForVirtualRepeat();
expect(input.attr('readonly')).toBe('readonly');
expect(ctrl.hidden).toBe(true);
element.remove();
}));
it('should forward focus to the input element with md-autofocus', inject(function($timeout) {
var scope = createScope();
var template =
'<md-autocomplete ' +
' md-selected-item="selectedItem" ' +
' md-search-text="searchText" ' +
' md-items="item in match(searchText)" ' +
' md-item-text="item.display" ' +
' placeholder="placeholder"' +
' md-autofocus>' +
' <span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
document.body.appendChild(element[0]);
// Initial timeout for gathering elements
$timeout.flush();
element.triggerHandler('focus');
expect(document.activeElement).toBe(input[0]);
element.remove();
}));
it('allows using an empty readonly attribute', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template = '\
<md-autocomplete\
md-input-id="{{inputId}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
readonly>\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('readonly')).toBe('readonly');
element.remove();
}));
it('allows you to set an input id with floating label', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template = '\
<md-autocomplete\
md-floating-label="Some Label"\
md-input-id="{{inputId}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('id')).toBe(scope.inputId);
element.remove();
}));
it('forwards the `md-input-class` attribute to the input', function() {
var scope = createScope(null, {inputClass: 'custom-input-class'});
var template = '\
<md-autocomplete\
md-floating-label="Some Label"\
md-input-class="{{inputClass}}"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input).toHaveClass(scope.inputClass);
element.remove();
});
it('forwards the `md-select-on-focus` attribute to the input', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-select-on-focus="" ' +
'tabindex="3"' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('md-select-on-focus')).toBe("");
element.remove();
}));
it('should support ng-trim for the search input', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'ng-trim="false" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('ng-trim')).toBe("false");
scope.$apply('searchText = " Text "');
expect(scope.searchText).not.toBe('Text');
element.remove();
}));
it('should support ng-pattern for the search input', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<form name="testForm">' +
'<md-autocomplete ' +
'md-input-name="autocomplete" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'ng-pattern="/^[0-9]$/" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</form>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('ng-pattern')).toBeTruthy();
scope.$apply('searchText = "Test"');
expect(scope.testForm.autocomplete.$error['pattern']).toBeTruthy();
scope.$apply('searchText = "3"');
expect(scope.testForm.autocomplete.$error['pattern']).toBeFalsy();
element.remove();
}));
it('forwards the tabindex to the input', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'tabindex="3"' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('tabindex')).toBe('3');
element.remove();
}));
it('always sets the tabindex of the autcomplete to `-1`', inject(function() {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'tabindex="3"' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
expect(element.attr('tabindex')).toBe('-1');
element.remove();
}));
it('should emit the ngBlur event from the input', inject(function() {
var scope = createScope(null, {
onBlur: jasmine.createSpy('onBlur event')
});
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'ng-blur="onBlur($event)" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
input.triggerHandler('blur');
expect(scope.onBlur).toHaveBeenCalledTimes(1);
// Confirm that the ngFocus event was called with the $event local.
var focusEvent = scope.onBlur.calls.mostRecent().args[0];
expect(focusEvent.target).toBe(input[0]);
element.remove();
}));
it('should emit the ngFocus event from the input', inject(function() {
var scope = createScope(null, {
onFocus: jasmine.createSpy('onFocus event')
});
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'ng-focus="onFocus($event)" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
input.triggerHandler('focus');
expect(scope.onFocus).toHaveBeenCalledTimes(1);
// Confirm that the ngFocus event was called with the $event object.
var focusEvent = scope.onFocus.calls.mostRecent().args[0];
expect(focusEvent.target).toBe(input[0]);
element.remove();
}));
it('should not show a loading progress when the items object is invalid', inject(function() {
var scope = createScope(null, {
match: function() {
// Return an invalid object, which is not an array, neither a promise.
return {}
}
});
var template =
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'tabindex="3"' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
scope.$apply('searchText = "test"');
expect(ctrl.loading).toBe(false);
element.remove();
}));
it('clears the value when hitting escape', inject(function($mdConstant, $timeout) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
var ctrl = element.controller('mdAutocomplete');
expect(scope.searchText).toBe('');
scope.$apply('searchText = "test"');
expect(scope.searchText).toBe('test');
$timeout.flush();
scope.$apply(function() {
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
});
expect(scope.searchText).toBe('');
element.remove();
}));
describe('md-input-maxlength', function() {
it('should correctly set the form to invalid if floating label is present', inject(function($timeout) {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<form name="testForm">' +
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-input-maxlength="2" ' +
'md-input-name="testAutocomplete" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'tabindex="3"' +
'md-floating-label="Favorite state">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</form>';
var element = compile(template, scope);
var input = element.find('input');
expect(scope.searchText).toBe('');
expect(scope.testForm.$valid).toBe(true);
scope.$apply('searchText = "Exceeded"');
expect(scope.testForm.$valid).toBe(false);
element.remove();
}));
it('should correctly set the form to invalid when no floating label is present', inject(function($timeout) {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<form name="testForm">' +
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-input-maxlength="5" ' +
'md-input-name="testAutocomplete" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" >' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</form>';
var element = compile(template, scope);
var input = element.find('input');
expect(scope.searchText).toBe('');
expect(scope.testForm.$valid).toBe(true);
scope.$apply('searchText = "Exceeded"');
expect(scope.testForm.$valid).toBe(false);
element.remove();
}));
it('should not clear the view value if the input is invalid', inject(function($timeout) {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<form name="testForm">' +
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-input-maxlength="2" ' +
'md-input-name="testAutocomplete" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'tabindex="3"' +
'md-floating-label="Favorite state">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</form>';
var element = compile(template, scope);
var input = element.find('input');
expect(scope.searchText).toBe('');
expect(scope.testForm.$valid).toBe(true);
input.val('Exceeded');
input.triggerHandler('change');
scope.$digest();
expect(scope.testForm.$valid).toBe(false);
expect(scope.searchText).toBe('Exceeded');
element.remove();
}));
});
describe('md-input-minlength', function() {
it('should correctly set the form to invalid when floating label is present', inject(function($timeout) {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<form name="testForm">' +
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-input-minlength="4" ' +
'md-input-name="testAutocomplete" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'tabindex="3"' +
'md-floating-label="Favorite state">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</form>';
var element = compile(template, scope);
var input = element.find('input');
scope.$apply('searchText = "abc"');
expect(scope.testForm.$valid).toBe(false);
scope.$apply('searchText = "abcde"');
expect(scope.testForm.$valid).toBe(true);
element.remove();
}));
it('should correctly set the form to invalid when no floating label is present', inject(function($timeout) {
var scope = createScope(null, {inputId: 'custom-input-id'});
var template =
'<form name="testForm">' +
'<md-autocomplete ' +
'md-input-id="{{inputId}}" ' +
'md-input-minlength="4" ' +
'md-input-name="testAutocomplete" ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" >' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</form>';
var element = compile(template, scope);
var input = element.find('input');
scope.$apply('searchText = "abc"');
expect(scope.testForm.$valid).toBe(false);
scope.$apply('searchText = "abcde"');
expect(scope.testForm.$valid).toBe(true);
element.remove();
}));
});
describe('md-escape-options checks', function() {
var scope, ctrl, element;
var template = '\
<md-autocomplete\
md-escape-options="{{escapeOptions}}"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
beforeEach( inject(function($timeout, $material) {
scope = createScope();
element = compile(template, scope);
ctrl = element.controller('mdAutocomplete');
$material.flushInterimElement();
// Update the scope
element.scope().searchText = 'fo';
waitForVirtualRepeat(element);
// Focus the input
ctrl.focus();
$timeout.flush();
expect(ctrl.hidden).toBe(false);
expect(scope.searchText).toBe('fo');
waitForVirtualRepeat(element);
$timeout.flush();
expect(ctrl.hidden).toBe(false);
}));
afterEach(function() { element.remove() });
it('does not clear the value nor blur when hitting escape', inject(function($mdConstant, $document, $timeout) {
scope.$apply('escapeOptions = "none"');
scope.$apply(function() {
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
$timeout.flush();
expect(ctrl.hidden).toBe(true);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
$timeout.flush();
});
expect(scope.searchText).toBe('fo');
expect($document.activeElement).toBe(ctrl[0]);
}));
it('does not clear the value but does blur when hitting escape', inject(function($mdConstant, $document, $timeout) {
scope.$apply('escapeOptions = "blur"');
scope.$apply(function() {
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
$timeout.flush();
expect(ctrl.hidden).toBe(true);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
$timeout.flush();
});
expect(scope.searchText).toBe('fo');
expect($document.activeElement).toBe(undefined);
}));
it('clear the value but does not blur when hitting escape', inject(function($mdConstant, $document, $timeout) {
scope.$apply('escapeOptions = "clear"');
scope.$apply(function() {
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
$timeout.flush();
expect(ctrl.hidden).toBe(true);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
$timeout.flush();
});
expect(scope.searchText).toBe('');
expect($document.activeElement).toBe(ctrl[0]);
}));
});
it('should not show the progressbar when hitting escape on an empty input', inject(function($mdConstant, $timeout) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-search-text="searchText"\
md-items="item in match(searchText)">\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
$timeout.flush();
scope.$apply(function() {
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE));
});
expect(element.find('md-progress-linear').length).toBe(0);
element.remove();
}));
it('should not close list on ENTER key if nothing is selected', inject(function($timeout, $mdConstant, $material) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
$material.flushInterimElement();
// Update the scope
element.scope().searchText = 'fo';
waitForVirtualRepeat(element);
// Focus the input
ctrl.focus();
$timeout.flush();
expect(ctrl.hidden).toBe(false);
// Run our key events
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
// Check expectations again
expect(ctrl.hidden).toBe(false);
element.remove();
}));
});
describe('basic functionality with template', function() {
it('updates selected item and search text', inject(function($timeout, $material, $mdConstant) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<md-item-template>\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-item-template>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
$material.flushInterimElement();
// Focus the input
ctrl.focus();
element.scope().searchText = 'fo';
waitForVirtualRepeat(element);
expect(scope.searchText).toBe('fo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(ul.find('li').length).toBe(1);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
expect(scope.searchText).toBe('foo');
expect(scope.selectedItem).toBe(scope.match(scope.searchText)[0]);
element.remove();
}));
it('properly clears values when the item ends in a space character', inject(function($timeout, $material, $mdConstant) {
var myItems = ['foo ', 'bar', 'baz'].map(function(item) {
return {display: item};
});
var scope = createScope(myItems);
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<md-item-template>\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-item-template>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
$material.flushInterimElement();
// Focus the input
ctrl.focus();
element.scope().searchText = 'fo';
waitForVirtualRepeat(element);
expect(scope.searchText).toBe('fo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(ul.find('li').length).toBe(1);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
$timeout.flush();
expect(scope.searchText).toBe('foo ');
expect(scope.selectedItem).toBe(scope.match(scope.searchText)[0]);
ctrl.clear();
$timeout.flush();
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
element.remove();
}));
it('compiles the template against the parent scope', inject(function($timeout, $material) {
var scope = createScope(null, {bang: 'boom'});
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>' +
' <span class="find-parent-scope">{{bang}}</span>' +
' <span class="find-index">{{$index}}</span>' +
' <span class="find-item">{{item.display}}</span>' +
' </md-item-template>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
$material.flushOutstandingAnimations();
expect(scope.bang).toBe('boom');
// Focus the input
ctrl.focus();
element.scope().searchText = 'fo';
// Run our initial flush
$timeout.flush();
waitForVirtualRepeat(element);
// Wait for the next tick when the values will be updated
$timeout.flush();
var li = ul.find('li')[0];
// Expect it to be compiled against the parent scope and have our variables copied
expect(li.querySelector('.find-parent-scope').innerHTML).toBe('boom');
expect(li.querySelector('.find-index').innerHTML).toBe('0');
expect(li.querySelector('.find-item').innerHTML).toBe('foo');
// Make sure we wrap up anything and remove the element
$timeout.flush();
element.remove();
}));
it('removes the md-scroll-mask on cleanup', inject(function($mdUtil, $timeout, $material) {
spyOn($mdUtil, 'enableScrolling').and.callThrough();
var scope = createScope();
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
$material.flushOutstandingAnimations();
// Focus our input
ctrl.focus();
// Set our search text to a value that we know doesn't exist
scope.searchText = 'somethingthatdoesnotexist';
// Run our initial flush
$timeout.flush();
waitForVirtualRepeat(element);
// Wait for the next tick when the values will be updated
$timeout.flush();
expect(ctrl.hidden).toBeFalsy();
// Make sure we wrap up anything and remove the element
$timeout.flush();
element.remove();
scope.$destroy();
// Should be hidden on once the scope is destroyed to ensure proper cleanup (like md-scroll-mask is removed from the DOM)
expect($mdUtil.enableScrolling).toHaveBeenCalled();
}));
it('removes the md-scroll-mask when md-autocomplete removed on change', inject(function($mdUtil, $timeout, $material) {
spyOn($mdUtil, 'enableScrolling').and.callThrough();
var scope = createScope();
var template =
'<div>' +
' <md-autocomplete' +
' ng-if="!removeAutocomplete"' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
' </md-autocomplete>' +
'</div>';
var element = compile(template, scope);
var ctrl = element.children().controller('mdAutocomplete');
$material.flushOutstandingAnimations();
// Focus our input
ctrl.focus();
// Set our search text to a value to make md-scroll-mask added to DOM
scope.$apply('searchText = "searchText"');
$timeout.flush();
// Set removeAutocomplete to false to remove the md-autocomplete
scope.$apply('removeAutocomplete = true');
expect($mdUtil.enableScrolling).toHaveBeenCalled();
}));
it('should initialize the search text with an empty string', inject(function($mdUtil, $timeout, $material) {
var scope = createScope();
// Delete our searchText variable from the generated scope, because we
// want to confirm, that the autocomplete uses an empty string by default.
delete scope.searchText;
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
$material.flushOutstandingAnimations();
// Run our initial flush
$timeout.flush();
waitForVirtualRepeat(element);
// Set our search text to a value that we know doesn't exist
expect(scope.searchText).toBe('');
// Make sure we wrap up anything and remove the element
$timeout.flush();
element.remove();
}));
it('ensures the parent scope digests along with the current scope', inject(function($timeout, $material) {
var scope = createScope(null, {bang: 'boom'});
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>' +
' <span class="find-parent-scope">{{bang}}</span>' +
' <span class="find-index">{{$index}}</span>' +
' <span class="find-item">{{item.display}}</span>' +
' </md-item-template>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
$material.flushOutstandingAnimations();
// Focus the input
ctrl.focus();
element.scope().searchText = 'fo';
// Run our initial flush
$timeout.flush();
waitForVirtualRepeat(element);
// Wait for the next tick when the values will be updated
$timeout.flush();
var li = ul.find('li')[0];
var parentScope = angular.element(li.querySelector('.find-parent-scope')).scope();
// When the autocomplete item's scope digests, ensure that the parent
// scope does too.
parentScope.bang = 'big';
scope.$digest();
expect(li.querySelector('.find-parent-scope').innerHTML).toBe('big');
// Make sure we wrap up anything and remove the element
$timeout.flush();
element.remove();
}));
it('is hidden when no matches are found without an md-not-found template', inject(function($timeout, $material) {
var scope = createScope();
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
$material.flushOutstandingAnimations();
// Focus our input
ctrl.focus();
// Set our search text to a value that we know doesn't exist
scope.searchText = 'somethingthatdoesnotexist';
// Run our initial flush
$timeout.flush();
waitForVirtualRepeat(element);
// Wait for the next tick when the values will be updated
$timeout.flush();
// We should be hidden since no md-not-found template was provided
expect(ctrl.hidden).toBe(true);
// Make sure we wrap up anything and remove the element
$timeout.flush();
element.remove();
}));
it('is visible when no matches are found with an md-not-found template', inject(function($timeout, $material) {
var scope = createScope();
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
$material.flushOutstandingAnimations();
// Focus our input
ctrl.focus();
// Set our search text to a value that we know doesn't exist
scope.searchText = 'somethingthatdoesnotexist';
// Run our initial flush
$timeout.flush();
waitForVirtualRepeat(element);
// Wait for the next tick when the values will be updated
$timeout.flush();
// We should be visible since an md-not-found template was provided
expect(ctrl.hidden).toBe(false);
// Make sure we wrap up anything and remove the element
$timeout.flush();
element.remove();
}));
it('properly sets hasNotFound when element is hidden through ng-if', inject(function() {
var scope = createScope();
var template1 =
'<div>' +
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'placeholder="placeholder" ' +
'ng-if="showAutocomplete">' +
'<md-item-template>{{item.display}}</md-item-template>' +
'<md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>' +
'</div>';
var element = compile(template1, scope);
var ctrl = element.children().controller('mdAutocomplete');
expect(ctrl).toBeUndefined();
scope.$apply('showAutocomplete = true');
ctrl = element.children().controller('mdAutocomplete');
expect(ctrl.hasNotFound).toBe(true);
}));
it('properly sets hasNotFound with multiple autocompletes', inject(function($timeout, $material) {
var scope = createScope();
var template1 =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>';
var element1 = compile(template1, scope);
var ctrl1 = element1.controller('mdAutocomplete');
var template2 =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
'</md-autocomplete>';
var element2 = compile(template2, scope);
var ctrl2 = element2.controller('mdAutocomplete');
// The first autocomplete has a template, the second one does not
expect(ctrl1.hasNotFound).toBe(true);
expect(ctrl2.hasNotFound).toBe(false);
}));
it('shows the md-not-found template even if we have lost focus', inject(function($timeout) {
var scope = createScope();
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>';
var element = compile(template, scope);
var controller = element.controller('mdAutocomplete');
controller.focus();
scope.searchText = 'somethingthatdoesnotexist';
$timeout.flush();
controller.listEnter();
expect(controller.notFoundVisible()).toBe(true);
controller.blur();
expect(controller.notFoundVisible()).toBe(true);
controller.listLeave();
expect(controller.notFoundVisible()).toBe(false);
$timeout.flush();
element.remove();
}));
it('should not show the md-not-found template if we lost focus and left the list', inject(function($timeout) {
var scope = createScope();
var template =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' placeholder="placeholder">' +
' <md-item-template>{{item.display}}</md-item-template>' +
' <md-not-found>Sorry, not found...</md-not-found>' +
'</md-autocomplete>';
var element = compile(template, scope);
var controller = element.controller('mdAutocomplete');
controller.focus();
scope.searchText = 'somethingthatdoesnotexist';
$timeout.flush();
controller.listEnter();
expect(controller.notFoundVisible()).toBe(true);
controller.listLeave();
controller.blur();
expect(controller.notFoundVisible()).toBe(false);
$timeout.flush();
element.remove();
}));
it('should log a warning if the display text does not evaluate to a string',
inject(function($log) {
spyOn($log, 'warn');
var scope = createScope();
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText"' +
'md-items="item in match(searchText)"> ' +
'</md-autocomplete>';
var element = compile(template, scope);
scope.$apply(function() {
scope.selectedItem = { display: 'foo' };
});
expect($log.warn).toHaveBeenCalled();
expect($log.warn.calls.mostRecent().args[0]).toMatch(/md-item-text/);
element.remove();
})
);
});
describe('clear button', function() {
it('should show the clear button for inset autocomplete', function() {
var scope = createScope();
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'placeholder="placeholder"> ' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var wrapEl = element.find('md-autocomplete-wrap');
expect(ctrl.scope.clearButton).toBe(true);
expect(wrapEl).toHaveClass('md-show-clear-button');
});
it('should not show the clear button for floating label autocomplete', function() {
var scope = createScope();
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-floating-label="Label"> ' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var wrapEl = element.find('md-autocomplete-wrap');
expect(ctrl.scope.clearButton).toBe(false);
expect(wrapEl).not.toHaveClass('md-show-clear-button');
});
it('should allow developers to toggle the clear button', function() {
var scope = createScope();
var template =
'<md-autocomplete ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-floating-label="Label" ' +
'md-clear-button="showButton">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var wrapEl = element.find('md-autocomplete-wrap');
expect(ctrl.scope.clearButton).toBeFalsy();
expect(wrapEl).not.toHaveClass('md-show-clear-button');
scope.$apply('showButton = true');
expect(ctrl.scope.clearButton).toBe(true);
expect(wrapEl).toHaveClass('md-show-clear-button');
});
});
describe('xss prevention', function() {
it('should not allow html to slip through', inject(function($timeout, $material) {
var html = 'foo <img src="img" onerror="alert(1)" />';
var scope = createScope([{ display: html }]);
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
md-min-length="0"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
var ul = element.find('ul');
$material.flushOutstandingAnimations();
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
// Focus the input
ctrl.focus();
scope.$apply('searchText = "fo"');
$timeout.flush();
waitForVirtualRepeat(element);
expect(scope.searchText).toBe('fo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(ul.find('li').length).toBe(1);
expect(ul.find('li').find('img').length).toBe(0);
element.remove();
}));
});
describe('Async matching', function() {
it('properly stops the loading indicator when clearing', inject(function($timeout, $material) {
var scope = createScope();
var template =
'<md-autocomplete ' +
' md-search-text="searchText"' +
' md-items="item in asyncMatch(searchText)" ' +
' md-item-text="item.display" ' +
' placeholder="placeholder">' +
' <span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
var ctrl = element.controller('mdAutocomplete');
$material.flushInterimElement();
scope.$apply('searchText = "test"');
ctrl.clear();
expect(ctrl.loading).toBe(true);
$timeout.flush();
expect(ctrl.loading).toBe(false);
}));
});
describe('accessibility', function() {
var $mdLiveAnnouncer, $timeout, $mdConstant = null;
var liveEl, scope, element, ctrl = null;
var BASIC_TEMPLATE =
'<md-autocomplete' +
' md-selected-item="selectedItem"' +
' md-search-text="searchText"' +
' md-items="item in match(searchText)"' +
' md-item-text="item.display"' +
' md-min-length="0"' +
' placeholder="placeholder">' +
' <span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
beforeEach(inject(function ($injector) {
$mdLiveAnnouncer = $injector.get('$mdLiveAnnouncer');
$mdConstant = $injector.get('$mdConstant');
$timeout = $injector.get('$timeout');
liveEl = $mdLiveAnnouncer._liveElement;
scope = createScope();
element = compile(BASIC_TEMPLATE, scope);
ctrl = element.controller('mdAutocomplete');
// Flush the initial autocomplete timeout to gather the elements.
$timeout.flush();
}));
it('should announce count on dropdown open', function() {
ctrl.focus();
waitForVirtualRepeat();
expect(ctrl.hidden).toBe(false);
expect(liveEl.textContent).toBe('There are 3 matches available.');
});
it('should announce count and selection on dropdown open', function() {
// Manually enable md-autoselect for the autocomplete.
ctrl.index = 0;
ctrl.focus();
waitForVirtualRepeat();
expect(ctrl.hidden).toBe(false);
// Expect the announcement to contain the current selection in the dropdown.
expect(liveEl.textContent).toBe(scope.items[0].display + ' There are 3 matches available.');
});
it('should announce the selection when using the arrow keys', function() {
ctrl.focus();
waitForVirtualRepeat();
expect(ctrl.hidden).toBe(false);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
// Flush twice, because the display value will be resolved asynchronously and then the live-announcer will
// be triggered.
$timeout.flush();
$timeout.flush();
expect(ctrl.index).toBe(0);
expect(liveEl.textContent).toBe(scope.items[0].display);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
// Flush twice, because the display value will be resolved asynchronously and then the
// live-announcer will be triggered.
$timeout.flush();
$timeout.flush();
expect(ctrl.index).toBe(1);
expect(liveEl.textContent).toBe(scope.items[1].display);
});
it('should announce when an option is selected', function() {
ctrl.focus();
waitForVirtualRepeat();
expect(ctrl.hidden).toBe(false);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW));
// Flush twice, because the display value will be resolved asynchronously and then the
// live-announcer will be triggered.
$timeout.flush();
$timeout.flush();
expect(ctrl.index).toBe(0);
expect(liveEl.textContent).toBe(scope.items[0].display);
ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER));
// Flush twice, because the display value will be resolved asynchronously and then the
// live-announcer will be triggered.
$timeout.flush();
$timeout.flush();
expect(liveEl.textContent).toBe(scope.items[0].display + ' ' + ctrl.selectedMessage);
});
it('should announce the count when matches change', function() {
ctrl.focus();
waitForVirtualRepeat();
expect(ctrl.hidden).toBe(false);
expect(liveEl.textContent).toBe('There are 3 matches available.');
scope.$apply('searchText = "fo"');
$timeout.flush();
expect(liveEl.textContent).toBe('There is 1 match available.');
});
});
describe('API access', function() {
it('clears the selected item', inject(function($timeout) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
element.scope().searchText = 'fo';
$timeout.flush();
ctrl.select(0);
$timeout.flush();
expect(scope.searchText).toBe('foo');
expect(scope.selectedItem).not.toBeNull();
expect(scope.selectedItem.display).toBe('foo');
expect(scope.match(scope.searchText).length).toBe(1);
ctrl.clear();
element.scope().$apply();
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
element.remove();
}));
it('notifies selected item watchers', inject(function($timeout) {
var scope = createScope();
scope.itemChanged = jasmine.createSpy('itemChanged');
var registeredWatcher = jasmine.createSpy('registeredWatcher');
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-selected-item-change="itemChanged(selectedItem)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
ctrl.registerSelectedItemWatcher(registeredWatcher);
element.scope().searchText = 'fo';
$timeout.flush();
ctrl.select(0);
$timeout.flush();
expect(scope.itemChanged).toHaveBeenCalled();
expect(scope.itemChanged.calls.mostRecent().args[0].display).toBe('foo');
expect(registeredWatcher).toHaveBeenCalled();
expect(registeredWatcher.calls.mostRecent().args[0].display).toBe('foo');
expect(registeredWatcher.calls.mostRecent().args[1]).toBeNull();
expect(scope.selectedItem).not.toBeNull();
expect(scope.selectedItem.display).toBe('foo');
// Un-register the watcher
ctrl.unregisterSelectedItemWatcher(registeredWatcher);
ctrl.clear();
element.scope().$apply();
expect(registeredWatcher.calls.count()).toBe(1);
expect(scope.itemChanged.calls.count()).toBe(2);
expect(scope.itemChanged.calls.mostRecent().args[0]).toBeNull();
expect(scope.selectedItem).toBeNull();
element.remove();
}));
it('passes the value to the item watcher', inject(function($timeout) {
var scope = createScope();
var itemValue = null;
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-selected-item-change="itemChanged(item)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
scope.itemChanged = function(item) {
itemValue = item;
};
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
element.scope().searchText = 'fo';
$timeout.flush();
ctrl.select(0);
$timeout.flush();
expect(itemValue).not.toBeNull();
expect(itemValue.display).toBe('foo');
ctrl.clear();
element.scope().$apply();
element.remove();
}));
});
describe('md-select-on-match', function() {
it('selects matching item on exact match when `md-select-on-match` is toggled', inject(function($timeout) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-select-on-match\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
element.scope().searchText = 'foo';
$timeout.flush();
expect(scope.selectedItem).not.toBe(null);
expect(scope.selectedItem.display).toBe('foo');
element.remove();
}));
it('selects matching item on exact match with caching enabled', inject(function($timeout) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-select-on-match\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
scope.$apply('searchText = "foo"');
$timeout.flush();
expect(scope.selectedItem).not.toBe(null);
expect(scope.selectedItem.display).toBe('foo');
scope.$apply('searchText = ""');
$timeout.flush();
expect(scope.selectedItem).toBeFalsy();
scope.$apply('searchText = "foo"');
$timeout.flush();
expect(scope.selectedItem).not.toBe(null);
expect(scope.selectedItem.display).toBe('foo');
element.remove();
}));
it('should not select matching item on exact match when `md-select-on-match` is NOT toggled', inject(function($timeout) {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
element.scope().searchText = 'foo';
$timeout.flush();
expect(scope.selectedItem).toBe(null);
element.remove();
}));
it('selects matching item using case insensitive', inject(function($timeout) {
var scope = createScope(null, null, true);
var template =
'<md-autocomplete ' +
'md-select-on-match ' +
'md-selected-item="selectedItem" ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'placeholder="placeholder" ' +
'md-match-case-insensitive="true">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>';
var element = compile(template, scope);
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBe(null);
element.scope().searchText = 'FoO';
$timeout.flush();
expect(scope.selectedItem).not.toBe(null);
expect(scope.selectedItem.display).toBe('foo');
element.remove();
}));
});
describe('when requiring a match', function() {
it('should correctly update the validity', inject(function($timeout) {
var scope = createScope();
var template = '\
<form name="form">\
<md-autocomplete\
md-input-name="autocomplete"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
md-require-match="true">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>\
</form>';
var element = compile(template, scope);
var ctrl = element.find('md-autocomplete').controller('mdAutocomplete');
// Flush the element gathering.
$timeout.flush();
scope.$apply('searchText = "fo"');
$timeout.flush();
ctrl.select(0);
$timeout.flush();
expect(scope.searchText).toBe('foo');
expect(scope.selectedItem).not.toBeNull();
expect(scope.selectedItem.display).toBe('foo');
expect(scope.match(scope.searchText).length).toBe(1);
expect(scope.form.autocomplete.$error['md-require-match']).toBeFalsy();
scope.$apply('searchText = "food"');
$timeout.flush();
expect(scope.searchText).toBe('food');
expect(scope.selectedItem).toBeNull();
expect(scope.form.autocomplete.$error['md-require-match']).toBeTruthy();
}));
it('should not set to invalid if searchText is empty', inject(function($timeout) {
var scope = createScope();
var template = '\
<form name="form">\
<md-autocomplete\
md-input-name="autocomplete"\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
md-require-match="true">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>\
</form>';
compile(template, scope);
// Flush the element gathering.
$timeout.flush();
scope.$apply('searchText = "food"');
$timeout.flush();
expect(scope.searchText).toBe('food');
expect(scope.selectedItem).toBeNull();
expect(scope.form.autocomplete.$error['md-require-match']).toBeTruthy();
scope.$apply('searchText = ""');
expect(scope.searchText).toBe('');
expect(scope.selectedItem).toBeNull();
expect(scope.form.autocomplete.$error['md-require-match']).toBeFalsy();
}));
});
describe('when required', function() {
it('properly handles md-min-length="0" and undefined searchText', function() {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
md-min-length="0" \
required\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var error;
try {
var element = compile(template, scope);
scope.searchText = undefined;
scope.$digest();
} catch (e) {
error = e;
}
expect(error).toBe(undefined);
element.remove();
});
it('validates an empty `required` as true', function() {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
md-min-length="0" \
required\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
expect(ctrl.isRequired).toBe(true);
});
it('correctly validates an interpolated `ng-required` value', function() {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
md-min-length="0" \
ng-required="interpolateRequired"\
placeholder="placeholder">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var ctrl = element.controller('mdAutocomplete');
expect(ctrl.isRequired).toBe(false);
scope.interpolateRequired = false;
scope.$apply();
expect(ctrl.isRequired).toBe(false);
scope.interpolateRequired = true;
scope.$apply();
expect(ctrl.isRequired).toBe(true);
});
it('forwards the md-no-asterisk attribute', function() {
var scope = createScope();
var template = '\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
md-min-length="0" \
required\
md-no-asterisk="true"\
md-floating-label="Asterisk Label">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>';
var element = compile(template, scope);
var input = element.find('input');
expect(input.attr('md-no-asterisk')).toBe('true');
});
});
describe('dropdown position', function() {
var DEFAULT_MAX_ITEMS = 5;
var DEFAULT_ITEM_HEIGHT = 48;
var dropdownItems = DEFAULT_MAX_ITEMS;
/**
* Function to create fake matches with the given dropdown items.
* Useful when running tests against the dropdown max items calculations.
* @returns {Array} Fake matches.
*/
function fakeItemMatch() {
var matches = [];
for (var i = 0; i < dropdownItems; i++) {
matches.push('Item ' + i);
}
return matches;
}
it('should adjust the width when the window resizes', inject(function($timeout, $window) {
var scope = createScope();
var template =
'<div style="width: 400px">' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-min-length="0" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
expect(ctrl.positionDropdown).toBeTruthy();
// Focus the Autocomplete to open the dropdown.
ctrl.focus();
scope.$apply('searchText = "fo"');
waitForVirtualRepeat(element);
// The scroll repeat container has been moved to the body element to avoid
// z-index / overflow issues.
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
// Expect the current width of the scrollContainer to be the same as of the parent element
// at initialization.
expect(scrollContainer.style.minWidth).toBe('400px');
// Change the parents width, to be shrink the scrollContainers width.
parent.css('width', '200px');
// Update the scrollContainers rectangle, by triggering a reposition of the dropdown.
angular.element($window).triggerHandler('resize');
$timeout.flush();
// The scroll container should have a width of 200px, since we changed the parents width.
expect(scrollContainer.style.minWidth).toBe('200px');
document.body.removeChild(parent[0]);
}));
it('should adjust the width when manually repositioning', inject(function($timeout) {
var scope = createScope();
var template =
'<div style="width: 400px">' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-min-length="0" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
expect(ctrl.positionDropdown).toBeTruthy();
// Focus the Autocomplete to open the dropdown.
ctrl.focus();
scope.$apply('searchText = "fo"');
waitForVirtualRepeat(element);
// The scroll repeat container has been moved to the body element to avoid
// z-index / overflow issues.
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
// Expect the current width of the scrollContainer to be the same as of the parent element
// at initialization.
expect(scrollContainer.style.minWidth).toBe('400px');
// Change the parents width, to be shrink the scrollContainers width.
parent.css('width', '200px');
// Update the scrollContainers rectangle, by triggering a reposition of the dropdown.
ctrl.positionDropdown();
// The scroll container should have a width of 200px, since we changed the parents width.
expect(scrollContainer.style.minWidth).toBe('200px');
document.body.removeChild(parent[0]);
}));
it('should show on focus when min-length is met', inject(function($timeout) {
var scope = createScope();
// Overwrite the match function to always show some results.
scope.match = function() {
return scope.items;
};
var template =
'<div style="width: 400px">' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-min-length="0" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
ctrl.focus();
waitForVirtualRepeat(element);
// The scroll repeat container has been moved to the body element to avoid
// z-index / overflow issues.
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
// Expect the current width of the scrollContainer to be the same as of the parent element
// at initialization.
expect(scrollContainer.offsetParent).toBeTruthy();
document.body.removeChild(parent[0]);
}));
it('should not show on focus when min-length is not met', inject(function($timeout) {
var scope = createScope();
// Overwrite the match function to always show some results.
scope.match = function() {
return scope.items;
};
var template =
'<div style="width: 400px">' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-min-length="1" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
ctrl.focus();
waitForVirtualRepeat(element);
// The scroll repeat container has been moved to the body element to avoid
// z-index / overflow issues.
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
// Expect the dropdown to not show up, because the min-length is not met.
expect(scrollContainer.offsetParent).toBeFalsy();
ctrl.blur();
// Add one char to the searchText to match the minlength.
scope.$apply('searchText = "X"');
ctrl.focus();
waitForVirtualRepeat(element);
// Expect the dropdown to not show up, because the min-length is not met.
expect(scrollContainer.offsetParent).toBeTruthy();
document.body.removeChild(parent[0]);
}));
it('should calculate the height from the default max items', inject(function($timeout) {
var scope = createScope();
scope.match = fakeItemMatch;
var template =
'<div>' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item" ' +
'md-min-length="0" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
// Focus the autocomplete and trigger a query to be able to open the dropdown.
ctrl.focus();
scope.$apply('searchText = "Query 1"');
waitForVirtualRepeat(element);
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
expect(scrollContainer.style.maxHeight).toBe(DEFAULT_MAX_ITEMS * DEFAULT_ITEM_HEIGHT + 'px');
dropdownItems = 6;
// Trigger a new query to request an update of the items and dropdown.
scope.$apply('searchText = "Query 2"');
// The dropdown should not increase its height because of the new extra item.
expect(scrollContainer.style.maxHeight).toBe(DEFAULT_MAX_ITEMS * DEFAULT_ITEM_HEIGHT + 'px');
document.body.removeChild(parent[0]);
}));
it('should calculate its height from the specified max items', inject(function($timeout) {
var scope = createScope();
var maxDropdownItems = 2;
// Set the current dropdown items to the new maximum.
dropdownItems = maxDropdownItems;
scope.match = fakeItemMatch;
var template =
'<div>' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item" ' +
'md-min-length="0" ' +
'md-dropdown-items="' + maxDropdownItems +'"' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
// Focus the autocomplete and trigger a query to be able to open the dropdown.
ctrl.focus();
scope.$apply('searchText = "Query 1"');
waitForVirtualRepeat(element);
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
expect(scrollContainer.style.maxHeight).toBe(maxDropdownItems * DEFAULT_ITEM_HEIGHT + 'px');
dropdownItems = 6;
// Trigger a new query to request an update of the items and dropdown.
scope.$apply('searchText = "Query 2"');
// The dropdown should not increase its height because of the new extra item.
expect(scrollContainer.style.maxHeight).toBe(maxDropdownItems * DEFAULT_ITEM_HEIGHT + 'px');
document.body.removeChild(parent[0]);
}));
it('should allow dropdown position to be specified', inject(function($timeout, $window) {
var scope = createScope();
scope.match = fakeItemMatch;
scope.position = 'top';
var template = '<div>' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item" ' +
'md-min-length="0" ' +
'md-dropdown-position="{{position}}" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
// Focus the autocomplete and trigger a query to be able to open the dropdown.
ctrl.focus();
scope.$apply('searchText = "Query 1"');
waitForVirtualRepeat(element);
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
expect(scrollContainer.style.top).toBe('auto');
expect(scrollContainer.style.bottom).toMatch(/[0-9]+px/);
// Change position and resize to force a DOM update.
scope.$apply('position = "bottom"');
angular.element($window).triggerHandler('resize');
$timeout.flush();
expect(scrollContainer.style.top).toMatch(/[0-9]+px/);
expect(scrollContainer.style.bottom).toBe('auto');
parent.remove();
}));
it('should not position dropdown on resize when being hidden', inject(function($window, $timeout) {
var scope = createScope();
var template =
'<div style="width: 400px">' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item.display" ' +
'md-min-length="0" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item.display}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
expect(ctrl.positionDropdown).toBeTruthy();
// The scroll repeat container has been moved to the body element to avoid
// z-index / overflow issues.
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
// Expect the scroll container to not have minWidth set, because it was never positioned.
expect(scrollContainer.style.minWidth).toBe('');
// Change the parents width, to be shrink the scrollContainers width.
parent.css('width', '200px');
// Trigger a window resize, which should adjust the width of the scroll container.
angular.element($window).triggerHandler('resize');
$timeout.flush();
// The scroll container should still have no minWidth, because there was no positioning called yet.
expect(scrollContainer.style.minWidth).toBe('');
document.body.removeChild(parent[0]);
}));
it('should grow and shrink depending on the amount of items', inject(function($timeout) {
var scope = createScope();
dropdownItems = 2;
scope.match = fakeItemMatch;
var template =
'<div>' +
'<md-autocomplete ' +
'md-search-text="searchText" ' +
'md-items="item in match(searchText)" ' +
'md-item-text="item" ' +
'md-min-length="0" ' +
'placeholder="placeholder">' +
'<span md-highlight-text="searchText">{{item}}</span>' +
'</md-autocomplete>' +
'</div>';
var parent = compile(template, scope);
var element = parent.find('md-autocomplete');
var ctrl = element.controller('mdAutocomplete');
// Add container to the DOM to be able to test the rect calculations.
document.body.appendChild(parent[0]);
$timeout.flush();
// Focus the autocomplete and trigger a query to be able to open the dropdown.
ctrl.focus();
scope.$apply('searchText = "A"');
waitForVirtualRepeat(element);
var scrollContainer = document.body.querySelector('.md-virtual-repeat-container');
expect(scrollContainer).toBeTruthy();
expect(scrollContainer.style.height).toBe(dropdownItems * DEFAULT_ITEM_HEIGHT + 'px');
dropdownItems = DEFAULT_MAX_ITEMS;
// Trigger a new query to request an update of the items and dropdown.
scope.$apply('searchText = "B"');
expect(scrollContainer.style.height).toBe(dropdownItems * DEFAULT_ITEM_HEIGHT + 'px');
document.body.removeChild(parent[0]);
}));
});
describe('md-highlight-text', function() {
it('updates when content is modified', inject(function() {
var template = '<div md-highlight-text="query">{{message}}</div>';
var scope = createScope(null, {message: 'some text', query: 'some'});
var element = compile(template, scope);
expect(element.html()).toBe('<span class="highlight">some</span> text');
scope.query = 'so';
scope.$apply();
expect(element.html()).toBe('<span class="highlight">so</span>me text');
scope.message = 'some more text';
scope.$apply();
expect(element.html()).toBe('<span class="highlight">so</span>me more text');
element.remove();
}));
it('should properly apply highlight flags', function() {
var template = '<div md-highlight-text="query" md-highlight-flags="{{flags}}">{{message}}</div>';
var scope = createScope(null, {message: 'Some text', query: 'some', flags: '^i'});
var element = compile(template, scope);
expect(element.html()).toBe('<span class="highlight">Some</span> text');
scope.query = 'text';
scope.$apply();
expect(element.html()).toBe('Some text');
scope.message = 'Some text, some flags';
scope.query = 'some';
scope.flags = 'ig';
element = compile(template, scope);
expect(element.html()).toBe('<span class="highlight">Some</span> text, <span class="highlight">some</span> flags');
scope.query = 'some';
scope.flags = '^i';
element = compile(template, scope);
expect(element.html()).toBe('<span class="highlight">Some</span> text, some flags');
scope.query = 's';
scope.flags = '$i';
element = compile(template, scope);
expect(element.html()).toBe('Some text, some flag<span class="highlight">s</span>');
element.remove();
});
it('should correctly parse special text identifiers', function() {
var template = '<div md-highlight-text="query">{{message}}</div>';
var scope = createScope(null, {
message: 'AngularJS&Material',
query: 'AngularJS&'
});
var element = compile(template, scope);
expect(element.html()).toBe('<span class="highlight">AngularJS&</span>Material');
scope.query = 'AngularJS&Material';
scope.$apply();
expect(element.html()).toBe('<span class="highlight">AngularJS&Material</span>');
element.remove();
});
it('should properly parse html entity identifiers', function() {
var template = '<div md-highlight-text="query">{{message}}</div>';
var scope = createScope(null, {
message: 'AngularJS&Material',
query: ''
});
var element = compile(template, scope);
expect(element.html()).toBe('AngularJS&amp;Material');
scope.query = 'AngularJS&Material';
scope.$apply();
expect(element.html()).toBe('<span class="highlight">AngularJS&amp;Material</span>');
scope.query = 'AngularJS&';
scope.$apply();
expect(element.html()).toBe('<span class="highlight">AngularJS&</span>amp;Material');
element.remove();
});
it('should prevent XSS attacks from the highlight text', function() {
spyOn(window, 'alert');
var template = '<div md-highlight-text="query">{{message}}</div>';
var scope = createScope(null, {
message: 'AngularJS Material',
query: '<img src="img" onerror="alert(1)">'
});
var element = compile(template, scope);
expect(element.html()).toBe('AngularJS Material');
expect(window.alert).not.toHaveBeenCalled();
element.remove();
});
});
it('should prevent XSS attacks from the content text', function() {
spyOn(window, 'alert');
var template = '<div md-highlight-text="query">{{message}}</div>';
var scope = createScope(null, {
message: '<img src="img" onerror="alert(1)">',
query: ''
});
var element = compile(template, scope);
// Expect the image to be escaped due to XSS protection.
expect(element.html()).toBe('<img src="img" onerror="alert(1)">');
expect(window.alert).not.toHaveBeenCalled();
element.remove();
});
describe('md-autocomplete-snap', function() {
it('should match the width of the snap element if width is set', inject(function($timeout, $material) {
var template = '\
<div style="width: 1000px" md-autocomplete-snap="width">\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
style="width:200px">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>\
</div>';
var scope = createScope();
var element = compile(template, scope);
var autoEl = element.find('md-autocomplete');
var ctrl = autoEl.controller('mdAutocomplete');
var ul = element.find('ul');
angular.element(document.body).append(element);
$material.flushInterimElement();
ctrl.focus();
autoEl.scope().searchText = 'fo';
waitForVirtualRepeat(autoEl);
expect(ul[0].offsetWidth).toBe(1000);
element.remove();
}));
it('should match the width of the wrap element if width is not set', inject(function($timeout, $material) {
var template = '\
<div style="width: 1000px" md-autocomplete-snap>\
<md-autocomplete\
md-selected-item="selectedItem"\
md-search-text="searchText"\
md-items="item in match(searchText)"\
md-item-text="item.display"\
placeholder="placeholder"\
style="width:200px">\
<span md-highlight-text="searchText">{{item.display}}</span>\
</md-autocomplete>\
</div>';
var scope = createScope();
var element = compile(template, scope);
var autoEl = element.find('md-autocomplete');
var ctrl = autoEl.controller('mdAutocomplete');
var ul = element.find('ul');
angular.element(document.body).append(element);
$material.flushInterimElement();
ctrl.focus();
autoEl.scope().searchText = 'fo';
waitForVirtualRepeat(autoEl);
expect(ul[0].offsetWidth).toBe(200);
element.remove();
}));
});
});
<div ng-controller="DemoCtrl as ctrl" layout="column" ng-cloak>
<md-content class="md-padding">
<form ng-submit="$event.preventDefault()">
<p id="autocompleteDescription">
Use <code>md-autocomplete</code> to search for matches from local or remote data sources.
</p>
<md-autocomplete
ng-disabled="ctrl.isDisabled"
md-no-cache="ctrl.noCache"
md-selected-item="ctrl.selectedItem"
md-search-text-change="ctrl.searchTextChange(ctrl.searchText)"
md-search-text="ctrl.searchText"
md-selected-item-change="ctrl.selectedItemChange(item)"
md-items="item in ctrl.querySearch(ctrl.searchText)"
md-item-text="item.display"
md-min-length="0"
placeholder="What is your favorite US state?"
aria-describedby="autocompleteDescription autocompleteDetailedDescription">
<md-item-template>
<span md-highlight-text="ctrl.searchText" md-highlight-flags="^i">{{item.display}}</span>
</md-item-template>
<md-not-found>
No states matching "{{ctrl.searchText}}" were found.
<a ng-click="ctrl.newState(ctrl.searchText)">Create a new one!</a>
</md-not-found>
</md-autocomplete>
<br/>
<md-checkbox ng-model="ctrl.simulateQuery">Simulate query for results?</md-checkbox>
<md-checkbox ng-model="ctrl.noCache">Disable caching of queries?</md-checkbox>
<md-checkbox ng-model="ctrl.isDisabled">Disable the input?</md-checkbox>
<p id="autocompleteDetailedDescription">
By default, <code>md-autocomplete</code> will cache results when performing a query.
After the initial call is performed, it will use the cached results to eliminate unnecessary
server requests or lookup logic. This can be disabled above.
</p>
</form>
</md-content>
</div>
(function () {
'use strict';
angular
.module('autocompleteDemo', ['ngMaterial'])
.controller('DemoCtrl', DemoCtrl);
function DemoCtrl ($timeout, $q, $log) {
var self = this;
self.simulateQuery = false;
self.isDisabled = false;
// list of `state` value/display objects
self.states = loadAll();
self.querySearch = querySearch;
self.selectedItemChange = selectedItemChange;
self.searchTextChange = searchTextChange;
self.newState = newState;
function newState(state) {
alert("Sorry! You'll need to create a Constitution for " + state + " first!");
}
// ******************************
// Internal methods
// ******************************
/**
* Search for states... use $timeout to simulate
* remote dataservice call.
*/
function querySearch (query) {
var results = query ? self.states.filter( createFilterFor(query) ) : self.states,
deferred;
if (self.simulateQuery) {
deferred = $q.defer();
$timeout(function () { deferred.resolve( results ); }, Math.random() * 1000, false);
return deferred.promise;
} else {
return results;
}
}
function searchTextChange(text) {
$log.info('Text changed to ' + text);
}
function selectedItemChange(item) {
$log.info('Item changed to ' + JSON.stringify(item));
}
/**
* Build `states` list of key/value pairs
*/
function loadAll() {
var allStates = 'Alabama, Alaska, Arizona, Arkansas, California, Colorado, Connecticut, Delaware,\
Florida, Georgia, Hawaii, Idaho, Illinois, Indiana, Iowa, Kansas, Kentucky, Louisiana,\
Maine, Maryland, Massachusetts, Michigan, Minnesota, Mississippi, Missouri, Montana,\
Nebraska, Nevada, New Hampshire, New Jersey, New Mexico, New York, North Carolina,\
North Dakota, Ohio, Oklahoma, Oregon, Pennsylvania, Rhode Island, South Carolina,\
South Dakota, Tennessee, Texas, Utah, Vermont, Virginia, Washington, West Virginia,\
Wisconsin, Wyoming';
return allStates.split(/, +/g).map( function (state) {
return {
value: state.toLowerCase(),
display: state
};
});
}
/**
* Create filter function for a query string
*/
function createFilterFor(query) {
var lowercaseQuery = query.toLowerCase();
return function filterFn(state) {
return (state.value.indexOf(lowercaseQuery) === 0);
};
}
}
})();