Skip to content

Instantly share code, notes, and snippets.

@ivadenis
Forked from jharding/README.md
Created December 13, 2013 02:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ivadenis/7938920 to your computer and use it in GitHub Desktop.
Save ivadenis/7938920 to your computer and use it in GitHub Desktop.

build status

Inspired by twitter.com's autocomplete search functionality, typeahead.js is a flexible JavaScript library that provides a strong foundation for building robust typeaheads.

The typeahead.js library is built on top of 2 components: the data component, dataset, and the UI component, typeahead. Datasets are responsible for providing suggestions for a given query. Typeaheads are responsible for rendering suggestions and handling DOM interactions. Both components can be used separately, but when used together, they provided a rich typeahead experience.

Table of Contents

Features

Typeahead

  • Displays suggestions to end-users as they type
  • Shows top suggestion as a hint (i.e. background text)
  • Supports custom templates to allow for UI flexibility
  • Works well with RTL languages and input method editors
  • Highlights query matches within the suggestion
  • Triggers custom events

Dataset

  • Works with hardcoded data
  • Prefetches data on initialization to reduce suggestion latency
  • Uses local storage intelligently to cut down on network requests
  • Backfills suggestions from a remote source
  • Rate-limits and caches network requests to remote sources to lighten the load

Examples

For some working examples of typeahead.js, visit our examples page.

Typeahead

The typeahead component is a jQuery plugin for adding typeahead functionality to input elements. It deals with rendering suggestions and handling DOM interactions.

Typeahead API

jQuery#typeahead(options)

Turns any input[type="text"] element into a typeahead. options is an options hash that's used to configure the typeahead to your liking. For more info about what options are available, check out the Options section.

$('.typeahead').typeahead({
  minLength: 3,
  sections: {
    hightlight: true,
    source: myDataset
  }
});

jQuery#typeahead('destroy')

Removes typeahead.js functionality and reverts the input element back to how it was before it was turned into a typeahead.

$('.typeahead').typeahead('destroy');

jQuery#typeahead('open')

Opens the dropdown menu of typeahead. Note that being open does not mean that the menu is visible. The menu is only visible when it is open and not empty.

$('.typeahead').typeahead('open');

jQuery#typeahead('close')

Closes the dropdown menu of typeahead.

$('.typeahead').typeahead('close');

jQuery#typeahead('val')

Returns the current value of the typeahead. The value is the text the user has entered into the input element.

var myVal = $('.typeahead').typeahead('val');

jQuery#typeahead('val', val)

Sets the value of the typeahead. This should be used in place of jQuery#val.

$('.typeahead').typeahead('val', myVal);

Typeahead Options

When initializing a typeahead, there are a number of options you can configure.

  • minLength – The minimum character length needed before suggestions start getting renderd. Defaults to 0.

  • hint – If false, the typeahead will not show a hint. Defaults to true.

  • autoselect – If true, when the dropdown menu is open and the user hits enter, the top suggestion will be selected. Defaults to false.

  • sections – Can be either one or many (that is an array) sections. Refer to Sections for more info.

Sections

A typeahead is composed of one or more sections. For simple use cases, one section will usually suffice. If however you wanted to build something like the search typeahead on twitter.com, you'd need multiple sections.

Sections can be configured using the following options.

  • name – The name of the section. Defaults to a random number.

  • source – The backing data source for the section. Can be either a dataset or a function with the signature (query, cb). If the latter, cb is expected to be invoked with an array of datums that are a match for query. Required.

  • highlight – If true, when suggestions are rendered, pattern matches for the current query in text nodes will be wrapped in a strong element. Defaults to false.

  • templates – A hash of templates to be used when rendering the section.

    • empty – Rendered when 0 suggestions are available for the given query. Can be either a HTML string or a precompiled template. If it's a precompiled template, the passed in context will contain query.

    • header – Rendered at the top of the section. Can be either a HTML string or a precompiled template. If it's a precompiled template, the passed in context will contain query and isEmpty.

    • footer– Rendered at the bottom of the section. Can be either a HTML string or a precompiled template. If it's a precompiled template, the passed in context will contain query and isEmpty.

    • suggestion – Used to render a single suggestion. If set, this has to be a precompiled tempate. The associated datum object will serves as the context. Defaults to the value of the datum wrapped in a p tag.

Custom Events

The typeahead component triggers the following custom events.

  • typeahead:opened – Triggered when the dropdown menu of a typeahead is opened.

  • typeahead:closed – Triggered when the dropdown menu of a typeahead is closed.

  • typeahead:cursorchanged – Triggered when the dropdown menu cursor is moved to a different suggestion. The datum for the suggestion that the cursor was moved to is passed to the event handler as an argument in addition to the name of the section it belongs to.

  • typeahead:selected – Triggered when a suggestion from the dropdown menu is selected. The datum for the selected suggestion is passed to the event handler as an argument in addition to the name of the section it belongs to.

  • typeahead:autocompleted – Triggered when the query is autocompleted. Autocompleted means the query was changed to the hint. The datum used for autocompletion is passed to the event handler as an argument in addition to the name of the section it belongs to.

All custom events are triggered on the element initialized as a typeahead.

Look and Feel

Below is a faux mustache template describing the DOM structure of a typeahead dropdown menu. Keep in mind that header, footer, suggestion, and empty come from the templates mentioned here.

