├── .gitignore ├── src ├── images │ └── dynamic-select.png ├── index.js └── DynamicSelect.vue ├── LICENSE ├── package.json ├── README.md └── dist ├── vue-dynamic-select.esm.js ├── vue-dynamic-select.min.js └── vue-dynamic-select.umd.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /src/images/dynamic-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/silasmontgomery/vue-dynamic-select/HEAD/src/images/dynamic-select.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import DynamicSelect from './DynamicSelect.vue' 2 | 3 | // Declare install function executed by Vue.use() 4 | export function install(Vue) { 5 | if (install.installed) return; 6 | install.installed = true; 7 | Vue.component('DynamicSelect', DynamicSelect); 8 | } 9 | 10 | // Create module definition for Vue.use() 11 | const plugin = { 12 | install, 13 | }; 14 | 15 | // Auto-install when vue is found (eg. in browser via 169 | 170 | 224 | -------------------------------------------------------------------------------- /dist/vue-dynamic-select.esm.js: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // 4 | // 5 | // 6 | // 7 | // 8 | // 9 | // 10 | // 11 | // 12 | // 13 | // 14 | // 15 | 16 | var script = { 17 | props: { 18 | placeholder: { 19 | type: String, 20 | default: 'search', 21 | required: false 22 | }, 23 | options: { 24 | type: Array, 25 | default: function() { 26 | return [] 27 | }, 28 | required: true 29 | }, 30 | optionValue: { 31 | type: String, 32 | default: 'id', 33 | required: true 34 | }, 35 | optionText: { 36 | type: String, 37 | default: 'name', 38 | required: true 39 | }, 40 | value: { 41 | type: Object, 42 | default: function() { 43 | return null 44 | }, 45 | required: false 46 | } 47 | }, 48 | data: function() { 49 | return { 50 | hasFocus: false, 51 | search: null, 52 | selectedOption: this.value, 53 | selectedResult: 0 54 | }; 55 | }, 56 | mounted: function mounted() { 57 | // Add onclick method to body to hide result list when component loses focus 58 | window.addEventListener("click", this.loseFocus); 59 | }, 60 | destroyed: function destroyed() { 61 | window.removeEventListener("click", this.loseFocus); 62 | }, 63 | computed: { 64 | results: function() { 65 | var this$1 = this; 66 | 67 | // Filter items on search text (if not empty, case insensitive) and when item isn't already selected (else return all items not selected) 68 | return this.search ? this.options.filter(function (i) { return String(i[this$1.optionText]).toLowerCase().indexOf(this$1.search.toLowerCase()) > -1; }) : this.options; 69 | }, 70 | showResultList: function() { 71 | return this.hasFocus && this.results.length > 0; 72 | }, 73 | showPlaceholder: function() { 74 | return !this.hasFocus && !this.selectedOption; 75 | } 76 | }, 77 | watch: { 78 | hasFocus: function(hasFocus) { 79 | // Clear the search box when component loses focus 80 | window.removeEventListener("keydown", this.stopScroll); 81 | if(hasFocus) { 82 | window.addEventListener("keydown", this.stopScroll); 83 | this.$refs.search.focus(); 84 | } else { 85 | this.search = null; 86 | this.selectedResult = 0; 87 | this.$refs.search.blur(); 88 | } 89 | }, 90 | value: function() { 91 | var this$1 = this; 92 | 93 | // Load selected option on prop value change 94 | this.selectedOption = this.options.find( function (option) { 95 | return this$1.value && option[this$1.optionValue] == this$1.value[this$1.optionValue]; 96 | }); 97 | }, 98 | selectedOption: function() { 99 | // Provide selected item to parent 100 | this.$emit('input', this.selectedOption); 101 | }, 102 | search: function() { 103 | // Provide search text to parent (for ajax fetching, etc) 104 | this.$emit('search', this.search); 105 | } 106 | }, 107 | methods: { 108 | selectOption: function(option) { 109 | this.selectedOption = option; 110 | this.hasFocus = false; 111 | }, 112 | removeOption: function(event) { 113 | // Remove selected option if user hits backspace on empty search field 114 | if(event.keyCode === 8 && (this.search == null || this.search == '')) { 115 | this.selectedOption = null; 116 | this.hasFocus = false; 117 | event.preventDefault(); 118 | } 119 | }, 120 | moveToResults: function(event) { 121 | // Move down to first result if user presses down arrow (from search field) 122 | if(event.keyCode === 40) { 123 | if(this.$refs.result.length > 0) { 124 | this.$refs.resultList.children.item(0).focus(); 125 | } 126 | } 127 | }, 128 | navigateResults: function(option, event) { 129 | // Add option to selected items on enter key 130 | if(event.keyCode === 13) { 131 | this.selectOption(option); 132 | // Move up or down items in result list with up or down arrow keys 133 | } else if(event.keyCode === 40 || event.keyCode === 38) { 134 | if(event.keyCode === 40) { 135 | this.selectedResult++; 136 | } else if(event.keyCode === 38) { 137 | this.selectedResult--; 138 | } 139 | var next = this.$refs.resultList.children.item(this.selectedResult); 140 | if(next) { 141 | next.focus(); 142 | } else { 143 | this.selectedResult = 0; 144 | this.$refs.search.focus(); 145 | } 146 | } 147 | }, 148 | highlight: function(value) { 149 | // Highlights the part of each result that matches the search text 150 | if(this.search) { 151 | var matchPos = String(value).toLowerCase().indexOf(this.search.toLowerCase()); 152 | if(matchPos > -1) { 153 | var matchStr = String(value).substr(matchPos, this.search.length); 154 | value = String(value).replace(matchStr, ''+matchStr+''); 155 | } 156 | } 157 | 158 | return value; 159 | }, 160 | stopScroll: function(event) { 161 | if(event.keyCode === 40 || event.keyCode === 38) { 162 | event.preventDefault(); 163 | } 164 | }, 165 | loseFocus: function(event) { 166 | if(!this.$el.contains(event.target)) { 167 | this.hasFocus = false; 168 | } 169 | } 170 | } 171 | } 172 | 173 | function normalizeComponent(template, style, script, scopeId, isFunctionalTemplate, moduleIdentifier 174 | /* server only */ 175 | , shadowMode, createInjector, createInjectorSSR, createInjectorShadow) { 176 | if (typeof shadowMode !== 'boolean') { 177 | createInjectorSSR = createInjector; 178 | createInjector = shadowMode; 179 | shadowMode = false; 180 | } // Vue.extend constructor export interop. 181 | 182 | 183 | var options = typeof script === 'function' ? script.options : script; // render functions 184 | 185 | if (template && template.render) { 186 | options.render = template.render; 187 | options.staticRenderFns = template.staticRenderFns; 188 | options._compiled = true; // functional template 189 | 190 | if (isFunctionalTemplate) { 191 | options.functional = true; 192 | } 193 | } // scopedId 194 | 195 | 196 | if (scopeId) { 197 | options._scopeId = scopeId; 198 | } 199 | 200 | var hook; 201 | 202 | if (moduleIdentifier) { 203 | // server build 204 | hook = function hook(context) { 205 | // 2.3 injection 206 | context = context || // cached call 207 | this.$vnode && this.$vnode.ssrContext || // stateful 208 | this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext; // functional 209 | // 2.2 with runInNewContext: true 210 | 211 | if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') { 212 | context = __VUE_SSR_CONTEXT__; 213 | } // inject component styles 214 | 215 | 216 | if (style) { 217 | style.call(this, createInjectorSSR(context)); 218 | } // register component module identifier for async chunk inference 219 | 220 | 221 | if (context && context._registeredComponents) { 222 | context._registeredComponents.add(moduleIdentifier); 223 | } 224 | }; // used by ssr in case component is cached and beforeCreate 225 | // never gets called 226 | 227 | 228 | options._ssrRegister = hook; 229 | } else if (style) { 230 | hook = shadowMode ? function () { 231 | style.call(this, createInjectorShadow(this.$root.$options.shadowRoot)); 232 | } : function (context) { 233 | style.call(this, createInjector(context)); 234 | }; 235 | } 236 | 237 | if (hook) { 238 | if (options.functional) { 239 | // register for functional component in vue file 240 | var originalRender = options.render; 241 | 242 | options.render = function renderWithStyleInjection(h, context) { 243 | hook.call(context); 244 | return originalRender(h, context); 245 | }; 246 | } else { 247 | // inject component registration as beforeCreate hook 248 | var existing = options.beforeCreate; 249 | options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; 250 | } 251 | } 252 | 253 | return script; 254 | } 255 | 256 | var normalizeComponent_1 = normalizeComponent; 257 | 258 | var isOldIE = typeof navigator !== 'undefined' && /msie [6-9]\\b/.test(navigator.userAgent.toLowerCase()); 259 | function createInjector(context) { 260 | return function (id, style) { 261 | return addStyle(id, style); 262 | }; 263 | } 264 | var HEAD = document.head || document.getElementsByTagName('head')[0]; 265 | var styles = {}; 266 | 267 | function addStyle(id, css) { 268 | var group = isOldIE ? css.media || 'default' : id; 269 | var style = styles[group] || (styles[group] = { 270 | ids: new Set(), 271 | styles: [] 272 | }); 273 | 274 | if (!style.ids.has(id)) { 275 | style.ids.add(id); 276 | var code = css.source; 277 | 278 | if (css.map) { 279 | // https://developer.chrome.com/devtools/docs/javascript-debugging 280 | // this makes source maps inside style tags work properly in Chrome 281 | code += '\n/*# sourceURL=' + css.map.sources[0] + ' */'; // http://stackoverflow.com/a/26603875 282 | 283 | code += '\n/*# sourceMappingURL=data:application/json;base64,' + btoa(unescape(encodeURIComponent(JSON.stringify(css.map)))) + ' */'; 284 | } 285 | 286 | if (!style.element) { 287 | style.element = document.createElement('style'); 288 | style.element.type = 'text/css'; 289 | if (css.media) { style.element.setAttribute('media', css.media); } 290 | HEAD.appendChild(style.element); 291 | } 292 | 293 | if ('styleSheet' in style.element) { 294 | style.styles.push(code); 295 | style.element.styleSheet.cssText = style.styles.filter(Boolean).join('\n'); 296 | } else { 297 | var index = style.ids.size - 1; 298 | var textNode = document.createTextNode(code); 299 | var nodes = style.element.childNodes; 300 | if (nodes[index]) { style.element.removeChild(nodes[index]); } 301 | if (nodes.length) { style.element.insertBefore(textNode, nodes[index]); }else { style.element.appendChild(textNode); } 302 | } 303 | } 304 | } 305 | 306 | var browser = createInjector; 307 | 308 | /* script */ 309 | var __vue_script__ = script; 310 | 311 | /* template */ 312 | var __vue_render__ = function() { 313 | var _vm = this; 314 | var _h = _vm.$createElement; 315 | var _c = _vm._self._c || _h; 316 | return _c("div", [ 317 | _c( 318 | "div", 319 | { 320 | staticClass: "vue-dynamic-select", 321 | attrs: { tabindex: "0" }, 322 | on: { 323 | focusin: function($event) { 324 | _vm.hasFocus = true; 325 | } 326 | } 327 | }, 328 | [ 329 | _vm.showPlaceholder 330 | ? _c("div", { 331 | staticClass: "placeholder", 332 | domProps: { textContent: _vm._s(_vm.placeholder) } 333 | }) 334 | : _vm._e(), 335 | _vm._v(" "), 336 | _vm.selectedOption && !_vm.hasFocus 337 | ? _c("div", { 338 | staticClass: "selected-option", 339 | domProps: { 340 | textContent: _vm._s(_vm.selectedOption[_vm.optionText]) 341 | } 342 | }) 343 | : _vm._e(), 344 | _vm._v(" "), 345 | _c("input", { 346 | directives: [ 347 | { 348 | name: "model", 349 | rawName: "v-model", 350 | value: _vm.search, 351 | expression: "search" 352 | } 353 | ], 354 | ref: "search", 355 | staticClass: "search", 356 | attrs: { autocomplete: "off" }, 357 | domProps: { value: _vm.search }, 358 | on: { 359 | focus: function($event) { 360 | _vm.hasFocus = true; 361 | }, 362 | keyup: _vm.moveToResults, 363 | keydown: _vm.removeOption, 364 | input: function($event) { 365 | if ($event.target.composing) { 366 | return 367 | } 368 | _vm.search = $event.target.value; 369 | } 370 | } 371 | }), 372 | _vm._v(" "), 373 | _c("i", { staticClass: "dropdown" }), 374 | _vm._v(" "), 375 | _vm.showResultList 376 | ? _c( 377 | "div", 378 | { ref: "resultList", staticClass: "result-list" }, 379 | _vm._l(_vm.results, function(result) { 380 | return _c("div", { 381 | key: result[_vm.optionValue], 382 | ref: "result", 383 | refInFor: true, 384 | staticClass: "result", 385 | attrs: { tabindex: "0" }, 386 | domProps: { 387 | innerHTML: _vm._s(_vm.highlight(result[_vm.optionText])) 388 | }, 389 | on: { 390 | click: function($event) { 391 | return _vm.selectOption(result) 392 | }, 393 | keyup: function($event) { 394 | $event.preventDefault(); 395 | return _vm.navigateResults(result, $event) 396 | } 397 | } 398 | }) 399 | }), 400 | 0 401 | ) 402 | : _vm._e() 403 | ] 404 | ) 405 | ]) 406 | }; 407 | var __vue_staticRenderFns__ = []; 408 | __vue_render__._withStripped = true; 409 | 410 | /* style */ 411 | var __vue_inject_styles__ = function (inject) { 412 | if (!inject) { return } 413 | inject("data-v-24d1f392_0", { source: "\n.vue-dynamic-select[data-v-24d1f392] {\n border: 1px solid #ced4da; \n position: relative;\n padding: .375em .5em;\n border-radius: .25em;\n cursor: text;\n display: block;\n}\n.vue-dynamic-select i.dropdown[data-v-24d1f392] {\n width: 0; \n height: 0; \n border-left: 4px solid transparent; \n border-right: 4px solid transparent; \n border-top: 4px solid; \n float: right; \n top: .75em; \n opacity: .8; \n cursor: pointer;\n}\n.vue-dynamic-select .placeholder[data-v-24d1f392] {\n display: inline-block;\n color: #ccc;\n}\n.vue-dynamic-select .result-list[data-v-24d1f392] {\n border: 1px solid #ced4da; \n margin: calc(.375em - 1px) calc(-.5em - 1px);\n width: calc(100% + 2px);\n min-width: calc(100% + 2px);\n border-radius: 0 0 .25em .25em;\n cursor: pointer;\n position: absolute;\n z-index: 10;\n background-color: #fff;\n}\n.vue-dynamic-select .result-list .result[data-v-24d1f392] {\n padding: .375em .75em;\n color: #333;\n}\n.vue-dynamic-select .result-list .result[data-v-24d1f392]:hover, .vue-dynamic-select .result-list .result[data-v-24d1f392]:focus {\n background-color: #efefef;\n outline: none;\n}\n.vue-dynamic-select .selected-option[data-v-24d1f392] {\n display: inline-block;\n}\n.vue-dynamic-select .search[data-v-24d1f392] {\n border: none;\n width: 50px;\n}\n.vue-dynamic-select .search[data-v-24d1f392]:focus {\n outline: none;\n}\n", map: {"version":3,"sources":["/home/smontgomery/Projects/vue-dynamic-select/src/DynamicSelect.vue"],"names":[],"mappings":";AA0KA;IACA,yBAAA;IACA,kBAAA;IACA,oBAAA;IACA,oBAAA;IACA,YAAA;IACA,cAAA;AACA;AACA;IACA,QAAA;IACA,SAAA;IACA,kCAAA;IACA,mCAAA;IACA,qBAAA;IACA,YAAA;IACA,UAAA;IACA,WAAA;IACA,eAAA;AACA;AACA;IACA,qBAAA;IACA,WAAA;AACA;AACA;IACA,yBAAA;IACA,4CAAA;IACA,uBAAA;IACA,2BAAA;IACA,8BAAA;IACA,eAAA;IACA,kBAAA;IACA,WAAA;IACA,sBAAA;AACA;AACA;IACA,qBAAA;IACA,WAAA;AACA;AACA;IACA,yBAAA;IACA,aAAA;AACA;AACA;IACA,qBAAA;AACA;AACA;IACA,YAAA;IACA,WAAA;AACA;AACA;IACA,aAAA;AACA","file":"DynamicSelect.vue","sourcesContent":["\n\n\n\n\n"]}, media: undefined }); 414 | 415 | }; 416 | /* scoped */ 417 | var __vue_scope_id__ = "data-v-24d1f392"; 418 | /* module identifier */ 419 | var __vue_module_identifier__ = undefined; 420 | /* functional template */ 421 | var __vue_is_functional_template__ = false; 422 | /* style inject SSR */ 423 | 424 | 425 | 426 | var DynamicSelect = normalizeComponent_1( 427 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 428 | __vue_inject_styles__, 429 | __vue_script__, 430 | __vue_scope_id__, 431 | __vue_is_functional_template__, 432 | __vue_module_identifier__, 433 | browser, 434 | undefined 435 | ) 436 | 437 | // Declare install function executed by Vue.use() 438 | function install(Vue) { 439 | if (install.installed) { return; } 440 | install.installed = true; 441 | Vue.component('DynamicSelect', DynamicSelect); 442 | } 443 | 444 | // Create module definition for Vue.use() 445 | var plugin = { 446 | install: install, 447 | }; 448 | 449 | // Auto-install when vue is found (eg. in browser via \n\n\n"]}, media: undefined }); 417 | 418 | }; 419 | /* scoped */ 420 | var __vue_scope_id__ = "data-v-24d1f392"; 421 | /* module identifier */ 422 | var __vue_module_identifier__ = undefined; 423 | /* functional template */ 424 | var __vue_is_functional_template__ = false; 425 | /* style inject SSR */ 426 | 427 | 428 | 429 | var DynamicSelect = normalizeComponent_1( 430 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 431 | __vue_inject_styles__, 432 | __vue_script__, 433 | __vue_scope_id__, 434 | __vue_is_functional_template__, 435 | __vue_module_identifier__, 436 | browser, 437 | undefined 438 | ) 439 | 440 | // Declare install function executed by Vue.use() 441 | function install(Vue) { 442 | if (install.installed) { return; } 443 | install.installed = true; 444 | Vue.component('DynamicSelect', DynamicSelect); 445 | } 446 | 447 | // Create module definition for Vue.use() 448 | var plugin = { 449 | install: install, 450 | }; 451 | 452 | // Auto-install when vue is found (eg. in browser via \n\n\n"]}, media: undefined }); 420 | 421 | }; 422 | /* scoped */ 423 | var __vue_scope_id__ = "data-v-24d1f392"; 424 | /* module identifier */ 425 | var __vue_module_identifier__ = undefined; 426 | /* functional template */ 427 | var __vue_is_functional_template__ = false; 428 | /* style inject SSR */ 429 | 430 | 431 | 432 | var DynamicSelect = normalizeComponent_1( 433 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 434 | __vue_inject_styles__, 435 | __vue_script__, 436 | __vue_scope_id__, 437 | __vue_is_functional_template__, 438 | __vue_module_identifier__, 439 | browser, 440 | undefined 441 | ) 442 | 443 | // Declare install function executed by Vue.use() 444 | function install(Vue) { 445 | if (install.installed) { return; } 446 | install.installed = true; 447 | Vue.component('DynamicSelect', DynamicSelect); 448 | } 449 | 450 | // Create module definition for Vue.use() 451 | var plugin = { 452 | install: install, 453 | }; 454 | 455 | // Auto-install when vue is found (eg. in browser via