[Pkg-javascript-commits] [typeahead.js] 01/02: Imported Upstream version 0.11.1~dfsg1

Alexandre Viau aviau at moszumanska.debian.org
Wed Dec 30 01:59:02 UTC 2015


This is an automated email from the git hooks/post-receive script.

aviau pushed a commit to branch master
in repository typeahead.js.

commit ec6ce16711fa87d3efe7edb0143bf5df9b55475d
Author: aviau <alexandre at alexandreviau.net>
Date:   Tue Dec 29 20:35:45 2015 -0500

    Imported Upstream version 0.11.1~dfsg1
---
 .gitignore                                 |   16 +
 .jshintrc                                  |   16 +
 .travis.yml                                |   29 +
 CHANGELOG.md                               |  249 +++++
 CONTRIBUTING.md                            |  120 +++
 Gruntfile.js                               |  330 +++++++
 LICENSE                                    |   19 +
 README.md                                  |  188 ++++
 bower.json                                 |   13 +
 composer.json                              |   17 +
 doc/bloodhound.md                          |  277 ++++++
 doc/jquery_typeahead.md                    |  288 ++++++
 doc/migration/0.10.0.md                    |  234 +++++
 karma.conf.js                              |   50 +
 package.json                               |   68 ++
 src/bloodhound/bloodhound.js               |  192 ++++
 src/bloodhound/lru_cache.js                |  101 ++
 src/bloodhound/options_parser.js           |  195 ++++
 src/bloodhound/persistent_storage.js       |  147 +++
 src/bloodhound/prefetch.js                 |   91 ++
 src/bloodhound/remote.js                   |   58 ++
 src/bloodhound/search_index.js             |  191 ++++
 src/bloodhound/tokenizers.js               |   44 +
 src/bloodhound/transport.js                |  130 +++
 src/bloodhound/version.js                  |    7 +
 src/common/utils.js                        |  164 ++++
 src/typeahead/dataset.js                   |  330 +++++++
 src/typeahead/default_menu.js              |   75 ++
 src/typeahead/event_bus.js                 |   78 ++
 src/typeahead/event_emitter.js             |  119 +++
 src/typeahead/highlight.js                 |   84 ++
 src/typeahead/input.js                     |  339 +++++++
 src/typeahead/menu.js                      |  217 +++++
 src/typeahead/plugin.js                    |  291 ++++++
 src/typeahead/typeahead.js                 |  438 +++++++++
 src/typeahead/www.js                       |  113 +++
 test/bloodhound/bloodhound_spec.js         |  299 ++++++
 test/bloodhound/lru_cache_spec.js          |   43 +
 test/bloodhound/options_parser_spec.js     |  194 ++++
 test/bloodhound/persistent_storage_spec.js |  194 ++++
 test/bloodhound/prefetch_spec.js           |  182 ++++
 test/bloodhound/remote_spec.js             |   73 ++
 test/bloodhound/search_index_spec.js       |   72 ++
 test/bloodhound/tokenizers_spec.js         |   74 ++
 test/bloodhound/transport_spec.js          |  175 ++++
 test/ci                                    |   12 +
 test/fixtures/ajax_responses.js            |   19 +
 test/fixtures/data.js                      |  128 +++
 test/fixtures/html.js                      |   13 +
 test/helpers/typeahead_mocks.js            |   78 ++
 test/integration/test.html                 |  108 +++
 test/integration/test.js                   |  395 ++++++++
 test/playground.html                       |  346 +++++++
 test/typeahead/dataset_spec.js             |  469 +++++++++
 test/typeahead/default_results_spec.js     |  103 ++
 test/typeahead/event_bus_spec.js           |   42 +
 test/typeahead/event_emitter_spec.js       |  111 +++
 test/typeahead/highlight_spec.js           |  117 +++
 test/typeahead/input_spec.js               |  538 +++++++++++
 test/typeahead/plugin_spec.js              |  197 ++++
 test/typeahead/results_spec.js             |  332 +++++++
 test/typeahead/typeahead_spec.js           | 1412 ++++++++++++++++++++++++++++
 typeahead.js.jquery.json                   |   40 +
 63 files changed, 11084 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c415a06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+*.swp