<span class="tt-dropdown-menu">
  {{#sections}}
    <div class="tt-section-{{name}}">
      {{{header}}}
      <span class="tt-suggestions">
        {{#suggestions}}
          <div class="tt-suggestion">{{{suggestion}}}</div>
        {{/suggestions}}
        {{^suggestions}}
          {{{empty}}}
        {{/suggestions}}
      </span>
      {{{footer}}}
    </div>
  {{/sections}}
</span>

When an end-user mouses or keys over a .tt-suggestion, the class tt-cursor will be added to it. You can use this class as a hook for styling the "under cursor" state of suggestions.

Dataset

Datasets can be used as a source for sections. They're robust, flexible, and offer advanced functionality such as prefetching, intelligent caching, fast lookups, and backfilling with remote data.

Dataset API

Constructor

The constructor function. It takes a hash of options.

var dataset = new Dataset({
  name: myDatasetName,
  local: ['dog', 'pig', 'moose'],
  remote: 'http://example.com/animals?q=%QUERY'
});

Dataset#initialize()

Kicks off the initialization of the dataset. This includes processing the data provided through local and fetching and processing the data provided by prefetch. Dataset#get and Dataset#add will be useless until this method is called.

dataset.initialize();

Dataset#get(query, cb)

Retrieves datums from the dataset matching query and invokes cb with them. cb will always be called at least once with the mixed results from local and prefetch. If those results are insufficent, cb will be called again later with the mixed results from local, prefetch, and remote.

dataset.get(myQuery, function(suggestions) {
  suggestions.each(function(suggestion) {
    console.log(suggestion.value);
  });
});

Dataset Options

When initializing a dataset, there are a number of options you can configure.

  • name – The string used to identify the dataset. If set, typeahead.js will cache prefetched data in local storage, if possible.

  • valueKey – The key used to access the value of the datum in the datum object. Defaults to value.

  • limit – The max number of suggestions to return from Dataset#get. If not reached, the dataset will attempt to backfill the suggestions from remote.

  • tokenizer – A function with the signature (str) that returns an array of tokens. The default implementation of tokenizer splits str on whitespace.

  • dupChecker – A function with the signature (datum1, datum2) that returns true if the datums are duplicates or false otherwise. If dupChecker is true, a function that compares value properties will be used. This is used for making sure no duplicate suggestions are introduced from remote.

  • sorter – A compare function used to sort matched datums for a given query.

  • local – An array of datums.

  • prefetch – Can be a URL to a JSON file containing an array of datums or, if more configurability is needed, a prefetch options hash.

  • remote – Can be a URL to fetch suggestions from when the data provided by local and prefetch is insufficient or, if more configurability is needed, a remote options hash.

Prefetch

Prefetched data is fetched and processed on initialization. If the browser supports local storage, the processed data will be cached there to prevent additional network requests on subsequent page loads.

When configuring prefetch, the following options are available.

  • url – A URL to a JSON file containing an array of datums. Required.

  • ttl – The time (in milliseconds) the prefetched data should be cached in local storage. Defaults to 86400000 (1 day).

  • thumbprint – A string used for thumbprinting prefetched data. If this doesn't match what's stored in local storage, the data will be refetched.

  • filter – A function with the signature filter(parsedResponse) that transforms the response body into an array of datums. Expected to return an array of datums.

  • ajax – The ajax settings object passed to jQuery.ajax.

Remote

Remote data is only used when the data provided by local and prefetch is insufficient. In order to prevent an obscene number of requests being made to the remote endpoint, typeahead.js rate-limits remote requests.

When configuring remote, the following options are available.

  • url – A URL to make requests to when when the data provided by local and prefetch is insufficient. Required.

  • wildcard – The pattern in url that will be replaced with the user's query when a request is made. Defaults to %QUERY.

  • replace – A function with the signature replace(url, query) that can be used to override the request URL. Expected to return a valid URL. If set, no wildcard substitution will be performed on url.

  • rateLimitBy – The method used to rate-limit network requests. Can be either debounce or throttle. Defaults to debounce.

  • rateLimitWait – The time interval in milliseconds that will be used by rateLimitBy. Defaults to 300.

  • filter – A function with the signature filter(parsedResponse) that transforms the response body into an array of datums. Expected to return an array of datums.

  • ajax – The ajax settings object passed to jQuery.ajax.

Tokens

The algorithm used by datasets for providing suggestions for a given query is token-based. When Dataset#get is called, it tokenizes query using tokenizer and then invokes cb with all of the datums that contain those tokens.

By default, a dataset will generate tokens for a datum by tokenizing its value. However, it is possible to explicitly set the tokens for a datum by including a tokens property.

{
  value: 'typeahead.js'
  tokens: ['typeahead.js', 'typeahead', 'autocomlete', 'javascript'];
}

The above datum would be a valid suggestion for queries such as:

  • typehead
  • typehead.js
  • autoco
  • javascript type

Datum

The data representation of a suggestion is referred to as a datum. A datum is an object that can contain arbitrary properties. When a suggestion is rendered, its datum will be the context passed the suggestion template.

Datums are expected to contain a value property – when a suggestion is selected, this will be what the value of the input is set to. By default, it's expected the name of this property will be value, but it's configurable. See Dataset and Section for more details.

For ease of use, datums can also be represented as a string. Strings found in place of datum objects are implicitly converted to an object with its value property set to the string.

Here's a datum in its simplest form.

{
  value: "monkey"
}

Here's a more complex datum that would be used for a Twitter account typeahead. The value property here is handle and the datum contains additional properties to make it possible to render richer suggestions. This datum also explicitly sets its tokens.

{
  name: 'Jake Harding',
  handle: '@JakeHarding',
  tokens: ['jake', 'harding', 'jakeharding', '@jakeharding'],
  profileImageUrl: 'https://twitter.com/JakeHaridng/path/to/img'
}

Browser Support

  • Chrome
  • Firefox 3.5+
  • Safari 4+
  • Internet Explorer 7+
  • Opera 11+

Customer Support

For general questions about typeahead.js, tweet at @typeahead.

For technical questions, you should post a question on Stack Overflow and tag it with typeahead.js.

Issues

Discovered a bug? Please create an issue here on GitHub!

https://github.com/twitter/typeahead.js/issues

Versioning

For transparency and insight into our release cycle, releases will be numbered with the follow format:

<major>.<minor>.<patch>

And constructed with the following guidelines:

  • Breaking backwards compatibility bumps the major
  • New additions without breaking backwards compatibility bumps the minor
  • Bug fixes and misc changes bump the patch

For more information on semantic versioning, please visit http://semver.org/.

Testing

Tests are written using Jasmine and ran with Karma. To run the test suite with PhantomJS, run $ npm test.

Developers

If you plan on contributing to typeahead.js, be sure to read the contributing guidelines.

In order to build and test typeahead.js, you'll need to install its dev dependencies ($ npm install) and have grunt-cli installed ($ npm install -g grunt-cli). Below is an overview of the available Grunt tasks that'll be useful in development.

  • grunt build – Builds typeahead.js from source.
  • grunt lint – Runs source and test files through JSHint.
  • grunt watch – Rebuilds typeahead.js whenever a source file is modified.
  • grunt server – Serves files from the root of typeahead.js on localhost:8888. Useful for using test/playground.html for debugging/testing.
  • grunt dev – Runs grunt watch and grunt server in parallel.

Authors

License

Copyright 2013 Twitter, Inc.

Licensed under the MIT License

var animals = new Dataset({
name: 'animals',
local: ['dog', 'fish', 'cat', 'monkey', 'turtle', 'donkey', 'dragon']
}).initialize();
$('.typeahead').typeahead({
sections: {
name: 'animals',
source: animals,
highlight: true,
templates: {
empty: '<p>No animals found.</p>'
}
}
});
/*!
* typeahead.js 0.10.0-beta
* https://github.com/twitter/typeahead
* Copyright 2013 Twitter, Inc. and other contributors; Licensed MIT
*/
(function($) {
var _ = {
isMsie: function() {
var match = /(msie) ([\w.]+)/i.exec(navigator.userAgent);
return match ? parseInt(match[2], 10) : false;
},
isBlankString: function(str) {
return !str || /^\s*$/.test(str);
},
escapeRegExChars: function(str) {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
},
isString: function(obj) {
return typeof obj === "string";
},
isNumber: function(obj) {
return typeof obj === "number";
},
isArray: $.isArray,
isFunction: $.isFunction,
isObject: $.isPlainObject,
isUndefined: function(obj) {
return typeof obj === "undefined";
},
bind: $.proxy,
each: function(collection, cb) {
$.each(collection, reverseArgs);
function reverseArgs(index, value) {
return cb(value, index);
}
},
map: $.map,
filter: $.grep,
every: function(obj, test) {
var result = true;
if (!obj) {
return result;
}
$.each(obj, function(key, val) {
if (!(result = test.call(null, val, key, obj))) {
return false;
}
});
return !!result;
},
some: function(obj, test) {
var result = false;
if (!obj) {
return result;
}
$.each(obj, function(key, val) {
if (result = test.call(null, val, key, obj)) {
return false;
}
});
return !!result;
},
mixin: $.extend,
getUniqueId: function() {
var counter = 0;
return function() {
return counter++;
};
}(),
templatify: function templatify(obj) {
return $.isFunction(obj) ? obj : template;
function template() {
return String(obj);
}
},
defer: function(fn) {
setTimeout(fn, 0);
},
debounce: function(func, wait, immediate) {
var timeout, result;
return function() {
var context = this, args = arguments, later, callNow;
later = function() {
timeout = null;
if (!immediate) {
result = func.apply(context, args);
}
};
callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
result = func.apply(context, args);
}
return result;
};
},
throttle: function(func, wait) {
var context, args, timeout, result, previous, later;
previous = 0;
later = function() {
previous = new Date();
timeout = null;
result = func.apply(context, args);
};
return function() {
var now = new Date(), remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0) {
clearTimeout(timeout);
timeout = null;
previous = now;
result = func.apply(context, args);
} else if (!timeout) {
timeout = setTimeout(later, remaining);
}
return result;
};
},
noop: function() {}
};
var VERSION = "0.10.0-beta";
var LruCache = function(root, undefined) {
function LruCache(maxSize) {
this.maxSize = maxSize || 100;
this.size = 0;
this.hash = {};
this.list = new List();
}
_.mixin(LruCache.prototype, {
set: function set(key, val) {
var tailItem = this.list.tail, node;
if (this.size >= this.maxSize) {
this.list.remove(tailItem);
delete this.hash[tailItem.key];
}
if (node = this.hash[key]) {
node.val = val;
this.list.moveToFront(node);
} else {
node = new Node(key, val);
this.list.add(node);
this.hash[key] = node;
this.size++;
}
},
get: function get(key) {
var node = this.hash[key];
if (node) {
this.list.moveToFront(node);
return node.val;
}
}
});
function List() {
this.head = this.tail = null;
}
_.mixin(List.prototype, {
add: function add(node) {
if (this.head) {
node.next = this.head;
this.head.prev = node;
}
this.head = node;
this.tail = this.tail || node;
},
remove: function remove(node) {
node.prev ? node.prev.next = node.next : this.head = node.next;
node.next ? node.next.prev = node.prev : this.tail = node.prev;
},
moveToFront: function(node) {
this.remove(node);
this.add(node);
}
});
function Node(key, val) {
this.key = key;
this.val = val;
this.prev = this.next = null;
}
return LruCache;
}(this);
var PersistentStorage = function() {
var ls, methods;
try {
ls = window.localStorage;
ls.setItem("~~~", "!");
ls.removeItem("~~~");
} catch (err) {
ls = null;
}
function PersistentStorage(namespace) {
this.prefix = [ "__", namespace, "__" ].join("");
this.ttlKey = "__ttl__";
this.keyMatcher = new RegExp("^" + this.prefix);
}
if (ls && window.JSON) {
methods = {
_prefix: function(key) {
return this.prefix + key;
},
_ttlKey: function(key) {
return this._prefix(key) + this.ttlKey;
},
get: function(key) {
if (this.isExpired(key)) {
this.remove(key);
}
return decode(ls.getItem(this._prefix(key)));
},
set: function(key, val, ttl) {
if (_.isNumber(ttl)) {
ls.setItem(this._ttlKey(key), encode(now() + ttl));
} else {
ls.removeItem(this._ttlKey(key));
}
return ls.setItem(this._prefix(key), encode(val));
},
remove: function(key) {
ls.removeItem(this._ttlKey(key));
ls.removeItem(this._prefix(key));
return this;
},
clear: function() {
var i, key, keys = [], len = ls.length;
for (i = 0; i < len; i++) {
if ((key = ls.key(i)).match(this.keyMatcher)) {
keys.push(key.replace(this.keyMatcher, ""));
}
}
for (i = keys.length; i--; ) {
this.remove(keys[i]);
}
return this;
},
isExpired: function(key) {
var ttl = decode(ls.getItem(this._ttlKey(key)));
return _.isNumber(ttl) && now() > ttl ? true : false;
}
};
} else {
methods = {
get: _.noop,
set: _.noop,
remove: _.noop,
clear: _.noop,
isExpired: _.noop
};
}
_.mixin(PersistentStorage.prototype, methods);
return PersistentStorage;
function now() {
return new Date().getTime();
}
function encode(val) {
return JSON.stringify(_.isUndefined(val) ? null : val);
}
function decode(val) {
return JSON.parse(val);
}
}();
var Transport = function() {
var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, requestCache = new LruCache(10);
function Transport(o) {
o = o || {};
this._send = o.send ? callbackToDeferred(o.send) : $.ajax;
this._get = o.rateLimiter ? o.rateLimiter(this._get) : this._get;
}
Transport.setMaxPendingRequests = function setMaxPendingRequests(num) {
maxPendingRequests = num;
};
Transport.resetCache = function clearCache() {
requestCache = new LruCache(10);
};
_.mixin(Transport.prototype, {
_get: function(url, o, cb) {
var that = this, jqXhr;
if (jqXhr = pendingRequests[url]) {
jqXhr.done(done);
} else if (pendingRequestsCount < maxPendingRequests) {
pendingRequestsCount++;
pendingRequests[url] = this._send(url, o).done(done).always(always);
} else {
this.onDeckRequestArgs = [].slice.call(arguments, 0);
}
function done(resp) {
cb && cb(resp);
requestCache.set(url, resp);
}
function always() {
pendingRequestsCount--;
delete pendingRequests[url];
if (that.onDeckRequestArgs) {
that._get.apply(that, that.onDeckRequestArgs);
that.onDeckRequestArgs = null;
}
}
},
get: function(url, o, cb) {
var that = this, resp;
if (_.isFunction(o)) {
cb = o;
o = {};
}
if (resp = requestCache.get(url)) {
_.defer(function() {
cb && cb(resp);
});
} else {
this._get(url, o, cb);
}
return !!resp;
}
});
return Transport;
function callbackToDeferred(fn) {
return function customSendWrapper(url, o) {
var deferred = $.Deferred();
fn(url, o, onSuccess, onError);
return deferred;
function onSuccess(resp) {
_.defer(function() {
deferred.resolve(resp);
});
}
function onError(err) {
_.defer(function() {
deferred.reject(err);
});
}
};
}
}();
var SearchIndex = function() {
function SearchIndex(o) {
o = o || {};
this.tokenize = o.tokenizer || tokenize;
this.datums = [];
this.trie = newNode();
}
_.mixin(SearchIndex.prototype, {
bootstrap: function bootstrap(o) {
this.datums = o.datums;
this.trie = o.trie;
},
add: function(data) {
var that = this;
data = _.isArray(data) ? data : [ data ];
_.each(data, function(datum) {
var id, tokens;
id = that.datums.push(datum) - 1;
tokens = normalizeTokens(datum.tokens || that.tokenize(datum.value));
delete datum.tokens;
_.each(tokens, function(token) {
var node, chars, ch, ids;
node = that.trie;
chars = token.split("");
while (ch = chars.shift()) {
node = node.children[ch] || (node.children[ch] = newNode());
node.ids.push(id);
}
});
});
},
remove: function remove() {
$.error("not implemented");
},
get: function get(query) {
var that = this, tokens, matches;
tokens = this.tokenize(query);
_.each(tokens, function(token) {
var node, chars, ch, ids;
if (matches && matches.length === 0) {
return false;
}
node = that.trie;
chars = token.split("");
while (node && (ch = chars.shift())) {
node = node.children[ch];
}
if (node && chars.length === 0) {
ids = node.ids.slice(0);
matches = matches ? getIntersection(matches, ids) : ids;
} else {
return false;
}
});
return matches ? _.map(unique(matches), function(id) {
return that.datums[id];
}) : [];
},
serialize: function serialize() {
return {
datums: this.datums,
trie: this.trie
};
}
});
return SearchIndex;
function tokenize(str) {
return $.trim(str).toLowerCase().split(/\s+/);
}
function normalizeTokens(tokens) {
tokens = _.filter(tokens, function(token) {
return !!token;
});
tokens = _.map(tokens, function(token) {
return token.toLowerCase();
});
return tokens;
}
function newNode() {
return {
ids: [],
children: {}
};
}
function unique(array) {
var seen = {}, uniques = [];
for (var i = 0; i < array.length; i++) {
if (!seen[array[i]]) {
seen[array[i]] = true;
uniques.push(array[i]);
}
}
return uniques;
}
function getIntersection(arrayA, arrayB) {
var ai = 0, bi = 0, intersection = [];
arrayA = arrayA.sort(compare);
arrayB = arrayB.sort(compare);
while (ai < arrayA.length && bi < arrayB.length) {
if (arrayA[ai] < arrayB[bi]) {
ai++;
} else if (arrayA[ai] > arrayB[bi]) {
bi++;
} else {
intersection.push(arrayA[ai]);
ai++;
bi++;
}
}
return intersection;
function compare(a, b) {
return a - b;
}
}
}();
var Dataset = window.Dataset = function() {
var keys;
keys = {
data: "data",
protocol: "protocol",
thumbprint: "thumbprint"
};
function Dataset(o) {
if (!o || !o.local && !o.prefetch && !o.remote) {
$.error("one of local, prefetch, or remote is required");
}
this.name = o.name || _.getUniqueId();
this.limit = o.limit || 5;
this.valueKey = o.valueKey || "value";
this.dupChecker = getDupChecker(o.dupChecker);
this.sorter = getSorter(o.sorter);
this.local = getLocal(o);
this.prefetch = getPrefetch(o);
this.remote = getRemote(o);
this.index = new SearchIndex({
tokenizer: o.tokenizer
});
this.storage = o.name ? new PersistentStorage(o.name) : null;
}
_.mixin(Dataset.prototype, {
_loadPrefetch: function loadPrefetch(o) {
var that = this, serialized, deferred;
if (serialized = this._readFromStorage(o.thumbprint)) {
this.index.bootstrap(serialized);
deferred = $.Deferred().resolve();
} else {
deferred = $.ajax(o.url, o.ajax).done(handlePrefetchResponse);
}
return deferred;
function handlePrefetchResponse(resp) {
var filtered, normalized;
filtered = o.filter ? o.filter(resp) : resp;
that.add(filtered);
that._saveToStorage(that.index.serialize(), o.thumbprint, o.ttl);
}
},
_getFromRemote: function getFromRemote(query, cb) {
var that = this, url, uriEncodedQuery;
query = query || "";
uriEncodedQuery = encodeURIComponent(query);
url = this.remote.replace ? this.remote.replace(this.remote.url, query) : this.remote.url.replace(this.remote.wildcard, uriEncodedQuery);
return this.transport.get(url, this.remote.ajax, handleRemoteResponse);
function handleRemoteResponse(resp) {
var filtered = that.remote.filter ? that.remote.filter(resp) : resp;
cb(that._normalize(filtered));
}
},
_normalize: function normalize(data) {
var that = this;
return _.map(data, normalizeRawDatum);
function normalizeRawDatum(raw) {
var value, datum;
value = _.isString(raw) ? raw : raw[that.valueKey];
datum = {
value: value,
tokens: raw.tokens
};
_.isString(raw) ? (datum.raw = {})[that.valueKey] = raw : datum.raw = raw;
return datum;
}
},
_saveToStorage: function saveToStorage(data, thumbprint, ttl) {
if (this.storage) {
this.storage.set(keys.data, data, ttl);
this.storage.set(keys.protocol, location.protocol, ttl);
this.storage.set(keys.thumbprint, thumbprint, ttl);
}
},
_readFromStorage: function readFromStorage(thumbprint) {
var stored = {};
if (this.storage) {
stored.data = this.storage.get(keys.data);
stored.protocol = this.storage.get(keys.protocol);
stored.thumbprint = this.storage.get(keys.thumbprint);
}
isExpired = stored.thumbprint !== thumbprint || stored.protocol !== location.protocol;
return stored.data && !isExpired ? stored.data : null;
},
initialize: function initialize() {
var that = this, deferred;
deferred = this.prefetch ? this._loadPrefetch(this.prefetch) : $.Deferred().resolve();
this.local && deferred.done(addLocalToIndex);
this.transport = this.remote ? new Transport(this.remote) : null;
this.initialize = function initialize() {
return that;
};
return this;
function addLocalToIndex() {
that.add(that.local);
}
},
add: function add(data) {
var normalized;
data = _.isArray(data) ? data : [ data ];
normalized = this._normalize(data);
this.index.add(normalized);
},
get: function get(query, cb) {
var that = this, matches, cacheHit = false;
matches = _.map(this.index.get(query), pickRaw).sort(this.sorter).slice(0, this.limit);
if (matches.length < this.limit && this.transport) {
cacheHit = this._getFromRemote(query, returnRemoteMatches);
}
!cacheHit && cb && cb(matches);
function returnRemoteMatches(remoteMatches) {
var matchesWithBackfill = matches.slice(0);
remoteMatches = _.map(remoteMatches, pickRaw);
_.each(remoteMatches, function(remoteMatch) {
var isDuplicate;
isDuplicate = _.some(matchesWithBackfill, function(match) {
return that.dupChecker(remoteMatch, match);
});
!isDuplicate && matchesWithBackfill.push(remoteMatch);
return matchesWithBackfill.length < that.limit;
});
cb && cb(matchesWithBackfill.sort(this.sorter));
}
function pickRaw(obj) {
return obj.raw;
}
}
});
return Dataset;
function getSorter(sorter) {
return sorter || defaultSorter;
function defaultSorter() {
return 0;
}
}
function getDupChecker(dupChecker) {
if (!_.isFunction(dupChecker)) {
dupChecker = dupChecker === false ? ignoreDups : standardDupChecker;
}
return dupChecker;
function ignoreDups() {
return false;
}
function standardDupChecker(a, b) {
return a.value === b.value;
}
}
function getLocal(o) {
return o.local || null;
}
function getPrefetch(o) {
var prefetch, defaults;
defaults = {
url: null,
thumbprint: "",
ttl: 24 * 60 * 60 * 1e3,
filter: null,
ajax: {}
};
if (prefetch = o.prefetch || null) {
prefetch = _.isString(prefetch) ? {
url: prefetch
} : prefetch;
prefetch = _.mixin(defaults, prefetch);
prefetch.thumbprint = VERSION + prefetch.thumbprint;
prefetch.ajax.method = prefetch.ajax.method || "get";
prefetch.ajax.dataType = prefetch.ajax.dataType || "json";
}
return prefetch;
}
function getRemote(o) {
var remote, defaults;
defaults = {
url: null,
wildcard: "%QUERY",
replace: null,
rateLimitBy: "debounce",
rateLimitWait: 300,
send: null,
filter: null,
ajax: {}
};
if (remote = o.remote || null) {
remote = _.isString(remote) ? {
url: remote
} : remote;
remote = _.mixin(defaults, remote);
remote.rateLimiter = /^throttle$/i.test(remote.rateLimitBy) ? byThrottle(remote.rateLimitWait) : byDebounce(remote.rateLimitWait);
remote.ajax.method = remote.ajax.method || "get";
remote.ajax.dataType = remote.ajax.dataType || "json";
delete remote.rateLimitBy;
delete remote.rateLimitWait;
}
return remote;
function byDebounce(wait) {
return function(fn) {
return _.debounce(fn, wait);
};
}
function byThrottle(wait) {
return function(fn) {
return _.throttle(fn, wait);
};
}
}
}();
var html = {
wrapper: '<span class="twitter-typeahead"></span>',
dropdown: '<span class="tt-dropdown-menu"></span>',
section: '<div class="tt-section-%CLASS%"></div>',
suggestions: '<span class="tt-suggestions"></span>',
suggestion: '<div class="tt-suggestion">%BODY%</div>'
};
var css = {
wrapper: {
position: "relative",
display: "inline-block"
},
hint: {
position: "absolute",
top: "0",
left: "0",
borderColor: "transparent",
boxShadow: "none"
},
input: {
position: "relative",
verticalAlign: "top",
backgroundColor: "transparent"
},
inputWithNoHint: {
position: "relative",
verticalAlign: "top"
},
dropdown: {
position: "absolute",
top: "100%",
left: "0",
zIndex: "100",
display: "none"
},
suggestions: {
display: "block"
},
suggestion: {
whiteSpace: "nowrap",
cursor: "pointer"
},
suggestionChild: {
whiteSpace: "normal"
},
ltr: {
left: "0",
right: "auto"
},
rtl: {
left: "auto",
right: " 0"
}
};
if (_.isMsie()) {
_.mixin(css.query, {
backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"
});
}
if (_.isMsie() && _.isMsie() <= 7) {
_.mixin(css.wrapper, {
display: "inline",
zoom: "1"
});
_.mixin(css.query, {
marginTop: "-1px"
});
}
var EventBus = function() {
var namespace = "typeahead:";
function EventBus(o) {
if (!o || !o.el) {
$.error("EventBus initialized without el");
}
this.$el = $(o.el);
}
_.mixin(EventBus.prototype, {
trigger: function(type) {
var args = [].slice.call(arguments, 1);
this.$el.trigger(namespace + type, args);
}
});
return EventBus;
}();
var EventEmitter = function() {
var splitter = /\s+/, nextTick = getNextTick();
return {
onSync: onSync,
onAsync: onAsync,
off: off,
trigger: trigger
};
function on(method, types, cb, context) {
var type;
if (!cb) {
return this;
}
types = types.split(splitter);
cb = context ? bindContext(cb, context) : cb;
this._callbacks = this._callbacks || {};
while (type = types.shift()) {
this._callbacks[type] = this._callbacks[type] || {
sync: [],
async: []
};
this._callbacks[type][method].push(cb);
}
return this;
}
function onAsync(types, cb, context) {
return on.call(this, "async", types, cb, context);
}
function onSync(types, cb, context) {
return on.call(this, "sync", types, cb, context);
}
function off(types) {
var type;
if (!this._callbacks) {
return this;
}
types = types.split(splitter);
while (type = types.shift()) {
delete this._callbacks[type];
}
return this;
}
function trigger(types) {
var that = this, type, callbacks, args, syncFlush, asyncFlush;
if (!this._callbacks) {
return this;
}
types = types.split(splitter);
args = [].slice.call(arguments, 1);
while ((type = types.shift()) && (callbacks = this._callbacks[type])) {
syncFlush = getFlush(callbacks.sync, this, [ type ].concat(args));
asyncFlush = getFlush(callbacks.async, this, [ type ].concat(args));
syncFlush() && nextTick(asyncFlush);
}
return this;
}
function getFlush(callbacks, context, args) {
return flush;
function flush() {
var cancelled;
for (var i = 0; !cancelled && i < callbacks.length; i += 1) {
cancelled = callbacks[i].apply(context, args) === false;
}
return !cancelled;
}
}
function getNextTick() {
var nextTickFn, messageChannel;
if (window.setImmediate) {
nextTickFn = function nextTickSetImmediate(fn) {
setImmediate(function() {
fn();
});
};
} else {
nextTickFn = function nextTickSetTimeout(fn) {
setTimeout(function() {
fn();
}, 0);
};
}
return nextTickFn;
}
function bindContext(fn, context) {
return fn.bind ? fn.bind(context) : function() {
fn.apply(context, [].slice.call(arguments, 0));
};
}
}();
var highlight = function(doc) {
var defaults = {
node: null,
pattern: null,
tagName: "strong",
className: null,
wordsOnly: false,
caseSensitive: false
};
return function hightlight(o) {
var regex;
o = _.mixin({}, defaults, o);
if (!o.node || !o.pattern) {
throw new Error("both node and pattern must be set");
}
o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ];
regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly);
traverse(o.node, hightlightTextNode);
function hightlightTextNode(textNode) {
var match, patternNode;
if (match = regex.exec(textNode.data)) {
wrapperNode = doc.createElement(o.tagName);
o.className && (wrapperNode.className = o.className);
patternNode = textNode.splitText(match.index);
patternNode.splitText(match[0].length);
wrapperNode.appendChild(patternNode.cloneNode(true));
textNode.parentNode.replaceChild(wrapperNode, patternNode);
}
return !!match;
}
function traverse(el, hightlightTextNode) {
var childNode, TEXT_NODE_TYPE = 3;
for (var i = 0; i < el.childNodes.length; i++) {
childNode = el.childNodes[i];
if (childNode.nodeType === TEXT_NODE_TYPE) {
i += hightlightTextNode(childNode) ? 1 : 0;
} else {
traverse(childNode, hightlightTextNode);
}
}
}
};
function getRegex(patterns, caseSensitive, wordsOnly) {
var escapedPatterns = [], regexStr;
for (var i = 0; i < patterns.length; i++) {
escapedPatterns.push(_.escapeRegExChars(patterns[i]));
}
regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")";
return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i");
}
}(window.document);
var Input = function() {
var specialKeyCodeMap;
specialKeyCodeMap = {
9: "tab",
27: "esc",
37: "left",
39: "right",
13: "enter",
38: "up",
40: "down"
};
function Input(o) {
var that = this, onBlur, onFocus, onKeydown, onInput;
o = o || {};
if (!o.input) {
$.error("input is missing");
}
onBlur = _.bind(this._onBlur, this);
onFocus = _.bind(this._onFocus, this);
onKeydown = _.bind(this._onKeydown, this);
onInput = _.bind(this._onInput, this);
this.$hint = $(o.hint);
this.$input = $(o.input).on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown);
if (this.$hint.length === 0) {
this.setHintValue = this.getHintValue = this.clearHint = _.noop;
}
if (!_.isMsie()) {
this.$input.on("input.tt", onInput);
} else {
this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) {
if (specialKeyCodeMap[$e.which || $e.keyCode]) {
return;
}
_.defer(_.bind(that._onInput, that, $e));
});
}
this.query = this.$input.val();
this.$overflowHelper = buildOverflowHelper(this.$input);
}
Input.normalizeQuery = function(str) {
return (str || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " ");
};
_.mixin(Input.prototype, EventEmitter, {
_onBlur: function onBlur($e) {
this.resetInputValue();
this.trigger("blurred");
},
_onFocus: function onFocus($e) {
this.trigger("focused");
},
_onKeydown: function onKeydown($e) {
var keyName = specialKeyCodeMap[$e.which || $e.keyCode];
this._managePreventDefault(keyName, $e);
if (keyName && this._shouldTrigger(keyName, $e)) {
this.trigger(keyName + "Keyed", $e);
}
},
_onInput: function onInput($e) {
this._checkInputValue();
},
_managePreventDefault: function managePreventDefault(keyName, $e) {
var preventDefault, hintValue, inputValue;
switch (keyName) {
case "tab":
hintValue = this.getHintValue();
inputValue = this.getInputValue();
preventDefault = hintValue && hintValue !== inputValue && !withModifier($e);
break;
case "up":
case "down":
preventDefault = !withModifier($e);
break;
default:
preventDefault = false;
}
preventDefault && $e.preventDefault();
},
_shouldTrigger: function shouldTrigger(keyName, $e) {
var trigger;
switch (keyName) {
case "tab":
trigger = !withModifier($e);
break;
default:
trigger = true;
}
return trigger;
},
_checkInputValue: function checkInputValue() {
var inputValue, areEquivalent, hasDifferentWhitespace;
inputValue = this.getInputValue();
areEquivalent = areQueriesEquivalent(inputValue, this.query);
hasDifferentWhitespace = areEquivalent ? this.query.length !== inputValue.length : false;
if (!areEquivalent) {
this.trigger("queryChanged", this.query = inputValue);
} else if (hasDifferentWhitespace) {
this.trigger("whitespaceChanged", this.query);
}
},
focus: function focus() {
this.$input.focus();
},
blur: function blur() {
this.$input.blur();
},
getQuery: function getQuery() {
return this.query;
},
setQuery: function setQuery(query) {
this.query = query;
},
getInputValue: function getInputValue() {
return this.$input.val();
},
setInputValue: function setInputValue(value, silent) {
this.$input.val(value);
!silent && this._checkInputValue();
},
getHintValue: function getHintValue() {
return this.$hint.val();
},
setHintValue: function setHintValue(value) {
this.$hint.val(value);
},
resetInputValue: function resetInputValue() {
this.$input.val(this.query);
},
clearHint: function clearHint() {
this.$hint.val("");
},
getLanguageDirection: function getLanguageDirection() {
return (this.$input.css("direction") || "ltr").toLowerCase();
},
hasOverflow: function hasOverflow() {
this.$overflowHelper.text(this.getInputValue());
return this.$overflowHelper.width() > this.$input.width();
},
isCursorAtEnd: function() {
var valueLength, selectionStart, range;
valueLength = this.$input.val().length;
selectionStart = this.$input[0].selectionStart;
if (_.isNumber(selectionStart)) {
return selectionStart === valueLength;
} else if (document.selection) {
range = document.selection.createRange();
range.moveStart("character", -valueLength);
return valueLength === range.text.length;
}
return true;
},
destroy: function destroy() {
this.$hint.off(".tt");
this.$input.off(".tt");
this.$hint = this.$input = this.$overflowHelper = null;
}
});
return Input;
function buildOverflowHelper($input) {
return $("<span></span>").css({
position: "absolute",
left: "-9999px",
visibility: "hidden",
whiteSpace: "nowrap",
fontFamily: $input.css("font-family"),
fontSize: $input.css("font-size"),
fontStyle: $input.css("font-style"),
fontVariant: $input.css("font-variant"),
fontWeight: $input.css("font-weight"),
wordSpacing: $input.css("word-spacing"),
letterSpacing: $input.css("letter-spacing"),
textIndent: $input.css("text-indent"),
textRendering: $input.css("text-rendering"),
textTransform: $input.css("text-transform")
}).insertAfter($input);
}
function areQueriesEquivalent(a, b) {
return Input.normalizeQuery(a) === Input.normalizeQuery(b);
}
function withModifier($e) {
return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey;
}
}();
var Section = function() {
var sectionKey = "ttSection", valueKey = "ttValue", datumKey = "ttDatum";
function Section(o) {
o = o || {};
o.templates = o.templates || {};
if (!o.source) {
$.error("missing source");
}
this.query = null;
this.highlight = !!o.highlight;
this.name = o.name || _.getUniqueId();
this.source = setupSource(o.source);
this.valueKey = getValueKeyFromDataset(o.source) || o.valueKey || "value";
this.templates = getTemplates(o.templates, this.valueKey);
this.$el = $(html.section.replace("%CLASS%", this.name));
}
Section.extractSectionName = function extractSectionName(el) {
return $(el).data(sectionKey);
};
Section.extractValue = function extractDatum(el) {
return $(el).data(valueKey);
};
Section.extractDatum = function extractDatum(el) {
return $(el).data(datumKey);
};
_.mixin(Section.prototype, EventEmitter, {
_render: function render(query, suggestions) {
var that = this, hasSuggestions;
this.$el.empty();
hasSuggestions = suggestions && suggestions.length;
if (!hasSuggestions && this.templates.empty) {
this.$el.html(getEmptyHtml()).append(that.templates.header ? getHeaderHtml() : null).prepend(that.templates.footer ? getFooterHtml() : null);
} else if (hasSuggestions) {
this.$el.html(getSuggestionsHtml()).append(that.templates.header ? getHeaderHtml() : null).prepend(that.templates.footer ? getFooterHtml() : null);
}
this.trigger("rendered");
function getEmptyHtml() {
return that.templates.empty({
query: query
});
}
function getSuggestionsHtml() {
var $suggestions;
$suggestions = $(html.suggestions).css(css.suggestions).append(_.map(suggestions, getSuggestionNode));
that.highlight && highlight({
node: $suggestions[0],
pattern: query
});
return $suggestions;
function getSuggestionNode(suggestion) {
var $el, innerHtml, outerHtml;
innerHtml = that.templates.suggestion(suggestion);
outerHtml = html.suggestion.replace("%BODY%", innerHtml);
$el = $(outerHtml).data(sectionKey, that.name).data(valueKey, suggestion[that.valueKey]).data(datumKey, suggestion);
$el.children().each(function() {
$(this).css(css.suggestionChild);
});
return $el;
}
}
function getHeaderHtml() {
return that.templates.header({
query: query,
isEmpty: !hasSuggestions
});
}
function getFooterHtml() {
return that.templates.footer({
query: query,
isEmpty: !hasSuggestions
});
}
},
getRoot: function getRoot() {
return this.$el;
},
update: function update(query) {
var that = this;
this.query = query;
this.source(query, renderIfQueryIsSame);
function renderIfQueryIsSame(suggestions) {
query === that.query && that._render(query, suggestions);
}
},
clear: function clear() {
this._render(this.query || "");
},
isEmpty: function isEmpty() {
return this.$el.is(":empty");
},
destroy: function destroy() {
this.$el = null;
}
});
return Section;
function setupSource(source) {
var Dataset = window.Dataset;
return Dataset && source instanceof Dataset ? _.bind(source.get, source) : source;
}
function getValueKeyFromDataset(source) {
return Dataset && source instanceof Dataset ? source.valueKey : null;
}
function getTemplates(templates, valueKey) {
valueKey = valueKey || "value";
return {
empty: templates.empty && _.templatify(templates.empty),
header: templates.header && _.templatify(templates.header),
footer: templates.footer && _.templatify(templates.footer),
suggestion: templates.suggestion || suggestionTemplate
};
function suggestionTemplate(context) {
return "<p>" + context[valueKey] + "</p>";
}
}
}();
var Dropdown = function() {
function Dropdown(o) {
var that = this, onMouseEnter, onMouseLeave, onSuggestionClick, onSuggestionMouseEnter, onSuggestionMouseLeave;
o = o || {};
if (!o.menu) {
$.error("menu and/or sections are required");
}
this.isOpen = false;
this.isEmpty = true;
this.isMouseOverDropdown = false;
this.sections = _.map(o.sections, initializeSection);
onMouseEnter = _.bind(this._onMouseEnter, this);
onMouseLeave = _.bind(this._onMouseLeave, this);
onSuggestionClick = _.bind(this._onSuggestionClick, this);
onSuggestionMouseEnter = _.bind(this._onSuggestionMouseEnter, this);
onSuggestionMouseLeave = _.bind(this._onSuggestionMouseLeave, this);
this.$menu = $(o.menu).on("mouseenter.tt", onMouseEnter).on("mouseleave.tt", onMouseLeave).on("click.tt", ".tt-suggestion", onSuggestionClick).on("mouseenter.tt", ".tt-suggestion", onSuggestionMouseEnter).on("mouseleave.tt", ".tt-suggestion", onSuggestionMouseLeave);
_.each(this.sections, function(section) {
that.$menu.append(section.getRoot());
section.onSync("rendered", that._onRendered, that);
});
}
_.mixin(Dropdown.prototype, EventEmitter, {
_onMouseEnter: function onMouseEnter($e) {
this.isMouseOverDropdown = true;
},
_onMouseLeave: function onMouseLeave($e) {
this.isMouseOverDropdown = false;
},
_onSuggestionClick: function onSuggestionClick($e) {
this.trigger("suggestionClicked", $($e.currentTarget));
},
_onSuggestionMouseEnter: function onSuggestionMouseEnter($e) {
this._setCursor($($e.currentTarget));
},
_onSuggestionMouseLeave: function onSuggestionMouseLeave($e) {
this._removeCursor();
},
_onRendered: function onRendered() {
this.isEmpty = _.every(this.sections, isSectionEmpty);
this.isEmpty ? this._hide() : this.isOpen && this._show();
this.trigger("sectionRendered");
function isSectionEmpty(section) {
return section.isEmpty();
}
},
_hide: function() {
this.$menu.hide();
},
_show: function() {
this.$menu.css("display", "block");
},
_getSuggestions: function getSuggestions() {
return this.$menu.find(".tt-suggestion");
},
_getCursor: function getCursor() {
return this.$menu.find(".tt-cursor").first();
},
_setCursor: function setCursor($el) {
$el.first().addClass("tt-cursor");
this.trigger("cursorMoved");
},
_removeCursor: function removeCursor() {
this._getCursor().removeClass("tt-cursor");
},
_moveCursor: function moveCursor(increment) {
var $suggestions, $oldCursor, newCursorIndex, $newCursor;
if (!this.isOpen) {
return;
}
$oldCursor = this._getCursor();
$suggestions = this._getSuggestions();
this._removeCursor();
newCursorIndex = $suggestions.index($oldCursor) + increment;
newCursorIndex = (newCursorIndex + 1) % ($suggestions.length + 1) - 1;
if (newCursorIndex === -1) {
this.trigger("cursorRemoved");
return;
} else if (newCursorIndex < -1) {
newCursorIndex = $suggestions.length - 1;
}
this._setCursor($newCursor = $suggestions.eq(newCursorIndex));
this._ensureVisible($newCursor);
},
_ensureVisible: function ensureVisible($el) {
var elTop, elBottom, menuScrollTop, menuHeight;
elTop = $el.position().top;
elBottom = elTop + $el.outerHeight(true);
menuScrollTop = this.$menu.scrollTop();
menuHeight = this.$menu.height() + parseInt(this.$menu.css("paddingTop"), 10) + parseInt(this.$menu.css("paddingBottom"), 10);
if (elTop < 0) {
this.$menu.scrollTop(menuScrollTop + elTop);
} else if (menuHeight < elBottom) {
this.$menu.scrollTop(menuScrollTop + (elBottom - menuHeight));
}
},
close: function close() {
if (this.isOpen) {
this.isOpen = this.isMouseOverDropdown = false;
this._removeCursor();
this._hide();
this.trigger("closed");
}
},
open: function open() {
if (!this.isOpen) {
this.isOpen = true;
!this.isEmpty && this._show();
this.trigger("opened");
}
},
setLanguageDirection: function setLanguageDirection(dir) {
this.$menu.css(dir === "ltr" ? css.ltr : css.rtl);
},
moveCursorUp: function moveCursorUp() {
this._moveCursor(-1);
},
moveCursorDown: function moveCursorDown() {
this._moveCursor(+1);
},
getDatumForSuggestion: function getDatumForSuggestion($el) {
var datum = null;
if ($el.length) {
datum = {
raw: Section.extractDatum($el),
value: Section.extractValue($el),
sectionName: Section.extractSectionName($el)
};
}
return datum;
},
getDatumForCursor: function getDatumForCursor() {
return this.getDatumForSuggestion(this._getCursor().first());
},
getDatumForTopSuggestion: function getDatumForTopSuggestion() {
return this.getDatumForSuggestion(this._getSuggestions().first());
},
update: function update(query) {
_.each(this.sections, updateSection);
function updateSection(section) {
section.update(query);
}
},
empty: function empty() {
_.each(this.sections, clearSection);
function clearSection(section) {
section.clear();
}
},
isVisible: function isVisible() {
return this.isOpen && !this.isEmpty;
},
destroy: function destroy() {
this.$menu.off(".tt");
this.$menu = null;
_.each(this.sections, destroySection);
function destroySection(section) {
section.destroy();
}
}
});
return Dropdown;
function initializeSection(oSection) {
return new Section(oSection);
}
}();
var Typeahead = function() {
var attrsKey = "ttAttrs";
function Typeahead(o) {
var $menu, $input, $hint, sections;
o = o || {};
if (!o.input) {
$.error("missing input");
}
this.autoselect = o.autoselect;
this.minLength = o.minLength || 0;
this.$node = buildDomStructure(o.input, o.withHint);
$menu = this.$node.find(".tt-dropdown-menu");
$input = this.$node.find(".tt-input");
$hint = this.$node.find(".tt-hint");
this.eventBus = new EventBus({
el: $input
});
this.dropdown = new Dropdown({
menu: $menu,
sections: o.sections
}).onSync("suggestionClicked", this._onSuggestionClicked, this).onSync("cursorMoved", this._onCursorMoved, this).onSync("cursorRemoved", this._onCursorRemoved, this).onSync("sectionRendered", this._onSectionRendered, this).onSync("opened", this._onOpened, this).onSync("closed", this._onClosed, this);
this.input = new Input({
input: $input,
hint: $hint
}).onSync("focused", this._onFocused, this).onSync("blurred", this._onBlurred, this).onSync("enterKeyed", this._onEnterKeyed, this).onSync("tabKeyed", this._onTabKeyed, this).onSync("escKeyed", this._onEscKeyed, this).onSync("upKeyed", this._onUpKeyed, this).onSync("downKeyed", this._onDownKeyed, this).onSync("leftKeyed", this._onLeftKeyed, this).onSync("rightKeyed", this._onRightKeyed, this).onSync("queryChanged", this._onQueryChanged, this).onSync("whitespaceChanged", this._onWhitespaceChanged, this);
}
_.mixin(Typeahead.prototype, {
_onSuggestionClicked: function onSuggestionClicked(type, $el) {
var datum;
if (datum = this.dropdown.getDatumForSuggestion($el)) {
this._select(datum);
this.input.focus();
}
},
_onCursorMoved: function onCursorMoved() {
var datum = this.dropdown.getDatumForCursor();
this.input.clearHint();
this.input.setInputValue(datum.value, true);
this.eventBus.trigger("cursorchanged", datum.raw, datum.sectionName);
},
_onCursorRemoved: function onCursorRemoved() {
this.input.resetInputValue();
this._updateHint();
},
_onSectionRendered: function onSectionRendered() {
this._updateHint();
},
_onOpened: function onOpened() {
this._updateHint();
this.eventBus.trigger("opened");
},
_onClosed: function onClosed() {
this.input.clearHint();
this.eventBus.trigger("closed");
},
_onFocused: function onFocused() {
this.dropdown.open();
},
_onBlurred: function onBlurred() {
!this.dropdown.isMouseOverDropdown && this.dropdown.close();
},
_onEnterKeyed: function onEnterKeyed(type, $e) {
var cursorDatum, topSuggestionDatum;
cursorDatum = this.dropdown.getDatumForCursor();
topSuggestionDatum = this.dropdown.getDatumForTopSuggestion();
if (cursorDatum) {
this._select(cursorDatum);
$e.preventDefault();
} else if (this.autoselect && topSuggestionDatum) {
this._select(topSuggestionDatum);
$e.preventDefault();
}
},
_onTabKeyed: function onTabKeyed(type, $e) {
var datum;
if (datum = this.dropdown.getDatumForCursor()) {
this._select(datum);
$e.preventDefault();
} else {
this._autocomplete();
}
},
_onEscKeyed: function onEscKeyed() {
this.dropdown.close();
this.input.resetInputValue();
},
_onUpKeyed: function onUpKeyed() {
this.dropdown.open();
this.dropdown.moveCursorUp();
},
_onDownKeyed: function onDownKeyed() {
this.dropdown.open();
this.dropdown.moveCursorDown();
},
_onLeftKeyed: function onLeftKeyed() {
this.dir === "rtl" && this._autocomplete();
},
_onRightKeyed: function onRightKeyed() {
this.dir === "ltr" && this._autocomplete();
},
_onQueryChanged: function onQueryChanged(e, query) {
this.input.clearHint();
this.dropdown.empty();
query.length >= this.minLength && this.dropdown.update(query);
this.dropdown.open();
this._setLanguageDirection();
},
_onWhitespaceChanged: function onWhitespaceChanged() {
this._updateHint();
this.dropdown.open();
},
_setLanguageDirection: function setLanguageDirection() {
var dir;
if (this.dir !== (dir = this.input.getLanguageDirection())) {
this.dir = dir;
this.$node.css("direction", dir);
this.dropdown.setLanguageDirection(dir);
}
},
_updateHint: function updateHint() {
var datum, inputValue, query, escapedQuery, frontMatchRegEx, match;
datum = this.dropdown.getDatumForTopSuggestion();
if (datum && this.dropdown.isVisible() && !this.input.hasOverflow()) {
inputValue = this.input.getInputValue();
query = Input.normalizeQuery(inputValue);
escapedQuery = _.escapeRegExChars(query);
frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.*$)", "i");
match = frontMatchRegEx.exec(datum.value);
this.input.setHintValue(inputValue + (match ? match[1] : ""));
}
},
_autocomplete: function autocomplete() {
var hint, query, datum;
hint = this.input.getHintValue();
query = this.input.getQuery();
if (hint && query !== hint && this.input.isCursorAtEnd()) {
datum = this.dropdown.getDatumForTopSuggestion();
datum && this.input.setInputValue(datum.value);
this.eventBus.trigger("autocompleted", datum.raw, datum.sectionName);
}
},
_select: function select(datum) {
this.input.clearHint();
this.input.setQuery(datum.value);
this.input.setInputValue(datum.value, true);
this._setLanguageDirection();
_.defer(_.bind(this.dropdown.close, this.dropdown));
this.eventBus.trigger("selected", datum.raw, datum.sectionName);
},
open: function open() {
this.dropdown.open();
},
close: function close() {
this.dropdown.close();
},
getQuery: function getQuery() {
return this.input.getQuery();
},
setQuery: function setQuery(val) {
this.input.setInputValue(val);
},
destroy: function destroy() {
this.input.destroy();
this.dropdown.destroy();
destroyDomStructure(this.$node);
this.$node = null;
}
});
return Typeahead;
function buildDomStructure(input, withHint) {
var $input, $wrapper, $dropdown, $hint;
$input = $(input);
$wrapper = $(html.wrapper).css(css.wrapper);
$dropdown = $(html.dropdown).css(css.dropdown);
$hint = $input.clone().css(css.hint).css(getBackgroundStyles($input));
$hint.removeData().addClass("tt-hint").removeAttr("id name placeholder").prop("disabled", true).attr({
autocomplete: "off",
spellcheck: "false"
});
$input.data(attrsKey, {
dir: $input.attr("dir"),
autocomplete: $input.attr("autocomplete"),
spellcheck: $input.attr("spellcheck"),
style: $input.attr("style")
});
$input.addClass("tt-input").attr({
autocomplete: "off",
spellcheck: false
}).css(withHint ? css.input : css.inputWithNoHint);
try {
!$input.attr("dir") && $input.attr("dir", "auto");
} catch (e) {}
return $input.wrap($wrapper).parent().prepend(withHint ? $hint : null).append($dropdown);
}
function getBackgroundStyles($el) {
return {
backgroundAttachment: $el.css("background-attachment"),
backgroundClip: $el.css("background-clip"),
backgroundColor: $el.css("background-color"),
backgroundImage: $el.css("background-image"),
backgroundOrigin: $el.css("background-origin"),
backgroundPosition: $el.css("background-position"),
backgroundRepeat: $el.css("background-repeat"),
backgroundSize: $el.css("background-size")
};
}
function destroyDomStructure($node) {
var $input = $node.find(".tt-input");
_.each($input.data(attrsKey), function(val, key) {
_.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val);
});
$input.detach().removeData(attrsKey).removeClass("tt-input").insertAfter($node);
$node.remove();
}
}();
(function() {
var typeaheadKey, methods;
typeaheadKey = "ttTypeahead";
methods = {
initialize: function initialize(o) {
o = o || {};
return this.each(attach);
function attach() {
var $input = $(this), typeahead;
typeahead = new Typeahead({
input: $input,
withHint: _.isUndefined(o.hint) ? true : !!o.hint,
minLength: o.minLength || 0,
autoselect: !!o.autoselect,
sections: _.isArray(o.sections) ? o.sections : [ o.sections ]
});
$input.data(typeaheadKey, typeahead);
}
},
open: function open() {
return this.each(openTypeahead);
function openTypeahead() {
var $input = $(this), typeahead;
if (typeahead = $input.data(typeaheadKey)) {
typeahead.open();
}
}
},
close: function close() {
return this.each(closeTypeahead);
function closeTypeahead() {
var $input = $(this), typeahead;
if (typeahead = $input.data(typeaheadKey)) {
typeahead.close();
}
}
},
val: function val(newVal) {
return newVal ? this.each(setQuery) : this.map(getQuery).get();
function setQuery() {
var $input = $(this), typeahead;
if (typeahead = $input.data(typeaheadKey)) {
typeahead.setQuery(newVal);
}
}
function getQuery() {
var $input = $(this), typeahead, query;
if (typeahead = $input.data(typeaheadKey)) {
query = typeahead.getQuery();
}
return query;
}
},
destroy: function destroy() {
return this.each(unattach);
function unattach() {
var $input = $(this), typeahead;
if (typeahead = $input.data(typeaheadKey)) {
typeahead.destroy();
$input.removeData(typeaheadKey);
}
}
}
};
jQuery.fn.typeahead = function(method) {
if (methods[method]) {
return methods[method].apply(this, [].slice.call(arguments, 1));
} else {
return methods.initialize.apply(this, arguments);
}
};
})();
})(window.jQuery);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment