├── .gitignore ├── LICENSE ├── bower.json ├── package.json ├── style.css ├── example.html ├── README.md ├── dist ├── typeahead.bundle.min.js ├── typeahead.cjs.js └── typeahead.bundle.js └── typeahead.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012 Twitter, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeahead", 3 | "description": "typeahead component", 4 | "main": "dist/typeahead.bundle.min.js", 5 | "authors": [ 6 | "Roman Shtylman ", 7 | "Neil Freeman " 8 | ], 9 | "license": "Apache", 10 | "keywords": [ 11 | "typeahead", 12 | "ui" 13 | ], 14 | "homepage": "https://github.com/defunctzombie/typeahead", 15 | "moduleType": [ 16 | "globals" 17 | ], 18 | "ignore": [ 19 | "**/.*", 20 | "node_modules", 21 | "bower_components", 22 | "test", 23 | "tests" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeahead", 3 | "version": "0.2.2", 4 | "description": "typeahead component", 5 | "main": "dist/typeahead.cjs.js", 6 | "esnext:main": "typeahead.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/shtylman/typeahead.git" 10 | }, 11 | "style": "./style.css", 12 | "author": "Roman Shtylman ", 13 | "scripts": { 14 | "cjs": "rollup -f cjs typeahead.js -o dist/typeahead.cjs.js", 15 | "bundle": "rollup -f iife --name Typeahead typeahead.js -o dist/typeahead.bundle.js", 16 | "bundle.min": "uglifyjs dist/typeahead.bundle.js -cmo dist/typeahead.bundle.min.js", 17 | "prepublish": "npm run cjs && npm run bundle && npm run bundle.min" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | .typeahead { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | z-index: 1000; 6 | float: left; 7 | min-width: 160px; 8 | padding: 5px 0; 9 | margin: 2px 0 0; 10 | list-style: none; 11 | background-color: white; 12 | border: 1px solid #CCC; 13 | } 14 | 15 | .typeahead li { 16 | line-height: 20px; 17 | } 18 | 19 | .typeahead a { 20 | display: block; 21 | padding: 3px 20px; 22 | clear: both; 23 | font-weight: normal; 24 | line-height: 20px; 25 | color: #333; 26 | white-space: nowrap; 27 | text-decoration: none; 28 | } 29 | 30 | .typeahead .active > a { 31 | color: white; 32 | text-decoration: none; 33 | background-color: #0081C2; 34 | outline: 0; 35 | } 36 | 37 | .typeahead.hidden { 38 | display: none; 39 | } 40 | -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | typeahead example 6 | 7 | 8 | 22 | 23 | 24 | 25 |
26 | 27 |
28 | 29 |

This example uses objects to access an object for each match, rather than a single string.

