├── .babelrc
├── .eslintrc.json
├── .gitignore
├── .npmignore
├── LICENSE.md
├── README.md
├── bablerc
├── jest.config.js
├── package-lock.json
├── package.json
├── public
├── app.css
├── favicon.ico
└── index.html
├── simple-single-select.png
├── single-select-happy.png
├── src
├── App.vue
├── VueSingleSelect.vue
├── index.js
├── main.js
└── pointerScroll.js
├── tests
└── unit
│ └── single-select.spec.js
├── webpack.config.js
├── wrapper.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "transform-es2015-modules-commonjs"
4 | ]
5 | }
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:vue/essential"
9 | ],
10 | "globals": {
11 | "Atomics": "readonly",
12 | "SharedArrayBuffer": "readonly"
13 | },
14 | "parserOptions": {
15 | "ecmaVersion": 2018,
16 | "sourceType": "module"
17 | },
18 | "plugins": [
19 | "vue"
20 | ],
21 | "rules": {
22 | }
23 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
63 | #dist
64 | dist/
65 |
66 | #emacs
67 | *~
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | #
2 | webpack.config.js
3 | *~
4 | src
5 | public
6 | .babelrc
7 | jest.config.js
8 | coverage
9 | *.png
10 | yarn*
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Rob Rogers
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vue-single-select
2 |
3 | simple autocomplete select dropdown component for Vue apps for you!
4 |
5 | ## Demo
6 | [Check it out on CodeSandbox](https://codesandbox.io/s/vue-template-sgjfj?fontsize=14)
7 |
8 | ## What It Does
9 | vue-single-select provides a **simple** component for making long, unwieldy select boxes more friendly, like Chosen for jQuery.
10 |
11 | ## How Simple?
12 |
13 | This **simple**
14 |
15 | ```html
16 |
20 | ```
21 |
22 |
23 |
24 | ## What It Does Not Do
25 |
26 | Nope no Multi Select. See vue-taggable-select for this.
27 |
28 | [Vue Taggable Select](https://www.npmjs.com/package/vue-taggable-select)
29 |
30 | ### Install or Use Via CDN
31 |
32 | ```html
33 |
34 |
35 |
39 |
40 | ```
41 |
42 | ```html
43 |
44 |
45 |
54 | ````
55 |
56 | ### Install Via NPM
57 |
58 | ```bash
59 | $ npm i vue-single-select
60 | ```
61 |
62 | ### Register it
63 |
64 | In your component:
65 |
66 | ```javascript
67 | import VueSingleSelect from "vue-single-select";
68 | export default {
69 | components: {
70 | VueSingleSelect
71 | },
72 | //...
73 | }
74 | ```
75 |
76 | Globally:
77 |
78 | ```javascript
79 | import VueSingleSelect from "vue-single-select";
80 | Vue.component('vue-single-select', VueSingleSelect);
81 | ```
82 |
83 | ### Use It
84 |
85 | ```html
86 |
91 | ```
92 |
93 | ### Use It Again
94 |
95 | #### Specify a custom option label and option value
96 |
97 | Here each option refereneces a post title in the **posts** list in data.
98 | The option value references a post id in the same list. Like:
99 |
100 | ```
101 | posts: [{title: "ok dude", id: 1}, {title: "awesome dude", id: 2}, ...]
102 | ```
103 |
104 | ```
105 |
106 | ```
107 |
108 | ```html
109 |
121 | ```
122 |
123 | ### Use It Again
124 |
125 | #### Specify a custom option label.
126 |
127 | Here the Option Label references a reply the **replies** list in data.
128 | With a format like:
129 | ```
130 | replies: [{reply: "ok dude"}, {reply: "awesome dude"}, ...]
131 | ```
132 |
133 | ```html
134 |
146 | ```
147 |
148 | ### Dont like the Styling?
149 |
150 | You can override some of it. Like so:
151 |
152 | ```html
153 |
171 | ```
172 |
173 | Then all you need to do is provide some class definitions like so:
174 |
175 | ```css
176 | .form-control {
177 | color: pink;
178 | width: 10000px;
179 | _go: nuts;
180 | }
181 | .glyphicon {
182 | display:none;
183 | }
184 | .form-group {
185 | background-color: pink;
186 | font-size: 16px;
187 | }
188 |
189 | .required {
190 | color: #721c24;
191 | background-color: #f8d7da;
192 | border-color: #f5c6cb;
193 | }
194 | .dropdown: {
195 | color: violet;
196 | }
197 | .active {
198 | background-color: lemonchiffon;
199 | }
200 |
201 | ```
202 |
203 | **Note: Bootstrap 3 Users May want to increase the size of the icons.**
204 |
205 | If so do this:
206 | ```css
207 | .icons svg {
208 | height: 1em;
209 | width: 1em;
210 | }
211 | ```
212 |
213 | #### See defaults below.
214 |
215 | ### Dont like the styling at all?
216 |
217 | Use the slots option to really mix it up.
218 |
219 | This is a little advanced, but it's not too hard. Take a look:
220 |
221 | ```html
222 |
228 |
229 |
232 |
233 | {{idx}}
234 |
235 | {{option.title}}
236 |
237 |
238 |
239 | ```
240 |
241 | ```css
242 | .emoji-happy::before {
243 | content:"\1F600"
244 | }
245 | .emoji-sad::before {
246 | content:"\1F622"
247 | }
248 | ```
249 | The key is the **template** element.
250 |
251 | Here I give you the option and the current index. From there you can add html, add exta info, or a smiley face.
252 |
253 | And here you go:
254 |
255 | 
256 | ### Kitchen Sink
257 |
258 | Meh, see props below.
259 |
260 | ## Why vue-single-select is better
261 |
262 | 1. It handles custom label/value props for displaying options.
263 |
264 | Other select components require you to conform to their format. Which often means data wrangling.
265 |
266 | 2. It's easier on the DOM.
267 |
268 | Other components will load up all the options available in the select element. This can be heavy. vue-single-select makes an executive decision that you probably will not want to scroll more than N options before you want to narrow things down a bit. You can change this, but the default is 30.
269 |
270 | 3. Snappy Event Handling
271 |
272 | - up and down arrows for selecting options
273 | - enter to select first match
274 | - remembers selection on change
275 | - hit the escape key to, well, escape
276 |
277 | 4. Lightweight
278 |
279 | - Why are the other packages so big and actually have dependencies?
280 |
281 | 5. It works for regular 'POST backs' to the server.
282 |
283 | If you are doing a regular post or just gathering the form data you don't need to do anything extra to provide a name and value for the selected option.
284 |
285 | 6. Mine just looks nicer
286 |
287 | 7. It's simple!!
288 |
289 | ## Available Props:
290 |
291 | There are more props than I'd like. But I needed them so you might too.
292 |
293 | ```javascript
294 | props: {
295 | value: {
296 | required: true
297 | },
298 | // Give your element a name.
299 | // Good for doing a POST
300 | name: {
301 | type: String,
302 | required: false,
303 | default: () => ""
304 | },
305 | // Your list of things for the select
306 | options: {
307 | type: Array,
308 | required: false,
309 | default: () => []
310 | },
311 | // Tells vue-single-select what key to use
312 | // for generating option labels
313 | optionLabel: {
314 | type: String,
315 | required: false,
316 | default: () => null
317 | },
318 | // Tells vue-single-select the value
319 | // you want populated in the select for the
320 | // input
321 | optionKey: {
322 | type: String,
323 | required: false,
324 | default: () => null
325 | },
326 | placeholder: {
327 | type: String,
328 | required: false,
329 | default: () => "Search Here"
330 | },
331 | maxHeight: {
332 | type: String,
333 | default: () => "220px",
334 | required: false
335 | },
336 | // Give your input an html element id
337 | inputId: {
338 | type: String,
339 | default: () => "single-select",
340 | required: false
341 | },
342 | //Customize the styling by providing
343 | //these FIVE custom style definitions.
344 | classes: {
345 | type: Object,
346 | required: false,
347 | default: () => {
348 | return {
349 | wrapper: "single-select-wrapper",
350 | input: "search-input",
351 | icons: "icons",
352 | required: "required",
353 | activeClass: 'active',
354 | dropdown: 'dropdown'
355 | };
356 | }
357 | },
358 | // Seed search text with initial value
359 | initial: {
360 | type: String,
361 | required: false,
362 | default: () => null
363 | },
364 | // Disable it!
365 | disabled: {
366 | type: Boolean,
367 | required: false,
368 | default: () => false
369 | },
370 | // Make it required
371 | required: {
372 | type: Boolean,
373 | required: false,
374 | default: () => false
375 | },
376 | // Number of results to show at a time
377 | maxResults: {
378 | type: Number,
379 | required: false,
380 | default: () => 30
381 | },
382 | // meh...
383 | tabindex: {
384 | type: String,
385 | required: false,
386 | default: () => {
387 | return "";
388 | }
389 | },
390 | // Tell vue-single-select what to display
391 | // as the selected option
392 | getOptionDescription: {
393 | type: Function,
394 | default: function (option) {
395 | if (this.optionKey && this.optionLabel) {
396 | return option[this.optionKey] + " " + option[this.optionLabel];
397 | }
398 | if (this.optionLabel) {
399 | return option[this.optionLabel];
400 | }
401 | if (this.optionKey) {
402 | return option[this.optionKey];
403 | }
404 | return option;
405 | }
406 | },
407 | // Use this to actually give vue-single-select
408 | // a value for doing a POST
409 | getOptionValue: {
410 | type: Function,
411 | default: function (option) {
412 | if (this.optionKey) {
413 | return option[this.optionKey];
414 | }
415 |
416 | if (this.optionLabel) {
417 | return option[this.optionLabel];
418 | }
419 |
420 | return option;
421 | }
422 | },
423 | //Default filtering, provide your own for fun
424 | //Like startsWith instead of includes
425 | filterBy: {
426 | type: Function,
427 | default: function (option) {
428 | if (this.optionLabel && this.optionKey) {
429 | return (
430 | option[this.optionLabel]
431 | .toString()
432 | .toLowerCase()
433 | .includes(this.searchText.toString().toLowerCase()) ||
434 | option[this.optionKey]
435 | .toString()
436 | .toLowerCase()
437 | .includes(this.searchText.toString().toLowerCase())
438 | )
439 | }
440 |
441 | if (this.optionLabel) {
442 | return option[this.optionLabel]
443 | .toString()
444 | .toLowerCase()
445 | .includes(this.searchText.toString().toLowerCase())
446 | }
447 |
448 | if (this.optionKey) {
449 | option[this.optionKey]
450 | .toString()
451 | .toLowerCase()
452 | .includes(this.searchText.toString().toLowerCase())
453 | }
454 |
455 | return option
456 | .toString()
457 | .toLowerCase()
458 | .includes(this.searchText.toString().toLowerCase())
459 | }
460 | }
461 | }
462 | ```
463 |
464 | ## Q&A
465 |
466 | Q. _What about Ajax?_
467 |
468 | A. Good question. Why aren't you passing data in as a prop?
469 | Seriously, this is just a widget why does it need knowledge of it's data source?
470 |
471 | Q. _How do I change how items are filtered?_
472 |
473 | A. Easy. See prop above, `matchingOptions`. Just override it with your own method as a prop.
474 |
475 | Q. _What about Templating?_
476 |
477 | A. What about it? Just use the new scoped slots!
478 |
479 | Q. _What about Multiple Selects?_
480 |
481 | A. Nope not found here. See vue-taggable-select
482 |
483 | Q. _Can I trust this?_
484 |
485 | A. Yep. It's backed by tests using jest and vue test utils.
486 |
--------------------------------------------------------------------------------
/bablerc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["env", { "modules": false }],
4 | "stage-3"
5 | ]
6 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | moduleFileExtensions: [
3 | 'js',
4 | 'jsx',
5 | 'json',
6 | 'vue'
7 | ],
8 | transform: {
9 | '^.+\\.vue$': 'vue-jest',
10 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
11 | '^.+\\.jsx?$': 'babel-jest'
12 | },
13 | moduleNameMapper: {
14 | '^@/(.*)$': '/src/$1'
15 | },
16 | snapshotSerializers: [
17 | 'jest-serializer-vue'
18 | ],
19 | testMatch: [
20 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
21 | ],
22 | testURL: 'http://localhost/',
23 | collectCoverage: true,
24 | "coveragePathIgnorePatterns": [
25 | "/node_modules/",
26 | "testconfig.js",
27 | "package.json",
28 | "package-lock.json"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-single-select",
3 | "version": "1.1.0",
4 | "scripts": {
5 | "serve": "vue-cli-service serve",
6 | "build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
7 | "lint": "vue-cli-service lint",
8 | "prepublish": "npm build",
9 | "vbuild": "vue-cli-service build",
10 | "dev": "cross-env NODE_ENV=development webpack",
11 | "test:unit": "vue-cli-service test:unit",
12 | "test": "jest"
13 | },
14 | "devDependencies": {
15 | "@vue/cli-plugin-unit-jest": "^3.0.4",
16 | "@vue/cli-service": "^3.0.4",
17 | "@vue/test-utils": "^1.0.0-beta.25",
18 | "babel-core": "^6.26.3",
19 | "babel-loader": "^8.0.4",
20 | "babel-preset-env": "^1.6.0",
21 | "babel-preset-stage-3": "^6.24.1",
22 | "cache-loader": "^1.2.2",
23 | "copy-webpack-plugin": "^4.5.2",
24 | "cross-env": "^5.0.5",
25 | "css-loader": "^1.0.0",
26 | "eslint": "^6.1.0",
27 | "eslint-plugin-vue": "^5.2.3",
28 | "file-loader": "^2.0.0",
29 | "fsevents": "1.2.9",
30 | "global": "^4.3.2",
31 | "vue": "^2.5.11",
32 | "vue-loader": "15.4.2",
33 | "vue-template-compiler": "^2.4.4",
34 | "webpack": "^4.16.3",
35 | "webpack-cli": "^3.1.0",
36 | "webpack-dev-server": "^3.1.9",
37 | "webpack-merge": "^4.1.3"
38 | },
39 | "unpkg": "dist/vue-single-select.js",
40 | "main": "dist/index",
41 | "repository": {
42 | "type": "git",
43 | "url": "git+https://github.com/robrogers3/vue-single-select.git"
44 | },
45 | "description": "single select autocomplete dropdown for vue",
46 | "author": "Rob Rogers",
47 | "homepage": "https://github.com/robrogers3/vue-single-select#readme",
48 | "bugs": {
49 | "url": "https://github.com/robrogers3/vue-single-select/issues"
50 | },
51 | "browser": {
52 | "./sfc": "dist/vue-single-select.vue"
53 | },
54 | "peerDependencies": {
55 | "vue": "2.x"
56 | },
57 | "keywords": [
58 | "vue",
59 | "js",
60 | "dropdown",
61 | "autocomplete",
62 | "select",
63 | "element"
64 | ],
65 | "license": "ISC",
66 | "dependencies": {}
67 | }
68 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robrogers3/vue-single-select/ad18f93dd0247a6ca8a0236418294751af5cce80/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | xhello-world
16 |
23 |
24 |
25 |
26 |
27 |
h1. Bootstrap heading
28 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/simple-single-select.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robrogers3/vue-single-select/ad18f93dd0247a6ca8a0236418294751af5cce80/simple-single-select.png
--------------------------------------------------------------------------------
/single-select-happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robrogers3/vue-single-select/ad18f93dd0247a6ca8a0236418294751af5cce80/single-select-happy.png
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
33 |
34 |
120 |
121 |
148 |
--------------------------------------------------------------------------------
/src/VueSingleSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
22 |
23 |
28 |
33 |
38 |
39 |
40 |
49 | -
61 | {{ getOptionDescription(option) }}
62 |
63 |
64 |
65 |
66 |
67 |
86 |
87 |
88 |
387 |
588 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import VueSingleSelect from './VueSingleSelect.vue';
2 | export default VueSingleSelect;
3 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import App from './App.vue'
3 |
4 | Vue.config.productionTip = false
5 |
6 | new Vue({
7 | render: h => h(App)
8 | }).$mount('#app')
9 |
--------------------------------------------------------------------------------
/src/pointerScroll.js:
--------------------------------------------------------------------------------
1 | // flow scored from vue select thanks Jeff!
2 |
3 | export default {
4 | watch: {
5 | pointer() {
6 | this.maybeAdjustScroll()
7 | }
8 | },
9 | data() {
10 | return {
11 | pointer: -1
12 | }
13 | },
14 | methods: {
15 | /**
16 | * Adjust the scroll position of the dropdown list
17 | * if the current pointer is outside of the
18 | * overflow bounds.
19 | * @returns {*}
20 | */
21 | maybeAdjustScroll () {
22 | let pixelsToPointerTop = this.pixelsToPointerTop()
23 | let pixelsToPointerBottom = this.pixelsToPointerBottom()
24 |
25 | if ( pixelsToPointerTop <= this.viewport().top) {
26 | return this.scrollTo( pixelsToPointerTop )
27 | } else if (pixelsToPointerBottom >= this.viewport().bottom) {
28 | return this.scrollTo( this.viewport().top + this.pointerHeight() )
29 | }
30 | },
31 |
32 | /**
33 | * The distance in pixels from the top of the dropdown
34 | * list to the top of the current pointer element.
35 | * @returns {number}
36 | */
37 | pixelsToPointerTop() {
38 | let pixelsToPointerTop = 0
39 | if( !this.$refs.options ) {
40 | return 0;
41 | }
42 |
43 | for (let i = 0; i < this.pointer; i++) {
44 | pixelsToPointerTop += this.$refs.options.children[i].offsetHeight
45 | }
46 |
47 | return pixelsToPointerTop
48 | },
49 |
50 | /**
51 | * The distance in pixels from the top of the dropdown
52 | * list to the bottom of the current pointer element.
53 | * @returns {*}
54 | */
55 | pixelsToPointerBottom() {
56 | return this.pixelsToPointerTop() + this.pointerHeight()
57 | },
58 |
59 | /**
60 | * The offsetHeight of the current pointer element.
61 | * @returns {number}
62 | */
63 | pointerHeight() {
64 | let element = this.$refs.options ? this.$refs.options.children[this.pointer] : false
65 | return element ? element.offsetHeight : 0
66 | },
67 |
68 | /**
69 | * The currently viewable portion of the options.
70 | * @returns {{top: (string|*|number), bottom: *}}
71 | */
72 | viewport() {
73 | return {
74 | top: this.$refs.options ? this.$refs.options.scrollTop: 0,
75 | bottom: this.$refs.options ? this.$refs.options.offsetHeight + this.$refs.options.scrollTop : 0
76 | }
77 | },
78 |
79 | /**
80 | * Scroll the options to a given position.
81 | * @param position
82 | * @returns {*}
83 | */
84 | scrollTo(position) {
85 | return this.$refs.options ? this.$refs.options.scrollTop = position : null
86 | },
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/tests/unit/single-select.spec.js:
--------------------------------------------------------------------------------
1 | import { shallowMount, mount } from '@vue/test-utils'
2 | import VueSingleSelect from '@/VueSingleSelect.vue'
3 | let wrapper
4 |
5 | describe('VueSingleSelect', () => {
6 | let someOptions = ['apple','cherry','pear','peach','banana','orange','plum', 'grape']
7 | beforeEach(() => {
8 | wrapper = shallowMount(VueSingleSelect, {
9 | propsData: {
10 | value: null,
11 | options: someOptions
12 | }
13 | })
14 | })
15 |
16 | it('needs some props', () => {
17 | expect(wrapper.props().options).toBe(someOptions)
18 | expect(wrapper.props().value).toBe(null)
19 | })
20 |
21 | it('allows selecting an option with mouse', () => {
22 | type('cher', '.search-input')
23 | see('cherry', 'ul')
24 | see('cherry', 'li')
25 | click('li:first-child')
26 | has('cherry','input')
27 | })
28 |
29 | it('allows selecting an option with enter', () => {
30 | wrapper.setProps({
31 | name: 'fruit'
32 | })
33 | type('cher', '.search-input')
34 | see('cherry', 'li')
35 | triggerKeyUp('.search-input', 'enter')
36 | has('cherry','.search-input')
37 | const nameInput = wrapper.find('input[name=fruit]')
38 | expect(nameInput.element.value).toBe('cherry')
39 | })
40 | it('allows selecting an option with cursor', () => {
41 | wrapper.setProps({
42 | name: 'fruit'
43 | })
44 | focus('.search-input')
45 | triggerKeyUp('.search-input', 'down')
46 | see('apple','li:first-child')
47 | click('li:first-child')
48 | const nameInput = wrapper.find('input[name=fruit]')
49 | expect(nameInput.element.value).toBe('apple')
50 | })
51 | it('handles objects with labels and keys', () => {
52 | const options = [
53 | {'title': 'foo', id: 1},
54 | {'title': 'bar', id: 2},
55 | {'title': 'baz', id: 3},
56 | {'title': 'zazz', id: 4}
57 | ]
58 | wrapper.setProps({
59 | options,
60 | optionLabel: 'title',
61 | optionKey: 'id'
62 | })
63 | expect(wrapper.props().options).toBe(options)
64 | type('a', 'input.search-input')
65 | expect(wrapper.findAll('li').length).toBe(3)
66 | see('bar','li:first-child')
67 | click('li:first-child')
68 | has('2 bar', '.search-input')
69 | })
70 | it('can toggle the dropdown', () => {
71 | expect(wrapper.findAll('ul').length).toBe(0)
72 | click('svg');
73 | expect(wrapper.findAll('ul').length).toBe(1)
74 | });
75 | it('can escape out of the input', () => {
76 | focus('.search-input')
77 | expect(wrapper.findAll('ul').length).toBe(1)
78 | triggerKeyUp('.search-input', 'tab')
79 | expect(wrapper.findAll('ul').length).toBe(0)
80 | })
81 | it('allows you to unselect an option', () => {
82 | type('cher', '.search-input')
83 | see('cherry', 'ul')
84 | see('cherry', 'li')
85 | click('li:first-child')
86 | has('cherry','input')
87 | click('svg')
88 | has('', 'input')
89 | })
90 | it('disabled prop disables selection', () => {
91 | wrapper.setProps({
92 | disabled: true
93 | })
94 | expect(wrapper.findAll('ul').length).toBe(0)
95 | click('svg');
96 | expect(wrapper.findAll('ul').length).toBe(0)
97 | type('cher', '.search-input');
98 | expect(wrapper.findAll('ul').length).toBe(0)
99 | expect(wrapper.find({ref: 'search'}).element.disabled).toBe(true)
100 | expect(wrapper.find({ref: 'search'}).exists()).toBe(true)
101 | expect(wrapper.find({ref: 'match'}).exists()).toBe(false)
102 | })
103 | it('it seeds the search text from the selected option', () => {
104 | wrapper.setProps({
105 | required: true
106 | })
107 | expect(wrapper.find({ref: 'match'}).exists()).toBe(false)
108 | type('cher', '.search-input')
109 | see('cherry', 'ul')
110 | see('cherry', 'li')
111 | click('li:first-child')
112 | has('cherry', '.search-input')
113 | expect(wrapper.find({ref: 'match'}).exists()).toBe(true)
114 | type('cherry', '.search-input')
115 | expect(wrapper.find({ref: 'match'}).exists()).toBe(false)
116 | has('cherry', '.search-input')
117 | })
118 | it('will set the required prop on the search element', () => {
119 | wrapper.setProps({
120 | required: true
121 | })
122 | expect(wrapper.find('input[required=required]').exists()).toBe(true)
123 | let el = wrapper.find('input[required=required]')
124 | expect(el.element.classList.toString().includes('required')).toBe(true)
125 | type('cher', '.search-input')
126 | click('li:first-child')
127 | expect(wrapper.find('input[required=required]').exists()).toBe(true)
128 | el = wrapper.find('input[required=required]')
129 | expect(el.element.classList.toString().includes('required')).toBe(false)
130 |
131 | })
132 | it('toggles the search input when toggling dropdown', () => {
133 | type('cher', '.search-input')
134 | see('cherry', 'ul')
135 | see('cherry', 'li')
136 | click('li:first-child')
137 | has('cherry', '.search-input')
138 | focus('.search-input')
139 | has('cherry', '.search-input')
140 | click('svg')
141 | has('', '.search-input')
142 | })
143 | it('shows no items when there is no search input', () => {
144 | expect(wrapper.findAll('li').length).toBe(0)
145 | })
146 | it('shows no items when there are no matching items', () => {
147 | type('nono', '.search-input')
148 | expect(wrapper.findAll('li').length).toBe(0)
149 | })
150 | it('shows no items when there are no matching items', () => {
151 | type('nono', '.search-input')
152 | expect(wrapper.findAll('li').length).toBe(0)
153 | })
154 | it('respects the updated value prop', () => {
155 | type('cher', '.search-input')
156 | click('li:first-child')
157 | has('cherry','input')
158 | wrapper.setProps({
159 | value: 'apple'
160 | })
161 | has('apple', 'input')
162 | })
163 | it('shows all options when search in focused', () => {
164 | focus('.search-input')
165 | expect(wrapper.findAll('li').length).toEqual(someOptions.length)
166 | })
167 | it('only shows the maximum amount of options', () => {
168 | wrapper.setProps({
169 | maxResults: 3
170 | })
171 | focus('.search-input')
172 | expect(wrapper.findAll('li').length).toEqual(3)
173 | })
174 | it('allows for a custom option description', () => {
175 | wrapper.setProps({
176 | getOptionDescription: (option) => {
177 | return option + ' zed'
178 | }
179 | })
180 | focus('.search-input')
181 | see('apple zed', 'li:first-child')
182 | type('cherry', '.search-input')
183 | see('cherry zed', 'li:first-child')
184 | click('li:first-child')
185 | has('cherry zed', '.search-input')
186 | })
187 | it('allows for a custom option value', () => {
188 | wrapper.setProps({
189 | getOptionValue: (option) => {
190 | return option + ' fred'
191 | },
192 | name: 'fruit'
193 | })
194 | type('cherry', '.search-input')
195 | click('li:first-child')
196 | const nameInput = wrapper.find('input[name=fruit]')
197 | expect(nameInput.element.value).toBe('cherry fred')
198 | })
199 | it('allows for a custom matching options prop', () => {
200 | wrapper.setProps({
201 | filterBy: function (option) {
202 | return option
203 | .toString()
204 | .toLowerCase()
205 | .includes(this.searchText.toString().toLowerCase())
206 | }
207 | })
208 | type('erry', '.search-input');
209 | see('cherry', 'li:first-child')
210 | wrapper.setProps({
211 | filterBy: function (option) {
212 | return option
213 | .toString()
214 | .toLowerCase()
215 | .startsWith(this.searchText.toString().toLowerCase())
216 | }
217 | })
218 | type('erry', '.search-input');
219 | notSee('cherry')
220 | });
221 | it('updating selected resets search', () => {
222 | wrapper.setProps({
223 | name: 'fruit'
224 | })
225 | type('cher', '.search-input')
226 | click('li:first-child')
227 | has('cherry', '.search-input');
228 | expect(wrapper.find('input[name=fruit]').element.value).toBe('cherry')
229 | type('', '.search-input');
230 | //click({ ref: 'match' })
231 | expect(wrapper.find({ ref: 'selectedValue' }).exists()).toBe(false)
232 | })
233 | });
234 | const peek = (opt) => {
235 | console.log('peek', wrapper.vm[opt])
236 | };
237 | const has = (text, selector) => {
238 | let wrap = selector ? wrapper.find(selector) : wrapper
239 | expect(wrap.element.value).toBe(text)
240 | };
241 | let see = (text, selector) => {
242 | let wrap = selector ? wrapper.find(selector) : wrapper
243 | expect(wrap.html()).toContain(text)
244 | };
245 | let notSee = (text, selector) => {
246 | let wrap = selector ? wrapper.find(selector) : wrapper
247 | expect(wrap.html()).not.toContain(text)
248 | };
249 | let type = (text, selector) => {
250 | let node = wrapper.find(selector);
251 | node.element.value = text;
252 | node.trigger('input');
253 | };
254 | let focus = (selector) => {
255 | wrapper.find(selector).trigger('focus');
256 | };
257 | let click = (selector) => {
258 | wrapper.find(selector).trigger('click');
259 | };
260 | let triggerKeyUp = (selector, type) => {
261 | type = 'keyup.' + type;
262 | wrapper.find(selector).trigger(type);
263 | };
264 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const merge = require('webpack-merge');
3 | const path = require('path');
4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
5 | const CopyWebpackPlugin = require('copy-webpack-plugin');
6 | const VueLoaderPlugin = require('vue-loader/lib/plugin')
7 |
8 | function resolve (dir) {
9 | return path.join(__dirname, '..', dir)
10 | }
11 |
12 | var config = {
13 | mode: process.env.NODE_ENV,
14 | output: {
15 | path: path.resolve(__dirname + '/dist/'),
16 | },
17 | resolve: {
18 | alias: {
19 | 'vue$': 'vue/dist/vue.common.js'
20 | }
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.vue$/,
26 | loader: 'vue-loader',
27 | },
28 | {
29 | test: /\.js$/,
30 | loader: 'babel-loader',
31 | include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
32 | },
33 | {
34 | test: /\.css$/,
35 | use: [
36 | 'vue-style-loader',
37 | 'css-loader'
38 | ]
39 | },
40 | ]
41 | },
42 | plugins: [
43 | new VueLoaderPlugin(),
44 | new CopyWebpackPlugin(
45 | [
46 | {
47 | from: path.resolve(__dirname + '/src/'),
48 | to: path.resolve(__dirname + '/dist/'),
49 | ignore: [
50 | '*~',
51 | '.DS_Store',
52 | 'App.vue',
53 | 'main.js'
54 | ]
55 | }
56 | ]
57 | )
58 | ],
59 | optimization: {
60 |
61 | }
62 | };
63 |
64 | module.exports = [
65 |
66 | // Config 1: For browser environment
67 | merge(config, {
68 | entry: path.resolve(__dirname, './wrapper.js'),
69 | output: {
70 | filename: 'vue-single-select.js',
71 | libraryTarget: 'window',
72 | }
73 | }),
74 |
75 | // Config 2: For Node-based development environments
76 | // merge(config, {
77 | // entry: path.resolve(__dirname, './single-select.vue'),
78 | // output: {
79 | // filename: 'vue-clock.min.js',
80 | // libraryTarget: 'umd',
81 | // }
82 | // })
83 | ];
84 | if (process.env.NODE_ENV === 'production') {
85 | module.exports.devtool = '#source-map'
86 | // http://vue-loader.vuejs.org/en/workflow/production.html
87 | // module.exports.plugins = (module.exports.plugins || []).concat([
88 | // new webpack.LoaderOptionsPlugin({
89 | // minimize: true
90 | // })
91 | // ])
92 | }
93 |
--------------------------------------------------------------------------------
/wrapper.js:
--------------------------------------------------------------------------------
1 | // Import vue component
2 | import VueSingleSelect from './src/VueSingleSelect.vue';
3 |
4 | // Declare install function executed by Vue.use()
5 | export function install(Vue) {
6 | if (install.installed) return;
7 | install.installed = true;
8 | Vue.component('vue-single-select', VueSingleSelect);
9 | }
10 |
11 | // Create module definition for Vue.use()
12 | const plugin = {
13 | install,
14 | };
15 |
16 | // Auto-install when vue is found (eg. in browser via