/* * angucomplete-alt * Autocomplete directive for AngularJS * This is a fork of Daryl Rowland's angucomplete with some extra features. * By Hidenari Nozaki */ /*! Copyright (c) 2014 Hidenari Nozaki and contributors | Licensed under the MIT license */ (function (root, factory) { 'use strict'; if (typeof module !== 'undefined' && module.exports) { // CommonJS module.exports = factory(require('angular')); } else if (typeof define === 'function' && define.amd) { // AMD define(['angular'], factory); } else { // Global Variables factory(root.angular); } }(window, function (angular) { 'use strict'; angular.module('angucomplete-alt', []).directive('angucompleteAlt', ['$q', '$parse', '$http', '$sce', '$timeout', '$templateCache', '$interpolate', function ($q, $parse, $http, $sce, $timeout, $templateCache, $interpolate) { // keyboard events var KEY_DW = 40; var KEY_RT = 39; var KEY_UP = 38; var KEY_LF = 37; var KEY_ES = 27; var KEY_EN = 13; var KEY_TAB = 9; var MIN_LENGTH = 3; var MAX_LENGTH = 524288; // the default max length per the html maxlength attribute var PAUSE = 500; var BLUR_TIMEOUT = 200; // string constants var REQUIRED_CLASS = 'autocomplete-required'; var TEXT_SEARCHING = 'Searching...'; var TEXT_NORESULTS = 'No results found'; var TEMPLATE_URL = '/angucomplete-alt/index.html'; // Set the default template for this directive $templateCache.put(TEMPLATE_URL, '
' + ' ' + '
' + '
' + '
' + '
' + '
' + ' ' + '
' + '
' + '
' + '
{{ result.title }}
' + '
' + '
{{result.description}}
' + '
' + '
' + '
' ); function link(scope, elem, attrs, ctrl) { var inputField = elem.find('input'); var minlength = MIN_LENGTH; var searchTimer = null; var hideTimer; var requiredClassName = REQUIRED_CLASS; var responseFormatter; var validState = null; var httpCanceller = null; var httpCallInProgress = false; var dd = elem[0].querySelector('.angucomplete-dropdown'); var isScrollOn = false; var mousedownOn = null; var unbindInitialValue; var displaySearching; var displayNoResults; elem.on('mousedown', function(event) { if (event.target.id) { mousedownOn = event.target.id; if (mousedownOn === scope.id + '_dropdown') { document.body.addEventListener('click', clickoutHandlerForDropdown); } } else { mousedownOn = event.target.className; } }); scope.currentIndex = scope.focusFirst ? 0 : null; scope.searching = false; unbindInitialValue = scope.$watch('initialValue', function(newval) { if (newval) { // remove scope listener unbindInitialValue(); // change input handleInputChange(newval, true); } }); scope.$watch('fieldRequired', function(newval, oldval) { if (newval !== oldval) { if (!newval) { ctrl[scope.inputName].$setValidity(requiredClassName, true); } else if (!validState || scope.currentIndex === -1) { handleRequired(false); } else { handleRequired(true); } } }); scope.$on('angucomplete-alt:clearInput', function (event, elementId) { if (!elementId || elementId === scope.id) { scope.searchStr = null; callOrAssign(); handleRequired(false); clearResults(); } }); scope.$on('angucomplete-alt:changeInput', function (event, elementId, newval) { if (!!elementId && elementId === scope.id) { handleInputChange(newval); } }); function handleInputChange(newval, initial) { if (newval) { if (typeof newval === 'object') { scope.searchStr = extractTitle(newval); callOrAssign({originalObject: newval}); } else if (typeof newval === 'string' && newval.length > 0) { scope.searchStr = newval; } else { if (console && console.error) { console.error('Tried to set ' + (!!initial ? 'initial' : '') + ' value of angucomplete to', newval, 'which is an invalid value'); } } handleRequired(true); } } // #194 dropdown list not consistent in collapsing (bug). function clickoutHandlerForDropdown(event) { mousedownOn = null; scope.hideResults(event); document.body.removeEventListener('click', clickoutHandlerForDropdown); } // for IE8 quirkiness about event.which function ie8EventNormalizer(event) { return event.which ? event.which : event.keyCode; } function callOrAssign(value) { if (typeof scope.selectedObject === 'function') { scope.selectedObject(value, scope.selectedObjectData); } else { scope.selectedObject = value; } if (value) { handleRequired(true); } else { handleRequired(false); } } function callFunctionOrIdentity(fn) { return function(data) { return scope[fn] ? scope[fn](data) : data; }; } function setInputString(str) { callOrAssign({originalObject: str}); if (scope.clearSelected) { scope.searchStr = null; } clearResults(); } function extractTitle(data) { // split title fields and run extractValue for each and join with ' ' return scope.titleField.split(',') .map(function(field) { return extractValue(data, field); }) .join(' '); } function extractValue(obj, key) { var keys, result; if (key) { keys= key.split('.'); result = obj; for (var i = 0; i < keys.length; i++) { result = result[keys[i]]; } } else { result = obj; } return result; } function findMatchString(target, str) { var result, matches, re; // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions // Escape user input to be treated as a literal string within a regular expression re = new RegExp(str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); if (!target) { return; } if (!target.match || !target.replace) { target = target.toString(); } matches = target.match(re); if (matches) { result = target.replace(re, ''+ matches[0] +''); } else { result = target; } return $sce.trustAsHtml(result); } function handleRequired(valid) { scope.notEmpty = valid; validState = scope.searchStr; if (scope.fieldRequired && ctrl && scope.inputName) { ctrl[scope.inputName].$setValidity(requiredClassName, valid); } } function keyupHandler(event) { var which = ie8EventNormalizer(event); if (which === KEY_LF || which === KEY_RT) { // do nothing return; } if (which === KEY_UP || which === KEY_EN) { event.preventDefault(); } else if (which === KEY_DW) { event.preventDefault(); if (!scope.showDropdown && scope.searchStr && scope.searchStr.length >= minlength) { initResults(); scope.searching = true; searchTimerComplete(scope.searchStr); } } else if (which === KEY_ES) { clearResults(); scope.$apply(function() { inputField.val(scope.searchStr); }); } else { if (minlength === 0 && !scope.searchStr) { return; } if (!scope.searchStr || scope.searchStr === '') { scope.showDropdown = false; } else if (scope.searchStr.length >= minlength) { initResults(); if (searchTimer) { $timeout.cancel(searchTimer); } scope.searching = true; searchTimer = $timeout(function() { searchTimerComplete(scope.searchStr); }, scope.pause); } if (validState && validState !== scope.searchStr && !scope.clearSelected) { scope.$apply(function() { callOrAssign(); }); } } } function handleOverrideSuggestions(event) { if (scope.overrideSuggestions && !(scope.selectedObject && scope.selectedObject.originalObject === scope.searchStr)) { if (event) { event.preventDefault(); } // cancel search timer $timeout.cancel(searchTimer); // cancel http request cancelHttpRequest(); setInputString(scope.searchStr); } } function dropdownRowOffsetHeight(row) { var css = getComputedStyle(row); return row.offsetHeight + parseInt(css.marginTop, 10) + parseInt(css.marginBottom, 10); } function dropdownHeight() { return dd.getBoundingClientRect().top + parseInt(getComputedStyle(dd).maxHeight, 10); } function dropdownRow() { return elem[0].querySelectorAll('.angucomplete-row')[scope.currentIndex]; } function dropdownRowTop() { return dropdownRow().getBoundingClientRect().top - (dd.getBoundingClientRect().top + parseInt(getComputedStyle(dd).paddingTop, 10)); } function dropdownScrollTopTo(offset) { dd.scrollTop = dd.scrollTop + offset; } function updateInputField(){ var current = scope.results[scope.currentIndex]; if (scope.matchClass) { inputField.val(extractTitle(current.originalObject)); } else { inputField.val(current.title); } } function keydownHandler(event) { var which = ie8EventNormalizer(event); var row = null; var rowTop = null; if (which === KEY_EN && scope.results) { if (scope.currentIndex >= 0 && scope.currentIndex < scope.results.length) { event.preventDefault(); scope.selectResult(scope.results[scope.currentIndex]); } else { handleOverrideSuggestions(event); clearResults(); } scope.$apply(); } else if (which === KEY_DW && scope.results) { event.preventDefault(); if ((scope.currentIndex + 1) < scope.results.length && scope.showDropdown) { scope.$apply(function() { scope.currentIndex ++; updateInputField(); }); if (isScrollOn) { row = dropdownRow(); if (dropdownHeight() < row.getBoundingClientRect().bottom) { dropdownScrollTopTo(dropdownRowOffsetHeight(row)); } } } } else if (which === KEY_UP && scope.results) { event.preventDefault(); if (scope.currentIndex >= 1) { scope.$apply(function() { scope.currentIndex --; updateInputField(); }); if (isScrollOn) { rowTop = dropdownRowTop(); if (rowTop < 0) { dropdownScrollTopTo(rowTop - 1); } } } else if (scope.currentIndex === 0) { scope.$apply(function() { scope.currentIndex = -1; inputField.val(scope.searchStr); }); } } else if (which === KEY_TAB) { if (scope.results && scope.results.length > 0 && scope.showDropdown) { if (scope.currentIndex === -1 && scope.overrideSuggestions) { // intentionally not sending event so that it does not // prevent default tab behavior handleOverrideSuggestions(); } else { if (scope.currentIndex === -1) { scope.currentIndex = 0; } scope.selectResult(scope.results[scope.currentIndex]); scope.$digest(); } } else { // no results // intentionally not sending event so that it does not // prevent default tab behavior if (scope.searchStr && scope.searchStr.length > 0) { handleOverrideSuggestions(); } } } else if (which === KEY_ES) { // This is very specific to IE10/11 #272 // without this, IE clears the input text event.preventDefault(); } } function httpSuccessCallbackGen(str) { return function(responseData, status, headers, config) { // normalize return obejct from promise if (!status && !headers && !config && responseData.data) { responseData = responseData.data; } scope.searching = false; processResults( extractValue(responseFormatter(responseData), scope.remoteUrlDataField), str); }; } function httpErrorCallback(errorRes, status, headers, config) { scope.searching = httpCallInProgress; // normalize return obejct from promise if (!status && !headers && !config) { status = errorRes.status; } // cancelled/aborted if (status === 0 || status === -1) { return; } if (scope.remoteUrlErrorCallback) { scope.remoteUrlErrorCallback(errorRes, status, headers, config); } else { if (console && console.error) { console.error('http error'); } } } function cancelHttpRequest() { if (httpCanceller) { httpCanceller.resolve(); } } function getRemoteResults(str) { var params = {}, url = scope.remoteUrl + encodeURIComponent(str); if (scope.remoteUrlRequestFormatter) { params = {params: scope.remoteUrlRequestFormatter(str)}; url = scope.remoteUrl; } if (!!scope.remoteUrlRequestWithCredentials) { params.withCredentials = true; } cancelHttpRequest(); httpCanceller = $q.defer(); params.timeout = httpCanceller.promise; httpCallInProgress = true; $http.get(url, params) .then(httpSuccessCallbackGen(str)) .catch(httpErrorCallback) .finally(function(){httpCallInProgress=false;}); } function getRemoteResultsWithCustomHandler(str) { cancelHttpRequest(); httpCanceller = $q.defer(); scope.remoteApiHandler(str, httpCanceller.promise) .then(httpSuccessCallbackGen(str)) .catch(httpErrorCallback); /* IE8 compatible scope.remoteApiHandler(str, httpCanceller.promise) ['then'](httpSuccessCallbackGen(str)) ['catch'](httpErrorCallback); */ } function clearResults() { scope.showDropdown = false; scope.results = []; if (dd) { dd.scrollTop = 0; } } function initResults() { scope.showDropdown = displaySearching; scope.currentIndex = scope.focusFirst ? 0 : -1; scope.results = []; } function getLocalResults(str) { var i, match, s, value, searchFields = scope.searchFields.split(','), matches = []; if (typeof scope.parseInput() !== 'undefined') { str = scope.parseInput()(str); } for (i = 0; i < scope.localData.length; i++) { match = false; for (s = 0; s < searchFields.length; s++) { value = extractValue(scope.localData[i], searchFields[s]) || ''; match = match || (value.toString().toLowerCase().indexOf(str.toString().toLowerCase()) >= 0); } if (match) { matches[matches.length] = scope.localData[i]; } } return matches; } function checkExactMatch(result, obj, str){ if (!str) { return false; } for(var key in obj){ if(obj[key].toLowerCase() === str.toLowerCase()){ scope.selectResult(result); return true; } } return false; } function searchTimerComplete(str) { // Begin the search if (!str || str.length < minlength) { return; } if (scope.localData) { scope.$apply(function() { var matches; if (typeof scope.localSearch() !== 'undefined') { matches = scope.localSearch()(str, scope.localData); } else { matches = getLocalResults(str); } scope.searching = false; processResults(matches, str); }); } else if (scope.remoteApiHandler) { getRemoteResultsWithCustomHandler(str); } else { getRemoteResults(str); } } function processResults(responseData, str) { var i, description, image, text, formattedText, formattedDesc; if (responseData && responseData.length > 0) { scope.results = []; for (i = 0; i < responseData.length; i++) { if (scope.titleField && scope.titleField !== '') { text = formattedText = extractTitle(responseData[i]); } description = ''; if (scope.descriptionField) { description = formattedDesc = extractValue(responseData[i], scope.descriptionField); } image = ''; if (scope.imageField) { image = extractValue(responseData[i], scope.imageField); } if (scope.matchClass) { formattedText = findMatchString(text, str); formattedDesc = findMatchString(description, str); } scope.results[scope.results.length] = { title: formattedText, description: formattedDesc, image: image, originalObject: responseData[i] }; } } else { scope.results = []; } if (scope.autoMatch && scope.results.length === 1 && checkExactMatch(scope.results[0], {title: text, desc: description || ''}, scope.searchStr)) { scope.showDropdown = false; } else if (scope.results.length === 0 && !displayNoResults) { scope.showDropdown = false; } else { scope.showDropdown = true; } } function showAll() { if (scope.localData) { scope.searching = false; processResults(scope.localData, ''); } else if (scope.remoteApiHandler) { scope.searching = true; getRemoteResultsWithCustomHandler(''); } else { scope.searching = true; getRemoteResults(''); } } scope.onFocusHandler = function() { if (scope.focusIn) { scope.focusIn(); } if (minlength === 0 && (!scope.searchStr || scope.searchStr.length === 0)) { scope.currentIndex = scope.focusFirst ? 0 : scope.currentIndex; scope.showDropdown = true; showAll(); } }; scope.hideResults = function() { if (mousedownOn && (mousedownOn === scope.id + '_dropdown' || mousedownOn.indexOf('angucomplete') >= 0)) { mousedownOn = null; } else { hideTimer = $timeout(function() { clearResults(); scope.$apply(function() { if (scope.searchStr && scope.searchStr.length > 0) { inputField.val(scope.searchStr); } }); }, BLUR_TIMEOUT); cancelHttpRequest(); if (scope.focusOut) { scope.focusOut(); } if (scope.overrideSuggestions) { if (scope.searchStr && scope.searchStr.length > 0 && scope.currentIndex === -1) { handleOverrideSuggestions(); } } } }; scope.resetHideResults = function() { if (hideTimer) { $timeout.cancel(hideTimer); } }; scope.hoverRow = function(index) { scope.currentIndex = index; }; scope.selectResult = function(result) { // Restore original values if (scope.matchClass) { result.title = extractTitle(result.originalObject); result.description = extractValue(result.originalObject, scope.descriptionField); } if (scope.clearSelected) { scope.searchStr = null; } else { scope.searchStr = result.title; } callOrAssign(result); clearResults(); }; scope.inputChangeHandler = function(str) { if (str.length < minlength) { cancelHttpRequest(); clearResults(); } else if (str.length === 0 && minlength === 0) { showAll(); } if (scope.inputChanged) { str = scope.inputChanged(str); } return str; }; // check required if (scope.fieldRequiredClass && scope.fieldRequiredClass !== '') { requiredClassName = scope.fieldRequiredClass; } // check min length if (scope.minlength && scope.minlength !== '') { minlength = parseInt(scope.minlength, 10); } // check pause time if (!scope.pause) { scope.pause = PAUSE; } // check clearSelected if (!scope.clearSelected) { scope.clearSelected = false; } // check override suggestions if (!scope.overrideSuggestions) { scope.overrideSuggestions = false; } // check required field if (scope.fieldRequired && ctrl) { // check initial value, if given, set validitity to true if (scope.initialValue) { handleRequired(true); } else { handleRequired(false); } } scope.inputType = attrs.type ? attrs.type : 'text'; // set strings for "Searching..." and "No results" scope.textSearching = attrs.textSearching ? attrs.textSearching : TEXT_SEARCHING; scope.textNoResults = attrs.textNoResults ? attrs.textNoResults : TEXT_NORESULTS; displaySearching = scope.textSearching === 'false' ? false : true; displayNoResults = scope.textNoResults === 'false' ? false : true; // set max length (default to maxlength deault from html scope.maxlength = attrs.maxlength ? attrs.maxlength : MAX_LENGTH; // register events inputField.on('keydown', keydownHandler); inputField.on('keyup compositionend', keyupHandler); // set response formatter responseFormatter = callFunctionOrIdentity('remoteUrlResponseFormatter'); // set isScrollOn $timeout(function() { var css = getComputedStyle(dd); isScrollOn = css.maxHeight && css.overflowY === 'auto'; }); } return { restrict: 'EA', require: '^?form', scope: { selectedObject: '=', selectedObjectData: '=', disableInput: '=', initialValue: '=', localData: '=', localSearch: '&', remoteUrlRequestFormatter: '=', remoteUrlRequestWithCredentials: '@', remoteUrlResponseFormatter: '=', remoteUrlErrorCallback: '=', remoteApiHandler: '=', id: '@', type: '@', placeholder: '@', textSearching: '@', textNoResults: '@', remoteUrl: '@', remoteUrlDataField: '@', titleField: '@', descriptionField: '@', imageField: '@', inputClass: '@', pause: '@', searchFields: '@', minlength: '@', matchClass: '@', clearSelected: '@', overrideSuggestions: '@', fieldRequired: '=', fieldRequiredClass: '@', inputChanged: '=', autoMatch: '@', focusOut: '&', focusIn: '&', fieldTabindex: '@', inputName: '@', focusFirst: '@', parseInput: '&' }, templateUrl: function(element, attrs) { return attrs.templateUrl || TEMPLATE_URL; }, compile: function(tElement) { var startSym = $interpolate.startSymbol(); var endSym = $interpolate.endSymbol(); if (!(startSym === '{{' && endSym === '}}')) { var interpolatedHtml = tElement.html() .replace(/\{\{/g, startSym) .replace(/\}\}/g, endSym); tElement.html(interpolatedHtml); } return link; } }; }]); }));