2 * jQuery Plugin: Tokenizing Autocomplete Text Entry
5 * Copyright (c) 2009 James Smith (http://loopj.com)
6 * Licensed jointly under the GPL and MIT licenses,
7 * choose which one suits your project best!
13 var DEFAULT_SETTINGS
= {
20 propertyToSearch
: "name",
24 hintText
: "Type in a search term",
25 noResultsText
: "No results",
26 searchingText
: "Searching...",
27 deleteText
: "×",
28 animateDropdown
: true,
30 // Tokenization settings
33 preventDuplicates
: false,
38 // Prepopulation settings
40 processPrePopulate
: false,
42 // Manipulation settings
43 idPrefix
: "token-input-",
46 resultsFormatter: function(item
){ return "<li>" + item
[this.propertyToSearch
]+ "</li>" },
47 tokenFormatter: function(item
) { return "<li><p>" + item
[this.propertyToSearch
] + "</p></li>" },
56 // Default classes to use when theming
57 var DEFAULT_CLASSES
= {
58 tokenList
: "token-input-list",
59 token
: "token-input-token",
60 tokenDelete
: "token-input-delete-token",
61 selectedToken
: "token-input-selected-token",
62 highlightedToken
: "token-input-highlighted-token",
63 dropdown
: "token-input-dropdown",
64 dropdownItem
: "token-input-dropdown-item",
65 dropdownItem2
: "token-input-dropdown-item2",
66 selectedDropdownItem
: "token-input-selected-dropdown-item",
67 inputToken
: "token-input-input-token"
70 // Input box position "enum"
96 // Additional public (exposed) methods
98 init: function(url_or_data_or_function
, options
) {
99 var settings
= $.extend({}, DEFAULT_SETTINGS
, options
|| {});
101 return this.each(function () {
102 $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function
, settings
));
106 this.data("tokenInputObject").clear();
109 add: function(item
) {
110 this.data("tokenInputObject").add(item
);
113 remove: function(item
) {
114 this.data("tokenInputObject").remove(item
);
118 return this.data("tokenInputObject").getTokens();
122 // Expose the .tokenInput function to jQuery as a plugin
123 $.fn
.tokenInput = function (method
) {
124 // Method calling and initialization logic
125 if(methods
[method
]) {
126 return methods
[method
].apply(this, Array
.prototype.slice
.call(arguments
, 1));
128 return methods
.init
.apply(this, arguments
);
132 // TokenList class for each input
133 $.TokenList = function (input
, url_or_data
, settings
) {
138 // Configure the data source
139 if($.type(url_or_data
) === "string" || $.type(url_or_data
) === "function") {
140 // Set the url to query against
141 settings
.url
= url_or_data
;
143 // If the URL is a function, evaluate it here to do our initalization work
144 var url
= computeURL();
146 // Make a smart guess about cross-domain if it wasn't explicitly specified
147 if(settings
.crossDomain
=== undefined) {
148 if(url
.indexOf("://") === -1) {
149 settings
.crossDomain
= false;
151 settings
.crossDomain
= (location
.href
.split(/\/+/g)[1] !== url
.split(/\/+/g)[1]);
154 } else if(typeof(url_or_data
) === "object") {
155 // Set the local data to search through
156 settings
.local_data
= url_or_data
;
160 if(settings
.classes
) {
161 // Use custom class names
162 settings
.classes
= $.extend({}, DEFAULT_CLASSES
, settings
.classes
);
163 } else if(settings
.theme
) {
164 // Use theme-suffixed default class names
165 settings
.classes
= {};
166 $.each(DEFAULT_CLASSES
, function(key
, value
) {
167 settings
.classes
[key
] = value
+ "-" + settings
.theme
;
170 settings
.classes
= DEFAULT_CLASSES
;
175 var saved_tokens
= [];
177 // Keep track of the number of tokens in the list
180 // Basic cache to save on db hits
181 var cache
= new $.TokenList
.Cache();
183 // Keep track of the timeout, old vals
187 // Create a new text input an attach keyup events
188 var input_box
= $("<input type=\"text\" autocomplete=\"off\">")
192 .attr("id", settings
.idPrefix
+ input
.id
)
194 if (settings
.tokenLimit
=== null || settings
.tokenLimit
!== token_count
) {
195 show_dropdown_hint();
202 .bind("keyup keydown blur update", resize_input
)
203 .keydown(function (event
) {
207 switch(event
.keyCode
) {
213 previous_token
= input_token
.prev();
214 next_token
= input_token
.next();
216 if((previous_token
.length
&& previous_token
.get(0) === selected_token
) || (next_token
.length
&& next_token
.get(0) === selected_token
)) {
217 // Check if there is a previous/next token and it is selected
218 if(event
.keyCode
=== KEY
.LEFT
|| event
.keyCode
=== KEY
.UP
) {
219 deselect_token($(selected_token
), POSITION
.BEFORE
);
221 deselect_token($(selected_token
), POSITION
.AFTER
);
223 } else if((event
.keyCode
=== KEY
.LEFT
|| event
.keyCode
=== KEY
.UP
) && previous_token
.length
) {
224 // We are moving left, select the previous token if it exists
225 select_token($(previous_token
.get(0)));
226 } else if((event
.keyCode
=== KEY
.RIGHT
|| event
.keyCode
=== KEY
.DOWN
) && next_token
.length
) {
227 // We are moving right, select the next token if it exists
228 select_token($(next_token
.get(0)));
231 var dropdown_item
= null;
233 if(event
.keyCode
=== KEY
.DOWN
|| event
.keyCode
=== KEY
.RIGHT
) {
234 dropdown_item
= $(selected_dropdown_item
).next();
236 dropdown_item
= $(selected_dropdown_item
).prev();
239 if(dropdown_item
.length
) {
240 select_dropdown_item(dropdown_item
);
247 previous_token
= input_token
.prev();
249 if(!$(this).val().length
) {
251 delete_token($(selected_token
));
252 hidden_input
.change();
253 } else if(previous_token
.length
) {
254 select_token($(previous_token
.get(0)));
258 } else if($(this).val().length
=== 1) {
261 // set a timeout just long enough to let this function finish.
262 setTimeout(function(){do_search();}, 5);
268 case KEY
.NUMPAD_ENTER
:
269 // Comma should NOT select token CRM-8488
271 if(selected_dropdown_item
) {
272 add_token($(selected_dropdown_item
).data("tokeninput"));
273 hidden_input
.change();
283 if(String
.fromCharCode(event
.which
)) {
284 // set a timeout just long enough to let this function finish.
285 setTimeout(function(){do_search();}, 5);
291 // Keep a reference to the original input box
292 var hidden_input
= $(input
)
302 // Keep a reference to the selected token and dropdown item
303 var selected_token
= null;
304 var selected_token_index
= 0;
305 var selected_dropdown_item
= null;
307 // The list to store the token items in
308 var token_list
= $("<ul />")
309 .addClass(settings
.classes
.tokenList
)
310 .click(function (event
) {
311 var li
= $(event
.target
).closest("li");
312 if(li
&& li
.get(0) && $.data(li
.get(0), "tokeninput")) {
313 toggle_select_token(li
);
315 // Deselect selected token
317 deselect_token($(selected_token
), POSITION
.END
);
324 .mouseover(function (event
) {
325 var li
= $(event
.target
).closest("li");
326 if(li
&& selected_token
!== this) {
327 li
.addClass(settings
.classes
.highlightedToken
);
330 .mouseout(function (event
) {
331 var li
= $(event
.target
).closest("li");
332 if(li
&& selected_token
!== this) {
333 li
.removeClass(settings
.classes
.highlightedToken
);
336 .insertBefore(hidden_input
);
338 // The token holding the input box
339 var input_token
= $("<li />")
340 .addClass(settings
.classes
.inputToken
)
341 .appendTo(token_list
)
344 // The list to store the dropdown items in
345 var dropdown
= $("<div>")
346 .addClass(settings
.classes
.dropdown
)
350 // Magic element to help us resize the text input
351 var input_resizer
= $("<tester/>")
352 .insertAfter(input_box
)
354 position
: "absolute",
358 fontSize
: input_box
.css("fontSize"),
359 fontFamily
: input_box
.css("fontFamily"),
360 fontWeight
: input_box
.css("fontWeight"),
361 letterSpacing
: input_box
.css("letterSpacing"),
365 // Pre-populate list if items exist
366 hidden_input
.val("");
367 var li_data
= settings
.prePopulate
|| hidden_input
.data("pre");
368 if(settings
.processPrePopulate
&& $.isFunction(settings
.onResult
)) {
369 li_data
= settings
.onResult
.call(hidden_input
, li_data
);
371 if(li_data
&& li_data
.length
) {
372 $.each(li_data
, function (index
, value
) {
378 // Initialization is done
379 if($.isFunction(settings
.onReady
)) {
380 settings
.onReady
.call();
387 this.clear = function() {
388 token_list
.children("li").each(function() {
389 if ($(this).children("input").length
=== 0) {
390 delete_token($(this));
395 this.add = function(item
) {
399 this.remove = function(item
) {
400 token_list
.children("li").each(function() {
401 if ($(this).children("input").length
=== 0) {
402 var currToken
= $(this).data("tokeninput");
404 for (var prop
in item
) {
405 if (item
[prop
] !== currToken
[prop
]) {
411 delete_token($(this));
417 this.getTokens = function() {
425 function checkTokenLimit() {
426 if(settings
.tokenLimit
!== null && token_count
>= settings
.tokenLimit
) {
433 function resize_input() {
434 if(input_val
=== (input_val
= input_box
.val())) {return;}
436 // Enter new content into resizer and resize input accordingly
437 var escaped
= input_val
.replace(/&/g, '&').replace(/\s
/g,' ').replace(/</g, '<').replace(/>/g
, '>');
438 input_resizer
.html(escaped
);
439 input_box
.width(input_resizer
.width() + 30);
442 function is_printable_character(keycode
) {
443 return ((keycode
>= 48 && keycode
<= 90) || // 0-1a-z
444 (keycode
>= 96 && keycode
<= 111) || // numpad 0-9 + - / * .
445 (keycode
>= 186 && keycode
<= 192) || // ; = , - . / ^
446 (keycode
>= 219 && keycode
<= 222)); // ( \ ) '
449 // Inner function to a token to the list
450 function insert_token(item
) {
451 var this_token
= settings
.tokenFormatter(item
);
452 this_token
= $(this_token
)
453 .addClass(settings
.classes
.token
)
454 .insertBefore(input_token
);
456 // The 'delete token' button
457 $("<span>" + settings
.deleteText
+ "</span>")
458 .addClass(settings
.classes
.tokenDelete
)
459 .appendTo(this_token
)
461 delete_token($(this).parent());
462 hidden_input
.change();
466 // Store data on the token
467 var token_data
= {"id": item
.id
};
468 token_data
[settings
.propertyToSearch
] = item
[settings
.propertyToSearch
];
469 $.data(this_token
.get(0), "tokeninput", item
);
471 // Save this token for duplicate checking
472 saved_tokens
= saved_tokens
.slice(0,selected_token_index
).concat([token_data
]).concat(saved_tokens
.slice(selected_token_index
));
473 selected_token_index
++;
475 // Update the hidden input
476 update_hidden_input(saved_tokens
, hidden_input
);
480 // Check the token limit
481 if(settings
.tokenLimit
!== null && token_count
>= settings
.tokenLimit
) {
489 // Add a token to the token list based on user input
490 function add_token (item
) {
491 var callback
= settings
.onAdd
;
493 // See if the token already exists and select it if we don't want duplicates
494 if(token_count
> 0 && settings
.preventDuplicates
) {
495 var found_existing_token
= null;
496 token_list
.children().each(function () {
497 var existing_token
= $(this);
498 var existing_data
= $.data(existing_token
.get(0), "tokeninput");
499 if(existing_data
&& existing_data
.id
=== item
.id
) {
500 found_existing_token
= existing_token
;
505 if(found_existing_token
) {
506 select_token(found_existing_token
);
507 input_token
.insertAfter(found_existing_token
);
513 // Insert the new tokens
514 if(settings
.tokenLimit
== null || token_count
< settings
.tokenLimit
) {
522 // Don't show the help dropdown, they've got the idea
525 // Execute the onAdd callback if defined
526 if($.isFunction(callback
)) {
527 callback
.call(hidden_input
,item
);
531 // Select a token in the token list
532 function select_token (token
) {
533 token
.addClass(settings
.classes
.selectedToken
);
534 selected_token
= token
.get(0);
539 // Hide dropdown if it is visible (eg if we clicked to select token)
543 // Deselect a token in the token list
544 function deselect_token (token
, position
) {
545 token
.removeClass(settings
.classes
.selectedToken
);
546 selected_token
= null;
548 if(position
=== POSITION
.BEFORE
) {
549 input_token
.insertBefore(token
);
550 selected_token_index
--;
551 } else if(position
=== POSITION
.AFTER
) {
552 input_token
.insertAfter(token
);
553 selected_token_index
++;
555 input_token
.appendTo(token_list
);
556 selected_token_index
= token_count
;
559 // Show the input box and give it focus again
563 // Toggle selection of a token in the token list
564 function toggle_select_token(token
) {
565 var previous_selected_token
= selected_token
;
568 deselect_token($(selected_token
), POSITION
.END
);
571 if(previous_selected_token
=== token
.get(0)) {
572 deselect_token(token
, POSITION
.END
);
578 // Delete a token from the token list
579 function delete_token (token
) {
580 // Remove the id from the saved list
581 var token_data
= $.data(token
.get(0), "tokeninput");
582 var callback
= settings
.onDelete
;
584 var index
= token
.prevAll().length
;
585 if(index
> selected_token_index
) index
--;
589 selected_token
= null;
591 // Show the input box and give it focus again
594 // Remove this token from the saved list
595 saved_tokens
= saved_tokens
.slice(0,index
).concat(saved_tokens
.slice(index
+1));
596 if(index
< selected_token_index
) selected_token_index
--;
598 // Update the hidden input
599 update_hidden_input(saved_tokens
, hidden_input
);
603 if(settings
.tokenLimit
!== null) {
610 // Execute the onDelete callback if defined
611 if($.isFunction(callback
)) {
612 callback
.call(hidden_input
,token_data
);
616 // Update the hidden input box value
617 function update_hidden_input(saved_tokens
, hidden_input
) {
618 var token_values
= $.map(saved_tokens
, function (el
) {
619 return el
[settings
.tokenValue
];
621 hidden_input
.val(token_values
.join(settings
.tokenDelimiter
));
625 // Hide and clear the results dropdown
626 function hide_dropdown () {
627 dropdown
.hide().empty();
628 selected_dropdown_item
= null;
631 function show_dropdown() {
634 position
: "absolute",
635 top
: $(token_list
).offset().top
+ $(token_list
).outerHeight(),
636 left
: $(token_list
).offset().left
,
642 function show_dropdown_searching () {
643 if(settings
.searchingText
) {
644 dropdown
.html("<p>"+settings
.searchingText
+"</p>");
649 function show_dropdown_hint () {
650 if(settings
.hintText
) {
651 dropdown
.html("<p>"+settings
.hintText
+"</p>");
656 // Highlight the query part of the search term
657 function highlight_term(value
, term
) {
658 return value
.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term
+ ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<b>$1</b>");
661 function find_value_and_highlight_term(template
, value
, term
) {
662 return template
.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + value
+ ")(?![^<>]*>)(?![^&;]+;)", "g"), highlight_term(value
, term
));
665 // Populate the results dropdown with some results
666 function populate_dropdown (query
, results
) {
667 if(results
&& results
.length
) {
669 var dropdown_ul
= $("<ul>")
671 .mouseover(function (event
) {
672 select_dropdown_item($(event
.target
).closest("li"));
674 .mousedown(function (event
) {
675 add_token($(event
.target
).closest("li").data("tokeninput"));
676 hidden_input
.change();
681 $.each(results
, function(index
, value
) {
682 var this_li
= settings
.resultsFormatter(value
);
684 this_li
= find_value_and_highlight_term(this_li
,value
[settings
.propertyToSearch
], query
);
686 this_li
= $(this_li
).appendTo(dropdown_ul
);
689 this_li
.addClass(settings
.classes
.dropdownItem
);
691 this_li
.addClass(settings
.classes
.dropdownItem2
);
695 select_dropdown_item(this_li
);
698 $.data(this_li
.get(0), "tokeninput", value
);
703 if(settings
.animateDropdown
) {
704 dropdown_ul
.slideDown("fast");
709 if(settings
.noResultsText
) {
710 dropdown
.html("<p>"+settings
.noResultsText
+"</p>");
716 // Highlight an item in the results dropdown
717 function select_dropdown_item (item
) {
719 if(selected_dropdown_item
) {
720 deselect_dropdown_item($(selected_dropdown_item
));
723 item
.addClass(settings
.classes
.selectedDropdownItem
);
724 selected_dropdown_item
= item
.get(0);
728 // Remove highlighting from an item in the results dropdown
729 function deselect_dropdown_item (item
) {
730 item
.removeClass(settings
.classes
.selectedDropdownItem
);
731 selected_dropdown_item
= null;
734 // Do a search and show the "searching" dropdown if the input is longer
735 // than settings.minChars
736 function do_search() {
737 var query
= input_box
.val().toLowerCase();
739 if(query
&& query
.length
) {
741 deselect_token($(selected_token
), POSITION
.AFTER
);
744 if(query
.length
>= settings
.minChars
) {
745 show_dropdown_searching();
746 clearTimeout(timeout
);
748 timeout
= setTimeout(function(){
750 }, settings
.searchDelay
);
757 // Do the actual search
758 function run_search(query
) {
759 var cache_key
= query
+ computeURL();
760 var cached_results
= cache
.get(cache_key
);
762 populate_dropdown(query
, cached_results
);
764 // Are we doing an ajax search or local data search?
766 var url
= computeURL();
767 // Extract exisiting get params
768 var ajax_params
= {};
769 ajax_params
.data
= {};
770 if(url
.indexOf("?") > -1) {
771 var parts
= url
.split("?");
772 ajax_params
.url
= parts
[0];
774 var param_array
= parts
[1].split("&");
775 $.each(param_array
, function (index
, value
) {
776 var kv
= value
.split("=");
777 ajax_params
.data
[kv
[0]] = kv
[1];
780 ajax_params
.url
= url
;
783 // Prepare the request
784 ajax_params
.data
[settings
.queryParam
] = query
;
785 ajax_params
.type
= settings
.method
;
786 ajax_params
.dataType
= settings
.contentType
;
787 if(settings
.crossDomain
) {
788 ajax_params
.dataType
= "jsonp";
791 // Attach the success callback
792 ajax_params
.success = function(results
) {
793 if($.isFunction(settings
.onResult
)) {
794 results
= settings
.onResult
.call(hidden_input
, results
);
796 cache
.add(cache_key
, settings
.jsonContainer
? results
[settings
.jsonContainer
] : results
);
798 // only populate the dropdown if the results are associated with the active search query
799 if(input_box
.val().toLowerCase() === query
) {
800 populate_dropdown(query
, settings
.jsonContainer
? results
[settings
.jsonContainer
] : results
);
806 } else if(settings
.local_data
) {
807 // Do the search through local data
808 var results
= $.grep(settings
.local_data
, function (row
) {
809 return row
[settings
.propertyToSearch
].toLowerCase().indexOf(query
.toLowerCase()) > -1;
812 if($.isFunction(settings
.onResult
)) {
813 results
= settings
.onResult
.call(hidden_input
, results
);
815 cache
.add(cache_key
, results
);
816 populate_dropdown(query
, results
);
821 // compute the dynamic URL
822 function computeURL() {
823 var url
= settings
.url
;
824 if(typeof settings
.url
== 'function') {
825 url
= settings
.url
.call();
831 // Really basic cache for the results
832 $.TokenList
.Cache = function (options
) {
833 var settings
= $.extend({
840 var flush = function () {
845 this.add = function (query
, results
) {
846 if(size
> settings
.max_size
) {
854 data
[query
] = results
;
857 this.get = function (query
) {