├── .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 |
2 |
3 |
vue-accessible-multiselect
4 |
74 |
75 |
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 | [](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 |
35 |
38 |
39 | 😋 Select one of the following options:
42 | 🎃 Select one of the following options
45 | 💎
46 | 🔥 Woooow, {{ value }}
47 | {{ option.label }}
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 |
6 |
14 |
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 |
2 | .v-multiselect(:class="className")
3 | span.v-multiselect__label(
4 | v-if="hasSlot('label') || label"
5 | :id="labelId"
6 | )
7 | slot(name="label") {{ label }}:
8 | .v-multiselect__inner
9 | button.v-multiselect__btn(
10 | :id="buttonId"
11 | ref="button"
12 | :disabled="disabled"
13 | :aria-expanded="ariaExpanded"
14 | :aria-labelledby="`${labelId ? labelId : ''} ${buttonId}`"
15 | :class="btnClass"
16 | type="button"
17 | aria-haspopup="listbox"
18 | @click="toggle"
19 | )
20 | span.v-multiselect__prepend(v-if="hasSlot('prepend')")
21 | slot(name="prepend")
22 | span.v-multiselect__placeholder(v-if="isPlaceholderVisible")
23 | slot(
24 | name="placeholder"
25 | :placeholder="placeholder"
26 | ) {{ placeholder }}
27 | span.v-multiselect__selected(v-if="Array.isArray(value) && value.length !== 0")
28 | slot(
29 | :value="value"
30 | :options="options"
31 | name="selected"
32 | ) {{ selectedText }}
33 | span.v-multiselect__arrow
34 | slot(name="arrow")
35 | svg(viewBox="0 0 255 255")
36 | path(d="M0 64l128 127L255 64z")
37 | transition(
38 | :name="transition ? transition.name : ''"
39 | :mode="transition ? transition.mode : ''"
40 | )
41 | .v-multiselect__menu(v-if="open")
42 | ul.v-multiselect__list(
43 | v-if="Array.isArray(options) && options.length"
44 | ref="list"
45 | aria-multiselectable="true"
46 | :aria-activedescendant="getOptionId(options[activeDescendantIndex])"
47 | :aria-labelledby="labelId"
48 | role="listbox"
49 | tabindex="-1"
50 | style="position: relative;"
51 | @keydown="keyDownHandler"
52 | @keydown.space="$event.preventDefault()"
53 | @keyup="keyUpHandler"
54 | @keyup.up="directionHandler($event, 'up')"
55 | @keyup.down="directionHandler($event, 'down')"
56 | @keyup.esc="escapeHandler"
57 | @keyup.space="spaceHandler"
58 | @keyup.home="homeAndEndHandler"
59 | @keyup.end="homeAndEndHandler"
60 | @blur="blurHandler"
61 | )
62 | li.v-multiselect__option(
63 | v-for="(option, index) in options"
64 | :key="index"
65 | :id="getOptionId(option)"
66 | ref="options"
67 | role="option"
68 | :class="{ 'v-multiselect__option--selected': isSelected(option), 'v-multiselect__option--focus': index === activeDescendantIndex }"
69 | :aria-selected="isSelected(option) ? 'true': 'false'"
70 | @click="input(option)"
71 | )
72 | slot(
73 | name="option"
74 | :option="option"
75 | :value="value"
76 | ) {{ option.label }}
77 | .v-multiselect__no-options(v-else)
78 | slot(name="no-options")
79 | span No options provided
80 |
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 |
--------------------------------------------------------------------------------