[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