twitter-typeahead-rails 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore CHANGED
@@ -15,3 +15,5 @@ spec/reports
15
15
  test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
+ .idea
19
+
data/README.md CHANGED
@@ -14,7 +14,7 @@ Add this line to your application's Gemfile:
14
14
 
15
15
  or
16
16
 
17
- gem 'twitter-typeahead-rails', :git => "git@github.com:yourabi/twitter-typeahead-rails.git"
17
+ gem 'twitter-typeahead-rails', :git => "git://github.com/yourabi/twitter-typeahead-rails.git"
18
18
 
19
19
 
20
20
  And then execute:
@@ -34,6 +34,7 @@ Add one the folllwing to your application.js mainifest:
34
34
  ```js
35
35
 
36
36
  //= require twitter/typeahead
37
+
37
38
  //= require twitter/typeahead.min
38
39
 
39
40
  ```
@@ -48,17 +49,7 @@ $(document).ready(function() {
48
49
 
49
50
  ```
50
51
 
51
- You'll also probably want to enable include the CSS in your application.css manifest. Include one of the following:
52
-
53
- ```js
54
-
55
- *= require twitter/typeahead
56
-
57
- *= require twitter/typeahead.min
58
-
59
- ```
60
-
61
- Currently this version tracks version v0.8.0.
52
+ Currently this version tracks version v0.9.2.
62
53
 
63
54
  ## Contributing
64
55
 
@@ -67,3 +58,4 @@ Currently this version tracks version v0.8.0.
67
58
  3. Commit your changes (`git commit -am 'Add some feature'`)
68
59
  4. Push to the branch (`git push origin my-new-feature`)
69
60
  5. Create new Pull Request
61
+
@@ -1,7 +1,7 @@
1
1
  module Twitter
2
2
  module Typeahead
3
3
  module Rails
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.2"
5
5
  end
6
6
  end
7
7
  end
@@ -1,14 +1,21 @@
1
1
  /*!
2
- * Twitter Typeahead 0.8.0
2
+ * typeahead.js 0.9.2
3
3
  * https://github.com/twitter/typeahead
4
4
  * Copyright 2013 Twitter, Inc. and other contributors; Licensed MIT
5
5
  */
6
6
 
7
- (function() {
8
- var VERSION = "0.8.0";
7
+ (function($) {
8
+ var VERSION = "0.9.2";
9
9
  var utils = {
10
10
  isMsie: function() {
11
- return /msie [\w.]+/i.test(navigator.userAgent);
11
+ var match = /(msie) ([\w.]+)/i.exec(navigator.userAgent);
12
+ return match ? parseInt(match[2], 10) : false;
13
+ },
14
+ isBlankString: function(str) {
15
+ return !str || /^\s*$/.test(str);
16
+ },
17
+ escapeRegExChars: function(str) {
18
+ return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
12
19
  },
13
20
  isString: function(obj) {
14
21
  return typeof obj === "string";
@@ -18,9 +25,7 @@
18
25
  },
19
26
  isArray: $.isArray,
20
27
  isFunction: $.isFunction,
21
- isObject: function(obj) {
22
- return obj !== Object(obj);
23
- },
28
+ isObject: $.isPlainObject,
24
29
  isUndefined: function(obj) {
25
30
  return typeof obj === "undefined";
26
31
  },
@@ -28,7 +33,7 @@
28
33
  bindAll: function(obj) {
29
34
  var val;
30
35
  for (var key in obj) {
31
- utils.isFunction(val = obj[key]) && (obj[key] = $.proxy(val, obj));
36
+ $.isFunction(val = obj[key]) && (obj[key] = $.proxy(val, obj));
32
37
  }
33
38
  },
34
39
  indexOf: function(haystack, needle) {
@@ -41,15 +46,7 @@
41
46
  },
42
47
  each: $.each,
43
48
  map: $.map,
44
- filter: function(obj, test) {
45
- var results = [];
46
- $.each(obj, function(key, val) {
47
- if (test(val, key, obj)) {
48
- results.push(val);
49
- }
50
- });
51
- return results;
52
- },
49
+ filter: $.grep,
53
50
  every: function(obj, test) {
54
51
  var result = true;
55
52
  if (!obj) {
@@ -62,13 +59,17 @@
62
59
  });
63
60
  return !!result;
64
61
  },
65
- keys: function(obj) {
66
- if (!utils.isObject(obj)) {
67
- throw new TypeError("invalid object");
62
+ some: function(obj, test) {
63
+ var result = false;
64
+ if (!obj) {
65
+ return result;
68
66
  }
69
- return $.map(obj, function(val, key) {
70
- return key;
67
+ $.each(obj, function(key, val) {
68
+ if (result = test.call(null, val, key, obj)) {
69
+ return false;
70
+ }
71
71
  });
72
+ return !!result;
72
73
  },
73
74
  mixin: $.extend,
74
75
  getUniqueId: function() {
@@ -77,6 +78,9 @@
77
78
  return counter++;
78
79
  };
79
80
  }(),
81
+ defer: function(fn) {
82
+ setTimeout(fn, 0);
83
+ },
80
84
  debounce: function(func, wait, immediate) {
81
85
  var timeout, result;
82
86
  return function() {
@@ -119,16 +123,8 @@
119
123
  return result;
120
124
  };
121
125
  },
122
- uniqueArray: function(array) {
123
- var u = {}, a = [];
124
- for (var i = 0, l = array.length; i < l; ++i) {
125
- if (u.hasOwnProperty(array[i])) {
126
- continue;
127
- }
128
- a.push(array[i]);
129
- u[array[i]] = 1;
130
- }
131
- return a;
126
+ tokenizeQuery: function(str) {
127
+ return $.trim(str).toLowerCase().split(/[\s]+/);
132
128
  },
133
129
  tokenizeText: function(str) {
134
130
  return $.trim(str).toLowerCase().split(/[\s\-_]+/);
@@ -174,49 +170,75 @@
174
170
  }
175
171
  };
176
172
  }();
173
+ var EventBus = function() {
174
+ var namespace = "typeahead:";
175
+ function EventBus(o) {
176
+ if (!o || !o.el) {
177
+ $.error("EventBus initialized without el");
178
+ }
179
+ this.$el = $(o.el);
180
+ }
181
+ utils.mixin(EventBus.prototype, {
182
+ trigger: function(type) {
183
+ var args = [].slice.call(arguments, 1);
184
+ this.$el.trigger(namespace + type, args);
185
+ }
186
+ });
187
+ return EventBus;
188
+ }();
177
189
  var PersistentStorage = function() {
178
- var ls = window.localStorage, methods;
190
+ var ls, methods;
191
+ try {
192
+ ls = window.localStorage;
193
+ } catch (err) {
194
+ ls = null;
195
+ }
179
196
  function PersistentStorage(namespace) {
180
197
  this.prefix = [ "__", namespace, "__" ].join("");
181
198
  this.ttlKey = "__ttl__";
182
199
  this.keyMatcher = new RegExp("^" + this.prefix);
183
200
  }
184
- if (window.localStorage && window.JSON) {
201
+ if (ls && window.JSON) {
185
202
  methods = {
203
+ _prefix: function(key) {
204
+ return this.prefix + key;
205
+ },
206
+ _ttlKey: function(key) {
207
+ return this._prefix(key) + this.ttlKey;
208
+ },
186
209
  get: function(key) {
187
- var ttl = decode(ls.getItem(this.prefix + key));
188
- if (utils.isNumber(ttl) && now() > ttl) {
189
- ls.removeItem(this.prefix + key + this.ttlKey);
210
+ if (this.isExpired(key)) {
211
+ this.remove(key);
190
212
  }
191
- return decode(ls.getItem(this.prefix + key));
213
+ return decode(ls.getItem(this._prefix(key)));
192
214
  },
193
215
  set: function(key, val, ttl) {
194
216
  if (utils.isNumber(ttl)) {
195
- ls.setItem(this.prefix + key + this.ttlKey, encode(now() + ttl));
217
+ ls.setItem(this._ttlKey(key), encode(now() + ttl));
196
218
  } else {
197
- ls.removeItem(this.prefix + key + this.ttlKey);
219
+ ls.removeItem(this._ttlKey(key));
198
220
  }
199
- return ls.setItem(this.prefix + key, encode(val));
221
+ return ls.setItem(this._prefix(key), encode(val));
200
222
  },
201
223
  remove: function(key) {
202
- ls.removeItem(this.prefix + key + this.ttlKey);
203
- ls.removeItem(this.prefix + key);
224
+ ls.removeItem(this._ttlKey(key));
225
+ ls.removeItem(this._prefix(key));
204
226
  return this;
205
227
  },
206
228
  clear: function() {
207
- var i, key, len = ls.length;
208
- for (i = 0; i < len; i += 1) {
209
- key = ls.key(i);
210
- if (key.match(this.keyMatcher)) {
211
- i -= 1;
212
- len -= 1;
213
- this.remove(key.replace(this.keyMatcher, ""));
229
+ var i, key, keys = [], len = ls.length;
230
+ for (i = 0; i < len; i++) {
231
+ if ((key = ls.key(i)).match(this.keyMatcher)) {
232
+ keys.push(key.replace(this.keyMatcher, ""));
214
233
  }
215
234
  }
235
+ for (i = keys.length; i--; ) {
236
+ this.remove(keys[i]);
237
+ }
216
238
  return this;
217
239
  },
218
240
  isExpired: function(key) {
219
- var ttl = decode(ls.getItem(this.prefix + key + this.ttlKey));
241
+ var ttl = decode(ls.getItem(this._ttlKey(key)));
220
242
  return utils.isNumber(ttl) && now() > ttl ? true : false;
221
243
  }
222
244
  };
@@ -235,10 +257,10 @@
235
257
  return new Date().getTime();
236
258
  }
237
259
  function encode(val) {
238
- return JSON.stringify(val);
260
+ return JSON.stringify(utils.isUndefined(val) ? null : val);
239
261
  }
240
262
  function decode(val) {
241
- return utils.isUndefined(val) ? undefined : JSON.parse(val);
263
+ return JSON.parse(val);
242
264
  }
243
265
  }();
244
266
  var RequestCache = function() {
@@ -266,381 +288,350 @@
266
288
  return RequestCache;
267
289
  }();
268
290
  var Transport = function() {
291
+ var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests, requestCache;
269
292
  function Transport(o) {
270
- var rateLimitFn;
271
293
  utils.bindAll(this);
272
- o = o || {};
273
- rateLimitFn = /^throttle$/i.test(o.rateLimitFn) ? utils.throttle : utils.debounce;
274
- this.wait = o.wait || 300;
294
+ o = utils.isString(o) ? {
295
+ url: o
296
+ } : o;
297
+ requestCache = requestCache || new RequestCache();
298
+ maxPendingRequests = utils.isNumber(o.maxParallelRequests) ? o.maxParallelRequests : maxPendingRequests || 6;
299
+ this.url = o.url;
275
300
  this.wildcard = o.wildcard || "%QUERY";
276
- this.maxConcurrentRequests = o.maxConcurrentRequests || 6;
277
- this.concurrentRequests = 0;
278
- this.onDeckRequestArgs = null;
279
- this.cache = new RequestCache();
280
- this.get = rateLimitFn(this.get, this.wait);
301
+ this.filter = o.filter;
302
+ this.replace = o.replace;
303
+ this.ajaxSettings = {
304
+ type: "get",
305
+ cache: o.cache,
306
+ timeout: o.timeout,
307
+ dataType: o.dataType || "json",
308
+ beforeSend: o.beforeSend
309
+ };
310
+ this._get = (/^throttle$/i.test(o.rateLimitFn) ? utils.throttle : utils.debounce)(this._get, o.rateLimitWait || 300);
281
311
  }
282
312
  utils.mixin(Transport.prototype, {
283
- _incrementConcurrentRequests: function() {
284
- this.concurrentRequests++;
285
- },
286
- _decrementConcurrentRequests: function() {
287
- this.concurrentRequests--;
288
- },
289
- _belowConcurrentRequestsThreshold: function() {
290
- return this.concurrentRequests < this.maxConcurrentRequests;
291
- },
292
- get: function(url, query, cb) {
293
- var that = this, resp;
294
- url = url.replace(this.wildcard, encodeURIComponent(query || ""));
295
- if (resp = this.cache.get(url)) {
296
- cb && cb(resp);
297
- } else if (this._belowConcurrentRequestsThreshold()) {
298
- $.ajax({
299
- url: url,
300
- type: "GET",
301
- dataType: "json",
302
- beforeSend: function() {
303
- that._incrementConcurrentRequests();
304
- },
305
- success: function(resp) {
306
- cb && cb(resp);
307
- that.cache.set(url, resp);
308
- },
309
- complete: function() {
310
- that._decrementConcurrentRequests();
311
- if (that.onDeckRequestArgs) {
312
- that.get.apply(that, that.onDeckRequestArgs);
313
- that.onDeckRequestArgs = null;
314
- }
315
- }
316
- });
313
+ _get: function(url, cb) {
314
+ var that = this;
315
+ if (belowPendingRequestsThreshold()) {
316
+ this._sendRequest(url).done(done);
317
317
  } else {
318
318
  this.onDeckRequestArgs = [].slice.call(arguments, 0);
319
319
  }
320
+ function done(resp) {
321
+ var data = that.filter ? that.filter(resp) : resp;
322
+ cb && cb(data);
323
+ requestCache.set(url, resp);
324
+ }
325
+ },
326
+ _sendRequest: function(url) {
327
+ var that = this, jqXhr = pendingRequests[url];
328
+ if (!jqXhr) {
329
+ incrementPendingRequests();
330
+ jqXhr = pendingRequests[url] = $.ajax(url, this.ajaxSettings).always(always);
331
+ }
332
+ return jqXhr;
333
+ function always() {
334
+ decrementPendingRequests();
335
+ pendingRequests[url] = null;
336
+ if (that.onDeckRequestArgs) {
337
+ that._get.apply(that, that.onDeckRequestArgs);
338
+ that.onDeckRequestArgs = null;
339
+ }
340
+ }
341
+ },
342
+ get: function(query, cb) {
343
+ var that = this, encodedQuery = encodeURIComponent(query || ""), url, resp;
344
+ cb = cb || utils.noop;
345
+ url = this.replace ? this.replace(this.url, encodedQuery) : this.url.replace(this.wildcard, encodedQuery);
346
+ if (resp = requestCache.get(url)) {
347
+ utils.defer(function() {
348
+ cb(that.filter ? that.filter(resp) : resp);
349
+ });
350
+ } else {
351
+ this._get(url, cb);
352
+ }
353
+ return !!resp;
320
354
  }
321
355
  });
322
356
  return Transport;
357
+ function incrementPendingRequests() {
358
+ pendingRequestsCount++;
359
+ }
360
+ function decrementPendingRequests() {
361
+ pendingRequestsCount--;
362
+ }
363
+ function belowPendingRequestsThreshold() {
364
+ return pendingRequestsCount < maxPendingRequests;
365
+ }
323
366
  }();
324
367
  var Dataset = function() {
368
+ var keys = {
369
+ thumbprint: "thumbprint",
370
+ protocol: "protocol",
371
+ itemHash: "itemHash",
372
+ adjacencyList: "adjacencyList"
373
+ };
325
374
  function Dataset(o) {
326
375
  utils.bindAll(this);
327
- this.storage = new PersistentStorage(o.name);
328
- this.adjacencyList = {};
376
+ if (utils.isString(o.template) && !o.engine) {
377
+ $.error("no template engine specified");
378
+ }
379
+ if (!o.local && !o.prefetch && !o.remote) {
380
+ $.error("one of local, prefetch, or remote is required");
381
+ }
382
+ this.name = o.name || utils.getUniqueId();
383
+ this.limit = o.limit || 5;
384
+ this.minLength = o.minLength || 1;
385
+ this.header = o.header;
386
+ this.footer = o.footer;
387
+ this.valueKey = o.valueKey || "value";
388
+ this.template = compileTemplate(o.template, o.engine, this.valueKey);
389
+ this.local = o.local;
390
+ this.prefetch = o.prefetch;
391
+ this.remote = o.remote;
329
392
  this.itemHash = {};
330
- this.name = o.name;
331
- this.resetDataOnProtocolSwitch = o.resetDataOnProtocolSwitch || false;
332
- this.prefetchUrl = o.prefetch;
333
- this.queryUrl = o.remote;
334
- this.rawData = o.local;
335
- this.transport = o.transport;
336
- this.limit = o.limit || 10;
337
- this._customMatcher = o.matcher || null;
338
- this._customRanker = o.ranker || null;
339
- this._ttl_ms = o.ttl_ms || 3 * 24 * 60 * 60 * 1e3;
340
- this.storageAdjacencyList = "adjacencyList";
341
- this.storageHash = "itemHash";
342
- this.storageProtocol = "protocol";
343
- this.storageVersion = "version";
344
- this._loadData();
393
+ this.adjacencyList = {};
394
+ this.storage = o.name ? new PersistentStorage(o.name) : null;
345
395
  }
346
396
  utils.mixin(Dataset.prototype, {
347
- _isMetadataExpired: function() {
348
- var isExpired = this.storage.isExpired(this.storageProtocol);
349
- var isCacheStale = this.storage.isExpired(this.storageAdjacencyList) || this.storage.isExpired(this.storageHash);
350
- var resetForProtocolSwitch = this.resetDataOnProtocolSwitch && this.storage.get(this.storageProtocol) != utils.getProtocol();
351
- if (VERSION == this.storage.get(this.storageVersion) && !resetForProtocolSwitch && !isExpired && !isCacheStale) {
352
- return false;
353
- }
354
- return true;
355
- },
356
- _loadData: function() {
357
- this.rawData && this._processRawData(this.rawData);
358
- this._getDataFromLocalStorage();
359
- if (this._isMetadataExpired() || this.itemHash === {}) {
360
- this.prefetchUrl && this._prefetch(this.prefetchUrl);
361
- }
397
+ _processLocalData: function(data) {
398
+ this._mergeProcessedData(this._processData(data));
362
399
  },
363
- _getDataFromLocalStorage: function() {
364
- this.itemHash = this.storage.get(this.storageHash) || this.itemHash;
365
- this.adjacencyList = this.storage.get(this.storageAdjacencyList) || this.adjacencyList;
366
- },
367
- _getPotentiallyMatchingIds: function(terms) {
368
- var potentiallyMatchingIds = [];
369
- var lists = [];
370
- utils.map(terms, utils.bind(function(term) {
371
- var list = this.adjacencyList[term.charAt(0)];
372
- if (!list) {
373
- return;
374
- }
375
- lists.push(list);
376
- }, this));
377
- if (lists.length === 1) {
378
- return lists[0];
400
+ _loadPrefetchData: function(o) {
401
+ var that = this, thumbprint = VERSION + (o.thumbprint || ""), storedThumbprint, storedProtocol, storedItemHash, storedAdjacencyList, isExpired, deferred;
402
+ if (this.storage) {
403
+ storedThumbprint = this.storage.get(keys.thumbprint);
404
+ storedProtocol = this.storage.get(keys.protocol);
405
+ storedItemHash = this.storage.get(keys.itemHash);
406
+ storedAdjacencyList = this.storage.get(keys.adjacencyList);
379
407
  }
380
- var listLengths = [];
381
- $.each(lists, function(i, list) {
382
- listLengths.push(list.length);
383
- });
384
- var shortestListIndex = utils.indexOf(listLengths, Math.min.apply(null, listLengths)) || 0;
385
- var shortestList = lists[shortestListIndex] || [];
386
- potentiallyMatchingIds = utils.map(shortestList, function(item) {
387
- var idInEveryList = utils.every(lists, function(list) {
388
- return utils.indexOf(list, item) > -1;
408
+ isExpired = storedThumbprint !== thumbprint || storedProtocol !== utils.getProtocol();
409
+ o = utils.isString(o) ? {
410
+ url: o
411
+ } : o;
412
+ o.ttl = utils.isNumber(o.ttl) ? o.ttl : 24 * 60 * 60 * 1e3;
413
+ if (storedItemHash && storedAdjacencyList && !isExpired) {
414
+ this._mergeProcessedData({
415
+ itemHash: storedItemHash,
416
+ adjacencyList: storedAdjacencyList
389
417
  });
390
- if (idInEveryList) {
391
- return item;
392
- }
393
- });
394
- return potentiallyMatchingIds;
395
- },
396
- _getItemsFromIds: function(ids) {
397
- var items = [];
398
- utils.map(ids, utils.bind(function(id) {
399
- var item = this.itemHash[id];
400
- if (item) {
401
- items.push(item);
402
- }
403
- }, this));
404
- return items;
405
- },
406
- _matcher: function(terms) {
407
- if (this._customMatcher) {
408
- var customMatcher = this._customMatcher;
409
- return function(item) {
410
- return customMatcher(item);
411
- };
418
+ deferred = $.Deferred().resolve();
412
419
  } else {
413
- return function(item) {
414
- var tokens = item.tokens;
415
- var allTermsMatched = utils.every(terms, function(term) {
416
- var tokensMatched = utils.filter(tokens, function(token) {
417
- return token.indexOf(term) === 0;
418
- });
419
- return tokensMatched.length;
420
- });
421
- if (allTermsMatched) {
422
- return item;
423
- }
424
- };
420
+ deferred = $.getJSON(o.url).done(processPrefetchData);
425
421
  }
426
- },
427
- _compareItems: function(a, b, areLocalItems) {
428
- var aScoreBoost = !a.score_boost ? 0 : a.score_boost, bScoreBoost = !b.score_boost ? 0 : b.score_boost, aScore = !a.score ? 0 : a.score, bScore = !b.score ? 0 : b.score;
429
- if (areLocalItems) {
430
- return b.weight + bScoreBoost - (a.weight + aScoreBoost);
431
- } else {
432
- return bScore + bScoreBoost - (aScore + aScoreBoost);
422
+ return deferred;
423
+ function processPrefetchData(data) {
424
+ var filteredData = o.filter ? o.filter(data) : data, processedData = that._processData(filteredData), itemHash = processedData.itemHash, adjacencyList = processedData.adjacencyList;
425
+ if (that.storage) {
426
+ that.storage.set(keys.itemHash, itemHash, o.ttl);
427
+ that.storage.set(keys.adjacencyList, adjacencyList, o.ttl);
428
+ that.storage.set(keys.thumbprint, thumbprint, o.ttl);
429
+ that.storage.set(keys.protocol, utils.getProtocol(), o.ttl);
430
+ }
431
+ that._mergeProcessedData(processedData);
433
432
  }
434
433
  },
435
- _ranker: function(a, b) {
436
- if (this._customRanker) {
437
- return this._customRanker(a, b);
434
+ _transformDatum: function(datum) {
435
+ var value = utils.isString(datum) ? datum : datum[this.valueKey], tokens = datum.tokens || utils.tokenizeText(value), item = {
436
+ value: value,
437
+ tokens: tokens
438
+ };
439
+ if (utils.isString(datum)) {
440
+ item.datum = {};
441
+ item.datum[this.valueKey] = datum;
438
442
  } else {
439
- var aIsLocal = a.weight && a.weight !== 0;
440
- var bIsLocal = b.weight && b.weight !== 0;
441
- if (aIsLocal && !bIsLocal) {
442
- return -1;
443
- } else if (bIsLocal && !aIsLocal) {
444
- return 1;
445
- } else {
446
- return aIsLocal && bIsLocal ? this._compareItems(a, b, true) : this._compareItems(a, b, false);
447
- }
443
+ item.datum = datum;
448
444
  }
445
+ item.tokens = utils.filter(item.tokens, function(token) {
446
+ return !utils.isBlankString(token);
447
+ });
448
+ item.tokens = utils.map(item.tokens, function(token) {
449
+ return token.toLowerCase();
450
+ });
451
+ return item;
449
452
  },
450
- _processRawData: function(data) {
451
- this.itemHash = {};
452
- this.adjacencyList = {};
453
- utils.map(data, utils.bind(function(item) {
454
- var tokens;
455
- if (item.tokens) {
456
- tokens = item.tokens;
457
- } else {
458
- item = {
459
- tokens: utils.tokenizeText(item),
460
- value: item
461
- };
462
- tokens = item.tokens;
463
- }
464
- item.id = utils.getUniqueId(item.value);
465
- utils.map(tokens, utils.bind(function(token) {
466
- var firstChar = token.charAt(0);
467
- if (!this.adjacencyList[firstChar]) {
468
- this.adjacencyList[firstChar] = [ item.id ];
469
- } else {
470
- if (utils.indexOf(this.adjacencyList[firstChar], item.id) === -1) {
471
- this.adjacencyList[firstChar].push(item.id);
472
- }
473
- }
474
- }, this));
475
- this.itemHash[item.id] = item;
476
- }, this));
477
- this.storage.set(this.storageHash, this.itemHash, this._ttl_ms);
478
- this.storage.set(this.storageAdjacencyList, this.adjacencyList, this._ttl_ms);
479
- this.storage.set(this.storageVersion, VERSION, this._ttl_ms);
480
- this.storage.set(this.storageProtocol, utils.getProtocol(), this._ttl_ms);
481
- },
482
- _prefetch: function(url) {
483
- var processPrefetchSuccess = function(data) {
484
- if (!data) {
485
- return;
486
- }
487
- utils.map(data, function(item) {
488
- if (utils.isString(item)) {
489
- return {
490
- value: item,
491
- tokens: utils.tokenizeText(item.toLowerCase())
492
- };
493
- } else {
494
- utils.map(item.tokens, function(token, i) {
495
- item.tokens[i] = token.toLowerCase();
496
- });
497
- return item;
498
- }
453
+ _processData: function(data) {
454
+ var that = this, itemHash = {}, adjacencyList = {};
455
+ utils.each(data, function(i, datum) {
456
+ var item = that._transformDatum(datum), id = utils.getUniqueId(item.value);
457
+ itemHash[id] = item;
458
+ utils.each(item.tokens, function(i, token) {
459
+ var character = token.charAt(0), adjacency = adjacencyList[character] || (adjacencyList[character] = [ id ]);
460
+ !~utils.indexOf(adjacency, id) && adjacency.push(id);
499
461
  });
500
- this._processRawData(data);
501
- };
502
- var processPrefetchError = function() {
503
- this._getDataFromLocalStorage();
462
+ });
463
+ return {
464
+ itemHash: itemHash,
465
+ adjacencyList: adjacencyList
504
466
  };
505
- $.ajax({
506
- url: url,
507
- success: utils.bind(processPrefetchSuccess, this),
508
- error: utils.bind(processPrefetchError, this)
467
+ },
468
+ _mergeProcessedData: function(processedData) {
469
+ var that = this;
470
+ utils.mixin(this.itemHash, processedData.itemHash);
471
+ utils.each(processedData.adjacencyList, function(character, adjacency) {
472
+ var masterAdjacency = that.adjacencyList[character];
473
+ that.adjacencyList[character] = masterAdjacency ? masterAdjacency.concat(adjacency) : adjacency;
509
474
  });
510
475
  },
511
- _processRemoteSuggestions: function(callback, matchedItems) {
512
- return function(data) {
513
- var remoteAndLocalSuggestions = {}, dedupedSuggestions = [];
514
- utils.each(data, function(index, item) {
515
- if (utils.isString(item)) {
516
- remoteAndLocalSuggestions[item] = {
517
- value: item
518
- };
519
- } else {
520
- remoteAndLocalSuggestions[item.value] = item;
521
- }
522
- });
523
- utils.each(matchedItems, function(index, item) {
524
- if (remoteAndLocalSuggestions[item.value]) {
525
- return true;
526
- }
527
- if (utils.isString(item)) {
528
- remoteAndLocalSuggestions[item] = {
529
- value: item
530
- };
531
- } else {
532
- remoteAndLocalSuggestions[item.value] = item;
533
- }
476
+ _getLocalSuggestions: function(terms) {
477
+ var that = this, firstChars = [], lists = [], shortestList, suggestions = [];
478
+ utils.each(terms, function(i, term) {
479
+ var firstChar = term.charAt(0);
480
+ !~utils.indexOf(firstChars, firstChar) && firstChars.push(firstChar);
481
+ });
482
+ utils.each(firstChars, function(i, firstChar) {
483
+ var list = that.adjacencyList[firstChar];
484
+ if (!list) {
485
+ return false;
486
+ }
487
+ lists.push(list);
488
+ if (!shortestList || list.length < shortestList.length) {
489
+ shortestList = list;
490
+ }
491
+ });
492
+ if (lists.length < firstChars.length) {
493
+ return [];
494
+ }
495
+ utils.each(shortestList, function(i, id) {
496
+ var item = that.itemHash[id], isCandidate, isMatch;
497
+ isCandidate = utils.every(lists, function(list) {
498
+ return ~utils.indexOf(list, id);
534
499
  });
535
- utils.each(remoteAndLocalSuggestions, function(index, item) {
536
- dedupedSuggestions.push(item);
500
+ isMatch = isCandidate && utils.every(terms, function(term) {
501
+ return utils.some(item.tokens, function(token) {
502
+ return token.indexOf(term) === 0;
503
+ });
537
504
  });
538
- callback && callback(dedupedSuggestions);
505
+ isMatch && suggestions.push(item);
506
+ });
507
+ return suggestions;
508
+ },
509
+ initialize: function() {
510
+ var deferred;
511
+ this.local && this._processLocalData(this.local);
512
+ this.transport = this.remote ? new Transport(this.remote) : null;
513
+ deferred = this.prefetch ? this._loadPrefetchData(this.prefetch) : $.Deferred().resolve();
514
+ this.local = this.prefetch = this.remote = null;
515
+ this.initialize = function() {
516
+ return deferred;
539
517
  };
518
+ return deferred;
540
519
  },
541
- getSuggestions: function(query, callback) {
542
- var terms = utils.tokenizeText(query);
543
- var potentiallyMatchingIds = this._getPotentiallyMatchingIds(terms);
544
- var potentiallyMatchingItems = this._getItemsFromIds(potentiallyMatchingIds);
545
- var matchedItems = utils.filter(potentiallyMatchingItems, this._matcher(terms));
546
- matchedItems.sort(this._ranker);
547
- callback && callback(matchedItems);
548
- if (matchedItems.length < this.limit && this.queryUrl) {
549
- this.transport.get(this.queryUrl, query, this._processRemoteSuggestions(callback, matchedItems));
520
+ getSuggestions: function(query, cb) {
521
+ var that = this, terms, suggestions, cacheHit = false;
522
+ if (query.length < this.minLength) {
523
+ return;
524
+ }
525
+ terms = utils.tokenizeQuery(query);
526
+ suggestions = this._getLocalSuggestions(terms).slice(0, this.limit);
527
+ if (suggestions.length < this.limit && this.transport) {
528
+ cacheHit = this.transport.get(query, processRemoteData);
529
+ }
530
+ !cacheHit && cb && cb(suggestions);
531
+ function processRemoteData(data) {
532
+ suggestions = suggestions.slice(0);
533
+ utils.each(data, function(i, datum) {
534
+ var item = that._transformDatum(datum), isDuplicate;
535
+ isDuplicate = utils.some(suggestions, function(suggestion) {
536
+ return item.value === suggestion.value;
537
+ });
538
+ !isDuplicate && suggestions.push(item);
539
+ return suggestions.length < that.limit;
540
+ });
541
+ cb && cb(suggestions);
550
542
  }
551
543
  }
552
544
  });
553
545
  return Dataset;
546
+ function compileTemplate(template, engine, valueKey) {
547
+ var renderFn, compiledTemplate;
548
+ if (utils.isFunction(template)) {
549
+ renderFn = template;
550
+ } else if (utils.isString(template)) {
551
+ compiledTemplate = engine.compile(template);
552
+ renderFn = utils.bind(compiledTemplate.render, compiledTemplate);
553
+ } else {
554
+ renderFn = function(context) {
555
+ return "<p>" + context[valueKey] + "</p>";
556
+ };
557
+ }
558
+ return renderFn;
559
+ }
554
560
  }();
555
561
  var InputView = function() {
556
562
  function InputView(o) {
557
563
  var that = this;
558
564
  utils.bindAll(this);
559
565
  this.specialKeyCodeMap = {
560
- 9: {
561
- event: "tab"
562
- },
563
- 27: {
564
- event: "esc"
565
- },
566
- 37: {
567
- event: "left"
568
- },
569
- 39: {
570
- event: "right"
571
- },
572
- 13: {
573
- event: "enter"
574
- },
575
- 38: {
576
- event: "up",
577
- preventDefault: true
578
- },
579
- 40: {
580
- event: "down",
581
- preventDefault: true
582
- }
566
+ 9: "tab",
567
+ 27: "esc",
568
+ 37: "left",
569
+ 39: "right",
570
+ 13: "enter",
571
+ 38: "up",
572
+ 40: "down"
583
573
  };
584
- this.query = "";
585
574
  this.$hint = $(o.hint);
586
- this.$input = $(o.input).on("blur", this._handleBlur).on("focus", this._handleFocus).on("keydown", this._handleSpecialKeyEvent);
575
+ this.$input = $(o.input).on("blur.tt", this._handleBlur).on("focus.tt", this._handleFocus).on("keydown.tt", this._handleSpecialKeyEvent);
587
576
  if (!utils.isMsie()) {
588
- this.$input.on("input", this._compareQueryToInputValue);
577
+ this.$input.on("input.tt", this._compareQueryToInputValue);
589
578
  } else {
590
- this.$input.on("keydown keypress cut paste", function(e) {
591
- if (that.specialKeyCodeMap[e.which || e.keyCode]) {
579
+ this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) {
580
+ if (that.specialKeyCodeMap[$e.which || $e.keyCode]) {
592
581
  return;
593
582
  }
594
- setTimeout(that._compareQueryToInputValue, 0);
583
+ utils.defer(that._compareQueryToInputValue);
595
584
  });
596
585
  }
586
+ this.query = this.$input.val();
587
+ this.$overflowHelper = buildOverflowHelper(this.$input);
597
588
  }
598
589
  utils.mixin(InputView.prototype, EventTarget, {
599
590
  _handleFocus: function() {
600
- this.trigger("focus");
591
+ this.trigger("focused");
601
592
  },
602
593
  _handleBlur: function() {
603
- this.trigger("blur");
594
+ this.trigger("blured");
604
595
  },
605
- _handleSpecialKeyEvent: function(e) {
606
- var keyCode = this.specialKeyCodeMap[e.which || e.keyCode];
607
- if (keyCode) {
608
- this.trigger(keyCode.event, e);
609
- keyCode.preventDefault && e.preventDefault();
610
- }
596
+ _handleSpecialKeyEvent: function($e) {
597
+ var keyName = this.specialKeyCodeMap[$e.which || $e.keyCode];
598
+ keyName && this.trigger(keyName + "Keyed", $e);
611
599
  },
612
600
  _compareQueryToInputValue: function() {
613
601
  var inputValue = this.getInputValue(), isSameQuery = compareQueries(this.query, inputValue), isSameQueryExceptWhitespace = isSameQuery ? this.query.length !== inputValue.length : false;
614
602
  if (isSameQueryExceptWhitespace) {
615
- this.trigger("whitespaceChange", {
603
+ this.trigger("whitespaceChanged", {
616
604
  value: this.query
617
605
  });
618
606
  } else if (!isSameQuery) {
619
- this.trigger("queryChange", {
607
+ this.trigger("queryChanged", {
620
608
  value: this.query = inputValue
621
609
  });
622
610
  }
623
611
  },
612
+ destroy: function() {
613
+ this.$hint.off(".tt");
614
+ this.$input.off(".tt");
615
+ this.$hint = this.$input = this.$overflowHelper = null;
616
+ },
624
617
  focus: function() {
625
618
  this.$input.focus();
626
619
  },
627
620
  blur: function() {
628
621
  this.$input.blur();
629
622
  },
630
- setPreventDefaultValueForKey: function(key, value) {
631
- this.specialKeyCodeMap[key].preventDefault = !!value;
632
- },
633
623
  getQuery: function() {
634
624
  return this.query;
635
625
  },
626
+ setQuery: function(query) {
627
+ this.query = query;
628
+ },
636
629
  getInputValue: function() {
637
630
  return this.$input.val();
638
631
  },
639
632
  setInputValue: function(value, silent) {
640
633
  this.$input.val(value);
641
- if (silent !== true) {
642
- this._compareQueryToInputValue();
643
- }
634
+ !silent && this._compareQueryToInputValue();
644
635
  },
645
636
  getHintValue: function() {
646
637
  return this.$hint.val();
@@ -651,9 +642,13 @@
651
642
  getLanguageDirection: function() {
652
643
  return (this.$input.css("direction") || "ltr").toLowerCase();
653
644
  },
645
+ isOverflow: function() {
646
+ this.$overflowHelper.text(this.getInputValue());
647
+ return this.$overflowHelper.width() > this.$input.width();
648
+ },
654
649
  isCursorAtEnd: function() {
655
650
  var valueLength = this.$input.val().length, selectionStart = this.$input[0].selectionStart, range;
656
- if (selectionStart) {
651
+ if (utils.isNumber(selectionStart)) {
657
652
  return selectionStart === valueLength;
658
653
  } else if (document.selection) {
659
654
  range = document.selection.createRange();
@@ -664,17 +659,51 @@
664
659
  }
665
660
  });
666
661
  return InputView;
662
+ function buildOverflowHelper($input) {
663
+ return $("<span></span>").css({
664
+ position: "absolute",
665
+ left: "-9999px",
666
+ visibility: "hidden",
667
+ whiteSpace: "nowrap",
668
+ fontFamily: $input.css("font-family"),
669
+ fontSize: $input.css("font-size"),
670
+ fontStyle: $input.css("font-style"),
671
+ fontVariant: $input.css("font-variant"),
672
+ fontWeight: $input.css("font-weight"),
673
+ wordSpacing: $input.css("word-spacing"),
674
+ letterSpacing: $input.css("letter-spacing"),
675
+ textIndent: $input.css("text-indent"),
676
+ textRendering: $input.css("text-rendering"),
677
+ textTransform: $input.css("text-transform")
678
+ }).insertAfter($input);
679
+ }
667
680
  function compareQueries(a, b) {
668
- a = (a || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " ").toLowerCase();
669
- b = (b || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " ").toLowerCase();
681
+ a = (a || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " ");
682
+ b = (b || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " ");
670
683
  return a === b;
671
684
  }
672
685
  }();
673
686
  var DropdownView = function() {
687
+ var html = {
688
+ suggestionsList: '<span class="tt-suggestions"></span>'
689
+ }, css = {
690
+ suggestionsList: {
691
+ display: "block"
692
+ },
693
+ suggestion: {
694
+ whiteSpace: "nowrap",
695
+ cursor: "pointer"
696
+ },
697
+ suggestionChild: {
698
+ whiteSpace: "normal"
699
+ }
700
+ };
674
701
  function DropdownView(o) {
675
702
  utils.bindAll(this);
676
- this.isMouseOverDropdown;
677
- this.$menu = $(o.menu).on("mouseenter", this._handleMouseenter).on("mouseleave", this._handleMouseleave).on("mouseover", ".tt-suggestions > .tt-suggestion", this._handleMouseover).on("click", ".tt-suggestions > .tt-suggestion", this._handleSelection);
703
+ this.isOpen = false;
704
+ this.isEmpty = true;
705
+ this.isMouseOverDropdown = false;
706
+ this.$menu = $(o.menu).on("mouseenter.tt", this._handleMouseenter).on("mouseleave.tt", this._handleMouseleave).on("click.tt", ".tt-suggestion", this._handleSelection).on("mouseover.tt", ".tt-suggestion", this._handleMouseover);
678
707
  }
679
708
  utils.mixin(DropdownView.prototype, EventTarget, {
680
709
  _handleMouseenter: function() {
@@ -683,16 +712,24 @@
683
712
  _handleMouseleave: function() {
684
713
  this.isMouseOverDropdown = false;
685
714
  },
686
- _handleMouseover: function(e) {
715
+ _handleMouseover: function($e) {
716
+ var $suggestion = $($e.currentTarget);
687
717
  this._getSuggestions().removeClass("tt-is-under-cursor");
688
- $(e.currentTarget).addClass("tt-is-under-cursor");
718
+ $suggestion.addClass("tt-is-under-cursor");
689
719
  },
690
- _handleSelection: function(e) {
691
- this.trigger("select", formatDataForSuggestion($(e.currentTarget)));
720
+ _handleSelection: function($e) {
721
+ var $suggestion = $($e.currentTarget);
722
+ this.trigger("suggestionSelected", extractSuggestion($suggestion));
723
+ },
724
+ _show: function() {
725
+ this.$menu.css("display", "block");
726
+ },
727
+ _hide: function() {
728
+ this.$menu.hide();
692
729
  },
693
730
  _moveCursor: function(increment) {
694
731
  var $suggestions, $cur, nextIndex, $underCursor;
695
- if (!this.$menu.hasClass("tt-is-open")) {
732
+ if (!this.isVisible()) {
696
733
  return;
697
734
  }
698
735
  $suggestions = this._getSuggestions();
@@ -701,38 +738,53 @@
701
738
  nextIndex = $suggestions.index($cur) + increment;
702
739
  nextIndex = (nextIndex + 1) % ($suggestions.length + 1) - 1;
703
740
  if (nextIndex === -1) {
704
- this.trigger("cursorOff");
741
+ this.trigger("cursorRemoved");
705
742
  return;
706
743
  } else if (nextIndex < -1) {
707
744
  nextIndex = $suggestions.length - 1;
708
745
  }
709
746
  $underCursor = $suggestions.eq(nextIndex).addClass("tt-is-under-cursor");
710
- this.trigger("cursorOn", {
711
- value: $underCursor.data("value")
712
- });
747
+ this.trigger("cursorMoved", extractSuggestion($underCursor));
713
748
  },
714
749
  _getSuggestions: function() {
715
750
  return this.$menu.find(".tt-suggestions > .tt-suggestion");
716
751
  },
717
- hideUnlessMouseIsOverDropdown: function() {
752
+ destroy: function() {
753
+ this.$menu.off(".tt");
754
+ this.$menu = null;
755
+ },
756
+ isVisible: function() {
757
+ return this.isOpen && !this.isEmpty;
758
+ },
759
+ closeUnlessMouseIsOverDropdown: function() {
718
760
  if (!this.isMouseOverDropdown) {
719
- this.hide();
761
+ this.close();
720
762
  }
721
763
  },
722
- hide: function() {
723
- if (this.$menu.hasClass("tt-is-open")) {
724
- this.$menu.removeClass("tt-is-open").find(".tt-suggestions > .tt-suggestion").removeClass("tt-is-under-cursor");
725
- this.trigger("hide");
764
+ close: function() {
765
+ if (this.isOpen) {
766
+ this.isOpen = false;
767
+ this._hide();
768
+ this.$menu.find(".tt-suggestions > .tt-suggestion").removeClass("tt-is-under-cursor");
769
+ this.trigger("closed");
726
770
  }
727
771
  },
728
- show: function() {
729
- if (!this.$menu.hasClass("tt-is-open")) {
730
- this.$menu.addClass("tt-is-open");
731
- this.trigger("show");
772
+ open: function() {
773
+ if (!this.isOpen) {
774
+ this.isOpen = true;
775
+ !this.isEmpty && this._show();
776
+ this.trigger("opened");
732
777
  }
733
778
  },
734
- isOpen: function() {
735
- return this.$menu.hasClass("tt-is-open");
779
+ setLanguageDirection: function(dir) {
780
+ var ltrCss = {
781
+ left: "0",
782
+ right: "auto"
783
+ }, rtlCss = {
784
+ left: "auto",
785
+ right: " 0"
786
+ };
787
+ dir === "ltr" ? this.$menu.css(ltrCss) : this.$menu.css(rtlCss);
736
788
  },
737
789
  moveCursorUp: function() {
738
790
  this._moveCursor(-1);
@@ -742,100 +794,147 @@
742
794
  },
743
795
  getSuggestionUnderCursor: function() {
744
796
  var $suggestion = this._getSuggestions().filter(".tt-is-under-cursor").first();
745
- return $suggestion.length > 0 ? formatDataForSuggestion($suggestion) : null;
797
+ return $suggestion.length > 0 ? extractSuggestion($suggestion) : null;
746
798
  },
747
799
  getFirstSuggestion: function() {
748
800
  var $suggestion = this._getSuggestions().first();
749
- return $suggestion.length > 0 ? formatDataForSuggestion($suggestion) : null;
801
+ return $suggestion.length > 0 ? extractSuggestion($suggestion) : null;
750
802
  },
751
- renderSuggestions: function(query, dataset, suggestions) {
752
- var datasetClassName = "tt-dataset-" + dataset.name, $dataset = this.$menu.find("." + datasetClassName), elBuilder, fragment, el;
803
+ renderSuggestions: function(dataset, suggestions) {
804
+ var datasetClassName = "tt-dataset-" + dataset.name, wrapper = '<div class="tt-suggestion">%body</div>', compiledHtml, $suggestionsList, $dataset = this.$menu.find("." + datasetClassName), elBuilder, fragment, $el;
753
805
  if ($dataset.length === 0) {
754
- $dataset = $('<li><ol class="tt-suggestions"></ol></li>').addClass(datasetClassName).appendTo(this.$menu);
806
+ $suggestionsList = $(html.suggestionsList).css(css.suggestionsList);
807
+ $dataset = $("<div></div>").addClass(datasetClassName).append(dataset.header).append($suggestionsList).append(dataset.footer).appendTo(this.$menu);
755
808
  }
756
- elBuilder = document.createElement("div");
757
- fragment = document.createDocumentFragment();
758
- this.clearSuggestions(dataset.name);
759
809
  if (suggestions.length > 0) {
760
- this.$menu.removeClass("tt-is-empty");
810
+ this.isEmpty = false;
811
+ this.isOpen && this._show();
812
+ elBuilder = document.createElement("div");
813
+ fragment = document.createDocumentFragment();
761
814
  utils.each(suggestions, function(i, suggestion) {
762
- elBuilder.innerHTML = dataset.template.render(suggestion);
763
- el = elBuilder.firstChild;
764
- el.setAttribute("data-value", suggestion.value);
765
- fragment.appendChild(el);
815
+ compiledHtml = dataset.template(suggestion.datum);
816
+ elBuilder.innerHTML = wrapper.replace("%body", compiledHtml);
817
+ $el = $(elBuilder.firstChild).css(css.suggestion).data("suggestion", suggestion);
818
+ $el.children().each(function() {
819
+ $(this).css(css.suggestionChild);
820
+ });
821
+ fragment.appendChild($el[0]);
766
822
  });
823
+ $dataset.show().find(".tt-suggestions").html(fragment);
824
+ } else {
825
+ this.clearSuggestions(dataset.name);
767
826
  }
768
- $dataset.find("> .tt-suggestions").data({
769
- query: query,
770
- dataset: dataset.name
771
- }).append(fragment);
772
- this.trigger("suggestionsRender");
827
+ this.trigger("suggestionsRendered");
773
828
  },
774
829
  clearSuggestions: function(datasetName) {
775
- var $suggestions = datasetName ? this.$menu.find(".tt-dataset-" + datasetName + " .tt-suggestions") : this.$menu.find(".tt-suggestions");
830
+ var $datasets = datasetName ? this.$menu.find(".tt-dataset-" + datasetName) : this.$menu.find('[class^="tt-dataset-"]'), $suggestions = $datasets.find(".tt-suggestions");
831
+ $datasets.hide();
776
832
  $suggestions.empty();
777
- this._getSuggestions().length === 0 && this.$menu.addClass("tt-is-empty");
833
+ if (this._getSuggestions().length === 0) {
834
+ this.isEmpty = true;
835
+ this._hide();
836
+ }
778
837
  }
779
838
  });
780
839
  return DropdownView;
781
- function formatDataForSuggestion($suggestion) {
782
- var $suggestions = $suggestion.parents(".tt-suggestions").first();
783
- return {
784
- value: $suggestion.data("value"),
785
- query: $suggestions.data("query"),
786
- dataset: $suggestions.data("dataset")
787
- };
840
+ function extractSuggestion($el) {
841
+ return $el.data("suggestion");
788
842
  }
789
843
  }();
790
844
  var TypeaheadView = function() {
791
845
  var html = {
792
846
  wrapper: '<span class="twitter-typeahead"></span>',
793
- hint: '<input class="tt-hint" type="text" autocomplete="false" spellcheck="false" disabled>',
794
- dropdown: '<ol class="tt-dropdown-menu tt-is-empty"></ol>'
847
+ hint: '<input class="tt-hint" type="text" autocomplete="off" spellcheck="off" disabled>',
848
+ dropdown: '<span class="tt-dropdown-menu"></span>'
849
+ }, css = {
850
+ wrapper: {
851
+ position: "relative",
852
+ display: "inline-block"
853
+ },
854
+ hint: {
855
+ position: "absolute",
856
+ top: "0",
857
+ left: "0",
858
+ borderColor: "transparent",
859
+ boxShadow: "none"
860
+ },
861
+ query: {
862
+ position: "relative",
863
+ verticalAlign: "top",
864
+ backgroundColor: "transparent"
865
+ },
866
+ dropdown: {
867
+ position: "absolute",
868
+ top: "100%",
869
+ left: "0",
870
+ zIndex: "100",
871
+ display: "none"
872
+ }
795
873
  };
874
+ if (utils.isMsie()) {
875
+ utils.mixin(css.query, {
876
+ backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)"
877
+ });
878
+ }
879
+ if (utils.isMsie() && utils.isMsie() <= 7) {
880
+ utils.mixin(css.wrapper, {
881
+ display: "inline",
882
+ zoom: "1"
883
+ });
884
+ utils.mixin(css.query, {
885
+ marginTop: "-1px"
886
+ });
887
+ }
796
888
  function TypeaheadView(o) {
889
+ var $menu, $input, $hint;
797
890
  utils.bindAll(this);
798
- this.$node = wrapInput(o.input);
891
+ this.$node = buildDomStructure(o.input);
799
892
  this.datasets = o.datasets;
800
- utils.each(this.datasets, function(key, dataset) {
801
- var parentTemplate = '<li class="tt-suggestion">%body</li>';
802
- if (dataset.template) {
803
- dataset.template = dataset.engine.compile(parentTemplate.replace("%body", dataset.template));
804
- } else {
805
- dataset.template = {
806
- render: function(context) {
807
- return parentTemplate.replace("%body", "<p>" + context.value + "</p>");
808
- }
809
- };
810
- }
811
- });
812
- this.inputView = new InputView({
813
- input: this.$node.find(".tt-query"),
814
- hint: this.$node.find(".tt-hint")
815
- });
893
+ this.dir = null;
894
+ this.eventBus = o.eventBus;
895
+ $menu = this.$node.find(".tt-dropdown-menu");
896
+ $input = this.$node.find(".tt-query");
897
+ $hint = this.$node.find(".tt-hint");
816
898
  this.dropdownView = new DropdownView({
817
- menu: this.$node.find(".tt-dropdown-menu")
818
- });
819
- this.dropdownView.on("select", this._handleSelection).on("cursorOn", this._clearHint).on("cursorOn", this._setInputValueToSuggestionUnderCursor).on("cursorOff", this._setInputValueToQuery).on("cursorOff", this._updateHint).on("suggestionsRender", this._updateHint).on("show", this._updateHint).on("hide", this._clearHint);
820
- this.inputView.on("focus", this._showDropdown).on("blur", this._hideDropdown).on("blur", this._setInputValueToQuery).on("enter", this._handleSelection).on("queryChange", this._clearHint).on("queryChange", this._clearSuggestions).on("queryChange", this._getSuggestions).on("whitespaceChange", this._updateHint).on("queryChange whitespaceChange", this._showDropdown).on("queryChange whitespaceChange", this._setLanguageDirection).on("esc", this._hideDropdown).on("esc", this._setInputValueToQuery).on("up down", this._moveDropdownCursor).on("up down", this._showDropdown).on("tab", this._setPreventDefaultValueForTab).on("tab left right", this._autocomplete);
899
+ menu: $menu
900
+ }).on("suggestionSelected", this._handleSelection).on("cursorMoved", this._clearHint).on("cursorMoved", this._setInputValueToSuggestionUnderCursor).on("cursorRemoved", this._setInputValueToQuery).on("cursorRemoved", this._updateHint).on("suggestionsRendered", this._updateHint).on("opened", this._updateHint).on("closed", this._clearHint).on("opened closed", this._propagateEvent);
901
+ this.inputView = new InputView({
902
+ input: $input,
903
+ hint: $hint
904
+ }).on("focused", this._openDropdown).on("blured", this._closeDropdown).on("blured", this._setInputValueToQuery).on("enterKeyed", this._handleSelection).on("queryChanged", this._clearHint).on("queryChanged", this._clearSuggestions).on("queryChanged", this._getSuggestions).on("whitespaceChanged", this._updateHint).on("queryChanged whitespaceChanged", this._openDropdown).on("queryChanged whitespaceChanged", this._setLanguageDirection).on("escKeyed", this._closeDropdown).on("escKeyed", this._setInputValueToQuery).on("tabKeyed upKeyed downKeyed", this._managePreventDefault).on("upKeyed downKeyed", this._moveDropdownCursor).on("upKeyed downKeyed", this._openDropdown).on("tabKeyed leftKeyed rightKeyed", this._autocomplete);
821
905
  }
822
906
  utils.mixin(TypeaheadView.prototype, EventTarget, {
823
- _setPreventDefaultValueForTab: function(e) {
824
- var hint = this.inputView.getHintValue(), inputValue = this.inputView.getInputValue(), preventDefault = hint && hint !== inputValue;
825
- this.inputView.setPreventDefaultValueForKey("9", preventDefault);
907
+ _managePreventDefault: function(e) {
908
+ var $e = e.data, hint, inputValue, preventDefault = false;
909
+ switch (e.type) {
910
+ case "tabKeyed":
911
+ hint = this.inputView.getHintValue();
912
+ inputValue = this.inputView.getInputValue();
913
+ preventDefault = hint && hint !== inputValue;
914
+ break;
915
+
916
+ case "upKeyed":
917
+ case "downKeyed":
918
+ preventDefault = !$e.shiftKey && !$e.ctrlKey && !$e.metaKey;
919
+ break;
920
+ }
921
+ preventDefault && $e.preventDefault();
826
922
  },
827
923
  _setLanguageDirection: function() {
828
- var dirClassName = "tt-" + this.inputView.getLanguageDirection();
829
- if (!this.$node.hasClass(dirClassName)) {
830
- this.$node.removeClass("tt-ltr tt-rtl").addClass(dirClassName);
924
+ var dir = this.inputView.getLanguageDirection();
925
+ if (dir !== this.dir) {
926
+ this.dir = dir;
927
+ this.$node.css("direction", dir);
928
+ this.dropdownView.setLanguageDirection(dir);
831
929
  }
832
930
  },
833
931
  _updateHint: function() {
834
- var dataForFirstSuggestion = this.dropdownView.getFirstSuggestion(), hint = dataForFirstSuggestion ? dataForFirstSuggestion.value : null, inputValue, query, beginsWithQuery, match;
835
- if (hint && this.dropdownView.isOpen()) {
932
+ var suggestion = this.dropdownView.getFirstSuggestion(), hint = suggestion ? suggestion.value : null, dropdownIsVisible = this.dropdownView.isVisible(), inputHasOverflow = this.inputView.isOverflow(), inputValue, query, escapedQuery, beginsWithQuery, match;
933
+ if (hint && dropdownIsVisible && !inputHasOverflow) {
836
934
  inputValue = this.inputView.getInputValue();
837
935
  query = inputValue.replace(/\s{2,}/g, " ").replace(/^\s+/g, "");
838
- beginsWithQuery = new RegExp("^(?:" + query + ")(.*$)", "i");
936
+ escapedQuery = utils.escapeRegExChars(query);
937
+ beginsWithQuery = new RegExp("^(?:" + escapedQuery + ")(.*$)", "i");
839
938
  match = beginsWithQuery.exec(hint);
840
939
  this.inputView.setHintValue(inputValue + (match ? match[1] : ""));
841
940
  }
@@ -850,45 +949,48 @@
850
949
  this.inputView.setInputValue(this.inputView.getQuery());
851
950
  },
852
951
  _setInputValueToSuggestionUnderCursor: function(e) {
853
- this.inputView.setInputValue(e.data.value, true);
952
+ var suggestion = e.data;
953
+ this.inputView.setInputValue(suggestion.value, true);
854
954
  },
855
- _showDropdown: function() {
856
- this.dropdownView.show();
955
+ _openDropdown: function() {
956
+ this.dropdownView.open();
857
957
  },
858
- _hideDropdown: function(e) {
859
- this.dropdownView[e.type === "blur" ? "hideUnlessMouseIsOverDropdown" : "hide"]();
958
+ _closeDropdown: function(e) {
959
+ this.dropdownView[e.type === "blured" ? "closeUnlessMouseIsOverDropdown" : "close"]();
860
960
  },
861
961
  _moveDropdownCursor: function(e) {
862
- this.dropdownView[e.type === "up" ? "moveCursorUp" : "moveCursorDown"]();
962
+ var $e = e.data;
963
+ if (!$e.shiftKey && !$e.ctrlKey && !$e.metaKey) {
964
+ this.dropdownView[e.type === "upKeyed" ? "moveCursorUp" : "moveCursorDown"]();
965
+ }
863
966
  },
864
967
  _handleSelection: function(e) {
865
- var byClick = e.type === "select", suggestionData = byClick ? e.data : this.dropdownView.getSuggestionUnderCursor();
866
- if (suggestionData) {
867
- this.inputView.setInputValue(suggestionData.value);
968
+ var byClick = e.type === "suggestionSelected", suggestion = byClick ? e.data : this.dropdownView.getSuggestionUnderCursor();
969
+ if (suggestion) {
970
+ this.inputView.setInputValue(suggestion.value);
868
971
  byClick ? this.inputView.focus() : e.data.preventDefault();
869
- byClick && utils.isMsie() ? setTimeout(this.dropdownView.hide, 0) : this.dropdownView.hide();
972
+ byClick && utils.isMsie() ? utils.defer(this.dropdownView.close) : this.dropdownView.close();
973
+ this.eventBus.trigger("selected", suggestion.datum);
870
974
  }
871
975
  },
872
976
  _getSuggestions: function() {
873
977
  var that = this, query = this.inputView.getQuery();
978
+ if (utils.isBlankString(query)) {
979
+ return;
980
+ }
874
981
  utils.each(this.datasets, function(i, dataset) {
875
982
  dataset.getSuggestions(query, function(suggestions) {
876
- that._renderSuggestions(query, dataset, suggestions);
983
+ if (query === that.inputView.getQuery()) {
984
+ that.dropdownView.renderSuggestions(dataset, suggestions);
985
+ }
877
986
  });
878
987
  });
879
988
  },
880
- _renderSuggestions: function(query, dataset, suggestions) {
881
- if (query !== this.inputView.getQuery()) {
882
- return;
883
- }
884
- suggestions = suggestions.slice(0, dataset.limit);
885
- this.dropdownView.renderSuggestions(query, dataset, suggestions);
886
- },
887
989
  _autocomplete: function(e) {
888
- var isCursorAtEnd, ignoreEvent, query, hint;
889
- if (e.type === "right" || e.type === "left") {
990
+ var isCursorAtEnd, ignoreEvent, query, hint, suggestion;
991
+ if (e.type === "rightKeyed" || e.type === "leftKeyed") {
890
992
  isCursorAtEnd = this.inputView.isCursorAtEnd();
891
- ignoreEvent = this.inputView.getLanguageDirection() === "ltr" ? e.type === "left" : e.type === "right";
993
+ ignoreEvent = this.inputView.getLanguageDirection() === "ltr" ? e.type === "leftKeyed" : e.type === "rightKeyed";
892
994
  if (!isCursorAtEnd || ignoreEvent) {
893
995
  return;
894
996
  }
@@ -896,89 +998,129 @@
896
998
  query = this.inputView.getQuery();
897
999
  hint = this.inputView.getHintValue();
898
1000
  if (hint !== "" && query !== hint) {
899
- this.inputView.setInputValue(hint);
1001
+ suggestion = this.dropdownView.getFirstSuggestion();
1002
+ this.inputView.setInputValue(suggestion.value);
1003
+ this.eventBus.trigger("autocompleted", suggestion.datum);
900
1004
  }
1005
+ },
1006
+ _propagateEvent: function(e) {
1007
+ this.eventBus.trigger(e.type);
1008
+ },
1009
+ destroy: function() {
1010
+ this.inputView.destroy();
1011
+ this.dropdownView.destroy();
1012
+ destroyDomStructure(this.$node);
1013
+ this.$node = null;
1014
+ },
1015
+ setQuery: function(query) {
1016
+ this.inputView.setQuery(query);
1017
+ this.inputView.setInputValue(query);
1018
+ this._clearHint();
1019
+ this._clearSuggestions();
1020
+ this._getSuggestions();
901
1021
  }
902
1022
  });
903
1023
  return TypeaheadView;
904
- function wrapInput(input) {
905
- var $input = $(input), $hint = $(html.hint).css({
906
- "background-color": $input.css("background-color")
1024
+ function buildDomStructure(input) {
1025
+ var $wrapper = $(html.wrapper), $dropdown = $(html.dropdown), $input = $(input), $hint = $(html.hint);
1026
+ $wrapper = $wrapper.css(css.wrapper);
1027
+ $dropdown = $dropdown.css(css.dropdown);
1028
+ $hint.css(css.hint).css({
1029
+ backgroundAttachment: $input.css("background-attachment"),
1030
+ backgroundClip: $input.css("background-clip"),
1031
+ backgroundColor: $input.css("background-color"),
1032
+ backgroundImage: $input.css("background-image"),
1033
+ backgroundOrigin: $input.css("background-origin"),
1034
+ backgroundPosition: $input.css("background-position"),
1035
+ backgroundRepeat: $input.css("background-repeat"),
1036
+ backgroundSize: $input.css("background-size")
907
1037
  });
908
- if ($input.length === 0) {
909
- return null;
910
- }
1038
+ $input.data("ttAttrs", {
1039
+ dir: $input.attr("dir"),
1040
+ autocomplete: $input.attr("autocomplete"),
1041
+ spellcheck: $input.attr("spellcheck"),
1042
+ style: $input.attr("style")
1043
+ });
1044
+ $input.addClass("tt-query").attr({
1045
+ autocomplete: "off",
1046
+ spellcheck: false
1047
+ }).css(css.query);
911
1048
  try {
912
1049
  !$input.attr("dir") && $input.attr("dir", "auto");
913
1050
  } catch (e) {}
914
- return $input.attr({
915
- autocomplete: false,
916
- spellcheck: false
917
- }).addClass("tt-query").wrap(html.wrapper).parent().prepend($hint).append(html.dropdown);
1051
+ return $input.wrap($wrapper).parent().prepend($hint).append($dropdown);
1052
+ }
1053
+ function destroyDomStructure($node) {
1054
+ var $input = $node.find(".tt-query");
1055
+ utils.each($input.data("ttAttrs"), function(key, val) {
1056
+ utils.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val);
1057
+ });
1058
+ $input.detach().removeData("ttAttrs").removeClass("tt-query").insertAfter($node);
1059
+ $node.remove();
918
1060
  }
919
1061
  }();
920
1062
  (function() {
921
- var initializedDatasets = {}, transportOptions = {}, transport, methods;
922
- jQuery.fn.typeahead = typeahead;
923
- typeahead.configureTransport = configureTransport;
1063
+ var cache = {}, viewKey = "ttView", methods;
924
1064
  methods = {
925
1065
  initialize: function(datasetDefs) {
926
- var datasets = {};
1066
+ var datasets;
927
1067
  datasetDefs = utils.isArray(datasetDefs) ? datasetDefs : [ datasetDefs ];
928
1068
  if (datasetDefs.length === 0) {
929
- throw new Error("no datasets provided");
930
- }
931
- delete typeahead.configureTransport;
932
- transport = transport || new Transport(transportOptions);
933
- utils.each(datasetDefs, function(i, datasetDef) {
934
- var dataset, name = datasetDef.name = datasetDef.name || utils.getUniqueId();
935
- if (initializedDatasets[name]) {
936
- dataset = initializedDatasets[name];
937
- } else {
938
- datasetDef.limit = datasetDef.limit || 5;
939
- datasetDef.template = datasetDef.template;
940
- datasetDef.engine = datasetDef.engine;
941
- if (datasetDef.template && !datasetDef.engine) {
942
- throw new Error("no template engine specified for " + name);
943
- }
944
- dataset = initializedDatasets[name] = new Dataset({
945
- name: datasetDef.name,
946
- limit: datasetDef.limit,
947
- local: datasetDef.local,
948
- prefetch: datasetDef.prefetch,
949
- remote: datasetDef.remote,
950
- matcher: datasetDef.matcher,
951
- ranker: datasetDef.ranker,
952
- transport: transport
953
- });
1069
+ $.error("no datasets provided");
1070
+ }
1071
+ datasets = utils.map(datasetDefs, function(o) {
1072
+ var dataset = cache[o.name] ? cache[o.name] : new Dataset(o);
1073
+ if (o.name) {
1074
+ cache[o.name] = dataset;
954
1075
  }
955
- datasets[name] = {
956
- name: datasetDef.name,
957
- limit: datasetDef.limit,
958
- template: datasetDef.template,
959
- engine: datasetDef.engine,
960
- getSuggestions: dataset.getSuggestions
961
- };
1076
+ return dataset;
962
1077
  });
963
- return this.each(function() {
964
- $(this).data({
965
- typeahead: new TypeaheadView({
966
- input: this,
967
- datasets: datasets
968
- })
1078
+ return this.each(initialize);
1079
+ function initialize() {
1080
+ var $input = $(this), deferreds, eventBus = new EventBus({
1081
+ el: $input
969
1082
  });
970
- });
1083
+ deferreds = utils.map(datasets, function(dataset) {
1084
+ return dataset.initialize();
1085
+ });
1086
+ $input.data(viewKey, new TypeaheadView({
1087
+ input: $input,
1088
+ eventBus: eventBus = new EventBus({
1089
+ el: $input
1090
+ }),
1091
+ datasets: datasets
1092
+ }));
1093
+ $.when.apply($, deferreds).always(function() {
1094
+ utils.defer(function() {
1095
+ eventBus.trigger("initialized");
1096
+ });
1097
+ });
1098
+ }
1099
+ },
1100
+ destroy: function() {
1101
+ return this.each(destroy);
1102
+ function destroy() {
1103
+ var $this = $(this), view = $this.data(viewKey);
1104
+ if (view) {
1105
+ view.destroy();
1106
+ $this.removeData(viewKey);
1107
+ }
1108
+ }
1109
+ },
1110
+ setQuery: function(query) {
1111
+ return this.each(setQuery);
1112
+ function setQuery() {
1113
+ var view = $(this).data(viewKey);
1114
+ view && view.setQuery(query);
1115
+ }
971
1116
  }
972
1117
  };
973
- function typeahead(method) {
1118
+ jQuery.fn.typeahead = function(method) {
974
1119
  if (methods[method]) {
975
- methods[method].apply(this, [].slice.call(arguments, 1));
1120
+ return methods[method].apply(this, [].slice.call(arguments, 1));
976
1121
  } else {
977
- methods.initialize.apply(this, arguments);
1122
+ return methods.initialize.apply(this, arguments);
978
1123
  }
979
- }
980
- function configureTransport(o) {
981
- transportOptions = o;
982
- }
1124
+ };
983
1125
  })();
984
- })();
1126
+ })(window.jQuery);