+.DS_Store
+
+.grunt
+_SpecRunner.html
+test/coverage
+
+dist_temp
+
+node_modules
+npm-debug.log
+
+bower_components
+
+*.iml
+.idea
diff --git a/.jshintrc b/.jshintrc
new file mode 100644
index 0000000..d14cf74
--- /dev/null
+++ b/.jshintrc
@@ -0,0 +1,16 @@
+{
+  "curly": true,
+  "newcap": true,
+  "noarg": true,
+  "quotmark": "single",
+  "regexp": true,
+  "trailing": true,
+
+  "boss": true,
+  "eqnull": true,
+  "expr": true,
+  "validthis": true,
+  
+  "browser": true,
+  "jquery": true
+}
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..626ffac
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,29 @@
+language: node_js
+env: 
+  matrix: 
+  - TEST_SUITE=unit
+  - TEST_SUITE=integration BROWSER='firefox'
+  - TEST_SUITE=integration BROWSER='firefox:3.5'
+  - TEST_SUITE=integration BROWSER='firefox:3.6'
+  - TEST_SUITE=integration BROWSER='safari:5'
+  - TEST_SUITE=integration BROWSER='safari:6'
+  - TEST_SUITE=integration BROWSER='safari:7'
+  - TEST_SUITE=integration BROWSER='internet explorer:8'
+  - TEST_SUITE=integration BROWSER='internet explorer:9'
+  - TEST_SUITE=integration BROWSER='internet explorer:10'
+  - TEST_SUITE=integration BROWSER='internet explorer:11'
+  - TEST_SUITE=integration BROWSER='chrome'
+  global: 
+  - secure: VY4J2ERfrMEin++f4+UDDtTMWLuE3jaYAVchRxfO2c6PQUYgR+SW4SMekz855U/BuptMtiVMR2UUoNGMgOSKIFkIXpPfHhx47G5a541v0WNjXfQ2qzivXAWaXNK3l3C58z4dKxgPWsFY9JtMVCddJd2vQieAILto8D8G09p7bpo=
+  - secure: kehbNCoYUG2gLnhmCH/oKhlJG6LoxgcOPMCtY7KOI4ropG8qlypb+O2b/19+BWeO3aIuMB0JajNh3p2NL0UKgLmUK7EYBA9fQz+vesFReRk0V/KqMTSxHJuseM4aLOWA2Wr9US843VGltfODVvDN5sNrfY7RcoRx2cTK/k1CXa8=
+node_js: 
+- 0.11.13
+before_script: 
+- npm install -g grunt-cli at 0.1.13
+- npm install -g node-static at 0.7.3
+- npm install -g bower at 1.3.8
+- bower install
+- grunt build
+script: test/ci
+addons:
+  sauce_connect: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..2e7e7b8
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,249 @@
+Changelog
+=========
+
+For transparency and insight into our release cycle, releases will be numbered 
+with the follow format:
+
+`<major>.<minor>.<patch>`
+
+And constructed with the following guidelines:
+
+* Breaking backwards compatibility bumps the major
+* New additions without breaking backwards compatibility bumps the minor
+* Bug fixes and misc changes bump the patch
+
+For more information on semantic versioning, please visit http://semver.org/.
+
+---
+
+### 0.11.1 April 26, 2015
+
+* Add prepare option to prefetch. [#1181]
+* Handle QuotaExceededError. [#1110]
+* Escape HTML entities from suggestion display value when rendering with default
+  template. [#964]
+* List jquery as a dependency in package.json. [#1143]
+
+### 0.11.0 April 25, 2015
+
+An overhaul of typeahead.js – consider this a release candidate for v1. There
+are bunch of API changes with this release so don't expect backwards 
+compatibility with previous versions. There are also many new undocumented 
+features that have been introduced. Documentation for those features will be 
+added before v1 ships.
+
+Beware that since this release is pretty much a rewrite, there are bound to be
+some bugs. To be safe, you should consider this release beta software and 
+throughly test your integration of it before using it in production 
+environments. This caveat only applies to this release as subsequent releases
+will address any issues that come up.
+
+### 0.10.5 August 7, 2014
+
+* Increase supported version range for jQuery dependency. [#917]
+
+### 0.10.4 July 13, 2014
+
+**Hotfix**
+
+* Fix regression that breaks Bloodhound instances when more than 1 instance is
+  relying on remote data. [#899]
+
+### 0.10.3 July 10, 2014
+
+**Bug fixes**
+
+* `Bloodhound#clearPrefetchCache` now works with cache keys that contain regex 
+  characters. [#771]
+* Prevent outdated network requests from being sent. [#809]
+* Add support to object tokenizers for multiple property tokenization. [#811]
+* Fix broken `jQuery#typeahead('val')` method. [#815]
+* Remove `disabled` attribute from the hint input control. [#839]
+* Add `tt-highlight` class to highlighted text. [#833]
+* Handle non-string types that are passed to `jQuery#typeahead('val', val)`. [#881]
+
+### 0.10.2 March 10, 2014
+
+* Prevent flickering of dropdown menu when requesting remote suggestions. [#718]
+* Reduce hint flickering. [#754]
+* Added `Bloodhound#{clear, clearPrefetchCache, clearRemoteCache}` and made it
+  possible to reinitialize Bloodhound instances. [#703]
+* Invoke `local` function during initialization. [#687]
+* In addition to HTML strings, templates can now return DOM nodes. [#742]
+* Prevent `jQuery#typeahead('val', val)` from opening dropdown menus of 
+  non-active typeaheads. [#646]
+* Fix bug in IE that resulted in dropdown menus with overflow being closed
+  when clicking on the scrollbar. [#705]
+* Only show dropdown menu if `minLength` is satisfied. [#710]
+
+### 0.10.1 February 9, 2014
+
+**Hotfix**
+
+* Fixed bug that prevented some ajax configs from being respected. [#630]
+* Event delegation on suggestion clicks is no longer broken. [#118]
+* Ensure dataset names are valid class name suffixes. [#610]
+* Added support for `displayKey` to be a function. [#633]
+* `jQuery#typeahead('val')` now mirrors `jQuery#val()`. [#659]
+* Datasets can now be passed to jQuery plugin as an array. [#664]
+* Added a `noConflict` method to the jQuery plugin. [#612]
+* Bloodhound's `local` property can now be a function. [#485]
+
+### 0.10.0 February 2, 2014
+
+**Introducting Bloodhound**
+
+This release was almost a complete rewrite of typeahead.js and will hopefully
+lay the foundation for the 1.0.0 release. It's impossible to enumerate all of 
+the issues that were fixed. If you want to get an idea of what issues 0.10.0 
+resolved, take a look at the closed issues in the [0.10.0 milestone].
+
+The most important change in 0.10.0 is that typeahead.js was broken up into 2 
+individual components: Bloodhound and jQuery#typeahead. Bloodhound is an 
+feature-rich suggestion engine. jQuery#typeahead is a jQuery plugin that turns
+input controls into typeaheads.
+
+It's impossible to write a typeahead library that supports every use-case out 
+of the box – that was the main motivation behind decomposing typeahead.js. 
+Previously, some prospective typeahead.js users were unable to use the library 
+because either the suggestion engine or the typeahead UI did not meet their
+requirements. In those cases, they were either forced to fork typeahead.js and
+make the necessary modifications or they had to give up on using typeahead.js
+entirely. Now they have the option of swapping out the component that doesn't 
+work for them with a custom implementation.
+
+### 0.9.3 June 24, 2013
+
+* Ensure cursor visibility in menus with overflow. [#209]
+* Fixed bug that led to the menu staying open when it should have been closed. [#260]
+* Private browsing in Safari no longer breaks prefetch. [#270]
+* Pressing tab while a suggestion is highlighted now results in a selection. [#266]
+* Dataset name is now passed as an argument for typeahead:selected event. [#207]
+
+### 0.9.2 April 14, 2013
+
+* Prefetch usage no longer breaks when cookies are disabled. [#190]
+* Precompiled templates are now wrapped in the appropriate DOM element. [#172]
+
+### 0.9.1 April 1, 2013
+
+* Multiple requests no longer get sent for a query when datasets share a remote source. [#152]
+* Datasets now support precompiled templates. [#137]
+* Cached remote suggestions now get rendered immediately. [#156]
+* Added typeahead:autocompleted event. [#132]
+* Added a plugin method for programmatically setting the query. Experimental. [#159]
+* Added minLength option for datasets. Experimental. [#131]
+* Prefetch objects now support thumbprint option. Experimental. [#157]
+
+### 0.9.0 March 24, 2013
+
+**Custom events, no more typeahead.css, and an improved API**
+
+* Implemented the triggering of custom events. [#106]
+* Got rid of typeahead.css and now apply styling through JavaScript. [#15]
+* Made the API more flexible and addressed a handful of remote issues by rewriting the transport component. [#25]
+* Added support for dataset headers and footers. [#81]
+* No longer cache unnamed datasets. [#116]
+* Made the key name of the value property configurable. [#115]
+* Input values set before initialization of typeaheads are now respected. [#109]
+* Fixed an input value/hint casing bug. [#108]
+
+### 0.8.2 March 04, 2013
+
+* Fixed bug causing error to be thrown when initializing a typeahead on multiple elements. [#51]
+* Tokens with falsy values are now filtered out – was causing wonky behavior. [#75]
+* No longer making remote requests for blank queries. [#74]
+* Datums with regex characters in their value no longer cause errors. [#77]
+* Now compatible with the Closure Compiler. [#48]
+* Reference to jQuery is now obtained through window.jQuery, not window.$. [#47]
+* Added a plugin method for destroying typeaheads. Won't be documented until v0.9 and might change before then. [#59]
+
+### 0.8.1 February 25, 2013
+
+* Fixed bug preventing local and prefetch from being used together. [#39]
+* No longer prevent default browser behavior when up or down arrow is pressed with a modifier. [#6]
+* Hint is hidden when user entered query is wider than the input. [#26]
+* Data stored in localStorage now expires properly. [#34]
+* Normalized search tokens and fixed query tokenization. [#38]
+* Remote suggestions now are appended, not prepended to suggestions list. [#40]
+* Fixed some typos through the codebase. [#3]
+
+### 0.8.0 February 19, 2013
+
+**Initial public release**
+
+* Prefetch and search data locally insanely fast.
+* Search hard-coded, prefetched, and/or remote data.
+* Hinting.
+* RTL/IME/international support.
+* Search multiple datasets.
+* Share datasets (and caching) between multiple inputs.
+* And much, much more...
+
+[0.10.0 milestone]: https://github.com/twitter/typeahead.js/issues?milestone=8&page=1&state=closed
+
+[#1181]: https://github.com/twitter/typeahead.js/pull/1181
+[#1143]: https://github.com/twitter/typeahead.js/pull/1143
+[#1110]: https://github.com/twitter/typeahead.js/pull/1110
+[#964]: https://github.com/twitter/typeahead.js/pull/964
+[#917]: https://github.com/twitter/typeahead.js/pull/917
+[#899]: https://github.com/twitter/typeahead.js/pull/899
+[#881]: https://github.com/twitter/typeahead.js/pull/881
+[#839]: https://github.com/twitter/typeahead.js/pull/839
+[#833]: https://github.com/twitter/typeahead.js/pull/833
+[#815]: https://github.com/twitter/typeahead.js/pull/815
+[#811]: https://github.com/twitter/typeahead.js/pull/811
+[#809]: https://github.com/twitter/typeahead.js/pull/809
+[#771]: https://github.com/twitter/typeahead.js/pull/771
+[#754]: https://github.com/twitter/typeahead.js/pull/754
+[#742]: https://github.com/twitter/typeahead.js/pull/742
+[#718]: https://github.com/twitter/typeahead.js/pull/718
+[#710]: https://github.com/twitter/typeahead.js/pull/710
+[#705]: https://github.com/twitter/typeahead.js/pull/705
+[#703]: https://github.com/twitter/typeahead.js/pull/703
+[#687]: https://github.com/twitter/typeahead.js/pull/687
+[#664]: https://github.com/twitter/typeahead.js/pull/664
+[#659]: https://github.com/twitter/typeahead.js/pull/659
+[#646]: https://github.com/twitter/typeahead.js/pull/646
+[#633]: https://github.com/twitter/typeahead.js/pull/633
+[#630]: https://github.com/twitter/typeahead.js/pull/630
+[#612]: https://github.com/twitter/typeahead.js/pull/612
+[#610]: https://github.com/twitter/typeahead.js/pull/610
+[#485]: https://github.com/twitter/typeahead.js/pull/485
+[#270]: https://github.com/twitter/typeahead.js/pull/270
+[#266]: https://github.com/twitter/typeahead.js/pull/266
+[#260]: https://github.com/twitter/typeahead.js/pull/260
+[#209]: https://github.com/twitter/typeahead.js/pull/209
+[#207]: https://github.com/twitter/typeahead.js/pull/207
+[#190]: https://github.com/twitter/typeahead.js/pull/190
+[#172]: https://github.com/twitter/typeahead.js/pull/172
+[#159]: https://github.com/twitter/typeahead.js/pull/159
+[#157]: https://github.com/twitter/typeahead.js/pull/157
+[#156]: https://github.com/twitter/typeahead.js/pull/156
+[#152]: https://github.com/twitter/typeahead.js/pull/152
+[#137]: https://github.com/twitter/typeahead.js/pull/137
+[#132]: https://github.com/twitter/typeahead.js/pull/132
+[#131]: https://github.com/twitter/typeahead.js/pull/131
+[#118]: https://github.com/twitter/typeahead.js/pull/118
+[#116]: https://github.com/twitter/typeahead.js/pull/116
+[#115]: https://github.com/twitter/typeahead.js/pull/115
+[#109]: https://github.com/twitter/typeahead.js/pull/109
+[#108]: https://github.com/twitter/typeahead.js/pull/108
+[#106]: https://github.com/twitter/typeahead.js/pull/106
+[#81]: https://github.com/twitter/typeahead.js/pull/81
+[#77]: https://github.com/twitter/typeahead.js/pull/77
+[#75]: https://github.com/twitter/typeahead.js/pull/75
+[#74]: https://github.com/twitter/typeahead.js/pull/74
+[#59]: https://github.com/twitter/typeahead.js/pull/59
+[#51]: https://github.com/twitter/typeahead.js/pull/51
+[#48]: https://github.com/twitter/typeahead.js/pull/48
+[#47]: https://github.com/twitter/typeahead.js/pull/47
+[#40]: https://github.com/twitter/typeahead.js/pull/40
+[#39]: https://github.com/twitter/typeahead.js/pull/39
+[#38]: https://github.com/twitter/typeahead.js/pull/38
+[#34]: https://github.com/twitter/typeahead.js/pull/34
+[#26]: https://github.com/twitter/typeahead.js/pull/26
+[#25]: https://github.com/twitter/typeahead.js/pull/25
+[#15]: https://github.com/twitter/typeahead.js/pull/15
+[#6]: https://github.com/twitter/typeahead.js/pull/6
+[#3]: https://github.com/twitter/typeahead.js/pull/3
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bad2614
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,120 @@
+Contributing to typeahead.js
+============================
+
+*These contributing guidelines were proudly stolen from the 
+[Flight](https://github.com/flightjs/flight) project*
+
+Looking to contribute something to typeahead.js? Here's how you can help.
+
+Bugs Reports
+------------
+
+A bug is a _demonstrable problem_ that is caused by the code in the
+repository. Good bug reports are extremely helpful – thank you!
+
+Guidelines for bug reports:
+
+1. **Use the GitHub issue search** — check if the issue has already been
+   reported.
+
+2. **Check if the issue has been fixed** — try to reproduce it using the
+   latest `master` or integration branch in the repository.
+
+3. **Isolate the problem** — ideally create a reduced test
+   case and a live example.
+
+4. Please try to be as detailed as possible in your report. Include specific
+   information about the environment – operating system and version, browser
+   and version, version of typeahead.js – and steps required to reproduce the 
+  issue.
+
+Feature Requests & Contribution Enquiries
+-----------------------------------------
+
+Feature requests are welcome. But take a moment to find out whether your idea
+fits with the scope and aims of the project. It's up to *you* to make a strong
+case for the inclusion of your feature. Please provide as much detail and
+context as possible.
+
+Contribution enquiries should take place before any significant pull request,
+otherwise you risk spending a lot of time working on something that we might
+have good reasons for rejecting.
+
+Pull Requests
+-------------
+
+Good pull requests – patches, improvements, new features – are a fantastic
+help. They should remain focused in scope and avoid containing unrelated
+commits.
+
+Make sure to adhere to the coding conventions used throughout the codebase
+(indentation, accurate comments, etc.) and any other requirements (such as test
+coverage).
+
+Please follow this process; it's the best way to get your work included in the
+project:
+
+1. [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork,
+   and configure the remotes:
+
+   ```bash
+   # Clone your fork of the repo into the current directory
+   git clone https://github.com/<your-username>/typeahead.js
+   # Navigate to the newly cloned directory
+   cd <repo-name>
+   # Assign the original repo to a remote called "upstream"
+   git remote add upstream git://github.com/twitter/typeahead.js
+   ```
+
+2. If you cloned a while ago, get the latest changes from upstream:
+
+   ```bash
+   git checkout master
+   git pull upstream master
+   ```
+
+3. Install the dependencies (you must have Node.js and [Bower](http://bower.io)
+   installed), and create a new topic branch (off the main project development
+   branch) to contain your feature, change, or fix:
+
+   ```bash
+   npm install
+   bower install
+   git checkout -b <topic-branch-name>
+   ```
+
+4. Make sure to update, or add to the tests when appropriate. Patches and
+   features will not be accepted without tests. Run `npm test` to check that
+   all tests pass after you've made changes.
+
+5. Commit your changes in logical chunks. Provide clear and explanatory commit
+   messages. Use Git's [interactive rebase](https://help.github.com/articles/interactive-rebase) feature to tidy up
+   your commits before making them public.
+
+6. Locally merge (or rebase) the upstream development branch into your topic branch:
+
+   ```bash
+   git pull [--rebase] upstream master
+   ```
+
+7. Push your topic branch up to your fork:
+
+   ```bash
+   git push origin <topic-branch-name>
+   ```
+
+8. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/)
+    with a clear title and description.
+
+9. If you are asked to amend your changes before they can be merged in, please
+   use `git commit --amend` (or rebasing for multi-commit Pull Requests) and
+   force push to your remote feature branch. You may also be asked to squash
+   commits.
+
+License
+-------
+
+By contributing your code,
+
+You agree to license your contribution under the terms of the MIT License
+https://github.com/twitter/typeahead.js/blob/master/LICENSE
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..f074763
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,330 @@
+var semver = require('semver'),
+    f = require('util').format,
+    files = {
+      common: [
+      'src/common/utils.js'
+      ],
+      bloodhound: [
+      'src/bloodhound/version.js',
+      'src/bloodhound/tokenizers.js',
+      'src/bloodhound/lru_cache.js',
+      'src/bloodhound/persistent_storage.js',
+      'src/bloodhound/transport.js',
+      'src/bloodhound/search_index.js',
+      'src/bloodhound/prefetch.js',
+      'src/bloodhound/remote.js',
+      'src/bloodhound/options_parser.js',
+      'src/bloodhound/bloodhound.js'
+      ],
+      typeahead: [
+      'src/typeahead/www.js',
+      'src/typeahead/event_bus.js',
+      'src/typeahead/event_emitter.js',
+      'src/typeahead/highlight.js',
+      'src/typeahead/input.js',
+      'src/typeahead/dataset.js',
+      'src/typeahead/menu.js',
+      'src/typeahead/default_menu.js',
+      'src/typeahead/typeahead.js',
+      'src/typeahead/plugin.js'
+      ]
+    };
+
+module.exports = function(grunt) {
+  grunt.initConfig({
+    version: grunt.file.readJSON('package.json').version,
+
+    tempDir: 'dist_temp',
+    buildDir: 'dist',
+
+    banner: [
+      '/*!',
+      ' * typeahead.js <%= version %>',
+      ' * https://github.com/twitter/typeahead.js',
+      ' * Copyright 2013-<%= grunt.template.today("yyyy") %> Twitter, Inc. and other contributors; Licensed MIT',
+      ' */\n\n'
+    ].join('\n'),
+
+    uglify: {
+      options: {
+        banner: '<%= banner %>'
+      },
+
+      concatBloodhound: {
+        options: {
+          mangle: false,
+          beautify: true,
+          compress: false,
+          banner: ''
+        },
+        src: files.common.concat(files.bloodhound),
+        dest: '<%= tempDir %>/bloodhound.js'
+      },
+      concatTypeahead: {
+        options: {
+          mangle: false,
+          beautify: true,
+          compress: false,
+          banner: ''
+        },
+        src: files.common.concat(files.typeahead),
+        dest: '<%= tempDir %>/typeahead.jquery.js'
+      },
+
+      bloodhound: {
+        options: {
+          mangle: false,
+          beautify: true,
+          compress: false
+        },
+        src: '<%= tempDir %>/bloodhound.js',
+        dest: '<%= buildDir %>/bloodhound.js'
+      },
+      bloodhoundMin: {
+        options: {
+          mangle: true,
+          compress: {}
+        },
+        src: '<%= tempDir %>/bloodhound.js',
+        dest: '<%= buildDir %>/bloodhound.min.js'
+      },
+      typeahead: {
+        options: {
+          mangle: false,
+          beautify: true,
+          compress: false
+        },
+        src: '<%= tempDir %>/typeahead.jquery.js',
+        dest: '<%= buildDir %>/typeahead.jquery.js'
+      },
+      typeaheadMin: {
+        options: {
+          mangle: true,
+          compress: {}
+        },
+        src: '<%= tempDir %>/typeahead.jquery.js',
+        dest: '<%= buildDir %>/typeahead.jquery.min.js'
+      },
+      bundle: {
+        options: {
+          mangle: false,
+          beautify: true,
+          compress: false
+        },
+        src: [
+          '<%= tempDir %>/bloodhound.js',
+          '<%= tempDir %>/typeahead.jquery.js'
+        ],
+        dest: '<%= buildDir %>/typeahead.bundle.js'
+
+      },
+      bundleMin: {
+        options: {
+          mangle: true,
+          compress: {}
+        },
+        src: [
+          '<%= tempDir %>/bloodhound.js',
+          '<%= tempDir %>/typeahead.jquery.js'
+        ],
+        dest: '<%= buildDir %>/typeahead.bundle.min.js'
+      }
+    },
+
+    umd: {
+      bloodhound: {
+        src: '<%= tempDir %>/bloodhound.js',
+        objectToExport: 'Bloodhound',
+        amdModuleId: 'bloodhound',
+        deps: {
+          default: ['$'],
+          amd: ['jquery'],
+          cjs: ['jquery'],
+          global: ['jQuery']
+        }
+      },
+      typeahead: {
+        src: '<%= tempDir %>/typeahead.jquery.js',
+        amdModuleId: 'typeahead.js',
+        deps: {
+          default: ['$'],
+          amd: ['jquery'],
+          cjs: ['jquery'],
+          global: ['jQuery']
+        }
+      }
+    },
+
+    sed: {
+      version: {
+        pattern: '%VERSION%',
+        replacement: '<%= version %>',
+        recursive: true,
+        path: '<%= buildDir %>'
+      }
+    },
+
+    jshint: {
+      options: {
+        jshintrc: '.jshintrc'
+      },
+      src: 'src/**/*.js',
+      test: ['test/**/*_spec.js', 'test/integration/test.js'],
+      gruntfile: ['Gruntfile.js']
+    },
+
+    watch: {
+      js: {
+        files: 'src/**/*',
+        tasks: 'build'
+      }
+    },
+
+    exec: {
+      npm_publish: 'npm publish',
+      git_is_clean: 'test -z "$(git status --porcelain)"',
+      git_on_master: 'test $(git symbolic-ref --short -q HEAD) = master',
+      git_add: 'git add .',
+      git_push: 'git push && git push --tags',
+      git_commit: {
+        cmd: function(m) { return f('git commit -m "%s"', m); }
+      },
+      git_tag: {
+        cmd: function(v) { return f('git tag v%s -am "%s"', v, v); }
+      },
+      publish_assets: [
+        'cp -r <%= buildDir %> typeahead.js',
+        'zip -r typeahead.js/typeahead.js.zip typeahead.js',
+        'git checkout gh-pages',
+        'rm -rf releases/latest',
+        'cp -r typeahead.js releases/<%= version %>',
+        'cp -r typeahead.js releases/latest',
+        'git add releases/<%= version %> releases/latest',
+        'sed -E -i "" \'s/v[0-9]+\\.[0-9]+\\.[0-9]+/v<%= version %>/\' index.html',
+        'git add index.html',
+        'git commit -m "Add assets for <%= version %>."',
+        'git push',
+        'git checkout -',
+        'rm -rf typeahead.js'
+      ].join(' && ')
+    },
+
+    clean: {
+      dist: 'dist'
+    },
+
+    connect: {
+      server: {
+        options: { port: 8888, keepalive: true }
+      }
+    },
+
+    concurrent: {
+      options: { logConcurrentOutput: true },
+      dev: ['server', 'watch']
+    },
+
+    step: {
+      options: {
+        option: false
+      }
+    }
+  });
+
+  grunt.registerTask('release', '#shipit', function(version) {
+    var curVersion = grunt.config.get('version');
+
+    version = semver.inc(curVersion, version) || version;
+
+    if (!semver.valid(version) || semver.lte(version, curVersion)) {
+      grunt.fatal('hey dummy, that version is no good!');
+    }
+
+    grunt.config.set('version', version);
+
+    grunt.task.run([
+      'exec:git_on_master',
+      'exec:git_is_clean',
+      f('step:Update to version %s?', version),
+      f('manifests:%s', version),
+      'build',
+      'exec:git_add',
+      f('exec:git_commit:%s', version),
+      f('exec:git_tag:%s', version),
+      'step:Push changes?',
+      'exec:git_push',
+      'step:Publish to npm?',
+      'exec:npm_publish',
+      'step:Publish assets?',
+      'exec:publish_assets'
+    ]);
+  });
+
+  grunt.registerTask('manifests', 'Update manifests.', function(version) {
+    var _ = grunt.util._,
+        pkg = grunt.file.readJSON('package.json'),
+        bower = grunt.file.readJSON('bower.json'),
+        jqueryPlugin = grunt.file.readJSON('typeahead.js.jquery.json');
+
+    bower = JSON.stringify(_.extend(bower, {
+      name: pkg.name,
+      version: version
+    }), null, 2);
+
+    jqueryPlugin = JSON.stringify(_.extend(jqueryPlugin, {
+      name: pkg.name,
+      title: pkg.name,
+      version: version,
+      author: pkg.author,
+      description: pkg.description,
+      keywords: pkg.keywords,
+      homepage: pkg.homepage,
+      bugs: pkg.bugs,
+      maintainers: pkg.contributors
+    }), null, 2);
+
+    pkg = JSON.stringify(_.extend(pkg, {
+      version: version
+    }), null, 2);
+
+    grunt.file.write('package.json', pkg);
+    grunt.file.write('bower.json', bower);
+    grunt.file.write('typeahead.js.jquery.json', jqueryPlugin);
+  });
+
+  // aliases
+  // -------
+
+  grunt.registerTask('default', 'build');
+  grunt.registerTask('server', 'connect:server');
+  grunt.registerTask('lint', 'jshint');
+  grunt.registerTask('dev', ['build', 'concurrent:dev']);
+  grunt.registerTask('build', [
+    'uglify:concatBloodhound',
+    'uglify:concatTypeahead',
+    'umd:bloodhound',
+    'umd:typeahead',
+    'uglify:bloodhound',
+    'uglify:bloodhoundMin',
+    'uglify:typeahead',
+    'uglify:typeaheadMin',
+    'uglify:bundle',
+    'uglify:bundleMin',
+    'sed:version'
+  ]);
+
+  // load tasks
+  // ----------
+
+  grunt.loadNpmTasks('grunt-umd');
+  grunt.loadNpmTasks('grunt-sed');
+  grunt.loadNpmTasks('grunt-exec');
+  grunt.loadNpmTasks('grunt-step');
+  grunt.loadNpmTasks('grunt-concurrent');
+  grunt.loadNpmTasks('grunt-contrib-watch');
+  grunt.loadNpmTasks('grunt-contrib-clean');
+  grunt.loadNpmTasks('grunt-contrib-uglify');
+  grunt.loadNpmTasks('grunt-contrib-jshint');
+  grunt.loadNpmTasks('grunt-contrib-concat');
+  grunt.loadNpmTasks('grunt-contrib-connect');
+};
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..83817ba
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2013-2014 Twitter, Inc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ff2f6c3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,188 @@
+[![build status](https://secure.travis-ci.org/twitter/typeahead.js.svg?branch=master)](http://travis-ci.org/twitter/typeahead.js)
+[![Built with Grunt](https://cdn.gruntjs.com/builtwith.png)](http://gruntjs.com/)
+
+
+[typeahead.js][gh-page]
+=======================
+
+Inspired by [twitter.com]'s autocomplete search functionality, typeahead.js is 
+a flexible JavaScript library that provides a strong foundation for building 
+robust typeaheads.
+
+The typeahead.js library consists of 2 components: the suggestion engine, 
+[Bloodhound], and the UI view, [Typeahead]. 
+The suggestion engine is responsible for computing suggestions for a given 
+query. The UI view is responsible for rendering suggestions and handling DOM 
+interactions. Both components can be used separately, but when used together, 
+they can provide a rich typeahead experience.
+
+<!-- section links -->
+
+[gh-page]: http://twitter.github.io/typeahead.js/
+[twitter.com]: https://twitter.com
+[Bloodhound]: https://github.com/twitter/typeahead.js/blob/master/doc/bloodhound.md
+[Typeahead]: https://github.com/twitter/typeahead.js/blob/master/doc/jquery_typeahead.md
+
+Getting Started
+---------------
+
+How you acquire typeahead.js is up to you.
+
+Preferred method:
+* Install with [Bower]: `$ bower install typeahead.js`
+
+Other methods:
+* [Download zipball of latest release][zipball].
+* Download the latest dist files individually:
+  * *[bloodhound.js]* (standalone suggestion engine)
+  * *[typeahead.jquery.js]* (standalone UI view)
+  * *[typeahead.bundle.js]* (*bloodhound.js* + *typeahead.jquery.js*)
+  * *[typeahead.bundle.min.js]*
+
+**Note:** both *bloodhound.js* and *typeahead.jquery.js* have a dependency on 
+[jQuery] 1.9+.
+
+<!-- section links -->
+
+[Bower]: http://bower.io/
+[zipball]: http://twitter.github.com/typeahead.js/releases/latest/typeahead.js.zip
+[bloodhound.js]: http://twitter.github.com/typeahead.js/releases/latest/bloodhound.js
+[typeahead.jquery.js]: http://twitter.github.com/typeahead.js/releases/latest/typeahead.jquery.js
+[typeahead.bundle.js]: http://twitter.github.com/typeahead.js/releases/latest/typeahead.bundle.js
+[typeahead.bundle.min.js]: http://twitter.github.com/typeahead.js/releases/latest/typeahead.bundle.min.js
+[jQuery]: http://jquery.com/
+
+Documentation 
+-------------
+
+* [Typeahead Docs]
+* [Bloodhound Docs]
+
+[Typeahead Docs]: https://github.com/twitter/typeahead.js/blob/master/doc/jquery_typeahead.md
+[Bloodhound Docs]: https://github.com/twitter/typeahead.js/blob/master/doc/bloodhound.md
+
+Examples
+--------
+
+For some working examples of typeahead.js, visit the [examples page].
+
+<!-- section links -->
+
+[examples page]: http://twitter.github.io/typeahead.js/examples
+
+Browser Support
+---------------
+
+* Chrome
+* Firefox 3.5+
+* Safari 4+
+* Internet Explorer 8+
+* Opera 11+
+
+**NOTE:** typeahead.js is not tested on mobile browsers.
+
+Customer Support
+----------------
+
+For general questions about typeahead.js, tweet at [@typeahead].
+
+For technical questions, you should post a question on [Stack Overflow] and tag 
+it with [typeahead.js][so tag].
+
+<!-- section links -->
+
+[Stack Overflow]: http://stackoverflow.com/
+[@typeahead]: https://twitter.com/typeahead
+[so tag]: http://stackoverflow.com/questions/tagged/typeahead.js
+
+Issues
+------
+
+Discovered a bug? Please create an issue here on GitHub!
+
+https://github.com/twitter/typeahead.js/issues
+
+Versioning
+----------
+
+For transparency and insight into our release cycle, releases will be numbered 
+with the following format:
+
+`<major>.<minor>.<patch>`
+
+And constructed with the following guidelines:
+
+* Breaking backwards compatibility bumps the major
+* New additions without breaking backwards compatibility bumps the minor
+* Bug fixes and misc changes bump the patch
+
+For more information on semantic versioning, please visit http://semver.org/.
+
+Testing
+-------
+
+Tests are written using [Jasmine] and ran with [Karma]. To run
+the test suite with PhantomJS, run `$ npm test`.
+
+<!-- section links -->
+
+[Jasmine]: http://jasmine.github.io/
+[Karma]: http://karma-runner.github.io/
+
+Developers
+----------
+
+If you plan on contributing to typeahead.js, be sure to read the 
+[contributing guidelines]. A good starting place for new contributors are issues
+labeled with [entry-level]. Entry-level issues tend to require minor changes 
+and provide developers a chance to get more familiar with typeahead.js before
+taking on more challenging work.
+
+In order to build and test typeahead.js, you'll need to install its dev 
+dependencies (`$ npm install`) and have [grunt-cli] 
+installed (`$ npm install -g grunt-cli`). Below is an overview of the available 
+Grunt tasks that'll be useful in development.
+
+* `grunt build` – Builds *typeahead.js* from source.
+* `grunt lint` – Runs source and test files through JSHint.
+* `grunt watch` – Rebuilds *typeahead.js* whenever a source file is modified.
+* `grunt server` – Serves files from the root of typeahead.js on localhost:8888. 
+  Useful for using *test/playground.html* for debugging/testing.
+* `grunt dev` – Runs `grunt watch` and `grunt server` in parallel.
+
+<!-- section links -->
+
+[contributing guidelines]: https://github.com/twitter/typeahead.js/blob/master/CONTRIBUTING.md
+[entry-level]: https://github.com/twitter/typeahead.js/issues?&labels=entry-level&state=open
+[grunt-cli]: https://github.com/gruntjs/grunt-cli
+
+Maintainers
+-----------
+
+* **Jake Harding** 
+  * [@JakeHarding](https://twitter.com/JakeHarding) 
+  * [GitHub](https://github.com/jharding)
+
+* **You?**
+
+Authors
+-------
+
+* **Jake Harding** 
+  * [@JakeHarding](https://twitter.com/JakeHarding) 
+  * [GitHub](https://github.com/jharding)
+
+* **Veljko Skarich**
+  * [@vskarich](https://twitter.com/vskarich) 
+  * [GitHub](https://github.com/vskarich)
+
+* **Tim Trueman**
+  * [@timtrueman](https://twitter.com/timtrueman) 
+  * [GitHub](https://github.com/timtrueman)
+
+License
+-------
+
+Copyright 2013 Twitter, Inc.
+
+Licensed under the MIT License
diff --git a/bower.json b/bower.json
new file mode 100644
index 0000000..1de2364
--- /dev/null
+++ b/bower.json
@@ -0,0 +1,13 @@
+{
+  "name": "typeahead.js",
+  "version": "0.11.1",
+  "main": "dist/typeahead.bundle.js",
+  "dependencies": {
+    "jquery": ">=1.7"
+  },
+  "devDependencies": {
+    "jquery": "~1.7",
+    "jasmine-ajax": "~1.3.1",
+    "jasmine-jquery": "~1.5.2"
+  }
+}
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..3f116f7
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,17 @@
+{
+    "name": "twitter/typeahead.js",
+    "description": "fast and fully-featured autocomplete library",
+    "keywords": ["typeahead", "autocomplete"],
+    "homepage": "http://twitter.github.com/typeahead.js",
+    "authors": [
+        {
+            "name": "Twitter Inc.",
+            "homepage": "https://twitter.com/twitteross"
+        }
+    ],
+    "support": {
+        "issues": "https://github.com/twitter/typeahead.js/issues"
+    },
+    "author": "Twitter Inc.",
+    "license": "MIT"
+}
diff --git a/doc/bloodhound.md b/doc/bloodhound.md
new file mode 100644
index 0000000..ebb4df6
--- /dev/null
+++ b/doc/bloodhound.md
@@ -0,0 +1,277 @@
+Bloodhound
+==========
+
+Bloodhound is the typeahead.js suggestion engine. Bloodhound is robust, 
+flexible, and offers advanced functionalities such as prefetching, intelligent
+caching, fast lookups, and backfilling with remote data.
+
+Table of Contents
+-----------------
+
+* [Features](#features)
+* [Usage](#usage)
+  * [API](#api)
+  * [Options](#options)
+  * [Prefetch](#prefetch)
+  * [Remote](#remote)
+
+Features
+--------
+
+* Works with hardcoded data
+* Prefetches data on initialization to reduce suggestion latency
+* Uses local storage intelligently to cut down on network requests
+* Backfills suggestions from a remote source
+* Rate-limits and caches network requests to remote sources to lighten the load
+
+Usage
+-----
+
+### API
+
+* [`new Bloodhound(options)`](#new-bloodhoundoptions)
+* [`Bloodhound.noConflict()`](#bloodhoundnoconflict)
+* [`Bloodhound#initialize(reinitialize)`](#bloodhoundinitializereinitialize)
+* [`Bloodhound#add(data)`](#bloodhoundadddata)
+* [`Bloodhound#get(ids)`](#bloodhoundgetids)
+* [`Bloodhound#search(query, sync, async)`](#bloodhoundsearchquery-sync-async)
+* [`Bloodhound#clear()`](#bloodhoundclear)
+
+#### new Bloodhound(options)
+
+The constructor function. It takes an [options hash](#options) as its only 
+argument.
+
+```javascript
+var engine = new Bloodhound({
+  local: ['dog', 'pig', 'moose'],
+  queryTokenizer: Bloodhound.tokenizers.whitespace,
+  datumTokenizer: Bloodhound.tokenizers.whitespace
+});
+```
+
+#### Bloodhound.noConflict()
+
+Returns a reference to `Bloodhound` and reverts `window.Bloodhound` to its 
+previous value. Can be used to avoid naming collisions. 
+
+```javascript
+var Dachshund = Bloodhound.noConflict();
+```
+
+#### Bloodhound#initialize(reinitialize) 
+
+Kicks off the initialization of the suggestion engine. Initialization entails
+adding the data provided by `local` and `prefetch` to the internal search 
+index as well as setting up transport mechanism used by `remote`. Before 
+`#initialize` is called, the `#get` and `#search` methods will effectively be
+no-ops.
+
+Note, unless the `initialize` option is `false`, this method is implicitly
+called by the constructor.
+
+```javascript
+var engine = new Bloodhound({
+  initialize: false,
+  local: ['dog', 'pig', 'moose'],
+  queryTokenizer: Bloodhound.tokenizers.whitespace,
+  datumTokenizer: Bloodhound.tokenizers.whitespace
+});
+
+var promise = engine.initialize();
+
+promise
+.done(function() { console.log('ready to go!'); })
+.fail(function() { console.log('err, something went wrong :('); });
+```
+
+After initialization, how subsequent invocations of `#initialize` behave 
+depends on the `reinitialize` argument. If `reinitialize` is falsy, the
+method will not execute the initialization logic and will just return the same 
+jQuery promise returned by the initial invocation. If `reinitialize` is truthy,
+the method will behave as if it were being called for the first time.
+
+```javascript
+var promise1 = engine.initialize();
+var promise2 = engine.initialize();
+var promise3 = engine.initialize(true);
+
+assert(promise1 === promise2);
+assert(promise3 !== promise1 && promise3 !== promise2);
+```
+
+<!-- section links -->
+
+[jQuery promise]: http://api.jquery.com/Types/#Promise
+
+#### Bloodhound#add(data)
+
+Takes one argument, `data`, which is expected to be an array. The data passed
+in will get added to the internal search index.
+
+```javascript
+engine.add([{ val: 'one' }, { val: 'two' }]);
+```
+
+#### Bloodhound#get(ids)
+
+Returns the data in the local search index corresponding to `ids`.
+
+```javascript
+  var engine = new Bloodhound({
+    local: [{ id: 1, name: 'dog' }, { id: 2, name: 'pig' }],
+    identify: function(obj) { return obj.id; },
+    queryTokenizer: Bloodhound.tokenizers.whitespace,
+    datumTokenizer: Bloodhound.tokenizers.whitespace
+  });
+
+  engine.get([1, 3]); // [{ id: 1, name: 'dog' }, null]
+```
+
+#### Bloodhound#search(query, sync, async)
+
+Returns the data that matches `query`. Matches found in the local search index
+will be passed to the `sync` callback. If the data passed to `sync` doesn't 
+contain at least `sufficient` number of datums, `remote` data will be requested 
+and then passed to the `async` callback.
+
+```javascript
+bloodhound.get(myQuery, sync, async);
+
+function sync(datums) {
+  console.log('datums from `local`, `prefetch`, and `#add`');
+  console.log(datums);
+}
+
+function async(datums) {
+  console.log('datums from `remote`');
+  console.log(datums);
+}
+```
+
+#### Bloodhound#clear()
+
+Clears the internal search index that's powered by `local`, `prefetch`, and 
+`#add`.
+
+```javascript
+engine.clear();
+```
+
+### Options
+
+When instantiating a Bloodhound suggestion engine, there are a number of 
+options you can configure.
+
+* `datumTokenizer` – A function with the signature `(datum)` that transforms a
+  datum into an array of string tokens. **Required**.
+
+* `queryTokenizer` – A function with the signature `(query)` that transforms a
+  query into an array of string tokens. **Required**.
+
+* `initialize` – If set to `false`, the Bloodhound instance will not be 
+  implicitly initialized by the constructor function. Defaults to `true`.
+
+* `identify` – Given a datum, this function is expected to return a unique id
+  for it. Defaults to `JSON.stringify`. Note that it is **highly recommended**
+  to override this option.
+
+* `sufficient` – If the number of datums provided from the internal search 
+  index is less than `sufficient`, `remote` will be used to backfill search
+  requests triggered by calling `#search`. Defaults to `5`.
+
+* `sorter` – A [compare function] used to sort data returned from the internal
+  search index.
+
+* `local` – An array of data or a function that returns an array of data. The 
+  data will be added to the internal search index when `#initialize` is called.
+
+* `prefetch` – Can be a URL to a JSON file containing an array of data or, if 
+  more configurability is needed, a [prefetch options hash](#prefetch).
+
+* `remote` – Can be a URL to fetch data from when the data provided by 
+  the internal search index is insufficient or, if more configurability is 
+  needed, a [remote options hash](#remote).
+
+<!-- section links -->
+
+[compare function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
+
+### Prefetch
+
+Prefetched data is fetched and processed on initialization. If the browser 
+supports local storage, the processed data will be cached there to 
+prevent additional network requests on subsequent page loads.
+
+**WARNING:** While it's possible to get away with it for smaller data sets, 
+prefetched data isn't meant to contain entire sets of data. Rather, it should 
+act as a first-level cache. Ignoring this warning means you'll run the risk of 
+hitting [local storage limits].
+
+When configuring `prefetch`, the following options are available.
+
+* `url` – The URL prefetch data should be loaded from. **Required.**
+
+* `cache` – If `false`, will not attempt to read or write to local storage and
+  will always load prefetch data from `url` on initialization.  Defaults to 
+  `true`.
+
+* `ttl` – The time (in milliseconds) the prefetched data should be cached in 
+  local storage. Defaults to `86400000` (1 day).
+
+* `cacheKey` – The key that data will be stored in local storage under. 
+  Defaults to value of `url`.
+
+* `thumbprint` – A string used for thumbprinting prefetched data. If this
+  doesn't match what's stored in local storage, the data will be refetched.
+
+* `prepare` – A function that provides a hook to allow you to prepare the 
+  settings object passed to `transport` when a request is about to be made. 
+  The function signature should be `prepare(settings)` where `settings` is the 
+  default settings object created internally by the Bloodhound instance. The 
+  `prepare` function should return a settings object. Defaults to the 
+  [identity function].
+
+* `transform` – A function with the signature `transform(response)` that allows
+  you to transform the prefetch response before the Bloodhound instance operates 
+  on it. Defaults to the [identity function].
+
+<!-- section links -->
+
+[local storage limits]: http://stackoverflow.com/a/2989317
+[identity function]: http://en.wikipedia.org/wiki/Identity_function
+
+### Remote
+
+Bloodhound only goes to the network when the internal search engine cannot 
+provide a sufficient number of results. In order to prevent an obscene number 
+of requests being made to the remote endpoint, requests are rate-limited.
+
+When configuring `remote`, the following options are available.
+
+* `url` – The URL remote data should be loaded from. **Required.**
+
+* `prepare` – A function that provides a hook to allow you to prepare the 
+  settings object passed to `transport` when a request is about to be made. 
+  The function signature should be `prepare(query, settings)`, where `query` is
+  the query `#search` was called with and `settings` is the default settings
+  object created internally by the Bloodhound instance. The `prepare` function
+  should return a settings object. Defaults to the [identity function].
+
+* `wildcard` – A convenience option for `prepare`. If set, `prepare` will be a
+  function that replaces the value of this option in `url` with the URI encoded
+  query.
+
+* `rateLimitBy` – The method used to rate-limit network requests. Can be either 
+  `debounce` or `throttle`. Defaults to `debounce`.
+
+* `rateLimitWait` – The time interval in milliseconds that will be used by 
+  `rateLimitBy`. Defaults to `300`.
+
+* `transform` – A function with the signature `transform(response)` that allows
+  you to transform the remote response before the Bloodhound instance operates 
+  on it. Defaults to the [identity function].
+
+<!-- section links -->
+
+[identity function]: http://en.wikipedia.org/wiki/Identity_function
diff --git a/doc/jquery_typeahead.md b/doc/jquery_typeahead.md
new file mode 100644
index 0000000..f640260
--- /dev/null
+++ b/doc/jquery_typeahead.md
@@ -0,0 +1,288 @@
+jQuery#typeahead
+----------------
+
+The UI component of typeahead.js is available as a jQuery plugin. It's 
+responsible for rendering suggestions and handling DOM interactions.
+
+Table of Contents
+-----------------
+
+* [Features](#features)
+* [Usage](#usage)
+  * [API](#api)
+  * [Options](#options)
+  * [Datasets](#datasets)
+  * [Custom Events](#custom-events)
+  * [Class Names](#class-names)
+
+Features
+--------
+
+* Displays suggestions to end-users as they type
+* Shows top suggestion as a hint (i.e. background text)
+* Supports custom templates to allow for UI flexibility
+* Works well with RTL languages and input method editors
+* Highlights query matches within the suggestion
+* Triggers custom events to encourage extensibility
+
+Usage
+-----
+
+### API
+
+* [`jQuery#typeahead(options, [*datasets])`](#jquerytypeaheadoptions-datasets)
+* [`jQuery#typeahead('val')`](#jquerytypeaheadval)
+* [`jQuery#typeahead('val', val)`](#jquerytypeaheadval-val)
+* [`jQuery#typeahead('destroy')`](#jquerytypeaheaddestroy)
+* [`jQuery.fn.typeahead.noConflict()`](#jqueryfntypeaheadnoconflict)
+
+#### jQuery#typeahead(options, [\*datasets])
+
+For a given `input[type="text"]`, enables typeahead functionality. `options` 
+is an options hash that's used for configuration. Refer to [Options](#options) 
+for more info regarding the available configs. Subsequent arguments 
+(`*datasets`), are individual option hashes for datasets. For more details 
+regarding datasets, refer to [Datasets](#datasets).
+
+```javascript
+$('.typeahead').typeahead({
+  minLength: 3,
+  highlight: true
+},
+{
+  name: 'my-dataset',
+  source: mySource
+});
+```
+
+#### jQuery#typeahead('val')
+
+Returns the current value of the typeahead. The value is the text the user has 
+entered into the `input` element.
+
+```javascript
+var myVal = $('.typeahead').typeahead('val');
+```
+
+#### jQuery#typeahead('val', val)
+
+Sets the value of the typeahead. This should be used in place of `jQuery#val`.
+
+```javascript
+$('.typeahead').typeahead('val', myVal);
+```
+
+#### jQuery#typeahead('open')
+
+Opens the suggestion menu.
+
+```javascript
+$('.typeahead').typeahead('open');
+```
+
+#### jQuery#typeahead('close')
+
+Closes the suggestion menu.
+
+```javascript
+$('.typeahead').typeahead('close');
+```
+
+#### jQuery#typeahead('destroy')
+
+Removes typeahead functionality and reverts the `input` element back to its 
+original state.
+
+```javascript
+$('.typeahead').typeahead('destroy');
+```
+
+#### jQuery.fn.typeahead.noConflict()
+
+Returns a reference to the typeahead plugin and reverts `jQuery.fn.typeahead` 
+to its previous value. Can be used to avoid naming collisions. 
+
+```javascript
+var typeahead = jQuery.fn.typeahead.noConflict();
+jQuery.fn._typeahead = typeahead;
+```
+
+### Options
+
+When initializing a typeahead, there are a number of options you can configure.
+
+* `highlight` – If `true`, when suggestions are rendered, pattern matches
+  for the current query in text nodes will be wrapped in a `strong` element with
+  its class set to `{{classNames.highlight}}`. Defaults to `false`.
+
+* `hint` – If `false`, the typeahead will not show a hint. Defaults to `true`.
+
+* `minLength` – The minimum character length needed before suggestions start 
+  getting rendered. Defaults to `1`.
+
+* `classNames` – For overriding the default class names used. See 
+  [Class Names](#class-names) for more details.
+
+### Datasets
+
+A typeahead is composed of one or more datasets. When an end-user modifies the
+value of a typeahead, each dataset will attempt to render suggestions for the
+new value. 
+
+For most use cases, one dataset should suffice. It's only in the scenario where
+you want rendered suggestions to be grouped based on some sort of categorical 
+relationship that you'd need to use multiple datasets. For example, on 
+twitter.com, the search typeahead groups results into recent searches, trends, 
+and accounts – that would be a great use case for using multiple datasets.
+
+Datasets can be configured using the following options.
+
+* `source` – The backing data source for suggestions. Expected to be a function 
+  with the signature `(query, syncResults, asyncResults)`. `syncResults` should
+  be called with suggestions computed synchronously and `asyncResults` should be 
+  called with suggestions computed asynchronously (e.g. suggestions that come 
+  for an AJAX request). `source` can also be a Bloodhound instance. 
+  **Required**.
+
+* `async` – Lets the dataset know if async suggestions should be expected. If
+  not set, this information is inferred from the signature of `source` i.e.
+  if the `source` function expects 3 arguments, `async` will be set to `true`.
+
+* `name` – The name of the dataset. This will be appended to 
+  `{{classNames.dataset}}-` to form the class name of the containing DOM 
+  element. Must only consist of underscores, dashes, letters (`a-z`), and 
+  numbers. Defaults to a random number.
+
+* `limit` – The max number of suggestions to be displayed. Defaults to `5`.
+
+* `display` – For a given suggestion, determines the string representation 
+  of it. This will be used when setting the value of the input control after a 
+  suggestion is selected. Can be either a key string or a function that 
+  transforms a suggestion object into a string. Defaults to stringifying the 
+  suggestion.
+
+* `templates` – A hash of templates to be used when rendering the dataset. Note
+  a precompiled template is a function that takes a JavaScript object as its
+  first argument and returns a HTML string.
+
+  * `notFound` – Rendered when `0` suggestions are available for the given 
+    query. Can be either a HTML string or a precompiled template. If it's a 
+    precompiled template, the passed in context will contain `query`.
+
+  * `pending` - Rendered when `0` synchronous suggestions are available but
+    asynchronous suggestions are expected. Can be either a HTML string or a 
+    precompiled template. If it's a precompiled template, the passed in context 
+    will contain `query`.
+
+  * `header`– Rendered at the top of the dataset when suggestions are present. 
+    Can be either a HTML string or a precompiled template. If it's a precompiled 
+    template, the passed in context will contain `query` and `suggestions`.
+
+  * `footer`– Rendered at the bottom of the dataset when suggestions are 
+    present. Can be either a HTML string or a precompiled template. If it's a 
+    precompiled template, the passed in context will contain `query` and
+    `suggestions`.
+
+  * `suggestion` – Used to render a single suggestion. If set, this has to be a 
+    precompiled template. The associated suggestion object will serve as the 
+    context. Defaults to the value of `display` wrapped in a `div` tag i.e. 
+    `<div>{{value}}</div>`.
+
+### Custom Events
+
+The following events get triggered on the input element during the life-cycle of
+a typeahead.
+
+* `typeahead:active` – Fired when the typeahead moves to active state.
+
+* `typeahead:idle` – Fired when the typeahead moves to idle state.
+
+* `typeahead:open` – Fired when the results container is opened.
+
+* `typeahead:close` – Fired when the results container is closed.
+
+* `typeahead:change` – Normalized version of the native [`change` event]. 
+  Fired when input loses focus and the value has changed since it originally 
+  received focus.
+
+* `typeahead:render` – Fired when suggestions are rendered for a dataset. The
+  event handler will be invoked with 4 arguments: the jQuery event object, the
+  suggestions that were rendered, a flag indicating whether the suggestions
+  were fetched asynchronously, and the name of the dataset the rendering 
+  occurred in.
+
+* `typeahead:select` – Fired when a suggestion is selected. The event handler 
+  will be invoked with 2 arguments: the jQuery event object and the suggestion
+  object that was selected.
+
+* `typeahead:autocomplete` – Fired when a autocompletion occurs. The 
+  event handler will be invoked with 2 arguments: the jQuery event object and 
+  the suggestion object that was used for autocompletion.
+
+* `typeahead:cursorchange` – Fired when the results container cursor moves. The 
+  event handler will be invoked with 2 arguments: the jQuery event object and 
+  the suggestion object that was moved to.
+
+* `typeahead:asyncrequest` – Fired when an async request for suggestions is 
+  sent. The event handler will be invoked with 3 arguments: the jQuery event 
+  object, the current query, and the name of the dataset the async request 
+  belongs to.
+
+* `typeahead:asynccancel` – Fired when an async request is cancelled. The event 
+  handler will be invoked with 3 arguments: the jQuery event object, the current 
+  query, and the name of the dataset the async request belonged to.
+
+* `typeahead:asyncreceive` – Fired when an async request completes. The event 
+  handler will be invoked with 3 arguments: the jQuery event object, the current 
+  query, and the name of the dataset the async request belongs to.
+
+Example usage:
+
+```
+$('.typeahead').bind('typeahead:select', function(ev, suggestion) {
+  console.log('Selection: ' + suggestion);
+});
+```
+
+**NOTE**: Every event does not supply the same arguments. See the event
+descriptions above for details on each event's argument list.
+
+<!-- section links -->
+
+[`change` event]: https://developer.mozilla.org/en-US/docs/Web/Events/change
+
+### Class Names
+
+* `input` - Added to input that's initialized into a typeahead. Defaults to 
+  `tt-input`.
+
+* `hint` - Added to hint input. Defaults to `tt-hint`.
+
+* `menu` - Added to menu element. Defaults to `tt-menu`.
+
+* `dataset` - Added to dataset elements. to Defaults to `tt-dataset`.
+
+* `suggestion` - Added to suggestion elements. Defaults to `tt-suggestion`.
+
+* `empty` - Added to menu element when it contains no content. Defaults to 
+  `tt-empty`.
+
+* `open` - Added to menu element when it is opened. Defaults to `tt-open`.
+
+* `cursor` - Added to suggestion element when menu cursor moves to said 
+  suggestion. Defaults to `tt-cursor`.
+
+* `highlight` - Added to the element that wraps highlighted text. Defaults to 
+  `tt-highlight`.
+
+To override any of these defaults, you can use the `classNames` option:
+
+```javascript
+$('.typeahead').typeahead({
+  classNames: {
+    input: 'Typeahead-input',
+    hint: 'Typeahead-hint',
+    selectable: 'Typeahead-selectable'
+  }
+});
+```
diff --git a/doc/migration/0.10.0.md b/doc/migration/0.10.0.md
new file mode 100644
index 0000000..0fc263d
--- /dev/null
+++ b/doc/migration/0.10.0.md
@@ -0,0 +1,234 @@
+Migrating to typeahead.js v0.10.0
+=================================
+
+Preamble
+--------
+
+v0.10.0 of typeahead.js ended up being almost a complete rewrite. Many things 
+stayed the same, but there were a handful of changes you need to be aware of 
+if you plan on upgrading from an older version. This document aims to call out 
+those changes and explain what you need to do in order to have an painless 
+upgrade.
+
+Notable Changes
+----------------
+
+### First Argument to the jQuery Plugin
+
+In v0.10.0, the first argument to `jQuery#typeahead` is an options hash that
+can be used to configure the behavior of the typeahead. This is in contrast
+to previous versions where `jQuery#typeahead` expected just a series of datasets
+to be passed to it:
+
+```javascript
+// pre-v0.10.0
+$('.typeahead').typeahead(myDataset);
+
+// v0.10.0
+$('.typeahead').typeahead({
+  highlight: true,
+  hint: false
+}, myDataset);
+```
+
+If you're fine with the default configuration, you can just pass `null` as the 
+first argument:
+
+```javascript
+$('.typeahead').typeahead(null, myDataset);
+```
+
+### Bloodhound Suggestion Engine
+
+The most notable change in v0.10.0 is that typeahead.js has been decomposed into
+a suggestion engine and a UI view. As part of this change, the way you configure
+datasets has changed. Previously, a dataset config would have looked like:
+
+```javascript
+{
+  valueKey: 'num',
+  local: [{ num: 'one' }, { num: 'two' }, { num: 'three' }],
+  prefetch: '/prefetch',
+  remote: '/remote?q=%QUERY'
+}
+```
+
+In v0.10.0, an equivalent dataset config would look like:
+
+```javascript
+{
+ displayKey: 'num',
+ source: mySource
+}
+```
+
+As you can see, `local`, `prefetch`, and `remote` are no longer defined at the 
+dataset level. Instead, all you set in a dataset config is `source`. `source` is
+expected to be a function with the signature `function(query, callback)`. When a
+typeahead's query changes, suggestions will be requested from  `source`. It's
+expected `source` will compute the suggestion set and invoke `callback` with an array
+of suggestion objects. The typeahead will then go on to render those suggestions.
+
+If you're wondering if you can still configure `local`, `prefetch`, and 
+`remote`, don't worry, that's where the Bloodhound suggestion engine comes in.
+Here's how you would define `mySource` which was referenced in the previous 
+code snippet:
+
+```
+var mySource = new Bloodhound({
+  datumTokenizer: function(d) { 
+    return Bloodhound.tokenizers.whitespace(d.num); 
+  },
+  queryTokenizer: Bloodhound.tokenizers.whitespace,
+  local: [{ num: 'one' }, { num: 'two' }, { num: 'three' }],
+  prefetch: '/prefetch',
+  remote: '/remote?q=%QUERY'
+});
+
+// this kicks off the loading and processing of local and prefetch data
+// the suggestion engine will be useless until it is initialized
+mySource.initialize();
+```
+
+In the above snippet, a Bloodhound suggestion engine is initialized and that's 
+what will be used as the source of your dataset. There's still one last thing
+that needs to be done before you can use a Bloodhound suggestion engine as the 
+source of a dataset. Because datasets expect `source` to be function, the 
+Bloodhound instance needs to be wrapped in an adapter so it can meet that 
+expectation.
+
+```
+mySource = mySource.ttAdapter();
+```
+
+Put it all together:
+
+```javascript
+var mySource = new Bloodhound({
+  datumTokenizer: function(d) { 
+    return Bloodhound.tokenizers.whitespace(d.num); 
+  },
+  queryTokenizer: Bloodhound.tokenizers.whitespace,
+  local: [{ num: 'one' }, { num: 'two' }, { num: 'three' }],
+  prefetch: '/prefetch',
+  remote: '/remote?q=%QUERY'
+});
+
+mySource.initialize();
+
+$('.typeahead').typeahead(null, {
+  displayKey: 'num',
+  source: mySource.ttAdapter()
+});
+```
+
+### Tokenization Methods Must Be Provided
+
+The Bloodhound suggestion engine is token-based, so how datums and queries are
+tokenized plays a vital role in the quality of search results. Pre-v0.10.0,
+it was not possible to configure the tokenization method. Starting in v0.10.0,
+you **must** specify how you want datums and queries tokenized. 
+
+The most common tokenization methods split a given string on whitespace or 
+non-word characters. Bloodhound provides implementations for those methods
+out of the box:
+
+```javascript
+// returns ['one', 'two', 'twenty-five']
+Bloodhound.tokenizers.whitespace('  one two  twenty-five');
+
+// returns ['one', 'two', 'twenty', 'five']
+Bloodhound.tokenizers.nonword('  one two  twenty-five');
+```
+
+For query tokenization, you'll probably want to use one of the above methods.
+For datum tokenization, this is where you may want to do something a tad bit
+more advanced.
+
+For datums, sometimes you want tokens to be dervied from more than one property. 
+For example, if you were building a search engine for GitHub repositories, it'd 
+probably be wise to have tokens derived from the repo's name, owner, and 
+primary language:
+
+```javascript
+var repos = [
+  { name: 'example', owner: 'John Doe', language: 'JavaScript' },
+  { name: 'another example', owner: 'Joe Doe', language: 'Scala' }
+];
+
+function customTokenizer(datum) {
+  var nameTokens = Bloodhound.tokenizers.whitespace(datum.name);
+  var ownerTokens = Bloodhound.tokenizers.whitespace(datum.owner);
+  var languageTokens = Bloodhound.tokenizers.whitespace(datum.language);
+
+  return nameTokens.concat(ownerTokens).concat(languageTokens);
+}
+```
+
+There may also be the scenario where you want datum tokenization to be performed
+on the backend. The best way to do that is to just add a property to your datums 
+that contains those tokens. You can then provide a tokenizer that just returns 
+the already existing tokens:
+
+```javascript
+var sports = [
+  { value: 'football', tokens: ['football', 'pigskin'] },
+  { value: 'basketball', tokens: ['basketball', 'bball'] }
+];
+
+function customTokenizer(datum) { return datum.tokens; }
+```
+
+There are plenty of other ways you could go about tokenizing datums, it really
+just depends on what you are trying to accomplish.
+
+### String Datums Are No Longer Supported
+
+Dropping support for string datums was a difficult choice, but in the end it
+made sense for a number of reasons. If you still want to hydrate the suggestion 
+engine with string datums, you'll need to use the `filter` function:
+
+```javascript
+var engine = new Bloodhound({
+  prefetch: {
+    url: '/data',
+    filter: function(data) {
+      // assume data is an array of strings e.g. ['one', 'two', 'three']
+      return $.map(data, function(str) { return { value: str }; });
+    },
+    datumTokenizer: function(d) { 
+      return Bloodhound.tokenizers.whitespace(d.value); 
+    },
+    queryTokenizer: Bloodhound.tokenizers.whitespace
+  }
+});
+```
+
+### Precompiled Templates Are Now Required
+
+In previous versions of typeahead.js, you could specify a string template along
+with the templating engine that should be used to compile/render it. In 
+v0.10.0, you can no longer specify templating engines; instead you must provide
+precompiled templates. Precompiled templates are functions that take one 
+argument: the context the template should be rendered with. 
+
+Most of the popular templating engines allow for the creation of precompiled 
+templates. For example, you can generate one using Handlebars by doing the
+following:
+
+```javascript
+var precompiledTemplate = Handlebars.compile('<p>{{value}}</p>');
+```
+
+[Handlebars]: http://handlebarsjs.com/
+
+### CSS Class Changes
+
+`tt-is-under-cursor` is now `tt-cursor` - Applied to a hovered-on suggestion (either via cursor or arrow key).
+
+`tt-query` is now `tt-input` - Applied to the typeahead input field.
+
+Something Missing?
+------------------
+
+If something is missing from this migration guide, pull requests are accepted :)
diff --git a/karma.conf.js b/karma.conf.js
new file mode 100644
index 0000000..ef9d559
--- /dev/null
+++ b/karma.conf.js
@@ -0,0 +1,50 @@
+module.exports = function(config) {
+  config.set({
+    basePath: '',
+
+    preprocessors: {
+      'src/**/*.js': 'coverage'
+    },
+
+    reporters: ['progress', 'coverage'],
+
+    browsers: ['Chrome'],
+
+    frameworks: ['jasmine'],
+
+    coverageReporter: {
+      type: 'html',
+      dir: 'test/coverage/'
+    },
+
+    files: [
+      'bower_components/jquery/jquery.js',
+      'src/common/utils.js',
+      'src/bloodhound/version.js',
+      'src/bloodhound/tokenizers.js',
+      'src/bloodhound/lru_cache.js',
+      'src/bloodhound/persistent_storage.js',
+      'src/bloodhound/transport.js',
+      'src/bloodhound/remote.js',
+      'src/bloodhound/prefetch.js',
+      'src/bloodhound/search_index.js',
+      'src/bloodhound/options_parser.js',
+      'src/bloodhound/bloodhound.js',
+      'src/typeahead/www.js',
+      'src/typeahead/event_bus.js',
+      'src/typeahead/event_emitter.js',
+      'src/typeahead/highlight.js',
+      'src/typeahead/input.js',
+      'src/typeahead/dataset.js',
+      'src/typeahead/menu.js',
+      'src/typeahead/default_menu.js',
+      'src/typeahead/typeahead.js',
+      'src/typeahead/plugin.js',
+      'test/fixtures/**/*',
+      'bower_components/jasmine-jquery/lib/jasmine-jquery.js',
+      'bower_components/jasmine-ajax/lib/mock-ajax.js',
+      'test/helpers/**/*',
+      'test/**/*_spec.js'
+    ]
+  });
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..376c92a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,68 @@
+{
+  "name": "typeahead.js",
+  "description": "fast and fully-featured autocomplete library",
+  "keywords": [
+    "typeahead",
+    "autocomplete"
+  ],
+  "homepage": "http://twitter.github.com/typeahead.js",
+  "bugs": "https://github.com/twitter/typeahead.js/issues",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/twitter/typeahead.js.git"
+  },
+  "author": {
+    "name": "Twitter, Inc.",
+    "url": "https://twitter.com/twitteross"
+  },
+  "contributors": [
+    {
+      "name": "Jake Harding",
+      "url": "https://twitter.com/JakeHarding"
+    },
+    {
+      "name": "Tim Trueman",
+      "url": "https://twitter.com/timtrueman"
+    },
+    {
+      "name": "Veljko Skarich",
+      "url": "https://twitter.com/vskarich"
+    }
+  ],
+  "dependencies": {
+    "jquery": ">=1.7"
+  },
+  "devDependencies": {
+    "chai": "^1.9.1",
+    "colors": "^0.6.2",
+    "grunt": "~0.4",
+    "grunt-concurrent": "^0.5.0",
+    "grunt-contrib-clean": "~0.4.0",
+    "grunt-contrib-concat": "~0.1",
+    "grunt-contrib-connect": "~0.1",
+    "grunt-contrib-jshint": "~0.8",
+    "grunt-contrib-uglify": "~0.2.6",
+    "grunt-contrib-watch": "~0.2",
+    "grunt-exec": "~0.4.5",
+    "grunt-sed": "~0.1",
+    "grunt-step": "~0.2.0",
+    "grunt-umd": "^2.3.3",
+    "karma": "^0.12.22",
+    "karma-chrome-launcher": "^0.1.4",
+    "karma-coverage": "^0.2.6",
+    "karma-firefox-launcher": "^0.1.3",
+    "karma-jasmine": "^0.1.5",
+    "karma-opera-launcher": "^0.1.0",
+    "karma-phantomjs-launcher": "^0.1.4",
+    "karma-safari-launcher": "^0.1.1",
+    "mocha": "^1.20.1",
+    "semver": "~1.1.3",
+    "underscore": "^1.6.0",
+    "yiewd": "^0.5.0"
+  },
+  "scripts": {
+    "test": "./node_modules/karma/bin/karma start --single-run --browsers PhantomJS"
+  },
+  "version": "0.11.1",
+  "main": "dist/typeahead.bundle.js"
+}
\ No newline at end of file
diff --git a/src/bloodhound/bloodhound.js b/src/bloodhound/bloodhound.js
new file mode 100644
index 0000000..912d9d4
--- /dev/null
+++ b/src/bloodhound/bloodhound.js
@@ -0,0 +1,192 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Bloodhound = (function() {
+  'use strict';
+
+  var old;
+
+  old = window && window.Bloodhound;
+
+  // constructor
+  // -----------
+
+  function Bloodhound(o) {
+    o = oParser(o);
+
+    this.sorter = o.sorter;
+    this.identify = o.identify;
+    this.sufficient = o.sufficient;
+
+    this.local = o.local;
+    this.remote = o.remote ? new Remote(o.remote) : null;
+    this.prefetch = o.prefetch ? new Prefetch(o.prefetch) : null;
+
+    // the backing data structure used for fast pattern matching
+    this.index = new SearchIndex({
+      identify: this.identify,
+      datumTokenizer: o.datumTokenizer,
+      queryTokenizer: o.queryTokenizer
+    });
+
+    // hold off on intialization if the intialize option was explicitly false
+    o.initialize !== false && this.initialize();
+  }
+
+  // static methods
+  // --------------
+
+  Bloodhound.noConflict = function noConflict() {
+    window && (window.Bloodhound = old);
+    return Bloodhound;
+  };
+
+  Bloodhound.tokenizers = tokenizers;
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Bloodhound.prototype, {
+
+    // ### super secret stuff used for integration with jquery plugin
+
+    __ttAdapter: function ttAdapter() {
+      var that = this;
+
+      return this.remote ? withAsync : withoutAsync;
+
+      function withAsync(query, sync, async) {
+        return that.search(query, sync, async);
+      }
+
+      function withoutAsync(query, sync) {
+        return that.search(query, sync);
+      }
+    },
+
+    // ### private
+
+    _loadPrefetch: function loadPrefetch() {
+      var that = this, deferred, serialized;
+
+      deferred = $.Deferred();
+
+      if (!this.prefetch) {
+        deferred.resolve();
+      }
+
+      else if (serialized = this.prefetch.fromCache()) {
+        this.index.bootstrap(serialized);
+        deferred.resolve();
+      }
+
+      else {
+        this.prefetch.fromNetwork(done);
+      }
+
+      return deferred.promise();
+
+      function done(err, data) {
+        if (err) { return deferred.reject(); }
+
+        that.add(data);
+        that.prefetch.store(that.index.serialize());
+        deferred.resolve();
+      }
+    },
+
+    _initialize: function initialize() {
+      var that = this, deferred;
+
+      // in case this is a reinitialization, clear previous data
+      this.clear();
+
+      (this.initPromise = this._loadPrefetch())
+      .done(addLocalToIndex); // local must be added to index after prefetch
+
+      return this.initPromise;
+
+      function addLocalToIndex() { that.add(that.local); }
+    },
+
+    // ### public
+
+    initialize: function initialize(force) {
+      return !this.initPromise || force ? this._initialize() : this.initPromise;
+    },
+
+    // TODO: before initialize what happens?
+    add: function add(data) {
+      this.index.add(data);
+      return this;
+    },
+
+    get: function get(ids) {
+      ids = _.isArray(ids) ? ids : [].slice.call(arguments);
+      return this.index.get(ids);
+    },
+
+    search: function search(query, sync, async) {
+      var that = this, local;
+
+      local = this.sorter(this.index.search(query));
+
+      // return a copy to guarantee no changes within this scope
+      // as this array will get used when processing the remote results
+      sync(this.remote ? local.slice() : local);
+
+      if (this.remote && local.length < this.sufficient) {
+        this.remote.get(query, processRemote);
+      }
+
+      else if (this.remote) {
+        // #149: prevents outdated rate-limited requests from being sent
+        this.remote.cancelLastRequest();
+      }
+
+      return this;
+
+      function processRemote(remote) {
+        var nonDuplicates = [];
+
+        // exclude duplicates
+        _.each(remote, function(r) {
+           !_.some(local, function(l) {
+            return that.identify(r) === that.identify(l);
+          }) && nonDuplicates.push(r);
+        });
+
+        async && async(nonDuplicates);
+      }
+    },
+
+    all: function all() {
+      return this.index.all();
+    },
+
+    clear: function clear() {
+      this.index.reset();
+      return this;
+    },
+
+    clearPrefetchCache: function clearPrefetchCache() {
+      this.prefetch && this.prefetch.clear();
+      return this;
+    },
+
+    clearRemoteCache: function clearRemoteCache() {
+      Transport.resetCache();
+      return this;
+    },
+
+    // DEPRECATED: will be removed in v1
+    ttAdapter: function ttAdapter() {
+      return this.__ttAdapter();
+    }
+  });
+
+  return Bloodhound;
+})();
diff --git a/src/bloodhound/lru_cache.js b/src/bloodhound/lru_cache.js
new file mode 100644
index 0000000..5dea9c7
--- /dev/null
+++ b/src/bloodhound/lru_cache.js
@@ -0,0 +1,101 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+// inspired by https://github.com/jharding/lru-cache
+
+var LruCache = (function() {
+  'use strict';
+
+  function LruCache(maxSize) {
+    this.maxSize = _.isNumber(maxSize) ? maxSize : 100;
+    this.reset();
+
+    // if max size is less than 0, provide a noop cache
+    if (this.maxSize <= 0) {
+      this.set = this.get = $.noop;
+    }
+  }
+
+  _.mixin(LruCache.prototype, {
+    set: function set(key, val) {
+      var tailItem = this.list.tail, node;
+
+      // at capacity
+      if (this.size >= this.maxSize) {
+        this.list.remove(tailItem);
+        delete this.hash[tailItem.key];
+
+        this.size--;
+      }
+
+      // writing over existing key
+      if (node = this.hash[key]) {
+        node.val = val;
+        this.list.moveToFront(node);
+      }
+
+      // new key
+      else {
+        node = new Node(key, val);
+
+        this.list.add(node);
+        this.hash[key] = node;
+
+        this.size++;
+      }
+    },
+
+    get: function get(key) {
+      var node = this.hash[key];
+
+      if (node) {
+        this.list.moveToFront(node);
+        return node.val;
+      }
+    },
+
+    reset: function reset() {
+      this.size = 0;
+      this.hash = {};
+      this.list = new List();
+    }
+  });
+
+  function List() {
+    this.head = this.tail = null;
+  }
+
+  _.mixin(List.prototype, {
+    add: function add(node) {
+      if (this.head) {
+        node.next = this.head;
+        this.head.prev = node;
+      }
+
+      this.head = node;
+      this.tail = this.tail || node;
+    },
+
+    remove: function remove(node) {
+      node.prev ? node.prev.next = node.next : this.head = node.next;
+      node.next ? node.next.prev = node.prev : this.tail = node.prev;
+    },
+
+    moveToFront: function(node) {
+      this.remove(node);
+      this.add(node);
+    }
+  });
+
+  function Node(key, val) {
+    this.key = key;
+    this.val = val;
+    this.prev = this.next = null;
+  }
+
+  return LruCache;
+
+})();
diff --git a/src/bloodhound/options_parser.js b/src/bloodhound/options_parser.js
new file mode 100644
index 0000000..74107f7
--- /dev/null
+++ b/src/bloodhound/options_parser.js
@@ -0,0 +1,195 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var oParser = (function() {
+  'use strict';
+
+  return function parse(o) {
+    var defaults, sorter;
+
+    defaults = {
+      initialize: true,
+      identify: _.stringify,
+      datumTokenizer: null,
+      queryTokenizer: null,
+      sufficient: 5,
+      sorter: null,
+      local: [],
+      prefetch: null,
+      remote: null
+    };
+
+    o = _.mixin(defaults, o || {});
+
+    // throw error if required options are not set
+    !o.datumTokenizer && $.error('datumTokenizer is required');
+    !o.queryTokenizer && $.error('queryTokenizer is required');
+
+    sorter = o.sorter;
+    o.sorter = sorter ? function(x) { return x.sort(sorter); } : _.identity;
+
+    o.local = _.isFunction(o.local) ? o.local() : o.local;
+    o.prefetch = parsePrefetch(o.prefetch);
+    o.remote = parseRemote(o.remote);
+
+    return o;
+  };
+
+  function parsePrefetch(o) {
+    var defaults;
+
+    if (!o) { return null; }
+
+    defaults = {
+      url: null,
+      ttl: 24 * 60 * 60 * 1000, // 1 day
+      cache: true,
+      cacheKey: null,
+      thumbprint: '',
+      prepare: _.identity,
+      transform: _.identity,
+      transport: null
+    };
+
+    // support basic (url) and advanced configuration
+    o = _.isString(o) ? { url: o } : o;
+    o = _.mixin(defaults, o);
+
+    // throw error if required options are not set
+    !o.url && $.error('prefetch requires url to be set');
+
+    // DEPRECATED: filter will be dropped in v1
+    o.transform = o.filter || o.transform;
+
+    o.cacheKey = o.cacheKey || o.url;
+    o.thumbprint = VERSION + o.thumbprint;
+    o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax;
+
+    return o;
+  }
+
+  function parseRemote(o) {
+    var defaults;
+
+    if (!o) { return; }
+
+    defaults = {
+      url: null,
+      cache: true, // leave undocumented
+      prepare: null,
+      replace: null,
+      wildcard: null,
+      limiter: null,
+      rateLimitBy: 'debounce',
+      rateLimitWait: 300,
+      transform: _.identity,
+      transport: null
+    };
+
+    // support basic (url) and advanced configuration
+    o = _.isString(o) ? { url: o } : o;
+    o = _.mixin(defaults, o);
+
+    // throw error if required options are not set
+    !o.url && $.error('remote requires url to be set');
+
+    // DEPRECATED: filter will be dropped in v1
+    o.transform = o.filter || o.transform;
+
+    o.prepare = toRemotePrepare(o);
+    o.limiter = toLimiter(o);
+    o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax;
+
+    delete o.replace;
+    delete o.wildcard;
+    delete o.rateLimitBy;
+    delete o.rateLimitWait;
+
+    return o;
+  }
+
+  function toRemotePrepare(o) {
+    var prepare, replace, wildcard;
+
+    prepare = o.prepare;
+    replace = o.replace;
+    wildcard = o.wildcard;
+
+    if (prepare) { return prepare; }
+
+    if (replace) {
+      prepare = prepareByReplace;
+    }
+
+    else if (o.wildcard) {
+      prepare = prepareByWildcard;
+    }
+
+    else {
+      prepare = idenityPrepare;
+    }
+
+    return prepare;
+
+    function prepareByReplace(query, settings) {
+      settings.url = replace(settings.url, query);
+      return settings;
+    }
+
+    function prepareByWildcard(query, settings) {
+      settings.url = settings.url.replace(wildcard, encodeURIComponent(query));
+      return settings;
+    }
+
+    function idenityPrepare(query, settings) {
+      return settings;
+    }
+  }
+
+  function toLimiter(o) {
+    var limiter, method, wait;
+
+    limiter = o.limiter;
+    method = o.rateLimitBy;
+    wait = o.rateLimitWait;
+
+    if (!limiter) {
+      limiter = /^throttle$/i.test(method) ? throttle(wait) : debounce(wait);
+    }
+
+    return limiter;
+
+    function debounce(wait) {
+      return function debounce(fn) { return _.debounce(fn, wait); };
+    }
+
+    function throttle(wait) {
+      return function throttle(fn) { return _.throttle(fn, wait); };
+    }
+  }
+
+  function callbackToDeferred(fn) {
+    return function wrapper(o) {
+      var deferred = $.Deferred();
+
+      fn(o, onSuccess, onError);
+
+      return deferred;
+
+      function onSuccess(resp) {
+        // defer in case fn is synchronous, otherwise done
+        // and always handlers will be attached after the resolution
+        _.defer(function() { deferred.resolve(resp); });
+      }
+
+      function onError(err) {
+        // defer in case fn is synchronous, otherwise done
+        // and always handlers will be attached after the resolution
+        _.defer(function() { deferred.reject(err); });
+      }
+    };
+  }
+})();
diff --git a/src/bloodhound/persistent_storage.js b/src/bloodhound/persistent_storage.js
new file mode 100644
index 0000000..ece79a9
--- /dev/null
+++ b/src/bloodhound/persistent_storage.js
@@ -0,0 +1,147 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var PersistentStorage = (function() {
+  'use strict';
+
+  var LOCAL_STORAGE;
+
+  try {
+    LOCAL_STORAGE = window.localStorage;
+
+    // while in private browsing mode, some browsers make
+    // localStorage available, but throw an error when used
+    LOCAL_STORAGE.setItem('~~~', '!');
+    LOCAL_STORAGE.removeItem('~~~');
+  } catch (err) {
+    LOCAL_STORAGE = null;
+  }
+
+  // constructor
+  // -----------
+
+  function PersistentStorage(namespace, override) {
+    this.prefix = ['__', namespace, '__'].join('');
+    this.ttlKey = '__ttl__';
+    this.keyMatcher = new RegExp('^' + _.escapeRegExChars(this.prefix));
+
+    // for testing purpose
+    this.ls = override || LOCAL_STORAGE;
+
+    // if local storage isn't available, everything becomes a noop
+    !this.ls && this._noop();
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(PersistentStorage.prototype, {
+    // ### private
+
+    _prefix: function(key) {
+      return this.prefix + key;
+    },
+
+    _ttlKey: function(key) {
+      return this._prefix(key) + this.ttlKey;
+    },
+
+    _noop: function() {
+      this.get =
+      this.set =
+      this.remove =
+      this.clear =
+      this.isExpired = _.noop;
+    },
+
+    _safeSet: function(key, val) {
+      try {
+        this.ls.setItem(key, val);
+      } catch (err) {
+        // hit the localstorage limit so clean up and better luck next time
+        if (err.name === 'QuotaExceededError') {
+          this.clear();
+          this._noop();
+        }
+      }
+    },
+
+    // ### public
+
+    get: function(key) {
+      if (this.isExpired(key)) {
+        this.remove(key);
+      }
+
+      return decode(this.ls.getItem(this._prefix(key)));
+    },
+
+    set: function(key, val, ttl) {
+      if (_.isNumber(ttl)) {
+        this._safeSet(this._ttlKey(key), encode(now() + ttl));
+      }
+
+      else {
+        this.ls.removeItem(this._ttlKey(key));
+      }
+
+      return this._safeSet(this._prefix(key), encode(val));
+    },
+
+    remove: function(key) {
+      this.ls.removeItem(this._ttlKey(key));
+      this.ls.removeItem(this._prefix(key));
+
+      return this;
+    },
+
+    clear: function() {
+      var i, keys = gatherMatchingKeys(this.keyMatcher);
+
+      for (i = keys.length; i--;) {
+        this.remove(keys[i]);
+      }
+
+      return this;
+    },
+
+    isExpired: function(key) {
+      var ttl = decode(this.ls.getItem(this._ttlKey(key)));
+
+      return _.isNumber(ttl) && now() > ttl ? true : false;
+    }
+  });
+
+  return PersistentStorage;
+
+  // helper functions
+  // ----------------
+
+  function now() {
+    return new Date().getTime();
+  }
+
+  function encode(val) {
+    // convert undefined to null to avoid issues with JSON.parse
+    return JSON.stringify(_.isUndefined(val) ? null : val);
+  }
+
+  function decode(val) {
+    return $.parseJSON(val);
+  }
+
+  function gatherMatchingKeys(keyMatcher) {
+    var i, key, keys = [], len = LOCAL_STORAGE.length;
+
+    for (i = 0; i < len; i++) {
+      if ((key = LOCAL_STORAGE.key(i)).match(keyMatcher)) {
+        keys.push(key.replace(keyMatcher, ''));
+      }
+    }
+
+    return keys;
+  }
+})();
diff --git a/src/bloodhound/prefetch.js b/src/bloodhound/prefetch.js
new file mode 100644
index 0000000..1ad68db
--- /dev/null
+++ b/src/bloodhound/prefetch.js
@@ -0,0 +1,91 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Prefetch = (function() {
+  'use strict';
+
+  var keys;
+
+  keys = { data: 'data', protocol: 'protocol', thumbprint: 'thumbprint' };
+
+  // constructor
+  // -----------
+
+  // defaults for options are handled in options_parser
+  function Prefetch(o) {
+    this.url = o.url;
+    this.ttl = o.ttl;
+    this.cache = o.cache;
+    this.prepare = o.prepare;
+    this.transform = o.transform;
+    this.transport = o.transport;
+    this.thumbprint = o.thumbprint;
+
+    this.storage = new PersistentStorage(o.cacheKey);
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Prefetch.prototype, {
+
+    // ### private
+
+    _settings: function settings() {
+      return { url: this.url, type: 'GET', dataType: 'json' };
+    },
+
+    // ### public
+
+    store: function store(data) {
+      if (!this.cache) { return; }
+
+      this.storage.set(keys.data, data, this.ttl);
+      this.storage.set(keys.protocol, location.protocol, this.ttl);
+      this.storage.set(keys.thumbprint, this.thumbprint, this.ttl);
+    },
+
+    fromCache: function fromCache() {
+      var stored = {}, isExpired;
+
+      if (!this.cache) { return null; }
+
+      stored.data = this.storage.get(keys.data);
+      stored.protocol = this.storage.get(keys.protocol);
+      stored.thumbprint = this.storage.get(keys.thumbprint);
+
+      // the stored data is considered expired if the thumbprints
+      // don't match or if the protocol it was originally stored under
+      // has changed
+      isExpired =
+        stored.thumbprint !== this.thumbprint ||
+        stored.protocol !== location.protocol;
+
+      // TODO: if expired, remove from local storage
+
+      return stored.data && !isExpired ? stored.data : null;
+    },
+
+    fromNetwork: function(cb) {
+      var that = this, settings;
+
+      if (!cb) { return; }
+
+      settings = this.prepare(this._settings());
+      this.transport(settings).fail(onError).done(onResponse);
+
+      function onError() { cb(true); }
+      function onResponse(resp) { cb(null, that.transform(resp)); }
+    },
+
+    clear: function clear() {
+      this.storage.clear();
+      return this;
+    }
+  });
+
+  return Prefetch;
+})();
diff --git a/src/bloodhound/remote.js b/src/bloodhound/remote.js
new file mode 100644
index 0000000..66fc619
--- /dev/null
+++ b/src/bloodhound/remote.js
@@ -0,0 +1,58 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Remote = (function() {
+  'use strict';
+
+  // constructor
+  // -----------
+
+  function Remote(o) {
+    this.url = o.url;
+    this.prepare = o.prepare;
+    this.transform = o.transform;
+
+    this.transport = new Transport({
+      cache: o.cache,
+      limiter: o.limiter,
+      transport: o.transport
+    });
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Remote.prototype, {
+    // ### private
+
+    _settings: function settings() {
+      return { url: this.url, type: 'GET', dataType: 'json' };
+    },
+
+    // ### public
+
+    get: function get(query, cb) {
+      var that = this, settings;
+
+      if (!cb) { return; }
+
+      query = query || '';
+      settings = this.prepare(query, this._settings());
+
+      return this.transport.get(settings, onResponse);
+
+      function onResponse(err, resp) {
+        err ? cb([]) : cb(that.transform(resp));
+      }
+    },
+
+    cancelLastRequest: function cancelLastRequest() {
+      this.transport.cancel();
+    }
+  });
+
+  return Remote;
+})();
diff --git a/src/bloodhound/search_index.js b/src/bloodhound/search_index.js
new file mode 100644
index 0000000..84b8c6a
--- /dev/null
+++ b/src/bloodhound/search_index.js
@@ -0,0 +1,191 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var SearchIndex = window.SearchIndex = (function() {
+  'use strict';
+
+  var CHILDREN = 'c', IDS = 'i';
+
+  // constructor
+  // -----------
+
+  function SearchIndex(o) {
+    o = o || {};
+
+    if (!o.datumTokenizer || !o.queryTokenizer) {
+      $.error('datumTokenizer and queryTokenizer are both required');
+    }
+
+    this.identify = o.identify || _.stringify;
+    this.datumTokenizer = o.datumTokenizer;
+    this.queryTokenizer = o.queryTokenizer;
+
+    this.reset();
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(SearchIndex.prototype, {
+
+    // ### public
+
+    bootstrap: function bootstrap(o) {
+      this.datums = o.datums;
+      this.trie = o.trie;
+    },
+
+    add: function(data) {
+      var that = this;
+
+      data = _.isArray(data) ? data : [data];
+
+      _.each(data, function(datum) {
+        var id, tokens;
+
+        that.datums[id = that.identify(datum)] = datum;
+        tokens = normalizeTokens(that.datumTokenizer(datum));
+
+        _.each(tokens, function(token) {
+          var node, chars, ch;
+
+          node = that.trie;
+          chars = token.split('');
+
+          while (ch = chars.shift()) {
+            node = node[CHILDREN][ch] || (node[CHILDREN][ch] = newNode());
+            node[IDS].push(id);
+          }
+        });
+      });
+    },
+
+    get: function get(ids) {
+      var that = this;
+
+      return _.map(ids, function(id) { return that.datums[id]; });
+    },
+
+    search: function search(query) {
+      var that = this, tokens, matches;
+
+      tokens = normalizeTokens(this.queryTokenizer(query));
+
+      _.each(tokens, function(token) {
+        var node, chars, ch, ids;
+
+        // previous tokens didn't share any matches
+        if (matches && matches.length === 0) {
+          return false;
+        }
+
+        node = that.trie;
+        chars = token.split('');
+
+        while (node && (ch = chars.shift())) {
+          node = node[CHILDREN][ch];
+        }
+
+        if (node && chars.length === 0) {
+          ids = node[IDS].slice(0);
+          matches = matches ? getIntersection(matches, ids) : ids;
+        }
+
+        // break early if we find out there are no possible matches
+        else {
+          matches = [];
+          return false;
+        }
+      });
+
+      return matches ?
+        _.map(unique(matches), function(id) { return that.datums[id]; }) : [];
+    },
+
+    all: function all() {
+      var values = [];
+
+      for (var key in this.datums) {
+        values.push(this.datums[key]);
+      }
+
+      return values;
+    },
+
+    reset: function reset() {
+      this.datums = {};
+      this.trie = newNode();
+    },
+
+    serialize: function serialize() {
+      return { datums: this.datums, trie: this.trie };
+    }
+  });
+
+  return SearchIndex;
+
+  // helper functions
+  // ----------------
+
+  function normalizeTokens(tokens) {
+   // filter out falsy tokens
+    tokens = _.filter(tokens, function(token) { return !!token; });
+
+    // normalize tokens
+    tokens = _.map(tokens, function(token) { return token.toLowerCase(); });
+
+    return tokens;
+  }
+
+  function newNode() {
+    var node = {};
+
+    node[IDS] = [];
+    node[CHILDREN] = {};
+
+    return node;
+  }
+
+  function unique(array) {
+    var seen = {}, uniques = [];
+
+    for (var i = 0, len = array.length; i < len; i++) {
+      if (!seen[array[i]]) {
+        seen[array[i]] = true;
+        uniques.push(array[i]);
+      }
+    }
+
+    return uniques;
+  }
+
+  function getIntersection(arrayA, arrayB) {
+    var ai = 0, bi = 0, intersection = [];
+
+    arrayA = arrayA.sort();
+    arrayB = arrayB.sort();
+
+    var lenArrayA = arrayA.length, lenArrayB = arrayB.length;
+
+    while (ai < lenArrayA && bi < lenArrayB) {
+      if (arrayA[ai] < arrayB[bi]) {
+        ai++;
+      }
+
+      else if (arrayA[ai] > arrayB[bi]) {
+        bi++;
+      }
+
+      else {
+        intersection.push(arrayA[ai]);
+        ai++;
+        bi++;
+      }
+    }
+
+    return intersection;
+  }
+})();
diff --git a/src/bloodhound/tokenizers.js b/src/bloodhound/tokenizers.js
new file mode 100644
index 0000000..38d3a26
--- /dev/null
+++ b/src/bloodhound/tokenizers.js
@@ -0,0 +1,44 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var tokenizers = (function() {
+  'use strict';
+
+  return {
+    nonword: nonword,
+    whitespace: whitespace,
+    obj: {
+      nonword: getObjTokenizer(nonword),
+      whitespace: getObjTokenizer(whitespace)
+    }
+  };
+
+  function whitespace(str) {
+    str = _.toStr(str);
+    return str ? str.split(/\s+/) : [];
+  }
+
+  function nonword(str) {
+    str = _.toStr(str);
+    return str ? str.split(/\W+/) : [];
+  }
+
+  function getObjTokenizer(tokenizer) {
+    return function setKey(keys) {
+      keys = _.isArray(keys) ? keys : [].slice.call(arguments, 0);
+
+      return function tokenize(o) {
+        var tokens = [];
+
+        _.each(keys, function(k) {
+          tokens = tokens.concat(tokenizer(_.toStr(o[k])));
+        });
+
+        return tokens;
+      };
+    };
+  }
+})();
diff --git a/src/bloodhound/transport.js b/src/bloodhound/transport.js
new file mode 100644
index 0000000..1b45e70
--- /dev/null
+++ b/src/bloodhound/transport.js
@@ -0,0 +1,130 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Transport = (function() {
+  'use strict';
+
+  var pendingRequestsCount = 0,
+      pendingRequests = {},
+      maxPendingRequests = 6,
+      sharedCache = new LruCache(10);
+
+  // constructor
+  // -----------
+
+  function Transport(o) {
+    o = o || {};
+
+    this.cancelled = false;
+    this.lastReq = null;
+
+    this._send = o.transport;
+    this._get = o.limiter ? o.limiter(this._get) : this._get;
+
+    this._cache = o.cache === false ? new LruCache(0) : sharedCache;
+  }
+
+  // static methods
+  // --------------
+
+  Transport.setMaxPendingRequests = function setMaxPendingRequests(num) {
+    maxPendingRequests = num;
+  };
+
+  Transport.resetCache = function resetCache() {
+    sharedCache.reset();
+  };
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Transport.prototype, {
+
+    // ### private
+
+    _fingerprint: function fingerprint(o) {
+      o = o || {};
+      return o.url + o.type + $.param(o.data || {});
+    },
+
+    _get: function(o, cb) {
+      var that = this, fingerprint, jqXhr;
+
+      fingerprint = this._fingerprint(o);
+
+      // #149: don't make a network request if there has been a cancellation
+      // or if the url doesn't match the last url Transport#get was invoked with
+      if (this.cancelled || fingerprint !== this.lastReq) { return; }
+
+      // a request is already in progress, piggyback off of it
+      if (jqXhr = pendingRequests[fingerprint]) {
+        jqXhr.done(done).fail(fail);
+      }
+
+      // under the pending request threshold, so fire off a request
+      else if (pendingRequestsCount < maxPendingRequests) {
+        pendingRequestsCount++;
+        pendingRequests[fingerprint] =
+          this._send(o).done(done).fail(fail).always(always);
+      }
+
+      // at the pending request threshold, so hang out in the on deck circle
+      else {
+        this.onDeckRequestArgs = [].slice.call(arguments, 0);
+      }
+
+      function done(resp) {
+        cb(null, resp);
+        that._cache.set(fingerprint, resp);
+      }
+
+      function fail() {
+        cb(true);
+      }
+
+      function always() {
+        pendingRequestsCount--;
+        delete pendingRequests[fingerprint];
+
+        // ensures request is always made for the last query
+        if (that.onDeckRequestArgs) {
+          that._get.apply(that, that.onDeckRequestArgs);
+          that.onDeckRequestArgs = null;
+        }
+      }
+    },
+
+    // ### public
+
+    get: function(o, cb) {
+      var resp, fingerprint;
+
+      cb = cb || $.noop;
+      o = _.isString(o) ? { url: o } : (o || {});
+
+      fingerprint = this._fingerprint(o);
+
+      this.cancelled = false;
+      this.lastReq = fingerprint;
+
+      // in-memory cache hit
+      if (resp = this._cache.get(fingerprint)) {
+        cb(null, resp);
+      }
+
+      // go to network
+      else {
+        this._get(o, cb);
+      }
+    },
+
+    cancel: function() {
+      this.cancelled = true;
+    }
+  });
+
+  return Transport;
+})();
diff --git a/src/bloodhound/version.js b/src/bloodhound/version.js
new file mode 100644
index 0000000..c816cff
--- /dev/null
+++ b/src/bloodhound/version.js
@@ -0,0 +1,7 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var VERSION = '%VERSION%';
diff --git a/src/common/utils.js b/src/common/utils.js
new file mode 100644
index 0000000..96faede
--- /dev/null
+++ b/src/common/utils.js
@@ -0,0 +1,164 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var _ = (function() {
+  'use strict';
+
+  return {
+    isMsie: function() {
+      // from https://github.com/ded/bowser/blob/master/bowser.js
+      return (/(msie|trident)/i).test(navigator.userAgent) ?
+        navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false;
+    },
+
+    isBlankString: function(str) { return !str || /^\s*$/.test(str); },
+
+    // http://stackoverflow.com/a/6969486
+    escapeRegExChars: function(str) {
+      return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
+    },
+
+    isString: function(obj) { return typeof obj === 'string'; },
+
+    isNumber: function(obj) { return typeof obj === 'number'; },
+
+    isArray: $.isArray,
+
+    isFunction: $.isFunction,
+
+    isObject: $.isPlainObject,
+
+    isUndefined: function(obj) { return typeof obj === 'undefined'; },
+
+    isElement: function(obj) { return !!(obj && obj.nodeType === 1); },
+
+    isJQuery: function(obj) { return obj instanceof $; },
+
+    toStr: function toStr(s) {
+      return (_.isUndefined(s) || s === null) ? '' : s + '';
+    },
+
+    bind: $.proxy,
+
+    each: function(collection, cb) {
+      // stupid argument order for jQuery.each
+      $.each(collection, reverseArgs);
+
+      function reverseArgs(index, value) { return cb(value, index); }
+    },
+
+    map: $.map,
+
+    filter: $.grep,
+
+    every: function(obj, test) {
+      var result = true;
+
+      if (!obj) { return result; }
+
+      $.each(obj, function(key, val) {
+        if (!(result = test.call(null, val, key, obj))) {
+          return false;
+        }
+      });
+
+      return !!result;
+    },
+
+    some: function(obj, test) {
+      var result = false;
+
+      if (!obj) { return result; }
+
+      $.each(obj, function(key, val) {
+        if (result = test.call(null, val, key, obj)) {
+          return false;
+        }
+      });
+
+      return !!result;
+    },
+
+    mixin: $.extend,
+
+    identity: function(x) { return x; },
+
+    clone: function(obj) { return $.extend(true, {}, obj); },
+
+    getIdGenerator: function() {
+      var counter = 0;
+      return function() { return counter++; };
+    },
+
+    templatify: function templatify(obj) {
+      return $.isFunction(obj) ? obj : template;
+
+      function template() { return String(obj); }
+    },
+
+    defer: function(fn) { setTimeout(fn, 0); },
+
+    debounce: function(func, wait, immediate) {
+      var timeout, result;
+
+      return function() {
+        var context = this, args = arguments, later, callNow;
+
+        later = function() {
+          timeout = null;
+          if (!immediate) { result = func.apply(context, args); }
+        };
+
+        callNow = immediate && !timeout;
+
+        clearTimeout(timeout);
+        timeout = setTimeout(later, wait);
+
+        if (callNow) { result = func.apply(context, args); }
+
+        return result;
+      };
+    },
+
+    throttle: function(func, wait) {
+      var context, args, timeout, result, previous, later;
+
+      previous = 0;
+      later = function() {
+        previous = new Date();
+        timeout = null;
+        result = func.apply(context, args);
+      };
+
+      return function() {
+        var now = new Date(),
+            remaining = wait - (now - previous);
+
+        context = this;
+        args = arguments;
+
+        if (remaining <= 0) {
+          clearTimeout(timeout);
+          timeout = null;
+          previous = now;
+          result = func.apply(context, args);
+        }
+
+        else if (!timeout) {
+          timeout = setTimeout(later, remaining);
+        }
+
+        return result;
+      };
+    },
+
+    stringify: function(val) {
+      return _.isString(val) ? val : JSON.stringify(val);
+    },
+
+    noop: function() {}
+  };
+})();
diff --git a/src/typeahead/dataset.js b/src/typeahead/dataset.js
new file mode 100644
index 0000000..fce09c3
--- /dev/null
+++ b/src/typeahead/dataset.js
@@ -0,0 +1,330 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Dataset = (function() {
+  'use strict';
+
+  var keys, nameGenerator;
+
+  keys = {
+    val: 'tt-selectable-display',
+    obj: 'tt-selectable-object'
+  };
+
+  nameGenerator = _.getIdGenerator();
+
+  // constructor
+  // -----------
+
+  function Dataset(o, www) {
+    o = o || {};
+    o.templates = o.templates || {};
+
+    // DEPRECATED: empty will be dropped in v1
+    o.templates.notFound = o.templates.notFound || o.templates.empty;
+
+    if (!o.source) {
+      $.error('missing source');
+    }
+
+    if (!o.node) {
+      $.error('missing node');
+    }
+
+    if (o.name && !isValidName(o.name)) {
+      $.error('invalid dataset name: ' + o.name);
+    }
+
+    www.mixin(this);
+
+    this.highlight = !!o.highlight;
+    this.name = o.name || nameGenerator();
+
+    this.limit = o.limit || 5;
+    this.displayFn = getDisplayFn(o.display || o.displayKey);
+    this.templates = getTemplates(o.templates, this.displayFn);
+
+    // use duck typing to see if source is a bloodhound instance by checking
+    // for the __ttAdapter property; otherwise assume it is a function
+    this.source = o.source.__ttAdapter ? o.source.__ttAdapter() : o.source;
+
+    // if the async option is undefined, inspect the source signature as
+    // a hint to figuring out of the source will return async suggestions
+    this.async = _.isUndefined(o.async) ? this.source.length > 2 : !!o.async;
+
+    this._resetLastSuggestion();
+
+    this.$el = $(o.node)
+    .addClass(this.classes.dataset)
+    .addClass(this.classes.dataset + '-' + this.name);
+  }
+
+  // static methods
+  // --------------
+
+  Dataset.extractData = function extractData(el) {
+    var $el = $(el);
+
+    if ($el.data(keys.obj)) {
+      return {
+        val: $el.data(keys.val) || '',
+        obj: $el.data(keys.obj) || null
+      };
+    }
+
+    return null;
+  };
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Dataset.prototype, EventEmitter, {
+
+    // ### private
+
+    _overwrite: function overwrite(query, suggestions) {
+      suggestions = suggestions || [];
+
+      // got suggestions: overwrite dom with suggestions
+      if (suggestions.length) {
+        this._renderSuggestions(query, suggestions);
+      }
+
+      // no suggestions, expecting async: overwrite dom with pending
+      else if (this.async && this.templates.pending) {
+        this._renderPending(query);
+      }
+
+      // no suggestions, not expecting async: overwrite dom with not found
+      else if (!this.async && this.templates.notFound) {
+        this._renderNotFound(query);
+      }
+
+      // nothing to render: empty dom
+      else {
+        this._empty();
+      }
+
+      this.trigger('rendered', this.name, suggestions, false);
+    },
+
+    _append: function append(query, suggestions) {
+      suggestions = suggestions || [];
+
+      // got suggestions, sync suggestions exist: append suggestions to dom
+      if (suggestions.length && this.$lastSuggestion.length) {
+        this._appendSuggestions(query, suggestions);
+      }
+
+      // got suggestions, no sync suggestions: overwrite dom with suggestions
+      else if (suggestions.length) {
+        this._renderSuggestions(query, suggestions);
+      }
+
+      // no async/sync suggestions: overwrite dom with not found
+      else if (!this.$lastSuggestion.length && this.templates.notFound) {
+        this._renderNotFound(query);
+      }
+
+      this.trigger('rendered', this.name, suggestions, true);
+    },
+
+    _renderSuggestions: function renderSuggestions(query, suggestions) {
+      var $fragment;
+
+      $fragment = this._getSuggestionsFragment(query, suggestions);
+      this.$lastSuggestion = $fragment.children().last();
+
+      this.$el.html($fragment)
+      .prepend(this._getHeader(query, suggestions))
+      .append(this._getFooter(query, suggestions));
+    },
+
+    _appendSuggestions: function appendSuggestions(query, suggestions) {
+      var $fragment, $lastSuggestion;
+
+      $fragment = this._getSuggestionsFragment(query, suggestions);
+      $lastSuggestion = $fragment.children().last();
+
+      this.$lastSuggestion.after($fragment);
+
+      this.$lastSuggestion = $lastSuggestion;
+    },
+
+    _renderPending: function renderPending(query) {
+      var template = this.templates.pending;
+
+      this._resetLastSuggestion();
+      template && this.$el.html(template({
+        query: query,
+        dataset: this.name,
+      }));
+    },
+
+    _renderNotFound: function renderNotFound(query) {
+      var template = this.templates.notFound;
+
+      this._resetLastSuggestion();
+      template && this.$el.html(template({
+        query: query,
+        dataset: this.name,
+      }));
+    },
+
+    _empty: function empty() {
+      this.$el.empty();
+      this._resetLastSuggestion();
+    },
+
+    _getSuggestionsFragment: function getSuggestionsFragment(query, suggestions) {
+      var that = this, fragment;
+
+      fragment = document.createDocumentFragment();
+      _.each(suggestions, function getSuggestionNode(suggestion) {
+        var $el, context;
+
+        context = that._injectQuery(query, suggestion);
+
+        $el = $(that.templates.suggestion(context))
+        .data(keys.obj, suggestion)
+        .data(keys.val, that.displayFn(suggestion))
+        .addClass(that.classes.suggestion + ' ' + that.classes.selectable);
+
+        fragment.appendChild($el[0]);
+      });
+
+      this.highlight && highlight({
+        className: this.classes.highlight,
+        node: fragment,
+        pattern: query
+      });
+
+      return $(fragment);
+    },
+
+    _getFooter: function getFooter(query, suggestions) {
+      return this.templates.footer ?
+        this.templates.footer({
+          query: query,
+          suggestions: suggestions,
+          dataset: this.name
+        }) : null;
+    },
+
+    _getHeader: function getHeader(query, suggestions) {
+      return this.templates.header ?
+        this.templates.header({
+          query: query,
+          suggestions: suggestions,
+          dataset: this.name
+        }) : null;
+    },
+
+    _resetLastSuggestion: function resetLastSuggestion() {
+      this.$lastSuggestion = $();
+    },
+
+    _injectQuery: function injectQuery(query, obj) {
+      return _.isObject(obj) ? _.mixin({ _query: query }, obj) : obj;
+    },
+
+    // ### public
+
+    update: function update(query) {
+      var that = this, canceled = false, syncCalled = false, rendered = 0;
+
+      // cancel possible pending update
+      this.cancel();
+
+      this.cancel = function cancel() {
+        canceled = true;
+        that.cancel = $.noop;
+        that.async && that.trigger('asyncCanceled', query);
+      };
+
+      this.source(query, sync, async);
+      !syncCalled && sync([]);
+
+      function sync(suggestions) {
+        if (syncCalled) { return; }
+
+        syncCalled = true;
+        suggestions = (suggestions || []).slice(0, that.limit);
+        rendered = suggestions.length;
+
+        that._overwrite(query, suggestions);
+
+        if (rendered < that.limit && that.async) {
+          that.trigger('asyncRequested', query);
+        }
+      }
+
+      function async(suggestions) {
+        suggestions = suggestions || [];
+
+        // if the update has been canceled or if the query has changed
+        // do not render the suggestions as they've become outdated
+        if (!canceled && rendered < that.limit) {
+          that.cancel = $.noop;
+          rendered += suggestions.length;
+          that._append(query, suggestions.slice(0, that.limit - rendered));
+
+          that.async && that.trigger('asyncReceived', query);
+        }
+      }
+    },
+
+    // cancel function gets set in #update
+    cancel: $.noop,
+
+    clear: function clear() {
+      this._empty();
+      this.cancel();
+      this.trigger('cleared');
+    },
+
+    isEmpty: function isEmpty() {
+      return this.$el.is(':empty');
+    },
+
+    destroy: function destroy() {
+      // #970
+      this.$el = $('<div>');
+    }
+  });
+
+  return Dataset;
+
+  // helper functions
+  // ----------------
+
+  function getDisplayFn(display) {
+    display = display || _.stringify;
+
+    return _.isFunction(display) ? display : displayFn;
+
+    function displayFn(obj) { return obj[display]; }
+  }
+
+  function getTemplates(templates, displayFn) {
+    return {
+      notFound: templates.notFound && _.templatify(templates.notFound),
+      pending: templates.pending && _.templatify(templates.pending),
+      header: templates.header && _.templatify(templates.header),
+      footer: templates.footer && _.templatify(templates.footer),
+      suggestion: templates.suggestion || suggestionTemplate
+    };
+
+    function suggestionTemplate(context) {
+      return $('<div>').text(displayFn(context));
+    }
+  }
+
+  function isValidName(str) {
+    // dashes, underscores, letters, and numbers
+    return (/^[_a-zA-Z0-9-]+$/).test(str);
+  }
+})();
diff --git a/src/typeahead/default_menu.js b/src/typeahead/default_menu.js
new file mode 100644
index 0000000..799dfff
--- /dev/null
+++ b/src/typeahead/default_menu.js
@@ -0,0 +1,75 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var DefaultMenu = (function() {
+  'use strict';
+
+  var s = Menu.prototype;
+
+  function DefaultMenu() {
+    Menu.apply(this, [].slice.call(arguments, 0));
+  }
+
+  _.mixin(DefaultMenu.prototype, Menu.prototype, {
+    // overrides
+    // ---------
+
+    open: function open() {
+      // only display the menu when there's something to be shown
+      !this._allDatasetsEmpty() && this._show();
+      return s.open.apply(this, [].slice.call(arguments, 0));
+    },
+
+    close: function close() {
+      this._hide();
+      return s.close.apply(this, [].slice.call(arguments, 0));
+    },
+
+    _onRendered: function onRendered() {
+      if (this._allDatasetsEmpty()) {
+        this._hide();
+      }
+
+      else {
+        this.isOpen() && this._show();
+      }
+
+      return s._onRendered.apply(this, [].slice.call(arguments, 0));
+    },
+
+    _onCleared: function onCleared() {
+      if (this._allDatasetsEmpty()) {
+        this._hide();
+      }
+
+      else {
+        this.isOpen() && this._show();
+      }
+
+      return s._onCleared.apply(this, [].slice.call(arguments, 0));
+    },
+
+    setLanguageDirection: function setLanguageDirection(dir) {
+      this.$node.css(dir === 'ltr' ? this.css.ltr : this.css.rtl);
+      return s.setLanguageDirection.apply(this, [].slice.call(arguments, 0));
+    },
+
+    // private
+    // ---------
+
+    _hide: function hide() {
+      this.$node.hide();
+    },
+
+    _show: function show() {
+      // can't use jQuery#show because $node is a span element we want
+      // display: block; not dislay: inline;
+      this.$node.css('display', 'block');
+    }
+  });
+
+  return DefaultMenu;
+})();
diff --git a/src/typeahead/event_bus.js b/src/typeahead/event_bus.js
new file mode 100644
index 0000000..77e681c
--- /dev/null
+++ b/src/typeahead/event_bus.js
@@ -0,0 +1,78 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var EventBus = (function() {
+  'use strict';
+
+  var namespace, deprecationMap;
+
+  namespace = 'typeahead:';
+
+  // DEPRECATED: will be remove in v1
+  //
+  // NOTE: there is no deprecation plan for the opened and closed event
+  // as their behavior has changed enough that it wouldn't make sense
+  deprecationMap = {
+    render: 'rendered',
+    cursorchange: 'cursorchanged',
+    select: 'selected',
+    autocomplete: 'autocompleted'
+  };
+
+  // constructor
+  // -----------
+
+  function EventBus(o) {
+    if (!o || !o.el) {
+      $.error('EventBus initialized without el');
+    }
+
+    this.$el = $(o.el);
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(EventBus.prototype, {
+
+    // ### private
+
+    _trigger: function(type, args) {
+      var $e;
+
+      $e = $.Event(namespace + type);
+      (args = args || []).unshift($e);
+
+      this.$el.trigger.apply(this.$el, args);
+
+      return $e;
+    },
+
+    // ### public
+
+    before: function(type) {
+      var args, $e;
+
+      args = [].slice.call(arguments, 1);
+      $e = this._trigger('before' + type, args);
+
+      return $e.isDefaultPrevented();
+    },
+
+    trigger: function(type) {
+      var deprecatedType;
+
+      this._trigger(type, [].slice.call(arguments, 1));
+
+      // TODO: remove in v1
+      if (deprecatedType = deprecationMap[type]) {
+        this._trigger(deprecatedType, [].slice.call(arguments, 1));
+      }
+    }
+  });
+
+  return EventBus;
+})();
diff --git a/src/typeahead/event_emitter.js b/src/typeahead/event_emitter.js
new file mode 100644
index 0000000..0643e37
--- /dev/null
+++ b/src/typeahead/event_emitter.js
@@ -0,0 +1,119 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+// inspired by https://github.com/jharding/boomerang
+
+var EventEmitter = (function() {
+  'use strict';
+
+  var splitter = /\s+/, nextTick = getNextTick();
+
+  return {
+    onSync: onSync,
+    onAsync: onAsync,
+    off: off,
+    trigger: trigger
+  };
+
+  function on(method, types, cb, context) {
+    var type;
+
+    if (!cb) { return this; }
+
+    types = types.split(splitter);
+    cb = context ? bindContext(cb, context) : cb;
+
+    this._callbacks = this._callbacks || {};
+
+    while (type = types.shift()) {
+      this._callbacks[type] = this._callbacks[type] || { sync: [], async: [] };
+      this._callbacks[type][method].push(cb);
+    }
+
+    return this;
+  }
+
+  function onAsync(types, cb, context) {
+    return on.call(this, 'async', types, cb, context);
+  }
+
+  function onSync(types, cb, context) {
+    return on.call(this, 'sync', types, cb, context);
+  }
+
+  function off(types) {
+    var type;
+
+    if (!this._callbacks) { return this; }
+
+    types = types.split(splitter);
+
+    while (type = types.shift()) {
+      delete this._callbacks[type];
+    }
+
+    return this;
+  }
+
+  function trigger(types) {
+    var type, callbacks, args, syncFlush, asyncFlush;
+
+    if (!this._callbacks) { return this; }
+
+    types = types.split(splitter);
+    args = [].slice.call(arguments, 1);
+
+    while ((type = types.shift()) && (callbacks = this._callbacks[type])) {
+      syncFlush = getFlush(callbacks.sync, this, [type].concat(args));
+      asyncFlush = getFlush(callbacks.async, this, [type].concat(args));
+
+      syncFlush() && nextTick(asyncFlush);
+    }
+
+    return this;
+  }
+
+  function getFlush(callbacks, context, args) {
+    return flush;
+
+    function flush() {
+      var cancelled;
+
+      for (var i = 0, len = callbacks.length; !cancelled && i < len; i += 1) {
+        // only cancel if the callback explicitly returns false
+        cancelled = callbacks[i].apply(context, args) === false;
+      }
+
+      return !cancelled;
+    }
+  }
+
+  function getNextTick() {
+    var nextTickFn;
+
+    // IE10+
+    if (window.setImmediate) {
+      nextTickFn = function nextTickSetImmediate(fn) {
+        setImmediate(function() { fn(); });
+      };
+    }
+
+    // old browsers
+    else {
+      nextTickFn = function nextTickSetTimeout(fn) {
+        setTimeout(function() { fn(); }, 0);
+      };
+    }
+
+    return nextTickFn;
+  }
+
+  function bindContext(fn, context) {
+    return fn.bind ?
+      fn.bind(context) :
+      function() { fn.apply(context, [].slice.call(arguments, 0)); };
+  }
+})();
diff --git a/src/typeahead/highlight.js b/src/typeahead/highlight.js
new file mode 100644
index 0000000..1e33821
--- /dev/null
+++ b/src/typeahead/highlight.js
@@ -0,0 +1,84 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+// inspired by https://github.com/jharding/bearhug
+
+var highlight = (function(doc) {
+  'use strict';
+
+  var defaults = {
+        node: null,
+        pattern: null,
+        tagName: 'strong',
+        className: null,
+        wordsOnly: false,
+        caseSensitive: false
+      };
+
+  return function hightlight(o) {
+    var regex;
+
+    o = _.mixin({}, defaults, o);
+
+    if (!o.node || !o.pattern) {
+      // fail silently
+      return;
+    }
+
+    // support wrapping multiple patterns
+    o.pattern = _.isArray(o.pattern) ? o.pattern : [o.pattern];
+
+    regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly);
+    traverse(o.node, hightlightTextNode);
+
+    function hightlightTextNode(textNode) {
+      var match, patternNode, wrapperNode;
+
+      if (match = regex.exec(textNode.data)) {
+        wrapperNode = doc.createElement(o.tagName);
+        o.className && (wrapperNode.className = o.className);
+
+        patternNode = textNode.splitText(match.index);
+        patternNode.splitText(match[0].length);
+        wrapperNode.appendChild(patternNode.cloneNode(true));
+
+        textNode.parentNode.replaceChild(wrapperNode, patternNode);
+      }
+
+      return !!match;
+    }
+
+    function traverse(el, hightlightTextNode) {
+      var childNode, TEXT_NODE_TYPE = 3;
+
+      for (var i = 0; i < el.childNodes.length; i++) {
+        childNode = el.childNodes[i];
+
+        if (childNode.nodeType === TEXT_NODE_TYPE) {
+          i += hightlightTextNode(childNode) ? 1 : 0;
+        }
+
+        else {
+          traverse(childNode, hightlightTextNode);
+        }
+      }
+    }
+  };
+
+  function getRegex(patterns, caseSensitive, wordsOnly) {
+    var escapedPatterns = [], regexStr;
+
+    for (var i = 0, len = patterns.length; i < len; i++) {
+      escapedPatterns.push(_.escapeRegExChars(patterns[i]));
+    }
+
+    regexStr = wordsOnly ?
+      '\\b(' + escapedPatterns.join('|') + ')\\b' :
+      '(' + escapedPatterns.join('|') + ')';
+
+    return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, 'i');
+  }
+})(window.document);
diff --git a/src/typeahead/input.js b/src/typeahead/input.js
new file mode 100644
index 0000000..2003124
--- /dev/null
+++ b/src/typeahead/input.js
@@ -0,0 +1,339 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Input = (function() {
+  'use strict';
+
+  var specialKeyCodeMap;
+
+  specialKeyCodeMap = {
+    9: 'tab',
+    27: 'esc',
+    37: 'left',
+    39: 'right',
+    13: 'enter',
+    38: 'up',
+    40: 'down'
+  };
+
+  // constructor
+  // -----------
+
+  function Input(o, www) {
+    o = o || {};
+
+    if (!o.input) {
+      $.error('input is missing');
+    }
+
+    www.mixin(this);
+
+    this.$hint = $(o.hint);
+    this.$input = $(o.input);
+
+    // the query defaults to whatever the value of the input is
+    // on initialization, it'll most likely be an empty string
+    this.query = this.$input.val();
+
+    // for tracking when a change event should be triggered
+    this.queryWhenFocused = this.hasFocus() ? this.query : null;
+
+    // helps with calculating the width of the input's value
+    this.$overflowHelper = buildOverflowHelper(this.$input);
+
+    // detect the initial lang direction
+    this._checkLanguageDirection();
+
+    // if no hint, noop all the hint related functions
+    if (this.$hint.length === 0) {
+      this.setHint =
+      this.getHint =
+      this.clearHint =
+      this.clearHintIfInvalid = _.noop;
+    }
+  }
+
+  // static methods
+  // --------------
+
+  Input.normalizeQuery = function(str) {
+    // strips leading whitespace and condenses all whitespace
+    return (_.toStr(str)).replace(/^\s*/g, '').replace(/\s{2,}/g, ' ');
+  };
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Input.prototype, EventEmitter, {
+
+    // ### event handlers
+
+    _onBlur: function onBlur() {
+      this.resetInputValue();
+      this.trigger('blurred');
+    },
+
+    _onFocus: function onFocus() {
+      this.queryWhenFocused = this.query;
+      this.trigger('focused');
+    },
+
+    _onKeydown: function onKeydown($e) {
+      // which is normalized and consistent (but not for ie)
+      var keyName = specialKeyCodeMap[$e.which || $e.keyCode];
+
+      this._managePreventDefault(keyName, $e);
+      if (keyName && this._shouldTrigger(keyName, $e)) {
+        this.trigger(keyName + 'Keyed', $e);
+      }
+    },
+
+    _onInput: function onInput() {
+      this._setQuery(this.getInputValue());
+      this.clearHintIfInvalid();
+      this._checkLanguageDirection();
+    },
+
+    // ### private
+
+    _managePreventDefault: function managePreventDefault(keyName, $e) {
+      var preventDefault;
+
+      switch (keyName) {
+        case 'up':
+        case 'down':
+          preventDefault = !withModifier($e);
+          break;
+
+        default:
+          preventDefault = false;
+      }
+
+      preventDefault && $e.preventDefault();
+    },
+
+    _shouldTrigger: function shouldTrigger(keyName, $e) {
+      var trigger;
+
+      switch (keyName) {
+        case 'tab':
+          trigger = !withModifier($e);
+          break;
+
+        default:
+          trigger = true;
+      }
+
+      return trigger;
+    },
+
+    _checkLanguageDirection: function checkLanguageDirection() {
+      var dir = (this.$input.css('direction') || 'ltr').toLowerCase();
+
+      if (this.dir !== dir) {
+        this.dir = dir;
+        this.$hint.attr('dir', dir);
+        this.trigger('langDirChanged', dir);
+      }
+    },
+
+    _setQuery: function setQuery(val, silent) {
+      var areEquivalent, hasDifferentWhitespace;
+
+      areEquivalent = areQueriesEquivalent(val, this.query);
+      hasDifferentWhitespace = areEquivalent ?
+        this.query.length !== val.length : false;
+
+      this.query = val;
+
+      if (!silent && !areEquivalent) {
+        this.trigger('queryChanged', this.query);
+      }
+
+      else if (!silent && hasDifferentWhitespace) {
+        this.trigger('whitespaceChanged', this.query);
+      }
+    },
+
+    // ### public
+
+    bind: function() {
+      var that = this, onBlur, onFocus, onKeydown, onInput;
+
+      // bound functions
+      onBlur = _.bind(this._onBlur, this);
+      onFocus = _.bind(this._onFocus, this);
+      onKeydown = _.bind(this._onKeydown, this);
+      onInput = _.bind(this._onInput, this);
+
+      this.$input
+      .on('blur.tt', onBlur)
+      .on('focus.tt', onFocus)
+      .on('keydown.tt', onKeydown);
+
+      // ie8 don't support the input event
+      // ie9 doesn't fire the input event when characters are removed
+      if (!_.isMsie() || _.isMsie() > 9) {
+        this.$input.on('input.tt', onInput);
+      }
+
+      else {
+        this.$input.on('keydown.tt keypress.tt cut.tt paste.tt', function($e) {
+          // if a special key triggered this, ignore it
+          if (specialKeyCodeMap[$e.which || $e.keyCode]) { return; }
+
+          // give the browser a chance to update the value of the input
+          // before checking to see if the query changed
+          _.defer(_.bind(that._onInput, that, $e));
+        });
+      }
+
+      return this;
+    },
+
+    focus: function focus() {
+      this.$input.focus();
+    },
+
+    blur: function blur() {
+      this.$input.blur();
+    },
+
+    getLangDir: function getLangDir() {
+      return this.dir;
+    },
+
+    getQuery: function getQuery() {
+      return this.query || '';
+    },
+
+    setQuery: function setQuery(val, silent) {
+      this.setInputValue(val);
+      this._setQuery(val, silent);
+    },
+
+    hasQueryChangedSinceLastFocus: function hasQueryChangedSinceLastFocus() {
+      return this.query !== this.queryWhenFocused;
+    },
+
+    getInputValue: function getInputValue() {
+      return this.$input.val();
+    },
+
+    setInputValue: function setInputValue(value) {
+      this.$input.val(value);
+      this.clearHintIfInvalid();
+      this._checkLanguageDirection();
+    },
+
+    resetInputValue: function resetInputValue() {
+      this.setInputValue(this.query);
+    },
+
+    getHint: function getHint() {
+      return this.$hint.val();
+    },
+
+    setHint: function setHint(value) {
+      this.$hint.val(value);
+    },
+
+    clearHint: function clearHint() {
+      this.setHint('');
+    },
+
+    clearHintIfInvalid: function clearHintIfInvalid() {
+      var val, hint, valIsPrefixOfHint, isValid;
+
+      val = this.getInputValue();
+      hint = this.getHint();
+      valIsPrefixOfHint = val !== hint && hint.indexOf(val) === 0;
+      isValid = val !== '' && valIsPrefixOfHint && !this.hasOverflow();
+
+      !isValid && this.clearHint();
+    },
+
+    hasFocus: function hasFocus() {
+      return this.$input.is(':focus');
+    },
+
+    hasOverflow: function hasOverflow() {
+      // 2 is arbitrary, just picking a small number to handle edge cases
+      var constraint = this.$input.width() - 2;
+
+      this.$overflowHelper.text(this.getInputValue());
+
+      return this.$overflowHelper.width() >= constraint;
+    },
+
+    isCursorAtEnd: function() {
+      var valueLength, selectionStart, range;
+
+      valueLength = this.$input.val().length;
+      selectionStart = this.$input[0].selectionStart;
+
+      if (_.isNumber(selectionStart)) {
+       return selectionStart === valueLength;
+      }
+
+      else if (document.selection) {
+        // NOTE: this won't work unless the input has focus, the good news
+        // is this code should only get called when the input has focus
+        range = document.selection.createRange();
+        range.moveStart('character', -valueLength);
+
+        return valueLength === range.text.length;
+      }
+
+      return true;
+    },
+
+    destroy: function destroy() {
+      this.$hint.off('.tt');
+      this.$input.off('.tt');
+      this.$overflowHelper.remove();
+
+      // #970
+      this.$hint = this.$input = this.$overflowHelper = $('<div>');
+    }
+  });
+
+  return Input;
+
+  // helper functions
+  // ----------------
+
+  function buildOverflowHelper($input) {
+    return $('<pre aria-hidden="true"></pre>')
+    .css({
+      // position helper off-screen
+      position: 'absolute',
+      visibility: 'hidden',
+      // avoid line breaks and whitespace collapsing
+      whiteSpace: 'pre',
+      // use same font css as input to calculate accurate width
+      fontFamily: $input.css('font-family'),
+      fontSize: $input.css('font-size'),
+      fontStyle: $input.css('font-style'),
+      fontVariant: $input.css('font-variant'),
+      fontWeight: $input.css('font-weight'),
+      wordSpacing: $input.css('word-spacing'),
+      letterSpacing: $input.css('letter-spacing'),
+      textIndent: $input.css('text-indent'),
+      textRendering: $input.css('text-rendering'),
+      textTransform: $input.css('text-transform')
+    })
+    .insertAfter($input);
+  }
+
+  function areQueriesEquivalent(a, b) {
+    return Input.normalizeQuery(a) === Input.normalizeQuery(b);
+  }
+
+  function withModifier($e) {
+    return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey;
+  }
+})();
diff --git a/src/typeahead/menu.js b/src/typeahead/menu.js
new file mode 100644
index 0000000..b0574e2
--- /dev/null
+++ b/src/typeahead/menu.js
@@ -0,0 +1,217 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Menu = (function() {
+  'use strict';
+
+  // constructor
+  // -----------
+
+  function Menu(o, www) {
+    var that = this;
+
+    o = o || {};
+
+    if (!o.node) {
+      $.error('node is required');
+    }
+
+    www.mixin(this);
+
+    this.$node = $(o.node);
+
+    // the latest query #update was called with
+    this.query = null;
+    this.datasets = _.map(o.datasets, initializeDataset);
+
+    function initializeDataset(oDataset) {
+      var node = that.$node.find(oDataset.node).first();
+      oDataset.node = node.length ? node : $('<div>').appendTo(that.$node);
+
+      return new Dataset(oDataset, www);
+    }
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Menu.prototype, EventEmitter, {
+
+    // ### event handlers
+
+    _onSelectableClick: function onSelectableClick($e) {
+      this.trigger('selectableClicked', $($e.currentTarget));
+    },
+
+    _onRendered: function onRendered(type, dataset, suggestions, async) {
+      this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty());
+      this.trigger('datasetRendered', dataset, suggestions, async);
+    },
+
+    _onCleared: function onCleared() {
+      this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty());
+      this.trigger('datasetCleared');
+    },
+
+    _propagate: function propagate() {
+      this.trigger.apply(this, arguments);
+    },
+
+    // ### private
+
+    _allDatasetsEmpty: function allDatasetsEmpty() {
+      return _.every(this.datasets, isDatasetEmpty);
+
+      function isDatasetEmpty(dataset) { return dataset.isEmpty(); }
+    },
+
+    _getSelectables: function getSelectables() {
+      return this.$node.find(this.selectors.selectable);
+    },
+
+    _removeCursor: function _removeCursor() {
+      var $selectable = this.getActiveSelectable();
+      $selectable && $selectable.removeClass(this.classes.cursor);
+    },
+
+    _ensureVisible: function ensureVisible($el) {
+      var elTop, elBottom, nodeScrollTop, nodeHeight;
+
+      elTop = $el.position().top;
+      elBottom = elTop + $el.outerHeight(true);
+      nodeScrollTop = this.$node.scrollTop();
+      nodeHeight = this.$node.height() +
+        parseInt(this.$node.css('paddingTop'), 10) +
+        parseInt(this.$node.css('paddingBottom'), 10);
+
+      if (elTop < 0) {
+        this.$node.scrollTop(nodeScrollTop + elTop);
+      }
+
+      else if (nodeHeight < elBottom) {
+        this.$node.scrollTop(nodeScrollTop + (elBottom - nodeHeight));
+      }
+    },
+
+    // ### public
+
+    bind: function() {
+    var that = this, onSelectableClick;
+
+      onSelectableClick = _.bind(this._onSelectableClick, this);
+      this.$node.on('click.tt', this.selectors.selectable, onSelectableClick);
+
+      _.each(this.datasets, function(dataset) {
+        dataset
+        .onSync('asyncRequested', that._propagate, that)
+        .onSync('asyncCanceled', that._propagate, that)
+        .onSync('asyncReceived', that._propagate, that)
+        .onSync('rendered', that._onRendered, that)
+        .onSync('cleared', that._onCleared, that);
+      });
+
+      return this;
+    },
+
+    isOpen: function isOpen() {
+      return this.$node.hasClass(this.classes.open);
+    },
+
+    open: function open() {
+      this.$node.addClass(this.classes.open);
+    },
+
+    close: function close() {
+      this.$node.removeClass(this.classes.open);
+      this._removeCursor();
+    },
+
+    setLanguageDirection: function setLanguageDirection(dir) {
+      this.$node.attr('dir', dir);
+    },
+
+    selectableRelativeToCursor: function selectableRelativeToCursor(delta) {
+      var $selectables, $oldCursor, oldIndex, newIndex;
+
+      $oldCursor = this.getActiveSelectable();
+      $selectables = this._getSelectables();
+
+      // shifting before and after modulo to deal with -1 index
+      oldIndex = $oldCursor ? $selectables.index($oldCursor) : -1;
+      newIndex = oldIndex + delta;
+      newIndex = (newIndex + 1) % ($selectables.length + 1) - 1;
+
+      // wrap new index if less than -1
+      newIndex = newIndex < -1 ? $selectables.length - 1 : newIndex;
+
+      return newIndex === -1 ? null : $selectables.eq(newIndex);
+    },
+
+    setCursor: function setCursor($selectable) {
+      this._removeCursor();
+
+      if ($selectable = $selectable && $selectable.first()) {
+        $selectable.addClass(this.classes.cursor);
+
+        // in the case of scrollable overflow
+        // make sure the cursor is visible in the node
+        this._ensureVisible($selectable);
+      }
+    },
+
+    getSelectableData: function getSelectableData($el) {
+      return ($el && $el.length) ? Dataset.extractData($el) : null;
+    },
+
+    getActiveSelectable: function getActiveSelectable() {
+      var $selectable = this._getSelectables().filter(this.selectors.cursor).first();
+
+      return $selectable.length ? $selectable : null;
+    },
+
+    getTopSelectable: function getTopSelectable() {
+      var $selectable = this._getSelectables().first();
+
+      return $selectable.length ? $selectable : null;
+    },
+
+    update: function update(query) {
+      var isValidUpdate = query !== this.query;
+
+      // don't update if the query hasn't changed
+      if (isValidUpdate) {
+        this.query = query;
+        _.each(this.datasets, updateDataset);
+      }
+
+      return isValidUpdate;
+
+      function updateDataset(dataset) { dataset.update(query); }
+    },
+
+    empty: function empty() {
+      _.each(this.datasets, clearDataset);
+
+      this.query = null;
+      this.$node.addClass(this.classes.empty);
+
+      function clearDataset(dataset) { dataset.clear(); }
+    },
+
+    destroy: function destroy() {
+      this.$node.off('.tt');
+
+      // #970
+      this.$node = $('<div>');
+
+      _.each(this.datasets, destroyDataset);
+
+      function destroyDataset(dataset) { dataset.destroy(); }
+    }
+  });
+
+  return Menu;
+})();
diff --git a/src/typeahead/plugin.js b/src/typeahead/plugin.js
new file mode 100644
index 0000000..9c1db19
--- /dev/null
+++ b/src/typeahead/plugin.js
@@ -0,0 +1,291 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+(function() {
+  'use strict';
+
+  var old, keys, methods;
+
+  old = $.fn.typeahead;
+
+  keys = {
+    www: 'tt-www',
+    attrs: 'tt-attrs',
+    typeahead: 'tt-typeahead'
+  };
+
+  methods = {
+    // supported signatures:
+    // function(o, dataset, dataset, ...)
+    // function(o, [dataset, dataset, ...])
+    initialize: function initialize(o, datasets) {
+      var www;
+
+      datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1);
+
+      o = o || {};
+      www = WWW(o.classNames);
+
+      return this.each(attach);
+
+      function attach() {
+        var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu,
+            eventBus, input, menu, typeahead, MenuConstructor;
+
+        // highlight is a top-level config that needs to get inherited
+        // from all of the datasets
+        _.each(datasets, function(d) { d.highlight = !!o.highlight; });
+
+        $input = $(this);
+        $wrapper = $(www.html.wrapper);
+        $hint = $elOrNull(o.hint);
+        $menu = $elOrNull(o.menu);
+
+        defaultHint = o.hint !== false && !$hint;
+        defaultMenu = o.menu !== false && !$menu;
+
+        defaultHint && ($hint = buildHintFromInput($input, www));
+        defaultMenu && ($menu = $(www.html.menu).css(www.css.menu));
+
+        // hint should be empty on init
+        $hint && $hint.val('');
+        $input = prepInput($input, www);
+
+        // only apply inline styles and make dom changes if necessary
+        if (defaultHint || defaultMenu) {
+          $wrapper.css(www.css.wrapper);
+          $input.css(defaultHint ? www.css.input : www.css.inputWithNoHint);
+
+          $input
+          .wrap($wrapper)
+          .parent()
+          .prepend(defaultHint ? $hint : null)
+          .append(defaultMenu ? $menu : null);
+        }
+
+        MenuConstructor = defaultMenu ? DefaultMenu : Menu;
+
+        eventBus = new EventBus({ el: $input });
+        input = new Input({ hint: $hint, input: $input, }, www);
+        menu = new MenuConstructor({
+          node: $menu,
+          datasets: datasets
+        }, www);
+
+        typeahead = new Typeahead({
+          input: input,
+          menu: menu,
+          eventBus: eventBus,
+          minLength: o.minLength
+        }, www);
+
+        $input.data(keys.www, www);
+        $input.data(keys.typeahead, typeahead);
+      }
+    },
+
+    isEnabled: function isEnabled() {
+      var enabled;
+
+      ttEach(this.first(), function(t) { enabled = t.isEnabled(); });
+      return enabled;
+    },
+
+    enable: function enable() {
+      ttEach(this, function(t) { t.enable(); });
+      return this;
+    },
+
+    disable: function disable() {
+      ttEach(this, function(t) { t.disable(); });
+      return this;
+    },
+
+    isActive: function isActive() {
+      var active;
+
+      ttEach(this.first(), function(t) { active = t.isActive(); });
+      return active;
+    },
+
+    activate: function activate() {
+      ttEach(this, function(t) { t.activate(); });
+      return this;
+    },
+
+    deactivate: function deactivate() {
+      ttEach(this, function(t) { t.deactivate(); });
+      return this;
+    },
+
+    isOpen: function isOpen() {
+      var open;
+
+      ttEach(this.first(), function(t) { open = t.isOpen(); });
+      return open;
+    },
+
+    open: function open() {
+      ttEach(this, function(t) { t.open(); });
+      return this;
+    },
+
+    close: function close() {
+      ttEach(this, function(t) { t.close(); });
+      return this;
+    },
+
+    select: function select(el) {
+      var success = false, $el = $(el);
+
+      ttEach(this.first(), function(t) { success = t.select($el); });
+      return success;
+    },
+
+    autocomplete: function autocomplete(el) {
+      var success = false, $el = $(el);
+
+      ttEach(this.first(), function(t) { success = t.autocomplete($el); });
+      return success;
+    },
+
+    moveCursor: function moveCursoe(delta) {
+      var success = false;
+
+      ttEach(this.first(), function(t) { success = t.moveCursor(delta); });
+      return success;
+    },
+
+    // mirror jQuery#val functionality: reads opearte on first match,
+    // write operates on all matches
+    val: function val(newVal) {
+      var query;
+
+      if (!arguments.length) {
+        ttEach(this.first(), function(t) { query = t.getVal(); });
+        return query;
+      }
+
+      else {
+        ttEach(this, function(t) { t.setVal(newVal); });
+        return this;
+      }
+    },
+
+    destroy: function destroy() {
+      ttEach(this, function(typeahead, $input) {
+        revert($input);
+        typeahead.destroy();
+      });
+
+      return this;
+    }
+  };
+
+  $.fn.typeahead = function(method) {
+    // methods that should only act on intialized typeaheads
+    if (methods[method]) {
+      return methods[method].apply(this, [].slice.call(arguments, 1));
+    }
+
+    else {
+      return methods.initialize.apply(this, arguments);
+    }
+  };
+
+  $.fn.typeahead.noConflict = function noConflict() {
+    $.fn.typeahead = old;
+    return this;
+  };
+
+  // helper methods
+  // --------------
+
+  function ttEach($els, fn) {
+    $els.each(function() {
+      var $input = $(this), typeahead;
+
+      (typeahead = $input.data(keys.typeahead)) && fn(typeahead, $input);
+    });
+  }
+
+  function buildHintFromInput($input, www) {
+    return $input.clone()
+    .addClass(www.classes.hint)
+    .removeData()
+    .css(www.css.hint)
+    .css(getBackgroundStyles($input))
+    .prop('readonly', true)
+    .removeAttr('id name placeholder required')
+    .attr({ autocomplete: 'off', spellcheck: 'false', tabindex: -1 });
+  }
+
+  function prepInput($input, www) {
+    // store the original values of the attrs that get modified
+    // so modifications can be reverted on destroy
+    $input.data(keys.attrs, {
+      dir: $input.attr('dir'),
+      autocomplete: $input.attr('autocomplete'),
+      spellcheck: $input.attr('spellcheck'),
+      style: $input.attr('style')
+    });
+
+    $input
+    .addClass(www.classes.input)
+    .attr({ autocomplete: 'off', spellcheck: false });
+
+    // ie7 does not like it when dir is set to auto
+    try { !$input.attr('dir') && $input.attr('dir', 'auto'); } catch (e) {}
+
+    return $input;
+  }
+
+  function getBackgroundStyles($el) {
+    return {
+      backgroundAttachment: $el.css('background-attachment'),
+      backgroundClip: $el.css('background-clip'),
+      backgroundColor: $el.css('background-color'),
+      backgroundImage: $el.css('background-image'),
+      backgroundOrigin: $el.css('background-origin'),
+      backgroundPosition: $el.css('background-position'),
+      backgroundRepeat: $el.css('background-repeat'),
+      backgroundSize: $el.css('background-size')
+    };
+  }
+
+  function revert($input) {
+    var www, $wrapper;
+
+    www = $input.data(keys.www);
+    $wrapper = $input.parent().filter(www.selectors.wrapper);
+
+    // need to remove attrs that weren't previously defined and
+    // revert attrs that originally had a value
+    _.each($input.data(keys.attrs), function(val, key) {
+      _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val);
+    });
+
+    $input
+    .removeData(keys.typeahead)
+    .removeData(keys.www)
+    .removeData(keys.attr)
+    .removeClass(www.classes.input);
+
+    if ($wrapper.length) {
+      $input.detach().insertAfter($wrapper);
+      $wrapper.remove();
+    }
+  }
+
+  function $elOrNull(obj) {
+    var isValid, $el;
+
+    isValid = _.isJQuery(obj) || _.isElement(obj);
+    $el = isValid ? $(obj).first() : [];
+
+    return $el.length ? $el : null;
+  }
+})();
diff --git a/src/typeahead/typeahead.js b/src/typeahead/typeahead.js
new file mode 100644
index 0000000..f84078e
--- /dev/null
+++ b/src/typeahead/typeahead.js
@@ -0,0 +1,438 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var Typeahead = (function() {
+  'use strict';
+
+  // constructor
+  // -----------
+
+  function Typeahead(o, www) {
+    var onFocused, onBlurred, onEnterKeyed, onTabKeyed, onEscKeyed, onUpKeyed,
+        onDownKeyed, onLeftKeyed, onRightKeyed, onQueryChanged,
+        onWhitespaceChanged;
+
+    o = o || {};
+
+    if (!o.input) {
+      $.error('missing input');
+    }
+
+    if (!o.menu) {
+      $.error('missing menu');
+    }
+
+    if (!o.eventBus) {
+      $.error('missing event bus');
+    }
+
+    www.mixin(this);
+
+    this.eventBus = o.eventBus;
+    this.minLength = _.isNumber(o.minLength) ? o.minLength : 1;
+
+    this.input = o.input;
+    this.menu = o.menu;
+
+    this.enabled = true;
+
+    // activate the typeahead on init if the input has focus
+    this.active = false;
+    this.input.hasFocus() && this.activate();
+
+    // detect the initial lang direction
+    this.dir = this.input.getLangDir();
+
+    this._hacks();
+
+    this.menu.bind()
+    .onSync('selectableClicked', this._onSelectableClicked, this)
+    .onSync('asyncRequested', this._onAsyncRequested, this)
+    .onSync('asyncCanceled', this._onAsyncCanceled, this)
+    .onSync('asyncReceived', this._onAsyncReceived, this)
+    .onSync('datasetRendered', this._onDatasetRendered, this)
+    .onSync('datasetCleared', this._onDatasetCleared, this);
+
+    // composed event handlers for input
+    onFocused = c(this, 'activate', 'open', '_onFocused');
+    onBlurred = c(this, 'deactivate', '_onBlurred');
+    onEnterKeyed = c(this, 'isActive', 'isOpen', '_onEnterKeyed');
+    onTabKeyed = c(this, 'isActive', 'isOpen', '_onTabKeyed');
+    onEscKeyed = c(this, 'isActive', '_onEscKeyed');
+    onUpKeyed = c(this, 'isActive', 'open', '_onUpKeyed');
+    onDownKeyed = c(this, 'isActive', 'open', '_onDownKeyed');
+    onLeftKeyed = c(this, 'isActive', 'isOpen', '_onLeftKeyed');
+    onRightKeyed = c(this, 'isActive', 'isOpen', '_onRightKeyed');
+    onQueryChanged = c(this, '_openIfActive', '_onQueryChanged');
+    onWhitespaceChanged = c(this, '_openIfActive', '_onWhitespaceChanged');
+
+    this.input.bind()
+    .onSync('focused', onFocused, this)
+    .onSync('blurred', onBlurred, this)
+    .onSync('enterKeyed', onEnterKeyed, this)
+    .onSync('tabKeyed', onTabKeyed, this)
+    .onSync('escKeyed', onEscKeyed, this)
+    .onSync('upKeyed', onUpKeyed, this)
+    .onSync('downKeyed', onDownKeyed, this)
+    .onSync('leftKeyed', onLeftKeyed, this)
+    .onSync('rightKeyed', onRightKeyed, this)
+    .onSync('queryChanged', onQueryChanged, this)
+    .onSync('whitespaceChanged', onWhitespaceChanged, this)
+    .onSync('langDirChanged', this._onLangDirChanged, this);
+  }
+
+  // instance methods
+  // ----------------
+
+  _.mixin(Typeahead.prototype, {
+
+    // here's where hacks get applied and we don't feel bad about it
+    _hacks: function hacks() {
+      var $input, $menu;
+
+      // these default values are to make testing easier
+      $input = this.input.$input || $('<div>');
+      $menu = this.menu.$node || $('<div>');
+
+      // #705: if there's scrollable overflow, ie doesn't support
+      // blur cancellations when the scrollbar is clicked
+      //
+      // #351: preventDefault won't cancel blurs in ie <= 8
+      $input.on('blur.tt', function($e) {
+        var active, isActive, hasActive;
+
+        active = document.activeElement;
+        isActive = $menu.is(active);
+        hasActive = $menu.has(active).length > 0;
+
+        if (_.isMsie() && (isActive || hasActive)) {
+          $e.preventDefault();
+          // stop immediate in order to prevent Input#_onBlur from
+          // getting exectued
+          $e.stopImmediatePropagation();
+          _.defer(function() { $input.focus(); });
+        }
+      });
+
+      // #351: prevents input blur due to clicks within menu
+      $menu.on('mousedown.tt', function($e) { $e.preventDefault(); });
+    },
+
+    // ### event handlers
+
+    _onSelectableClicked: function onSelectableClicked(type, $el) {
+      this.select($el);
+    },
+
+    _onDatasetCleared: function onDatasetCleared() {
+      this._updateHint();
+    },
+
+    _onDatasetRendered: function onDatasetRendered(type, dataset, suggestions, async) {
+      this._updateHint();
+      this.eventBus.trigger('render', suggestions, async, dataset);
+    },
+
+    _onAsyncRequested: function onAsyncRequested(type, dataset, query) {
+      this.eventBus.trigger('asyncrequest', query, dataset);
+    },
+
+    _onAsyncCanceled: function onAsyncCanceled(type, dataset, query) {
+      this.eventBus.trigger('asynccancel', query, dataset);
+    },
+
+    _onAsyncReceived: function onAsyncReceived(type, dataset, query) {
+      this.eventBus.trigger('asyncreceive', query, dataset);
+    },
+
+    _onFocused: function onFocused() {
+      this._minLengthMet() && this.menu.update(this.input.getQuery());
+    },
+
+    _onBlurred: function onBlurred() {
+      if (this.input.hasQueryChangedSinceLastFocus()) {
+        this.eventBus.trigger('change', this.input.getQuery());
+      }
+    },
+
+    _onEnterKeyed: function onEnterKeyed(type, $e) {
+      var $selectable;
+
+      if ($selectable = this.menu.getActiveSelectable()) {
+        this.select($selectable) && $e.preventDefault();
+      }
+    },
+
+    _onTabKeyed: function onTabKeyed(type, $e) {
+      var $selectable;
+
+      if ($selectable = this.menu.getActiveSelectable()) {
+        this.select($selectable) && $e.preventDefault();
+      }
+
+      else if ($selectable = this.menu.getTopSelectable()) {
+        this.autocomplete($selectable) && $e.preventDefault();
+      }
+    },
+
+    _onEscKeyed: function onEscKeyed() {
+      this.close();
+    },
+
+    _onUpKeyed: function onUpKeyed() {
+      this.moveCursor(-1);
+    },
+
+    _onDownKeyed: function onDownKeyed() {
+      this.moveCursor(+1);
+    },
+
+    _onLeftKeyed: function onLeftKeyed() {
+      if (this.dir === 'rtl' && this.input.isCursorAtEnd()) {
+        this.autocomplete(this.menu.getTopSelectable());
+      }
+    },
+
+    _onRightKeyed: function onRightKeyed() {
+      if (this.dir === 'ltr' && this.input.isCursorAtEnd()) {
+        this.autocomplete(this.menu.getTopSelectable());
+      }
+    },
+
+    _onQueryChanged: function onQueryChanged(e, query) {
+      this._minLengthMet(query) ? this.menu.update(query) : this.menu.empty();
+    },
+
+    _onWhitespaceChanged: function onWhitespaceChanged() {
+      this._updateHint();
+    },
+
+    _onLangDirChanged: function onLangDirChanged(e, dir) {
+      if (this.dir !== dir) {
+        this.dir = dir;
+        this.menu.setLanguageDirection(dir);
+      }
+    },
+
+    // ### private
+
+    _openIfActive: function openIfActive() {
+      this.isActive() && this.open();
+    },
+
+    _minLengthMet: function minLengthMet(query) {
+      query = _.isString(query) ? query : (this.input.getQuery() || '');
+
+      return query.length >= this.minLength;
+    },
+
+    _updateHint: function updateHint() {
+      var $selectable, data, val, query, escapedQuery, frontMatchRegEx, match;
+
+      $selectable = this.menu.getTopSelectable();
+      data = this.menu.getSelectableData($selectable);
+      val = this.input.getInputValue();
+
+      if (data && !_.isBlankString(val) && !this.input.hasOverflow()) {
+        query = Input.normalizeQuery(val);
+        escapedQuery = _.escapeRegExChars(query);
+
+        // match input value, then capture trailing text
+        frontMatchRegEx = new RegExp('^(?:' + escapedQuery + ')(.+$)', 'i');
+        match = frontMatchRegEx.exec(data.val);
+
+        // clear hint if there's no trailing text
+        match && this.input.setHint(val + match[1]);
+      }
+
+      else {
+        this.input.clearHint();
+      }
+    },
+
+    // ### public
+
+    isEnabled: function isEnabled() {
+      return this.enabled;
+    },
+
+    enable: function enable() {
+      this.enabled = true;
+    },
+
+    disable: function disable() {
+      this.enabled = false;
+    },
+
+    isActive: function isActive() {
+      return this.active;
+    },
+
+    activate: function activate() {
+      // already active
+      if (this.isActive()) {
+        return true;
+      }
+
+      // unable to activate either due to the typeahead being disabled
+      // or due to the active event being prevented
+      else if (!this.isEnabled() || this.eventBus.before('active')) {
+        return false;
+      }
+
+      // activate
+      else {
+        this.active = true;
+        this.eventBus.trigger('active');
+        return true;
+      }
+    },
+
+    deactivate: function deactivate() {
+      // already idle
+      if (!this.isActive()) {
+        return true;
+      }
+
+      // unable to deactivate due to the idle event being prevented
+      else if (this.eventBus.before('idle')) {
+        return false;
+      }
+
+      // deactivate
+      else {
+        this.active = false;
+        this.close();
+        this.eventBus.trigger('idle');
+        return true;
+      }
+    },
+
+    isOpen: function isOpen() {
+      return this.menu.isOpen();
+    },
+
+    open: function open() {
+      if (!this.isOpen() && !this.eventBus.before('open')) {
+        this.menu.open();
+        this._updateHint();
+        this.eventBus.trigger('open');
+      }
+
+      return this.isOpen();
+    },
+
+    close: function close() {
+      if (this.isOpen() && !this.eventBus.before('close')) {
+        this.menu.close();
+        this.input.clearHint();
+        this.input.resetInputValue();
+        this.eventBus.trigger('close');
+      }
+      return !this.isOpen();
+    },
+
+    setVal: function setVal(val) {
+      // expect val to be a string, so be safe, and coerce
+      this.input.setQuery(_.toStr(val));
+    },
+
+    getVal: function getVal() {
+      return this.input.getQuery();
+    },
+
+    select: function select($selectable) {
+      var data = this.menu.getSelectableData($selectable);
+
+      if (data && !this.eventBus.before('select', data.obj)) {
+        this.input.setQuery(data.val, true);
+
+        this.eventBus.trigger('select', data.obj);
+        this.close();
+
+        // return true if selection succeeded
+        return true;
+      }
+
+      return false;
+    },
+
+    autocomplete: function autocomplete($selectable) {
+      var query, data, isValid;
+
+      query = this.input.getQuery();
+      data = this.menu.getSelectableData($selectable);
+      isValid = data && query !== data.val;
+
+      if (isValid && !this.eventBus.before('autocomplete', data.obj)) {
+        this.input.setQuery(data.val);
+        this.eventBus.trigger('autocomplete', data.obj);
+
+        // return true if autocompletion succeeded
+        return true;
+      }
+
+      return false;
+    },
+
+    moveCursor: function moveCursor(delta) {
+      var query, $candidate, data, payload, cancelMove;
+
+      query = this.input.getQuery();
+      $candidate = this.menu.selectableRelativeToCursor(delta);
+      data = this.menu.getSelectableData($candidate);
+      payload = data ? data.obj : null;
+
+      // update will return true when it's a new query and new suggestions
+      // need to be fetched – in this case we don't want to move the cursor
+      cancelMove = this._minLengthMet() && this.menu.update(query);
+
+      if (!cancelMove && !this.eventBus.before('cursorchange', payload)) {
+        this.menu.setCursor($candidate);
+
+        // cursor moved to different selectable
+        if (data) {
+          this.input.setInputValue(data.val);
+        }
+
+        // cursor moved off of selectables, back to input
+        else {
+          this.input.resetInputValue();
+          this._updateHint();
+        }
+
+        this.eventBus.trigger('cursorchange', payload);
+
+        // return true if move succeeded
+        return true;
+      }
+
+      return false;
+    },
+
+    destroy: function destroy() {
+      this.input.destroy();
+      this.menu.destroy();
+    }
+  });
+
+  return Typeahead;
+
+  // helper functions
+  // ----------------
+
+  function c(ctx) {
+    var methods = [].slice.call(arguments, 1);
+
+    return function() {
+      var args = [].slice.call(arguments);
+
+      _.each(methods, function(method) {
+        return ctx[method].apply(ctx, args);
+      });
+    };
+  }
+})();
diff --git a/src/typeahead/www.js b/src/typeahead/www.js
new file mode 100644
index 0000000..065af1f
--- /dev/null
+++ b/src/typeahead/www.js
@@ -0,0 +1,113 @@
+/*
+ * typeahead.js
+ * https://github.com/twitter/typeahead.js
+ * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT
+ */
+
+var WWW = (function() {
+  'use strict';
+
+  var defaultClassNames = {
+    wrapper: 'twitter-typeahead',
+    input: 'tt-input',
+    hint: 'tt-hint',
+    menu: 'tt-menu',
+    dataset: 'tt-dataset',
+    suggestion: 'tt-suggestion',
+    selectable: 'tt-selectable',
+    empty: 'tt-empty',
+    open: 'tt-open',
+    cursor: 'tt-cursor',
+    highlight: 'tt-highlight'
+  };
+
+  return build;
+
+  function build(o) {
+    var www, classes;
+
+    classes = _.mixin({}, defaultClassNames, o);
+
+    www = {
+      css: buildCss(),
+      classes: classes,
+      html: buildHtml(classes),
+      selectors: buildSelectors(classes)
+    };
+
+    return {
+      css: www.css,
+      html: www.html,
+      classes: www.classes,
+      selectors: www.selectors,
+      mixin: function(o) { _.mixin(o, www); }
+    };
+  }
+
+  function buildHtml(c) {
+    return {
+      wrapper: '<span class="' + c.wrapper + '"></span>',
+      menu: '<div class="' + c.menu + '"></div>'
+    };
+  }
+
+  function buildSelectors(classes) {
+    var selectors = {};
+    _.each(classes, function(v, k) { selectors[k] = '.' + v; });
+
+    return selectors;
+  }
+
+  function buildCss() {
+    var css =  {
+      wrapper: {
+        position: 'relative',
+        display: 'inline-block'
+      },
+      hint: {
+        position: 'absolute',
+        top: '0',
+        left: '0',
+        borderColor: 'transparent',
+        boxShadow: 'none',
+        // #741: fix hint opacity issue on iOS
+        opacity: '1'
+      },
+      input: {
+        position: 'relative',
+        verticalAlign: 'top',
+        backgroundColor: 'transparent'
+      },
+      inputWithNoHint: {
+        position: 'relative',
+        verticalAlign: 'top'
+      },
+      menu: {
+        position: 'absolute',
+        top: '100%',
+        left: '0',
+        zIndex: '100',
+        display: 'none'
+      },
+      ltr: {
+        left: '0',
+        right: 'auto'
+      },
+      rtl: {
+        left: 'auto',
+        right:' 0'
+      }
+    };
+
+    // ie specific styling
+    if (_.isMsie()) {
+       // ie6-8 (and 9?) doesn't fire hover and click events for elements with
+       // transparent backgrounds, for a workaround, use 1x1 transparent gif
+      _.mixin(css.input, {
+        backgroundImage: 'url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)'
+      });
+    }
+
+    return css;
+  }
+})();
diff --git a/test/bloodhound/bloodhound_spec.js b/test/bloodhound/bloodhound_spec.js
new file mode 100644
index 0000000..ffd5fc2
--- /dev/null
+++ b/test/bloodhound/bloodhound_spec.js
@@ -0,0 +1,299 @@
+describe('Bloodhound', function() {
+
+  function build(o) {
+    return new Bloodhound(_.mixin({
+      datumTokenizer: datumTokenizer,
+      queryTokenizer: queryTokenizer
+    }, o || {}));
+  }
+
+  beforeEach(function() {
+    jasmine.Remote.useMock();
+    jasmine.Prefetch.useMock();
+    jasmine.Transport.useMock();
+    jasmine.PersistentStorage.useMock();
+  });
+
+  afterEach(function() {
+    clearAjaxRequests();
+  });
+
+  describe('#initialize', function() {
+    beforeEach(function() {
+      this.bloodhound = build({ initialize: false });
+      spyOn(this.bloodhound, '_initialize').andCallThrough();
+    });
+
+    it('should not initialize if intialize option is false', function() {
+      expect(this.bloodhound._initialize).not.toHaveBeenCalled();
+    });
+
+    it('should not support reinitialization by default', function() {
+      var p1, p2;
+
+      p1 = this.bloodhound.initialize();
+      p2 = this.bloodhound.initialize();
+
+      expect(p1).toBe(p2);
+      expect(this.bloodhound._initialize.callCount).toBe(1);
+    });
+
+    it('should reinitialize if reintialize flag is true', function() {
+      var p1, p2;
+
+      p1 = this.bloodhound.initialize();
+      p2 = this.bloodhound.initialize(true);
+
+      expect(p1).not.toBe(p2);
+      expect(this.bloodhound._initialize.callCount).toBe(2);
+    });
+
+    it('should clear the index', function() {
+      this.bloodhound = build({ initialize: false, prefetch: '/prefetch' });
+      spyOn(this.bloodhound, 'clear');
+      this.bloodhound.initialize();
+
+      expect(this.bloodhound.clear).toHaveBeenCalled();
+    });
+
+    it('should load data from prefetch cache if available', function() {
+      this.bloodhound = build({ initialize: false, prefetch: '/prefetch' });
+      this.bloodhound.prefetch.fromCache.andReturn(fixtures.serialized.simple);
+      this.bloodhound.initialize();
+
+      expect(this.bloodhound.all()).toEqual(fixtures.data.simple);
+      expect(this.bloodhound.prefetch.fromNetwork).not.toHaveBeenCalled();
+    });
+
+    it('should load data from prefetch network as fallback', function() {
+      this.bloodhound = build({ initialize: false, prefetch: '/prefetch' });
+      this.bloodhound.prefetch.fromCache.andReturn(null);
+      this.bloodhound.prefetch.fromNetwork.andCallFake(fakeFromNetwork);
+      this.bloodhound.initialize();
+
+      expect(this.bloodhound.all()).toEqual(fixtures.data.simple);
+
+      function fakeFromNetwork(cb) { cb(null, fixtures.data.simple); }
+    });
+
+    it('should store prefetch network data in the prefetch cache', function() {
+      this.bloodhound = build({ initialize: false, prefetch: '/prefetch' });
+      this.bloodhound.prefetch.fromCache.andReturn(null);
+      this.bloodhound.prefetch.fromNetwork.andCallFake(fakeFromNetwork);
+      this.bloodhound.initialize();
+
+      expect(this.bloodhound.prefetch.store)
+      .toHaveBeenCalledWith(fixtures.serialized.simple);
+
+      function fakeFromNetwork(cb) { cb(null, fixtures.data.simple); }
+    });
+
+    it('should add local after prefetch is loaded', function() {
+      this.bloodhound = build({
+        initialize: false,
+        local: [{ foo: 'bar' }],
+        prefetch: '/prefetch'
+      });
+      this.bloodhound.prefetch.fromNetwork.andCallFake(fakeFromNetwork);
+
+      expect(this.bloodhound.all()).toEqual([]);
+      this.bloodhound.initialize();
+      expect(this.bloodhound.all()).toEqual([{ foo: 'bar' }]);
+
+      function fakeFromNetwork(cb) { cb(null, []); }
+    });
+  });
+
+  describe('#add', function() {
+    it('should add datums to search index', function() {
+      var spy = jasmine.createSpy();
+
+      this.bloodhound = build().add(fixtures.data.simple);
+
+      this.bloodhound.search('big', spy);
+
+      expect(spy).toHaveBeenCalledWith([
+        { value: 'big' },
+        { value: 'bigger' },
+        { value: 'biggest' }
+      ]);
+    });
+  });
+
+  describe('#get', function() {
+    beforeEach(function() {
+      this.bloodhound = build({
+        identify: function(d) { return d.value; },
+        local: fixtures.data.simple
+      });
+    });
+
+    it('should support array signature', function() {
+      expect(this.bloodhound.get(['big', 'bigger'])).toEqual([
+        { value: 'big' },
+        { value: 'bigger' }
+      ]);
+    });
+
+    it('should support splat signature', function() {
+      expect(this.bloodhound.get('big', 'bigger')).toEqual([
+        { value: 'big' },
+        { value: 'bigger' }
+      ]);
+    });
+
+    it('should return nothing for unknown ids', function() {
+      expect(this.bloodhound.get('big', 'foo', 'bigger')).toEqual([
+        { value: 'big' },
+        { value: 'bigger' }
+      ]);
+    });
+  });
+
+  describe('#clear', function() {
+    it('should remove all datums to search index', function() {
+      var spy = jasmine.createSpy();
+
+      this.bloodhound = build({ local: fixtures.data.simple }).clear();
+
+      this.bloodhound.search('big', spy);
+
+      expect(spy).toHaveBeenCalledWith([]);
+    });
+  });
+
+  describe('#clearPrefetchCache', function() {
+    it('should clear persistent storage', function() {
+      this.bloodhound = build({ prefetch: '/prefetch' }).clearPrefetchCache();
+      expect(this.bloodhound.prefetch.clear).toHaveBeenCalled();
+    });
+  });
+
+  describe('#clearRemoteCache', function() {
+    it('should clear remote request cache', function() {
+      spyOn(Transport, 'resetCache');
+      this.bloodhound = build({ remote: '/remote' }).clearRemoteCache();
+      expect(Transport.resetCache).toHaveBeenCalled();
+    });
+  });
+
+  describe('#all', function() {
+    it('should return all local results', function() {
+      this.bloodhound = build({ local: fixtures.data.simple });
+      expect(this.bloodhound.all()).toEqual(fixtures.data.simple);
+    });
+  });
+
+  describe('#search – local', function() {
+    it('should return sync matches', function() {
+      var spy = jasmine.createSpy();
+
+      this.bloodhound = build({ local: fixtures.data.simple });
+
+      this.bloodhound.search('big', spy);
+
+      expect(spy).toHaveBeenCalledWith([
+        { value: 'big' },
+        { value: 'bigger' },
+        { value: 'biggest' }
+      ]);
+    });
+  });
+
+  describe('#search – prefetch', function() {
+    it('should return sync matches', function() {
+      var spy = jasmine.createSpy();
+
+      this.bloodhound = build({ initialize: false, prefetch: '/prefetch' });
+      this.bloodhound.prefetch.fromCache.andReturn(fixtures.serialized.simple);
+      this.bloodhound.initialize();
+
+      this.bloodhound.search('big', spy);
+
+      expect(spy).toHaveBeenCalledWith([
+        { value: 'big' },
+        { value: 'bigger' },
+        { value: 'biggest' }
+      ]);
+    });
+  });
+
+  describe('#search – remote', function() {
+    it('should return async matches', function() {
+      var spy = jasmine.createSpy();
+
+      this.bloodhound = build({ remote: '/remote' });
+      this.bloodhound.remote.get.andCallFake(fakeGet);
+      this.bloodhound.search('dog', $.noop, spy);
+
+      expect(spy.callCount).toBe(1);
+
+      function fakeGet(o, cb) { cb(fixtures.data.animals); }
+    });
+  });
+
+  describe('#search – integration', function() {
+    it('should backfill when local/prefetch is not sufficient', function() {
+      var syncSpy, asyncSpy;
+
+      syncSpy = jasmine.createSpy();
+      asyncSpy = jasmine.createSpy();
+
+      this.bloodhound = build({
+        sufficient: 3,
+        local: fixtures.data.simple,
+        remote: '/remote'
+      });
+      this.bloodhound.remote.get.andCallFake(fakeGet);
+
+      this.bloodhound.search('big', syncSpy, asyncSpy);
+
+      expect(syncSpy).toHaveBeenCalledWith([
+        { value: 'big' },
+        { value: 'bigger' },
+        { value: 'biggest' }
+      ]);
+      expect(asyncSpy).not.toHaveBeenCalled();
+
+      this.bloodhound.search('bigg', syncSpy, asyncSpy);
+
+      expect(syncSpy).toHaveBeenCalledWith([
+        { value: 'bigger' },
+        { value: 'biggest' }
+      ]);
+      expect(asyncSpy).toHaveBeenCalledWith(fixtures.data.animals);
+
+      function fakeGet(o, cb) { cb(fixtures.data.animals); }
+    });
+
+    it('should remove duplicates from backfill', function() {
+      var syncSpy, asyncSpy;
+
+      syncSpy = jasmine.createSpy();
+      asyncSpy = jasmine.createSpy();
+
+      this.bloodhound = build({
+        identify: function(d) { return d.value; },
+        local: fixtures.data.animals,
+        remote: '/remote'
+      });
+      this.bloodhound.remote.get.andCallFake(fakeGet);
+
+      this.bloodhound.search('dog', syncSpy, asyncSpy);
+
+      expect(syncSpy).toHaveBeenCalledWith([{ value: 'dog' }]);
+      expect(asyncSpy).toHaveBeenCalledWith([
+        { value: 'cat' },
+        { value: 'moose' }
+      ]);
+
+      function fakeGet(o, cb) { cb(fixtures.data.animals); }
+    });
+  });
+
+  // helper functions
+  // ----------------
+
+  function datumTokenizer(d) { return $.trim(d.value).split(/\s+/); }
+  function queryTokenizer(s) { return $.trim(s).split(/\s+/); }
+});
diff --git a/test/bloodhound/lru_cache_spec.js b/test/bloodhound/lru_cache_spec.js
new file mode 100644
index 0000000..87e121d
--- /dev/null
+++ b/test/bloodhound/lru_cache_spec.js
@@ -0,0 +1,43 @@
+describe('LruCache', function() {
+
+  beforeEach(function() {
+    this.cache = new LruCache(3);
+  });
+
+  it('should make entries retrievable by their keys', function() {
+    var key = 'key', val = 42;
+
+    this.cache.set(key, val);
+    expect(this.cache.get(key)).toBe(val);
+  });
+
+  it('should return undefined if key has not been set', function() {
+    expect(this.cache.get('wat?')).toBeUndefined();
+  });
+
+  it('should hold up to maxSize entries', function() {
+    this.cache.set('one', 1);
+    this.cache.set('two', 2);
+    this.cache.set('three', 3);
+    this.cache.set('four', 4);
+
+    expect(this.cache.get('one')).toBeUndefined();
+    expect(this.cache.get('two')).toBe(2);
+    expect(this.cache.get('three')).toBe(3);
+    expect(this.cache.get('four')).toBe(4);
+  });
+
+  it('should evict lru entry if cache is full', function() {
+    this.cache.set('one', 1);
+    this.cache.set('two', 2);
+    this.cache.set('three', 3);
+    this.cache.get('one');
+    this.cache.set('four', 4);
+
+    expect(this.cache.get('one')).toBe(1);
+    expect(this.cache.get('two')).toBeUndefined();
+    expect(this.cache.get('three')).toBe(3);
+    expect(this.cache.get('four')).toBe(4);
+    expect(this.cache.size).toBe(3);
+  });
+});
diff --git a/test/bloodhound/options_parser_spec.js b/test/bloodhound/options_parser_spec.js
new file mode 100644
index 0000000..20d6487
--- /dev/null
+++ b/test/bloodhound/options_parser_spec.js
@@ -0,0 +1,194 @@
+describe('options parser', function() {
+
+  function build(o) {
+    return oParser(_.mixin({
+      datumTokenizer: $.noop,
+      queryTokenizer: $.noop
+    }, o || {}));
+  }
+
+  function prefetch(o) {
+    return oParser({
+      datumTokenizer: $.noop,
+      queryTokenizer: $.noop,
+      prefetch: _.mixin({
+        url: '/example'
+      }, o || {})
+    });
+  }
+
+  function remote(o) {
+    return oParser({
+      datumTokenizer: $.noop,
+      queryTokenizer: $.noop,
+      remote: _.mixin({
+        url: '/example'
+      }, o || {})
+    });
+  }
+
+  it('should throw exception if datumTokenizer is not set', function() {
+    expect(parse).toThrow();
+    function parse() { build({ datumTokenizer: null }); }
+  });
+
+  it('should throw exception if queryTokenizer is not set', function() {
+    expect(parse).toThrow();
+    function parse() { build({ queryTokenizer: null }); }
+  });
+
+  it('should wrap sorter', function() {
+    var o = build({ sorter: function(a, b) {  return a -b; } });
+    expect(o.sorter([2, 1, 3])).toEqual([1, 2, 3]);
+  });
+
+  it('should default sorter to identity function', function() {
+    var o = build();
+    expect(o.sorter([2, 1, 3])).toEqual([2, 1, 3]);
+  });
+
+  describe('local', function() {
+    it('should default to empty array', function() {
+      var o = build();
+      expect(o.local).toEqual([]);
+    });
+
+    it('should support function', function() {
+      var o = build({ local: function() { return [1]; } });
+      expect(o.local).toEqual([1]);
+    });
+
+    it('should support arrays', function() {
+      var o = build({ local: [1] });
+      expect(o.local).toEqual([1]);
+    });
+  });
+
+  describe('prefetch', function() {
+    it('should throw exception if url is not set', function() {
+      expect(parse).toThrow();
+      function parse() { prefetch({ url: null }); }
+    });
+
+    it('should support simple string format', function() {
+      expect(build({ prefetch: '/prefetch' }).prefetch).toBeDefined();
+    });
+
+    it('should default ttl to 1 day', function() {
+      var o = prefetch();
+      expect(o.prefetch.ttl).toBe(86400000);
+    });
+
+    it('should default cache to true', function() {
+      var o = prefetch();
+      expect(o.prefetch.cache).toBe(true);
+    });
+
+    it('should default transform to identiy function', function() {
+      var o = prefetch();
+      expect(o.prefetch.transform('foo')).toBe('foo');
+    });
+
+    it('should default cacheKey to url', function() {
+      var o = prefetch();
+      expect(o.prefetch.cacheKey).toBe(o.prefetch.url);
+    });
+
+    it('should default transport to jQuery.ajax', function() {
+      var o = prefetch();
+      expect(o.prefetch.transport).toBe($.ajax);
+    });
+
+    it('should prepend verison to thumbprint', function() {
+      var o = prefetch();
+      expect(o.prefetch.thumbprint).toBe('%VERSION%');
+
+      o = prefetch({ thumbprint: 'foo' });
+      expect(o.prefetch.thumbprint).toBe('%VERSION%foo');
+    });
+
+    it('should wrap custom transport to be deferred compatible', function() {
+      var o, errDeferred, successDeferred;
+
+      o = prefetch({ transport: errTransport });
+      errDeferred = o.prefetch.transport('q');
+
+      o = prefetch({ transport: successTransport });
+      successDeferred = o.prefetch.transport('q');
+
+      waits(0);
+      runs(function() {
+        expect(errDeferred.isRejected()).toBe(true);
+        expect(successDeferred.isResolved()).toBe(true);
+      });
+
+      function errTransport(q, success, error) { error(); }
+      function successTransport(q, success, error) { success(); }
+    });
+  });
+
+  describe('remote', function() {
+    it('should throw exception if url is not set', function() {
+      expect(parse).toThrow();
+      function parse() { remote({ url: null }); }
+    });
+
+    it('should support simple string format', function() {
+      expect(build({ remote: '/remote' }).remote).toBeDefined();
+    });
+
+    it('should default transform to identiy function', function() {
+      var o = remote();
+      expect(o.remote.transform('foo')).toBe('foo');
+    });
+
+    it('should default transport to jQuery.ajax', function() {
+      var o = remote();
+      expect(o.remote.transport).toBe($.ajax);
+    });
+
+    it('should default limiter to debouce', function() {
+      var o = remote();
+      expect(o.remote.limiter.name).toBe('debounce');
+    });
+
+    it('should default prepare to identity function', function() {
+      var o = remote();
+      expect(o.remote.prepare('q', { url: '/foo' })).toEqual({ url: '/foo' });
+    });
+
+    it('should support wildcard for prepare', function() {
+      var o = remote({ wildcard: '%FOO' });
+      expect(o.remote.prepare('=', { url: '/%FOO' })).toEqual({ url: '/%3D' });
+    });
+
+    it('should support replace for prepare', function() {
+      var o = remote({ replace: function() { return '/bar'; } });
+      expect(o.remote.prepare('q', { url: '/foo' })).toEqual({ url: '/bar' });
+    });
+
+    it('should should rateLimitBy for limiter', function() {
+      var o = remote({ rateLimitBy: 'throttle' });
+      expect(o.remote.limiter.name).toBe('throttle');
+    });
+
+    it('should wrap custom transport to be deferred compatible', function() {
+      var o, errDeferred, successDeferred;
+
+      o = remote({ transport: errTransport });
+      errDeferred = o.remote.transport('q');
+
+      o = remote({ transport: successTransport });
+      successDeferred = o.remote.transport('q');
+
+      waits(0);
+      runs(function() {
+        expect(errDeferred.isRejected()).toBe(true);
+        expect(successDeferred.isResolved()).toBe(true);
+      });
+
+      function errTransport(q, success, error) { error(); }
+      function successTransport(q, success, error) { success(); }
+    });
+  });
+});
diff --git a/test/bloodhound/persistent_storage_spec.js b/test/bloodhound/persistent_storage_spec.js
new file mode 100644
index 0000000..94a8994
--- /dev/null
+++ b/test/bloodhound/persistent_storage_spec.js
@@ -0,0 +1,194 @@
+describe('PersistentStorage', function() {
+  var engine, ls;
+
+  // test suite is dependent on localStorage being available
+  if (!window.localStorage) {
+    console.warn('no localStorage support – skipping PersistentStorage suite');
+    return;
+  }
+
+  // for good measure!
+  localStorage.clear();
+
+  beforeEach(function() {
+    ls = {
+      get length() { return localStorage.length; },
+      key: spyThrough('key'),
+      clear: spyThrough('clear'),
+      getItem: spyThrough('getItem'),
+      setItem: spyThrough('setItem'),
+      removeItem: spyThrough('removeItem')
+    };
+
+    engine = new PersistentStorage('ns', ls);
+    spyOn(Date.prototype, 'getTime').andReturn(0);
+  });
+
+  afterEach(function() {
+    localStorage.clear();
+  });
+
+  // public methods
+  // --------------
+
+  describe('#get', function() {
+    it('should access localStorage with prefixed key', function() {
+      engine.get('key');
+      expect(ls.getItem).toHaveBeenCalledWith('__ns__key');
+    });
+
+    it('should return undefined when key does not exist', function() {
+      expect(engine.get('does not exist')).toEqual(undefined);
+    });
+
+    it('should return value as correct type', function() {
+      engine.set('string', 'i am a string');
+      engine.set('number', 42);
+      engine.set('boolean', true);
+      engine.set('null', null);
+      engine.set('object', { obj: true });
+
+      expect(engine.get('string')).toEqual('i am a string');
+      expect(engine.get('number')).toEqual(42);
+      expect(engine.get('boolean')).toEqual(true);
+      expect(engine.get('null')).toBeNull();
+      expect(engine.get('object')).toEqual({ obj: true });
+    });
+
+    it('should expire stale keys', function() {
+      engine.set('key', 'value', -1);
+
+      expect(engine.get('key')).toBeNull();
+      expect(ls.getItem('__ns__key__ttl')).toBeNull();
+    });
+  });
+
+  describe('#set', function() {
+    it('should access localStorage with prefixed key', function() {
+      engine.set('key', 'val');
+      expect(ls.setItem.mostRecentCall.args[0]).toEqual('__ns__key');
+    });
+
+    it('should JSON.stringify value before storing', function() {
+      engine.set('key', 'val');
+      expect(ls.setItem.mostRecentCall.args[1]).toEqual(JSON.stringify('val'));
+    });
+
+    it('should store ttl if provided', function() {
+      var ttl = 1;
+      engine.set('key', 'value', ttl);
+
+      expect(ls.setItem.argsForCall[0])
+      .toEqual(['__ns__key__ttl__', ttl.toString()]);
+    });
+
+    it('should call clear if the localStorage limit has been reached', function() {
+      var spy;
+
+      ls.setItem.andCallFake(function() {
+        var err = new Error();
+        err.name = 'QuotaExceededError';
+
+        throw err;
+      });
+
+      engine.clear = spy = jasmine.createSpy();
+      engine.set('key', 'value', 1);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should noop if the localStorage limit has been reached', function() {
+      var get, set, remove, clear, isExpired;
+
+      ls.setItem.andCallFake(function() {
+        var err = new Error();
+        err.name = 'QuotaExceededError';
+
+        throw err;
+      });
+
+      get = engine.get;
+      set = engine.set;
+      remove = engine.remove;
+      clear = engine.clear;
+      isExpired = engine.isExpired;
+
+      engine.set('key', 'value', 1);
+
+      expect(engine.get).not.toBe(get);
+      expect(engine.set).not.toBe(set);
+      expect(engine.remove).not.toBe(remove);
+      expect(engine.clear).not.toBe(clear);
+      expect(engine.isExpired).not.toBe(isExpired);
+    });
+  });
+
+  describe('#remove', function() {
+
+    it('should remove key from storage', function() {
+      engine.set('key', 'val');
+      engine.remove('key');
+
+      expect(engine.get('key')).toBeNull();
+    });
+  });
+
+  describe('#clear', function() {
+    it('should work with namespaces that contain regex characters', function() {
+      engine = new PersistentStorage('ns?()');
+      engine.set('key1', 'val1');
+      engine.set('key2', 'val2');
+      engine.clear();
+
+      expect(engine.get('key1')).toEqual(undefined);
+      expect(engine.get('key2')).toEqual(undefined);
+    });
+
+    it('should remove all keys that exist in namespace of engine', function() {
+      engine.set('key1', 'val1');
+      engine.set('key2', 'val2');
+      engine.set('key3', 'val3');
+      engine.set('key4', 'val4', 0);
+      engine.clear();
+
+      expect(engine.get('key1')).toEqual(undefined);
+      expect(engine.get('key2')).toEqual(undefined);
+      expect(engine.get('key3')).toEqual(undefined);
+      expect(engine.get('key4')).toEqual(undefined);
+    });
+
+    it('should not affect keys with different namespace', function() {
+      ls.setItem('diff_namespace', 'val');
+      engine.clear();
+
+      expect(ls.getItem('diff_namespace')).toEqual('val');
+    });
+  });
+
+  describe('#isExpired', function() {
+    it('should be false for keys without ttl', function() {
+      engine.set('key', 'value');
+      expect(engine.isExpired('key')).toBe(false);
+    });
+
+    it('should be false for fresh keys', function() {
+      engine.set('key', 'value', 1);
+      expect(engine.isExpired('key')).toBe(false);
+    });
+
+    it('should be true for stale keys', function() {
+      engine.set('key', 'value', -1);
+      expect(engine.isExpired('key')).toBe(true);
+    });
+  });
+
+  // compatible across browsers
+  function spyThrough(method) {
+    return jasmine.createSpy().andCallFake(fake);
+
+    function fake() {
+      return localStorage[method].apply(localStorage, arguments);
+    }
+  }
+});
diff --git a/test/bloodhound/prefetch_spec.js b/test/bloodhound/prefetch_spec.js
new file mode 100644
index 0000000..b7238c7
--- /dev/null
+++ b/test/bloodhound/prefetch_spec.js
@@ -0,0 +1,182 @@
+describe('Prefetch', function() {
+
+  function build(o) {
+    return new Prefetch(_.mixin({
+      url: '/prefetch',
+      ttl: 3600,
+      cache: true,
+      thumbprint: '',
+      cacheKey: 'cachekey',
+      prepare: function(x) { return x; },
+      transform: function(x) { return x; },
+      transport: $.ajax
+    }, o || {}));
+  }
+
+  beforeEach(function() {
+    jasmine.PersistentStorage.useMock();
+
+    this.prefetch = build();
+    this.storage = this.prefetch.storage;
+    this.thumbprint = this.prefetch.thumbprint;
+  });
+
+  describe('#clear', function() {
+    it('should clear cache storage', function() {
+      this.prefetch.clear();
+      expect(this.storage.clear).toHaveBeenCalled();
+    });
+  });
+
+  describe('#store', function() {
+    it('should store data in the storage cache', function() {
+      this.prefetch.store({ foo: 'bar' });
+
+      expect(this.storage.set)
+      .toHaveBeenCalledWith('data', { foo: 'bar' }, 3600);
+    });
+
+    it('should store thumbprint in the storage cache', function() {
+      this.prefetch.store({ foo: 'bar' });
+
+      expect(this.storage.set)
+      .toHaveBeenCalledWith('thumbprint', jasmine.any(String), 3600);
+    });
+
+    it('should store protocol in the storage cache', function() {
+      this.prefetch.store({ foo: 'bar' });
+
+      expect(this.storage.set)
+      .toHaveBeenCalledWith('protocol', location.protocol, 3600);
+    });
+
+    it('should be noop if cache option is false', function() {
+      this.prefetch = build({ cache: false });
+
+      this.prefetch.store({ foo: 'bar' });
+
+      expect(this.storage.set).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('#fromCache', function() {
+    it('should return data if available', function() {
+      this.storage.get
+      .andCallFake(fakeStorageGet({ foo: 'bar' }, this.thumbprint));
+
+      expect(this.prefetch.fromCache()).toEqual({ foo: 'bar' });
+    });
+
+    it('should return null if data is expired', function() {
+      this.storage.get
+      .andCallFake(fakeStorageGet({ foo: 'bar' }, 'foo'));
+
+      expect(this.prefetch.fromCache()).toBeNull();
+    });
+
+    it('should return null if data does not exist', function() {
+      this.storage.get
+      .andCallFake(fakeStorageGet(null, this.thumbprint));
+
+      expect(this.prefetch.fromCache()).toBeNull();
+    });
+
+    it('should return null if cache option is false', function() {
+      this.prefetch = build({ cache: false });
+
+      this.storage.get
+      .andCallFake(fakeStorageGet({ foo: 'bar' }, this.thumbprint));
+
+      expect(this.prefetch.fromCache()).toBeNull();
+      expect(this.storage.get).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('#fromNetwork', function() {
+    it('should have sensible default request settings', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.prefetch, 'transport').andReturn($.Deferred());
+
+      this.prefetch.fromNetwork(spy);
+
+      expect(this.prefetch.transport).toHaveBeenCalledWith({
+        url: '/prefetch',
+        type: 'GET',
+        dataType: 'json'
+      });
+    });
+
+    it('should transform request settings with prepare', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.prefetch, 'prepare').andReturn({ foo: 'bar' });
+      spyOn(this.prefetch, 'transport').andReturn($.Deferred());
+
+      this.prefetch.fromNetwork(spy);
+
+      expect(this.prefetch.transport).toHaveBeenCalledWith({ foo: 'bar' });
+    });
+
+    it('should transform the response using transform', function() {
+      var spy;
+
+      this.prefetch = build({
+        transform: function() { return { bar: 'foo' }; }
+      });
+
+      spy = jasmine.createSpy();
+      spyOn(this.prefetch, 'transport')
+      .andReturn($.Deferred().resolve({ foo: 'bar' }));
+
+      this.prefetch.fromNetwork(spy);
+
+      expect(spy).toHaveBeenCalledWith(null, { bar: 'foo' });
+    });
+
+    it('should invoke callback with data if success', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.prefetch, 'transport')
+      .andReturn($.Deferred().resolve({ foo: 'bar' }));
+
+      this.prefetch.fromNetwork(spy);
+
+      expect(spy).toHaveBeenCalledWith(null, { foo: 'bar' });
+    });
+
+    it('should invoke callback with err argument true if failure', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.prefetch, 'transport').andReturn($.Deferred().reject());
+
+      this.prefetch.fromNetwork(spy);
+
+      expect(spy).toHaveBeenCalledWith(true);
+    });
+  });
+
+  function fakeStorageGet(data, thumbprint, protocol) {
+    return function(key) {
+      var val;
+
+      switch (key) {
+        case 'data':
+          val = data;
+          break;
+        case 'protocol':
+          val = protocol || location.protocol;
+          break;
+        case 'thumbprint':
+          val = thumbprint;
+          break;
+      }
+
+      return val;
+    };
+  }
+});
diff --git a/test/bloodhound/remote_spec.js b/test/bloodhound/remote_spec.js
new file mode 100644
index 0000000..0862dea
--- /dev/null
+++ b/test/bloodhound/remote_spec.js
@@ -0,0 +1,73 @@
+describe('Remote', function() {
+
+  beforeEach(function() {
+    jasmine.Transport.useMock();
+
+    this.remote = new Remote({
+      url: '/test?q=%QUERY',
+      prepare: function(x) { return x; },
+      transform: function(x) { return x; }
+    });
+
+    this.transport = this.remote.transport;
+  });
+
+  describe('#cancelLastRequest', function() {
+    it('should cancel last request', function() {
+      this.remote.cancelLastRequest();
+      expect(this.transport.cancel).toHaveBeenCalled();
+    });
+  });
+
+  describe('#get', function() {
+    it('should have sensible default request settings', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.remote, 'prepare');
+
+      this.remote.get('foo', spy);
+
+      expect(this.remote.prepare).toHaveBeenCalledWith('foo', {
+        url: '/test?q=%QUERY',
+        type: 'GET',
+        dataType: 'json'
+      });
+    });
+
+    it('should transform request settings with prepare', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.remote, 'prepare').andReturn([{ foo: 'bar' }]);
+
+      this.remote.get('foo', spy);
+
+      expect(this.transport.get)
+      .toHaveBeenCalledWith([{ foo: 'bar' }], jasmine.any(Function));
+    });
+
+    it('should transform response with transform', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      spyOn(this.remote, 'transform').andReturn([{ foo: 'bar' }]);
+      this.transport.get.andCallFake(function(_, cb) { cb(null, {}); });
+
+      this.remote.get('foo', spy);
+
+      expect(spy).toHaveBeenCalledWith([{ foo: 'bar' }]);
+    });
+
+    it('should return empty array on error', function() {
+      var spy;
+
+      spy = jasmine.createSpy();
+      this.transport.get.andCallFake(function(_, cb) { cb(true); });
+
+      this.remote.get('foo', spy);
+
+      expect(spy).toHaveBeenCalledWith([]);
+    });
+  });
+});
diff --git a/test/bloodhound/search_index_spec.js b/test/bloodhound/search_index_spec.js
new file mode 100644
index 0000000..7c577cc
--- /dev/null
+++ b/test/bloodhound/search_index_spec.js
@@ -0,0 +1,72 @@
+describe('SearchIndex', function() {
+
+  function build(o) {
+    return new SearchIndex(_.mixin({
+      datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
+      queryTokenizer: Bloodhound.tokenizers.whitespace
+    }, o || {}));
+  }
+
+  beforeEach(function() {
+    this.index = build();
+    this.index.add(fixtures.data.simple);
+  });
+
+  it('should support serialization/deserialization', function() {
+    var serialized = this.index.serialize();
+
+    this.index.bootstrap(serialized);
+
+    expect(this.index.search('smaller')).toEqual([{ value: 'smaller' }]);
+  });
+
+  it('should be able to add data on the fly', function() {
+    this.index.add({ value: 'new' });
+
+    expect(this.index.search('new')).toEqual([{ value: 'new' }]);
+  });
+
+  it('#get should return datums by id', function() {
+    this.index = build({ identify: function(d) { return d.value; } });
+    this.index.add(fixtures.data.simple);
+
+    expect(this.index.get(['big', 'bigger'])).toEqual([
+      { value: 'big' },
+      { value: 'bigger' }
+    ]);
+  });
+
+  it('#search should return datums that match the given query', function() {
+    expect(this.index.search('big')).toEqual([
+      { value: 'big' },
+      { value: 'bigger' },
+      { value: 'biggest' }
+    ]);
+
+    expect(this.index.search('small')).toEqual([
+      { value: 'small' },
+      { value: 'smaller' },
+      { value: 'smallest' }
+    ]);
+  });
+
+  it('#search should return an empty array of there are no matches', function() {
+    expect(this.index.search('wtf')).toEqual([]);
+  });
+
+  it('#serach should handle multi-token queries', function() {
+    this.index.add({ value: 'foo bar' });
+    expect(this.index.search('foo b')).toEqual([{ value: 'foo bar' }]);
+  });
+
+  it('#all should return all datums', function() {
+    expect(this.index.all()).toEqual(fixtures.data.simple);
+  });
+
+  it('#reset should empty the search index', function() {
+    this.index.reset();
+    expect(this.index.datums).toEqual([]);
+    expect(this.index.trie.i).toEqual([]);
+    expect(this.index.trie.c).toEqual({});
+  });
+});
diff --git a/test/bloodhound/tokenizers_spec.js b/test/bloodhound/tokenizers_spec.js
new file mode 100644
index 0000000..b4b9cf4
--- /dev/null
+++ b/test/bloodhound/tokenizers_spec.js
@@ -0,0 +1,74 @@
+describe('tokenizers', function() {
+
+  it('.whitespace should tokenize on whitespace', function() {
+    var tokens = tokenizers.whitespace('big-deal ok');
+    expect(tokens).toEqual(['big-deal', 'ok']);
+  });
+
+  it('.whitespace should treat null as empty string', function() {
+    var tokens = tokenizers.whitespace(null);
+    expect(tokens).toEqual([]);
+  });
+
+  it('.whitespace should treat undefined as empty string', function() {
+    var tokens = tokenizers.whitespace(undefined);
+    expect(tokens).toEqual([]);
+  });
+
+  it('.nonword should tokenize on non-word characters', function() {
+    var tokens = tokenizers.nonword('big-deal ok');
+    expect(tokens).toEqual(['big', 'deal', 'ok']);
+  });
+
+  it('.nonword should treat null as empty string', function() {
+    var tokens = tokenizers.nonword(null);
+    expect(tokens).toEqual([]);
+  });
+
+  it('.nonword should treat undefined as empty string', function() {
+    var tokens = tokenizers.nonword(undefined);
+    expect(tokens).toEqual([]);
+  });
+
+  it('.obj.whitespace should tokenize on whitespace', function() {
+    var t = tokenizers.obj.whitespace('val');
+    var tokens = t({ val: 'big-deal ok' });
+
+    expect(tokens).toEqual(['big-deal', 'ok']);
+  });
+
+  it('.obj.whitespace should accept multiple properties', function() {
+    var t = tokenizers.obj.whitespace('one', 'two');
+    var tokens = t({ one: 'big-deal ok', two: 'buzz' });
+
+    expect(tokens).toEqual(['big-deal', 'ok', 'buzz']);
+  });
+
+  it('.obj.whitespace should accept array', function() {
+    var t = tokenizers.obj.whitespace(['one', 'two']);
+    var tokens = t({ one: 'big-deal ok', two: 'buzz' });
+
+    expect(tokens).toEqual(['big-deal', 'ok', 'buzz']);
+  });
+
+  it('.obj.nonword should tokenize on non-word characters', function() {
+    var t = tokenizers.obj.nonword('val');
+    var tokens = t({ val: 'big-deal ok' });
+
+    expect(tokens).toEqual(['big', 'deal', 'ok']);
+  });
+
+  it('.obj.nonword should accept multiple properties', function() {
+    var t = tokenizers.obj.nonword('one', 'two');
+    var tokens = t({ one: 'big-deal ok', two: 'buzz' });
+
+    expect(tokens).toEqual(['big', 'deal', 'ok', 'buzz']);
+  });
+
+  it('.obj.nonword should accept array', function() {
+    var t = tokenizers.obj.nonword(['one', 'two']);
+    var tokens = t({ one: 'big-deal ok', two: 'buzz' });
+
+    expect(tokens).toEqual(['big', 'deal', 'ok', 'buzz']);
+  });
+});
diff --git a/test/bloodhound/transport_spec.js b/test/bloodhound/transport_spec.js
new file mode 100644
index 0000000..dde0eef
--- /dev/null
+++ b/test/bloodhound/transport_spec.js
@@ -0,0 +1,175 @@
+describe('Transport', function() {
+
+  beforeEach(function() {
+    jasmine.Ajax.useMock();
+    jasmine.Clock.useMock();
+
+    this.transport = new Transport({ transport: $.ajax });
+  });
+
+  afterEach(function() {
+    // run twice to flush out  on-deck requests
+    $.each(ajaxRequests, drop);
+    $.each(ajaxRequests, drop);
+
+    clearAjaxRequests();
+    Transport.resetCache();
+
+    function drop(i, req) {
+      req.readyState !== 4 && req.response(fixtures.ajaxResps.ok);
+    }
+  });
+
+  it('should use jQuery.ajax as the default transport mechanism', function() {
+    var req, resp = fixtures.ajaxResps.ok, spy = jasmine.createSpy();
+
+    this.transport.get('/test', spy);
+
+    req = mostRecentAjaxRequest();
+    req.response(resp);
+
+    expect(req.url).toBe('/test');
+    expect(spy).toHaveBeenCalledWith(null, resp.parsed);
+  });
+
+  it('should respect maxPendingRequests configuration', function() {
+    for (var i = 0; i < 10; i++) {
+      this.transport.get('/test' + i, $.noop);
+    }
+
+    expect(ajaxRequests.length).toBe(6);
+  });
+
+  it('should support rate limiting', function() {
+    this.transport = new Transport({ transport: $.ajax, limiter: limiter });
+
+    for (var i = 0; i < 5; i++) {
+      this.transport.get('/test' + i, $.noop);
+    }
+
+    jasmine.Clock.tick(100);
+    expect(ajaxRequests.length).toBe(1);
+
+    function limiter(fn) { return _.debounce(fn, 20); }
+  });
+
+  it('should cache most recent requests', function() {
+    var spy1 = jasmine.createSpy(), spy2 = jasmine.createSpy();
+
+    this.transport.get('/test1', $.noop);
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok);
+
+    this.transport.get('/test2', $.noop);
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok1);
+
+    expect(ajaxRequests.length).toBe(2);
+
+    this.transport.get('/test1', spy1);
+    this.transport.get('/test2', spy2);
+
+    jasmine.Clock.tick(0);
+
+    // no ajax requests were made on subsequent requests
+    expect(ajaxRequests.length).toBe(2);
+
+    expect(spy1).toHaveBeenCalledWith(null, fixtures.ajaxResps.ok.parsed);
+    expect(spy2).toHaveBeenCalledWith(null, fixtures.ajaxResps.ok1.parsed);
+  });
+
+  it('should not cache requests if cache option is false', function() {
+    this.transport = new Transport({ transport: $.ajax, cache: false });
+
+    this.transport.get('/test1', $.noop);
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok);
+    this.transport.get('/test1', $.noop);
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok);
+
+    expect(ajaxRequests.length).toBe(2);
+  });
+
+  it('should prevent dog pile', function() {
+    var spy1 = jasmine.createSpy(), spy2 = jasmine.createSpy();
+
+    this.transport.get('/test1', spy1);
+    this.transport.get('/test1', spy2);
+
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok);
+
+    expect(ajaxRequests.length).toBe(1);
+
+    waitsFor(function() { return spy1.callCount && spy2.callCount; });
+
+    runs(function() {
+      expect(spy1).toHaveBeenCalledWith(null, fixtures.ajaxResps.ok.parsed);
+      expect(spy2).toHaveBeenCalledWith(null, fixtures.ajaxResps.ok.parsed);
+    });
+  });
+
+  it('should always make a request for the last call to #get', function() {
+    var spy = jasmine.createSpy();
+
+    for (var i = 0; i < 6; i++) {
+      this.transport.get('/test' + i, $.noop);
+    }
+
+    this.transport.get('/test' + i, spy);
+    expect(ajaxRequests.length).toBe(6);
+
+    _.each(ajaxRequests, function(req) {
+      req.response(fixtures.ajaxResps.ok);
+    });
+
+    expect(ajaxRequests.length).toBe(7);
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok);
+
+    expect(spy).toHaveBeenCalled();
+  });
+
+  it('should invoke the callback with err set to true on failure', function() {
+    var req, resp = fixtures.ajaxResps.err, spy = jasmine.createSpy();
+
+    this.transport.get('/test', spy);
+
+    req = mostRecentAjaxRequest();
+    req.response(resp);
+
+    expect(req.url).toBe('/test');
+    expect(spy).toHaveBeenCalledWith(true);
+  });
+
+  it('should not send cancelled requests', function() {
+    this.transport = new Transport({ transport: $.ajax, limiter: limiter });
+
+    this.transport.get('/test', $.noop);
+    this.transport.cancel();
+
+    jasmine.Clock.tick(100);
+    expect(ajaxRequests.length).toBe(0);
+
+    function limiter(fn) { return _.debounce(fn, 20); }
+  });
+
+  it('should not send outdated requests', function() {
+    this.transport = new Transport({ transport: $.ajax, limiter: limiter });
+
+    // warm cache
+    this.transport.get('/test1', $.noop);
+    jasmine.Clock.tick(100);
+    mostRecentAjaxRequest().response(fixtures.ajaxResps.ok);
+
+    expect(mostRecentAjaxRequest().url).toBe('/test1');
+    expect(ajaxRequests.length).toBe(1);
+
+    // within the same rate-limit cycle, request test2 and test1. test2 becomes
+    // outdated after test1 is requested and no request is sent for test1
+    // because it's a cache hit
+    this.transport.get('/test2', $.noop);
+    this.transport.get('/test1', $.noop);
+
+    jasmine.Clock.tick(100);
+
+    expect(ajaxRequests.length).toBe(1);
+
+    function limiter(fn) { return _.debounce(fn, 20); }
+  });
+});
diff --git a/test/ci b/test/ci
new file mode 100755
index 0000000..0fb471e
--- /dev/null
+++ b/test/ci
@@ -0,0 +1,12 @@
+#!/bin/bash -x
+
+if [ "$TEST_SUITE" == "unit" ]; then
+  ./node_modules/karma/bin/karma start --single-run --browsers PhantomJS
+elif [ "$TRAVIS_SECURE_ENV_VARS" == "true" -a "$TEST_SUITE" == "integration" ]; then
+  static -p 8888 &
+  sleep 3
+  # integration tests are flaky, don't let them fail the build
+  ./node_modules/mocha/bin/mocha --harmony -R spec ./test/integration/test.js || true
+else
+  echo "Not running any tests"
+fi
diff --git a/test/fixtures/ajax_responses.js b/test/fixtures/ajax_responses.js
new file mode 100644
index 0000000..c24a698
--- /dev/null
+++ b/test/fixtures/ajax_responses.js
@@ -0,0 +1,19 @@
+var fixtures = fixtures || {};
+
+fixtures.ajaxResps = {
+  ok: {
+    status: 200,
+    responseText: '[{ "value": "big" }, { "value": "bigger" }, { "value": "biggest" }, { "value": "small" }, { "value": "smaller" }, { "value": "smallest" }]'
+  },
+  ok1: {
+    status: 200,
+    responseText: '["dog", "cat", "moose"]'
+  },
+  err: {
+    status: 500
+  }
+};
+
+$.each(fixtures.ajaxResps, function(i, resp) {
+  resp.responseText && (resp.parsed = $.parseJSON(resp.responseText));
+});
diff --git a/test/fixtures/data.js b/test/fixtures/data.js
new file mode 100644
index 0000000..f695eff
--- /dev/null
+++ b/test/fixtures/data.js
@@ -0,0 +1,128 @@
+var fixtures = fixtures || {};
+
+fixtures.data = {
+  simple: [
+    { value: 'big' },
+    { value: 'bigger' },
+    { value: 'biggest' },
+    { value: 'small' },
+    { value: 'smaller' },
+    { value: 'smallest' }
+  ],
+  animals: [
+    { value: 'dog' },
+    { value: 'cat' },
+    { value: 'moose' }
+  ]
+};
+
+fixtures.serialized = {
+  simple: {
+    "datums": {
+        "{\"value\":\"big\"}": {
+            "value": "big"
+        },
+        "{\"value\":\"bigger\"}": {
+            "value": "bigger"
+        },
+        "{\"value\":\"biggest\"}": {
+            "value": "biggest"
+        },
+        "{\"value\":\"small\"}": {
+            "value": "small"
+        },
+        "{\"value\":\"smaller\"}": {
+            "value": "smaller"
+        },
+        "{\"value\":\"smallest\"}": {
+            "value": "smallest"
+        }
+    },
+    "trie": {
+        "i": [],
+        "c": {
+            "b": {
+                "i": ["{\"value\":\"big\"}", "{\"value\":\"bigger\"}", "{\"value\":\"biggest\"}"],
+                "c": {
+                    "i": {
+                        "i": ["{\"value\":\"big\"}", "{\"value\":\"bigger\"}", "{\"value\":\"biggest\"}"],
+                        "c": {
+                            "g": {
+                                "i": ["{\"value\":\"big\"}", "{\"value\":\"bigger\"}", "{\"value\":\"biggest\"}"],
+                                "c": {
+                                    "g": {
+                                        "i": ["{\"value\":\"bigger\"}", "{\"value\":\"biggest\"}"],
+                                        "c": {
+                                            "e": {
+                                                "i": ["{\"value\":\"bigger\"}", "{\"value\":\"biggest\"}"],
+                                                "c": {
+                                                    "r": {
+                                                        "i": ["{\"value\":\"bigger\"}"],
+                                                        "c": {}
+                                                    },
+                                                    "s": {
+                                                        "i": ["{\"value\":\"biggest\"}"],
+                                                        "c": {
+                                                            "t": {
+                                                                "i": ["{\"value\":\"biggest\"}"],
+                                                                "c": {}
+                                                            }
+                                                        }
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+            "s": {
+                "i": ["{\"value\":\"small\"}", "{\"value\":\"smaller\"}", "{\"value\":\"smallest\"}"],
+                "c": {
+                    "m": {
+                        "i": ["{\"value\":\"small\"}", "{\"value\":\"smaller\"}", "{\"value\":\"smallest\"}"],
+                        "c": {
+                            "a": {
+                                "i": ["{\"value\":\"small\"}", "{\"value\":\"smaller\"}", "{\"value\":\"smallest\"}"],
+                                "c": {
+                                    "l": {
+                                        "i": ["{\"value\":\"small\"}", "{\"value\":\"smaller\"}", "{\"value\":\"smallest\"}"],
+                                        "c": {
+                                            "l": {
+                                                "i": ["{\"value\":\"small\"}", "{\"value\":\"smaller\"}", "{\"value\":\"smallest\"}"],
+                                                "c": {
+                                                    "e": {
+                                                        "i": ["{\"value\":\"smaller\"}", "{\"value\":\"smallest\"}"],
+                                                        "c": {
+                                                            "r": {
+                                                                "i": ["{\"value\":\"smaller\"}"],
+                                                                "c": {}
+                                                            },
+                                                            "s": {
+                                                                "i": ["{\"value\":\"smallest\"}"],
+                                                                "c": {
+                                                                    "t": {
+                                                                        "i": ["{\"value\":\"smallest\"}"],
+                                                                        "c": {}
+                                                                    }
+                                                                }
+                                                            }
+                                                        }
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+}
diff --git a/test/fixtures/html.js b/test/fixtures/html.js
new file mode 100644
index 0000000..1e2c2eb
--- /dev/null
+++ b/test/fixtures/html.js
@@ -0,0 +1,13 @@
+var fixtures = fixtures || {};
+
+fixtures.html = {
+  input: '<input class="tt-input" type="text" autocomplete="false" spellcheck="false">',
+  hint: '<input class="tt-hint" type="text" autocomplete="false" spellcheck="false" disabled>',
+  dataset: [
+    '<div class="tt-dataset-test">',
+      '<div class="tt-selectable"><p>one</p></div>',
+      '<div class="tt-selectable"><p>two</p></div>',
+      '<div class="tt-selectable"><p>three</p></div>',
+    '</div>'
+  ].join('')
+};
diff --git a/test/helpers/typeahead_mocks.js b/test/helpers/typeahead_mocks.js
new file mode 100644
index 0000000..d3f0d4e
--- /dev/null
+++ b/test/helpers/typeahead_mocks.js
@@ -0,0 +1,78 @@
+(function(root) {
+  var components;
+
+  components = [
+    'Bloodhound',
+    'Prefetch',
+    'Remote',
+    'PersistentStorage',
+    'Transport',
+    'SearchIndex',
+    'Input',
+    'Dataset',
+    'Menu'
+    ];
+
+  for (var i = 0; i < components.length; i++) {
+    makeMockable(components[i]);
+  }
+
+  function makeMockable(component) {
+    var Original, Mock;
+
+    Original = root[component];
+    Mock = mock(Original);
+
+    jasmine[component] = { useMock: useMock, uninstallMock: uninstallMock };
+
+    function useMock() {
+      root[component] = Mock;
+      jasmine.getEnv().currentSpec.after(uninstallMock);
+    }
+
+    function uninstallMock() {
+      root[component] = Original;
+    }
+  }
+
+  function mock(Constructor) {
+    var constructorSpy;
+
+    Mock.prototype = Constructor.prototype;
+    constructorSpy = jasmine.createSpy('mock constructor').andCallFake(Mock);
+
+    // copy instance methods
+    for (var key in Constructor) {
+      if (typeof Constructor[key] === 'function') {
+        constructorSpy[key] = Constructor[key];
+      }
+    }
+
+    return constructorSpy;
+
+    function Mock() {
+      var instance = _.mixin({}, Constructor.prototype);
+
+      for (var key in instance) {
+        if (typeof instance[key] === 'function') {
+          spyOn(instance, key);
+
+          // special case for some components
+          if (key === 'bind') {
+            instance[key].andCallFake(function() { return this; });
+          }
+        }
+      }
+
+      // have the event emitter methods call through
+      instance.onSync && instance.onSync.andCallThrough();
+      instance.onAsync && instance.onAsync.andCallThrough();
+      instance.off && instance.off.andCallThrough();
+      instance.trigger && instance.trigger.andCallThrough();
+
+      instance.constructor = Constructor;
+
+      return instance;
+    }
+  }
+})(this);
diff --git a/test/integration/test.html b/test/integration/test.html
new file mode 100644
index 0000000..e4d6812
--- /dev/null
+++ b/test/integration/test.html
@@ -0,0 +1,108 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title></title>
+    <script src="../../bower_components/jquery/jquery.js"></script>
+    <script src="../../dist/typeahead.bundle.js"></script>
+
+    <style>
+      .container {
+        width: 800px;
+        margin: 50px auto;
+      }
+
+      .typeahead-wrapper {
+        display: block;
+        margin: 50px 0;
+      }
+
+      .tt-menu {
+        background-color: #fff;
+        border: 1px solid #000;
+      }
+
+      .tt-suggestion.tt-cursor {
+        background-color: #ccc;
+      }
+    </style>
+  </head>
+
+  <body>
+    <div class="container">
+      <form action="/where" method="GET">
+        <div class="typeahead-wrapper">
+          <input id="states" name="states" type="text">
+          <input type="submit">
+        </div>
+      </form>
+    </div>
+
+    <script>
+      var states = new Bloodhound({
+        datumTokenizer: Bloodhound.tokenizers.obj.whitespace('val'),
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          { val: 'Alabama' },
+          { val: 'Alaska' },
+          { val: 'Arizona' },
+          { val: 'Arkansas' },
+          { val: 'California' },
+          { val: 'Colorado' },
+          { val: 'Connecticut' },
+          { val: 'Delaware' },
+          { val: 'Florida' },
+          { val: 'Georgia' },
+          { val: 'Hawaii' },
+          { val: 'Idaho' },
+          { val: 'Illinois' },
+          { val: 'Indiana' },
+          { val: 'Iowa' },
+          { val: 'Kansas' },
+          { val: 'Kentucky' },
+          { val: 'Louisiana' },
+          { val: 'Maine' },
+          { val: 'Maryland' },
+          { val: 'Massachusetts' },
+          { val: 'Michigan' },
+          { val: 'Minnesota' },
+          { val: 'Mississippi' },
+          { val: 'Missouri' },
+          { val: 'Montana' },
+          { val: 'Nebraska' },
+          { val: 'Nevada' },
+          { val: 'New Hampshire' },
+          { val: 'New Jersey' },
+          { val: 'New Mexico' },
+          { val: 'New York' },
+          { val: 'North Carolina' },
+          { val: 'North Dakota' },
+          { val: 'Ohio' },
+          { val: 'Oklahoma' },
+          { val: 'Oregon' },
+          { val: 'Pennsylvania' },
+          { val: 'Rhode Island' },
+          { val: 'South Carolina' },
+          { val: 'South Dakota' },
+          { val: 'Tennessee' },
+          { val: 'Texas' },
+          { val: 'Utah' },
+          { val: 'Vermont' },
+          { val: 'Virginia' },
+          { val: 'Washington' },
+          { val: 'West Virginia' },
+          { val: 'Wisconsin' },
+          { val: 'Wyoming' },
+          { val: 'this is a very long value so deal with it' }
+        ]
+      });
+
+      $('#states').typeahead({
+        highlight: true
+      },
+      {
+        display: 'val',
+        source: states
+      });
+    </script>
+  </body>
+</html>
diff --git a/test/integration/test.js b/test/integration/test.js
new file mode 100644
index 0000000..de3b8a4
--- /dev/null
+++ b/test/integration/test.js
@@ -0,0 +1,395 @@
+/* jshint esnext: true, evil: true, sub: true */
+
+var wd = require('yiewd'),
+    colors = require('colors'),
+    expect = require('chai').expect,
+    _ = require('underscore'),
+    f = require('util').format,
+    env = process.env;
+
+var browser, caps;
+
+browser = (process.env.BROWSER || 'chrome').split(':');
+
+caps = {
+  name: f('[%s] typeahead.js ui', browser.join(' , ')),
+  browserName: browser[0]
+};
+
+setIf(caps, 'version', browser[1]);
+setIf(caps, 'platform', browser[2]);
+setIf(caps, 'tunnel-identifier', env['TRAVIS_JOB_NUMBER']);
+setIf(caps, 'build', env['TRAVIS_BUILD_NUMBER']);
+setIf(caps, 'tags', env['CI'] ? ['CI'] : ['local']);
+
+function setIf(obj, key, val) {
+  val && (obj[key] = val);
+}
+
+describe('jquery-typeahead.js', function() {
+  var driver, body, input, hint, dropdown, allPassed = true;
+
+  this.timeout(300000);
+
+  before(function(done) {
+    var host = 'ondemand.saucelabs.com', port = 80, username, password;
+
+    if (env['CI']) {
+      host = 'localhost';
+      port = 4445;
+      username = env['SAUCE_USERNAME'];
+      password = env['SAUCE_ACCESS_KEY'];
+    }
+
+    driver = wd.remote(host, port, username, password);
+    driver.configureHttp({
+      timeout: 30000,
+      retries: 5,
+      retryDelay: 200
+    });
+
+    driver.on('status', function(info) {
+      console.log(info.cyan);
+    });
+
+    driver.on('command', function(meth, path, data) {
+      console.log(' > ' + meth.yellow, path.grey, data || '');
+    });
+
+    driver.run(function*() {
+      yield this.init(caps);
+      yield this.get('http://localhost:8888/test/integration/test.html');
+
+      body = yield this.elementByTagName('body');
+      input = yield this.elementById('states');
+      hint = yield this.elementByClassName('tt-hint');
+      dropdown = yield this.elementByClassName('tt-menu');
+
+      done();
+    });
+  });
+
+  afterEach(function(done) {
+    allPassed = allPassed && (this.currentTest.state === 'passed');
+
+    driver.run(function*() {
+      yield body.click();
+      yield this.execute('window.jQuery("#states").typeahead("val", "")');
+      done();
+    });
+  });
+
+  after(function(done) {
+    driver.run(function*() {
+      yield this.quit();
+      yield driver.sauceJobStatus(allPassed);
+      done();
+    });
+  });
+
+  describe('on blur', function() {
+    it('should close dropdown', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+        expect(yield dropdown.isDisplayed()).to.equal(true);
+
+        yield body.click();
+        expect(yield dropdown.isDisplayed()).to.equal(false);
+
+        done();
+      });
+    });
+
+    it('should clear hint', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+        expect(yield hint.getValue()).to.equal('michigan');
+
+        yield body.click();
+        expect(yield hint.getValue()).to.equal('');
+
+        done();
+      });
+    });
+  });
+
+  describe('on query change', function() {
+    it('should open dropdown if suggestions', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+
+        expect(yield dropdown.isDisplayed()).to.equal(true);
+
+        done();
+      });
+    });
+
+    it('should close dropdown if no suggestions', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('huh?');
+
+        expect(yield dropdown.isDisplayed()).to.equal(false);
+
+        done();
+      });
+    });
+
+    it('should render suggestions if suggestions', function(done) {
+      driver.run(function*() {
+        var suggestions;
+
+        yield input.click();
+        yield input.type('mi');
+
+        suggestions = yield dropdown.elementsByClassName('tt-suggestion');
+
+        expect(suggestions).to.have.length('4');
+        expect(yield suggestions[0].text()).to.equal('Michigan');
+        expect(yield suggestions[1].text()).to.equal('Minnesota');
+        expect(yield suggestions[2].text()).to.equal('Mississippi');
+        expect(yield suggestions[3].text()).to.equal('Missouri');
+
+        done();
+      });
+    });
+
+    it('should show hint if top suggestion is a match', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+
+        expect(yield hint.getValue()).to.equal('michigan');
+
+        done();
+      });
+    });
+
+    it('should match hint to query', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('NeW    JE');
+
+        expect(yield hint.getValue()).to.equal('NeW    JErsey');
+
+        done();
+      });
+    });
+
+    it('should not show hint if top suggestion is not a match', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('ham');
+
+        expect(yield hint.getValue()).to.equal('');
+
+        done();
+      });
+    });
+
+    it('should not show hint if there is query overflow', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('this    is    a very long    value     so ');
+
+        expect(yield hint.getValue()).to.equal('');
+
+        done();
+      });
+    });
+  });
+
+  describe('on up arrow', function() {
+    it('should cycle through suggestions', function(done) {
+      driver.run(function*() {
+        var suggestions;
+
+        yield input.click();
+        yield input.type('mi');
+
+        suggestions = yield dropdown.elementsByClassName('tt-suggestion');
+
+        yield input.type(wd.SPECIAL_KEYS['Up arrow']);
+        expect(yield input.getValue()).to.equal('Missouri');
+        expect(yield suggestions[3].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Up arrow']);
+        expect(yield input.getValue()).to.equal('Mississippi');
+        expect(yield suggestions[2].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Up arrow']);
+        expect(yield input.getValue()).to.equal('Minnesota');
+        expect(yield suggestions[1].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Up arrow']);
+        expect(yield input.getValue()).to.equal('Michigan');
+        expect(yield suggestions[0].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Up arrow']);
+        expect(yield input.getValue()).to.equal('mi');
+        expect(yield suggestions[0].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+        expect(yield suggestions[1].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+        expect(yield suggestions[2].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+        expect(yield suggestions[3].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+
+        done();
+      });
+    });
+  });
+
+  describe('on down arrow', function() {
+    it('should cycle through suggestions', function(done) {
+      driver.run(function*() {
+        var suggestions;
+
+        yield input.click();
+        yield input.type('mi');
+
+        suggestions = yield dropdown.elementsByClassName('tt-suggestion');
+
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        expect(yield input.getValue()).to.equal('Michigan');
+        expect(yield suggestions[0].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        expect(yield input.getValue()).to.equal('Minnesota');
+        expect(yield suggestions[1].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        expect(yield input.getValue()).to.equal('Mississippi');
+        expect(yield suggestions[2].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        expect(yield input.getValue()).to.equal('Missouri');
+        expect(yield suggestions[3].getAttribute('class')).to.equal('tt-suggestion tt-selectable tt-cursor');
+
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        expect(yield input.getValue()).to.equal('mi');
+        expect(yield suggestions[0].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+        expect(yield suggestions[1].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+        expect(yield suggestions[2].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+        expect(yield suggestions[3].getAttribute('class')).to.equal('tt-suggestion tt-selectable');
+
+        done();
+      });
+    });
+  });
+
+  describe('on escape', function() {
+    it('should close dropdown', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+        expect(yield dropdown.isDisplayed()).to.equal(true);
+
+        yield input.type(wd.SPECIAL_KEYS['Escape']);
+        expect(yield dropdown.isDisplayed()).to.equal(false);
+
+        done();
+      });
+    });
+
+    it('should clear hint', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+        expect(yield hint.getValue()).to.equal('michigan');
+
+        yield input.type(wd.SPECIAL_KEYS['Escape']);
+        expect(yield hint.getValue()).to.equal('');
+
+        done();
+      });
+    });
+  });
+
+  describe('on tab', function() {
+    it('should autocomplete if hint is present', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+
+        yield input.type(wd.SPECIAL_KEYS['Tab']);
+        expect(yield input.getValue()).to.equal('Michigan');
+
+        done();
+      });
+    });
+
+    it('should select if cursor is on suggestion', function(done) {
+      driver.run(function*() {
+        var suggestions;
+
+        yield input.click();
+        yield input.type('mi');
+
+        suggestions = yield dropdown.elementsByClassName('tt-suggestion');
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        yield input.type(wd.SPECIAL_KEYS['Tab']);
+
+        expect(yield dropdown.isDisplayed()).to.equal(false);
+        expect(yield input.getValue()).to.equal('Minnesota');
+
+        done();
+      });
+    });
+  });
+
+  describe('on right arrow', function() {
+    it('should autocomplete if hint is present', function(done) {
+      driver.run(function*() {
+        yield input.click();
+        yield input.type('mi');
+
+        yield input.type(wd.SPECIAL_KEYS['Right arrow']);
+        expect(yield input.getValue()).to.equal('Michigan');
+
+        done();
+      });
+    });
+  });
+
+  describe('on suggestion click', function() {
+    it('should select suggestion', function(done) {
+      driver.run(function*() {
+        var suggestions;
+
+        yield input.click();
+        yield input.type('mi');
+
+        suggestions = yield dropdown.elementsByClassName('tt-suggestion');
+        yield suggestions[1].click();
+
+        expect(yield dropdown.isDisplayed()).to.equal(false);
+        expect(yield input.getValue()).to.equal('Minnesota');
+
+        done();
+      });
+    });
+  });
+
+  describe('on enter', function() {
+    it('should select if cursor is on suggestion', function(done) {
+      driver.run(function*() {
+        var suggestions;
+
+        yield input.click();
+        yield input.type('mi');
+
+        suggestions = yield dropdown.elementsByClassName('tt-suggestion');
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        yield input.type(wd.SPECIAL_KEYS['Down arrow']);
+        yield input.type(wd.SPECIAL_KEYS['Return']);
+
+        expect(yield dropdown.isDisplayed()).to.equal(false);
+        expect(yield input.getValue()).to.equal('Minnesota');
+
+        done();
+      });
+    });
+  });
+});
diff --git a/test/playground.html b/test/playground.html
new file mode 100644
index 0000000..4dad238
--- /dev/null
+++ b/test/playground.html
@@ -0,0 +1,346 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <script src="../bower_components/jquery/jquery.js"></script>
+    <script src="../dist/typeahead.bundle.js"></script>
+
+    <style>
+      .container {
+        width: 800px;
+        margin: 50px auto;
+      }
+
+      .typeahead-wrapper {
+        display: block;
+        margin: 50px 0;
+      }
+
+      .tt-dropdown-menu {
+        background-color: #fff;
+        border: 1px solid #000;
+      }
+
+      .tt-suggestion.tt-cursor {
+        background-color: #ccc;
+      }
+
+      .triggered-events {
+        float: right;
+        width: 500px;
+        height: 300px;
+      }
+    </style>
+  </head>
+
+  <body>
+    <div class="container">
+      <textarea class="triggered-events"></textarea>
+      <form action="/where" method="GET">
+        <div class="typeahead-wrapper">
+          <input class="states" name="states" type="text" placeholder="states" value="Michigan">
+          <input type="submit">
+        </div>
+      </form>
+      <div class="typeahead-wrapper">
+        <input class="bad-tokens" type="text" placeholder="bad tokens">
+      </div>
+      <div class="typeahead-wrapper">
+        <input class="regex-symbols" type="text" placeholder="regex symbols">
+      </div>
+      <div class="typeahead-wrapper">
+        <input class="header-footer" type="text" placeholder="header footer">
+      </div>
+      <div class="typeahead-wrapper">
+        <input class="ltr" type="text" placeholder="ltr">
+      </div>
+      <div class="typeahead-wrapper">
+        <input class="rtl" type="text" placeholder="rtl">
+      </div>
+      <div class="typeahead-wrapper">
+        <input class="mixed" type="text" placeholder="mixed">
+      </div>
+    </div>
+    </div>
+
+    <script>
+      var states = new Bloodhound({
+        datumTokenizer: Bloodhound.tokenizers.whitespace,
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          'Alabama',
+          'Alaska',
+          'Arizona',
+          'Arkansas',
+          'California',
+          'Colorado',
+          'Connecticut',
+          'Delaware',
+          'Florida',
+          'Georgia',
+          'Hawaii',
+          'Idaho',
+          'Illinois',
+          'Indiana',
+          'Iowa',
+          'Kansas',
+          'Kentucky',
+          'Louisiana',
+          'Maine',
+          'Maryland',
+          'Massachusetts',
+          'Michigan',
+          'Minnesota',
+          'Mississippi',
+          'Missouri',
+          'Montana',
+          'Nebraska',
+          'Nevada',
+          'New Hampshire',
+          'New Jersey',
+          'New Mexico',
+          'New York',
+          'North Carolina',
+          'North Dakota',
+          'Ohio',
+          'Oklahoma',
+          'Oregon',
+          'Pennsylvania',
+          'Rhode Island',
+          'South Carolina',
+          'South Dakota',
+          'Tennessee',
+          'Texas',
+          'Utah',
+          'Vermont',
+          'Virginia',
+          'Washington',
+          'West Virginia',
+          'Wisconsin',
+          'Wyoming'
+        ]
+      });
+
+      states.initialize();
+
+      $('.states').typeahead({
+        highlight: true
+      },
+      {
+        source: states
+      });
+
+
+      var badTokens = new Bloodhound({
+        datumTokenizer: function(d) { return d.tokens; },
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          {
+            value1: 'all bad',
+            jake: '111',
+            tokens: ['  ', ' ', null, undefined, false, 'all', 'bad']
+          },
+          {
+            value1: 'whitespace',
+            jake: '112',
+            tokens: ['  ', ' ', '\t', '\n', 'whitespace']
+          },
+          {
+            value1: 'undefined',
+            jake: '113',
+            tokens: [undefined, 'undefined']
+          },
+          {
+            value1: 'null',
+            jake: '114',
+            tokens: [null, 'null']
+          },
+          {
+            value1: 'false',
+            jake: '115',
+            tokens: [false, 'false']
+          }
+        ]
+      });
+
+      badTokens.initialize();
+
+      $('.bad-tokens').typeahead(null, {
+        displayKey: 'value1',
+        source: badTokens
+      });
+
+      var regexSymbols = new Bloodhound({
+        datumTokenizer: function(d) { 
+          return Bloodhound.tokenizers.whitespace(d.val); 
+        },
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          { val: '*.js' },
+          { val: '[Tt]ypeahead.js' },
+          { val: '^typeahead.js$' },
+          { val: 'typeahead.js(0.8.2)' },
+          { val: 'typeahead.js(@\\d.\\d.\\d)' },
+          { val: 'typeahead.js at 0.8.2' }
+        ]
+      });
+
+      regexSymbols.initialize();
+
+      $('.regex-symbols').typeahead(null, {
+        displayKey: 'val',
+        source: regexSymbols
+      });
+
+      var abc = new Bloodhound({
+        datumTokenizer: function(d) { 
+          return Bloodhound.tokenizers.whitespace(d.val); 
+        },
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          { val: 'a' }, 
+          { val: 'ab' }, 
+          { val: 'abc' }, 
+          { val: 'abcd' }, 
+          { val: 'abcde' }
+        ]
+      });
+
+      abc.initialize();
+
+      $('.header-footer').typeahead(null, {
+        displayKey: 'val',
+        source: abc,
+        templates: {
+          header: '<h3>Header</h3>',
+          footer: '<h3>Footer</h3>'
+        }
+      },
+      {
+        displayKey: 'val',
+        source: abc,
+        templates: {
+          header: '<h3>start</h3>',
+          footer: '<h3>end</h3>',
+          empty: '<h3>empty</h3>'
+        }
+      });
+
+      var ltr = new Bloodhound({
+        datumTokenizer: function(d) { 
+          return Bloodhound.tokenizers.whitespace(d.val); 
+        },
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          { val: "one" },
+          { val: "two three" },
+          { val: "four" },
+          { val: "five six" },
+          { val: "seven" }
+        ]
+      });
+
+      ltr.initialize();
+
+      $('.ltr').typeahead({
+        highlight: true
+      },
+      {
+        displayKey: 'val',
+        source: ltr
+      });
+
+      var rtl = new Bloodhound({
+        datumTokenizer: function(d) { 
+          return Bloodhound.tokenizers.whitespace(d.val); 
+        },
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          { val: "שלום" },
+          { val: "ערב טוב" },
+          { val: "מה שלומך" },
+          { val: "רב תודות" },
+          { val: "אין דבר" }
+        ]
+      });
+
+      rtl.initialize();
+
+      $('.rtl').typeahead({
+        highlight: true
+      },
+      {
+        displayKey: 'val',
+        source: rtl
+      });
+
+      var mixed = new Bloodhound({
+        datumTokenizer: function(d) { 
+          return Bloodhound.tokenizers.whitespace(d.val); 
+        },
+        queryTokenizer: Bloodhound.tokenizers.whitespace,
+        local: [
+          { val: "שלום" },
+          { val: "ערב טוב" },
+          { val: "מה שלומך" },
+          { val: "one" },
+          { val: "two three" }
+        ]
+      });
+
+      mixed.initialize();
+
+      $('.mixed').typeahead({
+        highlight: true
+      },
+      {
+        displayKey: 'val',
+        source: mixed
+      });
+
+
+      $('input').on([
+        'typeahead:active',
+        'typeahead:idle',
+        'typeahead:open',
+        'typeahead:close',
+        'typeahead:change',
+        'typeahead:render',
+        'typeahead:select',
+        'typeahead:autocomplete',
+        'typeahead:cursorchange',
+      ].join(' '), logTypeaheadEvent);
+
+      $('form').on('submit', logSubmitEvent);
+      
+      function logSubmitEvent($e) {
+        var text; 
+
+        $e && $e.preventDefault(); 
+
+        text = JSON.stringify($(this).serializeArray()); 
+        writeToTextarea('submit', text);
+      }
+
+      function logTypeaheadEvent($e) {
+        var args, type, text;
+
+        args = [].slice.call(arguments, 1);
+        type = $e.type;
+        text = window.JSON ? JSON.stringify(args) : '';
+
+        writeToTextarea(type, text);
+      }
+
+      function writeToTextarea(/* lines */) {
+        var $textarea, val, text;
+
+        $textarea = $('.triggered-events');
+        val = $textarea.val();
+        text = [].join.call(arguments, '\n');
+
+        $textarea.val([val, text, '\n'].join('\n'));
+        $textarea[0].scrollTop = $textarea[0].scrollHeight;
+      }
+    </script>
+  </body>
+</html>
diff --git a/test/typeahead/dataset_spec.js b/test/typeahead/dataset_spec.js
new file mode 100644
index 0000000..e6560a5
--- /dev/null
+++ b/test/typeahead/dataset_spec.js
@@ -0,0 +1,469 @@
+describe('Dataset', function() {
+  var www = WWW(), mockSuggestions, mockSuggestionsDisplayFn;
+
+  mockSuggestions = [
+    { value: 'one', raw: { value: 'one' } },
+    { value: 'two', raw: { value: 'two' } },
+    { value: 'html', raw: { value: '<b>html</b>' } }
+  ];
+
+  mockSuggestionsDisplayFn = [
+    { display: '4' },
+    { display: '5' },
+    { display: '6' }
+  ];
+
+  beforeEach(function() {
+    this.dataset = new Dataset({
+      name: 'test',
+      node: $('<div>'),
+      source: this.source = jasmine.createSpy('source')
+    }, www);
+  });
+
+  it('should throw an error if source is missing', function() {
+    expect(noSource).toThrow();
+
+    function noSource() { new Dataset({}, www); }
+  });
+
+  it('should throw an error if the name is not a valid class name', function() {
+    expect(fn).toThrow();
+
+    function fn() {
+      var d = new Dataset({
+        name: 'a space',
+        node: $('<div>'),
+        source: $.noop
+      }, www);
+    }
+  });
+
+  describe('#getRoot', function() {
+    it('should return the root element', function() {
+      var sel = 'div' + www.selectors.dataset + www.selectors.dataset + '-test';
+      expect(this.dataset.$el).toBe(sel);
+    });
+  });
+
+  describe('#update', function() {
+    it('should render suggestions', function() {
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('one');
+      expect(this.dataset.$el).toContainText('two');
+      expect(this.dataset.$el).toContainText('html');
+    });
+
+    it('should escape html chars from display value when using default template', function() {
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('<b>html</b>');
+    });
+
+    it('should respect limit option', function() {
+      this.dataset.limit = 2;
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('one');
+      expect(this.dataset.$el).toContainText('two');
+      expect(this.dataset.$el).not.toContainText('three');
+    });
+
+    it('should allow custom display functions', function() {
+      this.dataset = new Dataset({
+        name: 'test',
+        node: $('<div>'),
+        display: function(o) { return o.display; },
+        source: this.source = jasmine.createSpy('source')
+      }, www);
+
+      this.source.andCallFake(syncMockSuggestionsDisplayFn);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('4');
+      expect(this.dataset.$el).toContainText('5');
+      expect(this.dataset.$el).toContainText('6');
+    });
+
+    it('should ignore async invocations of sync', function() {
+      this.source.andCallFake(asyncSync);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).not.toContainText('one');
+    });
+
+    it('should ignore subesequent invocations of sync', function() {
+      this.source.andCallFake(multipleSync);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el.find('.tt-suggestion')).toHaveLength(3);
+    });
+
+    it('should trigger asyncRequested when needing/expecting backfill', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.async = true;
+      this.dataset.onSync('asyncRequested', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should not trigger asyncRequested when not expecting backfill', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.async = false;
+      this.dataset.onSync('asyncRequested', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+
+    it('should not trigger asyncRequested when not expecting backfill', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.limit = 2;
+      this.dataset.async = true;
+      this.dataset.onSync('asyncRequested', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+
+    it('should trigger asyncCanceled when pending aysnc is canceled', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.async = true;
+      this.dataset.onSync('asyncCanceled', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+      this.dataset.cancel();
+
+      waits(100);
+
+      runs(function() {
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+
+    it('should not trigger asyncCanceled when cancel happens after update', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.async = true;
+      this.dataset.onSync('asyncCanceled', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+
+      waits(100);
+
+      runs(function() {
+        this.dataset.cancel();
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    it('should trigger asyncReceived when aysnc is received', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.async = true;
+      this.dataset.onSync('asyncReceived', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+
+      waits(100);
+
+      runs(function() {
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+
+    it('should not trigger asyncReceived if canceled', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.async = true;
+      this.dataset.onSync('asyncReceived', spy);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+      this.dataset.cancel();
+
+      waits(100);
+
+      runs(function() {
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    it('should not modify sync when async is added', function() {
+      var $test;
+
+      this.dataset.async = true;
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+      $test = this.dataset.$el.find('.tt-suggestion').first();
+      $test.addClass('test');
+
+      waits(100);
+
+      runs(function() {
+        expect($test).toHaveClass('test');
+      });
+    });
+
+    it('should respect limit option in regard to async', function() {
+      this.dataset.async = true;
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+      this.dataset.update('woah');
+
+      waits(100);
+
+      runs(function() {
+        expect(this.dataset.$el.find('.tt-suggestion')).toHaveLength(5);
+      });
+    });
+
+    it('should cancel pending async', function() {
+      var spy1 = jasmine.createSpy(), spy2 = jasmine.createSpy();
+
+      this.dataset.async = true;
+      this.dataset.onSync('asyncCanceled', spy1);
+      this.dataset.onSync('asyncReceived', spy2);
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+
+
+      this.dataset.update('woah');
+      this.dataset.update('woah again');
+
+      waits(100);
+
+      runs(function() {
+        expect(spy1.callCount).toBe(1);
+        expect(spy2.callCount).toBe(1);
+      });
+    });
+
+    it('should render notFound when no suggestions are available', function() {
+      this.dataset = new Dataset({
+        source: this.source,
+        node: $('<div>'),
+        templates: {
+          notFound: '<h2>empty</h2>'
+        }
+      }, www);
+
+      this.source.andCallFake(syncEmptySuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('empty');
+    });
+
+    it('should render pending when no suggestions are available but async is pending', function() {
+      this.dataset = new Dataset({
+        source: this.source,
+        node: $('<div>'),
+        async: true,
+        templates: {
+          pending: '<h2>pending</h2>'
+        }
+      }, www);
+
+      this.source.andCallFake(syncEmptySuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('pending');
+    });
+
+    it('should render header when suggestions are rendered', function() {
+      this.dataset = new Dataset({
+        source: this.source,
+        node: $('<div>'),
+        templates: {
+          header: '<h2>header</h2>'
+        }
+      }, www);
+
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('header');
+    });
+
+    it('should render footer when suggestions are rendered', function() {
+      this.dataset = new Dataset({
+        source: this.source,
+        node: $('<div>'),
+        templates: {
+          footer: function(c) { return '<p>' + c.query + '</p>'; }
+        }
+      }, www);
+
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).toContainText('woah');
+    });
+
+    it('should not render header/footer if there is no content', function() {
+      this.dataset = new Dataset({
+        source: this.source,
+        node: $('<div>'),
+        templates: {
+          header: '<h2>header</h2>',
+          footer: '<h2>footer</h2>'
+        }
+      }, www);
+
+      this.source.andCallFake(syncEmptySuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.$el).not.toContainText('header');
+      expect(this.dataset.$el).not.toContainText('footer');
+    });
+
+    it('should not render stale suggestions', function() {
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+      this.dataset.update('woah');
+
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('nelly');
+
+      waits(100);
+
+      runs(function() {
+        expect(this.dataset.$el).toContainText('one');
+        expect(this.dataset.$el).toContainText('two');
+        expect(this.dataset.$el).toContainText('html');
+        expect(this.dataset.$el).not.toContainText('four');
+        expect(this.dataset.$el).not.toContainText('five');
+      });
+    });
+
+    it('should not render async suggestions if update was canceled', function() {
+      this.source.andCallFake(fakeGetWithAsyncSuggestions);
+      this.dataset.update('woah');
+      this.dataset.cancel();
+
+      waits(100);
+
+      runs(function() {
+        var rendered = this.dataset.$el.find('.tt-suggestion');
+        expect(rendered).toHaveLength(3);
+      });
+    });
+
+    it('should trigger rendered after suggestions are rendered', function() {
+      var spy;
+
+      this.dataset.onSync('rendered', spy = jasmine.createSpy());
+
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      waitsFor(function() { return spy.callCount; });
+    });
+  });
+
+  describe('#clear', function() {
+    it('should clear suggestions', function() {
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      this.dataset.clear();
+      expect(this.dataset.$el).toBeEmpty();
+    });
+
+    it('should cancel pending updates', function() {
+      var spy;
+
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+      spy = spyOn(this.dataset, 'cancel');
+
+      this.dataset.clear();
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should trigger cleared', function() {
+      var spy;
+
+      this.dataset.onSync('cleared', spy = jasmine.createSpy());
+      this.dataset.clear();
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('#isEmpty', function() {
+    it('should return true when empty', function() {
+      expect(this.dataset.isEmpty()).toBe(true);
+    });
+
+    it('should return false when not empty', function() {
+      this.source.andCallFake(syncMockSuggestions);
+      this.dataset.update('woah');
+
+      expect(this.dataset.isEmpty()).toBe(false);
+    });
+  });
+
+  describe('#destroy', function() {
+    it('should set dataset element to dummy element', function() {
+      var $prevEl = this.dataset.$el;
+
+      this.dataset.destroy();
+      expect(this.dataset.$el).not.toBe($prevEl);
+    });
+  });
+
+  // helper functions
+  // ----------------
+
+  function syncEmptySuggestions(q, sync, async) {
+    sync([]);
+  }
+
+  function syncMockSuggestions(q, sync, async) {
+    sync(mockSuggestions);
+  }
+
+  function syncMockSuggestionsDisplayFn(q, sync, async) {
+    sync(mockSuggestionsDisplayFn);
+  }
+
+  function asyncSync(q, sync, async) {
+    setTimeout(function() { sync(mockSuggestions); }, 0);
+  }
+
+  function multipleSync(q, sync, async) {
+    sync(mockSuggestions);
+    sync(mockSuggestions);
+  }
+
+  function fakeGetWithAsyncSuggestions(query, sync, async) {
+    sync(mockSuggestions);
+
+    setTimeout(function() {
+      async([
+        { value: 'four', raw: { value: 'four' } },
+        { value: 'five', raw: { value: 'five' } },
+        { value: 'six', raw: { value: 'six' } },
+        { value: 'seven', raw: { value: 'seven' } },
+        { value: 'eight', raw: { value: 'eight' } },
+      ]);
+    }, 0);
+  }
+});
diff --git a/test/typeahead/default_results_spec.js b/test/typeahead/default_results_spec.js
new file mode 100644
index 0000000..8ce1181
--- /dev/null
+++ b/test/typeahead/default_results_spec.js
@@ -0,0 +1,103 @@
+describe('DefaultMenu', function() {
+  var www = WWW();
+
+  beforeEach(function() {
+    var $fixture;
+
+    jasmine.Dataset.useMock();
+
+    setFixtures('<div id="menu-fixture"></div>');
+
+    $fixture = $('#jasmine-fixtures');
+    this.$node = $fixture.find('#menu-fixture');
+    this.$node.html(fixtures.html.dataset);
+
+    this.view = new DefaultMenu({ node: this.$node, datasets: [{}] }, www).bind();
+    this.dataset = this.view.datasets[0];
+  });
+
+  describe('when rendered is triggered on a dataset', function() {
+    it('should hide menu if empty', function() {
+      this.dataset.isEmpty.andReturn(true);
+
+      this.view._show();
+      this.dataset.trigger('rendered');
+
+      expect(this.$node).not.toBeVisible();
+    });
+
+    it('should not show menu if not open', function() {
+      this.dataset.isEmpty.andReturn(false);
+
+      this.view._hide();
+      this.dataset.trigger('rendered');
+
+      expect(this.$node).not.toBeVisible();
+    });
+
+    it('should show menu if not empty and open', function() {
+      this.dataset.isEmpty.andReturn(false);
+
+      this.view._hide();
+      this.view.open();
+      this.dataset.trigger('rendered');
+
+      expect(this.$node).toBeVisible();
+    });
+  });
+
+  describe('when cleared is triggered on a dataset', function() {
+    it('should hide menu if empty', function() {
+      this.dataset.isEmpty.andReturn(true);
+
+      this.view._show();
+      this.dataset.trigger('cleared');
+
+      expect(this.$node).not.toBeVisible();
+    });
+
+    it('should not show menu if not open', function() {
+      this.dataset.isEmpty.andReturn(false);
+
+      this.view._hide();
+      this.dataset.trigger('cleared');
+
+      expect(this.$node).not.toBeVisible();
+    });
+
+    it('should show menu if not empty and open', function() {
+      this.dataset.isEmpty.andReturn(false);
+
+      this.view._hide();
+      this.view.open();
+      this.dataset.trigger('cleared');
+
+      expect(this.$node).toBeVisible();
+    });
+  });
+
+  describe('#open', function() {
+    it('should show menu if not empty', function() {
+      spyOn(this.view, '_allDatasetsEmpty').andReturn(false);
+      this.view.open();
+
+      expect(this.$node[0].getAttribute('style')).toMatch(/display: block/);
+    });
+
+    it('should not show menu if empty', function() {
+      spyOn(this.view, '_allDatasetsEmpty').andReturn(true);
+      this.view.open();
+
+      expect(this.$node).not.toHaveAttr('style', 'display: block;');
+    });
+  });
+
+  describe('#close', function() {
+    it('should hide menu', function() {
+      this.view._show();
+      this.view.close();
+
+      expect(this.$node).not.toBeVisible();
+    });
+  });
+});
diff --git a/test/typeahead/event_bus_spec.js b/test/typeahead/event_bus_spec.js
new file mode 100644
index 0000000..948061e
--- /dev/null
+++ b/test/typeahead/event_bus_spec.js
@@ -0,0 +1,42 @@
+describe('EventBus', function() {
+
+  beforeEach(function() {
+    var $fixture;
+
+    setFixtures(fixtures.html.input);
+
+    $fixture = $('#jasmine-fixtures');
+    this.$el = $fixture.find('.tt-input');
+
+    this.eventBus = new EventBus({ el: this.$el });
+  });
+
+  it('#trigger should trigger event', function() {
+    var spy = jasmine.createSpy();
+
+    this.$el.on('typeahead:fiz', spy);
+
+    this.eventBus.trigger('fiz');
+    expect(spy).toHaveBeenCalled();
+  });
+
+  it('#before should return false if default was not prevented', function() {
+    var spy = jasmine.createSpy();
+
+    this.$el.on('typeahead:beforefiz', spy);
+
+    expect(this.eventBus.before('fiz')).toBe(false);
+    expect(spy).toHaveBeenCalled();
+  });
+
+  it('#before should return true if default was prevented', function() {
+    var spy = jasmine.createSpy().andCallFake(prevent);
+
+    this.$el.on('typeahead:beforefiz', spy);
+
+    expect(this.eventBus.before('fiz')).toBe(true);
+    expect(spy).toHaveBeenCalled();
+
+    function prevent($e) { $e.preventDefault(); }
+  });
+});
diff --git a/test/typeahead/event_emitter_spec.js b/test/typeahead/event_emitter_spec.js
new file mode 100644
index 0000000..8142c18
--- /dev/null
+++ b/test/typeahead/event_emitter_spec.js
@@ -0,0 +1,111 @@
+describe('EventEmitter', function() {
+
+  beforeEach(function() {
+    this.spy = jasmine.createSpy();
+    this.target = _.mixin({}, EventEmitter);
+  });
+
+  it('methods should be chainable', function() {
+    expect(this.target.onSync()).toEqual(this.target);
+    expect(this.target.onAsync()).toEqual(this.target);
+    expect(this.target.off()).toEqual(this.target);
+    expect(this.target.trigger()).toEqual(this.target);
+  });
+
+  it('#on should take the context a callback should be called in', function() {
+    var context = { val: 3 }, cbContext;
+
+    this.target.onSync('xevent', setCbContext, context).trigger('xevent');
+
+    waitsFor(assertCbContext, 'callback was called in the wrong context');
+
+    function setCbContext() { cbContext = this; }
+    function assertCbContext() { return cbContext === context; }
+  });
+
+  it('#onAsync callbacks should be invoked asynchronously', function() {
+    this.target.onAsync('event', this.spy).trigger('event');
+
+    expect(this.spy.callCount).toBe(0);
+    waitsFor(assertCallCount(this.spy, 1), 'the callback was not invoked');
+  });
+
+  it('#onSync callbacks should be invoked synchronously', function() {
+    this.target.onSync('event', this.spy).trigger('event');
+
+    expect(this.spy.callCount).toBe(1);
+  });
+
+  it('#off should remove callbacks', function() {
+    this.target
+    .onSync('event1 event2', this.spy)
+    .onAsync('event1 event2', this.spy)
+    .off('event1 event2')
+    .trigger('event1 event2');
+
+    waits(100);
+    runs(assertCallCount(this.spy, 0));
+  });
+
+  it('methods should accept multiple event types', function() {
+    this.target
+    .onSync('event1 event2', this.spy)
+    .onAsync('event1 event2', this.spy)
+    .trigger('event1 event2');
+
+    expect(this.spy.callCount).toBe(2);
+    waitsFor(assertCallCount(this.spy, 4), 'the callback was not invoked');
+  });
+
+  it('the event type should be passed to the callback', function() {
+    this.target
+    .onSync('sync', this.spy)
+    .onAsync('async', this.spy)
+    .trigger('sync async');
+
+    waitsFor(assertArgs(this.spy, 0, ['sync']), 'bad args');
+    waitsFor(assertArgs(this.spy, 1, ['async']), 'bad args');
+  });
+
+  it('arbitrary args should be passed to the callback', function() {
+    this.target
+    .onSync('event', this.spy)
+    .onAsync('event', this.spy)
+    .trigger('event', 1, 2);
+
+    waitsFor(assertArgs(this.spy, 0, ['event', 1, 2]), 'bad args');
+    waitsFor(assertArgs(this.spy, 1, ['event', 1, 2]), 'bad args');
+  });
+
+  it('callback execution should be cancellable', function() {
+    var cancelSpy = jasmine.createSpy().andCallFake(cancel);
+
+    this.target
+    .onSync('one', cancelSpy)
+    .onSync('one', this.spy)
+    .onAsync('two', cancelSpy)
+    .onAsync('two', this.spy)
+    .onSync('three', cancelSpy)
+    .onAsync('three', this.spy)
+    .trigger('one two three');
+
+    waitsFor(assertCallCount(cancelSpy, 3));
+    waitsFor(assertCallCount(this.spy, 0));
+
+    function cancel() { return false; }
+  });
+
+  function assertCallCount(spy, expected) {
+    return function() { return spy.callCount === expected; };
+  }
+
+  function assertArgs(spy, call, expected) {
+    return function() {
+      var env = jasmine.getEnv(),
+          actual = spy.calls[call] ? spy.calls[call].args : undefined;
+
+      return env.equals_(actual, expected);
+    };
+  }
+
+});
diff --git a/test/typeahead/highlight_spec.js b/test/typeahead/highlight_spec.js
new file mode 100644
index 0000000..03f1d95
--- /dev/null
+++ b/test/typeahead/highlight_spec.js
@@ -0,0 +1,117 @@
+describe('highlight', function() {
+  it('should allow tagName to be specified', function() {
+    var before = 'abcde',
+        after = 'a<span>bcd</span>e',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'bcd', tagName: 'span' });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should allow className to be specified', function() {
+    var before = 'abcde',
+        after = 'a<strong class="one two">bcd</strong>e',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'bcd', className: 'one two' });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should be case insensitive by default', function() {
+    var before = 'ABCDE',
+        after = 'A<strong>BCD</strong>E',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'bcd' });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should support case sensitivity', function() {
+    var before = 'ABCDE',
+        after = 'ABCDE',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'bcd', caseSensitive: true });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should support words only matching', function() {
+    var before = 'tone one phone',
+        after = 'tone <strong>one</strong> phone',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'one', wordsOnly: true });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should support matching multiple patterns', function() {
+    var before = 'tone one phone',
+        after = '<strong>tone</strong> one <strong>phone</strong>',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: ['tone', 'phone'] });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should support regex chars in the pattern', function() {
+    var before = '*.js when?',
+        after = '<strong>*.</strong>js when<strong>?</strong>',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: ['*.', '?'] });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should work on complex html structures', function() {
+    var before = [
+          '<div>abcde',
+            '<span>abcde</span>',
+            '<div><p>abcde</p></div>',
+          '</div>'
+        ].join(''),
+        after = [
+          '<div><strong>abc</strong>de',
+            '<span><strong>abc</strong>de</span>',
+            '<div><p><strong>abc</strong>de</p></div>',
+          '</div>'
+        ].join(''),
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'abc' });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should ignore html tags and attributes', function() {
+    var before = '<span class="class"></span>',
+        after = '<span class="class"></span>',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: ['span', 'class'] });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should not match across tags', function() {
+    var before = 'a<span>b</span>c',
+        after = 'a<span>b</span>c',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'abc' });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  it('should ignore html comments', function() {
+    var before = '<!-- abc -->',
+        after = '<!-- abc -->',
+        testNode = buildTestNode(before);
+
+    highlight({ node: testNode, pattern: 'abc' });
+    expect(testNode.innerHTML).toEqual(after);
+  });
+
+  function buildTestNode(content) {
+    var node = document.createElement('div');
+    node.innerHTML = content;
+
+    return node;
+  }
+});
diff --git a/test/typeahead/input_spec.js b/test/typeahead/input_spec.js
new file mode 100644
index 0000000..149d075
--- /dev/null
+++ b/test/typeahead/input_spec.js
@@ -0,0 +1,538 @@
+describe('Input', function() {
+  var KEYS, www;
+
+   KEYS = {
+    enter: 13,
+    esc: 27,
+    tab: 9,
+    left: 37,
+    right: 39,
+    up: 38,
+    down: 40,
+    normal: 65 // "A" key
+  };
+
+  www = WWW();
+
+  beforeEach(function() {
+    var $fixture;
+
+    setFixtures(fixtures.html.input + fixtures.html.hint);
+
+    $fixture = $('#jasmine-fixtures');
+    this.$input = $fixture.find('.tt-input');
+    this.$hint = $fixture.find('.tt-hint');
+
+    this.view = new Input({ input: this.$input, hint: this.$hint }, www).bind();
+  });
+
+  it('should throw an error if no input is provided', function() {
+    expect(noInput).toThrow();
+
+    function noInput() { new Input({}, www); }
+  });
+
+  describe('when the blur DOM event is triggered', function() {
+    it('should reset the input value', function() {
+      this.view.setQuery('wine');
+      this.view.setInputValue('cheese');
+
+      this.$input.blur();
+
+      expect(this.$input.val()).toBe('wine');
+    });
+
+    it('should trigger blurred', function() {
+      var spy;
+
+      this.view.onSync('blurred', spy = jasmine.createSpy());
+      this.$input.blur();
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when the focus DOM event is triggered', function() {
+    it('should update queryWhenFocused', function() {
+      this.view.setQuery('hi');
+      this.$input.focus();
+      expect(this.view.hasQueryChangedSinceLastFocus()).toBe(false);
+      this.view.setQuery('bye');
+      expect(this.view.hasQueryChangedSinceLastFocus()).toBe(true);
+    });
+
+    it('should trigger focused', function() {
+      var spy;
+
+      this.view.onSync('focused', spy = jasmine.createSpy());
+      this.$input.focus();
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by tab', function() {
+    it('should trigger tabKeyed if no modifiers were pressed', function() {
+      var spy;
+
+      this.view.onSync('tabKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.tab);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should not trigger tabKeyed if modifiers were pressed', function() {
+      var spy;
+
+      this.view.onSync('tabKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.tab, true);
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by esc', function() {
+    it('should trigger escKeyed', function() {
+      var spy;
+
+      this.view.onSync('escKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.esc);
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by left', function() {
+    it('should trigger leftKeyed', function() {
+      var spy;
+
+      this.view.onSync('leftKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.left);
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by right', function() {
+    it('should trigger rightKeyed', function() {
+      var spy;
+
+      this.view.onSync('rightKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.right);
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by enter', function() {
+    it('should trigger enterKeyed', function() {
+      var spy;
+
+      this.view.onSync('enterKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.enter);
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by up', function() {
+    it('should trigger upKeyed', function() {
+      var spy;
+
+      this.view.onSync('upKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.up);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should prevent default if no modifers were pressed', function() {
+      var $e = simulateKeyEvent(this.$input, 'keydown', KEYS.up);
+
+      expect($e.preventDefault).toHaveBeenCalled();
+    });
+
+    it('should not prevent default if modifers were pressed', function() {
+      var $e = simulateKeyEvent(this.$input, 'keydown', KEYS.up, true);
+
+      expect($e.preventDefault).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('when the keydown DOM event is triggered by down', function() {
+    it('should trigger downKeyed', function() {
+      var spy;
+
+      this.view.onSync('downKeyed', spy = jasmine.createSpy());
+      simulateKeyEvent(this.$input, 'keydown', KEYS.down);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should prevent default if no modifers were pressed', function() {
+      var $e = simulateKeyEvent(this.$input, 'keydown', KEYS.down);
+
+      expect($e.preventDefault).toHaveBeenCalled();
+    });
+
+    it('should not prevent default if modifers were pressed', function() {
+      var $e = simulateKeyEvent(this.$input, 'keydown', KEYS.down, true);
+
+      expect($e.preventDefault).not.toHaveBeenCalled();
+    });
+  });
+
+  // NOTE: have to treat these as async because the ie polyfill acts
+  // in a async manner
+  describe('when the input DOM event is triggered', function() {
+    it('should update query', function() {
+      this.view.setQuery('wine');
+      this.view.setInputValue('cheese');
+
+      simulateInputEvent(this.$input);
+
+      waitsFor(function() { return this.view.getQuery() === 'cheese'; });
+    });
+
+    it('should trigger queryChanged if the query changed', function() {
+      var spy;
+
+      this.view.setQuery('wine');
+      this.view.setInputValue('cheese');
+      this.view.onSync('queryChanged', spy = jasmine.createSpy());
+
+      simulateInputEvent(this.$input);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should trigger whitespaceChanged if whitespace changed', function() {
+      var spy;
+
+      this.view.setQuery('wine  bar');
+      this.view.setInputValue('wine bar');
+      this.view.onSync('whitespaceChanged', spy = jasmine.createSpy());
+
+      simulateInputEvent(this.$input);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should clear hint if invalid', function() {
+      spyOn(this.view, 'clearHintIfInvalid');
+      simulateInputEvent(this.$input);
+      expect(this.view.clearHintIfInvalid).toHaveBeenCalled();
+    });
+
+    it('should check lang direction', function() {
+      var spy;
+
+      this.$input.css('direction', 'rtl');
+      this.view.onSync('langDirChanged', spy = jasmine.createSpy());
+
+      simulateInputEvent(this.$input);
+
+      expect(this.view.dir).toBe('rtl');
+      expect(this.$hint).toHaveAttr('dir', 'rtl');
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('.normalizeQuery', function() {
+    it('should strip leading whitespace', function() {
+      expect(Input.normalizeQuery('  foo')).toBe('foo');
+    });
+
+    it('should condense whitespace', function() {
+      expect(Input.normalizeQuery('foo   bar')).toBe('foo bar');
+    });
+
+    it('should play nice with non-string values', function() {
+      expect(Input.normalizeQuery(2)).toBe('2');
+      expect(Input.normalizeQuery([])).toBe('');
+      expect(Input.normalizeQuery(null)).toBe('');
+      expect(Input.normalizeQuery(undefined)).toBe('');
+      expect(Input.normalizeQuery(false)).toBe('false');
+    });
+  });
+
+  describe('#focus', function() {
+    it('should focus the input', function() {
+      this.$input.blur();
+      this.view.focus();
+
+      expect(this.$input).toBeFocused();
+    });
+  });
+
+  describe('#blur', function() {
+    it('should blur the input', function() {
+      this.$input.focus();
+      this.view.blur();
+
+      expect(this.$input).not.toBeFocused();
+    });
+  });
+
+  describe('#getQuery', function() {
+    it('should act as getter to the query property', function() {
+      this.view.setQuery('mouse');
+      expect(this.view.getQuery()).toBe('mouse');
+    });
+  });
+
+  describe('#setQuery', function() {
+    it('should act as setter to the query property', function() {
+      this.view.setQuery('mouse');
+      expect(this.view.getQuery()).toBe('mouse');
+    });
+
+    it('should update input value', function() {
+      this.view.setQuery('mouse');
+      expect(this.view.getInputValue()).toBe('mouse');
+    });
+
+    it('should trigger queryChanged if the query changed', function() {
+      var spy;
+
+      this.view.setQuery('wine');
+      this.view.onSync('queryChanged', spy = jasmine.createSpy());
+      this.view.setQuery('cheese');
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should trigger whitespaceChanged if whitespace changed', function() {
+      var spy;
+
+      this.view.setQuery('wine   bar');
+      this.view.onSync('whitespaceChanged', spy = jasmine.createSpy());
+      this.view.setQuery('wine bar');
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should clear hint if invalid', function() {
+      spyOn(this.view, 'clearHintIfInvalid');
+      simulateInputEvent(this.$input);
+      expect(this.view.clearHintIfInvalid).toHaveBeenCalled();
+    });
+  });
+
+  describe('#hasQueryChangedSinceLastFocus', function() {
+    it('should return true if the query has changed since focus', function() {
+      this.view.setQuery('hi');
+      this.$input.focus();
+      this.view.setQuery('bye');
+      expect(this.view.hasQueryChangedSinceLastFocus()).toBe(true);
+    });
+
+    it('should return false if the query has not changed since focus', function() {
+      this.view.setQuery('hi');
+      this.$input.focus();
+      expect(this.view.hasQueryChangedSinceLastFocus()).toBe(false);
+    });
+  });
+
+  describe('#getInputValue', function() {
+    it('should act as getter to the input value', function() {
+      this.$input.val('cheese');
+      expect(this.view.getInputValue()).toBe('cheese');
+    });
+  });
+
+  describe('#setInputValue', function() {
+    it('should act as setter to the input value', function() {
+      this.view.setInputValue('cheese');
+      expect(this.view.getInputValue()).toBe('cheese');
+    });
+
+    it('should clear hint if invalid', function() {
+      spyOn(this.view, 'clearHintIfInvalid');
+      this.view.setInputValue('cheese head');
+      expect(this.view.clearHintIfInvalid).toHaveBeenCalled();
+    });
+
+    it('should check lang direction', function() {
+      var spy;
+
+      this.$input.css('direction', 'rtl');
+      this.view.onSync('langDirChanged', spy = jasmine.createSpy());
+
+      simulateInputEvent(this.$input);
+
+      expect(this.view.dir).toBe('rtl');
+      expect(this.$hint).toHaveAttr('dir', 'rtl');
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('#getHint/#setHint', function() {
+    it('should act as getter/setter to value of hint', function() {
+      this.view.setHint('mountain');
+      expect(this.view.getHint()).toBe('mountain');
+    });
+  });
+
+  describe('#resetInputValue', function() {
+    it('should reset input value to last query', function() {
+      this.view.setQuery('cheese');
+      this.view.setInputValue('wine');
+
+      this.view.resetInputValue();
+      expect(this.view.getInputValue()).toBe('cheese');
+    });
+  });
+
+  describe('#clearHint', function() {
+    it('should set the hint value to the empty string', function() {
+      this.view.setHint('cheese');
+      this.view.clearHint();
+
+      expect(this.view.getHint()).toBe('');
+    });
+  });
+
+  describe('#clearHintIfInvalid', function() {
+    it('should clear hint if input value is empty string', function() {
+      this.view.setInputValue('');
+      this.view.setHint('cheese');
+      this.view.clearHintIfInvalid();
+
+      expect(this.view.getHint()).toBe('');
+    });
+
+    it('should clear hint if input value is not prefix of input', function() {
+      this.view.setInputValue('milk');
+      this.view.setHint('cheese');
+      this.view.clearHintIfInvalid();
+
+      expect(this.view.getHint()).toBe('');
+    });
+
+    it('should clear hint if overflow exists', function() {
+      spyOn(this.view, 'hasOverflow').andReturn(true);
+      this.view.setInputValue('che');
+      this.view.setHint('cheese');
+      this.view.clearHintIfInvalid();
+
+      expect(this.view.getHint()).toBe('');
+    });
+
+    it('should not clear hint if input value is prefix of input', function() {
+      this.view.setInputValue('che');
+      this.view.setHint('cheese');
+      this.view.clearHintIfInvalid();
+
+      expect(this.view.getHint()).toBe('cheese');
+    });
+  });
+
+  describe('#hasOverflow', function() {
+    it('should return true if the input has overflow text', function() {
+      var longStr = new Array(1000).join('a');
+
+      this.view.setInputValue(longStr);
+      expect(this.view.hasOverflow()).toBe(true);
+    });
+
+    it('should return false if the input has no overflow text', function() {
+      var shortStr = 'aah';
+
+      this.view.setInputValue(shortStr);
+      expect(this.view.hasOverflow()).toBe(false);
+    });
+  });
+
+  describe('#isCursorAtEnd', function() {
+    it('should return true if the text cursor is at the end', function() {
+      this.view.setInputValue('boo');
+
+      setCursorPosition(this.$input, 3);
+      expect(this.view.isCursorAtEnd()).toBe(true);
+    });
+
+    it('should return false if the text cursor is not at the end', function() {
+      this.view.setInputValue('boo');
+
+      setCursorPosition(this.$input, 1);
+      expect(this.view.isCursorAtEnd()).toBe(false);
+    });
+  });
+
+  describe('#destroy', function() {
+    it('should remove event handlers', function() {
+      var $input, $hint;
+
+      $hint = this.view.$hint;
+      $input = this.view.$input;
+
+      spyOn($hint, 'off');
+      spyOn($input, 'off');
+
+      this.view.destroy();
+
+      expect($hint.off).toHaveBeenCalledWith('.tt');
+      expect($input.off).toHaveBeenCalledWith('.tt');
+    });
+
+    it('should set references to DOM elements to dummy element', function() {
+      var $hint, $input, $overflowHelper;
+
+      $hint = this.view.$hint;
+      $input = this.view.$input;
+      $overflowHelper = this.view.$overflowHelper;
+
+      this.view.destroy();
+
+      expect(this.view.$hint).not.toBe($hint);
+      expect(this.view.$input).not.toBe($input);
+      expect(this.view.$overflowHelper).not.toBe($overflowHelper);
+    });
+  });
+
+  // helper functions
+  // ----------------
+
+  function simulateInputEvent($node) {
+    var $e, type;
+
+    type = _.isMsie() ? 'keypress' : 'input';
+    $e = $.Event(type);
+
+    $node.trigger($e);
+  }
+
+  function simulateKeyEvent($node, type, key, withModifier) {
+    var $e;
+
+    $e = $.Event(type, {
+      keyCode: key,
+      altKey: !!withModifier,
+      ctrlKey: !!withModifier,
+      metaKey: !!withModifier,
+      shiftKey: !!withModifier
+    });
+
+    spyOn($e, 'preventDefault');
+    $node.trigger($e);
+
+    return $e;
+  }
+
+  function setCursorPosition($input, pos) {
+    var input = $input[0], range;
+
+    if (input.setSelectionRange) {
+      input.focus();
+      input.setSelectionRange(pos, pos);
+    }
+
+    else if (input.createTextRange) {
+      range = input.createTextRange();
+      range.collapse(true);
+      range.moveEnd('character', pos);
+      range.moveStart('character', pos);
+      range.select();
+    }
+  }
+});
diff --git a/test/typeahead/plugin_spec.js b/test/typeahead/plugin_spec.js
new file mode 100644
index 0000000..7399be8
--- /dev/null
+++ b/test/typeahead/plugin_spec.js
@@ -0,0 +1,197 @@
+describe('$plugin', function() {
+
+  beforeEach(function() {
+    var $fixture;
+
+    setFixtures('<input class="test-input" type="text" autocomplete="on">');
+
+    $fixture = $('#jasmine-fixtures');
+    this.$input = $fixture.find('.test-input');
+
+    this.$input.typeahead(null, {
+      displayKey: 'v',
+      source: function(q, sync) {
+        sync([{ v: '1' }, { v: '2' }, { v: '3' }]);
+      }
+    });
+  });
+
+  it('#enable should enable the typaahead', function() {
+    this.$input.typeahead('disable');
+    expect(this.$input.typeahead('isEnabled')).toBe(false);
+
+    this.$input.typeahead('enable');
+    expect(this.$input.typeahead('isEnabled')).toBe(true);
+  });
+
+  it('#disable should disable the typaahead', function() {
+    this.$input.typeahead('enable');
+    expect(this.$input.typeahead('isEnabled')).toBe(true);
+
+    this.$input.typeahead('disable');
+    expect(this.$input.typeahead('isEnabled')).toBe(false);
+  });
+
+  it('#activate should activate the typaahead', function() {
+    this.$input.typeahead('deactivate');
+    expect(this.$input.typeahead('isActive')).toBe(false);
+
+    this.$input.typeahead('activate');
+    expect(this.$input.typeahead('isActive')).toBe(true);
+  });
+
+  it('#activate should fail to activate the typaahead if disabled', function() {
+    this.$input.typeahead('deactivate');
+    expect(this.$input.typeahead('isActive')).toBe(false);
+    this.$input.typeahead('disable');
+
+    this.$input.typeahead('activate');
+    expect(this.$input.typeahead('isActive')).toBe(false);
+  });
+
+  it('#deactivate should deactivate the typaahead', function() {
+    this.$input.typeahead('activate');
+    expect(this.$input.typeahead('isActive')).toBe(true);
+
+    this.$input.typeahead('deactivate');
+    expect(this.$input.typeahead('isActive')).toBe(false);
+  });
+
+  it('#open should open the menu', function() {
+    this.$input.typeahead('close');
+    expect(this.$input.typeahead('isOpen')).toBe(false);
+
+    this.$input.typeahead('open');
+    expect(this.$input.typeahead('isOpen')).toBe(true);
+  });
+
+  it('#close should close the menu', function() {
+    this.$input.typeahead('open');
+    expect(this.$input.typeahead('isOpen')).toBe(true);
+
+    this.$input.typeahead('close');
+    expect(this.$input.typeahead('isOpen')).toBe(false);
+  });
+
+  it('#select should select selectable', function() {
+    var $el;
+
+    // activate and set val to render some selectables
+    this.$input.typeahead('activate');
+    this.$input.typeahead('val', 'o');
+    $el = $('.tt-selectable').first();
+
+    expect(this.$input.typeahead('select', $el)).toBe(true);
+    expect(this.$input.typeahead('val')).toBe('1');
+  });
+
+  it('#select should return false if not valid selectable', function() {
+    var body;
+
+    // activate and set val to render some selectables
+    this.$input.typeahead('activate');
+    this.$input.typeahead('val', 'o');
+    body = document.body;
+
+    expect(this.$input.typeahead('select', body)).toBe(false);
+  });
+
+  it('#autocomplete should autocomplete to selectable', function() {
+    var $el;
+
+    // activate and set val to render some selectables
+    this.$input.typeahead('activate');
+    this.$input.typeahead('val', 'o');
+    $el = $('.tt-selectable').first();
+
+    expect(this.$input.typeahead('autocomplete', $el)).toBe(true);
+    expect(this.$input.typeahead('val')).toBe('1');
+  });
+
+  it('#autocomplete should return false if not valid selectable', function() {
+    var body;
+
+    // activate and set val to render some selectables
+    this.$input.typeahead('activate');
+    this.$input.typeahead('val', 'o');
+    body = document.body;
+
+    expect(this.$input.typeahead('autocomplete', body)).toBe(false);
+  });
+
+  it('#moveCursor should move cursor', function() {
+    var $el;
+
+    // activate and set val to render some selectables
+    this.$input.typeahead('activate');
+    this.$input.typeahead('val', 'o');
+    $el = $('.tt-selectable').first();
+
+    expect($el).not.toHaveClass('tt-cursor');
+    expect(this.$input.typeahead('moveCursor', 1)).toBe(true);
+    expect($el).toHaveClass('tt-cursor');
+  });
+
+  it('#select should return false if not valid selectable', function() {
+    var body;
+
+    // activate and set val to render some selectables
+    this.$input.typeahead('activate');
+    this.$input.typeahead('val', 'o');
+    body = document.body;
+
+    expect(this.$input.typeahead('select', body)).toBe(false);
+  });
+
+  it('#val() should typeahead value of element', function() {
+    var $els;
+
+    this.$input.typeahead('val', 'foo');
+    $els = this.$input.add('<div>');
+
+    expect($els.typeahead('val')).toBe('foo');
+  });
+
+  it('#val(q) should set query', function() {
+    this.$input.typeahead('val', 'foo');
+    expect(this.$input.typeahead('val')).toBe('foo');
+  });
+
+  it('#destroy should revert modified attributes', function() {
+    expect(this.$input).toHaveAttr('autocomplete', 'off');
+    expect(this.$input).toHaveAttr('dir');
+    expect(this.$input).toHaveAttr('spellcheck');
+    expect(this.$input).toHaveAttr('style');
+
+    this.$input.typeahead('destroy');
+
+    expect(this.$input).toHaveAttr('autocomplete', 'on');
+    expect(this.$input).not.toHaveAttr('dir');
+    expect(this.$input).not.toHaveAttr('spellcheck');
+    expect(this.$input).not.toHaveAttr('style');
+  });
+
+  it('#destroy should remove data', function() {
+    expect(this.$input.data('tt-www')).toBeTruthy();
+    expect(this.$input.data('tt-attrs')).toBeTruthy();
+    expect(this.$input.data('tt-typeahead')).toBeTruthy();
+
+    this.$input.typeahead('destroy');
+
+    expect(this.$input.data('tt-www')).toBeFalsy();
+    expect(this.$input.data('tt-attrs')).toBeFalsy();
+    expect(this.$input.data('tt-typeahead')).toBeFalsy();
+  });
+
+  it('#destroy should remove add classes', function() {
+    expect(this.$input).toHaveClass('tt-input');
+    this.$input.typeahead('destroy');
+    expect(this.$input).not.toHaveClass('tt-input');
+  });
+
+  it('#destroy should revert DOM changes', function() {
+    expect($('.twitter-typeahead')).toExist();
+    this.$input.typeahead('destroy');
+    expect($('.twitter-typeahead')).not.toExist();
+  });
+});
diff --git a/test/typeahead/results_spec.js b/test/typeahead/results_spec.js
new file mode 100644
index 0000000..f379fc3
--- /dev/null
+++ b/test/typeahead/results_spec.js
@@ -0,0 +1,332 @@
+describe('Menu', function() {
+  var www = WWW();
+
+  beforeEach(function() {
+    var $fixture;
+
+    jasmine.Dataset.useMock();
+
+    setFixtures('<div id="menu-fixture"></div>');
+
+    $fixture = $('#jasmine-fixtures');
+    this.$node = $fixture.find('#menu-fixture');
+    this.$node.html(fixtures.html.dataset);
+
+    this.view = new Menu({ node: this.$node, datasets: [{}] }, www).bind();
+    this.dataset = this.view.datasets[0];
+  });
+
+  it('should throw an error if node is missing', function() {
+    expect(noNode).toThrow();
+    function noNode() { new Menu({ datasets: [{}] }, www); }
+  });
+
+  describe('when click event is triggered on a selectable', function() {
+    it('should trigger selectableClicked', function() {
+      var spy;
+
+      this.view.onSync('selectableClicked', spy = jasmine.createSpy());
+
+      this.$node.find(www.selectors.selectable).first().click();
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when rendered is triggered on a dataset', function() {
+    it('should add empty class to node if empty', function() {
+      this.dataset.isEmpty.andReturn(true);
+
+      this.$node.removeClass(www.classes.empty);
+      this.dataset.trigger('rendered');
+
+      expect(this.$node).toHaveClass(www.classes.empty);
+    });
+
+    it('should remove empty class from node if not empty', function() {
+      this.dataset.isEmpty.andReturn(false);
+
+      this.$node.addClass(www.classes.empty);
+      this.dataset.trigger('rendered');
+
+      expect(this.$node).not.toHaveClass(www.classes.empty);
+    });
+
+    it('should trigger datasetRendered', function() {
+      var spy;
+
+      this.view.onSync('datasetRendered', spy = jasmine.createSpy());
+      this.dataset.trigger('rendered');
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when cleared is triggered on a dataset', function() {
+    it('should add empty class to node if empty', function() {
+      this.dataset.isEmpty.andReturn(true);
+
+      this.$node.removeClass(www.classes.empty);
+      this.dataset.trigger('cleared');
+
+      expect(this.$node).toHaveClass(www.classes.empty);
+    });
+
+    it('should remove empty class from node if not empty', function() {
+      this.dataset.isEmpty.andReturn(false);
+
+      this.$node.addClass(www.classes.empty);
+      this.dataset.trigger('cleared');
+
+      expect(this.$node).not.toHaveClass(www.classes.empty);
+    });
+
+    it('should trigger datasetCleared', function() {
+      var spy;
+
+      this.view.onSync('datasetCleared', spy = jasmine.createSpy());
+      this.dataset.trigger('cleared');
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when asyncRequested is triggered on a dataset', function() {
+    it('should propagate event', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.onSync('asyncRequested', spy);
+      this.dataset.trigger('asyncRequested');
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when asyncCanceled is triggered on a dataset', function() {
+    it('should propagate event', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.onSync('asyncCanceled', spy);
+      this.dataset.trigger('asyncCanceled');
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('when asyncReceieved is triggered on a dataset', function() {
+    it('should propagate event', function() {
+      var spy = jasmine.createSpy();
+
+      this.dataset.onSync('asyncReceived', spy);
+      this.dataset.trigger('asyncReceived');
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('#open', function() {
+    it('should add open class to node', function() {
+      this.$node.removeClass(www.classes.open);
+      this.view.open();
+
+      expect(this.$node).toHaveClass(www.classes.open);
+    });
+  });
+
+  describe('#close', function() {
+    it('should remove open class to node', function() {
+      this.$node.addClass(www.classes.open);
+      this.view.close();
+
+      expect(this.$node).not.toHaveClass(www.classes.open);
+    });
+
+    it('should remove cursor', function() {
+      var $selectable;
+
+      $selectable = this.view._getSelectables().first();
+      this.view.setCursor($selectable);
+
+      expect($selectable).toHaveClass(www.classes.cursor);
+
+      this.view.close();
+
+      expect($selectable).not.toHaveClass(www.classes.cursor);
+    });
+  });
+
+  describe('#setLanguageDirection', function() {
+    it('should update css for given language direction', function() {
+      this.view.setLanguageDirection('rtl');
+      expect(this.$node).toHaveAttr('dir', 'rtl');
+
+      this.view.setLanguageDirection('ltr');
+      expect(this.$node).toHaveAttr('dir', 'ltr');
+    });
+  });
+
+  describe('#selectableRelativeToCursor', function() {
+    it('should return selectable delta spots away from cursor', function() {
+      var $first, $second;
+
+      $first = this.view._getSelectables().eq(0);
+      $second = this.view._getSelectables().eq(1);
+
+      this.view.setCursor($first);
+      expect(this.view.selectableRelativeToCursor(+1)).toBe($second);
+    });
+
+    it('should support negative deltas', function() {
+      var $first, $second;
+
+      $first = this.view._getSelectables().eq(0);
+      $second = this.view._getSelectables().eq(1);
+
+      this.view.setCursor($second);
+      expect(this.view.selectableRelativeToCursor(-1)).toBe($first);
+    });
+
+    it('should wrap', function() {
+      var $expected, $actual;
+
+      $expected = this.view._getSelectables().eq(-1);
+      $actual = this.view.selectableRelativeToCursor(-1);
+
+      expect($actual).toBe($expected);
+    });
+
+    it('should return null if delta lands on input', function() {
+      var $first;
+
+      $first = this.view._getSelectables().eq(0);
+
+      this.view.setCursor($first);
+      expect(this.view.selectableRelativeToCursor(-1)).toBeNull();
+    });
+  });
+
+  describe('#setCursor', function() {
+    it('should remove cursor if null is passed in', function() {
+      var $selectable;
+
+      $selectable = this.view._getSelectables().eq(0);
+      this.view.setCursor($selectable);
+      expect(this.view.getActiveSelectable()).toBe($selectable);
+
+      this.view.setCursor(null);
+      expect(this.view.getActiveSelectable()).toBeNull();
+    });
+
+    it('should move cursor to passed in selectable', function() {
+      var $selectable;
+
+      $selectable = this.view._getSelectables().eq(0);
+
+      expect(this.view.getActiveSelectable()).toBeNull();
+      this.view.setCursor($selectable);
+      expect(this.view.getActiveSelectable()).toBe($selectable);
+    });
+  });
+
+  describe('#getSelectableData', function() {
+    it('should extract the data from the selectable element', function() {
+      var $selectable, datum;
+
+      $selectable = $('<div>').data({
+        'tt-selectable-display': 'one',
+        'tt-selectable-object': 'two'
+      });
+
+      data = this.view.getSelectableData($selectable);
+
+      expect(data).toEqual({ val: 'one', obj: 'two' });
+    });
+
+    it('should return null if no element is given', function() {
+      expect(this.view.getSelectableData($('notreal'))).toBeNull();
+    });
+  });
+
+  describe('#getActiveSelectable', function() {
+    it('should return the selectable the cursor is on', function() {
+      var $first;
+
+      $first = this.view._getSelectables().eq(0);
+      this.view.setCursor($first);
+
+      expect(this.view.getActiveSelectable()).toBe($first);
+    });
+
+    it('should return null if the cursor is off', function() {
+      expect(this.view.getActiveSelectable()).toBeNull();
+    });
+  });
+
+  describe('#getTopSelectable', function() {
+    it('should return the selectable at the top of the menu', function() {
+      var $first;
+
+      $first = this.view._getSelectables().eq(0);
+      expect(this.view.getTopSelectable()).toBe($first);
+    });
+  });
+
+  describe('#update', function() {
+    it('should invoke update on each dataset if valid update', function() {
+      this.view.update('fiz');
+      expect(this.dataset.update).toHaveBeenCalled();
+    });
+
+    it('should return true when valid update', function() {
+      expect(this.view.update('fiz')).toBe(true);
+    });
+
+    it('should return false when invalid update', function() {
+      this.view.update('fiz');
+      expect(this.view.update('fiz')).toBe(false);
+    });
+  });
+
+  describe('#empty', function() {
+    it('should set query to null', function() {
+      this.view.query = 'fiz';
+      this.view.empty();
+
+      expect(this.view.query).toBeNull();
+    });
+
+    it('should add empty class to node', function() {
+      this.$node.removeClass(www.classes.empty);
+      this.view.empty();
+
+      expect(this.$node).toHaveClass(www.classes.empty);
+    });
+
+    it('should invoke clear on each dataset', function() {
+      this.view.empty();
+      expect(this.dataset.clear).toHaveBeenCalled();
+    });
+  });
+
+  describe('#destroy', function() {
+    it('should remove event handlers', function() {
+      var $node = this.view.$node;
+
+      spyOn($node, 'off');
+      this.view.destroy();
+      expect($node.off).toHaveBeenCalledWith('.tt');
+    });
+
+    it('should destroy its datasets', function() {
+      this.view.destroy();
+      expect(this.dataset.destroy).toHaveBeenCalled();
+    });
+
+    it('should set node element to dummy element', function() {
+      var $node = this.view.$node;
+
+      this.view.destroy();
+      expect(this.view.$node).not.toBe($node);
+    });
+  });
+});
diff --git a/test/typeahead/typeahead_spec.js b/test/typeahead/typeahead_spec.js
new file mode 100644
index 0000000..589cf51
--- /dev/null
+++ b/test/typeahead/typeahead_spec.js
@@ -0,0 +1,1412 @@
+describe('Typeahead', function() {
+  var www, testData;
+
+  www = WWW();
+
+  beforeEach(function() {
+    var $fixture, $input;
+
+    jasmine.Input.useMock();
+    jasmine.Dataset.useMock();
+    jasmine.Menu.useMock();
+
+    setFixtures('<input type="text">');
+
+    $fixture = $('#jasmine-fixtures');
+    this.$input = $fixture.find('input');
+
+    testData = { val: 'foo bar', obj: 'fiz' };
+
+    this.view = new Typeahead({
+      input: new Input(),
+      menu: new Menu(),
+      eventBus: new EventBus({ el: this.$input })
+    }, www);
+
+    this.input = this.view.input;
+    this.menu = this.view.menu;
+  });
+
+  describe('on selectableClicked', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'selectableClicked';
+      payload = $('<foo>');
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onSelectableClicked');
+        this.menu.trigger(eventName, payload);
+        expect(this.view._onSelectableClicked).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should select the selectable', function() {
+        spyOn(this.view, 'select');
+        this.menu.trigger(eventName, payload);
+        expect(this.view.select).toHaveBeenCalledWith(payload);
+      });
+    });
+  });
+
+  describe('on asyncRequested', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'asyncRequested';
+    });
+
+    it('should trigger typeahead:asyncrequest', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:asyncrequest', spy);
+        this.menu.trigger(eventName);
+        expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('on asyncCanceled', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'asyncCanceled';
+    });
+
+    it('should trigger typeahead:asynccancel', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:asynccancel', spy);
+        this.menu.trigger(eventName);
+        expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('on asyncReceived', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'asyncReceived';
+    });
+
+    it('should trigger typeahead:asyncreceive', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:asyncreceive', spy);
+        this.menu.trigger(eventName);
+        expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('on datasetRendered', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'datasetRendered';
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onDatasetRendered');
+        this.menu.trigger(eventName);
+        expect(this.view._onDatasetRendered).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should update the hint', function() {
+        this.input.hasOverflow.andReturn(false);
+        this.menu.getTopSelectable.andReturn($('<fiz>'));
+        this.menu.getSelectableData.andReturn(testData);
+        this.input.getInputValue.andReturn(testData.val.slice(0, 2));
+
+        this.menu.trigger(eventName);
+
+        expect(this.input.setHint).toHaveBeenCalled();
+      });
+
+      it('should trigger typeahead:render', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:render', spy);
+        this.menu.trigger(eventName);
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('on datasetCleared', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'datasetCleared';
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onDatasetCleared');
+        this.menu.trigger(eventName);
+        expect(this.view._onDatasetCleared).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should update the hint', function() {
+        this.input.hasOverflow.andReturn(false);
+        this.menu.getTopSelectable.andReturn($('<fiz>'));
+        this.menu.getSelectableData.andReturn(testData);
+        this.input.getInputValue.andReturn(testData.val.slice(0, 2));
+
+        this.menu.trigger(eventName);
+
+        expect(this.input.setHint).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('on focused', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'focused';
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should activate typeahead', function() {
+        this.input.trigger(eventName);
+        expect(this.view.isActive()).toBe(true);
+      });
+
+      it('should open menu', function() {
+        this.input.trigger(eventName);
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should open menu', function() {
+        this.input.trigger(eventName);
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+
+      it('should update menu for query if minLength met', function() {
+        this.input.getQuery.andReturn('bar');
+        this.input.trigger(eventName);
+        expect(this.menu.update).toHaveBeenCalledWith('bar');
+      });
+
+      it('should not update menu for query if minLength not met', function() {
+        this.view.minLength = 1;
+        this.input.getQuery.andReturn('');
+        this.input.trigger(eventName);
+        expect(this.menu.update).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('on blurred', function() {
+    var eventName;
+
+    beforeEach(function() {
+      eventName = 'blurred';
+    });
+
+    it('should trigger typeahead:change if query changed since focus', function() {
+      var spy = jasmine.createSpy();
+
+      this.input.hasQueryChangedSinceLastFocus.andReturn(true);
+      this.$input.on('typeahead:change', spy);
+
+      this.input.trigger(eventName);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should not trigger typeahead:change if query has not changed since focus', function() {
+      var spy = jasmine.createSpy();
+
+      this.input.hasQueryChangedSinceLastFocus.andReturn(false);
+      this.$input.on('typeahead:change', spy);
+
+      this.input.trigger(eventName);
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should deactivate typeahead', function() {
+        this.input.trigger(eventName);
+        expect(this.view.isActive()).toBe(false);
+      });
+    });
+  });
+
+  describe('on enterKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'enterKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onEnterKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onEnterKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is closed', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(false);
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onEnterKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onEnterKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is open', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(true);
+      });
+
+      it('should select selectable if there is an active one', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select');
+        this.menu.getActiveSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.select).toHaveBeenCalledWith($el);
+      });
+
+      it('should prevent default if active selectale ', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select').andReturn(true);
+        this.menu.getActiveSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).toHaveBeenCalled();
+      });
+
+      it('should not select selectable if there is no active one', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select');
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.select).not.toHaveBeenCalledWith($el);
+      });
+
+      it('should not prevent default if no active selectale', function() {
+        var $el;
+
+        spyOn(this.view, 'select').andReturn(true);
+        $el = $('<bah>');
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).not.toHaveBeenCalled();
+      });
+
+      it('should not prevent default if selection of active selectable fails', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select').andReturn(false);
+        this.menu.getActiveSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('on tabKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'tabKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onTabKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onTabKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is closed', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(false);
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onTabKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onTabKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is open', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(true);
+      });
+
+      it('should select selectable if there is an active one', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select');
+        this.menu.getActiveSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.select).toHaveBeenCalledWith($el);
+      });
+
+      it('should prevent default if active selectale', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select').andReturn(true);
+        this.menu.getActiveSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).toHaveBeenCalled();
+      });
+
+      it('should not select selectable if there is no active one', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select');
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.select).not.toHaveBeenCalledWith($el);
+      });
+
+      it('should not prevent default if no active selectale', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select');
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).not.toHaveBeenCalled();
+      });
+
+      it('should not prevent default if selection of active selectable fails', function() {
+        var $el;
+
+        $el = $('<bah>');
+        spyOn(this.view, 'select').andReturn(false);
+        this.menu.getActiveSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).not.toHaveBeenCalled();
+      });
+
+      it('should autocomplete to top suggestion', function() {
+        var $el;
+
+        $el = $('<foo>');
+        spyOn(this.view, 'autocomplete');
+        this.menu.getTopSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.autocomplete).toHaveBeenCalledWith($el);
+      });
+
+      it('should prevent default behavior of DOM event if autocompletion succeeds', function() {
+        var $el;
+
+        $el = $('<foo>');
+        spyOn(this.view, 'autocomplete').andReturn(true);
+        this.menu.getTopSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).toHaveBeenCalled();
+      });
+
+      it('should not prevent default behavior of DOM event if autocompletion fails', function() {
+        var $el;
+
+        $el = $('<foo>');
+        spyOn(this.view, 'autocomplete').andReturn(false);
+        this.menu.getTopSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(payload.preventDefault).not.toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('on escKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'escKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onEscKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onEscKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should close', function() {
+        spyOn(this.view, 'close');
+        this.input.trigger(eventName, payload);
+        expect(this.view.close).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('on upKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'upKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onUpKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onUpKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+        spyOn(this.view, 'moveCursor');
+      });
+
+      it('should open menu', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+
+      it('should move cursor -1', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.view.moveCursor).toHaveBeenCalledWith(-1);
+      });
+    });
+  });
+
+  describe('on downKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'downKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onDownKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onDownKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+        spyOn(this.view, 'moveCursor');
+      });
+
+      it('should open menu', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+
+      it('should move cursor +1', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.view.moveCursor).toHaveBeenCalledWith(1);
+      });
+    });
+  });
+
+  describe('on leftKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'leftKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onLeftKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onLeftKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is closed', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(false);
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onLeftKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onLeftKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is open', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(true);
+      });
+
+      it('should autocomplete if language is rtl and text cursor is at end', function() {
+        var $el = $('<foo>');
+
+        spyOn(this.view, 'autocomplete');
+        this.view.dir = 'rtl';
+        this.input.isCursorAtEnd.andReturn(true);
+        this.menu.getTopSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.autocomplete).toHaveBeenCalledWith($el);
+      });
+    });
+  });
+
+  describe('on rightKeyed', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'rightKeyed';
+      payload = jasmine.createSpyObj('event', ['preventDefault']);
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onRightKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onRightKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is closed', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(false);
+      });
+
+      it('should do nothing', function() {
+        spyOn(this.view, '_onRightKeyed');
+        this.input.trigger(eventName, payload);
+        expect(this.view._onRightKeyed).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active and menu is open', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.menu.isOpen.andReturn(true);
+      });
+
+      it('should autocomplete if language is rtl and text cursor is at end', function() {
+        var $el = $('<foo>');
+
+        spyOn(this.view, 'autocomplete');
+        this.view.dir = 'ltr';
+        this.input.isCursorAtEnd.andReturn(true);
+        this.menu.getTopSelectable.andReturn($el);
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.view.autocomplete).toHaveBeenCalledWith($el);
+      });
+    });
+  });
+
+  describe('on queryChanged', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'queryChanged';
+      payload = '';
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should not open menu', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.menu.open).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+        this.view.open();
+      });
+
+      it('should open menu', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+
+      it('should empty menu if minLength is not satisfied', function() {
+        this.view.minLength = 100;
+        this.input.trigger(eventName, payload);
+
+        expect(this.menu.empty).toHaveBeenCalled();
+      });
+
+      it('should update menu if minLength is satisfied', function() {
+        this.input.trigger(eventName, 'fiz');
+        expect(this.menu.update).toHaveBeenCalledWith('fiz');
+      });
+    });
+  });
+
+  describe('on whitespaceChanged', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'whitespaceChanged';
+      payload = '';
+    });
+
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should not open menu', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.menu.open).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should open menu', function() {
+        this.input.trigger(eventName, payload);
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+
+      it('should update the hint', function() {
+        this.input.hasFocus.andReturn(true);
+        this.input.hasOverflow.andReturn(false);
+        this.menu.getTopSelectable.andReturn($('<fiz>'));
+        this.menu.getSelectableData.andReturn(testData);
+
+        this.input.getInputValue.andReturn(testData.val.slice(0, 2));
+
+        this.input.trigger(eventName, payload);
+
+        expect(this.input.setHint).toHaveBeenCalledWith(testData.val);
+      });
+    });
+  });
+
+  describe('on langDirChanged', function() {
+    var eventName, payload;
+
+    beforeEach(function() {
+      eventName = 'langDirChanged';
+      payload = 'rtl';
+    });
+
+    it('should set direction of menu if direction changed', function() {
+      this.view.dir = 'ltr';
+
+      this.input.trigger(eventName, payload);
+
+      expect(this.view.dir).toBe(payload);
+      expect(this.menu.setLanguageDirection).toHaveBeenCalled();
+    });
+
+    it('should do nothing if direction did not changed', function() {
+      this.view.dir = payload;
+
+      this.input.trigger(eventName, payload);
+
+      expect(this.view.dir).toBe(payload);
+      expect(this.menu.setLanguageDirection).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('#isActive', function() {
+    it('should return true if active', function() {
+      this.view.activate();
+      expect(this.view.isActive()).toBe(true);
+    });
+
+    it('should return false if active', function() {
+      this.view.deactivate();
+      expect(this.view.isActive()).toBe(false);
+    });
+  });
+
+  describe('#isEnabled', function() {
+    it('should returned enabled status', function() {
+      this.view.enable();
+      expect(this.view.isEnabled()).toBe(true);
+      this.view.disable();
+      expect(this.view.isEnabled()).toBe(false);
+    });
+  });
+
+  describe('#enable', function() {
+    it('should set enabled to true', function() {
+      this.view.enable();
+      expect(this.view.isEnabled()).toBe(true);
+    });
+  });
+
+  describe('#disable', function() {
+    it('should set enabled to false', function() {
+      this.view.disable();
+      expect(this.view.isEnabled()).toBe(false);
+    });
+  });
+
+  describe('#activate', function() {
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should do nothing', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeactive', spy);
+        this.view.activate();
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when idle and disabled', function() {
+      beforeEach(function() {
+        this.view.disable();
+        this.view.activate();
+      });
+
+      it('should do nothing', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeactive', spy);
+        this.view.activate();
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when idle and enabled', function() {
+      beforeEach(function() {
+        this.view.enable();
+        this.view.deactivate();
+      });
+
+      it('should trigger typeahead:beforeactive', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeactive', spy);
+        this.view.activate();
+        expect(spy).toHaveBeenCalled();
+      });
+
+      it('should support cancellation', function() {
+        var spy1, spy2;
+
+        spy1 = jasmine.createSpy().andCallFake(prevent);
+        spy2 = jasmine.createSpy();
+        this.$input.on('typeahead:beforeactive', spy1);
+        this.$input.on('typeahead:active', spy2);
+
+        this.view.activate();
+
+        expect(spy1).toHaveBeenCalled();
+        expect(spy2).not.toHaveBeenCalled();
+      });
+
+      it('should change state to active', function() {
+        expect(this.view.isActive()).toBe(false);
+        this.view.activate();
+        expect(this.view.isActive()).toBe(true);
+      });
+
+      it('should trigger typeahead:active if not canceled', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:active', spy);
+        this.view.activate();
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('#deactivate', function() {
+    describe('when idle', function() {
+      beforeEach(function() {
+        this.view.deactivate();
+      });
+
+      it('should do nothing', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeidle', spy);
+        this.view.deactivate();
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when active', function() {
+      beforeEach(function() {
+        this.view.activate();
+      });
+
+      it('should trigger typeahead:beforeidle', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeidle', spy);
+        this.view.deactivate();
+        expect(spy).toHaveBeenCalled();
+      });
+
+      it('should support cancellation', function() {
+        var spy1, spy2;
+
+        spy1 = jasmine.createSpy().andCallFake(prevent);
+        spy2 = jasmine.createSpy();
+        this.$input.on('typeahead:beforeidle', spy1);
+        this.$input.on('typeahead:idle', spy2);
+
+        this.view.deactivate();
+
+        expect(spy1).toHaveBeenCalled();
+        expect(spy2).not.toHaveBeenCalled();
+      });
+
+      it('should close', function() {
+        spyOn(this.view, 'close');
+        this.view.deactivate();
+        expect(this.view.close).toHaveBeenCalled();
+      });
+
+      it('should change state to idle', function() {
+        expect(this.view.isActive()).toBe(true);
+        this.view.deactivate();
+        expect(this.view.isActive()).toBe(false);
+      });
+
+      it('should trigger typeahead:idle if not canceled', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:idle', spy);
+        this.view.deactivate();
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('#isOpen', function() {
+    it('should return true if open', function() {
+      this.menu.isOpen.andReturn(true);
+      expect(this.view.isOpen()).toBe(true);
+    });
+
+    it('should return false if closed', function() {
+      this.menu.isOpen.andReturn(false);
+      expect(this.view.isOpen()).toBe(false);
+    });
+  });
+
+  describe('#open', function() {
+    describe('when open', function() {
+      beforeEach(function() {
+        spyOn(this.view, 'isOpen').andReturn(true);
+      });
+
+      it('should do nothing', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeopen', spy);
+        this.view.open();
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when closed', function() {
+      beforeEach(function() {
+        spyOn(this.view, 'isOpen').andReturn(false);
+      });
+
+      it('should trigger typeahead:beforeopen', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeopen', spy);
+        this.view.open();
+        expect(spy).toHaveBeenCalled();
+      });
+
+      it('should support cancellation', function() {
+        var spy1, spy2;
+
+        spy1 = jasmine.createSpy().andCallFake(prevent);
+        spy2 = jasmine.createSpy();
+        this.$input.on('typeahead:beforeopen', spy1);
+        this.$input.on('typeahead:open', spy2);
+
+        this.view.open();
+
+        expect(spy1).toHaveBeenCalled();
+        expect(spy2).not.toHaveBeenCalled();
+      });
+
+      it('should open menu', function() {
+        this.view.open();
+        expect(this.menu.open).toHaveBeenCalled();
+      });
+
+      it('should update hint if active', function() {
+        spyOn(this.view, 'isActive').andReturn(true);
+
+        this.input.hasOverflow.andReturn(false);
+        this.menu.getTopSelectable.andReturn($('<fiz>'));
+        this.menu.getSelectableData.andReturn(testData);
+        this.input.getInputValue.andReturn(testData.val.slice(0, 2));
+
+        this.view.open();
+
+        expect(this.input.setHint).toHaveBeenCalled();
+      });
+
+      it('should trigger typeahead:open if not canceled', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:open', spy);
+        this.view.open();
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('#close', function() {
+    describe('when closed', function() {
+      beforeEach(function() {
+        spyOn(this.view, 'isOpen').andReturn(false);
+      });
+
+      it('should do nothing', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeclose', spy);
+        this.view.open();
+        expect(spy).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when open', function() {
+      beforeEach(function() {
+        spyOn(this.view, 'isOpen').andReturn(true);
+      });
+
+      it('should trigger typeahead:beforeclose', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:beforeclose', spy);
+        this.view.close();
+        expect(spy).toHaveBeenCalled();
+      });
+
+      it('should support cancellation', function() {
+        var spy1, spy2;
+
+        spy1 = jasmine.createSpy().andCallFake(prevent);
+        spy2 = jasmine.createSpy();
+        this.$input.on('typeahead:beforeclose', spy1);
+        this.$input.on('typeahead:close', spy2);
+
+        this.view.close();
+
+        expect(spy1).toHaveBeenCalled();
+        expect(spy2).not.toHaveBeenCalled();
+      });
+
+      it('should close menu', function() {
+        this.view.close();
+        expect(this.menu.close).toHaveBeenCalled();
+      });
+
+      it('should clear hint', function() {
+        this.view.close();
+        expect(this.input.clearHint).toHaveBeenCalled();
+      });
+
+      it('should trigger typeahead:close if not canceled', function() {
+        var spy = jasmine.createSpy();
+
+        this.$input.on('typeahead:close', spy);
+        this.view.close();
+        expect(spy).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('#getVal', function() {
+    it('should return the current query', function() {
+      this.input.getQuery.andReturn('woah');
+      expect(this.view.getVal()).toBe('woah');
+    });
+  });
+
+  describe('#setVal', function() {
+    it('should update query', function() {
+      this.input.hasFocus.andReturn(true);
+      this.view.setVal('woah');
+      expect(this.input.setQuery).toHaveBeenCalledWith('woah');
+    });
+  });
+
+
+
+
+
+  describe('#select', function() {
+    it('should do nothing if element is not a selectable', function() {
+      var spy;
+
+      this.menu.getSelectableData.andReturn(null);
+      this.$input.on('typeahead:beforeselect', spy = jasmine.createSpy());
+
+      this.view.select($('<bah>'));
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+
+    it('should trigger typeahead:beforeselect', function() {
+      var spy;
+
+      this.menu.getSelectableData.andReturn(testData);
+      this.$input.on('typeahead:beforeselect', spy = jasmine.createSpy());
+
+      this.view.select($('<bah>'));
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should support cancellation', function() {
+      var spy1, spy2;
+
+      spy1 = jasmine.createSpy().andCallFake(prevent);
+      spy2 = jasmine.createSpy();
+
+      this.menu.getSelectableData.andReturn(testData);
+      this.$input.on('typeahead:beforeselect', spy1).on('typeahead:select', spy2);
+
+      this.view.select($('<bah>'));
+
+      expect(spy1).toHaveBeenCalled();
+      expect(spy2).not.toHaveBeenCalled();
+    });
+
+    it('should update query', function() {
+      this.menu.getSelectableData.andReturn(testData);
+      this.view.select($('<bah>'));
+      expect(this.input.setQuery).toHaveBeenCalledWith(testData.val, true);
+    });
+
+    it('should trigger typeahead:select', function() {
+      var spy;
+
+      this.menu.getSelectableData.andReturn(testData);
+      this.$input.on('typeahead:select', spy = jasmine.createSpy());
+
+      this.view.select($('<bah>'));
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should close', function() {
+      spyOn(this.view, 'close');
+      this.menu.getSelectableData.andReturn(testData);
+
+      this.view.select($('<bah>'));
+
+      expect(this.view.close).toHaveBeenCalled();
+    });
+  });
+
+  describe('#autocomplete', function() {
+    it('should abort if the query matches the top suggestion', function() {
+      var spy;
+
+      this.input.getQuery.andReturn(testData.val);
+      this.menu.getSelectableData.andReturn(testData);
+      this.$input.on('typeahead:beforeautocomplete', spy = jasmine.createSpy());
+
+      this.view.autocomplete($('<bah>'));
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+
+    it('should trigger typeahead:beforeautocomplete', function() {
+      var spy;
+
+      this.menu.getSelectableData.andReturn(testData);
+      this.$input.on('typeahead:beforeautocomplete', spy = jasmine.createSpy());
+
+      this.view.autocomplete($('<bah>'));
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should support cancellation', function() {
+      var spy1, spy2;
+
+      spy1 = jasmine.createSpy().andCallFake(prevent);
+      spy2 = jasmine.createSpy();
+      this.$input.on('typeahead:beforeautocomplete', spy1);
+      this.$input.on('typeahead:autocomplete', spy2);
+      this.menu.getSelectableData.andReturn(testData);
+
+      this.view.autocomplete($('<bah>'));
+
+      expect(spy1).toHaveBeenCalled();
+      expect(spy2).not.toHaveBeenCalled();
+    });
+
+    it('should update the query', function() {
+      this.menu.getSelectableData.andReturn(testData);
+      this.view.autocomplete($('<bah>'));
+      expect(this.input.setQuery).toHaveBeenCalledWith(testData.val);
+    });
+
+    it('should trigger typeahead:autocomplete', function() {
+      var spy;
+
+      this.menu.getSelectableData.andReturn(testData);
+      this.$input.on('typeahead:autocomplete', spy = jasmine.createSpy());
+
+      this.view.autocomplete($('<bah>'));
+
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('#moveCursor', function() {
+    beforeEach(function() {
+      this.input.getQuery.andReturn('foo');
+    });
+
+    it('should move cursor if minLength is not satisfied', function() {
+      var spy = jasmine.createSpy();
+
+      this.view.minLength = 100;
+      this.menu.update.andReturn(true);
+      this.$input.on('typeahead:beforecursorchange', spy);
+
+      this.view.moveCursor(1);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should move cursor if invalid update', function() {
+      var spy = jasmine.createSpy();
+
+      this.menu.update.andReturn(false);
+      this.$input.on('typeahead:beforecursorchange', spy);
+
+      this.view.moveCursor(1);
+
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should not move cursor if valid update', function() {
+      var spy = jasmine.createSpy();
+
+      this.menu.update.andReturn(true);
+      this.$input.on('typeahead:beforecursorchange', spy);
+
+      this.view.moveCursor(1);
+
+      expect(spy).not.toHaveBeenCalled();
+    });
+
+    it('should trigger typeahead:beforecursorchange', function() {
+      var spy = jasmine.createSpy();
+
+      this.$input.on('typeahead:beforecursorchange', spy);
+      this.view.moveCursor(1);
+      expect(spy).toHaveBeenCalled();
+    });
+
+    it('should support cancellation', function() {
+      var spy = jasmine.createSpy().andCallFake(prevent);
+
+      this.$input.on('typeahead:beforecursorchange', spy);
+      this.view.moveCursor(1);
+      expect(this.menu.setCursor).not.toHaveBeenCalled();
+    });
+
+    it('should update the input value if moved to selectable', function() {
+      this.menu.getSelectableData.andReturn(testData);
+      this.view.moveCursor(1);
+      expect(this.input.setInputValue).toHaveBeenCalledWith(testData.val);
+    });
+
+    it('should reset the input value if moved to input', function() {
+      this.view.moveCursor(1);
+      expect(this.input.resetInputValue).toHaveBeenCalled();
+    });
+
+    it('should update the hint', function() {
+      this.input.hasOverflow.andReturn(false);
+      this.menu.getTopSelectable.andReturn($('<fiz>'));
+      this.menu.getSelectableData.andCallFake(fake);
+      this.input.getInputValue.andReturn(testData.val.slice(0, 1));
+
+      this.view.moveCursor(1);
+
+      expect(this.input.setHint).toHaveBeenCalledWith(testData.val);
+
+      function fake($el) {
+        return ($el && $el.prop('tagName') === 'FIZ') ? testData : null;
+      }
+    });
+
+    it('should trigger cursorchange after setting cursor', function() {
+      var spy = jasmine.createSpy();
+
+      this.$input.on('typeahead:cursorchange', spy);
+      this.view.moveCursor(1);
+      expect(spy).toHaveBeenCalled();
+    });
+  });
+
+  describe('#destroy', function() {
+    it('should destroy input', function() {
+      this.view.destroy();
+
+      expect(this.input.destroy).toHaveBeenCalled();
+    });
+
+    it('should destroy menu', function() {
+      this.view.destroy();
+
+      expect(this.menu.destroy).toHaveBeenCalled();
+    });
+  });
+
+  function prevent($e) { $e.preventDefault(); }
+});
+
diff --git a/typeahead.js.jquery.json b/typeahead.js.jquery.json
new file mode 100644
index 0000000..8b29a42
--- /dev/null
+++ b/typeahead.js.jquery.json
@@ -0,0 +1,40 @@
+{
+  "licenses": [
+    {
+      "url": "https://github.com/twitter/typeahead.js/blob/master/LICENSE"
+    }
+  ],
+  "dependencies": {
+    "jquery": ">=1.7"
+  },
+  "docs": "https://github.com/twitter/typeahead.js",
+  "demo": "http://twitter.github.com/typeahead.js/examples",
+  "name": "typeahead.js",
+  "title": "typeahead.js",
+  "author": {
+    "name": "Twitter, Inc.",
+    "url": "https://twitter.com/twitteross"
+  },
+  "description": "fast and fully-featured autocomplete library",
+  "keywords": [
+    "typeahead",
+    "autocomplete"
+  ],
+  "homepage": "http://twitter.github.com/typeahead.js",
+  "bugs": "https://github.com/twitter/typeahead.js/issues",
+  "maintainers": [
+    {
+      "name": "Jake Harding",
+      "url": "https://twitter.com/JakeHarding"
+    },
+    {
+      "name": "Tim Trueman",
+      "url": "https://twitter.com/timtrueman"
+    },
+    {
+      "name": "Veljko Skarich",
+      "url": "https://twitter.com/vskarich"
+    }
+  ],
+  "version": "0.11.1"
+}
\ No newline at end of file

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-javascript/typeahead.js.git



More information about the Pkg-javascript-commits mailing list