├── .babelrc ├── .browserslistrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── build ├── base │ └── plugins │ │ └── index.js ├── rollup.config.dev.js └── rollup.config.prod.js ├── demo ├── App.vue ├── index.html └── index.js ├── docs ├── .vuepress │ └── config.js ├── README.md └── guide │ ├── README.md │ ├── api.md │ ├── keyboard.md │ └── usage.md ├── jest.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── components │ └── VueAccessibleMultiselect │ │ └── VueAccessibleMultiselect.vue ├── config │ └── index.js ├── constants │ ├── key-codes.js │ └── type-ahead.js ├── helpers │ ├── index.js │ └── validators.js ├── index.js └── styles │ ├── core.scss │ └── themes │ └── default.scss └── test ├── fixtures ├── options.js └── value.js ├── helpers └── index.js └── index.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env"]], 3 | "env": { 4 | "test": { 5 | "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | node_modules 4 | dist/* 5 | demo/demo* 6 | coverage/* 7 | docs/.vuepress/dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/__tests__/** -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrew Vasilchuk 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-accessible-multiselect 2 | 3 | Vue.js accessible multiselect component made according to [WAI-ARIA practices](https://www.w3.org/TR/wai-aria-practices/#Listbox). 4 | 5 | ## Features 6 | 7 | - ♿️ fully accessible to screen readers; 8 | - ⌨️ supports keyboard navigation (there really a lot of keyboard shortcuts); 9 | - 🔣 type-ahead to focus option that starts with typed symbols; 10 | - 💅 style agnostic, so you can style it whatever you like (but including `core.scss` is highly encouraged). 11 | 12 | ## Links 13 | - [Documentation](https://multiselect.vue-a11y.com) 14 | - [Demo - Edit on codesandbox](https://codesandbox.io/s/vue-accessible-multiselect-u7rdh) 15 | 16 | ## Tests 17 | 18 | ### Unit 19 | 20 | [`Jest`](https://jestjs.io) and [`VueTestUtils`](https://vue-test-utils.vuejs.org) is used for unit tests. 21 | 22 | You can run unit tests by running next command: 23 | 24 | ```bash 25 | npm run test:unit 26 | ``` 27 | 28 | ## Development 29 | 30 | 1. Clone this repository 31 | 2. Install dependencies using `yarn install` or `npm install` 32 | 3. Start development server using `npm run dev` 33 | 34 | ## Build 35 | 36 | 1. To build production ready build simply run `npm run build`: 37 | 38 | After successful build the following `dist` folder will be generated: 39 | 40 | ``` 41 | ├── styles 42 | │ ├── themes 43 | │ │ ├── default.css 44 | │ ├── core.css 45 | ├── vue-accessible-multiselect.common.js 46 | ├── vue-accessible-multiselect.esm.js 47 | ├── vue-accessible-multiselect.js 48 | ├── vue-accessible-multiselect.min.js 49 | ``` 50 | 51 | ## Powered by 52 | 53 | - `Rollup` (and plugins) 54 | - `Babel` 55 | - `SASS` and `node-sass` 56 | - `PostCSS` 57 | - `Autoprefixer` 58 | - `Jest` 59 | - `Vue Test Utils` 60 | - `keycode-js` 61 | - `lodash` 62 | 63 | ## License 64 | 65 | [MIT](http://opensource.org/licenses/MIT) 66 | -------------------------------------------------------------------------------- /build/base/plugins/index.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve' 2 | import common from 'rollup-plugin-commonjs' 3 | import vue from 'rollup-plugin-vue' 4 | 5 | export default [resolve(), common(), vue({ needMap: false })] 6 | -------------------------------------------------------------------------------- /build/rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import serve from 'rollup-plugin-serve' 3 | import livereload from 'rollup-plugin-livereload' 4 | import replace from 'rollup-plugin-replace' 5 | 6 | import plugins from './base/plugins/index' 7 | 8 | export default { 9 | input: path.resolve(__dirname, '../demo/index.js'), 10 | output: { 11 | file: path.resolve(__dirname, '../demo/demo.js'), 12 | format: 'iife', 13 | sourcemap: true, 14 | }, 15 | plugins: [ 16 | replace({ 17 | 'process.env.NODE_ENV': JSON.stringify('development'), 18 | }), 19 | serve({ 20 | open: true, 21 | contentBase: path.resolve(__dirname, '../demo'), 22 | port: 8080, 23 | }), 24 | livereload({ 25 | verbose: true, 26 | watch: path.resolve(__dirname, '../demo'), 27 | }), 28 | ].concat(plugins), 29 | } -------------------------------------------------------------------------------- /build/rollup.config.prod.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import replace from 'rollup-plugin-replace' 3 | import babel from 'rollup-plugin-babel' 4 | import { terser } from 'rollup-plugin-terser' 5 | 6 | import plugins from './base/plugins/index.js' 7 | 8 | const name = 'VueAccessibleMultiselect' 9 | 10 | export default [ 11 | { 12 | input: path.resolve(__dirname, '../src/index.js'), 13 | output: [ 14 | { 15 | file: 'dist/vue-accessible-multiselect.js', 16 | format: 'umd', 17 | name, 18 | }, 19 | { 20 | file: 'dist/vue-accessible-multiselect.common.js', 21 | format: 'cjs', 22 | }, 23 | { 24 | file: 'dist/vue-accessible-multiselect.esm.js', 25 | format: 'esm', 26 | }, 27 | ], 28 | plugins: [ 29 | replace({ 30 | 'process.env.NODE_ENV': JSON.stringify('production'), 31 | }), 32 | ].concat(plugins), 33 | }, 34 | { 35 | input: path.resolve(__dirname, '../src/index.js'), 36 | output: { 37 | file: 'dist/vue-accessible-multiselect.min.js', 38 | format: 'umd', 39 | name, 40 | }, 41 | plugins: [ 42 | replace({ 43 | 'process.env.NODE_ENV': JSON.stringify('production'), 44 | }), 45 | babel(), 46 | terser(), 47 | ].concat(plugins), 48 | }, 49 | ] 50 | -------------------------------------------------------------------------------- /demo/App.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 122 | 123 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vue-accessible-multiselect 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import VueAccessibleMultiselect, { config } from '../src' 4 | 5 | Vue.config.productionTip = false 6 | 7 | /* config.transition = { 8 | name: 'fade' 9 | } */ 10 | 11 | Vue.use(VueAccessibleMultiselect) 12 | 13 | new Vue({ 14 | el: '#app', 15 | render: h => h(App), 16 | }) 17 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: 'default-vue-a11y', 3 | title: 'Vue accessible multiselect', 4 | head: [ 5 | ['meta', { name: 'theme-color', content: '#fff' }], 6 | ], 7 | themeConfig: { 8 | home: false, 9 | repo: 'vue-a11y/vue-accessible-multiselect', 10 | docsDir: 'docs', 11 | docsBranch: 'master', 12 | editLinks: true, 13 | locales: { 14 | '/': { 15 | editLinkText: 'Edit this page on GitHub', 16 | nav: [ 17 | { 18 | text: 'Guide', 19 | link: '/guide/' 20 | } 21 | ], 22 | sidebar: [ 23 | '/', 24 | { 25 | title: 'Guide', 26 | collapsable: false, 27 | children: [ 28 | '/guide/', 29 | '/guide/usage.md', 30 | '/guide/api.md', 31 | '/guide/keyboard.md', 32 | ] 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Vue.js accessible multiselect component made according to WAI-ARIA practices. 4 | 5 | ## Features 6 | 7 | - ♿️ fully accessible to screen readers; 8 | - ⌨️ supports keyboard navigation (there really a lot of keyboard shortcuts); 9 | - 🔣 type-ahead to focus option that starts with typed symbols; 10 | - 💅 style agnostic, so you can style it whatever you like (but including core.scss is highly encouraged); 11 | 12 | ## Demo 13 | 14 | [![Edit vue-accessible-multiselect](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/vue-accessible-multiselect-u7rdh) -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Installation 4 | 5 | ### Via NPM 6 | 7 | ```bash 8 | $ npm install vue-accessible-multiselect --save 9 | ``` 10 | 11 | ### Via Yarn 12 | 13 | ```bash 14 | $ yarn add vue-accessible-multiselect 15 | ``` 16 | 17 | ## Initialization 18 | 19 | ### As a plugin 20 | 21 | It must be called before `new Vue()`. 22 | 23 | ```js 24 | import Vue from 'vue' 25 | import VueAccessibleMultiselect from 'vue-accessible-multiselect' 26 | 27 | Vue.use(VueAccessibleMultiselect) 28 | ``` 29 | 30 | ### As a global component 31 | 32 | ```js 33 | import Vue from 'vue' 34 | import { VueAccessibleMultiselect } from 'vue-accessible-multiselect' 35 | 36 | Vue.component('VueAccessibleMultiselect', VueAccessibleMultiselect) 37 | ``` 38 | 39 | ### As a local component 40 | 41 | ```js 42 | import { VueAccessibleMultiselect } from 'vue-accessible-multiselect' 43 | 44 | export default { 45 | name: 'YourAwesomeComponent', 46 | components: { 47 | VueAccessibleMultiselect, 48 | }, 49 | } 50 | ``` 51 | 52 | ::: tip 53 | To set global options (for example `transition` for each multiselect component), you should do the following: 54 | ::: 55 | 56 | ```js 57 | import { config } from 'vue-accessible-multiselect' 58 | 59 | config.transition = { 60 | name: 'foo', 61 | } 62 | ``` 63 | 64 | ::: tip 65 | Options passed locally via `props` will always take precedence over global config options. 66 | ::: 67 | 68 | Default `config.js`: 69 | 70 | ```js 71 | export default { 72 | transition: null, 73 | } 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/guide/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## Props 4 | 5 | `` accepts some `props`: 6 | 7 | | Prop | Description | 8 | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 9 | | `options: array` | `required`. Array of multiselect options. Should be an array of objects that match the following pattern `{ value: any, label: string }` | 10 | | `value: array` | `required`. Current value of multiselect. | 11 | | `label: string` | Multiselect label | 12 | | `placeholder: string` | Multiselect placeholder | 13 | | `disabled: boolean` | Whether multiselect is disabled | 14 | | `transition: object` | Through this object you can configure the transition of `.v-multiselect__menu` entrance and leave. Should match the following pattern `{ name: string, mode: string? }` | 15 | 16 | ## Slots 17 | 18 | `` provides you with some `slots` and `scopedSlots` you can use to fit your needs. 19 | 20 | | Slot | Scope | Description | 21 | | ------------- | -------------------- | ---------------- | 22 | | `label` | | Label slot | 23 | | `prepend` | | Prepend slot | 24 | | `placeholder` | `{ placeholder }` | Placeholder slot | 25 | | `selected` | `{ value, options }` | Selected slot | 26 | | `arrow` | | Arrow slot | 27 | | `option` | `{ value, option }` | Option slot | 28 | | `no-options` | | No options slot | 29 | 30 | ##### Example of possible usage of `slots` and `scopedSlots` 31 | 32 | ```vue 33 | 34 | 39 | 42 | 45 | 46 | 47 | 50 | 51 | ``` -------------------------------------------------------------------------------- /docs/guide/keyboard.md: -------------------------------------------------------------------------------- 1 | # Keyboard shortcuts 2 | 3 | `` is fully accessible when it comes to keyboard interaction. 4 | 5 | Here is some useful keys and their appropriate actions: 6 | 7 | - `Down Arrow` Moves focus to the next option 8 | - `Up Arrow` Moves focus to the previous option 9 | - `Home` Moves focus to first option 10 | - `End` Moves focus to last option 11 | - `Space` Changes the selection state of the focused option 12 | - `Shift + Down Arrow` Moves focus to and toggles the selected state of the next option 13 | - `Shift + Up Arrow` Moves focus to and toggles the selected state of the previous option 14 | - `Shift + Space` Selects contiguous items from the most recently selected item to the focused item 15 | - `Control + Shift + Home` Selects the focused option and all options up to the first option. Moves focus to the first option. 16 | - `Control + Shift + End` Selects the focused option and all options down to the last option. Moves focus to the last option 17 | - `Control + A` Selects all options in the list. If all options are selected, unselects all options 18 | 19 | Type ahead: 20 | 21 | - Type a character: focus moves to the next option with a label that starts with the typed character; 22 | - Type multiple characters in rapid succession: focus moves to the next option with a label that starts with the string of characters typed. 23 | -------------------------------------------------------------------------------- /docs/guide/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Template 4 | ```vue 5 | 15 | ``` 16 | 17 | ## Script 18 | 19 | ```js 20 | export default { 21 | // ... 22 | data() { 23 | return { 24 | value: [], 25 | options: [ 26 | { 27 | value: 0, 28 | label: '🍇 Grape', 29 | }, 30 | { 31 | value: { foo: 'bar' }, 32 | label: '🍉 Watermelon', 33 | }, 34 | { 35 | value: [1, 2, 3], 36 | label: '🥝 Kiwi', 37 | }, 38 | { 39 | value: false, 40 | label: '🥭 Mango', 41 | }, 42 | { 43 | value: true, 44 | label: '🍓 Strawberry', 45 | }, 46 | { 47 | value: 'lemon', 48 | label: '🍋 Lemon', 49 | }, 50 | ], 51 | } 52 | }, 53 | // ... 54 | } 55 | ``` 56 | 57 | ## Styles 58 | 59 | After that don't forget to include core styles. Note that library is sipped with default theme styles you can use. 60 | 61 | `SASS`: 62 | 63 | ```scss 64 | // recommended 65 | @import 'vue-accessible-multiselect/src/styles/core.scss'; 66 | 67 | // optional 68 | @import 'vue-accessible-multiselect/src/styles/themes/default.scss'; 69 | ``` 70 | 71 | Or already compiled `CSS`: 72 | 73 | ```css 74 | /* recommended */ 75 | @import 'vue-accessible-multiselect/dist/styles/core.css'; 76 | 77 | /* optional */ 78 | @import 'vue-accessible-multiselect/dist/styles/themes/default.css'; 79 | ``` 80 | 81 | ::: tip 82 | When you import already compiled CSS you don't have ability to override `SASS` variables during build process, so it is preferable to use `.scss` file. 83 | ::: 84 | 85 | `core.scss`, contains some `SASS` variables you can override during build process: 86 | 87 | ```scss 88 | $v-multiselect-menu-position-top: 100% !default; 89 | $v-multiselect-arrow-size: 8px !default; 90 | ``` 91 | 92 | `/themes/default.scss` `SASS` variables are listed [here](https://github.com/andrewvasilchuk/vue-accessible-multiselect/blob/master/src/styles/core.scss). 93 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'json', 5 | // tell Jest to handle `*.vue` files 6 | 'vue', 7 | ], 8 | transform: { 9 | // process `*.vue` files with `vue-jest` 10 | '.*\\.(vue)$': 'vue-jest', 11 | // process js with `babel-jest` 12 | '^.+\\.js$': '/node_modules/babel-jest', 13 | }, 14 | testPathIgnorePatterns: ['helpers', 'fixtures'], 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-accessible-multiselect", 3 | "version": "0.1.1", 4 | "private": false, 5 | "description": "Vue.js component for accessible multiselects", 6 | "keywords": [ 7 | "accessibility", 8 | "multiselect", 9 | "select", 10 | "vue", 11 | "vue-accessible-multiselect", 12 | "vue-multiselect", 13 | "vue-select" 14 | ], 15 | "homepage": "https://github.com/vue-a11y/vue-accessible-multiselect#readme", 16 | "bugs": { 17 | "url": "https://github.com/vue-a11y/vue-accessible-multiselect/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/vue-a11y/vue-accessible-multiselect" 22 | }, 23 | "license": "MIT", 24 | "author": "Andrew Vasilchuk ", 25 | "files": [ 26 | "src", 27 | "dist" 28 | ], 29 | "main": "dist/vue-accessible-multiselect.common.js", 30 | "unpkg": "dist/vue-accessible-multiselect.min.js", 31 | "module": "dist/vue-accessible-multiselect.esm.js", 32 | "scripts": { 33 | "build": "rimraf dist/* && rollup -c build/rollup.config.prod.js && npm run build:css && npm run postcss", 34 | "build:css": "node-sass ./src -o ./dist --output-style compressed -x", 35 | "dev": "rollup -c build/rollup.config.dev.js --watch", 36 | "postcss": "postcss ./dist/**/*.css -r --no-map", 37 | "docs:dev": "./node_modules/.bin/vuepress dev docs --no-cache", 38 | "docs:build": "./node_modules/.bin/vuepress build docs --no-cache && echo multiselect.vue-a11y.com >> docs/.vuepress/dist/CNAME", 39 | "docs:publish": "gh-pages -d docs/.vuepress/dist", 40 | "test:unit": "jest" 41 | }, 42 | "dependencies": { 43 | "keycode-js": "^2.0.3" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.6.4", 47 | "@babel/preset-env": "^7.6.2", 48 | "@vue/test-utils": "^1.0.0-beta.29", 49 | "autoprefixer": "^9.6.5", 50 | "babel-core": "7.0.0-bridge.0", 51 | "babel-jest": "^24.9.0", 52 | "gh-pages": "^2.2.0", 53 | "jest": "^24.9.0", 54 | "lodash": "^4.17.15", 55 | "node-sass": "^4.12.0", 56 | "postcss": "^7.0.18", 57 | "postcss-cli": "^6.1.3", 58 | "rimraf": "^3.0.0", 59 | "rollup": "^1.23.0", 60 | "rollup-plugin-babel": "^4.3.3", 61 | "rollup-plugin-commonjs": "^10.1.0", 62 | "rollup-plugin-livereload": "^1.0.3", 63 | "rollup-plugin-node-resolve": "^5.2.0", 64 | "rollup-plugin-postcss": "^2.0.3", 65 | "rollup-plugin-replace": "^2.2.0", 66 | "rollup-plugin-serve": "^1.0.1", 67 | "rollup-plugin-terser": "^5.1.2", 68 | "rollup-plugin-vue": "^5.0.1", 69 | "vue": "^2.6.11", 70 | "vue-jest": "^3.0.5", 71 | "vue-template-compiler": "^2.6.11", 72 | "vuepress": "^1.5.0", 73 | "vuepress-theme-default-vue-a11y": "^0.1.15", 74 | "watchpack": "^1.6.1" 75 | }, 76 | "peerDependencies": { 77 | "vue": "^2.6.11" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('autoprefixer')], 3 | } 4 | -------------------------------------------------------------------------------- /src/components/VueAccessibleMultiselect/VueAccessibleMultiselect.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | transition: null 3 | } -------------------------------------------------------------------------------- /src/constants/key-codes.js: -------------------------------------------------------------------------------- 1 | import { KEY_LEFT, KEY_UP, KEY_RIGHT, KEY_DOWN } from 'keycode-js' 2 | 3 | const ARROWS = [KEY_LEFT, KEY_UP, KEY_RIGHT, KEY_DOWN] 4 | 5 | export { 6 | ARROWS 7 | } -------------------------------------------------------------------------------- /src/constants/type-ahead.js: -------------------------------------------------------------------------------- 1 | const TYPE_AHEAD_TIMEOUT = 500 2 | 3 | export { 4 | TYPE_AHEAD_TIMEOUT 5 | } -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export function getItemsByRange(array, from, to) { 2 | if (to < from) { 3 | to = [from, (from = to)][0] 4 | } 5 | 6 | return array.slice(from, to + 1) 7 | } -------------------------------------------------------------------------------- /src/helpers/validators.js: -------------------------------------------------------------------------------- 1 | export const options = val => { 2 | if (Array.isArray(val)) { 3 | for (let i = 0; i < val.length; i++) { 4 | const option = val[i] 5 | 6 | if (typeof option === 'object' && option !== null) { 7 | if ('value' in option && 'label' in option) { 8 | continue 9 | } else { 10 | return false 11 | } 12 | } else { 13 | return false 14 | } 15 | } 16 | 17 | return true 18 | } else { 19 | return false 20 | } 21 | } 22 | 23 | export const value = val => 24 | val === undefined || val === null || Array.isArray(val) 25 | 26 | export const transition = val => { 27 | return val === null || (typeof val === 'object' && 'name' in val) 28 | } 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import VueAccessibleMultiselect from './components/VueAccessibleMultiselect/VueAccessibleMultiselect.vue' 2 | 3 | import config from './config' 4 | 5 | const Plugin = { 6 | install(Vue) { 7 | // Make sure that plugin can be installed only once 8 | if (this.installed) { 9 | return 10 | } 11 | 12 | this.installed = true 13 | 14 | Vue.component('VueAccessibleMultiselect', VueAccessibleMultiselect) 15 | }, 16 | } 17 | 18 | export default Plugin 19 | 20 | export { VueAccessibleMultiselect, config } 21 | -------------------------------------------------------------------------------- /src/styles/core.scss: -------------------------------------------------------------------------------- 1 | // v-multiselect__menu styles 2 | $v-multiselect-menu-position-top: 100% !default; 3 | 4 | // v-multiselect__arrow styles 5 | $v-multiselect-arrow-size: 8px !default; 6 | 7 | .v-multiselect { 8 | &__inner { 9 | position: relative; 10 | } 11 | 12 | &__menu { 13 | position: absolute; 14 | top: $v-multiselect-menu-position-top; 15 | left: 0; 16 | width: 100%; 17 | } 18 | 19 | &__list { 20 | padding-left: 0; 21 | margin: { 22 | top: 0; 23 | bottom: 0; 24 | } 25 | list-style: none; 26 | overflow: auto; 27 | } 28 | 29 | &__arrow { 30 | margin-left: auto; 31 | 32 | svg { 33 | display: inline-block; 34 | width: $v-multiselect-arrow-size; 35 | height: $v-multiselect-arrow-size; 36 | vertical-align: middle; 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/styles/themes/default.scss: -------------------------------------------------------------------------------- 1 | // v-multiselect__label styles 2 | $v-multiselect-label-display: inline-block !default; 3 | $v-multiselect-label-margin-bottom: 8px !default; 4 | $v-multiselect-label-font-size: 14px !default; 5 | 6 | // v-multiselect__prepend styles 7 | $v-multiselect-prepend-margin-right: 8px !default; 8 | 9 | // v-multiselect__btn styles 10 | $v-multiselect-btn-display: flex !default; 11 | $v-multiselect-btn-flex-align: center !default; 12 | $v-multiselect-btn-width: 100% !default; 13 | $v-multiselect-btn-height: 38px !default; 14 | $v-multiselect-btn-horizontal-padding: 16px !default; 15 | $v-multiselect-btn-vertical-padding: 0 !default; 16 | $v-multiselect-btn-border-width: 1px !default; 17 | $v-multiselect-btn-border-style: solid !default; 18 | $v-multiselect-btn-border-color: #ced4da !default; 19 | $v-multiselect-btn-border-radius: 4px !default; 20 | $v-multiselect-btn-background-color: #fff !default; 21 | $v-multiselect-btn-border-color-focus: #80bdff !default; 22 | $v-multiselect-btn-box-shadow-focus: 0 0 0 4px rgba(0, 128, 255, 0.24) !default; 23 | $v-multiselect-btn-font-size: 100% !default; 24 | 25 | // v-multiselect__selected styles 26 | $v-multiselect-selected-margin-right: 8px !default; 27 | 28 | // v-multiselect__menu styles 29 | $v-multiselect-menu-vertical-padding: 8px !default; 30 | $v-multiselect-menu-border-width: 1px !default; 31 | $v-multiselect-menu-border-style: solid !default; 32 | $v-multiselect-menu-border-color: rgba(0, 0, 0, 0.15) !default; 33 | $v-multiselect-menu-border-radius: 4px !default; 34 | $v-multiselect-menu-background-color: #fff !default; 35 | 36 | // v-multiselect__list styles 37 | $v-multiselect-list-max-height: 160px !default; 38 | 39 | // v-multiselect__option styles 40 | $v-multiselect-option-padding: 4px 16px !default; 41 | $v-multiselect-option-background-color-hover: #f8f9fa !default; 42 | $v-multiselect-option-background-color-focus: #f8f9fa !default; 43 | $v-multiselect-option-color-selected: #007bff !default; 44 | 45 | .v-multiselect { 46 | $block: &; 47 | 48 | &__label { 49 | display: $v-multiselect-label-display; 50 | margin-bottom: $v-multiselect-label-margin-bottom; 51 | font-size: $v-multiselect-label-font-size; 52 | } 53 | 54 | &__prepend { 55 | flex-shrink: 0; 56 | margin-right: $v-multiselect-prepend-margin-right; 57 | } 58 | 59 | &__btn { 60 | display: $v-multiselect-btn-display; 61 | width: $v-multiselect-btn-width; 62 | height: $v-multiselect-btn-height; 63 | padding: $v-multiselect-btn-vertical-padding $v-multiselect-btn-horizontal-padding; 64 | border: $v-multiselect-btn-border-width $v-multiselect-btn-border-style $v-multiselect-btn-border-color; 65 | border-radius: $v-multiselect-btn-border-radius; 66 | background-color: $v-multiselect-btn-background-color; 67 | font-size: $v-multiselect-btn-font-size; 68 | 69 | @if $v-multiselect-btn-display == flex { 70 | align-items: $v-multiselect-btn-flex-align; 71 | } 72 | 73 | &:focus { 74 | outline: 0; 75 | border-color: $v-multiselect-btn-border-color-focus; 76 | box-shadow: $v-multiselect-btn-box-shadow-focus; 77 | } 78 | } 79 | 80 | &__selected { 81 | display: block; 82 | overflow: hidden; 83 | margin-right: $v-multiselect-selected-margin-right; 84 | white-space: nowrap; 85 | text-overflow: ellipsis; 86 | } 87 | 88 | &__menu { 89 | padding: { 90 | top: $v-multiselect-menu-vertical-padding; 91 | bottom: $v-multiselect-menu-vertical-padding; 92 | } 93 | border: $v-multiselect-menu-border-width $v-multiselect-menu-border-style $v-multiselect-menu-border-color; 94 | border-radius: $v-multiselect-menu-border-radius; 95 | background-color: $v-multiselect-menu-background-color; 96 | } 97 | 98 | &__list { 99 | max-height: $v-multiselect-list-max-height; 100 | 101 | &:focus { 102 | outline: 0; 103 | } 104 | } 105 | 106 | &__option { 107 | padding: $v-multiselect-option-padding; 108 | cursor: pointer; 109 | 110 | &:hover { 111 | background-color: $v-multiselect-option-background-color-hover; 112 | } 113 | 114 | &--focus { 115 | background-color: $v-multiselect-option-background-color-focus; 116 | } 117 | 118 | &--selected { 119 | color: $v-multiselect-option-color-selected; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /test/fixtures/options.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | value: 1, 4 | label: 'foo', 5 | }, 6 | { 7 | value: 2, 8 | label: 'bar', 9 | }, 10 | { 11 | value: 3, 12 | label: 'baz', 13 | }, 14 | { 15 | value: 4, 16 | label: 'Vue', 17 | }, 18 | { 19 | value: 5, 20 | label: 'React', 21 | }, 22 | { 23 | value: 6, 24 | label: 'Angular', 25 | }, 26 | { 27 | value: 'JavaScript', 28 | label: 'JavaScript', 29 | }, 30 | ] 31 | -------------------------------------------------------------------------------- /test/fixtures/value.js: -------------------------------------------------------------------------------- 1 | import options from './options' 2 | 3 | export default function value(count = options.length) { 4 | if (count > options.length) { 5 | console.warn('Provided count is greater then options length') 6 | count = options.length 7 | } 8 | 9 | return options.slice(0, count).map(option => option.value) 10 | } 11 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export async function open(wrapper) { 4 | wrapper.setData({ open: true }) 5 | 6 | await wrapper.vm.$nextTick() 7 | 8 | return wrapper 9 | } 10 | 11 | export function getAllTypes({ except, only } = {}) { 12 | let types = [1, 'foo', true, {}, [], () => {}, undefined, null] 13 | 14 | if (except !== undefined) { 15 | if (Array.isArray(except)) { 16 | types = types.filter(type => { 17 | return except.every(e => !_[`is${getLodashSuffix(e)}`](type)) 18 | }) 19 | } else { 20 | throw new TypeError('Type of `options.except` should be Array') 21 | } 22 | } 23 | 24 | if (only !== undefined) { 25 | if (Array.isArray(only)) { 26 | types = types.filter(type => { 27 | return only.some(s => _[`is${getLodashSuffix(s)}`](type)) 28 | }) 29 | } else { 30 | throw new TypeError('Type of `options.only` should be Array') 31 | } 32 | } 33 | 34 | return types 35 | } 36 | 37 | function getLodashSuffix(type) { 38 | return type && type.name ? type.name : _.capitalize(String(type)) 39 | } 40 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | 3 | import { KEY_A, KEY_END, KEY_HOME, KEY_R, KEY_C, KEY_T } from 'keycode-js' 4 | 5 | import { ARROWS } from '../src/constants/key-codes' 6 | import { TYPE_AHEAD_TIMEOUT } from '../src/constants/type-ahead' 7 | 8 | import VueAccessibleMultiselect from '../src/components/VueAccessibleMultiselect/VueAccessibleMultiselect.vue' 9 | 10 | import FIXTURES_OPTIONS from './fixtures/options' 11 | import FIXTURES_VALUE from './fixtures/value' 12 | 13 | import { open as openMenu, getAllTypes } from './helpers' 14 | 15 | beforeEach(() => { 16 | console.error = jest.fn() 17 | // TODO: refactor line below 18 | jest.setTimeout(TYPE_AHEAD_TIMEOUT * 2) 19 | }) 20 | 21 | const classes = { 22 | label: '.v-multiselect__label', 23 | button: '.v-multiselect__btn', 24 | prepend: '.v-multiselect__prepend', 25 | placeholder: '.v-multiselect__placeholder', 26 | selected: '.v-multiselect__selected', 27 | list: '.v-multiselect__list', 28 | option: '.v-multiselect__option', 29 | 'option--selected': '.v-multiselect__option--selected', 30 | arrow: '.v-multiselect__arrow', 31 | 'no-options': '.v-multiselect__no-options', 32 | } 33 | 34 | async function factory(options = {}, open = true) { 35 | const defaultOptions = { 36 | propsData: { 37 | value: FIXTURES_VALUE(), 38 | options: FIXTURES_OPTIONS, 39 | }, 40 | } 41 | 42 | const wrapper = shallowMount( 43 | VueAccessibleMultiselect, 44 | Object.assign({}, defaultOptions, options) 45 | ) 46 | 47 | if (open) { 48 | await openMenu(wrapper) 49 | } 50 | 51 | return wrapper 52 | } 53 | 54 | describe('VueAccessibleMultiselect', () => { 55 | describe('props', () => { 56 | describe('options', () => { 57 | const validPropTypes = [Array] 58 | 59 | it('should call `console.error` if invalid value is passed', () => { 60 | const error = jest.spyOn(global.console, 'error') 61 | 62 | const types = getAllTypes({ except: validPropTypes }) 63 | 64 | types.forEach(type => { 65 | shallowMount(VueAccessibleMultiselect, { 66 | propsData: { value: [], options: type }, 67 | }) 68 | }) 69 | 70 | expect(error).toHaveBeenCalledTimes(types.length) 71 | }) 72 | 73 | it('should not call `console.error` if valid value is passed', () => { 74 | const error = jest.spyOn(global.console, 'error') 75 | 76 | const types = getAllTypes({ only: validPropTypes }) 77 | 78 | types.forEach(type => { 79 | shallowMount(VueAccessibleMultiselect, { 80 | propsData: { value: [], options: type }, 81 | }) 82 | }) 83 | 84 | expect(error).not.toHaveBeenCalled() 85 | }) 86 | }) 87 | 88 | describe('value', () => { 89 | const validPropTypes = [Array, undefined, null] 90 | 91 | it('should call `console.error` if value with invalid type is passed', () => { 92 | const error = jest.spyOn(global.console, 'error') 93 | 94 | const types = getAllTypes({ except: validPropTypes }) 95 | 96 | types.forEach(type => { 97 | shallowMount(VueAccessibleMultiselect, { 98 | propsData: { value: type, options: [] }, 99 | }) 100 | }) 101 | 102 | expect(error).toHaveBeenCalledTimes(types.length) 103 | }) 104 | 105 | it('should not call `console.error` if value with valid type is passed', () => { 106 | const error = jest.spyOn(global.console, 'error') 107 | 108 | const types = getAllTypes({ only: validPropTypes }) 109 | 110 | types.forEach(type => { 111 | shallowMount(VueAccessibleMultiselect, { 112 | propsData: { value: type, options: FIXTURES_OPTIONS }, 113 | }) 114 | }) 115 | 116 | expect(error).not.toHaveBeenCalled() 117 | }) 118 | }) 119 | 120 | describe('transition', () => { 121 | it('should call `console.error` if value with invalid type is passed', () => { 122 | const error = jest.spyOn(global.console, 'error') 123 | const types = getAllTypes({ except: [undefined, null] }) 124 | types.push({ foo: 'bar' }) 125 | 126 | types.forEach(type => { 127 | shallowMount(VueAccessibleMultiselect, { 128 | propsData: { 129 | value: FIXTURES_VALUE(), 130 | options: FIXTURES_OPTIONS, 131 | transition: type, 132 | }, 133 | }) 134 | }) 135 | 136 | expect(error).toHaveBeenCalledTimes(types.length) 137 | }) 138 | 139 | it('should not call `console.error` if valid value is passed', () => { 140 | const error = jest.spyOn(global.console, 'error') 141 | 142 | shallowMount(VueAccessibleMultiselect, { 143 | propsData: { 144 | value: FIXTURES_VALUE(), 145 | options: FIXTURES_OPTIONS, 146 | transition: { name: 'foo' }, 147 | }, 148 | }) 149 | 150 | expect(error).not.toHaveBeenCalled() 151 | }) 152 | }) 153 | 154 | describe('disabled', () => { 155 | const validPropTypes = [Boolean, undefined, null] 156 | 157 | it('should not call `console.error` if value with valid type is passed', () => { 158 | const error = jest.spyOn(global.console, 'error') 159 | 160 | const types = getAllTypes({ only: validPropTypes }) 161 | 162 | types.forEach(type => { 163 | shallowMount(VueAccessibleMultiselect, { 164 | propsData: { 165 | value: FIXTURES_VALUE(), 166 | options: FIXTURES_OPTIONS, 167 | disabled: type, 168 | }, 169 | }) 170 | }) 171 | 172 | expect(error).not.toHaveBeenCalled() 173 | }) 174 | 175 | it('should call `console.error` if value with invalid type is passed', () => { 176 | const error = jest.spyOn(global.console, 'error') 177 | const types = getAllTypes({ except: validPropTypes }) 178 | 179 | types.forEach(type => { 180 | shallowMount(VueAccessibleMultiselect, { 181 | propsData: { 182 | value: FIXTURES_VALUE(), 183 | options: FIXTURES_OPTIONS, 184 | disabled: type, 185 | }, 186 | }) 187 | }) 188 | 189 | expect(error).toHaveBeenCalledTimes(types.length) 190 | }) 191 | }) 192 | }) 193 | 194 | describe('label', () => { 195 | it('should render with value passed via props', () => { 196 | const label = 'foo' 197 | 198 | const wrapper = shallowMount(VueAccessibleMultiselect, { 199 | propsData: { label }, 200 | }) 201 | 202 | expect(wrapper.find(classes.label).text()).toBe(`${label}:`) 203 | }) 204 | 205 | it('should have unique id', () => { 206 | const label = 'foo' 207 | 208 | const wrapper = shallowMount(VueAccessibleMultiselect, { 209 | propsData: { label }, 210 | }) 211 | 212 | expect(wrapper.find(classes.label).element.id).toBe( 213 | `v-multiselect-label-${wrapper.vm._uid}` 214 | ) 215 | }) 216 | 217 | it("should render it's slot", () => { 218 | const label = 'foo' 219 | 220 | const wrapper = shallowMount(VueAccessibleMultiselect, { 221 | slots: { 222 | label, 223 | }, 224 | }) 225 | 226 | expect(wrapper.find(classes.label).element.innerHTML).toBe(label) 227 | }) 228 | 229 | it('should not render if value or slot are not passed', () => { 230 | const wrapper = shallowMount(VueAccessibleMultiselect) 231 | expect(wrapper.find(classes.label).exists()).toBe(false) 232 | }) 233 | 234 | const validPropTypes = [String, undefined, null] 235 | 236 | it('should not call `console.error` if value with valid type is passed', () => { 237 | const error = jest.spyOn(global.console, 'error') 238 | 239 | const types = getAllTypes({ only: validPropTypes }) 240 | 241 | types.forEach(type => { 242 | shallowMount(VueAccessibleMultiselect, { 243 | propsData: { 244 | value: FIXTURES_VALUE(), 245 | options: FIXTURES_OPTIONS, 246 | label: type, 247 | }, 248 | }) 249 | }) 250 | 251 | expect(error).not.toHaveBeenCalled() 252 | }) 253 | 254 | it('should call `console.error` if value with invalid type is passed', () => { 255 | const error = jest.spyOn(global.console, 'error') 256 | const types = getAllTypes({ except: validPropTypes }) 257 | 258 | types.forEach(type => { 259 | shallowMount(VueAccessibleMultiselect, { 260 | propsData: { 261 | value: FIXTURES_VALUE(), 262 | options: FIXTURES_OPTIONS, 263 | label: type, 264 | }, 265 | }) 266 | }) 267 | 268 | expect(error).toHaveBeenCalledTimes(types.length) 269 | }) 270 | }) 271 | 272 | describe('button', () => { 273 | it('should have unique id', () => { 274 | const wrapper = shallowMount(VueAccessibleMultiselect) 275 | 276 | expect(wrapper.find(classes.button).element.id).toBe( 277 | `v-multiselect-button-${wrapper.vm._uid}` 278 | ) 279 | }) 280 | 281 | it('should have `disabled` attribute when `disabled` prop is passed', () => { 282 | const wrapper = shallowMount(VueAccessibleMultiselect, { 283 | propsData: { disabled: true }, 284 | }) 285 | 286 | expect(wrapper.find(classes.button).element.disabled).toBe(true) 287 | }) 288 | 289 | it('should not have `disabled` attribute when `disabled` prop is not passed', () => { 290 | const wrapper = shallowMount(VueAccessibleMultiselect) 291 | 292 | expect(wrapper.find(classes.button).element.disabled).toBe(false) 293 | }) 294 | 295 | it('should have appropriate class when `disabled` prop is passed', () => { 296 | const wrapper = shallowMount(VueAccessibleMultiselect, { 297 | propsData: { disabled: true }, 298 | }) 299 | 300 | expect( 301 | wrapper 302 | .find(classes.button) 303 | .element.classList.contains('v-multiselect__btn--disabled') 304 | ).toBe(true) 305 | }) 306 | 307 | it('should not have `aria-expanded` attribute when menu is closed', () => { 308 | const wrapper = shallowMount(VueAccessibleMultiselect) 309 | 310 | expect( 311 | wrapper.find(classes.button).element.getAttribute('aria-expanded') 312 | ).toBe(null) 313 | }) 314 | 315 | it('should have `aria-expanded` attribute with `true` value when menu is open', async () => { 316 | const wrapper = shallowMount(VueAccessibleMultiselect) 317 | 318 | await openMenu(wrapper) 319 | 320 | expect( 321 | wrapper.find(classes.button).element.getAttribute('aria-expanded') 322 | ).toBe('true') 323 | }) 324 | 325 | it('should have `aria-haspopup` attribute with `listbox` value', () => { 326 | const wrapper = shallowMount(VueAccessibleMultiselect) 327 | 328 | expect( 329 | wrapper.find(classes.button).element.getAttribute('aria-haspopup') 330 | ).toBe('listbox') 331 | }) 332 | 333 | it('should have `type` attribute with `button` value', () => { 334 | const wrapper = shallowMount(VueAccessibleMultiselect) 335 | 336 | expect(wrapper.find(classes.button).element.getAttribute('type')).toBe( 337 | 'button' 338 | ) 339 | }) 340 | 341 | it('should have appropriate `aria-labelledby` attribute with correct value', () => { 342 | let wrapper = shallowMount(VueAccessibleMultiselect) 343 | 344 | expect( 345 | wrapper.find(classes.button).element.getAttribute('aria-labelledby') 346 | ).toBe(` v-multiselect-button-${wrapper.vm._uid}`) 347 | 348 | const label = 'foo' 349 | wrapper = shallowMount(VueAccessibleMultiselect, { propsData: { label } }) 350 | 351 | expect( 352 | wrapper.find(classes.button).element.getAttribute('aria-labelledby') 353 | ).toBe( 354 | `v-multiselect-label-${wrapper.vm._uid} v-multiselect-button-${wrapper.vm._uid}` 355 | ) 356 | 357 | wrapper = shallowMount(VueAccessibleMultiselect, { slots: { label } }) 358 | 359 | expect( 360 | wrapper.find(classes.button).element.getAttribute('aria-labelledby') 361 | ).toBe( 362 | `v-multiselect-label-${wrapper.vm._uid} v-multiselect-button-${wrapper.vm._uid}` 363 | ) 364 | }) 365 | 366 | it('should toggle `open` data property state when clicked', () => { 367 | const wrapper = shallowMount(VueAccessibleMultiselect) 368 | 369 | wrapper.find(classes.button).trigger('click') 370 | expect(wrapper.vm.open).toBe(true) 371 | 372 | wrapper.find(classes.button).trigger('click') 373 | expect(wrapper.vm.open).toBe(false) 374 | }) 375 | }) 376 | 377 | describe('prepend slot', () => { 378 | it('should render', () => { 379 | const prepend = 'foo' 380 | const wrapper = shallowMount(VueAccessibleMultiselect, { 381 | slots: { 382 | prepend, 383 | }, 384 | }) 385 | expect(wrapper.find(classes.prepend).element.innerHTML).toBe(prepend) 386 | }) 387 | 388 | it('should not render when it is not passed', () => { 389 | const wrapper = shallowMount(VueAccessibleMultiselect) 390 | expect(wrapper.find(classes.prepend).exists()).toBe(false) 391 | }) 392 | }) 393 | 394 | describe('placeholder', () => { 395 | it("should not render when `value` prop is truthy or `values` is type of `Array` and it's `length` is greater `0`", () => { 396 | const wrapper = shallowMount(VueAccessibleMultiselect, { 397 | propsData: { value: true }, 398 | }) 399 | 400 | expect(wrapper.find(classes.placeholder).exists()).toBe(false) 401 | 402 | wrapper.setProps({ value: [] }) 403 | 404 | expect(wrapper.find(classes.placeholder).exists()).toBe(false) 405 | }) 406 | 407 | describe('props', () => { 408 | it('should render with passed value', () => { 409 | const placeholder = 'foo' 410 | 411 | const wrapper = shallowMount(VueAccessibleMultiselect, { 412 | propsData: { placeholder }, 413 | }) 414 | expect(wrapper.find(classes.placeholder).text()).toBe(placeholder) 415 | }) 416 | 417 | it('should not render if value is not passed', () => { 418 | const wrapper = shallowMount(VueAccessibleMultiselect) 419 | expect(wrapper.find(classes.placeholder).exists()).toBe(false) 420 | }) 421 | 422 | const validPropTypes = [String, undefined, null] 423 | 424 | it('should not call `console.error` if value with valid type is passed', () => { 425 | const error = jest.spyOn(global.console, 'error') 426 | const types = getAllTypes({ only: validPropTypes }) 427 | 428 | types.forEach(type => { 429 | shallowMount(VueAccessibleMultiselect, { 430 | propsData: { 431 | value: FIXTURES_VALUE(), 432 | options: FIXTURES_OPTIONS, 433 | placeholder: type, 434 | }, 435 | }) 436 | }) 437 | 438 | expect(error).not.toHaveBeenCalled() 439 | }) 440 | 441 | it('should call `console.error` if value with invalid type is passed', () => { 442 | const error = jest.spyOn(global.console, 'error') 443 | const types = getAllTypes({ except: validPropTypes }) 444 | 445 | types.forEach(type => { 446 | shallowMount(VueAccessibleMultiselect, { 447 | propsData: { 448 | value: FIXTURES_VALUE(), 449 | options: FIXTURES_OPTIONS, 450 | placeholder: type, 451 | }, 452 | }) 453 | }) 454 | 455 | expect(error).toHaveBeenCalledTimes(types.length) 456 | }) 457 | }) 458 | 459 | describe('slot', () => { 460 | it('should render', () => { 461 | const placeholder = 'foo' 462 | 463 | const wrapper = shallowMount(VueAccessibleMultiselect, { 464 | slots: { 465 | placeholder, 466 | }, 467 | }) 468 | 469 | expect(wrapper.find(classes.placeholder).element.innerHTML).toBe( 470 | placeholder 471 | ) 472 | }) 473 | 474 | it('should correctly expose scopedSlots data', () => { 475 | const placeholderSlot = 476 | '{{ foo.placeholder }}' 477 | const placeholderValue = 'foo' 478 | 479 | const wrapper = shallowMount(VueAccessibleMultiselect, { 480 | propsData: { 481 | placeholder: placeholderValue, 482 | }, 483 | scopedSlots: { 484 | placeholder: placeholderSlot, 485 | }, 486 | }) 487 | 488 | expect(wrapper.find(classes.placeholder).element.innerHTML).toBe( 489 | `${placeholderValue}` 490 | ) 491 | }) 492 | 493 | it('should not render if it is not passed', () => { 494 | const wrapper = shallowMount(VueAccessibleMultiselect) 495 | expect(wrapper.find(classes.placeholder).exists()).toBe(false) 496 | }) 497 | }) 498 | }) 499 | 500 | describe('selected', () => { 501 | it("should render appropriate text when `value` with type of `Array` is passed and it's `length` is not equals to `0`", async () => { 502 | const wrapper = await factory() 503 | 504 | const text = FIXTURES_OPTIONS.map(option => option.label).join(', ') 505 | 506 | expect(wrapper.find(classes.selected).text()).toBe(text) 507 | }) 508 | 509 | it("should not render when `value` with invalid type is passed and it's `length` is equals to `0`", () => { 510 | const types = getAllTypes() 511 | 512 | types.forEach(type => { 513 | const wrapper = shallowMount(VueAccessibleMultiselect, { 514 | propsData: { value: type, options: FIXTURES_OPTIONS }, 515 | }) 516 | expect(wrapper.find(classes.selected).exists()).toBe(false) 517 | }) 518 | }) 519 | 520 | describe('slot', () => { 521 | it('should render when it is passed', async () => { 522 | const selected = 'foo' 523 | 524 | const wrapper = await factory({ 525 | slots: { 526 | selected, 527 | }, 528 | }) 529 | 530 | expect(wrapper.find(classes.selected).element.innerHTML).toBe(selected) 531 | }) 532 | 533 | it('should correctly expose `scopedSlots` props', async () => { 534 | const selected = 535 | '{{ foo.value.length }} {{ foo.options.length }}' 536 | 537 | const wrapper = await factory({ 538 | scopedSlots: { 539 | selected, 540 | }, 541 | }) 542 | 543 | expect(wrapper.find(classes.selected).element.innerHTML).toBe( 544 | `${FIXTURES_VALUE().length} ${FIXTURES_OPTIONS.length}` 545 | ) 546 | }) 547 | }) 548 | }) 549 | 550 | describe('arrow', () => { 551 | describe('slot', () => { 552 | it('should render when it is passed', () => { 553 | const arrow = 'foo' 554 | const wrapper = shallowMount(VueAccessibleMultiselect, { 555 | slots: { 556 | arrow, 557 | }, 558 | }) 559 | 560 | expect(wrapper.find(classes.arrow).element.innerHTML).toBe(arrow) 561 | }) 562 | 563 | it('should render with default slot content', () => { 564 | const wrapper = shallowMount(VueAccessibleMultiselect) 565 | expect(wrapper.find(classes.arrow).exists()).toBe(true) 566 | }) 567 | }) 568 | }) 569 | 570 | describe('list', () => { 571 | it('should not render if `options` is falsy or `options` type is not `Array` or `options.length === 0` and render if previous options are respected', async () => { 572 | const wrapper = shallowMount(VueAccessibleMultiselect) 573 | 574 | await openMenu(wrapper) 575 | 576 | expect(wrapper.find(classes.list).exists()).toBe(false) 577 | 578 | wrapper.setProps({ options: {} }) 579 | 580 | expect(wrapper.find(classes.list).exists()).toBe(false) 581 | 582 | wrapper.setProps({ options: [] }) 583 | 584 | expect(wrapper.find(classes.list).exists()).toBe(false) 585 | 586 | wrapper.setProps({ options: [{ value: 0, label: 'foo' }] }) 587 | 588 | expect(wrapper.find(classes.list).exists()).toBe(true) 589 | }) 590 | 591 | it('should have `aria-multiselectable` attribute with `true` value', async () => { 592 | const wrapper = await factory() 593 | 594 | expect( 595 | wrapper.find(classes.list).element.getAttribute('aria-multiselectable') 596 | ).toBe('true') 597 | }) 598 | 599 | it('should set `open` to `false` on blur event, if `e.relatedTarget` not equals to the button', async () => { 600 | const wrapper = await factory() 601 | 602 | wrapper.find(classes.list).trigger('blur') 603 | 604 | expect(wrapper.vm.open).toBe(false) 605 | 606 | wrapper.setData({ open: true }) 607 | 608 | wrapper.find(classes.button).element.focus() 609 | 610 | expect(wrapper.find(classes.button).element).toBe(document.activeElement) 611 | expect(wrapper.vm.open).toBe(true) 612 | }) 613 | 614 | it('should have correct `aria-activedescendant` attribute', async () => { 615 | const wrapper = await factory() 616 | 617 | const list = wrapper.find(classes.list).element 618 | 619 | let index = 0 620 | 621 | wrapper.setData({ activeDescendantIndex: index }) 622 | 623 | expect(list.getAttribute('aria-activedescendant')).toBe( 624 | `v-multiselect-option-${index}_${wrapper.vm._uid}` 625 | ) 626 | 627 | index++ 628 | 629 | wrapper.setData({ activeDescendantIndex: index }) 630 | 631 | expect(list.getAttribute('aria-activedescendant')).toBe( 632 | `v-multiselect-option-${index}_${wrapper.vm._uid}` 633 | ) 634 | }) 635 | 636 | it('should have `aria-labelledby` attribute which equals to `labelId`', async () => { 637 | const wrapper = await factory() 638 | 639 | expect( 640 | wrapper.find(classes.list).element.getAttribute('aria-labelledby') 641 | ).toBe(wrapper.vm.labelId) 642 | }) 643 | 644 | it('should have `role` attribute with `listbox` value', async () => { 645 | const wrapper = await factory() 646 | expect(wrapper.find(classes.list).element.getAttribute('role')).toBe( 647 | 'listbox' 648 | ) 649 | }) 650 | 651 | it('should have `tabindex` attribute with `-1` value', async () => { 652 | const wrapper = await factory() 653 | expect(wrapper.find(classes.list).element.getAttribute('tabindex')).toBe( 654 | '-1' 655 | ) 656 | }) 657 | 658 | it('should have `position: relative;` style', async () => { 659 | const wrapper = await factory() 660 | expect(wrapper.find(classes.list).element.style.position).toBe('relative') 661 | }) 662 | }) 663 | 664 | describe('option', () => { 665 | it('should have unique id', async () => { 666 | const wrapper = await factory() 667 | 668 | wrapper.findAll(classes.option).wrappers.forEach((option, index) => { 669 | expect(option.element.id).toBe( 670 | `v-multiselect-option-${index}_${wrapper.vm._uid}` 671 | ) 672 | }) 673 | }) 674 | 675 | it('should have attribute `role` with value `option`', async () => { 676 | const wrapper = await factory() 677 | 678 | wrapper.findAll(classes.option).wrappers.forEach(option => { 679 | expect(option.element.getAttribute('role')).toBe('option') 680 | }) 681 | }) 682 | 683 | it('should have attribute `aria-selected` with value `true` if `option.value` is in `value` and `false` if not', async () => { 684 | const wrapper = await factory() 685 | 686 | wrapper.setData({ value: FIXTURES_VALUE(1) }) 687 | 688 | const options = wrapper.findAll(classes.option).wrappers 689 | 690 | expect(options[0].element.getAttribute('aria-selected')).toBe('true') 691 | expect(options[1].element.getAttribute('aria-selected')).toBe('false') 692 | }) 693 | 694 | it('should emit `input` event with/without `option.value` when option is clicked', async () => { 695 | const wrapper = await factory() 696 | 697 | wrapper.find(classes.option).trigger('click') 698 | 699 | // option was selected so expect value without this option 700 | const value = FIXTURES_VALUE().slice(1) 701 | expect(wrapper.emitted('input')[0]).toEqual([value]) 702 | 703 | // emit v-model 704 | wrapper.setData({ value }) 705 | 706 | wrapper.find(classes.option).trigger('click') 707 | 708 | // option was not selected so expect value with this option 709 | value.push(FIXTURES_VALUE()[0]) 710 | expect(wrapper.emitted('input')[1]).toEqual([value]) 711 | }) 712 | 713 | it("should have appropriate class when selected and should not when isn't selected", async () => { 714 | const wrapper = await factory() 715 | const className = 'v-multiselect__option--selected' 716 | 717 | wrapper.setData({ value: FIXTURES_VALUE(1) }) 718 | 719 | const options = wrapper.findAll(classes.option).wrappers 720 | 721 | options.forEach((option, index) => { 722 | const { classList } = option.element 723 | if (index === 0) { 724 | expect(classList.contains(className)).toBe(true) 725 | } else { 726 | expect(classList.contains(className)).toBe(false) 727 | } 728 | }) 729 | }) 730 | 731 | it("should have appropriate class when option index equals to `activeDescendantIndex` and should not when doen't", async () => { 732 | const wrapper = await factory() 733 | const className = 'v-multiselect__option--focus' 734 | const activeDescendantIndex = 0 735 | 736 | wrapper.setData({ activeDescendantIndex }) 737 | 738 | const options = wrapper.findAll(classes.option).wrappers 739 | 740 | options.forEach((option, index) => { 741 | const { classList } = option.element 742 | if (index === activeDescendantIndex) { 743 | expect(classList.contains(className)).toBe(true) 744 | } else { 745 | expect(classList.contains(className)).toBe(false) 746 | } 747 | }) 748 | }) 749 | 750 | it('should correctly expose `scopedSlots` props', async () => { 751 | const option = 752 | '{{ foo.value.length }} {{ foo.option.label }}' 753 | 754 | const wrapper = await factory({ 755 | scopedSlots: { 756 | option, 757 | }, 758 | }) 759 | 760 | const options = wrapper.findAll(classes.option).wrappers 761 | 762 | options.forEach((option, index) => { 763 | expect(option.element.innerHTML).toBe( 764 | `${wrapper.vm.value.length} ${FIXTURES_OPTIONS[index].label}` 765 | ) 766 | }) 767 | }) 768 | }) 769 | 770 | describe('no-options', () => { 771 | it('should not render if `options` is type of `Array` and `options.length !== 0`', async () => { 772 | const wrapper = await factory() 773 | expect(wrapper.find(classes['no-options']).exists()).toBe(false) 774 | }) 775 | 776 | it('should render if `options` is not type of `Array` or `options.length == 0`', async () => { 777 | const types = getAllTypes({ except: [Array] }) 778 | types.push([]) 779 | 780 | types.forEach(async type => { 781 | const wrapper = await factory({ 782 | propsData: { value: [], options: type }, 783 | }) 784 | expect(wrapper.find(classes['no-options']).exists()).toBe(true) 785 | }) 786 | }) 787 | 788 | describe('slot', () => { 789 | it('should render with passed value', async () => { 790 | const slot = 'foo' 791 | const wrapper = shallowMount(VueAccessibleMultiselect, { 792 | slots: { 'no-options': slot }, 793 | }) 794 | await openMenu(wrapper) 795 | expect(wrapper.find(classes['no-options']).element.innerHTML).toBe(slot) 796 | }) 797 | }) 798 | }) 799 | 800 | describe('keyboard navigation', () => { 801 | it('should set `activeDescendantIndex` to the last option index when `END` key is pressed', async () => { 802 | const wrapper = await factory() 803 | 804 | wrapper.find(classes.list).trigger('keyup.end') 805 | 806 | expect(wrapper.vm.activeDescendantIndex).toBe( 807 | wrapper.vm.options.length - 1 808 | ) 809 | }) 810 | 811 | it('should set `activeDescendantIndex` to `0` when `END` key is pressed', async () => { 812 | const wrapper = await factory() 813 | 814 | wrapper.find(classes.list).trigger('keyup.home') 815 | 816 | expect(wrapper.vm.activeDescendantIndex).toBe(0) 817 | }) 818 | 819 | it('should emit `input` event with empty array when `Control + A` keys is clicked and all options is selected', async () => { 820 | const wrapper = await factory() 821 | 822 | wrapper.find(classes.list).trigger('keyup', { 823 | _keyCode: KEY_A, 824 | ctrlKey: true, 825 | }) 826 | 827 | // all options were selected, so empty array is expected 828 | expect(wrapper.emitted('input')[0]).toEqual([[]]) 829 | }) 830 | 831 | it('should emit `input` event with array which contains all options when `Control + A` keys is clicked and one or none options is selected', async () => { 832 | let wrapper = await factory({ 833 | propsData: { value: FIXTURES_VALUE(1), options: FIXTURES_OPTIONS }, 834 | }) 835 | 836 | const event = 'keyup' 837 | const options = { 838 | _keyCode: KEY_A, 839 | ctrlKey: true, 840 | } 841 | 842 | wrapper.find(classes.list).trigger(event, options) 843 | 844 | expect(wrapper.emitted('input')[0]).toEqual([FIXTURES_VALUE()]) 845 | 846 | wrapper = await factory({ 847 | propsData: { value: [], options: FIXTURES_OPTIONS }, 848 | }) 849 | 850 | wrapper.find(classes.list).trigger(event, options) 851 | 852 | expect(wrapper.emitted('input')[0]).toEqual([FIXTURES_VALUE()]) 853 | }) 854 | 855 | it('should set `open` to `false` when `ESC` key is pressed', async () => { 856 | const wrapper = await factory() 857 | 858 | wrapper.find(classes.list).trigger('keyup.esc') 859 | 860 | expect(wrapper.vm.open).toBe(false) 861 | }) 862 | 863 | it('should increment `activeDescendantIndex` when `Down Arrow` key is pressed, but should not increment when `activeDescendantIndex === options.length - 1`', async () => { 864 | const wrapper = await factory() 865 | const list = wrapper.find(classes.list) 866 | const { options } = wrapper.vm 867 | 868 | // penult 869 | wrapper.setData({ activeDescendantIndex: options.length - 2 }) 870 | 871 | list.trigger('keyup.down') 872 | 873 | expect(wrapper.vm.activeDescendantIndex).toEqual( 874 | wrapper.vm.options.length - 1 875 | ) 876 | 877 | list.trigger('keyup.down') 878 | 879 | // cannot be greater then `options` lasts index 880 | expect(wrapper.vm.activeDescendantIndex).toEqual( 881 | wrapper.vm.options.length - 1 882 | ) 883 | }) 884 | 885 | it('should increment `activeDescendantIndex` and emit `input` event with/without option with `activeDescendantIndex` when `Down Arrow + Shift` key is pressed and should not do this when `activeDescendantIndex` is equals to `options.length - 1`', async () => { 886 | const wrapper = await factory() 887 | const list = wrapper.find(classes.list) 888 | let value 889 | 890 | wrapper.setData({ activeDescendantIndex: 1 }) 891 | 892 | list.trigger('keyup.down', { 893 | shiftKey: true, 894 | }) 895 | 896 | // without third option because it was selected 897 | value = [...FIXTURES_VALUE()] 898 | const spliced = value.splice(2, 1) 899 | 900 | expect(wrapper.emitted('input')[0]).toEqual([value]) 901 | expect(wrapper.vm.activeDescendantIndex).toBe(2) 902 | 903 | // emit v-model 904 | wrapper.setData({ value }) 905 | wrapper.setData({ activeDescendantIndex: 1 }) 906 | 907 | list.trigger('keyup.down', { 908 | shiftKey: true, 909 | }) 910 | 911 | // now it was not selected so we expect it to be emitted 912 | value.splice(2, 0, spliced) 913 | 914 | expect(wrapper.emitted('input')[1]).toEqual([value]) 915 | expect(wrapper.vm.activeDescendantIndex).toBe(2) 916 | 917 | wrapper.setData({ activeDescendantIndex: FIXTURES_OPTIONS.length - 1 }) 918 | 919 | list.trigger('keyup.down', { 920 | shiftKey: true, 921 | }) 922 | 923 | expect(wrapper.emitted('input').length).toBe(2) 924 | }) 925 | 926 | it('should decrement `activeDescendantIndex` when `Up Arrow` key is pressed, but should not decrement when `activeDescendantIndex === 0`', async () => { 927 | const wrapper = await factory() 928 | const list = wrapper.find(classes.list) 929 | 930 | wrapper.setData({ activeDescendantIndex: 1 }) 931 | 932 | list.trigger('keyup.up') 933 | 934 | expect(wrapper.vm.activeDescendantIndex).toEqual(0) 935 | 936 | list.trigger('keyup.up') 937 | 938 | expect(wrapper.vm.activeDescendantIndex).toEqual(0) 939 | }) 940 | 941 | it('should decrement `activeDescendantIndex` and emit `input` event with/without option with `activeDescendantIndex` when `Up Arrow + Shift` key is pressed should not do this when `activeDescendantIndex` is equals to `0`)', async () => { 942 | const wrapper = await factory() 943 | const list = wrapper.find(classes.list) 944 | let value 945 | 946 | wrapper.setData({ activeDescendantIndex: 1 }) 947 | 948 | list.trigger('keyup.up', { 949 | shiftKey: true, 950 | }) 951 | 952 | // without first option because it was selected 953 | expect(wrapper.emitted('input')[0]).toEqual([FIXTURES_VALUE().slice(1)]) 954 | expect(wrapper.vm.activeDescendantIndex).toBe(0) 955 | 956 | // emit v-model 957 | wrapper.setData({ value: FIXTURES_VALUE().slice(1) }) 958 | wrapper.setData({ activeDescendantIndex: 1 }) 959 | 960 | list.trigger('keyup.up', { 961 | shiftKey: true, 962 | }) 963 | 964 | // now it was not selected so we expect it to be emitted 965 | value = FIXTURES_VALUE().slice(1) 966 | value.push(FIXTURES_VALUE()[0]) 967 | 968 | expect(wrapper.emitted('input')[1]).toEqual([value]) 969 | expect(wrapper.vm.activeDescendantIndex).toBe(0) 970 | 971 | wrapper.setData({ activeDescendantIndex: 0 }) 972 | 973 | list.trigger('keyup.up', { 974 | shiftKey: true, 975 | }) 976 | 977 | expect(wrapper.emitted('input').length).toBe(2) 978 | }) 979 | 980 | it(`should call \`preventDefault\` on \`keydown\` event for the following keyCodes: ${ARROWS.join( 981 | ', ' 982 | )}`, async () => { 983 | const wrapper = await factory() 984 | 985 | const preventDefault = jest.fn() 986 | 987 | const list = wrapper.find(classes.list) 988 | 989 | ARROWS.forEach(arrow => { 990 | list.trigger('keydown', { 991 | _keyCode: arrow, 992 | preventDefault, 993 | }) 994 | }) 995 | 996 | expect(preventDefault).toHaveBeenCalledTimes(ARROWS.length) 997 | }) 998 | 999 | it('should emit `input` event without option with index equals to `activeDescendantIndex` if option is selected and when `SPACE` key is pressed', async () => { 1000 | const wrapper = await factory() 1001 | const event = 'keyup.space' 1002 | 1003 | const list = wrapper.find(classes.list) 1004 | 1005 | list.trigger(event) 1006 | // without first option 1007 | expect(wrapper.emitted('input')[0]).toEqual([FIXTURES_VALUE().slice(1)]) 1008 | 1009 | wrapper.setData({ 1010 | activeDescendantIndex: wrapper.vm.activeDescendantIndex + 1, 1011 | }) 1012 | 1013 | list.trigger(event) 1014 | // without first and second option 1015 | expect(wrapper.emitted('input')[1]).toEqual([FIXTURES_VALUE().slice(2)]) 1016 | }) 1017 | 1018 | it('should emit `input` event with option with index equals to `activeDescendantIndex` if option is not selected and when `SPACE` key is pressed', async () => { 1019 | const options = [{ value: 1, label: 'foo' }, { value: 2, option: 'bar' }] 1020 | 1021 | const wrapper = await factory({ 1022 | propsData: { 1023 | value: [], 1024 | options, 1025 | }, 1026 | }) 1027 | 1028 | const event = 'keyup.space' 1029 | 1030 | const list = wrapper.find(classes.list) 1031 | 1032 | const value = [options[0].value] 1033 | 1034 | list.trigger(event) 1035 | // with first option 1036 | expect(wrapper.emitted('input')[0]).toEqual([value]) 1037 | 1038 | wrapper.setData({ 1039 | activeDescendantIndex: wrapper.vm.activeDescendantIndex + 1, 1040 | }) 1041 | 1042 | value.push(options[1].value) 1043 | 1044 | list.trigger(event) 1045 | // with first and second option 1046 | expect(wrapper.emitted('input')[1]).toEqual([value]) 1047 | }) 1048 | 1049 | it('should emit `input` event with option with index equals to `activeDescendantIndex` and all options down to the last option, and also set `activeDescendantIndex` to the last `options` index when `Ctrl + Shift + END` is pressed', async () => { 1050 | const wrapper = shallowMount(VueAccessibleMultiselect, { 1051 | propsData: { value: [], options: FIXTURES_OPTIONS }, 1052 | }) 1053 | 1054 | await openMenu(wrapper) 1055 | 1056 | wrapper.find(classes.list).trigger('keyup', { 1057 | ctrlKey: true, 1058 | shiftKey: true, 1059 | _keyCode: KEY_END, 1060 | }) 1061 | 1062 | const values = FIXTURES_VALUE() 1063 | 1064 | expect(wrapper.emitted('input')[0]).toEqual([values]) 1065 | expect(wrapper.vm.activeDescendantIndex).toBe(FIXTURES_OPTIONS.length - 1) 1066 | }) 1067 | 1068 | it('should emit `input` event with option with index equals to `activeDescendantIndex` and all options up to the first option, and also set `activeDescendantIndex` to the last `options` index when `Ctrl + Shift + HOME` is pressed', async () => { 1069 | const wrapper = shallowMount(VueAccessibleMultiselect, { 1070 | propsData: { value: [], options: FIXTURES_OPTIONS }, 1071 | }) 1072 | 1073 | await openMenu(wrapper) 1074 | 1075 | wrapper.setData({ activeDescendantIndex: FIXTURES_OPTIONS.length - 1 }) 1076 | 1077 | wrapper.find(classes.list).trigger('keyup', { 1078 | ctrlKey: true, 1079 | shiftKey: true, 1080 | _keyCode: KEY_HOME, 1081 | }) 1082 | 1083 | const values = FIXTURES_VALUE() 1084 | 1085 | expect(wrapper.emitted('input')[0]).toEqual([values.reverse()]) 1086 | expect(wrapper.vm.activeDescendantIndex).toBe(0) 1087 | }) 1088 | 1089 | describe('type-ahead', () => { 1090 | it('should set `activeDescendantIndex` to the index of item with a `label` that starts with the string of characters typed', async () => { 1091 | const wrapper = await factory() 1092 | 1093 | const list = wrapper.find(classes.list) 1094 | 1095 | list.trigger('keyup', { _keyCode: KEY_R }) 1096 | jest.setTimeout(TYPE_AHEAD_TIMEOUT - 100) 1097 | list.trigger('keyup', { _keyCode: KEY_A }) 1098 | jest.setTimeout(TYPE_AHEAD_TIMEOUT - 200) 1099 | list.trigger('keyup', { _keyCode: KEY_C }) 1100 | jest.setTimeout(TYPE_AHEAD_TIMEOUT - 300) 1101 | list.trigger('keyup', { _keyCode: KEY_T }) 1102 | 1103 | expect(wrapper.vm.activeDescendantIndex).toBe( 1104 | FIXTURES_OPTIONS.findIndex(option => 1105 | option.label.toUpperCase().startsWith('React'.toUpperCase()) 1106 | ) 1107 | ) 1108 | }) 1109 | 1110 | it(`should reset \`printedText\` after ${TYPE_AHEAD_TIMEOUT}ms if no \`keyup\` event is triggered`, async done => { 1111 | const wrapper = await factory() 1112 | 1113 | const list = wrapper.find(classes.list) 1114 | 1115 | list.trigger('keyup', { _keyCode: KEY_R }) 1116 | 1117 | expect(wrapper.vm.printedText).toBe('R') 1118 | 1119 | setTimeout(() => { 1120 | expect(wrapper.vm.printedText).toBe('') 1121 | done() 1122 | }, TYPE_AHEAD_TIMEOUT) 1123 | }) 1124 | }) 1125 | 1126 | it('should emit `input` event with options values from the most recently selected option to the `option` with index equals to `activeDescendantIndex`', async () => { 1127 | let wrapper = shallowMount(VueAccessibleMultiselect, { 1128 | propsData: { value: FIXTURES_VALUE(1), options: FIXTURES_OPTIONS }, 1129 | }) 1130 | 1131 | await openMenu(wrapper) 1132 | 1133 | wrapper.setData({ activeDescendantIndex: FIXTURES_OPTIONS.length - 1 }) 1134 | 1135 | wrapper.find(classes.list).trigger('keyup.space', { 1136 | shiftKey: 'true', 1137 | }) 1138 | 1139 | expect(wrapper.emitted('input')[0]).toEqual([FIXTURES_VALUE()]) 1140 | 1141 | let value = [FIXTURES_OPTIONS[FIXTURES_OPTIONS.length - 1].value] 1142 | 1143 | wrapper = shallowMount(VueAccessibleMultiselect, { 1144 | propsData: { value, options: FIXTURES_OPTIONS }, 1145 | }) 1146 | 1147 | await openMenu(wrapper) 1148 | 1149 | wrapper.setData({ activeDescendantIndex: 0 }) 1150 | 1151 | wrapper.find(classes.list).trigger('keyup.space', { 1152 | shiftKey: 'true', 1153 | }) 1154 | 1155 | value.push(...FIXTURES_VALUE().slice(0, -1)) 1156 | 1157 | expect(wrapper.emitted('input')[0]).toEqual([value]) 1158 | 1159 | wrapper = shallowMount(VueAccessibleMultiselect, { 1160 | propsData: { value: [], options: [{ value: 1, label: 'foo' }] }, 1161 | }) 1162 | 1163 | await openMenu(wrapper) 1164 | 1165 | wrapper.find(classes.list).trigger('keyup.space', { 1166 | shiftKey: 'true', 1167 | }) 1168 | 1169 | expect(wrapper.emitted('input')[0]).toEqual([[1]]) 1170 | }) 1171 | }) 1172 | 1173 | describe('computed', () => { 1174 | describe('labelId', () => { 1175 | it('should be correct', () => { 1176 | let wrapper = shallowMount(VueAccessibleMultiselect) 1177 | expect(wrapper.vm.labelId).toBe(null) 1178 | 1179 | wrapper = shallowMount(VueAccessibleMultiselect, { 1180 | propsData: { label: 'foo' }, 1181 | }) 1182 | expect(wrapper.vm.labelId).toBe( 1183 | `v-multiselect-label-${wrapper.vm._uid}` 1184 | ) 1185 | 1186 | wrapper = shallowMount(VueAccessibleMultiselect, { 1187 | slots: { label: 'foo' }, 1188 | }) 1189 | expect(wrapper.vm.labelId).toBe( 1190 | `v-multiselect-label-${wrapper.vm._uid}` 1191 | ) 1192 | }) 1193 | }) 1194 | }) 1195 | 1196 | describe('events', () => { 1197 | it('should set `open` to `false` when clicked outside of multiselect', async () => { 1198 | const wrapper = await factory({ attachToDocument: true }) 1199 | 1200 | document.body.click() 1201 | 1202 | expect(wrapper.vm.open).toBe(false) 1203 | }) 1204 | }) 1205 | 1206 | describe('classes', () => { 1207 | it('should have appropriate class when `open` is `true`, and does not when `false`', () => { 1208 | const wrapper = shallowMount(VueAccessibleMultiselect) 1209 | const { classList } = wrapper.element 1210 | const className = 'v-multiselect--opened' 1211 | 1212 | expect(classList.contains(className)).toBe(false) 1213 | 1214 | wrapper.setData({ open: true }) 1215 | 1216 | expect(classList.contains(className)).toBe(true) 1217 | }) 1218 | }) 1219 | 1220 | describe('emit', () => { 1221 | it('should emit `open` event when `open` becomes `true`', async () => { 1222 | const wrapper = await factory({}, false) 1223 | 1224 | await openMenu(wrapper) 1225 | 1226 | expect(wrapper.emitted().open).toBeTruthy() 1227 | }) 1228 | 1229 | it('should emit `close` event when `open` becomes `false`', async () => { 1230 | const wrapper = await factory({}, false) 1231 | 1232 | await openMenu(wrapper) 1233 | 1234 | wrapper.setData({ open: false }) 1235 | 1236 | expect(wrapper.emitted().close).toBeTruthy() 1237 | }) 1238 | }) 1239 | 1240 | it('should set `activeDescendantIndex` to the index of the first option which `option.value` is in `value`', async () => { 1241 | const wrapper = await factory() 1242 | 1243 | const index = wrapper 1244 | .findAll(classes.option) 1245 | .wrappers.findIndex(wrapper => { 1246 | return wrapper.element.classList.contains( 1247 | classes['option--selected'].substr(1) 1248 | ) // remove `.` 1249 | }) 1250 | 1251 | expect(wrapper.vm.activeDescendantIndex).toBe(index) 1252 | }) 1253 | 1254 | it('should set `activeDescendantIndex` to `0` when `value.length === 0`', async () => { 1255 | const wrapper = await factory({ 1256 | propsData: { value: [], options: FIXTURES_OPTIONS }, 1257 | }) 1258 | 1259 | expect(wrapper.vm.activeDescendantIndex).toBe(0) 1260 | }) 1261 | }) 1262 | --------------------------------------------------------------------------------