typeahead.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  1. angular.module('ui.bootstrap.debounce', [])
  2. /**
  3. * A helper, internal service that debounces a function
  4. */
  5. .factory('$$debounce', ['$timeout', function($timeout) {
  6. return function(callback, debounceTime) {
  7. var timeoutPromise;
  8. return function() {
  9. var self = this;
  10. var args = Array.prototype.slice(arguments);
  11. if (timeoutPromise) {
  12. $timeout.cancel(timeoutPromise);
  13. }
  14. timeoutPromise = $timeout(function() {
  15. callback.apply(self, args);
  16. }, debounceTime);
  17. };
  18. };
  19. }]);
  20. angular.module('ui.bootstrap.position', [])
  21. /**
  22. * A set of utility methods that can be use to retrieve position of DOM elements.
  23. * It is meant to be used where we need to absolute-position DOM elements in
  24. * relation to other, existing elements (this is the case for tooltips, popovers,
  25. * typeahead suggestions etc.).
  26. */
  27. .factory('$uibPosition', ['$document', '$window', function($document, $window) {
  28. function getStyle(el, cssprop) {
  29. if (el.currentStyle) { //IE
  30. return el.currentStyle[cssprop];
  31. } else if ($window.getComputedStyle) {
  32. return $window.getComputedStyle(el)[cssprop];
  33. }
  34. // finally try and get inline style
  35. return el.style[cssprop];
  36. }
  37. /**
  38. * Checks if a given element is statically positioned
  39. * @param element - raw DOM element
  40. */
  41. function isStaticPositioned(element) {
  42. return (getStyle(element, 'position') || 'static' ) === 'static';
  43. }
  44. /**
  45. * returns the closest, non-statically positioned parentOffset of a given element
  46. * @param element
  47. */
  48. var parentOffsetEl = function(element) {
  49. var docDomEl = $document[0];
  50. var offsetParent = element.offsetParent || docDomEl;
  51. while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent) ) {
  52. offsetParent = offsetParent.offsetParent;
  53. }
  54. return offsetParent || docDomEl;
  55. };
  56. return {
  57. /**
  58. * Provides read-only equivalent of jQuery's position function:
  59. * http://api.jquery.com/position/
  60. */
  61. position: function(element) {
  62. var elBCR = this.offset(element);
  63. var offsetParentBCR = { top: 0, left: 0 };
  64. var offsetParentEl = parentOffsetEl(element[0]);
  65. if (offsetParentEl !== $document[0]) {
  66. offsetParentBCR = this.offset(angular.element(offsetParentEl));
  67. offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop;
  68. offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft;
  69. }
  70. var boundingClientRect = element[0].getBoundingClientRect();
  71. return {
  72. width: boundingClientRect.width || element.prop('offsetWidth'),
  73. height: boundingClientRect.height || element.prop('offsetHeight'),
  74. top: elBCR.top - offsetParentBCR.top,
  75. left: elBCR.left - offsetParentBCR.left
  76. };
  77. },
  78. /**
  79. * Provides read-only equivalent of jQuery's offset function:
  80. * http://api.jquery.com/offset/
  81. */
  82. offset: function(element) {
  83. var boundingClientRect = element[0].getBoundingClientRect();
  84. return {
  85. width: boundingClientRect.width || element.prop('offsetWidth'),
  86. height: boundingClientRect.height || element.prop('offsetHeight'),
  87. top: boundingClientRect.top + ($window.pageYOffset || $document[0].documentElement.scrollTop),
  88. left: boundingClientRect.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft)
  89. };
  90. },
  91. /**
  92. * Provides coordinates for the targetEl in relation to hostEl
  93. */
  94. positionElements: function(hostEl, targetEl, positionStr, appendToBody) {
  95. var positionStrParts = positionStr.split('-');
  96. var pos0 = positionStrParts[0], pos1 = positionStrParts[1] || 'center';
  97. var hostElPos,
  98. targetElWidth,
  99. targetElHeight,
  100. targetElPos;
  101. hostElPos = appendToBody ? this.offset(hostEl) : this.position(hostEl);
  102. targetElWidth = targetEl.prop('offsetWidth');
  103. targetElHeight = targetEl.prop('offsetHeight');
  104. var shiftWidth = {
  105. center: function() {
  106. return hostElPos.left + hostElPos.width / 2 - targetElWidth / 2;
  107. },
  108. left: function() {
  109. return hostElPos.left;
  110. },
  111. right: function() {
  112. return hostElPos.left + hostElPos.width;
  113. }
  114. };
  115. var shiftHeight = {
  116. center: function() {
  117. return hostElPos.top + hostElPos.height / 2 - targetElHeight / 2;
  118. },
  119. top: function() {
  120. return hostElPos.top;
  121. },
  122. bottom: function() {
  123. return hostElPos.top + hostElPos.height;
  124. }
  125. };
  126. switch (pos0) {
  127. case 'right':
  128. targetElPos = {
  129. top: shiftHeight[pos1](),
  130. left: shiftWidth[pos0]()
  131. };
  132. break;
  133. case 'left':
  134. targetElPos = {
  135. top: shiftHeight[pos1](),
  136. left: hostElPos.left - targetElWidth
  137. };
  138. break;
  139. case 'bottom':
  140. targetElPos = {
  141. top: shiftHeight[pos0](),
  142. left: shiftWidth[pos1]()
  143. };
  144. break;
  145. default:
  146. targetElPos = {
  147. top: hostElPos.top - targetElHeight,
  148. left: shiftWidth[pos1]()
  149. };
  150. }
  151. return targetElPos;
  152. }
  153. };
  154. }]);
  155. angular.module('ui.bootstrap.typeahead', ['ui.bootstrap.debounce', 'ui.bootstrap.position'])
  156. /**
  157. * A helper service that can parse typeahead's syntax (string provided by users)
  158. * Extracted to a separate service for ease of unit testing
  159. */
  160. .factory('uibTypeaheadParser', ['$parse', function($parse) {
  161. // 00000111000000000000022200000000000000003333333333333330000000000044000
  162. var TYPEAHEAD_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+([\s\S]+?)$/;
  163. return {
  164. parse: function(input) {
  165. var match = input.match(TYPEAHEAD_REGEXP);
  166. if (!match) {
  167. throw new Error(
  168. 'Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_"' +
  169. ' but got "' + input + '".');
  170. }
  171. return {
  172. itemName: match[3],
  173. source: $parse(match[4]),
  174. viewMapper: $parse(match[2] || match[1]),
  175. modelMapper: $parse(match[1])
  176. };
  177. }
  178. };
  179. }])
  180. .controller('UibTypeaheadController', ['$scope', '$element', '$attrs', '$compile', '$parse', '$q', '$timeout', '$document', '$window', '$rootScope', '$$debounce', '$uibPosition', 'uibTypeaheadParser',
  181. function(originalScope, element, attrs, $compile, $parse, $q, $timeout, $document, $window, $rootScope, $$debounce, $position, typeaheadParser) {
  182. var HOT_KEYS = [9, 13, 27, 38, 40];
  183. var eventDebounceTime = 200;
  184. var modelCtrl, ngModelOptions;
  185. //SUPPORTED ATTRIBUTES (OPTIONS)
  186. //minimal no of characters that needs to be entered before typeahead kicks-in
  187. var minLength = originalScope.$eval(attrs.typeaheadMinLength);
  188. if (!minLength && minLength !== 0) {
  189. minLength = 1;
  190. }
  191. //minimal wait time after last character typed before typeahead kicks-in
  192. var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0;
  193. //should it restrict model values to the ones selected from the popup only?
  194. var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false;
  195. originalScope.$watch(attrs.typeaheadEditable, function (newVal) {
  196. isEditable = newVal !== false;
  197. });
  198. //binding to a variable that indicates if matches are being retrieved asynchronously
  199. var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop;
  200. //a callback executed when a match is selected
  201. var onSelectCallback = $parse(attrs.typeaheadOnSelect);
  202. //should it select highlighted popup value when losing focus?
  203. var isSelectOnBlur = angular.isDefined(attrs.typeaheadSelectOnBlur) ? originalScope.$eval(attrs.typeaheadSelectOnBlur) : false;
  204. //binding to a variable that indicates if there were no results after the query is completed
  205. var isNoResultsSetter = $parse(attrs.typeaheadNoResults).assign || angular.noop;
  206. var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined;
  207. var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false;
  208. var appendTo = attrs.typeaheadAppendTo ?
  209. originalScope.$eval(attrs.typeaheadAppendTo) : null;
  210. var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false;
  211. //If input matches an item of the list exactly, select it automatically
  212. var selectOnExact = attrs.typeaheadSelectOnExact ? originalScope.$eval(attrs.typeaheadSelectOnExact) : false;
  213. //binding to a variable that indicates if dropdown is open
  214. var isOpenSetter = $parse(attrs.typeaheadIsOpen).assign || angular.noop;
  215. var showHint = originalScope.$eval(attrs.typeaheadShowHint) || false;
  216. //INTERNAL VARIABLES
  217. //model setter executed upon match selection
  218. var parsedModel = $parse(attrs.ngModel);
  219. var invokeModelSetter = $parse(attrs.ngModel + '($$$p)');
  220. var $setModelValue = function(scope, newValue) {
  221. if (angular.isFunction(parsedModel(originalScope)) &&
  222. ngModelOptions && ngModelOptions.$options && ngModelOptions.$options.getterSetter) {
  223. return invokeModelSetter(scope, {$$$p: newValue});
  224. }
  225. return parsedModel.assign(scope, newValue);
  226. };
  227. //expressions used by typeahead
  228. var parserResult = typeaheadParser.parse(attrs.uibTypeahead);
  229. var hasFocus;
  230. //Used to avoid bug in iOS webview where iOS keyboard does not fire
  231. //mousedown & mouseup events
  232. //Issue #3699
  233. var selected;
  234. //create a child scope for the typeahead directive so we are not polluting original scope
  235. //with typeahead-specific data (matches, query etc.)
  236. var scope = originalScope.$new();
  237. var offDestroy = originalScope.$on('$destroy', function() {
  238. scope.$destroy();
  239. });
  240. scope.$on('$destroy', offDestroy);
  241. // WAI-ARIA
  242. var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000);
  243. element.attr({
  244. 'aria-autocomplete': 'list',
  245. 'aria-expanded': false,
  246. 'aria-owns': popupId
  247. });
  248. var inputsContainer, hintInputElem;
  249. //add read-only input to show hint
  250. if (showHint) {
  251. inputsContainer = angular.element('<div></div>');
  252. inputsContainer.css('position', 'relative');
  253. element.after(inputsContainer);
  254. hintInputElem = element.clone();
  255. hintInputElem.attr('placeholder', '');
  256. hintInputElem.val('');
  257. hintInputElem.css({
  258. 'position': 'absolute',
  259. 'top': '0px',
  260. 'left': '0px',
  261. 'border-color': 'transparent',
  262. 'box-shadow': 'none',
  263. 'opacity': 1,
  264. 'background': 'none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 255, 255)',
  265. 'color': '#999'
  266. });
  267. element.css({
  268. 'position': 'relative',
  269. 'vertical-align': 'top',
  270. 'background-color': 'transparent'
  271. });
  272. inputsContainer.append(hintInputElem);
  273. hintInputElem.after(element);
  274. }
  275. //pop-up element used to display matches
  276. var popUpEl = angular.element('<div uib-typeahead-popup></div>');
  277. popUpEl.attr({
  278. id: popupId,
  279. matches: 'matches',
  280. active: 'activeIdx',
  281. select: 'select(activeIdx)',
  282. 'move-in-progress': 'moveInProgress',
  283. query: 'query',
  284. position: 'position',
  285. 'assign-is-open': 'assignIsOpen(isOpen)'
  286. });
  287. //custom item template
  288. if (angular.isDefined(attrs.typeaheadTemplateUrl)) {
  289. popUpEl.attr('template-url', attrs.typeaheadTemplateUrl);
  290. }
  291. if (angular.isDefined(attrs.typeaheadPopupTemplateUrl)) {
  292. popUpEl.attr('popup-template-url', attrs.typeaheadPopupTemplateUrl);
  293. }
  294. var resetHint = function() {
  295. if (showHint) {
  296. hintInputElem.val('');
  297. }
  298. };
  299. var resetMatches = function() {
  300. scope.matches = [];
  301. scope.activeIdx = -1;
  302. element.attr('aria-expanded', false);
  303. resetHint();
  304. };
  305. var getMatchId = function(index) {
  306. return popupId + '-option-' + index;
  307. };
  308. // Indicate that the specified match is the active (pre-selected) item in the list owned by this typeahead.
  309. // This attribute is added or removed automatically when the `activeIdx` changes.
  310. scope.$watch('activeIdx', function(index) {
  311. if (index < 0) {
  312. element.removeAttr('aria-activedescendant');
  313. } else {
  314. element.attr('aria-activedescendant', getMatchId(index));
  315. }
  316. });
  317. var inputIsExactMatch = function(inputValue, index) {
  318. if (scope.matches.length > index && inputValue) {
  319. return inputValue.toUpperCase() === scope.matches[index].label.toUpperCase();
  320. }
  321. return false;
  322. };
  323. var getMatchesAsync = function(inputValue) {
  324. var locals = {$viewValue: inputValue};
  325. isLoadingSetter(originalScope, true);
  326. isNoResultsSetter(originalScope, false);
  327. $q.when(parserResult.source(originalScope, locals)).then(function(matches) {
  328. //it might happen that several async queries were in progress if a user were typing fast
  329. //but we are interested only in responses that correspond to the current view value
  330. var onCurrentRequest = inputValue === modelCtrl.$viewValue;
  331. if (onCurrentRequest && hasFocus) {
  332. if (matches && matches.length > 0) {
  333. scope.activeIdx = focusFirst ? 0 : -1;
  334. isNoResultsSetter(originalScope, false);
  335. scope.matches.length = 0;
  336. //transform labels
  337. for (var i = 0; i < matches.length; i++) {
  338. locals[parserResult.itemName] = matches[i];
  339. scope.matches.push({
  340. id: getMatchId(i),
  341. label: parserResult.viewMapper(scope, locals),
  342. model: matches[i]
  343. });
  344. }
  345. scope.query = inputValue;
  346. //position pop-up with matches - we need to re-calculate its position each time we are opening a window
  347. //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page
  348. //due to other elements being rendered
  349. recalculatePosition();
  350. element.attr('aria-expanded', true);
  351. //Select the single remaining option if user input matches
  352. if (selectOnExact && scope.matches.length === 1 && inputIsExactMatch(inputValue, 0)) {
  353. scope.select(0);
  354. }
  355. if (showHint) {
  356. var firstLabel = scope.matches[0].label;
  357. if (inputValue.length > 0 && firstLabel.slice(0, inputValue.length).toUpperCase() === inputValue.toUpperCase()) {
  358. hintInputElem.val(inputValue + firstLabel.slice(inputValue.length));
  359. }
  360. else {
  361. hintInputElem.val('');
  362. }
  363. }
  364. } else {
  365. resetMatches();
  366. isNoResultsSetter(originalScope, true);
  367. }
  368. }
  369. if (onCurrentRequest) {
  370. isLoadingSetter(originalScope, false);
  371. }
  372. }, function() {
  373. resetMatches();
  374. isLoadingSetter(originalScope, false);
  375. isNoResultsSetter(originalScope, true);
  376. });
  377. };
  378. // bind events only if appendToBody params exist - performance feature
  379. if (appendToBody) {
  380. angular.element($window).bind('resize', fireRecalculating);
  381. $document.find('body').bind('scroll', fireRecalculating);
  382. }
  383. // Declare the debounced function outside recalculating for
  384. // proper debouncing
  385. var debouncedRecalculate = $$debounce(function() {
  386. // if popup is visible
  387. if (scope.matches.length) {
  388. recalculatePosition();
  389. }
  390. scope.moveInProgress = false;
  391. }, eventDebounceTime);
  392. // Default progress type
  393. scope.moveInProgress = false;
  394. function fireRecalculating() {
  395. if (!scope.moveInProgress) {
  396. scope.moveInProgress = true;
  397. scope.$digest();
  398. }
  399. debouncedRecalculate();
  400. }
  401. // recalculate actual position and set new values to scope
  402. // after digest loop is popup in right position
  403. function recalculatePosition() {
  404. scope.position = appendToBody ? $position.offset(element) : $position.position(element);
  405. scope.position.top += element.prop('offsetHeight');
  406. }
  407. //we need to propagate user's query so we can higlight matches
  408. scope.query = undefined;
  409. //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later
  410. var timeoutPromise;
  411. var scheduleSearchWithTimeout = function(inputValue) {
  412. timeoutPromise = $timeout(function() {
  413. getMatchesAsync(inputValue);
  414. }, waitTime);
  415. };
  416. var cancelPreviousTimeout = function() {
  417. if (timeoutPromise) {
  418. $timeout.cancel(timeoutPromise);
  419. }
  420. };
  421. resetMatches();
  422. scope.assignIsOpen = function (isOpen) {
  423. isOpenSetter(originalScope, isOpen);
  424. };
  425. scope.select = function(activeIdx) {
  426. //called from within the $digest() cycle
  427. var locals = {};
  428. var model, item;
  429. selected = true;
  430. locals[parserResult.itemName] = item = scope.matches[activeIdx].model;
  431. model = parserResult.modelMapper(originalScope, locals);
  432. $setModelValue(originalScope, model);
  433. modelCtrl.$setValidity('editable', true);
  434. modelCtrl.$setValidity('parse', true);
  435. onSelectCallback(originalScope, {
  436. $item: item,
  437. $model: model,
  438. $label: parserResult.viewMapper(originalScope, locals)
  439. });
  440. resetMatches();
  441. //return focus to the input element if a match was selected via a mouse click event
  442. // use timeout to avoid $rootScope:inprog error
  443. if (scope.$eval(attrs.typeaheadFocusOnSelect) !== false) {
  444. $timeout(function() { element[0].focus(); }, 0, false);
  445. }
  446. };
  447. //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27)
  448. element.bind('keydown', function(evt) {
  449. //typeahead is open and an "interesting" key was pressed
  450. if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) {
  451. return;
  452. }
  453. // if there's nothing selected (i.e. focusFirst) and enter or tab is hit, clear the results
  454. if (scope.activeIdx === -1 && (evt.which === 9 || evt.which === 13)) {
  455. resetMatches();
  456. scope.$digest();
  457. return;
  458. }
  459. evt.preventDefault();
  460. if (evt.which === 40) {
  461. scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
  462. scope.$digest();
  463. popUpEl.children()[scope.activeIdx].scrollIntoView(false);
  464. } else if (evt.which === 38) {
  465. scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1;
  466. scope.$digest();
  467. popUpEl.children()[scope.activeIdx].scrollIntoView(false);
  468. } else if (evt.which === 13 || evt.which === 9) {
  469. scope.$apply(function () {
  470. scope.select(scope.activeIdx);
  471. });
  472. } else if (evt.which === 27) {
  473. evt.stopPropagation();
  474. resetMatches();
  475. scope.$digest();
  476. }
  477. });
  478. element.bind('focus', function () {
  479. hasFocus = true;
  480. if (minLength === 0 && !modelCtrl.$viewValue) {
  481. getMatchesAsync(modelCtrl.$viewValue);
  482. }
  483. });
  484. element.bind('blur', function() {
  485. if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) {
  486. selected = true;
  487. scope.$apply(function() {
  488. scope.select(scope.activeIdx);
  489. });
  490. }
  491. if (!isEditable && modelCtrl.$error.editable) {
  492. modelCtrl.$viewValue = '';
  493. element.val('');
  494. }
  495. hasFocus = false;
  496. selected = false;
  497. });
  498. // Keep reference to click handler to unbind it.
  499. var dismissClickHandler = function(evt) {
  500. // Issue #3973
  501. // Firefox treats right click as a click on document
  502. if (element[0] !== evt.target && evt.which !== 3 && scope.matches.length !== 0) {
  503. resetMatches();
  504. if (!$rootScope.$$phase) {
  505. scope.$digest();
  506. }
  507. }
  508. };
  509. $document.bind('click', dismissClickHandler);
  510. originalScope.$on('$destroy', function() {
  511. $document.unbind('click', dismissClickHandler);
  512. if (appendToBody || appendTo) {
  513. $popup.remove();
  514. }
  515. if (appendToBody) {
  516. angular.element($window).unbind('resize', fireRecalculating);
  517. $document.find('body').unbind('scroll', fireRecalculating);
  518. }
  519. // Prevent jQuery cache memory leak
  520. popUpEl.remove();
  521. if (showHint) {
  522. inputsContainer.remove();
  523. }
  524. });
  525. var $popup = $compile(popUpEl)(scope);
  526. if (appendToBody) {
  527. $document.find('body').append($popup);
  528. } else if (appendTo) {
  529. angular.element(appendTo).eq(0).append($popup);
  530. } else {
  531. element.after($popup);
  532. }
  533. this.init = function(_modelCtrl, _ngModelOptions) {
  534. modelCtrl = _modelCtrl;
  535. ngModelOptions = _ngModelOptions;
  536. //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM
  537. //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue
  538. modelCtrl.$parsers.unshift(function(inputValue) {
  539. hasFocus = true;
  540. if (minLength === 0 || inputValue && inputValue.length >= minLength) {
  541. if (waitTime > 0) {
  542. cancelPreviousTimeout();
  543. scheduleSearchWithTimeout(inputValue);
  544. } else {
  545. getMatchesAsync(inputValue);
  546. }
  547. } else {
  548. isLoadingSetter(originalScope, false);
  549. cancelPreviousTimeout();
  550. resetMatches();
  551. }
  552. if (isEditable) {
  553. return inputValue;
  554. }
  555. if (!inputValue) {
  556. // Reset in case user had typed something previously.
  557. modelCtrl.$setValidity('editable', true);
  558. return null;
  559. }
  560. modelCtrl.$setValidity('editable', false);
  561. return undefined;
  562. });
  563. modelCtrl.$formatters.push(function(modelValue) {
  564. var candidateViewValue, emptyViewValue;
  565. var locals = {};
  566. // The validity may be set to false via $parsers (see above) if
  567. // the model is restricted to selected values. If the model
  568. // is set manually it is considered to be valid.
  569. if (!isEditable) {
  570. modelCtrl.$setValidity('editable', true);
  571. }
  572. if (inputFormatter) {
  573. locals.$model = modelValue;
  574. return inputFormatter(originalScope, locals);
  575. }
  576. //it might happen that we don't have enough info to properly render input value
  577. //we need to check for this situation and simply return model value if we can't apply custom formatting
  578. locals[parserResult.itemName] = modelValue;
  579. candidateViewValue = parserResult.viewMapper(originalScope, locals);
  580. locals[parserResult.itemName] = undefined;
  581. emptyViewValue = parserResult.viewMapper(originalScope, locals);
  582. return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue;
  583. });
  584. };
  585. }])
  586. .directive('uibTypeahead', function() {
  587. return {
  588. controller: 'UibTypeaheadController',
  589. require: ['ngModel', '^?ngModelOptions', 'uibTypeahead'],
  590. link: function(originalScope, element, attrs, ctrls) {
  591. ctrls[2].init(ctrls[0], ctrls[1]);
  592. }
  593. };
  594. })
  595. .directive('uibTypeaheadPopup', function() {
  596. return {
  597. scope: {
  598. matches: '=',
  599. query: '=',
  600. active: '=',
  601. position: '&',
  602. moveInProgress: '=',
  603. select: '&',
  604. assignIsOpen: '&'
  605. },
  606. replace: true,
  607. templateUrl: function(element, attrs) {
  608. return attrs.popupTemplateUrl || 'html/typeahead-popup.html';
  609. },
  610. link: function(scope, element, attrs) {
  611. scope.templateUrl = attrs.templateUrl;
  612. scope.isOpen = function() {
  613. var isDropdownOpen = scope.matches.length > 0;
  614. scope.assignIsOpen({ isOpen: isDropdownOpen });
  615. return isDropdownOpen;
  616. };
  617. scope.isActive = function(matchIdx) {
  618. return scope.active === matchIdx;
  619. };
  620. scope.selectActive = function(matchIdx) {
  621. scope.active = matchIdx;
  622. };
  623. scope.selectMatch = function(activeIdx) {
  624. scope.select({activeIdx: activeIdx});
  625. };
  626. }
  627. };
  628. })
  629. .directive('uibTypeaheadMatch', ['$templateRequest', '$compile', '$parse', function($templateRequest, $compile, $parse) {
  630. return {
  631. scope: {
  632. index: '=',
  633. match: '=',
  634. query: '='
  635. },
  636. link: function(scope, element, attrs) {
  637. var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'html/typeahead-match.html';
  638. $templateRequest(tplUrl).then(function(tplContent) {
  639. var tplEl = angular.element(tplContent.trim());
  640. element.replaceWith(tplEl);
  641. $compile(tplEl)(scope);
  642. });
  643. }
  644. };
  645. }])
  646. .filter('uibTypeaheadHighlight', ['$sce', '$injector', '$log', function($sce, $injector, $log) {
  647. var isSanitizePresent;
  648. isSanitizePresent = $injector.has('$sanitize');
  649. function escapeRegexp(queryToEscape) {
  650. // Regex: capture the whole query string and replace it with the string that will be used to match
  651. // the results, for example if the capture is "a" the result will be \a
  652. return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
  653. }
  654. function containsHtml(matchItem) {
  655. return /<.*>/g.test(matchItem);
  656. }
  657. return function(matchItem, query) {
  658. if (!isSanitizePresent && containsHtml(matchItem)) {
  659. $log.warn('Unsafe use of typeahead please use ngSanitize'); // Warn the user about the danger
  660. }
  661. matchItem = query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '<strong>$&</strong>') : matchItem; // Replaces the capture string with a the same string inside of a "strong" tag
  662. if (!isSanitizePresent) {
  663. matchItem = $sce.trustAsHtml(matchItem); // If $sanitize is not present we pack the string in a $sce object for the ng-bind-html directive
  664. }
  665. return matchItem;
  666. };
  667. }]);