├── .gitignore ├── Gruntfile.js ├── LICENSE ├── README.md ├── bower.json ├── dist ├── autocomplete.min.css └── autocomplete.min.js ├── example ├── basic.html ├── custom-places.html ├── force-selection.html ├── options.html └── scroll.html ├── karma.conf.js ├── package.json ├── spec ├── autocomplete_spec.js └── support │ └── jasmine.json └── src ├── autocomplete.css └── autocomplete.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | bower_components/ 4 | coverage/ 5 | node_modules/ 6 | example/lib -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /* 2 | * angular-google-places-autocomplete 3 | * 4 | * Copyright (c) 2014 "kuhnza" David Kuhn 5 | * Licensed under the MIT license. 6 | * https://github.com/kuhnza/angular-google-places-autocomplete/blob/master/LICENSE 7 | */ 8 | 9 | 'use strict'; 10 | 11 | module.exports = function (grunt) { 12 | grunt.initConfig({ 13 | jshint: { 14 | options: { 15 | reporter: require('jshint-stylish'), 16 | jasmine: true, 17 | node: true, 18 | mocha: true, 19 | predef: ['after', 'afterEach', 'angular', 'before', 'beforeEach', 20 | 'describe', 'expect', 'inject', 'it', 'jasmine', 'spyOn', 21 | 'xdescribe', 'xit'] 22 | }, 23 | files: [ 24 | 'Gruntfile.js', 25 | 'src/**/*.js', 26 | 'test/**/*.js' 27 | ] 28 | }, 29 | 30 | karma: { 31 | options: { 32 | configFile: 'karma.conf.js' 33 | }, 34 | unit: { 35 | browsers: ['PhantomJS'], 36 | reporters: ['mocha'], 37 | autoWatch: false, 38 | singleRun: true 39 | }, 40 | dev: { 41 | browsers: ['PhantomJS'], 42 | reporters: ['mocha'], 43 | autoWatch: true, 44 | singleRun: false 45 | }, 46 | release: { 47 | browsers: ['PhantomJS', 'Chrome', 'Firefox'], 48 | reporters: ['mocha'], 49 | autoWatch: false, 50 | singleRun: true 51 | }, 52 | coverage: { 53 | browsers: ['PhantomJS'], 54 | reporters: ['coverage'], 55 | autoWatch: false, 56 | singleRun: true 57 | } 58 | }, 59 | 60 | clean: { 61 | dist: { 62 | src: 'dist', 63 | dot: true 64 | }, 65 | lib: { 66 | src: 'example/lib', 67 | dot: true 68 | }, 69 | bower: { 70 | src: 'bower_components', 71 | dot: true 72 | } 73 | }, 74 | 75 | bower: { 76 | install: { 77 | options: { 78 | targetDir: 'example/lib' 79 | } 80 | } 81 | }, 82 | 83 | cssmin: { 84 | dist: { 85 | expand: true, 86 | cwd: 'dist/', 87 | files: { 88 | 'dist/autocomplete.min.css': 'src/autocomplete.css' 89 | } 90 | } 91 | 92 | }, 93 | 94 | uglify: { 95 | dist: { 96 | files: { 97 | 'dist/autocomplete.min.js': 'src/autocomplete.js' 98 | } 99 | } 100 | } 101 | }); 102 | 103 | require('load-grunt-tasks')(grunt); 104 | 105 | grunt.registerTask('default', ['build']); 106 | grunt.registerTask('test', ['karma:unit']); 107 | grunt.registerTask('build', [ 108 | 'jshint', 109 | 'clean', 110 | 'bower', 111 | 'cssmin', 112 | 'uglify' 113 | ]); 114 | }; 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 "kuhnza" David Kuhn 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **WARNING: This library is deprecated** 2 | Unfortunately I no longer have the need, time or desire to maintain this lib. That said, if anyone wishes to take it on please let me know. 3 | 4 | 5 | angular-google-places-autocomplete 6 | ================ 7 | 8 | Angular directive for the Google Places Autocomplete component. 9 | 10 | Installation 11 | ------------ 12 | 13 | Install via bower: `bower install angular-google-places-autocomplete` 14 | 15 | Or if you're old skool, copy `src/autocomplete.js` into your project. 16 | 17 | Then add the script to your page (be sure to include the Google Places API as well): 18 | 19 | ```html 20 | 21 | 22 | ``` 23 | 24 | You'll probably also want the styles: 25 | 26 | ```html 27 | 28 | ``` 29 | 30 | Usage 31 | ----- 32 | 33 | First add the dependency to your app: 34 | 35 | ```javascript 36 | angular.module('myApp', ['google.places']); 37 | ``` 38 | 39 | Then you can use the directive on text inputs like so: 40 | 41 | ```html 42 | 43 | ``` 44 | 45 | The directive also supports the following _optional_ attributes: 46 | 47 | * forceSelection — forces the user to select from the dropdown. Defaults to `false`. 48 | * options — See [google.maps.places.AutocompleteRequest object specification](https://developers.google.com/maps/documentation/javascript/reference#AutocompletionRequest). 49 | 50 | Examples 51 | -------- 52 | 53 | * [Basic](example/basic.html) 54 | * [Options](example/options.html) 55 | * [Force selection](example/force-selection.html) 56 | * [Custom Places](example/custom-places.html) 57 | 58 | Issues or feature requests 59 | -------------------------- 60 | 61 | Create a ticket [here](https://github.com/kuhnza/angular-google-places-autocomplete/issues) 62 | 63 | Contributing 64 | ------------ 65 | 66 | Issue a pull request including any relevant testing and updated any documentation if required. 67 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-google-places-autocomplete", 3 | "version": "0.2.7", 4 | "main": [ 5 | "./src/autocomplete.js", 6 | "./src/autocomplete.css" 7 | ], 8 | "dependencies": { 9 | "angular": "^1.2.x" 10 | }, 11 | "devDependencies": { 12 | "angular-mocks": "^1.2.x" 13 | }, 14 | "ignore": [] 15 | } 16 | -------------------------------------------------------------------------------- /dist/autocomplete.min.css: -------------------------------------------------------------------------------- 1 | .pac-container{background-color:#fff;position:absolute!important;z-index:1000;border-radius:2px;border-top:1px solid #d9d9d9;font-family:Arial,sans-serif;box-shadow:0 2px 6px rgba(0,0,0,.3);-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.pac-container:after{content:"";padding:1px 1px 1px 0;height:16px;text-align:right;display:block;background-image:url(//maps.gstatic.com/mapfiles/api-3/images/powered-by-google-on-white2.png);background-position:right;background-repeat:no-repeat;background-size:104px 16px}.hdpi.pac-container:after{background-image:url(//maps.gstatic.com/mapfiles/api-3/images/powered-by-google-on-white2_hdpi.png)}.pac-item{cursor:default;padding:0 4px;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;line-height:30px;text-align:left;border-top:1px solid #e6e6e6;font-size:11px;color:#999}.pac-item:hover{background-color:#fafafa}.pac-item-selected,.pac-item-selected:hover{background-color:#ebf2fe}.pac-matched{font-weight:700}.pac-item-query{font-size:13px;padding-right:3px;color:#000}.pac-icon{width:15px;height:20px;margin-right:7px;margin-top:6px;display:inline-block;vertical-align:top;background-image:url(//maps.gstatic.com/mapfiles/api-3/images/autocomplete-icons.png);background-size:34px}.hdpi .pac-icon{background-image:url(//maps.gstatic.com/mapfiles/api-3/images/autocomplete-icons_hdpi.png)}.pac-icon-search{background-position:-1px -1px}.pac-item-selected .pac-icon-search{background-position:-18px -1px}.pac-icon-marker{background-position:-1px -161px}.pac-item-selected .pac-icon-marker{background-position:-18px -161px}.pac-placeholder{color:gray}.custom-prediction-label{font-style:italic} -------------------------------------------------------------------------------- /dist/autocomplete.min.js: -------------------------------------------------------------------------------- 1 | "use strict";angular.module("google.places",[]).factory("googlePlacesApi",["$window",function(a){if(!a.google)throw"Global `google` var missing. Did you forget to include the places API script?";return a.google}]).directive("gPlacesAutocomplete",["$parse","$compile","$timeout","$document","googlePlacesApi",function(a,b,c,d,e){return{restrict:"A",require:"^ngModel",scope:{model:"=ngModel",options:"=?",forceSelection:"=?",customPlaces:"=?"},controller:["$scope",function(a){}],link:function(a,f,g,h){function i(){f.bind("keydown",l),f.bind("blur",m),f.bind("submit",m),a.$watch("selected",n)}function j(){var c,e=angular.element("
"),f=angular.element(d[0].body);e.attr({input:"input",query:"query",predictions:"predictions",active:"active",selected:"selected"}),c=b(e)(a),f.append(c),a.$on("$destroy",function(){c.remove()})}function k(){h.$parsers.push(o),h.$formatters.push(p),h.$render=q}function l(b){0!==a.predictions.length&&-1!==w(A,b.which)&&(b.preventDefault(),b.which===z.down?(a.active=(a.active+1)%a.predictions.length,a.$digest()):b.which===z.up?(a.active=(a.active?a.active:a.predictions.length)-1,a.$digest()):13===b.which||9===b.which?(a.forceSelection&&(a.active=-1===a.active?0:a.active),a.$apply(function(){a.selected=a.active,-1===a.selected&&r()})):27===b.which&&a.$apply(function(){b.stopPropagation(),r()}))}function m(b){0!==a.predictions.length&&(a.forceSelection&&(a.selected=-1===a.selected?0:a.selected),a.$digest(),a.$apply(function(){-1===a.selected&&r()}))}function n(){var b;b=a.predictions[a.selected],b&&(b.is_custom?a.$apply(function(){a.model=b.place,a.$emit("g-places-autocomplete:select",b.place),c(function(){h.$viewChangeListeners.forEach(function(a){a()})})}):C.getDetails({placeId:b.place_id},function(b,d){d==e.maps.places.PlacesServiceStatus.OK&&a.$apply(function(){a.model=b,a.$emit("g-places-autocomplete:select",b),c(function(){h.$viewChangeListeners.forEach(function(a){a()})})})}),r())}function o(b){var c;return b&&u(b)?(a.query=b,c=angular.extend({input:b},a.options),B.getPlacePredictions(c,function(b,c){a.$apply(function(){var d;r(),a.customPlaces&&(d=s(a.query),a.predictions.push.apply(a.predictions,d)),c==e.maps.places.PlacesServiceStatus.OK&&a.predictions.push.apply(a.predictions,b),a.predictions.length>5&&(a.predictions.length=5)})}),a.forceSelection?h.$modelValue:b):b}function p(a){var b="";return u(a)?b=a:v(a)&&(b=a.formatted_address),b}function q(){return f.val(h.$viewValue)}function r(){a.active=-1,a.selected=-1,a.predictions=[]}function s(b){var c,d,e,f=[];for(e=0;e0&&f.push({is_custom:!0,custom_prediction_label:c.custom_prediction_label||"(Custom Non-Google Result)",description:c.formatted_address,place:c,matched_substrings:d.matched_substrings,terms:d.terms});return f}function t(a,b){var c,d,e,f=a+"",g=[],h=[];for(d=b.formatted_address.split(","),e=0;e0&&(c.length>=f.length?(x(c,f)&&h.push({length:f.length,offset:e}),f=""):x(f,c)?(h.push({length:c.length,offset:e}),f=f.replace(c,"").trim()):f=""),g.push({value:c,offset:b.formatted_address.indexOf(c)});return{matched_substrings:h,terms:g}}function u(a){return"[object String]"==Object.prototype.toString.call(a)}function v(a){return"[object Object]"==Object.prototype.toString.call(a)}function w(a,b){var c,d;if(null==a)return-1;for(d=a.length,c=0;d>c;c++)if(a[c]===b)return c;return-1}function x(a,b){return 0===y(a).lastIndexOf(y(b),0)}function y(a){return null==a?"":a.toLowerCase()}var z={tab:9,enter:13,esc:27,up:38,down:40},A=[z.tab,z.enter,z.esc,z.up,z.down],B=new e.maps.places.AutocompleteService,C=new e.maps.places.PlacesService(f[0]);!function(){a.query="",a.predictions=[],a.input=f,a.options=a.options||{},j(),i(),k()}()}}}]).directive("gPlacesAutocompleteDrawer",["$window","$document",function(a,b){var c=['
','
',"
","
"];return{restrict:"A",scope:{input:"=",query:"=",predictions:"=",active:"=",selected:"="},template:c.join(""),link:function(c,d){function e(c){var d=c[0],e=d.getBoundingClientRect(),f=b[0].documentElement,g=b[0].body,h=a.pageYOffset||f.scrollTop||g.scrollTop,i=a.pageXOffset||f.scrollLeft||g.scrollLeft;return{width:e.width,height:e.height,top:e.top+e.height+h,left:e.left+i}}d.bind("mousedown",function(a){a.preventDefault()}),a.onresize=function(){c.$apply(function(){c.position=e(c.input)})},c.isOpen=function(){return c.predictions.length>0},c.isActive=function(a){return c.active===a},c.selectActive=function(a){c.active=a},c.selectPrediction=function(a){c.selected=a},c.$watch("predictions",function(){c.position=e(c.input)},!0)}}}]).directive("gPlacesAutocompletePrediction",[function(){var a=['','','{{term.value | trailingComma:!$last}} ',' {{prediction.custom_prediction_label}}'];return{restrict:"A",scope:{index:"=",prediction:"=",query:"="},template:a.join("")}}]).filter("highlightMatched",["$sce",function(a){return function(b){var c,d="",e="";return b.matched_substrings.length>0&&b.terms.length>0&&(c=b.matched_substrings[0],d=b.terms[0].value.substr(c.offset,c.length),e=b.terms[0].value.substr(c.offset+c.length)),a.trustAsHtml(''+d+""+e)}}]).filter("unmatchedTermsOnly",[function(){return function(a,b){var c,d,e=[];for(c=0;c0&&d.offset>b.matched_substrings[0].length&&e.push(d);return e}}]).filter("trailingComma",[function(){return function(a,b){return b?a+",":a}}]); -------------------------------------------------------------------------------- /example/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Basic Usage 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 27 | 28 | 29 |
30 |
31 |
32 |

Basic Usage

33 | 34 |
35 | 36 |
37 | 38 |
Result:
39 |
{{place | json}}
40 |
41 |
42 |
43 | 44 | -------------------------------------------------------------------------------- /example/custom-places.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Injecting Custom Place Predictions 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 84 | 85 | 86 |
87 |
88 |
89 |

Injecting Custom Place Predictions

90 | 91 |

92 | Three custom results are injected into the Place Predictions. Try searching for "International Airport" 93 | or "Domestic Airport" to see them in action. 94 |

95 | 96 |

97 | 98 | Custom places appear with a label after them as required by the 99 | Google Places API terms. This label can be 100 | overridden by putting a custom_prediction_label on your custom place results. The label can 101 | also be styled via the .custom-prediction-label class. 102 |

103 | 104 |
105 | 106 |
107 | 108 |
Result:
109 |
{{place | json}}
110 |
111 |
112 |
113 | 114 | -------------------------------------------------------------------------------- /example/force-selection.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Forcing selection 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 27 | 28 | 29 |
30 |
31 |
32 |

Forcing selection

33 | 34 |
35 | 36 |
37 | 38 |
Result:
39 |
{{place | json}}
40 |
41 |
42 |
43 | 44 | -------------------------------------------------------------------------------- /example/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Usage with Options 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 32 | 33 | 34 |
35 |
36 |
37 |

Usage with Options

38 | 39 |
40 | 41 |
42 | 43 |
Result:
44 |
{{place | json}}
45 |
46 |
47 |
48 | 49 | -------------------------------------------------------------------------------- /example/scroll.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Basic Usage 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 27 | 28 | 29 |
30 |
31 |
32 |
33 |

Test autocomplete results with scroll

34 | 35 |
36 | 37 |
38 | 39 |
Result:
40 |
{{place | json}}
41 |
42 |
43 |
44 |
45 | 46 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: '', 4 | frameworks: ['jasmine'], 5 | files: [ 6 | 'bower_components/angular/angular.js', 7 | 'bower_components/angular-mocks/angular-mocks.js', 8 | 'src/**/*.js', 9 | 'spec/**/*.js' 10 | ], 11 | exclude: [], 12 | preprocessors: { 13 | 'src/**/*.js': 'coverage' 14 | }, 15 | port: 9876, 16 | colors: true, 17 | logLevel: config.LOG_INFO, 18 | coverageReporter: { 19 | dir: 'spec/coverage/', 20 | includeAllSources: true, 21 | reporters: [ 22 | { type: 'html', subdir: '.'}, 23 | { type: 'json', subdir: '.', file: 'coverage.json' } 24 | ] 25 | } 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-google-places-autocomplete", 3 | "version": "0.2.7", 4 | "repository": { 5 | "type": "git", 6 | "url": "http://github.com/kuhnza/angular-google-places-autocomplete.git" 7 | }, 8 | "dependencies": { 9 | "grunt-contrib-cssmin": "^0.10.0" 10 | }, 11 | "devDependencies": { 12 | "grunt": "~0.4.1", 13 | "grunt-bower-task": "^0.4.0", 14 | "grunt-contrib-clean": "^0.5.0", 15 | "grunt-contrib-jshint": "^0.11.2", 16 | "grunt-contrib-uglify": "~0.4.0", 17 | "grunt-karma": "~0.11.0", 18 | "jshint-stylish": "^2.0.1", 19 | "karma": "0.12.0", 20 | "karma-chrome-launcher": "~0.1.2", 21 | "karma-coverage": "^0.5.0", 22 | "karma-firefox-launcher": "~0.1.3", 23 | "karma-jasmine": "~0.2.1", 24 | "karma-mocha-reporter": "^1.1.1", 25 | "karma-phantomjs-launcher": "~0.1.1", 26 | "load-grunt-tasks": "0.4.0" 27 | }, 28 | "engines": { 29 | "node": ">=0.10.0" 30 | }, 31 | "scripts": { 32 | "test": "grunt test" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /spec/autocomplete_spec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * angular-google-places-autocomplete 3 | * 4 | * Copyright (c) 2014 "kuhnza" David Kuhn 5 | * Licensed under the MIT license. 6 | * https://github.com/kuhnza/angular-google-places-autocomplete/blob/master/LICENSE 7 | */ 8 | 9 | "use strict"; 10 | 11 | // Sample set of AutocompleteService predictions 12 | var PREDICTIONS = [ 13 | { 14 | "description": "18 Elizabeth Street, Waterloo, New South Wales, Australia", 15 | "id": "7ff2649b9154e8649a2516c95aa8cf6cc93a813f", 16 | "matched_substrings": [ 17 | { 18 | "length": 4, 19 | "offset": 0 20 | } 21 | ], 22 | "place_id": "ChIJTV3MUSCuEmsRmsdcXOWqjqsSOTE4IEVsaXphYmV0aCBTdHJlZXQsIFdhdGVybG9vLCBOZXcgU291dGggV2FsZXMsIEF1c3RyYWxpYQ", 23 | "reference": "CkQ9AAAAy3q7MwWKLBbOECg7_AABUyvfIA_UagPLlfKtRvB-Lb5JiTr_HQ-h_eqJ6kFgKTpkakpVxsnbe-qDa_g7Ot3zUhIQG3deNdBjROdh1GOclW50bBoUCuBA9EPr3XZO9WSsjqJgGzpPXqA", 24 | "terms": [ 25 | { 26 | "offset": 0, 27 | "value": "18 Elizabeth Street" 28 | }, 29 | { 30 | "offset": 21, 31 | "value": "Waterloo" 32 | }, 33 | { 34 | "offset": 31, 35 | "value": "New South Wales" 36 | }, 37 | { 38 | "offset": 48, 39 | "value": "Australia" 40 | } 41 | ], 42 | "types": [ "route", "geocode" ] 43 | }, 44 | { 45 | "description": "18 Enmore Road, Newtown, New South Wales, Australia", 46 | "id": "565bbf7706c54cce1f892cbafefc6536a8511401", 47 | "matched_substrings": [ 48 | { 49 | "length": 4, 50 | "offset": 0 51 | } 52 | ], 53 | "place_id": "ChIJBXTUmUewEmsRLURHiC6_kPESMzE4IEVubW9yZSBSb2FkLCBOZXd0b3duLCBOZXcgU291dGggV2FsZXMsIEF1c3RyYWxpYQ", 54 | "reference": "CkQ3AAAAGQ7efGL9QoxL-F6acCMBSflEN6a0x1ZGJJo5vJcZ0IHjVZVN3O4NUe2r1EQBJeolHTIj1I4A9f_NKlrB6-uNMRIQNJ93FN3UnjdZXe_MyREqdhoUH7lpHAddLM9XD4K08jh3e9KYuGk", 55 | "terms": [ 56 | { 57 | "offset": 0, 58 | "value": "18 Enmore Road" 59 | }, 60 | { 61 | "offset": 16, 62 | "value": "Newtown" 63 | }, 64 | { 65 | "offset": 25, 66 | "value": "New South Wales" 67 | }, 68 | { 69 | "offset": 42, 70 | "value": "Australia" 71 | } 72 | ], 73 | "types": [ "route", "geocode" ] 74 | }, 75 | { 76 | "description": "18 Edgecliff Road, Woollahra, New South Wales, Australia", 77 | "id": "693a0891e53897fe8927d83b673d173ed34ac6c1", 78 | "matched_substrings": [ 79 | { 80 | "length": 4, 81 | "offset": 0 82 | } 83 | ], 84 | "place_id": "ChIJe7rePu-tEmsRUwKubnjKGYUSODE4IEVkZ2VjbGlmZiBSb2FkLCBXb29sbGFocmEsIE5ldyBTb3V0aCBXYWxlcywgQXVzdHJhbGlh", 85 | "reference": "CkQ8AAAAPpSTyRB1rC-zSKLViiDvWaXdXuBOcCDAXhhlF0-STTrGifuUm3ziduX-H8zye8pTMIbvJi-e4JOq5lOadBBiwRIQmeOe9clMU26I1MH26B2LgxoUiGzILgMIhpNZqF50MpDcmYK0D0Q", 86 | "terms": [ 87 | { 88 | "offset": 0, 89 | "value": "18 Edgecliff Road" 90 | }, 91 | { 92 | "offset": 19, 93 | "value": "Woollahra" 94 | }, 95 | { 96 | "offset": 30, 97 | "value": "New South Wales" 98 | }, 99 | { 100 | "offset": 47, 101 | "value": "Australia" 102 | } 103 | ], 104 | "types": [ "route", "geocode" ] 105 | }, 106 | { 107 | "description": "18 Euston Road, Alexandria, New South Wales, Australia", 108 | "id": "01cfa48f0b27e93394f347a39b80412287e0a013", 109 | "matched_substrings": [ 110 | { 111 | "length": 4, 112 | "offset": 0 113 | } 114 | ], 115 | "place_id": "ChIJ52QqfbOxEmsR_wTF_yJgirISNjE4IEV1c3RvbiBSb2FkLCBBbGV4YW5kcmlhLCBOZXcgU291dGggV2FsZXMsIEF1c3RyYWxpYQ", 116 | "reference": "CkQ6AAAAxGKnSZMdZc9kOfUmzUaTf70zXn78P4J9oZCr06YFeAgxV-Y2ulX97fwb6Al4rJzASKaADCzgQiRNFOSuTcS1txIQjXtWiEthGmN-ZM7G2nMu6RoU_ItmcK4Cm59POh3fHhi6e75_Z_c", 117 | "terms": [ 118 | { 119 | "offset": 0, 120 | "value": "18 Euston Road" 121 | }, 122 | { 123 | "offset": 16, 124 | "value": "Alexandria" 125 | }, 126 | { 127 | "offset": 28, 128 | "value": "New South Wales" 129 | }, 130 | { 131 | "offset": 45, 132 | "value": "Australia" 133 | } 134 | ], 135 | "types": [ "route", "geocode" ] 136 | }, 137 | { 138 | "description": "18 Erskine Street, Sydney, New South Wales, Australia", 139 | "id": "69ebe34986ae44b13267116f8c4107d190ff2f01", 140 | "matched_substrings": [ 141 | { 142 | "length": 4, 143 | "offset": 0 144 | } 145 | ], 146 | "place_id": "ChIJ8dg8UEeuEmsRvdt4xsQoZUMSNTE4IEVyc2tpbmUgU3RyZWV0LCBTeWRuZXksIE5ldyBTb3V0aCBXYWxlcywgQXVzdHJhbGlh", 147 | "reference": "CkQ5AAAAWpXzWfHYj6pkpo62wsJYt-3Xyc6hXWQDQSFUZO7rSaUOF_7eyuDf03v1EwSEn8c6O-UtCyKbAzwPxRZeRX-HtRIQvh7iV2yFT2Wr1hMUgIoqfRoUW9VDCDS_bMk8Yhvg1_LVxRIctxA", 148 | "terms": [ 149 | { 150 | "offset": 0, 151 | "value": "18 Erskine Street" 152 | }, 153 | { 154 | "offset": 19, 155 | "value": "Sydney" 156 | }, 157 | { 158 | "offset": 27, 159 | "value": "New South Wales" 160 | }, 161 | { 162 | "offset": 44, 163 | "value": "Australia" 164 | } 165 | ], 166 | "types": [ "route", "geocode" ] 167 | } 168 | ]; 169 | 170 | describe('Factory: googlePlacesApi', function () { 171 | var googlePlacesApi; 172 | 173 | beforeEach(module('google.places')); 174 | beforeEach(inject(function (_$window_, _googlePlacesApi_) { 175 | googlePlacesApi = _googlePlacesApi_; 176 | })); 177 | 178 | it('should load', function () { 179 | expect(googlePlacesApi).toBeDefined(); 180 | }); 181 | }); 182 | 183 | describe('Directive: gPlacesAutocomplete', function () { 184 | var $parentScope, $isolatedScope, $compile, googlePlacesApi; 185 | 186 | function compileAndDigest(html) { 187 | var element = angular.element(html); 188 | $compile(element)($parentScope); 189 | $parentScope.$digest(); 190 | $isolatedScope = element.isolateScope(); 191 | } 192 | 193 | beforeEach(module('google.places')); 194 | beforeEach(inject(function ($rootScope, _$compile_) { 195 | $parentScope = $rootScope.$new(); 196 | $compile = _$compile_; 197 | $parentScope.place = null; 198 | compileAndDigest(''); 199 | })); 200 | 201 | // TODO: write more tests! 202 | it('should initialize model', function () { 203 | }); 204 | }); 205 | 206 | describe('Directive: gPlacesAutocompleteDrawer', function () { 207 | var $parentScope, $isolatedScope, $compile, element; 208 | var template = '
'; 209 | 210 | function compileAndDigest(html) { 211 | element = angular.element(html); 212 | $compile(element)($parentScope); 213 | $parentScope.$digest(); 214 | $isolatedScope = element.isolateScope(); 215 | } 216 | 217 | beforeEach(module('google.places')); 218 | beforeEach(inject(function ($rootScope, _$compile_) { 219 | $parentScope = $rootScope.$new(); 220 | $compile = _$compile_; 221 | $parentScope.input = angular.element(''); 222 | $parentScope.query = ''; 223 | $parentScope.predictions = []; 224 | })); 225 | 226 | describe('when there are no predictions', function () { 227 | beforeEach(function () { 228 | compileAndDigest(template); 229 | }); 230 | 231 | it('should close drawer', function () { 232 | expect($isolatedScope.isOpen()).toBe(false); 233 | }); 234 | }); 235 | 236 | describe('when there are predictions', function () { 237 | var predictionElements; 238 | 239 | beforeEach(function () { 240 | $parentScope.predictions = angular.copy(PREDICTIONS); 241 | compileAndDigest(template); 242 | predictionElements = element.children().children(); 243 | }); 244 | 245 | it('should open drawer', function () { 246 | expect($isolatedScope.isOpen()).toBe(true); 247 | }); 248 | 249 | it('should select the active prediction when hovering', function () { 250 | var activeElement = angular.element(predictionElements['1']); 251 | activeElement.triggerHandler('mouseenter'); 252 | 253 | expect($isolatedScope.active).toBe(1); 254 | expect($isolatedScope.isActive(1)).toBe(true); 255 | expect($isolatedScope.isActive(0)).toBe(false); 256 | }); 257 | 258 | it('should select the prediction on click', function () { 259 | var activeElement = angular.element(predictionElements['2']); 260 | activeElement.triggerHandler('click'); 261 | 262 | expect($isolatedScope.selected).toBe(2); 263 | }); 264 | 265 | it('should set the drawer position', function () { 266 | expect($isolatedScope.position).toBeDefined(); 267 | }); 268 | }); 269 | }); 270 | 271 | describe('Directive: gPlacesAutocompletePrediction', function () { 272 | var $parentScope, $isolatedScope, $compile; 273 | 274 | function compileAndDigest(html) { 275 | var element = angular.element(html); 276 | $compile(element)($parentScope); 277 | $parentScope.$digest(); 278 | $isolatedScope = element.isolateScope(); 279 | } 280 | 281 | beforeEach(module('google.places')); 282 | 283 | beforeEach(inject(function ($rootScope, _$compile_) { 284 | $parentScope = $rootScope.$new(); 285 | $compile = _$compile_; 286 | $parentScope.$index = 0; 287 | $parentScope.prediction = angular.copy(PREDICTIONS[0]); 288 | $parentScope.query = '18'; 289 | compileAndDigest('
'); 290 | })); 291 | 292 | // TODO: write more tests! 293 | it('should initialize model', function () { 294 | }); 295 | }); 296 | 297 | describe('Filter: unmatchedTermsOnly', function () { 298 | var unmatchedTermsOnlyFilter; 299 | 300 | beforeEach(module('google.places')); 301 | beforeEach(inject(function (_unmatchedTermsOnlyFilter_) { 302 | unmatchedTermsOnlyFilter = _unmatchedTermsOnlyFilter_; 303 | })); 304 | 305 | it('should only return unmatched terms for a prediction', function () { 306 | var prediction = angular.copy(PREDICTIONS[0]); 307 | var result = unmatchedTermsOnlyFilter(prediction.terms, prediction); 308 | 309 | expect(result).toEqual([ 310 | { "offset": 21, "value": "Waterloo" }, 311 | { "offset": 31, "value": "New South Wales" }, 312 | { "offset": 48, "value": "Australia" } 313 | ]); 314 | }); 315 | }); 316 | 317 | describe('Filter: trailingComma', function () { 318 | var trailingCommaFilter; 319 | 320 | beforeEach(module('google.places')); 321 | beforeEach(inject(function (_trailingCommaFilter_) { 322 | trailingCommaFilter = _trailingCommaFilter_; 323 | })); 324 | 325 | it('should append a trailing comma if condition is true', function () { 326 | var result = trailingCommaFilter('a string', true); 327 | 328 | expect(result).toEqual('a string,'); 329 | }); 330 | 331 | it('should omit the trailing comma if condition is false', function () { 332 | var result = trailingCommaFilter('a string', false); 333 | 334 | expect(result).toEqual('a string'); 335 | }); 336 | }); 337 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/autocomplete.css: -------------------------------------------------------------------------------- 1 | /* 2 | * These are the default styles applied to the stock Google Places Autocomplete component. Importantly they preserve 3 | * the required "powered by Google" logo. 4 | */ 5 | 6 | .pac-container { 7 | background-color: #fff; 8 | position: absolute !important; 9 | z-index: 1000; 10 | border-radius: 2px; 11 | border-top: 1px solid #d9d9d9; 12 | font-family: Arial, sans-serif; 13 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); 14 | -moz-box-sizing: border-box; 15 | -webkit-box-sizing: border-box; 16 | box-sizing: border-box; 17 | overflow: hidden 18 | } 19 | 20 | .pac-container:after { 21 | content: ""; 22 | padding: 1px 1px 1px 0; 23 | height: 16px; 24 | text-align: right; 25 | display: block; 26 | background-image: url(//maps.gstatic.com/mapfiles/api-3/images/powered-by-google-on-white2.png); 27 | background-position: right; 28 | background-repeat: no-repeat; 29 | background-size: 104px 16px 30 | } 31 | 32 | .hdpi.pac-container:after { 33 | background-image: url(//maps.gstatic.com/mapfiles/api-3/images/powered-by-google-on-white2_hdpi.png) 34 | } 35 | 36 | .pac-item { 37 | cursor: default; 38 | padding: 0 4px; 39 | text-overflow: ellipsis; 40 | overflow: hidden; 41 | white-space: nowrap; 42 | line-height: 30px; 43 | text-align: left; 44 | border-top: 1px solid #e6e6e6; 45 | font-size: 11px; 46 | color: #999 47 | } 48 | 49 | .pac-item:hover { 50 | background-color: #fafafa 51 | } 52 | 53 | .pac-item-selected, .pac-item-selected:hover { 54 | background-color: #ebf2fe 55 | } 56 | 57 | .pac-matched { 58 | font-weight: 700 59 | } 60 | 61 | .pac-item-query { 62 | font-size: 13px; 63 | padding-right: 3px; 64 | color: #000 65 | } 66 | 67 | .pac-icon { 68 | width: 15px; 69 | height: 20px; 70 | margin-right: 7px; 71 | margin-top: 6px; 72 | display: inline-block; 73 | vertical-align: top; 74 | background-image: url(//maps.gstatic.com/mapfiles/api-3/images/autocomplete-icons.png); 75 | background-size: 34px 76 | } 77 | 78 | .hdpi .pac-icon { 79 | background-image: url(//maps.gstatic.com/mapfiles/api-3/images/autocomplete-icons_hdpi.png) 80 | } 81 | 82 | .pac-icon-search { 83 | background-position: -1px -1px 84 | } 85 | 86 | .pac-item-selected .pac-icon-search { 87 | background-position: -18px -1px 88 | } 89 | 90 | .pac-icon-marker { 91 | background-position: -1px -161px 92 | } 93 | 94 | .pac-item-selected .pac-icon-marker { 95 | background-position: -18px -161px 96 | } 97 | 98 | .pac-placeholder { 99 | color: gray 100 | } 101 | 102 | .custom-prediction-label { 103 | font-style: italic; 104 | } -------------------------------------------------------------------------------- /src/autocomplete.js: -------------------------------------------------------------------------------- 1 | /* 2 | * angular-google-places-autocomplete 3 | * 4 | * Copyright (c) 2014 "kuhnza" David Kuhn 5 | * Licensed under the MIT license. 6 | * https://github.com/kuhnza/angular-google-places-autocomplete/blob/master/LICENSE 7 | */ 8 | 9 | 'use strict'; 10 | 11 | angular.module('google.places', []) 12 | /** 13 | * DI wrapper around global google places library. 14 | * 15 | * Note: requires the Google Places API to already be loaded on the page. 16 | */ 17 | .factory('googlePlacesApi', ['$window', function ($window) { 18 | if (!$window.google) throw 'Global `google` var missing. Did you forget to include the places API script?'; 19 | 20 | return $window.google; 21 | }]) 22 | 23 | /** 24 | * Autocomplete directive. Use like this: 25 | * 26 | * 27 | */ 28 | .directive('gPlacesAutocomplete', 29 | [ '$parse', '$compile', '$timeout', '$document', 'googlePlacesApi', 30 | function ($parse, $compile, $timeout, $document, google) { 31 | 32 | return { 33 | restrict: 'A', 34 | require: '^ngModel', 35 | scope: { 36 | model: '=ngModel', 37 | options: '=?', 38 | forceSelection: '=?', 39 | customPlaces: '=?' 40 | }, 41 | controller: ['$scope', function ($scope) {}], 42 | link: function ($scope, element, attrs, controller) { 43 | var keymap = { 44 | tab: 9, 45 | enter: 13, 46 | esc: 27, 47 | up: 38, 48 | down: 40 49 | }, 50 | hotkeys = [keymap.tab, keymap.enter, keymap.esc, keymap.up, keymap.down], 51 | autocompleteService = new google.maps.places.AutocompleteService(), 52 | placesService = new google.maps.places.PlacesService(element[0]); 53 | 54 | (function init() { 55 | $scope.query = ''; 56 | $scope.predictions = []; 57 | $scope.input = element; 58 | $scope.options = $scope.options || {}; 59 | 60 | initAutocompleteDrawer(); 61 | initEvents(); 62 | initNgModelController(); 63 | }()); 64 | 65 | function initEvents() { 66 | element.bind('keydown', onKeydown); 67 | element.bind('blur', onBlur); 68 | element.bind('submit', onBlur); 69 | 70 | $scope.$watch('selected', select); 71 | } 72 | 73 | function initAutocompleteDrawer() { 74 | // Drawer element used to display predictions 75 | var drawerElement = angular.element('
'), 76 | body = angular.element($document[0].body), 77 | $drawer; 78 | 79 | drawerElement.attr({ 80 | input: 'input', 81 | query: 'query', 82 | predictions: 'predictions', 83 | active: 'active', 84 | selected: 'selected' 85 | }); 86 | 87 | $drawer = $compile(drawerElement)($scope); 88 | body.append($drawer); // Append to DOM 89 | 90 | $scope.$on('$destroy', function() { 91 | $drawer.remove(); 92 | }); 93 | } 94 | 95 | function initNgModelController() { 96 | controller.$parsers.push(parse); 97 | controller.$formatters.push(format); 98 | controller.$render = render; 99 | } 100 | 101 | function onKeydown(event) { 102 | if ($scope.predictions.length === 0 || indexOf(hotkeys, event.which) === -1) { 103 | return; 104 | } 105 | 106 | event.preventDefault(); 107 | 108 | if (event.which === keymap.down) { 109 | $scope.active = ($scope.active + 1) % $scope.predictions.length; 110 | $scope.$digest(); 111 | } else if (event.which === keymap.up) { 112 | $scope.active = ($scope.active ? $scope.active : $scope.predictions.length) - 1; 113 | $scope.$digest(); 114 | } else if (event.which === 13 || event.which === 9) { 115 | if ($scope.forceSelection) { 116 | $scope.active = ($scope.active === -1) ? 0 : $scope.active; 117 | } 118 | 119 | $scope.$apply(function () { 120 | $scope.selected = $scope.active; 121 | 122 | if ($scope.selected === -1) { 123 | clearPredictions(); 124 | } 125 | }); 126 | } else if (event.which === 27) { 127 | $scope.$apply(function () { 128 | event.stopPropagation(); 129 | clearPredictions(); 130 | }); 131 | } 132 | } 133 | 134 | function onBlur(event) { 135 | if ($scope.predictions.length === 0) { 136 | return; 137 | } 138 | 139 | if ($scope.forceSelection) { 140 | $scope.selected = ($scope.selected === -1) ? 0 : $scope.selected; 141 | } 142 | 143 | $scope.$digest(); 144 | 145 | $scope.$apply(function () { 146 | if ($scope.selected === -1) { 147 | clearPredictions(); 148 | } 149 | }); 150 | } 151 | 152 | function select() { 153 | var prediction; 154 | 155 | prediction = $scope.predictions[$scope.selected]; 156 | if (!prediction) return; 157 | 158 | if (prediction.is_custom) { 159 | $scope.$apply(function () { 160 | $scope.model = prediction.place; 161 | $scope.$emit('g-places-autocomplete:select', prediction.place); 162 | $timeout(function () { 163 | controller.$viewChangeListeners.forEach(function (fn) { fn(); }); 164 | }); 165 | }); 166 | } else { 167 | placesService.getDetails({ placeId: prediction.place_id }, function (place, status) { 168 | if (status == google.maps.places.PlacesServiceStatus.OK) { 169 | $scope.$apply(function () { 170 | $scope.model = place; 171 | $scope.$emit('g-places-autocomplete:select', place); 172 | $timeout(function () { 173 | controller.$viewChangeListeners.forEach(function (fn) { fn(); }); 174 | }); 175 | }); 176 | } 177 | }); 178 | } 179 | 180 | clearPredictions(); 181 | } 182 | 183 | function parse(viewValue) { 184 | var request; 185 | 186 | if (!(viewValue && isString(viewValue))) return viewValue; 187 | 188 | $scope.query = viewValue; 189 | 190 | request = angular.extend({ input: viewValue }, $scope.options); 191 | autocompleteService.getPlacePredictions(request, function (predictions, status) { 192 | $scope.$apply(function () { 193 | var customPlacePredictions; 194 | 195 | clearPredictions(); 196 | 197 | if ($scope.customPlaces) { 198 | customPlacePredictions = getCustomPlacePredictions($scope.query); 199 | $scope.predictions.push.apply($scope.predictions, customPlacePredictions); 200 | } 201 | 202 | if (status == google.maps.places.PlacesServiceStatus.OK) { 203 | $scope.predictions.push.apply($scope.predictions, predictions); 204 | } 205 | 206 | if ($scope.predictions.length > 5) { 207 | $scope.predictions.length = 5; // trim predictions down to size 208 | } 209 | }); 210 | }); 211 | 212 | if ($scope.forceSelection) { 213 | return controller.$modelValue; 214 | } else { 215 | return viewValue; 216 | } 217 | } 218 | 219 | function format(modelValue) { 220 | var viewValue = ""; 221 | 222 | if (isString(modelValue)) { 223 | viewValue = modelValue; 224 | } else if (isObject(modelValue)) { 225 | viewValue = modelValue.formatted_address; 226 | } 227 | 228 | return viewValue; 229 | } 230 | 231 | function render() { 232 | return element.val(controller.$viewValue); 233 | } 234 | 235 | function clearPredictions() { 236 | $scope.active = -1; 237 | $scope.selected = -1; 238 | $scope.predictions = []; 239 | } 240 | 241 | function getCustomPlacePredictions(query) { 242 | var predictions = [], 243 | place, match, i; 244 | 245 | for (i = 0; i < $scope.customPlaces.length; i++) { 246 | place = $scope.customPlaces[i]; 247 | 248 | match = getCustomPlaceMatches(query, place); 249 | if (match.matched_substrings.length > 0) { 250 | predictions.push({ 251 | is_custom: true, 252 | custom_prediction_label: place.custom_prediction_label || '(Custom Non-Google Result)', // required by https://developers.google.com/maps/terms § 10.1.1 (d) 253 | description: place.formatted_address, 254 | place: place, 255 | matched_substrings: match.matched_substrings, 256 | terms: match.terms 257 | }); 258 | } 259 | } 260 | 261 | return predictions; 262 | } 263 | 264 | function getCustomPlaceMatches(query, place) { 265 | var q = query + '', // make a copy so we don't interfere with subsequent matches 266 | terms = [], 267 | matched_substrings = [], 268 | fragment, 269 | termFragments, 270 | i; 271 | 272 | termFragments = place.formatted_address.split(','); 273 | for (i = 0; i < termFragments.length; i++) { 274 | fragment = termFragments[i].trim(); 275 | 276 | if (q.length > 0) { 277 | if (fragment.length >= q.length) { 278 | if (startsWith(fragment, q)) { 279 | matched_substrings.push({ length: q.length, offset: i }); 280 | } 281 | q = ''; // no more matching to do 282 | } else { 283 | if (startsWith(q, fragment)) { 284 | matched_substrings.push({ length: fragment.length, offset: i }); 285 | q = q.replace(fragment, '').trim(); 286 | } else { 287 | q = ''; // no more matching to do 288 | } 289 | } 290 | } 291 | 292 | terms.push({ 293 | value: fragment, 294 | offset: place.formatted_address.indexOf(fragment) 295 | }); 296 | } 297 | 298 | return { 299 | matched_substrings: matched_substrings, 300 | terms: terms 301 | }; 302 | } 303 | 304 | function isString(val) { 305 | return Object.prototype.toString.call(val) == '[object String]'; 306 | } 307 | 308 | function isObject(val) { 309 | return Object.prototype.toString.call(val) == '[object Object]'; 310 | } 311 | 312 | function indexOf(array, item) { 313 | var i, length; 314 | 315 | if (array === null) return -1; 316 | 317 | length = array.length; 318 | for (i = 0; i < length; i++) { 319 | if (array[i] === item) return i; 320 | } 321 | return -1; 322 | } 323 | 324 | function startsWith(string1, string2) { 325 | return toLower(string1).lastIndexOf(toLower(string2), 0) === 0; 326 | } 327 | 328 | function toLower(string) { 329 | return (string === null) ? "" : string.toLowerCase(); 330 | } 331 | } 332 | }; 333 | } 334 | ]) 335 | 336 | 337 | .directive('gPlacesAutocompleteDrawer', ['$window', '$document', function ($window, $document) { 338 | var TEMPLATE = [ 339 | '
', 340 | '
', 343 | '
', 344 | '
' 345 | ]; 346 | 347 | return { 348 | restrict: 'A', 349 | scope:{ 350 | input: '=', 351 | query: '=', 352 | predictions: '=', 353 | active: '=', 354 | selected: '=' 355 | }, 356 | template: TEMPLATE.join(''), 357 | link: function ($scope, element) { 358 | element.bind('mousedown', function (event) { 359 | event.preventDefault(); // prevent blur event from firing when clicking selection 360 | }); 361 | 362 | $window.onresize = function () { 363 | $scope.$apply(function () { 364 | $scope.position = getDrawerPosition($scope.input); 365 | }); 366 | }; 367 | 368 | $scope.isOpen = function () { 369 | return $scope.predictions.length > 0; 370 | }; 371 | 372 | $scope.isActive = function (index) { 373 | return $scope.active === index; 374 | }; 375 | 376 | $scope.selectActive = function (index) { 377 | $scope.active = index; 378 | }; 379 | 380 | $scope.selectPrediction = function (index) { 381 | $scope.selected = index; 382 | }; 383 | 384 | $scope.$watch('predictions', function () { 385 | $scope.position = getDrawerPosition($scope.input); 386 | }, true); 387 | 388 | function getDrawerPosition(element) { 389 | var domEl = element[0], 390 | rect = domEl.getBoundingClientRect(), 391 | docEl = $document[0].documentElement, 392 | body = $document[0].body, 393 | scrollTop = $window.pageYOffset || docEl.scrollTop || body.scrollTop, 394 | scrollLeft = $window.pageXOffset || docEl.scrollLeft || body.scrollLeft; 395 | 396 | return { 397 | width: rect.width, 398 | height: rect.height, 399 | top: rect.top + rect.height + scrollTop, 400 | left: rect.left + scrollLeft 401 | }; 402 | } 403 | } 404 | }; 405 | }]) 406 | 407 | .directive('gPlacesAutocompletePrediction', [function () { 408 | var TEMPLATE = [ 409 | '', 410 | '', 411 | '{{term.value | trailingComma:!$last}} ', 412 | ' {{prediction.custom_prediction_label}}' 413 | ]; 414 | 415 | return { 416 | restrict: 'A', 417 | scope:{ 418 | index:'=', 419 | prediction:'=', 420 | query:'=' 421 | }, 422 | template: TEMPLATE.join('') 423 | }; 424 | }]) 425 | 426 | .filter('highlightMatched', ['$sce', function ($sce) { 427 | return function (prediction) { 428 | var matchedPortion = '', 429 | unmatchedPortion = '', 430 | matched; 431 | 432 | if (prediction.matched_substrings.length > 0 && prediction.terms.length > 0) { 433 | matched = prediction.matched_substrings[0]; 434 | matchedPortion = prediction.terms[0].value.substr(matched.offset, matched.length); 435 | unmatchedPortion = prediction.terms[0].value.substr(matched.offset + matched.length); 436 | } 437 | 438 | return $sce.trustAsHtml('' + matchedPortion + '' + unmatchedPortion); 439 | }; 440 | }]) 441 | 442 | .filter('unmatchedTermsOnly', [function () { 443 | return function (terms, prediction) { 444 | var i, term, filtered = []; 445 | 446 | for (i = 0; i < terms.length; i++) { 447 | term = terms[i]; 448 | if (prediction.matched_substrings.length > 0 && term.offset > prediction.matched_substrings[0].length) { 449 | filtered.push(term); 450 | } 451 | } 452 | 453 | return filtered; 454 | }; 455 | }]) 456 | 457 | .filter('trailingComma', [function () { 458 | return function (input, condition) { 459 | return (condition) ? input + ',' : input; 460 | }; 461 | }]); 462 | --------------------------------------------------------------------------------