twitter-typeahead-rails 0.8.0 → 0.9.2

Sign up to get free protection for your applications and to get access to all the features.
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);