/* * jQuery Plugin: Tokenizing Autocomplete Text Entry * Version 1.4.2 * * Copyright (c) 2009 James Smith (http://loopj.com) * Licensed jointly under the GPL and MIT licenses, * choose which one suits your project best! * */ (function ($) { // Default settings var DEFAULT_SETTINGS = { hintText: "Type in a search term", noResultsText: "No results", searchingText: "Searching...", deleteText: "×", searchDelay: 500, minChars: 1, tokenLimit: null, jsonContainer: null, method: "GET", contentType: "json", queryParam: "q", tokenDelimiter: ",", preventDuplicates: false, prePopulate: null, processPrePopulate: false, makeSortable: false, escapeHTML: true, animateDropdown: true, onResult: null, onAdd: null, onDelete: null, noCache: false }; // Default classes to use when theming var DEFAULT_CLASSES = { tokenList: "autocomplete", sortable: "sortable", token: "added tag", tokenDelete: "delete", selectedToken: "selected", highlightedToken: "highlighted", dropdown: "autocomplete dropdown", dropdownItem: "even", dropdownItem2: "odd", selectedDropdownItem: "selected", inputToken: "input", insertBefore: "selected", insertAfter: "selected" }; // Input box position "enum" var POSITION = { BEFORE: 0, AFTER: 1, END: 2 }; // Keys "enum" var KEY; if (!(KeyboardEvent.prototype.hasOwnProperty("key"))) { // Older browsers past IE8 KEY = { BACKSPACE: 8, TAB: 9, ENTER: 13, ESCAPE: 27, SPACE: 32, PAGE_UP: 33, PAGE_DOWN: 34, END: 35, HOME: 36, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40, DELETE: 46, NUMPAD_ENTER: 108, COMMA: 188 }; Object.defineProperty( KeyboardEvent.prototype, "key", { configurable: true, enumerable: true, get: function () { return this.keyCode; } }); } else { var browserHasKeyProp = true; KEY = { BACKSPACE: "Backspace", TAB: "Tab", ENTER: "Enter", ESCAPE: "Escape", SPACE: " ", PAGE_UP: "PageUp", PAGE_DOWN: "PageDown", END: "End", HOME: "Home", LEFT: "ArrowLeft", UP: "ArrowUp", RIGHT: "ArrowRight", DOWN: "ArrowDown", DELETE: "Delete", NUMPAD_ENTER: "Enter", COMMA: "," }; } // IE/Edge compatibility for event.key if (browserHasKeyProp) { (function() { var eventProto = KeyboardEvent.prototype; var keyProp = Object.getOwnPropertyDescriptor(eventProto, "key"); var keys = { Spacebar: " ", Esc: "Escape", Left: "ArrowLeft", Up: "ArrowUp", Right: "ArrowRight", Down: "ArrowDown", Del: "Delete", }; Object.defineProperty(eventProto, "key", { configurable: true, enumerable: true, get: function() { var key = keyProp.get.call(this); return keys.hasOwnProperty(key) ? keys[key] : key; } }); })(); } // Expose the .tokenInput function to jQuery as a plugin $.fn.tokenInput = function (url_or_data, options) { var settings = $.extend({}, DEFAULT_SETTINGS, options || {}); return this.each(function () { new $.TokenList(this, url_or_data, settings); }); }; // TokenList class for each input $.TokenList = function (input, url_or_data, settings) { // // Initialization // // Configure the data source if(typeof(url_or_data) === "string") { // Set the url to query against settings.url = url_or_data; // Make a smart guess about cross-domain if it wasn't explicitly specified if(settings.crossDomain === undefined) { if(settings.url.indexOf("://") === -1) { settings.crossDomain = false; } else { settings.crossDomain = (location.href.split(/\/+/g)[1] !== settings.url.split(/\/+/g)[1]); } } } else if(typeof(url_or_data) === "object") { // Set the local data to search through settings.local_data = url_or_data; } // Build class names if(settings.classes) { // Use custom class names settings.classes = $.extend({}, DEFAULT_CLASSES, settings.classes); } else if(settings.theme) { // Use theme-suffixed default class names settings.classes = {}; $.each(DEFAULT_CLASSES, function(key, value) { settings.classes[key] = value + "-" + settings.theme; }); } else { settings.classes = DEFAULT_CLASSES; } // Save the tokens var saved_tokens = []; // Keep track of the number of tokens in the list var token_count = 0; // Basic cache to save on db hits var cache = new $.TokenList.Cache(); // Keep track of the timeout, old vals var timeout; var input_val; // Keep a reference to the original input box's id so we can use it for our new input and its label var hidden_input_id = $(input) .attr('id'); // Keep a reference to the label whose for attribute that matches the original input box's id var hidden_input_label = $('label[for="' + hidden_input_id + '"]'); // Change the original label's for attribute so it will match the id attribue we give the new input box hidden_input_label.attr({ 'for': hidden_input_id + '_autocomplete' }); // Give the new input box an id attribute based on the original input box's id // Originally included .css({outline: "none" }), but we actually want to see an outline for accessibility reasons var input_box = $("") .attr({ 'id': hidden_input_id + '_autocomplete' }) .focus(function () { if (settings.tokenLimit === null || token_count < settings.tokenLimit) { if ($(this).val().length >= settings.minChars) { // run the search setTimeout(function(){do_search();}, 5); } else { show_dropdown_hint(); } } }) .blur(function () { hide_dropdown(); }) .keydown(function (event) { var previous_token; var next_token; switch(event.key) { case KEY.LEFT: case KEY.RIGHT: case KEY.UP: case KEY.DOWN: if(!first_dropdown_item || first_dropdown_item.is(":hidden")) { // There's no dropdown of search results available, we're aiming for the existing tokens if (selected_token) { // save prev and next tokens previous_token = $(selected_token).prev(); next_token = $(selected_token).next(); // no matter what, deselect the currently selected token if(event.key === KEY.LEFT || event.key === KEY.UP) { deselect_token($(selected_token), POSITION.BEFORE); } else { deselect_token($(selected_token), POSITION.AFTER); } } else { previous_token = input_token.prev(); next_token = input_token.next(); } if((event.key === KEY.LEFT || event.key === KEY.UP) && previous_token.length) { // We are moving left, select the previous token if it exists select_token($(previous_token.get(0))); } else if((event.key === KEY.RIGHT || event.key === KEY.DOWN) && next_token.length) { // We are moving right, select the next token if it exists select_token($(next_token.get(0))); } } else if (event.key === KEY.LEFT || event.key === KEY.RIGHT) { // ignore to allow users to move around in the string they're typing in the input field with the arrow keys return true; } else { // move up and down in the dropdown list var dropdown_item = null; if (!selected_dropdown_item) { dropdown_item = first_dropdown_item; } else if(event.key === KEY.DOWN) { dropdown_item = $(selected_dropdown_item).next(); } else { dropdown_item = $(selected_dropdown_item).prev(); } if(dropdown_item.length) { select_dropdown_item(dropdown_item); // scroll to newly selected item if necessary $(dropdown).scrollTo($(dropdown_item)); } else if (event.key === KEY.UP) { // deselect the dropdown item if(selected_dropdown_item) { deselect_dropdown_item($(selected_dropdown_item)); } } return false; } break; case KEY.BACKSPACE: case KEY.DELETE: previous_token = input_token.prev(); if(!$(this).val().length) { if(selected_token) { delete_token($(selected_token)); } else if(previous_token.length) { select_token($(previous_token.get(0))); } return false; } else if($(this).val().length === 1) { hide_dropdown(); } else { // set a timeout just long enough to let this function finish. // was 5. setTimeout(function(){do_search();}, 25); } break; case KEY.COMMA: case KEY.TAB: case KEY.ENTER: case KEY.NUMPAD_ENTER: if(selected_dropdown_item) { add_token($(selected_dropdown_item)); deselect_dropdown_item($(selected_dropdown_item)); hide_dropdown(); if (event.keyCode === KEY.TAB && settings.tokenLimit && settings.tokenLimit === token_count) { break; } else { return false; } } else if(input_box.val()) { // split contents and add them $.each(input_box.val().split(settings.tokenDelimiter), function(index, item) { add_token($.trim(item)); }); hide_dropdown(); if (event.keyCode === KEY.TAB && settings.tokenLimit && settings.tokenLimit === token_count) { break; } else { return false; } } break; case KEY.ESCAPE: hide_dropdown(); return true; default: if(String.fromCharCode(event.which)) { // set a timeout just long enough to let this function finish. // was 5. setTimeout(function(){do_search();}, 25); } break; } }) .keyup(function () { if ($(this).val().length < settings.minChars) { hide_dropdown(); clearTimeout(timeout); } }); // If the parent form is submitted and there is data in the input box, submit it $(input).closest("form").submit(function (){ if(input_box.val()) { // split contents and add them $.each(input_box.val().split(settings.tokenDelimiter), function(index, item) { add_token($.trim(item)); }); } }); // Keep a reference to the original input box // Remove its id because we don't want it duplicated after adding it to the new input var hidden_input = $(input) .hide() .focus(function () { input_box.focus(); }) .blur(function () { input_box.blur(); }); // Keep a reference to the selected token and dropdown item var selected_token = null; var selected_token_index = 0; var selected_dropdown_item = null; var first_dropdown_item = null; // The list to store the token items in var token_list = $("
"+settings.searchingText+"
"); show_dropdown(); } } function show_dropdown_hint () { if(settings.hintText) { dropdown.html(""+settings.hintText+"
"); show_dropdown(); } } // Highlight the query part of the search term function highlight_term(value, term) { var newvalue = value; $.each(term.split(' '), function(index, termbit) { if (!termbit) { // AO3-4976 skip empty strings return; } termbit = termbit.replace(/([.?*+^$[\]\\(){}-])/g, "\\$1"); newvalue = newvalue.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + termbit + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); }); return newvalue; } // Populate the results dropdown with some results function populate_dropdown (query, results) { if(results && results.length) { dropdown.empty(); var dropdown_ul = $(""+settings.noResultsText+"
"); show_dropdown(); } } } // Highlight an item in the results dropdown function select_dropdown_item (item) { if(item) { if(selected_dropdown_item) { deselect_dropdown_item($(selected_dropdown_item)); } item.addClass(settings.classes.selectedDropdownItem); selected_dropdown_item = item.get(0); } } // Remove highlighting from an item in the results dropdown function deselect_dropdown_item (item) { item.removeClass(settings.classes.selectedDropdownItem); selected_dropdown_item = null; } function escapeHTML(text) { if(!settings.escapeHTML) return text; return $("").text(text).html(); } // Do a search and show the "searching" dropdown if the input is longer // than settings.minChars function do_search() { var query = input_box.val().toLowerCase(); if( (query && query.length) || settings.minChars == 0) { if (selected_dropdown_item) { deselect_dropdown_item($(selected_dropdown_item)); } if(selected_token) { deselect_token($(selected_token), POSITION.AFTER); } if(settings.minChars == 0 || query.length >= settings.minChars) { show_dropdown_searching(); clearTimeout(timeout); timeout = setTimeout(function(){ run_search(query); }, settings.searchDelay); } else { hide_dropdown(); } } } // Do the actual search function run_search(query) { if(!settings.noCache) { var cached_results = cache.get(query); } if(!settings.noCache && cached_results) { populate_dropdown(query, cached_results); } else { search_and_cache(query); } } // Populate the cache with results and return the results function search_and_cache(query) { // Are we doing an ajax search or local data search? if(settings.url) { // ajax search ajax_search(query); } else if(settings.local_data) { // Do the search through local data var results = $.grep(settings.local_data, function (row) { return row.name.toLowerCase().indexOf(query.toLowerCase()) > -1; }); if($.isFunction(settings.onResult)) { results = settings.onResult.call(hidden_input, results); } cache.add(query, results); populate_dropdown(query, results); } } // Run ajax query function ajax_search(query) { // Extract exisiting get params var ajax_params = {}; ajax_params.data = {}; if(settings.url.indexOf("?") > -1) { var parts = settings.url.split("?"); ajax_params.url = parts[0]; var param_array = parts[1].split("&"); $.each(param_array, function (index, value) { var kv = value.split("="); ajax_params.data[kv[0]] = kv[1]; }); } else { ajax_params.url = settings.url; } // Get live params if(settings.liveParams) { var live_param_fields = settings.liveParams.split("&"); $.each(live_param_fields, function (index, value) { var kv = value.split("="); // test for checkboxes or text input field var id_to_get = '#' + kv[1] + ' input'; if ($(id_to_get).size() === 0) { id_to_get = '#' + kv[1]; } else { id_to_get += ':checked'; } var id_contents = $(id_to_get).map(function(i,n){return $(n).val();}).get(); if(id_contents) { ajax_params.data[kv[0]] = id_contents; } }); } // Prepare the request ajax_params.data[settings.queryParam] = query; ajax_params.type = settings.method; ajax_params.dataType = settings.contentType; if(settings.crossDomain) { ajax_params.dataType = "jsonp"; } // Attach the success callback ajax_params.success = function(results) { if($.isFunction(settings.onResult)) { results = settings.onResult.call(hidden_input, results); } if(!settings.noCache) { cache.add(query, settings.jsonContainer ? results[settings.jsonContainer] : results); } // only populate the dropdown if the results are associated with the active search query if(input_box.val().toLowerCase() === query && input_box.is(":focus")) { populate_dropdown(query, settings.jsonContainer ? results[settings.jsonContainer] : results); } }; // Make the request $.ajax(ajax_params); } }; // Really basic cache for the results $.TokenList.Cache = function (options) { var settings = $.extend({ max_size: 500 }, options); var data = {}; var size = 0; var flush = function () { data = {}; size = 0; }; this.clear_data = function () { flush(); }; this.add = function (query, results) { if(size > settings.max_size) { flush(); } if(!data[query]) { size += 1; } data[query] = results; }; this.get = function (query) { return data[query]; }; }; }(jQuery));