├── .gitignore ├── .jshintrc ├── .travis.yml ├── CHANGELOG.md ├── Gruntfile.js ├── LICENSE ├── README.md ├── angular-typeahead.js ├── bower.json ├── dist ├── angular-typeahead.js └── angular-typeahead.min.js ├── package.json ├── resources └── coverage.svg ├── tasks └── require-self.js └── test ├── angular-typeahead.spec.js ├── karma.amd.conf.js ├── karma.cjs.conf.js ├── karma.global.conf.js ├── karma.shared.conf.js └── test-amd.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor files 2 | .idea/ 3 | angular-typeahead.sublime-workspace 4 | angular-typeahead.sublime-project 5 | 6 | # NPM Files 7 | node_modules/ 8 | npm-debug.log 9 | 10 | # Bower Files 11 | bower_components 12 | 13 | # Build artifacts 14 | build/ 15 | 16 | # System crap 17 | .DS_Store 18 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "eqnull": true, 3 | "undef": true, 4 | "unused": true, 5 | "strict": "global", 6 | "globals": { 7 | "angular": true, 8 | "jasmine": true, 9 | "describe": true, 10 | "xdescribe": true, 11 | "before": true, 12 | "beforeEach": true, 13 | "after": true, 14 | "afterEach": true, 15 | "it": true, 16 | "xit": true, 17 | "it": true, 18 | "expect": true, 19 | "spyOn": true, 20 | "fail": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.4" 4 | before_script: 5 | - export DISPLAY=:99.0 6 | - sh -e /etc/init.d/xvfb start 7 | - npm install angular@$ANGULAR_VERSION 8 | script: 9 | - KARMA_BROWSER=Firefox npm test 10 | - KARMA_BROWSER=PhantomJS npm test # Webkit - should work like Chrome 11 | env: 12 | - ANGULAR_VERSION=v1.2.x 13 | - ANGULAR_VERSION=v1.3.x 14 | - ANGULAR_VERSION=v1.4.x 15 | - ANGULAR_VERSION=v1.5.x 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v1.0.2 4 | 5 | - Prevent $dirty flag on a form from being set to false on initialization 6 | 7 | ## v1.0.0 - Major refactoring 8 | 9 | The code has been rewritten almost from scratch with a focus on code quality and testing. This will make future development easier and limit the number of bugs on the long term. Beware that regressions are likely! 10 | 11 | - Built files are now wrapped in [UMD](https://github.com/umdjs/umd) 12 | - Add unit tests, jshint and continuous integration 13 | - Moved built files to `dist/` folder 14 | - Values set directly to $scope.model are no longer validated against the suggestions. 15 | - Remove ambiguous "suggestionKey" parameter 16 | - moved options not related to tt to top level 17 | - renamed "editable" option to "allow-custom" 18 | - Renamed events to match typeahead event names (see [#82](https://github.com/Siyfion/angular-typeahead/pull/82)) 19 | 20 | ## v0.3.2 21 | * Adds render event 22 | 23 | ## v0.3.1 24 | * Removed the "all-events" binding, as the code didn't work. 25 | 26 | ## v0.3.0 27 | * Fixes \#71, \#72 and \#79, binds ALL typeahead events adds `require()` support. 28 | 29 | ## v0.2.4 30 | * Adds typeahead.js v0.11.1 dependency 31 | 32 | ## v0.2.3 33 | * Adds async event propagation (Thanks @powange) 34 | * Fixes the Firefox "focus bug" (Thanks @CyborgMaster) 35 | 36 | ## v0.2.2 37 | * Adds a watcher to the datsets and options and reinitializes if any changes are detected. 38 | 39 | ## v0.2.1 40 | * Removes the event bindings as they should no-longer be needed due to the new way the ng-model is being updated. 41 | 42 | ## v0.2.0 43 | * This is a *potentially* **BREAKING CHANGE** release. 44 | * This adds initial support for two-way data binding. 45 | 46 | ## v0.1.4 47 | * Adds a sanity check to the options object for the 'editable' property. 48 | 49 | ## v0.1.3 50 | * Adds 'editable' option, to only allow model values from datum objects. (Thanks to @raphahardt) 51 | 52 | ## v0.1.2 53 | * Retains cursor position, thanks to @skakri. 54 | 55 | ## v0.1.1 56 | * Adds event propagation for all the other typeahead.js events. 57 | 58 | ## v0.1.0 59 | * This is a **BREAKING CHANGE** release. 60 | * Major new release that adds initial support for the new Twitter Typeahead v0.10.x release. 61 | * As a direct result of Twitter's changes, two-way binding is no longer possible at this time (the directive cannot access any local datum lists & never could access remote lists). 62 | * You can now update local datasets using [`Bloodhound#add`](https://github.com/twitter/typeahead.js/blob/master/src/bloodhound/bloodhound.js#L151) and I have questioned whether a similar method could be added for datum removal. 63 | 64 | ## v0.0.12 65 | * Merged in Jakob Lahmer's changes, which ensures that the typeahead events are propagated to the scope. 66 | 67 | ## v0.0.11 68 | * Merged in slobo's changes, including a refactoring of the event methods and a slight hack around the [object Object] issue. 69 | 70 | ## v0.0.10 71 | * Reverting previous change in v0.0.9 (return the datum object, not the string!) 72 | 73 | ## v0.0.9 74 | * Added type check to the datum object, in-case the datum is a string that is implicitly converted. 75 | 76 | ## v0.0.8 77 | * Bugfix for issue #10 78 | * The fix should also support datums with non-default value keys. 79 | 80 | ## v0.0.7 81 | * Now updates the ngModel with the raw user input. 82 | * Optimization on the ngModel watch. 83 | 84 | ## v0.0.6 85 | * Merged in @jmaynier's PR for supporting multiple datasets. (Thanks!) 86 | 87 | ## v0.0.5 88 | * Renamed the angular module to `siyfion.sfTypeahead`. 89 | * Renamed the angular directive to `sfTypeahead`. 90 | * Added two-way binding support to ng-model. 91 | 92 | ## v0.0.4 93 | * Added one-way binding support to ng-model. 94 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* jshint node: true */ 3 | module.exports = function (grunt) { 4 | 5 | var karma_browser = process.env.KARMA_BROWSER || 'Chrome'; 6 | 7 | // Project configuration. 8 | grunt.initConfig({ 9 | pkg: grunt.file.readJSON('package.json'), 10 | clean: { 11 | files: { 12 | src: [ 13 | 'build/' 14 | ] 15 | } 16 | }, 17 | copy: { 18 | files: { 19 | expand: true, 20 | src: ['angular-typeahead.js', 'angular-typeahead.min.js'], 21 | cwd: 'build', 22 | dest: 'dist/' 23 | } 24 | }, 25 | uglify: { 26 | build: { 27 | src: 'build/angular-typeahead.js', 28 | dest: 'build/angular-typeahead.min.js' 29 | } 30 | }, 31 | karma: { 32 | global: { 33 | configFile: 'test/karma.global.conf.js', 34 | browsers: [ karma_browser ] 35 | }, 36 | amd: { 37 | configFile: 'test/karma.amd.conf.js', 38 | browsers: [ karma_browser ] 39 | }, 40 | cjs: { 41 | configFile: 'test/karma.cjs.conf.js', 42 | browsers: [ karma_browser ] 43 | } 44 | }, 45 | jshint: { 46 | default: { 47 | options: { 48 | jshintrc: true, 49 | }, 50 | files: { 51 | src: [ 52 | 'angular-typeahead.js', 53 | 'test/*.js'] 54 | } 55 | } 56 | }, 57 | umd: { 58 | src: { 59 | options: { 60 | src: 'angular-typeahead.js', 61 | dest: 'build/angular-typeahead.js', 62 | amdModuleId: 'angular-typeahead', 63 | deps: { 64 | default: ['angular'], 65 | global: ['angular'], 66 | amd: ['angular'], 67 | cjs: ['angular', 'typeahead.js'] 68 | } 69 | } 70 | }, 71 | test: { 72 | src: 'test/angular-typeahead.spec.js', 73 | dest: 'build/angular-typeahead.spec.js', 74 | amdModuleId: 'build/angular-typeahead.spec', 75 | deps: { 76 | default: ['angular'], 77 | global: ['angular'], 78 | amd: ['angular', 'angular-typeahead', 'angular-mocks'], 79 | cjs: ['angular', 'angular-typeahead', 'angular-mocks'] 80 | } 81 | } 82 | }, 83 | watch: { 84 | default: { 85 | files: [ 86 | 'angular-typeahead.js', 87 | 'test/angular-typeahead.spec.js'], 88 | tasks: ['test:lite'], 89 | options: { 90 | spawn: false, 91 | }, 92 | }, 93 | }, 94 | }); 95 | 96 | // Load the plugins that provide the tasks. 97 | grunt.loadNpmTasks('grunt-contrib-clean'); 98 | grunt.loadNpmTasks('grunt-contrib-copy'); 99 | grunt.loadNpmTasks('grunt-contrib-uglify'); 100 | grunt.loadNpmTasks('grunt-contrib-jshint'); 101 | grunt.loadNpmTasks('grunt-karma'); 102 | grunt.loadNpmTasks('grunt-contrib-watch'); 103 | grunt.loadNpmTasks('grunt-umd'); 104 | 105 | grunt.registerTask('require-self', 'Fixes require calls to self for tests', require('./tasks/require-self')); 106 | 107 | // Utility Tasks 108 | grunt.registerTask('_build', ['require-self', 'umd']); 109 | grunt.registerTask('_test', ['karma', 'jshint']); 110 | 111 | 112 | // Tasks 113 | grunt.registerTask('test:lite', ['_build', 'karma:global', 'jshint']); 114 | grunt.registerTask('test', ['_build', '_test']); 115 | grunt.registerTask('dist', ['_build', 'uglify', 'copy']); 116 | }; 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Simon Mansfield and contributors. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sfTypeahead: A Twitter Typeahead directive 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/Siyfion/angular-typeahead.svg?branch=master)](https://travis-ci.org/Siyfion/angular-typeahead) 5 | ![Coverage: 100%](https://cdn.rawgit.com/Siyfion/angular-typeahead/master/resources/coverage.svg) 6 | [![Version](https://badge.fury.io/gh/Siyfion%2Fangular-typeahead.svg)](https://badge.fury.io/gh/Siyfion%2Fangular-typeahead) 7 | [![dependencies Status](https://david-dm.org/Siyfion/angular-typeahead/status.svg)](https://david-dm.org/Siyfion/angular-typeahead) 8 | 9 | A simple Angular.js directive wrapper around the Twitter Typeahead library. 10 | 11 | Getting Started 12 | --------------- 13 | 14 | Get angular-typeahead from your favorite source: 15 | 16 | * Install with [Bower][bower]: `$ bower install angular-typeahead` 17 | * Install with [npm][npm]: `$ npm install angular-typeahead` 18 | * Download latest *[angular-typeahead.js][angular-typeahead.js]* or *[angular-typeahead.min.js][angular-typeahead.min.js]*. 19 | 20 | **Note:** angular-typeahead supports [Angular.js][angularjs] v1.2.x through v1.5.x and depends on [typeahead.js][typeahead.js] v0.11.x. Make sure dependencies are met in your setup: 21 | 22 | * **global**: include jQuery, angularjs and typeahead.js before *angular-typeahead.js*. 23 | * **commonJS** (node, browserify): angular-typeahead explicitly *requires* `angular` and `typeahead.js`. (note: with browserify, include jquery.js and typeahead.js externally, because angular does not define a dependency on jquery) 24 | * **amd** (require.js): angular-typeahead explicitly *requires* `angular` and declares itself as `angular-typeahead`. Note that `typeahead.js` does not work well with AMD.js, you may find [this workaround](https://github.com/twitter/typeahead.js/issues/1211#issuecomment-129189829) useful. 25 | 26 | Demo 27 | --------------- 28 | 29 | Please feel free to play with the Plnkr: [LIVE DEMO][plnkr] 30 | 31 | Usage 32 | --------------- 33 | 34 | ```html 35 | 36 | ``` 37 | 38 | See the Plnkr [LIVE DEMO][plnkr] for a complete integrated example. 39 | 40 | ### Parameters 41 | 42 | | Parameter | Default | Description | 43 | |---------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 44 | | datasets | {} | One or an array of twitter typeahead [datasets][twitter datasets]. | 45 | | options | {} | [Options][twitter options] parameter passed directly to twitter typeahead. | 46 | | allow-custom | true | Boolean. If false, the model value can not take custom values as text is typed in the input field. | 47 | 48 | Contributing 49 | --------------- 50 | 51 | Please feel free to add any issues to the GitHub issue tracker. 52 | 53 | Contributions are welcome but please try to adhere to the folowing guidelines: 54 | 55 | ### Testing 56 | 57 | Any code you write should be tested. Test the "happy path" as well as corner cases. 58 | Code cannot be merged in master unless it achieves 100% coverage on everything. 59 | To run tests automatically when a file changes, run `npm run watch`. 60 | 61 | Tests run in Chrome by default, but you can override this by setting the `KARMA_BROWSER` 62 | environment variable. 63 | Example: 64 | ```sh 65 | KARMA_BROWSER=Firefox npm run watch 66 | KARMA_BROWSER=PhantomJS npm run watch 67 | ``` 68 | 69 | If you are not sure how to test something, ask about it in your pull request description. 70 | 71 | ### JSHint 72 | 73 | I recommend you use a jshint plugin in your editor, this will help you spot errors 74 | faster and make it easier to write clean code that is going to pass QA. 75 | In any case, `npm run watch` runs jshint on the code whenever you save. 76 | 77 | 78 | 79 | [angular-typeahead.js]: https://raw.github.com/Siyfion/angular-typeahead/master/dist/angular-typeahead.js 80 | [angular-typeahead.min.js]: https://raw.github.com/Siyfion/angular-typeahead/master/dist/angular-typeahead.min.js 81 | 82 | 83 | [bower]: http://twitter.github.com/bower/ 84 | [npm]: https://www.npmjs.com/ 85 | [jQuery]: http://jquery.com/ 86 | [angularjs]: http://angularjs.org/ 87 | [typeahead.js]: http://twitter.github.io/typeahead.js/ 88 | [plnkr]: http://plnkr.co/edit/k2JWu6tZMXwkB8Oi9CSv?p=preview 89 | [twitter datasets]: https://github.com/twitter/typeahead.js/blob/master/doc/jquery_typeahead.md#datasets 90 | [twitter options]: https://github.com/twitter/typeahead.js/blob/master/doc/jquery_typeahead.md#options 91 | -------------------------------------------------------------------------------- /angular-typeahead.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | angular.module('siyfion.sfTypeahead', []) 3 | 4 | // Inject the typeahead jquery plugin through angular to make it easier to unit 5 | // test the library. 6 | // Usage: 7 | // instead of `$element.typeahead(foo, bar)` 8 | // do `$typeahead($element, foo, bar)` 9 | .value('$typeahead', function(subject) { 10 | subject.typeahead.apply(subject, Array.prototype.slice.call(arguments, 1)); 11 | }) 12 | 13 | // The actual directive 14 | .directive('sfTypeahead', ['$typeahead', function ($typeahead) { 15 | 16 | return { 17 | restrict: 'AC', // Only apply on an attribute or class 18 | require: 'ngModel', // The two-way data bound value that is returned by the directive 19 | scope: { 20 | datasets: '=', 21 | options: '=', 22 | allowCustom: '=' // We cannot use '<' if we want to support angular 1.2.x 23 | }, 24 | link: function(scope, element, attrs, ngModel) { 25 | var initialized = false; 26 | var options; 27 | var datasets; 28 | // Unsubscribe handle for the scope watcher 29 | var unsubscribe = null; 30 | // Remembers whether the `datasets` parameter provided was an array or an object. 31 | var datasetsIsArray; 32 | 33 | // Create the typeahead on the element 34 | initialize(); 35 | 36 | // Watch for changes on datasets 37 | watchDatasets(); 38 | 39 | // Parses and validates what is going to be set to model (called when: ngModel.$setViewValue(value)) 40 | ngModel.$parsers.push(function(fromView) { 41 | // In Firefox, when the typeahead field loses focus, it fires an extra 42 | // angular input update event. This causes the stored model to be 43 | // replaced with the search string. If the typeahead search string 44 | // hasn't changed at all (the 'val' property doesn't update until 45 | // after the event loop finishes), then we can bail out early and keep 46 | // the current model value. 47 | if (angular.isObject(ngModel.$modelValue) && fromView === getDatumValue(ngModel.$modelValue)) { 48 | return ngModel.$modelValue; 49 | } 50 | 51 | if (!isCustomAllowed() && typeof fromView === 'string') { 52 | return ngModel.$modelValue; 53 | } 54 | 55 | return fromView; 56 | }); 57 | 58 | // Formats what is going to be displayed (called when: $scope.model = { object }) 59 | ngModel.$formatters.push(function(fromModel) { 60 | if (angular.isObject(fromModel)) { 61 | fromModel = getDatumValue(fromModel); 62 | } 63 | 64 | if (!fromModel) { 65 | $typeahead(element, 'val', ''); 66 | } else { 67 | $typeahead(element, 'val', fromModel); 68 | } 69 | return fromModel; 70 | }); 71 | 72 | function watchDatasets() { 73 | unsubscribe = datasetsIsArray ? 74 | scope.$watchCollection('datasets', datasetsChangeHandler) : 75 | scope.$watch('datasets', datasetsChangeHandler); 76 | } 77 | 78 | function datasetsChangeHandler(newValue, oldValue) { 79 | if (angular.equals(newValue, oldValue)) { 80 | return; 81 | } 82 | var oldDatasetsIsArray = datasetsIsArray; 83 | initialize(); 84 | if (datasetsIsArray !== oldDatasetsIsArray) { 85 | unsubscribe(); 86 | watchDatasets(); 87 | } 88 | } 89 | 90 | function initialize() { 91 | if (scope.datasets == null) { 92 | throw new Error('The datasets parameter is mandatory!'); 93 | } 94 | 95 | options = scope.options || {}; 96 | datasetsIsArray = angular.isArray(scope.datasets); 97 | datasets = datasetsIsArray ? scope.datasets : [scope.datasets]; 98 | 99 | if (!initialized) { 100 | $typeahead(element, options, datasets); 101 | scope.$watch('options', initialize); 102 | initialized = true; 103 | } else { 104 | $typeahead(element, 'destroy'); 105 | $typeahead(element, options, datasets); 106 | } 107 | } 108 | 109 | // Returns the string to be displayed given some datum 110 | function getDatumValue(datum) { 111 | for (var i in datasets) { 112 | var dataset = datasets[i]; 113 | var displayKey = dataset.displayKey || 'value'; 114 | var value = (angular.isFunction(displayKey) ? displayKey(datum) : datum[displayKey]) || ''; 115 | return value; 116 | } 117 | } 118 | 119 | function isCustomAllowed() { 120 | return scope.allowCustom === undefined || !!scope.allowCustom; 121 | } 122 | 123 | function updateScope (suggestion) { 124 | scope.$apply(function () { 125 | ngModel.$setViewValue(suggestion); 126 | }); 127 | } 128 | 129 | function forwardEvent(name) { 130 | element.bind(name, function() { 131 | scope.$emit(name, Array.prototype.slice. call(arguments, 1)); 132 | }); 133 | } 134 | 135 | // Update the value binding when a value is manually selected from the dropdown. 136 | element.bind('typeahead:select', function(evt, suggestion, dataset) { 137 | updateScope(suggestion); 138 | scope.$emit('typeahead:select', suggestion, dataset); 139 | }); 140 | 141 | // Update the value binding when a query is autocompleted. 142 | element.bind('typeahead:autocomplete', function(evt, suggestion, dataset) { 143 | updateScope(suggestion); 144 | scope.$emit('typeahead:autocomplete', suggestion, dataset); 145 | }); 146 | 147 | forwardEvent('typeahead:active'); 148 | forwardEvent('typeahead:idle'); 149 | forwardEvent('typeahead:open'); 150 | forwardEvent('typeahead:close'); 151 | forwardEvent('typeahead:change'); 152 | forwardEvent('typeahead:render'); 153 | forwardEvent('typeahead:cursorchange'); 154 | forwardEvent('typeahead:asyncrequest'); 155 | forwardEvent('typeahead:asynccancel'); 156 | forwardEvent('typeahead:asyncreceive'); 157 | } 158 | }; 159 | }]); 160 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-typeahead", 3 | "version": "1.0.2", 4 | "main": "./dist/angular-typeahead.js", 5 | "ignore": [ 6 | "build", 7 | "test", 8 | "resources", 9 | ".travis.yml", 10 | "package.json", 11 | "Gruntfile.js", 12 | "karma.conf.js", 13 | "angular-typeahead.js" 14 | ], 15 | "dependencies": { 16 | "typeahead.js": "~0.11.1" 17 | }, 18 | "description": "An Angular.js wrapper around the Twitter Typeahead library." 19 | } 20 | -------------------------------------------------------------------------------- /dist/angular-typeahead.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | // AMD. Register as an anonymous module unless amdModuleId is set 4 | define('angular-typeahead', ["angular"], function (a0) { 5 | return (factory(a0)); 6 | }); 7 | } else if (typeof exports === 'object') { 8 | // Node. Does not work with strict CommonJS, but 9 | // only CommonJS-like environments that support module.exports, 10 | // like Node. 11 | module.exports = factory(require("angular"),require("typeahead.js")); 12 | } else { 13 | factory(angular); 14 | } 15 | }(this, function (angular) { 16 | 17 | "use strict"; 18 | angular.module('siyfion.sfTypeahead', []) 19 | 20 | // Inject the typeahead jquery plugin through angular to make it easier to unit 21 | // test the library. 22 | // Usage: 23 | // instead of `$element.typeahead(foo, bar)` 24 | // do `$typeahead($element, foo, bar)` 25 | .value('$typeahead', function(subject) { 26 | subject.typeahead.apply(subject, Array.prototype.slice.call(arguments, 1)); 27 | }) 28 | 29 | // The actual directive 30 | .directive('sfTypeahead', ['$typeahead', function ($typeahead) { 31 | 32 | return { 33 | restrict: 'AC', // Only apply on an attribute or class 34 | require: 'ngModel', // The two-way data bound value that is returned by the directive 35 | scope: { 36 | datasets: '=', 37 | options: '=', 38 | allowCustom: '=' // We cannot use '<' if we want to support angular 1.2.x 39 | }, 40 | link: function(scope, element, attrs, ngModel) { 41 | var initialized = false; 42 | var options; 43 | var datasets; 44 | // Unsubscribe handle for the scope watcher 45 | var unsubscribe = null; 46 | // Remembers whether the `datasets` parameter provided was an array or an object. 47 | var datasetsIsArray; 48 | 49 | // Create the typeahead on the element 50 | initialize(); 51 | 52 | // Watch for changes on datasets 53 | watchDatasets(); 54 | 55 | // Parses and validates what is going to be set to model (called when: ngModel.$setViewValue(value)) 56 | ngModel.$parsers.push(function(fromView) { 57 | // In Firefox, when the typeahead field loses focus, it fires an extra 58 | // angular input update event. This causes the stored model to be 59 | // replaced with the search string. If the typeahead search string 60 | // hasn't changed at all (the 'val' property doesn't update until 61 | // after the event loop finishes), then we can bail out early and keep 62 | // the current model value. 63 | if (angular.isObject(ngModel.$modelValue) && fromView === getDatumValue(ngModel.$modelValue)) { 64 | return ngModel.$modelValue; 65 | } 66 | 67 | if (!isCustomAllowed() && typeof fromView === 'string') { 68 | return ngModel.$modelValue; 69 | } 70 | 71 | return fromView; 72 | }); 73 | 74 | // Formats what is going to be displayed (called when: $scope.model = { object }) 75 | ngModel.$formatters.push(function(fromModel) { 76 | if (angular.isObject(fromModel)) { 77 | fromModel = getDatumValue(fromModel); 78 | } 79 | 80 | if (!fromModel) { 81 | $typeahead(element, 'val', ''); 82 | } else { 83 | $typeahead(element, 'val', fromModel); 84 | } 85 | return fromModel; 86 | }); 87 | 88 | function watchDatasets() { 89 | unsubscribe = datasetsIsArray ? 90 | scope.$watchCollection('datasets', datasetsChangeHandler) : 91 | scope.$watch('datasets', datasetsChangeHandler); 92 | } 93 | 94 | function datasetsChangeHandler(newValue, oldValue) { 95 | if (angular.equals(newValue, oldValue)) { 96 | return; 97 | } 98 | var oldDatasetsIsArray = datasetsIsArray; 99 | initialize(); 100 | if (datasetsIsArray !== oldDatasetsIsArray) { 101 | unsubscribe(); 102 | watchDatasets(); 103 | } 104 | } 105 | 106 | function initialize() { 107 | if (scope.datasets == null) { 108 | throw new Error('The datasets parameter is mandatory!'); 109 | } 110 | 111 | options = scope.options || {}; 112 | datasetsIsArray = angular.isArray(scope.datasets); 113 | datasets = datasetsIsArray ? scope.datasets : [scope.datasets]; 114 | 115 | if (!initialized) { 116 | $typeahead(element, options, datasets); 117 | scope.$watch('options', initialize); 118 | initialized = true; 119 | } else { 120 | $typeahead(element, 'destroy'); 121 | $typeahead(element, options, datasets); 122 | } 123 | } 124 | 125 | // Returns the string to be displayed given some datum 126 | function getDatumValue(datum) { 127 | for (var i in datasets) { 128 | var dataset = datasets[i]; 129 | var displayKey = dataset.displayKey || 'value'; 130 | var value = (angular.isFunction(displayKey) ? displayKey(datum) : datum[displayKey]) || ''; 131 | return value; 132 | } 133 | } 134 | 135 | function isCustomAllowed() { 136 | return scope.allowCustom === undefined || !!scope.allowCustom; 137 | } 138 | 139 | function updateScope (suggestion) { 140 | scope.$apply(function () { 141 | ngModel.$setViewValue(suggestion); 142 | }); 143 | } 144 | 145 | function forwardEvent(name) { 146 | element.bind(name, function() { 147 | scope.$emit(name, Array.prototype.slice. call(arguments, 1)); 148 | }); 149 | } 150 | 151 | // Update the value binding when a value is manually selected from the dropdown. 152 | element.bind('typeahead:select', function(evt, suggestion, dataset) { 153 | updateScope(suggestion); 154 | scope.$emit('typeahead:select', suggestion, dataset); 155 | }); 156 | 157 | // Update the value binding when a query is autocompleted. 158 | element.bind('typeahead:autocomplete', function(evt, suggestion, dataset) { 159 | updateScope(suggestion); 160 | scope.$emit('typeahead:autocomplete', suggestion, dataset); 161 | }); 162 | 163 | forwardEvent('typeahead:active'); 164 | forwardEvent('typeahead:idle'); 165 | forwardEvent('typeahead:open'); 166 | forwardEvent('typeahead:close'); 167 | forwardEvent('typeahead:change'); 168 | forwardEvent('typeahead:render'); 169 | forwardEvent('typeahead:cursorchange'); 170 | forwardEvent('typeahead:asyncrequest'); 171 | forwardEvent('typeahead:asynccancel'); 172 | forwardEvent('typeahead:asyncreceive'); 173 | } 174 | }; 175 | }]); 176 | 177 | 178 | })); 179 | -------------------------------------------------------------------------------- /dist/angular-typeahead.min.js: -------------------------------------------------------------------------------- 1 | !function(a,b){"function"==typeof define&&define.amd?define("angular-typeahead",["angular"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("angular"),require("typeahead.js")):b(angular)}(this,function(a){"use strict";a.module("siyfion.sfTypeahead",[]).value("$typeahead",function(a){a.typeahead.apply(a,Array.prototype.slice.call(arguments,1))}).directive("sfTypeahead",["$typeahead",function(b){return{restrict:"AC",require:"ngModel",scope:{datasets:"=",options:"=",allowCustom:"="},link:function(c,d,e,f){function g(){r=p?c.$watchCollection("datasets",h):c.$watch("datasets",h)}function h(b,c){if(!a.equals(b,c)){var d=p;i(),p!==d&&(r(),g())}}function i(){if(null==c.datasets)throw new Error("The datasets parameter is mandatory!");n=c.options||{},p=a.isArray(c.datasets),o=p?c.datasets:[c.datasets],q?(b(d,"destroy"),b(d,n,o)):(b(d,n,o),c.$watch("options",i),q=!0)}function j(b){for(var c in o){var d=o[c],e=d.displayKey||"value",f=(a.isFunction(e)?e(b):b[e])||"";return f}}function k(){return void 0===c.allowCustom||!!c.allowCustom}function l(a){c.$apply(function(){f.$setViewValue(a)})}function m(a){d.bind(a,function(){c.$emit(a,Array.prototype.slice.call(arguments,1))})}var n,o,p,q=!1,r=null;i(),g(),f.$parsers.push(function(b){return a.isObject(f.$modelValue)&&b===j(f.$modelValue)?f.$modelValue:k()||"string"!=typeof b?b:f.$modelValue}),f.$formatters.push(function(c){return a.isObject(c)&&(c=j(c)),c?b(d,"val",c):b(d,"val",""),c}),d.bind("typeahead:select",function(a,b,d){l(b),c.$emit("typeahead:select",b,d)}),d.bind("typeahead:autocomplete",function(a,b,d){l(b),c.$emit("typeahead:autocomplete",b,d)}),m("typeahead:active"),m("typeahead:idle"),m("typeahead:open"),m("typeahead:close"),m("typeahead:change"),m("typeahead:render"),m("typeahead:cursorchange"),m("typeahead:asyncrequest"),m("typeahead:asynccancel"),m("typeahead:asyncreceive")}}}])}); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-typeahead", 3 | "version": "1.0.2", 4 | "subdomain": "Siyfion", 5 | "main": "dist/angular-typeahead.js", 6 | "author": "siyfion@gmail.com", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/Siyfion/angular-typeahead.git" 11 | }, 12 | "description": "An Angular.js wrapper around the Twitter Typeahead library.", 13 | "scripts": { 14 | "test": "grunt test", 15 | "watch": "grunt watch", 16 | "dist": "grunt dist" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "contributors": [ 22 | { 23 | "name": "Simon Mansfield", 24 | "email": "siyfion@gmail.com" 25 | }, 26 | { 27 | "name": "Hadrien Milano", 28 | "email": "hadrien.milano@gmail.com" 29 | } 30 | ], 31 | "keywords": [ 32 | "angular", 33 | "angularjs", 34 | "typeahead" 35 | ], 36 | "devDependencies": { 37 | "angular": "1.5.x", 38 | "angular-mocks": "^1.5.8", 39 | "browserify": "^13.1.0", 40 | "grunt": "1.0.1", 41 | "grunt-contrib-clean": "~1.0.0", 42 | "grunt-contrib-copy": "^1.0.0", 43 | "grunt-contrib-jshint": "^1.0.0", 44 | "grunt-contrib-uglify": "~2.0.0", 45 | "grunt-contrib-watch": "^1.0.0", 46 | "grunt-karma": "^2.0.0", 47 | "grunt-umd": "^2.3.6", 48 | "jquery": "^3.1.1", 49 | "karma": "^1.3.0", 50 | "karma-browserify": "^5.1.0", 51 | "karma-chrome-launcher": "^2.0.0", 52 | "karma-coverage": "^1.1.1", 53 | "karma-firefox-launcher": "^1.0.0", 54 | "karma-jasmine": "^1.0.2", 55 | "karma-phantomjs-launcher": "^1.0.2", 56 | "karma-requirejs": "^1.1.0", 57 | "karma-spec-reporter": "0.0.26", 58 | "karma-threshold-reporter": "^0.1.15", 59 | "requirejs": "^2.3.2", 60 | "typeahead.js": "^0.11.1", 61 | "watchify": "^3.7.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /resources/coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | coverage 12 | coverage 13 | 100% 14 | 100% 15 | 16 | 17 | -------------------------------------------------------------------------------- /tasks/require-self.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* jshint node: true */ 3 | module.exports = function() { 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | 7 | // Get the name of the module in the current working directory. 8 | var cwd = process.cwd(); 9 | 10 | // Compute the location and content for the pseudo-module. 11 | var modulePath = path.join(cwd, 'node_modules/angular-typeahead.js'); 12 | var moduleText = "module.exports = require('../build/angular-typeahead.js');"; 13 | 14 | // Create the pseudo-module. 15 | fs.writeFileSync(modulePath, moduleText); 16 | }; 17 | -------------------------------------------------------------------------------- /test/angular-typeahead.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var module = angular.mock.module; 4 | var inject = angular.mock.inject; 5 | 6 | describe('dependencies', function() { 7 | describe('typeahead.js', function() { 8 | it('is correctly loaded', inject(function($compile, $rootScope) { 9 | var elem = $compile('')($rootScope.$new()); 10 | expect(angular.isFunction(elem.typeahead)).toBe(true); 11 | })); 12 | }); 13 | }); 14 | 15 | describe('$typeahead', function() { 16 | beforeEach(module('siyfion.sfTypeahead')); 17 | it('provides a proxy to the jquery function `typeahead` to let tests hook it', 18 | inject(function($typeahead) { 19 | var subject = { 20 | typeahead: jasmine.createSpy('typeahead') 21 | }; 22 | $typeahead(subject, 'jasmine', 'test'); 23 | expect(subject.typeahead).toHaveBeenCalledWith('jasmine', 'test'); 24 | })); 25 | }); 26 | 27 | describe('sfTypeahead', function() { 28 | beforeEach(module('siyfion.sfTypeahead')); 29 | 30 | var $scope; 31 | var $element; 32 | var $typeahead; 33 | 34 | var createScope = function($rootScope) { 35 | var $scope = $rootScope.$new(); 36 | $scope.datasets = { 37 | source: jasmine.createSpy('dataset source'), 38 | }; 39 | $scope.options = { 40 | highlight: true 41 | }; 42 | $scope.model = 'simple value'; 43 | return $scope; 44 | }; 45 | 46 | beforeEach(module(function($provide) { 47 | $typeahead = jasmine.createSpy('typeahead').and.callFake(function(subject) { 48 | subject.typeahead.apply(subject, Array.prototype.slice.call(arguments, 1)); 49 | }); 50 | $provide.value('$typeahead', $typeahead); 51 | })); 52 | 53 | describe('Behaviour in a form', function() { 54 | it('sets the proper form flags when there is data', inject(function($rootScope, $compile) { 55 | $scope = createScope($rootScope); 56 | $compile('
' + 57 | '' + 58 | '
')($scope); 59 | $scope.$digest(); 60 | expect($scope.theForm.$pristine).toEqual(true); 61 | expect($scope.theForm.$dirty).toEqual(false); 62 | expect($scope.theForm.$valid).toEqual(true); 63 | expect($scope.theForm.$invalid).toEqual(false); 64 | })); 65 | it('sets the proper form flags when there is no data', inject(function($rootScope, $compile) { 66 | $scope = createScope($rootScope); 67 | $scope.model = undefined; 68 | $compile('
' + 69 | '' + 70 | '
')($scope); 71 | $scope.$digest(); 72 | expect($scope.theForm.$pristine).toEqual(true); 73 | expect($scope.theForm.$dirty).toEqual(false); 74 | expect($scope.theForm.$valid).toEqual(false); 75 | expect($scope.theForm.$invalid).toEqual(true); 76 | })); 77 | }); 78 | describe('Directive syntax', function() { 79 | it('is compiled on class name', inject(function($rootScope, $compile) { 80 | $scope = createScope($rootScope); 81 | $element = $compile('')($scope); 82 | $scope.$digest(); 83 | expect($element.hasClass("tt-input")).toBe(true); 84 | })); 85 | it('is compiled on attribute name', inject(function($rootScope, $compile) { 86 | $scope = createScope($rootScope); 87 | $element = $compile('')($scope); 88 | $scope.$digest(); 89 | expect($element.hasClass("tt-input")).toBe(true); 90 | })); 91 | it('is not compiled on tag name', inject(function($rootScope, $compile) { 92 | $scope = createScope($rootScope); 93 | $element = $compile('')($scope); 94 | $scope.$digest(); 95 | expect($element.hasClass("tt-input")).not.toBe(true); 96 | })); 97 | it('requires the datasets parameter', inject(function($rootScope, $compile) { 98 | $scope = createScope($rootScope); 99 | try { 100 | $element = $compile('')($scope); 101 | $scope.$digest(); 102 | fail('expected an exception'); 103 | } catch(e) { 104 | expect(e.message).toEqual('The datasets parameter is mandatory!'); 105 | // success 106 | } 107 | })); 108 | it('requires the ng-model parameter', inject(function($rootScope, $compile) { 109 | $scope = createScope($rootScope); 110 | try { 111 | $element = $compile('')($scope); 112 | $scope.$digest(); 113 | fail('expected an exception'); 114 | } catch(e) { 115 | // success 116 | } 117 | })); 118 | it('forwards `options` to tt', inject(function($rootScope, $compile) { 119 | $scope = createScope($rootScope); 120 | $element = $compile('')($scope); 121 | $scope.$digest(); 122 | expect($typeahead.calls.first().args[1]).toEqual($scope.options); 123 | })); 124 | }); 125 | describe('initialize', function() { 126 | it('recreates the typeahead when options attribute changes', 127 | inject(function($rootScope, $compile) { 128 | $scope = createScope($rootScope); 129 | $element = $compile('')($scope); 130 | $scope.$digest(); 131 | $scope.options = { 132 | highlight: false 133 | }; 134 | $scope.$digest(); 135 | expect($element.hasClass('tt-input')).toBe(true); 136 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), 'destroy'); 137 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), $scope.options, [$scope.datasets]); 138 | expect($element.val()).toEqual('simple value'); 139 | expect($scope.model).toEqual('simple value'); 140 | })); 141 | it('recreates the typeahead when datasets attribute changes', 142 | inject(function($rootScope, $compile) { 143 | $scope = createScope($rootScope); 144 | $element = $compile('')($scope); 145 | $scope.$digest(); 146 | $scope.datasets = { 147 | source: function() {} 148 | }; 149 | $scope.$digest(); 150 | expect($element.hasClass('tt-input')).toBe(true); 151 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), 'destroy'); 152 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), $scope.options, [$scope.datasets]); 153 | expect($element.val()).toEqual('simple value'); 154 | expect($scope.model).toEqual('simple value'); 155 | })); 156 | it('recreates the typeahead when datasets array attribute changes', 157 | inject(function($rootScope, $compile) { 158 | $scope = createScope($rootScope); 159 | $scope.datasets = [$scope.datasets]; 160 | $element = $compile('')($scope); 161 | $scope.$digest(); 162 | $scope.datasets.push({ 163 | source: function() {} 164 | }); 165 | $scope.$digest(); 166 | expect($element.hasClass('tt-input')).toBe(true); 167 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), 'destroy'); 168 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), $scope.options, $scope.datasets); 169 | expect($element.val()).toEqual('simple value'); 170 | expect($scope.model).toEqual('simple value'); 171 | })); 172 | it('recreates the typeahead when datasets array becomes a single dataset', 173 | inject(function($rootScope, $compile) { 174 | $scope = createScope($rootScope); 175 | $scope.datasets = [$scope.datasets]; 176 | $element = $compile('')($scope); 177 | $scope.$digest(); 178 | $scope.datasets = { 179 | source: function() {} 180 | }; 181 | $scope.$digest(); 182 | expect($element.hasClass('tt-input')).toBe(true); 183 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), 'destroy'); 184 | expect($typeahead).toHaveBeenCalledWith(jasmine.anything(), $scope.options, [$scope.datasets]); 185 | expect($element.val()).toEqual('simple value'); 186 | expect($scope.model).toEqual('simple value'); 187 | })); 188 | }); 189 | describe('model', function() { 190 | var $typeahead; 191 | var $replicated; 192 | beforeEach(inject(function($rootScope, $compile) { 193 | $scope = createScope($rootScope); 194 | $element = $compile('
' + 195 | '' + 196 | '' + 197 | '
')($scope); 198 | $typeahead = $element.find('#typeahead'); 199 | $replicated = $element.find('#replicated'); 200 | $scope.$digest(); 201 | })); 202 | it('is shown on initialization', function() { 203 | $scope.$digest(); 204 | expect($typeahead.val()).toEqual('simple value'); 205 | }); 206 | it('is bound [scope -> UI]', function() { 207 | expect($typeahead.val()).toEqual('simple value'); 208 | $scope.model = 'other value'; 209 | $scope.$digest(); 210 | expect($typeahead.val()).toEqual('other value'); 211 | }); 212 | it('is bound [UI -> scope]', function() { 213 | expect($typeahead.val()).toEqual('simple value'); 214 | $typeahead.val('other value').trigger('input'); 215 | expect($scope.model).toEqual('other value'); 216 | expect($replicated.val()).toEqual('other value'); 217 | }); 218 | it('is updated when on typeahead:select', inject(function() { 219 | $typeahead.val('other value').trigger('typeahead:select', 'other value'); 220 | expect($scope.model).toEqual('other value'); 221 | expect($replicated.val()).toEqual('other value'); 222 | })); 223 | it('is updated when on typeahead:autocomplete', function() { 224 | $typeahead.val('other value').trigger('typeahead:autocomplete', 'other value'); 225 | expect($scope.model).toEqual('other value'); 226 | expect($replicated.val()).toEqual('other value'); 227 | }); 228 | }); 229 | describe('formatter', function() { 230 | it('sets simple string values', inject(function($rootScope, $compile) { 231 | $scope = createScope($rootScope); 232 | $element = $compile('')($scope); 233 | $scope.$digest(); 234 | expect($element.val()).toEqual('simple value'); 235 | expect($element.typeahead('val')).toEqual('simple value'); 236 | })); 237 | it('sets objects with key', inject(function($rootScope, $compile) { 238 | $scope = createScope($rootScope); 239 | $scope.model = { 240 | value: 'simple value' 241 | }; 242 | $element = $compile('')($scope); 243 | $scope.$digest(); 244 | expect($element.val()).toEqual('simple value'); 245 | expect($element.typeahead('val')).toEqual('simple value'); 246 | })); 247 | it('sets the empty string for falsy values', inject(function($rootScope, $compile) { 248 | $scope = createScope($rootScope); 249 | $scope.model = { 250 | value: false 251 | }; 252 | $element = $compile('')($scope); 253 | $scope.$digest(); 254 | expect($element.val()).toEqual(''); 255 | expect($element.typeahead('val')).toEqual(''); 256 | })); 257 | }); 258 | describe('allowCustom', function() { 259 | beforeEach(inject(function($rootScope, $compile) { 260 | $scope = createScope($rootScope); 261 | $scope.allowCustom = false; 262 | $element = $compile('')($scope); 263 | $scope.$digest(); 264 | })); 265 | it('forbids modification of the model from user input', function() { 266 | expect($element.val()).toEqual('simple value'); 267 | $element.val('other value').trigger('input'); 268 | expect($scope.model).toEqual('simple value'); 269 | }); 270 | it('can be changed at runtime', function() { 271 | $scope.allowCustom = true; 272 | $scope.$digest(); 273 | expect($element.val()).toEqual('simple value'); 274 | $element.val('other value').trigger('input'); 275 | expect($scope.model).toEqual('other value'); 276 | }); 277 | }); 278 | describe('displayKey', function() { 279 | it('equals "value" by default', inject(function($rootScope, $compile) { 280 | $scope = createScope($rootScope); 281 | $scope.model = { 282 | value: 'simple value' 283 | }; 284 | $element = $compile('')($scope); 285 | $scope.$digest(); 286 | expect($element.val()).toEqual('simple value'); 287 | })); 288 | it('can be a function which returns the value', inject(function($rootScope, $compile) { 289 | $scope = createScope($rootScope); 290 | $scope.model = { 291 | my_value: 'simple value' 292 | }; 293 | $scope.datasets.displayKey = function(model) { return model.my_value; }; 294 | spyOn($scope.datasets, 'displayKey').and.callThrough(); 295 | $element = $compile('')($scope); 296 | $scope.$digest(); 297 | expect($element.val()).toEqual('simple value'); 298 | expect($scope.datasets.displayKey).toHaveBeenCalledWith($scope.model); 299 | })); 300 | it('Is an empty string if the key is falsy', inject(function($rootScope, $compile) { 301 | $scope = createScope($rootScope); 302 | $scope.model = { 303 | value: null 304 | }; 305 | $element = $compile('')($scope); 306 | $scope.$digest(); 307 | expect($element.val()).toEqual(''); 308 | })); 309 | }); 310 | describe('object model', function() { 311 | var $typeahead; 312 | var $replicated; 313 | beforeEach(inject(function($rootScope, $compile) { 314 | $scope = createScope($rootScope); 315 | $scope.model = { 316 | my_value: 'simple value' 317 | }; 318 | $scope.datasets.displayKey = 'my_value'; 319 | $element = $compile('
' + 320 | '' + 321 | '' + 322 | '
' 323 | )($scope); 324 | $typeahead = $element.find('#typeahead'); 325 | $replicated = $element.find('#replicated'); 326 | $scope.$digest(); 327 | })); 328 | it('is shown on initialization', function() { 329 | $scope.$digest(); 330 | expect($typeahead.val()).toEqual('simple value'); 331 | }); 332 | it('is bound [scope -> UI]', function() { 333 | expect($typeahead.val()).toEqual('simple value'); 334 | $scope.model = { 335 | my_value: 'other value' 336 | }; 337 | $scope.$digest(); 338 | expect($typeahead.val()).toEqual('other value'); 339 | }); 340 | it('is bound [UI -> scope] and takes the string value in that case', function() { 341 | expect($typeahead.val()).toEqual('simple value'); 342 | $typeahead.val('other value').trigger('input'); 343 | expect($scope.model).toEqual('other value'); 344 | expect($replicated.val()).toEqual(''); 345 | }); 346 | it('is updated on typeahead:select', inject(function() { 347 | $typeahead.val('other value').trigger('typeahead:select', { my_value: 'other value' }); 348 | expect($scope.model).toEqual({ my_value: 'other value' }); 349 | expect($replicated.val()).toEqual('other value'); 350 | })); 351 | it('is updated on typeahead:autocomplete', function() { 352 | $typeahead.val('other value').trigger('typeahead:autocomplete', { my_value: 'other value' }); 353 | expect($scope.model).toEqual({ my_value: 'other value' }); 354 | expect($replicated.val()).toEqual('other value'); 355 | }); 356 | it('prevents unintended model updates (#63)', 357 | function() { 358 | $typeahead.val('other value').trigger('typeahead:autocomplete', { my_value: 'other value' }); 359 | expect($scope.model).toEqual({ my_value: 'other value' }); 360 | $typeahead.trigger('input'); 361 | expect($scope.model).toEqual({ my_value: 'other value' }); 362 | }); 363 | }); 364 | describe('events', function() { 365 | var handler; 366 | beforeEach(inject(function($rootScope, $compile) { 367 | handler = jasmine.createSpy('event handler'); 368 | $scope = createScope($rootScope); 369 | $element = $compile('')($scope); 370 | $scope.$digest(); 371 | })); 372 | it('forwards typeahead:active', function() { 373 | $scope.$on('typeahead:active', handler); 374 | $element.trigger('typeahead:active', 'some value'); 375 | expect(handler).toHaveBeenCalled(); 376 | }); 377 | it('forwards typeahead:idle', function() { 378 | $scope.$on('typeahead:idle', handler); 379 | $element.trigger('typeahead:idle', 'some value'); 380 | expect(handler).toHaveBeenCalled(); 381 | }); 382 | it('forwards typeahead:open', function() { 383 | $scope.$on('typeahead:open', handler); 384 | $element.trigger('typeahead:open'); 385 | expect(handler).toHaveBeenCalled(); 386 | }); 387 | it('forwards typeahead:close', function() { 388 | $scope.$on('typeahead:close', handler); 389 | $element.trigger('typeahead:close', 'some value'); 390 | expect(handler).toHaveBeenCalled(); 391 | }); 392 | it('forwards typeahead:change', function() { 393 | $scope.$on('typeahead:change', handler); 394 | $element.trigger('typeahead:change', 'some value'); 395 | expect(handler).toHaveBeenCalled(); 396 | }); 397 | it('forwards typeahead:render', function() { 398 | $scope.$on('typeahead:render', handler); 399 | $element.trigger('typeahead:render'); 400 | expect(handler).toHaveBeenCalled(); 401 | }); 402 | it('forwards typeahead:select', function() { 403 | $scope.$on('typeahead:select', handler); 404 | $element.trigger('typeahead:select', 'some value'); 405 | expect(handler).toHaveBeenCalled(); 406 | }); 407 | it('forwards typeahead:autocomplete', function() { 408 | $scope.$on('typeahead:autocomplete', handler); 409 | $element.trigger('typeahead:autocomplete', 'some value'); 410 | expect(handler).toHaveBeenCalled(); 411 | }); 412 | it('forwards typeahead:cursorchange', function() { 413 | $scope.$on('typeahead:cursorchange', handler); 414 | $element.trigger('typeahead:cursorchange'); 415 | expect(handler).toHaveBeenCalled(); 416 | }); 417 | it('forwards typeahead:asyncrequest', function() { 418 | $scope.$on('typeahead:asyncrequest', handler); 419 | $element.trigger('typeahead:asyncrequest'); 420 | expect(handler).toHaveBeenCalled(); 421 | }); 422 | it('forwards typeahead:asynccancel', function() { 423 | $scope.$on('typeahead:asynccancel', handler); 424 | $element.trigger('typeahead:asynccancel'); 425 | expect(handler).toHaveBeenCalled(); 426 | }); 427 | it('forwards typeahead:asyncreceive', function() { 428 | $scope.$on('typeahead:asyncreceive', handler); 429 | $element.trigger('typeahead:asyncreceive'); 430 | expect(handler).toHaveBeenCalled(); 431 | }); 432 | }); 433 | }); 434 | -------------------------------------------------------------------------------- /test/karma.amd.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* jshint node: true*/ 3 | var conf = require('./karma.shared.conf.js')(); 4 | 5 | conf.files = [ 6 | 'node_modules/jquery/dist/jquery.js', 7 | 'node_modules/typeahead.js/dist/typeahead.bundle.js', 8 | {pattern: 'node_modules/angular/angular.js', included: false}, 9 | {pattern: 'node_modules/angular-mocks/angular-mocks.js', included: false}, 10 | {pattern: 'build/angular-typeahead.js', included: false}, 11 | 'build/angular-typeahead.spec.js' 12 | ]; 13 | conf.frameworks.push('requirejs'); 14 | conf.files.push('test/test-amd.js'); 15 | 16 | module.exports = function(config) { 17 | conf.logLevel = config[conf.logLevel]; 18 | config.set(conf); 19 | }; 20 | -------------------------------------------------------------------------------- /test/karma.cjs.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* jshint node: true*/ 3 | var conf = require('./karma.shared.conf.js')(); 4 | 5 | conf.files = [ 6 | 'node_modules/jquery/dist/jquery.js', 7 | 'node_modules/typeahead.js/dist/typeahead.jquery.js', 8 | 'build/angular-typeahead.spec.js' 9 | ]; 10 | conf.frameworks.push('browserify'); 11 | conf.preprocessors['build/angular-typeahead.spec.js'] = [ 'browserify' ]; 12 | 13 | module.exports = function(config) { 14 | conf.logLevel = config[conf.logLevel]; 15 | config.set(conf); 16 | }; 17 | -------------------------------------------------------------------------------- /test/karma.global.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* jshint node: true*/ 3 | var conf = require('./karma.shared.conf.js')(); 4 | 5 | conf.files = [ 6 | 'node_modules/jquery/dist/jquery.js', 7 | 'node_modules/typeahead.js/dist/typeahead.jquery.js', 8 | 'node_modules/angular/angular.js', 9 | 'node_modules/angular-mocks/angular-mocks.js', 10 | 'build/angular-typeahead.spec.js', 11 | 'angular-typeahead.js' 12 | ]; 13 | conf.preprocessors['angular-typeahead.js'] = ['coverage']; 14 | conf.reporters.push('coverage', 'threshold'); 15 | 16 | conf.coverageReporter = { 17 | type : 'html', 18 | dir : 'build/coverage/' 19 | }; 20 | 21 | conf.thresholdReporter = { 22 | statements: 100, 23 | branches: 100, 24 | functions: 100, 25 | lines: 100 26 | }; 27 | 28 | module.exports = function(config) { 29 | conf.logLevel = config[conf.logLevel]; 30 | config.set(conf); 31 | }; 32 | -------------------------------------------------------------------------------- /test/karma.shared.conf.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /* jshint node:true */ 3 | module.exports = function() { 4 | return { 5 | // base path that will be used to resolve all patterns (eg. files, exclude) 6 | basePath: '..', 7 | 8 | // frameworks to use 9 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 10 | frameworks: ['jasmine'], 11 | 12 | // list of files to exclude 13 | exclude: [], 14 | 15 | files: [ ], 16 | 17 | 18 | // preprocess matching files before serving them to the browser 19 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 20 | preprocessors: { }, 21 | 22 | // test results reporter to use 23 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 24 | reporters: ['spec'], 25 | 26 | // web server port 27 | port: 9876, 28 | 29 | // enable / disable colors in the output (reporters and logs) 30 | colors: true, 31 | 32 | // level of logging 33 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 34 | logLevel: 'LOG_INFO', 35 | 36 | // enable / disable watching file and executing tests whenever any file changes 37 | autoWatch: false, 38 | 39 | // start these browsers 40 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 41 | browsers: ['Chrome'], 42 | 43 | // Continuous Integration mode 44 | // if true, Karma captures browsers, runs the tests and exits 45 | singleRun: true, 46 | 47 | // Concurrency level 48 | // how many browser should be started simultaneous 49 | concurrency: Infinity 50 | }; 51 | }; 52 | -------------------------------------------------------------------------------- /test/test-amd.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* jshint browser: true */ 3 | /* globals require: true */ 4 | var allTestFiles = []; 5 | var TEST_REGEXP = /(spec|test)\.js$/i; 6 | 7 | // Get a list of all the test files to include 8 | Object.keys(window.__karma__.files).forEach(function (file) { 9 | if (TEST_REGEXP.test(file)) { 10 | // Normalize paths to RequireJS module names. 11 | // If you require sub-dependencies of test files to be loaded as-is (requiring file extension) 12 | // then do not normalize the paths 13 | var normalizedTestModule = file.replace(/^\/base\/|\.js$/g, ''); 14 | allTestFiles.push(normalizedTestModule); 15 | } 16 | }); 17 | 18 | require.config({ 19 | // Karma serves files under /base, which is the basePath from your config file 20 | baseUrl: '/base', 21 | 22 | // dynamically load all test files 23 | deps: allTestFiles, 24 | 25 | paths: { 26 | 'angular-typeahead': 'build/angular-typeahead', 27 | 'typeahead': 'node_modules/typeahead.js/dist/typeahead.bundle', 28 | 'angular': 'node_modules/angular/angular', 29 | 'angular-mocks': 'node_modules/angular-mocks/angular-mocks', 30 | 'jquery': 'node_modules/jquery/dist/jquery' 31 | }, 32 | 33 | shim: { 34 | 'angular': { 35 | exports: 'angular' 36 | }, 37 | 'angular-mocks': { 38 | deps: [ 'angular' ] 39 | }, 40 | 'jquery': { 41 | exports: '$' 42 | } 43 | }, 44 | 45 | // we have to kickoff jasmine, as it is asynchronous 46 | callback: function() { 47 | /* globals $: true */ 48 | require.s.contexts._.registry['typeahead.js'].factory( $ ); 49 | window.__karma__.start(); 50 | } 51 | }); 52 | --------------------------------------------------------------------------------