30 |
31 | 32 |
33 | 34 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typeahead 2 | 3 | typeahead widget 4 | 5 | ![typeahead](https://github.com/defunctzombie/typeahead/blob/gh-pages/img.png) 6 | 7 | ## use 8 | 9 | ```javascript 10 | var Typeahead = require('typeahead'); 11 | 12 | var input = document.createElement('input'); 13 | 14 | // source is an array of items 15 | var ta = Typeahead(input, { 16 | source: ['foo', 'bar', 'baz'] 17 | }); 18 | 19 | input // => 20 | ``` 21 | 22 | To get the default style you also have to include `style.css`. 23 | 24 | ## options 25 | 26 | ### source 27 | 28 | Array of values or function(query, result). Call result with array of return values. 29 | 30 | ```javascript 31 | var Typeahead = require('typeahead'); 32 | var input = document.createElement('input'); 33 | 34 | // source is an array of strings 35 | var ta = Typeahead(input, { 36 | source: function(query, result) { 37 | result(['foo', 'bar', 'baz']); 38 | } 39 | }); 40 | 41 | // Alternatively, source is an array of objects 42 | var ta = Typeahead(input, { 43 | source: function(query, result) { 44 | result(['foo', 'bar', 'baz']); 45 | } 46 | }); 47 | 48 | input // => 49 | ``` 50 | 51 | ### minLength 52 | Minimum input length before typeahead starts. 53 | 54 | ### menu 55 | Tag name of DOM element to create to contain the list of matches. Default is a `ul`. 56 | 57 | ### item 58 | Tag name to contain each match. Default is 'li'. Each item is further wrapped in an `` element. 59 | 60 | ### position 61 | location of the drop down menu. Valid values are ```above```, ```below``` and ```right```. default is ```below``` 62 | 63 | ### autoselect 64 | Automatically select first item in drop down menu. Valid values are ```true```, ```false```. Default is ```true```. 65 | 66 | ### updater 67 | Custom function to update the value of a selected item. Default returns the `value`. Should accept an DOM node and return a string. 68 | 69 | ### matcher 70 | Custom function to match a returned `item` or `item.value` with the input query. Default does a case-insensitive search for the query in the item. Should accept an item and return true or false. 71 | 72 | ### sorter 73 | Custom function to sort `item`s. Default puts case-sensitive matches above case-insensitive ones. Should accept and return an Array of items. 74 | 75 | ### highlighter 76 | Custom function to highlight matches. Default bolds the matched substring. Should accept an item and return an HTML string. 77 | 78 | ## style 79 | 80 | Custom styling can be applied for the following rules. 81 | 82 | ### .typeahead 83 | To style the list of suggestions. 84 | 85 | ### .typeahead.hidden 86 | To style the hidden state of the menu 87 | 88 | ### .typeahead li 89 | To style a li container. 90 | 91 | ### .typeahead a 92 | To style the actual item text and selection area. 93 | 94 | ### .typeahead .active > a 95 | To style the appearance of a selected item. 96 | 97 | ## License 98 | 99 | The current code is fork of the bootstrap typeahead component and is licensed under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) 100 | -------------------------------------------------------------------------------- /dist/typeahead.bundle.min.js: -------------------------------------------------------------------------------- 1 | var Typeahead=function(){"use strict";function e(e,n){this.element=e,this.options={};for(var i in t)t.hasOwnProperty(i)&&(this.options[i]=n[i]||t[i]);return this.matcher=n.matcher||this.matcher,this.sorter=n.sorter||this.sorter,this.highlighter=n.highlighter||this.highlighter,this.updater=n.updater||this.updater,this.menu=document.createElement(this.options.menu),this.menu.classList.add("typeahead"),this.menu.classList.add("hidden"),document.body.appendChild(this.menu),this.listen()}var t={source:[],items:8,menu:"ul",item:"li",minLength:1,autoselect:!0},n=function(e){var t=0,n=0;if(e.offsetParent)do t+=e.offsetLeft,n+=e.offsetTop;while(e=e.offsetParent);return{left:t,top:n}},i=e.prototype;return i.constructor=e,i.active=function(){return this.menu.getElementsByClassName("active").item(0)},i.select=function(){var e=new Event("change.typeahead"),t=this.active();Object.keys(t.dataset).forEach(function(e){this.element.dataset[e]=t.dataset[e]},this),this.element.value=this.updater(t),this.element.dispatchEvent(e),this.hide()},i.updater=function(e){return e.dataset.value},i.show=function(){var e=this,t=0,i=n(e.element),s=e.element;for(i.height=e.element.offsetHeight;s=s.parentElement;){var r=e.element.tagName.toLowerCase();"html"!==r&&"body"!==r&&(t+=s.scrollTop)}var o=i.top+i.height-t,a="auto",u=i.left;return"above"===e.options.position?(o="auto",a=document.body.clientHeight-i.top+3+"px"):"right"===e.options.position?(o=o-e.element.offsetHeight+"px",u=i.left+e.element.offsetWidth):o+="px",e.menu.style.top=o,e.menu.style.bottom=a,e.menu.style.left=u+"px",e.menu.classList.remove("hidden"),e},i.hide=function(){return this.menu.classList.add("hidden"),this},i.shown=function(){return!this.menu.classList.contains("hidden")},i.lookup=function(){var e=this;return e.query=e.element.value,!e.query||e.query.length"+t+""})},i.render=function(e){var t=this;return e=e.map(function(e){var n=document.createElement(t.options.item),i=document.createElement("a");return"string"==typeof e?(n.dataset.value=e,i.innerHTML=t.highlighter(e)):(Object.keys(e).forEach(function(t){n.dataset[t]=e[t]}),i.innerHTML=t.highlighter(e.value)),n.appendChild(i),n}),t.options.autoselect&&e[0].classList.add("active"),t.menu.innerHTML="",e.forEach(function(e){t.menu.appendChild(e)}),t},i.next=function(){var e=this.active(),t=e.nextElementSibling;e.classList.remove("active"),t||(t=this.menu.getElementsByTagName(this.options.item).item(0)),t.classList.add("active")},i.prev=function(){var e=this.active(),t=e.previousElementSibling;if(e.classList.remove("active"),!t){var n=this.menu.getElementsByTagName(this.options.item);t=n.item(n.length-1)}t.classList.add("active")},i.listen=function(){var e=this,t=e.element;return t.addEventListener("blur",e.blur.bind(e)),t.addEventListener("keypress",e.keypress.bind(e)),t.addEventListener("keyup",e.keyup.bind(e)),t.addEventListener("keydown",e.keydown.bind(e)),e.menu.addEventListener("click",e.click.bind(e)),e.menu.addEventListener("mouseenter",e.mouseenter.bind(e)),e},i.move=function(e){var t=this;if(this.shown()){switch(e.keyCode){case 9:case 13:case 27:e.preventDefault();break;case 38:e.preventDefault(),t.prev();break;case 40:e.preventDefault(),t.next()}e.stopPropagation()}},i.keydown=function(e){this.suppressKeyPressRepeat=[40,38,9,13,27].indexOf(e.keyCode)>=0,this.move(e)},i.keypress=function(e){this.suppressKeyPressRepeat||this.move(e)},i.keyup=function(e){var t=this;switch(e.keyCode){case 40:case 38:break;case 9:case 13:if(!this.shown())return;t.select();break;case 27:if(!t.shown())return;t.hide();break;default:t.lookup()}e.stopPropagation(),e.preventDefault()},i.blur=function(){var e=this;setTimeout(function(){e.hide()},150)},i.click=function(e){e.stopPropagation(),e.preventDefault(),this.select()},i.mouseenter=function(e){this.active().classList.remove("active"),e.currentTarget.classList.add("active")},e}(); -------------------------------------------------------------------------------- /dist/typeahead.cjs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var defaults = { 4 | source: [], 5 | items: 8, 6 | menu: 'ul', 7 | item: 'li', 8 | minLength: 1, 9 | autoselect: true 10 | } 11 | 12 | var offset = function(el) { 13 | var curleft = 0, 14 | curtop = 0; 15 | 16 | if (el.offsetParent) 17 | do { 18 | curleft += el.offsetLeft; 19 | curtop += el.offsetTop; 20 | } while (el = el.offsetParent) 21 | 22 | return { 23 | left: curleft, 24 | top: curtop 25 | } 26 | }; 27 | 28 | function Typeahead(element, options) { 29 | this.element = element; 30 | this.options = {}; 31 | 32 | // update keys in defaults function 33 | for (var key in defaults) { 34 | if (defaults.hasOwnProperty(key)) 35 | this.options[key] = options[key] || defaults[key]; 36 | } 37 | 38 | // update functions 39 | this.matcher = options.matcher || this.matcher; 40 | this.sorter = options.sorter || this.sorter; 41 | this.highlighter = options.highlighter || this.highlighter; 42 | this.updater = options.updater || this.updater; 43 | 44 | this.menu = document.createElement(this.options.menu); 45 | this.menu.classList.add('typeahead'); 46 | this.menu.classList.add('hidden'); 47 | document.body.appendChild(this.menu); 48 | 49 | return this.listen(); 50 | } 51 | 52 | // for minification 53 | var proto = Typeahead.prototype; 54 | 55 | proto.constructor = Typeahead; 56 | 57 | proto.active = function () { 58 | return this.menu.getElementsByClassName('active').item(0); 59 | } 60 | 61 | // select the current item 62 | proto.select = function() { 63 | var ev = new Event('change.typeahead'); 64 | var active = this.active() 65 | 66 | // add attributes to input element 67 | Object.keys(active.dataset).forEach(function(e) { 68 | this.element.dataset[e] = active.dataset[e]; 69 | }, this); 70 | 71 | this.element.value = this.updater(active); 72 | this.element.dispatchEvent(ev); 73 | this.hide(); 74 | } 75 | 76 | proto.updater = function (item) { 77 | return item.dataset.value; 78 | } 79 | 80 | // show the popup menu 81 | proto.show = function () { 82 | var self = this, 83 | scroll = 0, 84 | pos = offset(self.element), 85 | parent = self.element; 86 | 87 | pos.height = self.element.offsetHeight; 88 | 89 | while (parent = parent.parentElement) { 90 | // prevent adding window scroll 91 | var tag = self.element.tagName.toLowerCase(); 92 | if (tag === 'html' || tag === 'body') { 93 | continue; 94 | } 95 | scroll += parent.scrollTop 96 | } 97 | 98 | // if page has scrolled we need real position in viewport 99 | var top = pos.top + pos.height - scroll; 100 | var bottom = 'auto' 101 | var left = pos.left; 102 | 103 | if (self.options.position === 'above') { 104 | top = 'auto' 105 | bottom = (document.body.clientHeight - pos.top + 3) + 'px'; 106 | } else if (self.options.position === 'right') { 107 | top = (top - self.element.offsetHeight) + 'px'; 108 | left = (pos.left + self.element.offsetWidth); 109 | } else { 110 | top = top + 'px'; 111 | } 112 | 113 | self.menu.style.top = top; 114 | self.menu.style.bottom = bottom; 115 | self.menu.style.left = left + 'px'; 116 | self.menu.classList.remove('hidden'); 117 | return self; 118 | } 119 | 120 | // hide the popup menu 121 | proto.hide = function () { 122 | this.menu.classList.add('hidden'); 123 | return this; 124 | } 125 | 126 | /** 127 | * Returns true if the menu is currently show 128 | */ 129 | proto.shown = function () { 130 | return !this.menu.classList.contains('hidden'); 131 | } 132 | 133 | proto.lookup = function () { 134 | var self = this; 135 | self.query = self.element.value; 136 | 137 | if (!self.query || self.query.length < self.options.minLength) { 138 | return self.shown() ? self.hide() : self; 139 | } 140 | 141 | if (self.options.source instanceof Function) { 142 | self.options.source(self.query, self.process.bind(self)); 143 | } 144 | 145 | else { 146 | self.process(self.options.source); 147 | } 148 | return self; 149 | } 150 | 151 | proto.process = function (items) { 152 | var self = this; 153 | 154 | items = items.filter(self.matcher, self); 155 | items = self.sorter(items) 156 | 157 | if (!items.length) { 158 | return self.shown() ? self.hide() : self 159 | } 160 | 161 | self.render(items.slice(0, self.options.items)) 162 | .show() 163 | } 164 | 165 | proto.matcher = function (item) { 166 | var v = (typeof(item) === 'string') ? item : item.value; 167 | return ~v.toLowerCase().indexOf(this.query.toLowerCase()) 168 | } 169 | 170 | proto.sorter = function (items) { 171 | var beginswith = []; 172 | var caseSensitive = []; 173 | var caseInsensitive = []; 174 | 175 | items.forEach(function(item) { 176 | var v = (typeof(item) === 'string') ? item : item.value; 177 | 178 | if (!v.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item) 179 | else if (~v.indexOf(this.query)) caseSensitive.push(item) 180 | else caseInsensitive.push(item) 181 | }, this); 182 | 183 | return beginswith.concat(caseSensitive, caseInsensitive) 184 | } 185 | 186 | proto.highlighter = function (value) { 187 | var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); 188 | return value.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { 189 | return '' + match + '' 190 | }) 191 | } 192 | 193 | proto.render = function (items) { 194 | var self = this; 195 | 196 | items = items.map(function (item) { 197 | 198 | var li = document.createElement(self.options.item), 199 | a = document.createElement('a'); 200 | 201 | if (typeof(item) === 'string') { 202 | li.dataset.value = item; 203 | a.innerHTML = self.highlighter(item); 204 | } else { 205 | // we expect to have an object, and will fill up the dataset. 206 | Object.keys(item).forEach(function(x) { 207 | li.dataset[x] = item[x]; 208 | }); 209 | a.innerHTML = self.highlighter(item.value); 210 | } 211 | 212 | li.appendChild(a); 213 | return li; 214 | }); 215 | 216 | if (self.options.autoselect) 217 | items[0].classList.add('active'); 218 | 219 | self.menu.innerHTML = ''; 220 | items.forEach(function(item) { 221 | self.menu.appendChild(item); 222 | }); 223 | 224 | return self; 225 | } 226 | 227 | proto.next = function () { 228 | var active = this.active(); 229 | var next = active.nextElementSibling; 230 | active.classList.remove('active'); 231 | 232 | if (!next) { 233 | next = this.menu.getElementsByTagName(this.options.item).item(0); 234 | } 235 | 236 | next.classList.add('active'); 237 | } 238 | 239 | proto.prev = function () { 240 | var active = this.active(); 241 | var prev = active.previousElementSibling; 242 | active.classList.remove('active'); 243 | 244 | if (!prev) { 245 | var items = this.menu.getElementsByTagName(this.options.item); 246 | prev = items.item(items.length-1); 247 | } 248 | 249 | prev.classList.add('active'); 250 | } 251 | 252 | proto.listen = function () { 253 | var self = this, 254 | element = self.element; 255 | 256 | element.addEventListener('blur', self.blur.bind(self)); 257 | element.addEventListener('keypress', self.keypress.bind(self)); 258 | element.addEventListener('keyup', self.keyup.bind(self)); 259 | element.addEventListener('keydown', self.keydown.bind(self)); 260 | 261 | self.menu.addEventListener('click', self.click.bind(self)); 262 | self.menu.addEventListener('mouseenter', self.mouseenter.bind(self)); 263 | 264 | return self; 265 | } 266 | 267 | proto.move = function (e) { 268 | var self = this; 269 | if (!this.shown()) return 270 | 271 | switch(e.keyCode) { 272 | case 9: // tab 273 | case 13: // enter 274 | case 27: // escape 275 | e.preventDefault() 276 | break 277 | 278 | case 38: // up arrow 279 | e.preventDefault() 280 | self.prev() 281 | break 282 | 283 | case 40: // down arrow 284 | e.preventDefault() 285 | self.next() 286 | break 287 | } 288 | 289 | e.stopPropagation() 290 | } 291 | 292 | proto.keydown = function (e) { 293 | this.suppressKeyPressRepeat = [40,38,9,13,27].indexOf(e.keyCode) >= 0 294 | this.move(e) 295 | } 296 | 297 | proto.keypress = function (e) { 298 | if (this.suppressKeyPressRepeat) return 299 | this.move(e) 300 | } 301 | 302 | proto.keyup = function (e) { 303 | var self = this; 304 | 305 | switch(e.keyCode) { 306 | case 40: // down arrow 307 | case 38: // up arrow 308 | break 309 | 310 | case 9: // tab 311 | case 13: // enter 312 | if (!this.shown()) return 313 | self.select() 314 | break 315 | 316 | case 27: // escape 317 | if (!self.shown()) return 318 | self.hide() 319 | break 320 | 321 | default: 322 | self.lookup() 323 | } 324 | 325 | e.stopPropagation() 326 | e.preventDefault() 327 | } 328 | 329 | proto.blur = function () { 330 | var self = this; 331 | setTimeout(function () { self.hide() }, 150); 332 | } 333 | 334 | proto.click = function (e) { 335 | e.stopPropagation(); 336 | e.preventDefault(); 337 | this.select(); 338 | } 339 | 340 | proto.mouseenter = function (e) { 341 | this.active().classList.remove('active'); 342 | e.currentTarget.classList.add('active'); 343 | } 344 | 345 | module.exports = Typeahead; -------------------------------------------------------------------------------- /typeahead.js: -------------------------------------------------------------------------------- 1 | var defaults = { 2 | source: [], 3 | items: 8, 4 | menu: 'ul', 5 | item: 'li', 6 | minLength: 1, 7 | autoselect: true 8 | } 9 | 10 | var offset = function(el) { 11 | var curleft = 0, 12 | curtop = 0; 13 | 14 | if (el.offsetParent) 15 | do { 16 | curleft += el.offsetLeft; 17 | curtop += el.offsetTop; 18 | } while (el = el.offsetParent) 19 | 20 | return { 21 | left: curleft, 22 | top: curtop 23 | } 24 | }; 25 | 26 | /** 27 | * @constructs Typeahead 28 | * @param {DOM node} element 29 | * @param {object} options 30 | * @returns {Typeahead} 31 | */ 32 | export default function Typeahead(element, options) { 33 | this.element = element; 34 | this.options = {}; 35 | 36 | // update keys in defaults function 37 | for (var key in defaults) { 38 | if (defaults.hasOwnProperty(key)) 39 | this.options[key] = options[key] || defaults[key]; 40 | } 41 | 42 | // update functions 43 | this.matcher = options.matcher || this.matcher; 44 | this.sorter = options.sorter || this.sorter; 45 | this.highlighter = options.highlighter || this.highlighter; 46 | this.updater = options.updater || this.updater; 47 | 48 | this.menu = document.createElement(this.options.menu); 49 | this.menu.classList.add('typeahead'); 50 | this.menu.classList.add('hidden'); 51 | document.body.appendChild(this.menu); 52 | 53 | return this.listen(); 54 | } 55 | 56 | // for minification 57 | var proto = Typeahead.prototype; 58 | 59 | proto.constructor = Typeahead; 60 | 61 | proto.active = function () { 62 | return this.menu.getElementsByClassName('active').item(0); 63 | } 64 | 65 | // select the current item 66 | proto.select = function() { 67 | var ev = new Event('change.typeahead'); 68 | var active = this.active() 69 | 70 | // add attributes to input element 71 | Object.keys(active.dataset).forEach(function(e) { 72 | this.element.dataset[e] = active.dataset[e]; 73 | }, this); 74 | 75 | this.element.value = this.updater(active); 76 | this.element.dispatchEvent(ev); 77 | this.hide(); 78 | } 79 | 80 | proto.updater = function (item) { 81 | return item.dataset.value; 82 | } 83 | 84 | // show the popup menu 85 | proto.show = function () { 86 | var self = this, 87 | scroll = 0, 88 | pos = offset(self.element), 89 | parent = self.element; 90 | 91 | pos.height = self.element.offsetHeight; 92 | 93 | while (parent = parent.parentElement) { 94 | // prevent adding window scroll 95 | var tag = self.element.tagName.toLowerCase(); 96 | if (tag === 'html' || tag === 'body') { 97 | continue; 98 | } 99 | scroll += parent.scrollTop 100 | } 101 | 102 | // if page has scrolled we need real position in viewport 103 | var top = pos.top + pos.height - scroll; 104 | var bottom = 'auto' 105 | var left = pos.left; 106 | 107 | if (self.options.position === 'above') { 108 | top = 'auto' 109 | bottom = (document.body.clientHeight - pos.top + 3) + 'px'; 110 | } else if (self.options.position === 'right') { 111 | top = (top - self.element.offsetHeight) + 'px'; 112 | left = (pos.left + self.element.offsetWidth); 113 | } else { 114 | top = top + 'px'; 115 | } 116 | 117 | self.menu.style.top = top; 118 | self.menu.style.bottom = bottom; 119 | self.menu.style.left = left + 'px'; 120 | self.menu.classList.remove('hidden'); 121 | return self; 122 | } 123 | 124 | // hide the popup menu 125 | proto.hide = function () { 126 | this.menu.classList.add('hidden'); 127 | return this; 128 | } 129 | 130 | /** 131 | * Returns true if the menu is currently show 132 | */ 133 | proto.shown = function () { 134 | return !this.menu.classList.contains('hidden'); 135 | } 136 | 137 | proto.lookup = function () { 138 | var self = this; 139 | self.query = self.element.value; 140 | 141 | if (!self.query || self.query.length < self.options.minLength) { 142 | return self.shown() ? self.hide() : self; 143 | } 144 | 145 | if (self.options.source instanceof Function) { 146 | self.options.source(self.query, self.process.bind(self)); 147 | } 148 | 149 | else { 150 | self.process(self.options.source); 151 | } 152 | return self; 153 | } 154 | 155 | proto.process = function (items) { 156 | var self = this; 157 | 158 | items = items.filter(self.matcher, self); 159 | items = self.sorter(items) 160 | 161 | if (!items.length) { 162 | return self.shown() ? self.hide() : self 163 | } 164 | 165 | self.render(items.slice(0, self.options.items)) 166 | .show() 167 | } 168 | 169 | proto.matcher = function (item) { 170 | var v = (typeof(item) === 'string') ? item : item.value; 171 | return ~v.toLowerCase().indexOf(this.query.toLowerCase()) 172 | } 173 | 174 | proto.sorter = function (items) { 175 | var beginswith = []; 176 | var caseSensitive = []; 177 | var caseInsensitive = []; 178 | 179 | items.forEach(function(item) { 180 | var v = (typeof(item) === 'string') ? item : item.value; 181 | 182 | if (!v.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item) 183 | else if (~v.indexOf(this.query)) caseSensitive.push(item) 184 | else caseInsensitive.push(item) 185 | }, this); 186 | 187 | return beginswith.concat(caseSensitive, caseInsensitive) 188 | } 189 | 190 | proto.highlighter = function (value) { 191 | var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); 192 | return value.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { 193 | return '' + match + '' 194 | }) 195 | } 196 | 197 | proto.render = function (items) { 198 | var self = this; 199 | var mouseenter = self.mouseenter.bind(self); 200 | items = items.map(function (item) { 201 | var li = document.createElement(self.options.item), 202 | a = document.createElement('a'); 203 | 204 | li.addEventListener('mouseenter', mouseenter); 205 | 206 | if (typeof(item) === 'string') { 207 | li.dataset.value = item; 208 | a.innerHTML = self.highlighter(item); 209 | } else { 210 | // we expect to have an object, and will fill up the dataset. 211 | Object.keys(item).forEach(function(x) { 212 | li.dataset[x] = item[x]; 213 | }); 214 | a.innerHTML = self.highlighter(item.value); 215 | } 216 | 217 | li.appendChild(a); 218 | return li; 219 | }); 220 | 221 | if (self.options.autoselect) 222 | items[0].classList.add('active'); 223 | 224 | self.menu.innerHTML = ''; 225 | items.forEach(function(item) { 226 | self.menu.appendChild(item); 227 | }); 228 | 229 | return self; 230 | } 231 | 232 | proto.next = function () { 233 | var active = this.active(); 234 | var next = active.nextElementSibling; 235 | active.classList.remove('active'); 236 | 237 | if (!next) { 238 | next = this.menu.getElementsByTagName(this.options.item).item(0); 239 | } 240 | 241 | next.classList.add('active'); 242 | } 243 | 244 | proto.prev = function () { 245 | var active = this.active(); 246 | var prev = active.previousElementSibling; 247 | active.classList.remove('active'); 248 | 249 | if (!prev) { 250 | var items = this.menu.getElementsByTagName(this.options.item); 251 | prev = items.item(items.length-1); 252 | } 253 | 254 | prev.classList.add('active'); 255 | } 256 | 257 | proto.listen = function () { 258 | var self = this, 259 | element = self.element; 260 | 261 | element.addEventListener('blur', self.blur.bind(self)); 262 | element.addEventListener('keypress', self.keypress.bind(self)); 263 | element.addEventListener('keyup', self.keyup.bind(self)); 264 | element.addEventListener('keydown', self.keydown.bind(self)); 265 | 266 | self.menu.addEventListener('click', self.click.bind(self)); 267 | 268 | return self; 269 | } 270 | 271 | proto.move = function (e) { 272 | var self = this; 273 | if (!this.shown()) return 274 | 275 | switch(e.keyCode) { 276 | case 9: // tab 277 | case 13: // enter 278 | case 27: // escape 279 | e.preventDefault() 280 | break 281 | 282 | case 38: // up arrow 283 | e.preventDefault() 284 | self.prev() 285 | break 286 | 287 | case 40: // down arrow 288 | e.preventDefault() 289 | self.next() 290 | break 291 | } 292 | 293 | e.stopPropagation() 294 | } 295 | 296 | proto.keydown = function (e) { 297 | this.suppressKeyPressRepeat = [40,38,9,13,27].indexOf(e.keyCode) >= 0 298 | this.move(e) 299 | } 300 | 301 | proto.keypress = function (e) { 302 | if (this.suppressKeyPressRepeat) return 303 | this.move(e) 304 | } 305 | 306 | proto.keyup = function (e) { 307 | var self = this; 308 | 309 | switch(e.keyCode) { 310 | case 40: // down arrow 311 | case 38: // up arrow 312 | break 313 | 314 | case 9: // tab 315 | case 13: // enter 316 | if (!this.shown()) return 317 | self.select() 318 | break 319 | 320 | case 27: // escape 321 | if (!self.shown()) return 322 | self.hide() 323 | break 324 | 325 | default: 326 | self.lookup() 327 | } 328 | 329 | e.stopPropagation() 330 | e.preventDefault() 331 | } 332 | 333 | proto.blur = function () { 334 | var self = this; 335 | setTimeout(function () { self.hide() }, 150); 336 | } 337 | 338 | proto.click = function (e) { 339 | e.stopPropagation(); 340 | e.preventDefault(); 341 | this.select(); 342 | } 343 | 344 | proto.mouseenter = function (e) { 345 | var active = this.active(); 346 | if (active) active.classList.remove('active'); 347 | e.currentTarget.classList.add('active'); 348 | } 349 | -------------------------------------------------------------------------------- /dist/typeahead.bundle.js: -------------------------------------------------------------------------------- 1 | var Typeahead = (function () { 2 | 'use strict'; 3 | 4 | var defaults = { 5 | source: [], 6 | items: 8, 7 | menu: 'ul', 8 | item: 'li', 9 | minLength: 1, 10 | autoselect: true 11 | } 12 | 13 | var offset = function(el) { 14 | var curleft = 0, 15 | curtop = 0; 16 | 17 | if (el.offsetParent) 18 | do { 19 | curleft += el.offsetLeft; 20 | curtop += el.offsetTop; 21 | } while (el = el.offsetParent) 22 | 23 | return { 24 | left: curleft, 25 | top: curtop 26 | } 27 | }; 28 | 29 | function Typeahead(element, options) { 30 | this.element = element; 31 | this.options = {}; 32 | 33 | // update keys in defaults function 34 | for (var key in defaults) { 35 | if (defaults.hasOwnProperty(key)) 36 | this.options[key] = options[key] || defaults[key]; 37 | } 38 | 39 | // update functions 40 | this.matcher = options.matcher || this.matcher; 41 | this.sorter = options.sorter || this.sorter; 42 | this.highlighter = options.highlighter || this.highlighter; 43 | this.updater = options.updater || this.updater; 44 | 45 | this.menu = document.createElement(this.options.menu); 46 | this.menu.classList.add('typeahead'); 47 | this.menu.classList.add('hidden'); 48 | document.body.appendChild(this.menu); 49 | 50 | return this.listen(); 51 | } 52 | 53 | // for minification 54 | var proto = Typeahead.prototype; 55 | 56 | proto.constructor = Typeahead; 57 | 58 | proto.active = function () { 59 | return this.menu.getElementsByClassName('active').item(0); 60 | } 61 | 62 | // select the current item 63 | proto.select = function() { 64 | var ev = new Event('change.typeahead'); 65 | var active = this.active() 66 | 67 | // add attributes to input element 68 | Object.keys(active.dataset).forEach(function(e) { 69 | this.element.dataset[e] = active.dataset[e]; 70 | }, this); 71 | 72 | this.element.value = this.updater(active); 73 | this.element.dispatchEvent(ev); 74 | this.hide(); 75 | } 76 | 77 | proto.updater = function (item) { 78 | return item.dataset.value; 79 | } 80 | 81 | // show the popup menu 82 | proto.show = function () { 83 | var self = this, 84 | scroll = 0, 85 | pos = offset(self.element), 86 | parent = self.element; 87 | 88 | pos.height = self.element.offsetHeight; 89 | 90 | while (parent = parent.parentElement) { 91 | // prevent adding window scroll 92 | var tag = self.element.tagName.toLowerCase(); 93 | if (tag === 'html' || tag === 'body') { 94 | continue; 95 | } 96 | scroll += parent.scrollTop 97 | } 98 | 99 | // if page has scrolled we need real position in viewport 100 | var top = pos.top + pos.height - scroll; 101 | var bottom = 'auto' 102 | var left = pos.left; 103 | 104 | if (self.options.position === 'above') { 105 | top = 'auto' 106 | bottom = (document.body.clientHeight - pos.top + 3) + 'px'; 107 | } else if (self.options.position === 'right') { 108 | top = (top - self.element.offsetHeight) + 'px'; 109 | left = (pos.left + self.element.offsetWidth); 110 | } else { 111 | top = top + 'px'; 112 | } 113 | 114 | self.menu.style.top = top; 115 | self.menu.style.bottom = bottom; 116 | self.menu.style.left = left + 'px'; 117 | self.menu.classList.remove('hidden'); 118 | return self; 119 | } 120 | 121 | // hide the popup menu 122 | proto.hide = function () { 123 | this.menu.classList.add('hidden'); 124 | return this; 125 | } 126 | 127 | /** 128 | * Returns true if the menu is currently show 129 | */ 130 | proto.shown = function () { 131 | return !this.menu.classList.contains('hidden'); 132 | } 133 | 134 | proto.lookup = function () { 135 | var self = this; 136 | self.query = self.element.value; 137 | 138 | if (!self.query || self.query.length < self.options.minLength) { 139 | return self.shown() ? self.hide() : self; 140 | } 141 | 142 | if (self.options.source instanceof Function) { 143 | self.options.source(self.query, self.process.bind(self)); 144 | } 145 | 146 | else { 147 | self.process(self.options.source); 148 | } 149 | return self; 150 | } 151 | 152 | proto.process = function (items) { 153 | var self = this; 154 | 155 | items = items.filter(self.matcher, self); 156 | items = self.sorter(items) 157 | 158 | if (!items.length) { 159 | return self.shown() ? self.hide() : self 160 | } 161 | 162 | self.render(items.slice(0, self.options.items)) 163 | .show() 164 | } 165 | 166 | proto.matcher = function (item) { 167 | var v = (typeof(item) === 'string') ? item : item.value; 168 | return ~v.toLowerCase().indexOf(this.query.toLowerCase()) 169 | } 170 | 171 | proto.sorter = function (items) { 172 | var beginswith = []; 173 | var caseSensitive = []; 174 | var caseInsensitive = []; 175 | 176 | items.forEach(function(item) { 177 | var v = (typeof(item) === 'string') ? item : item.value; 178 | 179 | if (!v.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item) 180 | else if (~v.indexOf(this.query)) caseSensitive.push(item) 181 | else caseInsensitive.push(item) 182 | }, this); 183 | 184 | return beginswith.concat(caseSensitive, caseInsensitive) 185 | } 186 | 187 | proto.highlighter = function (value) { 188 | var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); 189 | return value.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { 190 | return '' + match + '' 191 | }) 192 | } 193 | 194 | proto.render = function (items) { 195 | var self = this; 196 | 197 | items = items.map(function (item) { 198 | 199 | var li = document.createElement(self.options.item), 200 | a = document.createElement('a'); 201 | 202 | if (typeof(item) === 'string') { 203 | li.dataset.value = item; 204 | a.innerHTML = self.highlighter(item); 205 | } else { 206 | // we expect to have an object, and will fill up the dataset. 207 | Object.keys(item).forEach(function(x) { 208 | li.dataset[x] = item[x]; 209 | }); 210 | a.innerHTML = self.highlighter(item.value); 211 | } 212 | 213 | li.appendChild(a); 214 | return li; 215 | }); 216 | 217 | if (self.options.autoselect) 218 | items[0].classList.add('active'); 219 | 220 | self.menu.innerHTML = ''; 221 | items.forEach(function(item) { 222 | self.menu.appendChild(item); 223 | }); 224 | 225 | return self; 226 | } 227 | 228 | proto.next = function () { 229 | var active = this.active(); 230 | var next = active.nextElementSibling; 231 | active.classList.remove('active'); 232 | 233 | if (!next) { 234 | next = this.menu.getElementsByTagName(this.options.item).item(0); 235 | } 236 | 237 | next.classList.add('active'); 238 | } 239 | 240 | proto.prev = function () { 241 | var active = this.active(); 242 | var prev = active.previousElementSibling; 243 | active.classList.remove('active'); 244 | 245 | if (!prev) { 246 | var items = this.menu.getElementsByTagName(this.options.item); 247 | prev = items.item(items.length-1); 248 | } 249 | 250 | prev.classList.add('active'); 251 | } 252 | 253 | proto.listen = function () { 254 | var self = this, 255 | element = self.element; 256 | 257 | element.addEventListener('blur', self.blur.bind(self)); 258 | element.addEventListener('keypress', self.keypress.bind(self)); 259 | element.addEventListener('keyup', self.keyup.bind(self)); 260 | element.addEventListener('keydown', self.keydown.bind(self)); 261 | 262 | self.menu.addEventListener('click', self.click.bind(self)); 263 | self.menu.addEventListener('mouseenter', self.mouseenter.bind(self)); 264 | 265 | return self; 266 | } 267 | 268 | proto.move = function (e) { 269 | var self = this; 270 | if (!this.shown()) return 271 | 272 | switch(e.keyCode) { 273 | case 9: // tab 274 | case 13: // enter 275 | case 27: // escape 276 | e.preventDefault() 277 | break 278 | 279 | case 38: // up arrow 280 | e.preventDefault() 281 | self.prev() 282 | break 283 | 284 | case 40: // down arrow 285 | e.preventDefault() 286 | self.next() 287 | break 288 | } 289 | 290 | e.stopPropagation() 291 | } 292 | 293 | proto.keydown = function (e) { 294 | this.suppressKeyPressRepeat = [40,38,9,13,27].indexOf(e.keyCode) >= 0 295 | this.move(e) 296 | } 297 | 298 | proto.keypress = function (e) { 299 | if (this.suppressKeyPressRepeat) return 300 | this.move(e) 301 | } 302 | 303 | proto.keyup = function (e) { 304 | var self = this; 305 | 306 | switch(e.keyCode) { 307 | case 40: // down arrow 308 | case 38: // up arrow 309 | break 310 | 311 | case 9: // tab 312 | case 13: // enter 313 | if (!this.shown()) return 314 | self.select() 315 | break 316 | 317 | case 27: // escape 318 | if (!self.shown()) return 319 | self.hide() 320 | break 321 | 322 | default: 323 | self.lookup() 324 | } 325 | 326 | e.stopPropagation() 327 | e.preventDefault() 328 | } 329 | 330 | proto.blur = function () { 331 | var self = this; 332 | setTimeout(function () { self.hide() }, 150); 333 | } 334 | 335 | proto.click = function (e) { 336 | e.stopPropagation(); 337 | e.preventDefault(); 338 | this.select(); 339 | } 340 | 341 | proto.mouseenter = function (e) { 342 | this.active().classList.remove('active'); 343 | e.currentTarget.classList.add('active'); 344 | } 345 | 346 | return Typeahead; 347 | 348 | }()); --------------------------------------------------------------------------------