UNPKG

22.9 kBJavaScriptView Raw
1/*
2 * angucomplete-alt
3 * Autocomplete directive for AngularJS
4 * This is a fork of Daryl Rowland's angucomplete with some extra features.
5 * By Hidenari Nozaki
6 */
7
8/*! Copyright (c) 2014 Hidenari Nozaki and contributors | Licensed under the MIT license */
9
10'use strict';
11
12(function (root, factory) {
13 if (typeof module !== 'undefined' && module.exports) {
14 // CommonJS
15 module.exports = factory(require('angular'));
16 } else if (typeof define === 'function' && define.amd) {
17 // AMD
18 define(['angular'], factory);
19 } else {
20 // Global Variables
21 factory(root.angular);
22 }
23}(window, function (angular) {
24
25 angular.module('angucomplete-alt', [] )
26 .directive('angucompleteAlt', ['$q', '$parse', '$http', '$sce', '$timeout', '$templateCache', function ($q, $parse, $http, $sce, $timeout, $templateCache) {
27 // keyboard events
28 var KEY_DW = 40;
29 var KEY_RT = 39;
30 var KEY_UP = 38;
31 var KEY_LF = 37;
32 var KEY_ES = 27;
33 var KEY_EN = 13;
34 var KEY_BS = 8;
35 var KEY_DEL = 46;
36 var KEY_TAB = 9;
37
38 var MIN_LENGTH = 3;
39 var MAX_LENGTH = 524288; // the default max length per the html maxlength attribute
40 var PAUSE = 500;
41 var BLUR_TIMEOUT = 200;
42
43 // string constants
44 var REQUIRED_CLASS = 'autocomplete-required';
45 var TEXT_SEARCHING = 'Searching...';
46 var TEXT_NORESULTS = 'No results found';
47 var TEMPLATE_URL = '/angucomplete-alt/index.html';
48
49 // Set the default template for this directive
50 $templateCache.put(TEMPLATE_URL,
51 '<div class="angucomplete-holder" ng-class="{\'angucomplete-dropdown-visible\': showDropdown}">' +
52 ' <input id="{{id}}_value" ng-model="searchStr" ng-disabled="disableInput" type="{{type}}" placeholder="{{placeholder}}" maxlength="{{maxlength}}" ng-focus="onFocusHandler()" class="{{inputClass}}" ng-focus="resetHideResults()" ng-blur="hideResults($event)" autocapitalize="off" autocorrect="off" autocomplete="off" ng-change="inputChangeHandler(searchStr)"/>' +
53 ' <div id="{{id}}_dropdown" class="angucomplete-dropdown" ng-show="showDropdown">' +
54 ' <div class="angucomplete-searching" ng-show="searching" ng-bind="textSearching"></div>' +
55 ' <div class="angucomplete-searching" ng-show="!searching && (!results || results.length == 0)" ng-bind="textNoResults"></div>' +
56 ' <div class="angucomplete-row" ng-repeat="result in results" ng-click="selectResult(result)" ng-mouseenter="hoverRow($index)" ng-class="{\'angucomplete-selected-row\': $index == currentIndex}">' +
57 ' <div ng-if="imageField" class="angucomplete-image-holder">' +
58 ' <img ng-if="result.image && result.image != \'\'" ng-src="{{result.image}}" class="angucomplete-image"/>' +
59 ' <div ng-if="!result.image && result.image != \'\'" class="angucomplete-image-default"></div>' +
60 ' </div>' +
61 ' <div class="angucomplete-title" ng-if="matchClass" ng-bind-html="result.title"></div>' +
62 ' <div class="angucomplete-title" ng-if="!matchClass">{{ result.title }}</div>' +
63 ' <div ng-if="matchClass && result.description && result.description != \'\'" class="angucomplete-description" ng-bind-html="result.description"></div>' +
64 ' <div ng-if="!matchClass && result.description && result.description != \'\'" class="angucomplete-description">{{result.description}}</div>' +
65 ' </div>' +
66 ' </div>' +
67 '</div>'
68 );
69
70 return {
71 restrict: 'EA',
72 require: '^?form',
73 scope: {
74 selectedObject: '=',
75 disableInput: '=',
76 initialValue: '@',
77 localData: '=',
78 remoteUrlRequestFormatter: '=',
79 remoteUrlRequestWithCredentials: '@',
80 remoteUrlResponseFormatter: '=',
81 remoteUrlErrorCallback: '=',
82 id: '@',
83 type: '@',
84 placeholder: '@',
85 remoteUrl: '@',
86 remoteUrlDataField: '@',
87 titleField: '@',
88 descriptionField: '@',
89 imageField: '@',
90 inputClass: '@',
91 pause: '@',
92 searchFields: '@',
93 minlength: '@',
94 matchClass: '@',
95 clearSelected: '@',
96 overrideSuggestions: '@',
97 fieldRequired: '@',
98 fieldRequiredClass: '@',
99 inputChanged: '=',
100 autoMatch: '@',
101 focusOut: '&',
102 focusIn: '&'
103 },
104 templateUrl: function(element, attrs) {
105 return attrs.templateUrl || TEMPLATE_URL;
106 },
107 link: function(scope, elem, attrs, ctrl) {
108 var inputField = elem.find('input');
109 var minlength = MIN_LENGTH;
110 var searchTimer = null;
111 var hideTimer;
112 var requiredClassName = REQUIRED_CLASS;
113 var responseFormatter;
114 var validState = null;
115 var httpCanceller = null;
116 var dd = elem[0].querySelector('.angucomplete-dropdown');
117 var isScrollOn = false;
118 var mousedownOn = null;
119 var unbindInitialValue;
120
121 elem.on('mousedown', function(event) {
122 mousedownOn = event.target.id;
123 });
124
125 scope.currentIndex = null;
126 scope.searching = false;
127 scope.searchStr = scope.initialValue;
128 unbindInitialValue = scope.$watch('initialValue', function(newval, oldval){
129 if (newval && newval.length > 0) {
130 scope.searchStr = scope.initialValue;
131 handleRequired(true);
132 unbindInitialValue();
133 }
134 });
135
136 scope.$on('angucomplete-alt:clearInput', function (event, elementId) {
137 if (!elementId) {
138 scope.searchStr = null;
139 clearResults();
140 }
141 else { // id is given
142 if (scope.id === elementId) {
143 scope.searchStr = null;
144 clearResults();
145 }
146 }
147 });
148
149 // for IE8 quirkiness about event.which
150 function ie8EventNormalizer(event) {
151 return event.which ? event.which : event.keyCode;
152 }
153
154 function callOrAssign(value) {
155 if (typeof scope.selectedObject === 'function') {
156 scope.selectedObject(value);
157 }
158 else {
159 scope.selectedObject = value;
160 }
161
162 if (value) {
163 handleRequired(true);
164 }
165 else {
166 handleRequired(false);
167 }
168 }
169
170 function callFunctionOrIdentity(fn) {
171 return function(data) {
172 return scope[fn] ? scope[fn](data) : data;
173 };
174 }
175
176 function setInputString(str) {
177 callOrAssign({originalObject: str});
178
179 if (scope.clearSelected) {
180 scope.searchStr = null;
181 }
182 clearResults();
183 }
184
185 function extractTitle(data) {
186 // split title fields and run extractValue for each and join with ' '
187 return scope.titleField.split(',')
188 .map(function(field) {
189 return extractValue(data, field);
190 })
191 .join(' ');
192 }
193
194 function extractValue(obj, key) {
195 var keys, result;
196 if (key) {
197 keys= key.split('.');
198 result = obj;
199 keys.forEach(function(k) { result = result[k]; });
200 }
201 else {
202 result = obj;
203 }
204 return result;
205 }
206
207 function findMatchString(target, str) {
208 var result, matches, re;
209 // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
210 // Escape user input to be treated as a literal string within a regular expression
211 re = new RegExp(str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
212 if (!target) { return; }
213 matches = target.match(re);
214 if (matches) {
215 result = target.replace(re,
216 '<span class="'+ scope.matchClass +'">'+ matches[0] +'</span>');
217 }
218 else {
219 result = target;
220 }
221 return $sce.trustAsHtml(result);
222 }
223
224 function handleRequired(valid) {
225 validState = scope.searchStr;
226 if (scope.fieldRequired && ctrl) {
227 ctrl.$setValidity(requiredClassName, valid);
228 }
229 }
230
231 function keyupHandler(event) {
232 var which = ie8EventNormalizer(event);
233 if (which === KEY_LF || which === KEY_RT) {
234 // do nothing
235 return;
236 }
237
238 if (which === KEY_UP || which === KEY_EN) {
239 event.preventDefault();
240 }
241 else if (which === KEY_DW) {
242 event.preventDefault();
243 if (!scope.showDropdown && scope.searchStr && scope.searchStr.length >= minlength) {
244 initResults();
245 scope.searching = true;
246 searchTimerComplete(scope.searchStr);
247 }
248 }
249 else if (which === KEY_ES) {
250 clearResults();
251 scope.$apply(function() {
252 inputField.val(scope.searchStr);
253 });
254 }
255 else {
256 if (!scope.searchStr || scope.searchStr === '') {
257 scope.showDropdown = false;
258 } else if (scope.searchStr.length >= minlength) {
259 initResults();
260
261 if (searchTimer) {
262 $timeout.cancel(searchTimer);
263 }
264
265 scope.searching = true;
266
267 searchTimer = $timeout(function() {
268 searchTimerComplete(scope.searchStr);
269 }, scope.pause);
270 }
271
272 if (validState && validState !== scope.searchStr && !scope.clearSelected) {
273 callOrAssign(undefined);
274 }
275 }
276 }
277
278 function handleOverrideSuggestions(event) {
279 if (scope.overrideSuggestions &&
280 !(scope.selectedObject && scope.selectedObject.originalObject === scope.searchStr)) {
281 if (event) {
282 event.preventDefault();
283 }
284 setInputString(scope.searchStr);
285 }
286 }
287
288 function dropdownRowOffsetHeight(row) {
289 var css = getComputedStyle(row);
290 return row.offsetHeight +
291 parseInt(css.marginTop, 10) + parseInt(css.marginBottom, 10);
292 }
293
294 function dropdownHeight() {
295 return dd.getBoundingClientRect().top +
296 parseInt(getComputedStyle(dd).maxHeight, 10);
297 }
298
299 function dropdownRow() {
300 return elem[0].querySelectorAll('.angucomplete-row')[scope.currentIndex];
301 }
302
303 function dropdownRowTop() {
304 return dropdownRow().getBoundingClientRect().top -
305 (dd.getBoundingClientRect().top +
306 parseInt(getComputedStyle(dd).paddingTop, 10));
307 }
308
309 function dropdownScrollTopTo(offset) {
310 dd.scrollTop = dd.scrollTop + offset;
311 }
312
313 function updateInputField(){
314 var current = scope.results[scope.currentIndex];
315 if (scope.matchClass) {
316 inputField.val(extractTitle(current.originalObject));
317 }
318 else {
319 inputField.val(current.title);
320 }
321 }
322
323 function keydownHandler(event) {
324 var which = ie8EventNormalizer(event);
325 var row = null;
326 var rowTop = null;
327
328 if (which === KEY_EN && scope.results) {
329 if (scope.currentIndex >= 0 && scope.currentIndex < scope.results.length) {
330 event.preventDefault();
331 scope.selectResult(scope.results[scope.currentIndex]);
332 } else {
333 handleOverrideSuggestions(event);
334 clearResults();
335 }
336 scope.$apply();
337 } else if (which === KEY_DW && scope.results) {
338 event.preventDefault();
339 if ((scope.currentIndex + 1) < scope.results.length && scope.showDropdown) {
340 scope.$apply(function() {
341 scope.currentIndex ++;
342 updateInputField();
343 });
344
345 if (isScrollOn) {
346 row = dropdownRow();
347 if (dropdownHeight() < row.getBoundingClientRect().bottom) {
348 dropdownScrollTopTo(dropdownRowOffsetHeight(row));
349 }
350 }
351 }
352 } else if (which === KEY_UP && scope.results) {
353 event.preventDefault();
354 if (scope.currentIndex >= 1) {
355 scope.$apply(function() {
356 scope.currentIndex --;
357 updateInputField();
358 });
359
360 if (isScrollOn) {
361 rowTop = dropdownRowTop();
362 if (rowTop < 0) {
363 dropdownScrollTopTo(rowTop - 1);
364 }
365 }
366 }
367 else if (scope.currentIndex === 0) {
368 scope.$apply(function() {
369 scope.currentIndex = -1;
370 inputField.val(scope.searchStr);
371 });
372 }
373 } else if (which === KEY_TAB) {
374 if (scope.results && scope.results.length > 0 && scope.showDropdown) {
375 if (scope.currentIndex === -1 && scope.overrideSuggestions) {
376 // intentionally not sending event so that it does not
377 // prevent default tab behavior
378 handleOverrideSuggestions();
379 }
380 else {
381 if (scope.currentIndex === -1) {
382 scope.currentIndex = 0;
383 }
384 scope.selectResult(scope.results[scope.currentIndex]);
385 scope.$digest();
386 }
387 }
388 else {
389 // no results
390 // intentionally not sending event so that it does not
391 // prevent default tab behavior
392 if (scope.searchStr && scope.searchStr.length > 0) {
393 handleOverrideSuggestions();
394 }
395 }
396 }
397 }
398
399 function httpSuccessCallbackGen(str) {
400 return function(responseData, status, headers, config) {
401 scope.searching = false;
402 processResults(
403 extractValue(responseFormatter(responseData), scope.remoteUrlDataField),
404 str);
405 };
406 }
407
408 function httpErrorCallback(errorRes, status, headers, config) {
409 if (status !== 0) {
410 if (scope.remoteUrlErrorCallback) {
411 scope.remoteUrlErrorCallback(errorRes, status, headers, config);
412 }
413 else {
414 if (console && console.error) {
415 console.error('http error');
416 }
417 }
418 }
419 }
420
421 function cancelHttpRequest() {
422 if (httpCanceller) {
423 httpCanceller.resolve();
424 }
425 }
426
427 function getRemoteResults(str) {
428 var params = {},
429 url = scope.remoteUrl + encodeURIComponent(str);
430 if (scope.remoteUrlRequestFormatter) {
431 params = {params: scope.remoteUrlRequestFormatter(str)};
432 url = scope.remoteUrl;
433 }
434 if (!!scope.remoteUrlRequestWithCredentials) {
435 params.withCredentials = true;
436 }
437 cancelHttpRequest();
438 httpCanceller = $q.defer();
439 params.timeout = httpCanceller.promise;
440 $http.get(url, params)
441 .success(httpSuccessCallbackGen(str))
442 .error(httpErrorCallback);
443 }
444
445 function clearResults() {
446 scope.showDropdown = false;
447 scope.results = [];
448 if (dd) {
449 dd.scrollTop = 0;
450 }
451 }
452
453 function initResults() {
454 scope.showDropdown = true;
455 scope.currentIndex = -1;
456 scope.results = [];
457 }
458
459 function getLocalResults(str) {
460 var i, match, s, value,
461 searchFields = scope.searchFields.split(','),
462 matches = [];
463
464 for (i = 0; i < scope.localData.length; i++) {
465 match = false;
466
467 for (s = 0; s < searchFields.length; s++) {
468 value = extractValue(scope.localData[i], searchFields[s]) || '';
469 match = match || (value.toLowerCase().indexOf(str.toLowerCase()) >= 0);
470 }
471
472 if (match) {
473 matches[matches.length] = scope.localData[i];
474 }
475 }
476
477 scope.searching = false;
478 processResults(matches, str);
479 }
480
481 function checkExactMatch(result, obj, str){
482 for(var key in obj){
483 if(obj[key].toLowerCase() === str.toLowerCase()){
484 scope.selectResult(result);
485 return;
486 }
487 }
488 }
489
490 function searchTimerComplete(str) {
491 // Begin the search
492 if (!str || str.length < minlength) {
493 return;
494 }
495 if (scope.localData) {
496 scope.$apply(function() {
497 getLocalResults(str);
498 });
499 }
500 else {
501 getRemoteResults(str);
502 }
503 }
504
505 function processResults(responseData, str) {
506 var i, description, image, text, formattedText, formattedDesc;
507
508 if (responseData && responseData.length > 0) {
509 scope.results = [];
510
511 for (i = 0; i < responseData.length; i++) {
512 if (scope.titleField && scope.titleField !== '') {
513 text = formattedText = extractTitle(responseData[i]);
514 }
515
516 description = '';
517 if (scope.descriptionField) {
518 description = formattedDesc = extractValue(responseData[i], scope.descriptionField);
519 }
520
521 image = '';
522 if (scope.imageField) {
523 image = extractValue(responseData[i], scope.imageField);
524 }
525
526 if (scope.matchClass) {
527 formattedText = findMatchString(text, str);
528 formattedDesc = findMatchString(description, str);
529 }
530
531 scope.results[scope.results.length] = {
532 title: formattedText,
533 description: formattedDesc,
534 image: image,
535 originalObject: responseData[i]
536 };
537
538 if (scope.autoMatch) {
539 checkExactMatch(scope.results[scope.results.length-1],
540 {title: text, desc: description || ''}, scope.searchStr);
541 }
542 }
543
544 } else {
545 scope.results = [];
546 }
547 }
548
549 function showAll() {
550 if (scope.localData) {
551 processResults(scope.localData, '');
552 }
553 else {
554 getRemoteResults('');
555 }
556 }
557
558 scope.onFocusHandler = function() {
559 if (scope.focusIn) {
560 scope.focusIn();
561 }
562 if (minlength === 0 && (!scope.searchStr || scope.searchStr.length === 0)) {
563 scope.showDropdown = true;
564 showAll();
565 }
566 };
567
568 scope.hideResults = function(event) {
569 if (mousedownOn === scope.id + '_dropdown') {
570 mousedownOn = null;
571 }
572 else {
573 hideTimer = $timeout(function() {
574 clearResults();
575 scope.$apply(function() {
576 if (scope.searchStr && scope.searchStr.length > 0) {
577 inputField.val(scope.searchStr);
578 }
579 });
580 }, BLUR_TIMEOUT);
581 cancelHttpRequest();
582
583 if (scope.focusOut) {
584 scope.focusOut();
585 }
586
587 if (scope.overrideSuggestions) {
588 if (scope.searchStr && scope.searchStr.length > 0 && scope.currentIndex === -1) {
589 handleOverrideSuggestions();
590 }
591 }
592 }
593 };
594
595 scope.resetHideResults = function() {
596 if (hideTimer) {
597 $timeout.cancel(hideTimer);
598 }
599 };
600
601 scope.hoverRow = function(index) {
602 scope.currentIndex = index;
603 };
604
605 scope.selectResult = function(result) {
606 // Restore original values
607 if (scope.matchClass) {
608 result.title = extractTitle(result.originalObject);
609 result.description = extractValue(result.originalObject, scope.descriptionField);
610 }
611
612 if (scope.clearSelected) {
613 scope.searchStr = null;
614 }
615 else {
616 scope.searchStr = result.title;
617 }
618 callOrAssign(result);
619 clearResults();
620 };
621
622 scope.inputChangeHandler = function(str) {
623 if (str.length < minlength) {
624 clearResults();
625 }
626 else if (str.length === 0 && minlength === 0) {
627 showAll();
628 }
629
630 if (scope.inputChanged) {
631 str = scope.inputChanged(str);
632 }
633 return str;
634 };
635
636 // check required
637 if (scope.fieldRequiredClass && scope.fieldRequiredClass !== '') {
638 requiredClassName = scope.fieldRequiredClass;
639 }
640
641 // check min length
642 if (scope.minlength && scope.minlength !== '') {
643 minlength = parseInt(scope.minlength, 10);
644 }
645
646 // check pause time
647 if (!scope.pause) {
648 scope.pause = PAUSE;
649 }
650
651 // check clearSelected
652 if (!scope.clearSelected) {
653 scope.clearSelected = false;
654 }
655
656 // check override suggestions
657 if (!scope.overrideSuggestions) {
658 scope.overrideSuggestions = false;
659 }
660
661 // check required field
662 if (scope.fieldRequired && ctrl) {
663 // check initial value, if given, set validitity to true
664 if (scope.initialValue) {
665 handleRequired(true);
666 }
667 else {
668 handleRequired(false);
669 }
670 }
671
672 // set strings for "Searching..." and "No results"
673 scope.textSearching = attrs.textSearching ? attrs.textSearching : TEXT_SEARCHING;
674 scope.textNoResults = attrs.textNoResults ? attrs.textNoResults : TEXT_NORESULTS;
675
676 // set max length (default to maxlength deault from html
677 scope.maxlength = attrs.maxlength ? attrs.maxlength : MAX_LENGTH;
678
679 // register events
680 inputField.on('keydown', keydownHandler);
681 inputField.on('keyup', keyupHandler);
682
683 // set response formatter
684 responseFormatter = callFunctionOrIdentity('remoteUrlResponseFormatter');
685
686 scope.$on('$destroy', function() {
687 // take care of required validity when it gets destroyed
688 handleRequired(true);
689 });
690
691 // set isScrollOn
692 $timeout(function() {
693 var css = getComputedStyle(dd);
694 isScrollOn = css.maxHeight && css.overflowY === 'auto';
695 });
696 }
697 };
698 }]);
699
700}));