├── .github
├── ISSUE_TEMPLATE
│ └── bug_report.yml
└── workflows
│ ├── build.yml
│ ├── changelog.yml
│ └── publish.yml
├── .gitignore
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── docs
├── .vitepress
│ ├── config.ts
│ └── theme
│ │ ├── custom.css
│ │ └── index.ts
├── demo
│ ├── controlled-menu.md
│ ├── custom-displayed-options.md
│ ├── custom-option-slot.md
│ ├── custom-tag-slot.md
│ ├── custom-value-mapping.md
│ ├── dropdown-menu-header.md
│ ├── infinite-scroll.md
│ ├── multiple-select-taggable.md
│ ├── multiple-select.md
│ ├── pre-selected-values.md
│ └── single-select.md
├── events.md
├── getting-started.md
├── index.md
├── multiselect.md
├── options.md
├── props.md
├── public
│ ├── favicon.ico
│ ├── favicon.png
│ └── logo.png
├── slots.md
├── styling.md
└── typescript.md
├── env.d.ts
├── eslint.config.js
├── package-lock.json
├── package.json
├── playground
├── PlaygroundLayout.vue
├── demos
│ ├── ControlledMenu.vue
│ ├── CustomMenuContainer.vue
│ ├── CustomMenuOption.vue
│ ├── CustomOptionLabelValue.vue
│ ├── CustomSearchFilter.vue
│ ├── InfiniteScroll.vue
│ ├── MenuHeader.vue
│ ├── MultiSelect.vue
│ ├── MultiSelectTaggable.vue
│ ├── SelectIsLoading.vue
│ ├── SingleSelect.vue
│ └── TaggableNoOptionsSlot.vue
├── index.html
└── main.ts
├── renovate.json
├── src
├── Indicators.vue
├── Menu.module.css
├── Menu.tsx
├── MenuOption.vue
├── MultiValue.vue
├── Placeholder.vue
├── Select.vue
├── Spinner.vue
├── icons
│ ├── ChevronDownIcon.vue
│ └── XMarkIcon.vue
├── index.ts
├── lib
│ ├── provide-inject.ts
│ └── uid.ts
└── types
│ ├── option.ts
│ ├── props.ts
│ └── slots.ts
├── tests
├── Indicators.spec.ts
├── Menu.spec.ts
├── MenuOption.spec.ts
├── Select.spec.ts
├── index.spec.ts
└── utils.ts
├── tsconfig.app.json
├── tsconfig.build.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.vitest.json
├── vite.config.ts
└── vitest.config.ts
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: 🐞 Bug report
2 | description: Create a report to help us improve
3 | body:
4 | - type: markdown
5 | attributes:
6 | value: |
7 | **Before You Start...**
8 |
9 | This form is only for submitting bug reports. If you have a usage question
10 | or are unsure if this is really a bug, make sure to:
11 |
12 | - Read the [docs](https://vue3-select-component.vercel.app/)
13 | - Ask on [GitHub Discussions](https://github.com/TotomInc/vue3-select-component/discussions)
14 |
15 | Also try to search for your issue - it may have already been answered.
16 | However, if you find that an old, closed issue still persists in the latest version,
17 | you should open a new issue using the form below instead of commenting on the old issue.
18 | - type: input
19 | id: version
20 | attributes:
21 | label: Vue3-Select-Component version
22 | validations:
23 | required: true
24 | - type: input
25 | id: reproduction-link
26 | attributes:
27 | label: Link to minimal reproduction
28 | description: |
29 | The easiest way to provide a reproduction is by showing the bug on the Vue3-Select-Component playground, go the the [docs](https://vue3-select-component.vercel.app/) and click on "Playground" on the header links.
30 | If it cannot be reproduced in the playground and requires a proper build setup, try [StackBlitz](https://vite.new/vue).
31 | If neither of these are suitable, you can always provide a GitHub repository.
32 |
33 | The reproduction should be **minimal** - i.e. it should contain only the bare minimum amount of code needed to show the bug.
34 |
35 | Please do not just fill in a random link. The issue will be closed if no valid reproduction is provided.
36 | placeholder: Reproduction Link
37 | validations:
38 | required: true
39 | - type: textarea
40 | id: steps-to-reproduce
41 | attributes:
42 | label: Steps to reproduce
43 | description: |
44 | What do we need to do after opening your repro in order to make the bug happen? Clear and concise reproduction instructions are important for us to be able to triage your issue in a timely manner. Note that you can use [Markdown](https://guides.github.com/features/mastering-markdown/) to format lists and code.
45 | placeholder: Steps to reproduce
46 | validations:
47 | required: true
48 | - type: textarea
49 | id: expected
50 | attributes:
51 | label: What is expected?
52 | validations:
53 | required: true
54 | - type: textarea
55 | id: actually-happening
56 | attributes:
57 | label: What is actually happening?
58 | validations:
59 | required: true
60 | - type: textarea
61 | id: additional-comments
62 | attributes:
63 | label: Any additional comments?
64 | description: e.g. some background/context of how you ran into this bug.
65 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build & type-check
2 |
3 | on:
4 | push:
5 | branches:
6 | - "*"
7 | pull_request:
8 | branches:
9 | - master
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Node.js environment
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: 22.x
23 |
24 | - name: Install dependencies
25 | run: npm i --no-audit
26 |
27 | - name: Build
28 | run: npm run build
29 |
30 | - name: Test
31 | run: npm run test
32 |
--------------------------------------------------------------------------------
/.github/workflows/changelog.yml:
--------------------------------------------------------------------------------
1 | # Release is automatically triggered when a new tag is pushed to the repository,
2 | # this is done using `npm run bumpp`.
3 | #
4 | # This action will use `changelogithub` which generates a changelog using
5 | # conventional commits, inside the GitHub release.
6 | #
7 | # See: https://github.com/antfu/changelogithub
8 |
9 | name: Generate changelog
10 |
11 | permissions:
12 | contents: write
13 |
14 | on:
15 | push:
16 | tags:
17 | - "v*"
18 |
19 | jobs:
20 | changelog:
21 | runs-on: ubuntu-latest
22 | if: startsWith(github.ref, 'refs/tags/v')
23 |
24 | steps:
25 | - uses: actions/checkout@v4
26 | with:
27 | fetch-depth: 0
28 |
29 | - name: Setup Node.js environment
30 | uses: actions/setup-node@v4
31 | with:
32 | node-version: 22.x
33 | registry-url: "https://registry.npmjs.org"
34 |
35 | - run: npx changelogithub
36 | env:
37 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
38 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish package to npm
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | if: startsWith(github.ref, 'refs/tags/v')
12 |
13 | steps:
14 | - name: Checkout repository
15 | uses: actions/checkout@v4
16 |
17 | - name: Setup Node.js environment
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: 22.x
21 | registry-url: "https://registry.npmjs.org"
22 |
23 | - name: Install dependencies
24 | run: npm i --no-audit
25 |
26 | - name: Build
27 | run: npm run build
28 |
29 | - name: Test
30 | run: npm run test
31 |
32 | - name: Publish to npm
33 | run: npm publish
34 | env:
35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | coverage
15 | *.local
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | !.vscode/settings.json
21 | .idea
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
28 | *.tsbuildinfo
29 |
30 | # VitePress
31 | docs/.vitepress/dist
32 | docs/.vitepress/cache
33 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Enable the ESlint flat config support.
3 | "eslint.experimental.useFlatConfig": true,
4 |
5 | // Disable the default formatter, use ESLint instead.
6 | "prettier.enable": false,
7 | "editor.formatOnSave": false,
8 |
9 | // Auto fix ESLint errors on save.
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll.eslint": "explicit",
12 | "source.organizeImports": "never"
13 | },
14 |
15 | // Silent the stylistic rules in you IDE, but still auto fix them.
16 | "eslint.rules.customizations": [
17 | { "rule": "style/*", "severity": "off" },
18 | { "rule": "format/*", "severity": "off" },
19 | { "rule": "*-indent", "severity": "off" },
20 | { "rule": "*-spacing", "severity": "off" },
21 | { "rule": "*-spaces", "severity": "off" },
22 | { "rule": "*-order", "severity": "off" },
23 | { "rule": "*-dangle", "severity": "off" },
24 | { "rule": "*-newline", "severity": "off" },
25 | { "rule": "*quotes", "severity": "off" },
26 | { "rule": "*semi", "severity": "off" }
27 | ],
28 |
29 | // Enable eslint for all supported languages.
30 | "eslint.validate": [
31 | "javascript",
32 | "javascriptreact",
33 | "typescript",
34 | "typescriptreact",
35 | "vue",
36 | "html",
37 | "markdown",
38 | "json",
39 | "jsonc",
40 | "yaml",
41 | "toml"
42 | ],
43 |
44 | // Vue Official extension should not have the hybrid mode.
45 | "vue.server.hybridMode": false
46 | }
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Thomas Cazade
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Vue3-Select-Component
11 |
12 |
13 |
14 | Best-in-class select component for Vue 3, with a focus on DX, accessibility and ease-of-use.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Documentation | Getting Started | Examples / Demos
33 |
34 |
35 | **Core features:**
36 |
37 | - ⚙️ Data manipulation with `v-model`
38 | - 🔑 [Typesafe relationship](https://vue3-select-component.vercel.app/typescript.html) between `options` and `v-model`
39 | - 🎨 Great styling out-of-the-box, customization with CSS variables & Vue `:deep`
40 | - ✅ Single & multi-select support
41 | - 🖌️ Infinite customization with ``s
42 | - 🪄 ` ` menu where you want
43 | - 📦 Extremely light, **0 dependencies** (4.4kb gzip)
44 |
45 | ## Installation
46 |
47 | Install the package with npm:
48 |
49 | ```bash
50 | npm i vue3-select-component
51 | ```
52 |
53 | Use it in your Vue 3 app:
54 |
55 | ```vue
56 |
62 |
63 |
64 |
65 |
73 |
74 |
75 | ```
76 |
77 | ## Advanced TypeScript usage
78 |
79 | Vue 3 Select Component creates a type-safe relationship between the `option.value` and the `v-model` prop.
80 |
81 | It also leverages the power of generics to provide types for additional properties on the options.
82 |
83 | ```vue
84 |
99 |
100 |
101 |
107 |
108 | ```
109 |
110 | [There's an entire documentation page](https://vue3-select-component.vercel.app/typescript.html) dedicated to usage with TypeScript.
111 |
112 | ## Releases
113 |
114 | For changelog, visit [releases](https://github.com/TotomInc/vue3-select-component/releases).
115 |
116 | ## License
117 |
118 | MIT Licensed. Copyright (c) Thomas Cazade 2024 - present.
119 |
--------------------------------------------------------------------------------
/docs/.vitepress/config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitepress";
2 |
3 | // https://vitepress.dev/reference/site-config
4 | export default defineConfig({
5 | title: "Vue 3 Select Component",
6 | description: "A performant & accessible Select component for Vue 3. Best in class Selecting solution for Vue 3.",
7 |
8 | head: [
9 | ["link", { rel: "icon", type: "image/png", href: "/favicon.png" }],
10 | ["meta", { name: "google-site-verification", content: "qv0rGOlwG3_UHi5HWsY3NtpsLZHcQ79xQHAgH2q_1WA" }],
11 | ["script", { "src": "https://cloud.umami.is/script.js", "data-website-id": "8fb344f7-10d2-44d7-bdc7-d8a4952b91c5", "defer": "true" }],
12 | ],
13 |
14 | sitemap: {
15 | hostname: "https://vue3-select-component.vercel.app",
16 | },
17 |
18 | themeConfig: {
19 | logo: "/logo.png",
20 | nav: [
21 | { text: "Home", link: "/" },
22 | { text: "Docs", link: "/getting-started" },
23 | { text: "Demo", link: "/demo/single-select" },
24 | { text: "Playground", link: "https://play.vuejs.org/#eNqNU01v2zAM/SuCLtmA2MaQ7ZK5Qbehh/WwDU2xyzwMqs04TmVJ0IedIfF/HyU7qZOmxS6GRT3yPT5SO/pJqbhxQOc0NbmulCUGrFOEM1FeZdSajC4yUdVKakt2RMOKdGSlZU0yinkZ/Xi8/elgCRxyO7qfRSaEolwiSICwISMTuRTGkoZxB+TKl02N1ZUoyZ4Ix/nijf++RWia9LpQBR4s1IozC3giJH1i9EdCmqiWBXDUHQpntA/PpbIV8mH8Vx8h2ApnD8DnZHLLGrYMFJNpLwiDGzMh3fQ5+P6vgmdg+wL4zpkxTPvjReCyrVZjpAnnI/T3oRFsPYe15AVo7GVonRGlZalZXXv7/NwcK4fek+AaOqUWPRoK0ptx4NrthiHs9zgxgSPKKOm6NFGYmiYjv+mU9oOOaqbijZECl2bnq2fDBe4KFuylDtsxx5+1tcrMkyQvBKbhfKpGxwJsIlSdXCMs0U7YqoaokPW1ZzM2KSr8jOIxmDp60LI1oLFKRgdvAk+CwQZ0pEGgNaD/l/cs7YT77O5V/ktbPlbQtm3shHosYwQkFxNOuCvk3CJjIPI8XSY69N8afDarqjxz3xepOOjv/ZqfTIFxLtvbELPawVF1vob88UJ8Y7a99h8aggWjTi3TJQyt3Sy/wRb/j5f48hwfJv7C5R0YyZ3X2MM+O1Gg7BEuqP0algmX+d7cbC0Ic2jKCw1uBHxw/ssrrT/JncXvRy7+wbn6mmjgLP4Qv5vR7h/FnLCQ" },
25 | { text: "Changelog", link: "https://github.com/TotomInc/vue3-select-component/releases" },
26 | ],
27 |
28 | footer: {
29 | copyright: "Released under the MIT License.",
30 | },
31 |
32 | sidebar: [
33 | {
34 | text: "Documentation",
35 | items: [
36 | { text: "Getting Started", link: "/getting-started" },
37 | { text: "Options", link: "/options" },
38 | { text: "Props", link: "/props" },
39 | { text: "Slots", link: "/slots" },
40 | { text: "Events", link: "/events" },
41 | { text: "Styling", link: "/styling" },
42 | { text: "TypeScript Guide", link: "/typescript" },
43 | ],
44 | },
45 | {
46 | text: "Demo links",
47 | items: [
48 | { text: "Single select", link: "/demo/single-select" },
49 | { text: "Multiple Select", link: "/demo/multiple-select" },
50 | { text: "Multiple Select Taggable", link: "/demo/multiple-select-taggable" },
51 | { text: "Custom option slot", link: "/demo/custom-option-slot" },
52 | { text: "Custom tag slot", link: "/demo/custom-tag-slot" },
53 | { text: "Custom value mapping", link: "/demo/custom-value-mapping" },
54 | { text: "Dropdown menu header", link: "/demo/dropdown-menu-header" },
55 | { text: "Custom displayed options", link: "/demo/custom-displayed-options" },
56 | { text: "Controlled menu", link: "/demo/controlled-menu" },
57 | { text: "Pre-selected values", link: "/demo/pre-selected-values" },
58 | { text: "Infinite scroll", link: "/demo/infinite-scroll" },
59 | ],
60 | },
61 | ],
62 |
63 | socialLinks: [
64 | { icon: "github", link: "https://github.com/TotomInc/vue3-select-component" },
65 | { icon: "npm", link: "https://www.npmjs.com/package/vue3-select-component" },
66 | ],
67 |
68 | search: {
69 | provider: "algolia",
70 | options: {
71 | appId: "ZOB728VULQ",
72 | apiKey: "0ef0bfc5f328b473061642ab4c730a3b",
73 | indexName: "vue3-select-component",
74 | },
75 | },
76 |
77 | outline: "deep",
78 | },
79 | });
80 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/custom.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --vp-c-brand-1: #34d399;
3 | --vp-c-brand-2: #059669;
4 | --vp-c-brand-3: #047857;
5 |
6 | --vp-c-brand-soft: rgb(52 211 153 / 20%);
7 | }
8 |
9 | /* Home-page large title. */
10 | .VPHero.VPHomeHero .name,
11 | .VPHero.VPHomeHero .text {
12 | max-width: unset;
13 | }
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | import DefaultTheme from "vitepress/theme";
2 | import "./custom.css";
3 |
4 | export default DefaultTheme;
5 |
--------------------------------------------------------------------------------
/docs/demo/controlled-menu.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Controlled menu'
3 | ---
4 |
5 | # Controlled menu
6 |
7 | Control the menu open state programmatically with the `isMenuOpen` prop.
8 |
9 |
17 |
18 |
19 | Toggle menu ({{ isMenuOpen ? "opened" : "closed" }})
20 |
21 |
22 |
23 |
34 |
35 |
36 | ## Demo source-code
37 |
38 | ```vue
39 |
46 |
47 |
48 |
49 | Toggle menu ({{ isMenuOpen ? "opened" : "closed" }})
50 |
51 |
52 |
63 |
64 | ```
65 |
--------------------------------------------------------------------------------
/docs/demo/custom-displayed-options.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Custom displayed options'
3 | ---
4 |
5 | # Custom displayed options
6 |
7 | The following example demonstrate how you can create a complex filter inside the options menu, using:
8 |
9 | - `displayedOptions` prop.
10 | - `#menu-header` slot.
11 |
12 | Make sure to check the props & slots documentation for each of them.
13 |
14 |
43 |
44 |
45 |
52 |
53 |
54 | Switch filter type (current: {{ filter }})
55 |
56 |
57 |
58 |
59 |
60 |
67 |
68 | ## Demo source-code
69 |
70 | ```vue
71 |
99 |
100 |
101 |
108 |
109 |
110 | Switch filter type (current: {{ filter }})
111 |
112 |
113 |
114 |
115 | ```
116 |
--------------------------------------------------------------------------------
/docs/demo/custom-option-slot.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Custom option slot'
3 | ---
4 |
5 | # Custom option slot
6 |
7 | The following example demonstrates how to use the `VueSelect` component with custom slots for `#value` & `#option` slots.
8 |
9 | ::: info
10 | Read more about available [slots here](../slots.md).
11 | :::
12 |
13 |
20 |
21 |
22 |
34 |
35 |
36 |
37 |
{{ option.label }}
38 |
39 |
40 |
41 |
42 |
43 | {{ option.label }} {{ option.value }}
44 |
45 |
46 |
47 |
48 |
49 |
74 |
75 | ## Demo source-code
76 |
77 | ```vue
78 |
84 |
85 |
86 |
98 |
99 |
100 |
101 |
{{ option.label }}
102 |
103 |
104 |
105 |
106 |
107 | {{ option.label }} {{ option.value }}
108 |
109 |
110 |
111 |
112 |
113 |
135 | ```
136 |
--------------------------------------------------------------------------------
/docs/demo/custom-tag-slot.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Custom tag slot'
3 | ---
4 |
5 | # Custom tag slot
6 |
7 | The following example demonstrates how to use the `VueSelect` component with a custom slot `#tag` when using the `isMulti` prop.
8 |
9 | ::: info
10 | Read more about available [slots here](../slots.md) and the `isMulti` prop [here](../props.md#isMulti).
11 | :::
12 |
13 |
20 |
21 |
22 |
31 |
32 |
33 | {{ option.username }} ×
34 |
35 |
36 |
37 |
38 |
39 |
60 |
61 | ## Demo source-code
62 |
63 | ```vue
64 |
70 |
71 |
80 |
81 |
82 | {{ option.username }} ×
83 |
84 |
85 |
86 |
87 |
108 | ```
109 |
--------------------------------------------------------------------------------
/docs/demo/custom-value-mapping.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Custom value mapping'
3 | ---
4 |
5 | # Custom value mapping
6 |
7 | ::: warning
8 | This isn't a common use-case. You should use the `label` and `value` properties of the option object when possible.
9 | Doing this will break the type-safety of the component.
10 | Read more about [`getOptionLabel` and `getOptionValue` props](../props.md).
11 | :::
12 |
13 | In the rare case you need to use different properties for the `label` and `value` of an option, you can use the `getOptionLabel` and `getOptionValue` props.
14 |
15 | If you're using TypeScript, be sure to read the [type-safety guide for these props](../typescript.md#custom-value-mapping) section.
16 |
17 |
24 |
25 |
26 |
36 |
37 |
38 | ## Demo source-code
39 |
40 | ```vue
41 |
47 |
48 |
49 |
59 |
60 | ```
61 |
--------------------------------------------------------------------------------
/docs/demo/dropdown-menu-header.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Dropdown menu header'
3 | ---
4 |
5 | # Dropdown menu header
6 |
7 | The following example demonstrates how to use the `VueSelect` component with a custom menu header before the options.
8 |
9 | In this example, we can make the menu header sticky, so it will always be visible when scrolling through the options.
10 |
11 |
18 |
19 |
20 |
30 |
31 |
34 |
35 |
36 |
37 |
38 |
51 |
52 | ## Demo source-code
53 |
54 | ```vue
55 |
61 |
62 |
63 |
73 |
74 |
77 |
78 |
79 |
80 |
81 |
94 | ```
95 |
--------------------------------------------------------------------------------
/docs/demo/infinite-scroll.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Infinite Scroll'
3 | ---
4 |
5 | # Infinite Scroll
6 |
7 | Due to VitePress restriction, it's not possible to show the infinite scroll demo here.
8 |
9 | Please refer to the [demo component source-code](https://github.com/TotomInc/vue3-select-component/blob/8241306c7ddcb9840ea55ffbea9e45b59b80fbdc/playground/demos/InfiniteScroll.vue).
10 |
11 | ```vue
12 |
63 |
64 |
65 |
72 |
73 |
83 |
84 |
85 |
86 |
87 | Selected book value: {{ selected || "none" }}
88 |
89 |
90 |
91 |
108 | ```
109 |
--------------------------------------------------------------------------------
/docs/demo/multiple-select-taggable.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Multiple Select Taggable'
3 | ---
4 |
5 | # Multiple Select Taggable
6 |
7 | The following example demonstrates how to use the `VueSelect` component to create a multiple select dropdown with taggable options _(options that are created by the user)_.
8 |
9 | ::: warning
10 | Setting `is-multi` to `true` will change the `v-model` to become an array of any `any[]`. Make sure to update your `v-model` accordingly.
11 | :::
12 |
13 |
32 |
33 |
34 | handleCreateOption(value)"
40 | />
41 |
42 |
43 | Selected value(s): **{{ selected.length ? selected.join(", ") : "none" }}**
44 |
45 | ## Demo source-code
46 |
47 | ```vue
48 |
68 |
69 |
70 | handleCreateOption(value)"
76 | />
77 |
78 | ```
79 |
--------------------------------------------------------------------------------
/docs/demo/multiple-select.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Multiple Select'
3 | ---
4 |
5 | # Multiple Select
6 |
7 | The following example demonstrates how to use the `VueSelect` component to create a multiple select dropdown.
8 |
9 | ::: warning
10 | Setting `is-multi` to `true` will change the `v-model` to become an array of any `any[]`. Make sure to update your `v-model` accordingly.
11 | :::
12 |
13 |
20 |
21 |
22 |
31 |
32 |
33 | Selected value(s): **{{ selected.length ? selected.join(", ") : "none" }}**
34 |
35 | ## Demo source-code
36 |
37 | ```vue
38 |
45 |
46 |
47 |
56 |
57 | ```
58 |
--------------------------------------------------------------------------------
/docs/demo/pre-selected-values.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Pre-selected values'
3 | ---
4 |
5 | # Pre-selected values (single & multi)
6 |
7 | The following example demonstrates how to use the `VueSelect` component with pre-selected values.
8 |
9 | This can be achieved by setting a value to the `ref` which is passed as a `v-model` to the `VueSelect` component.
10 |
11 |
27 |
28 | Single select:
29 |
30 |
34 |
35 | Multi select:
36 |
37 |
42 |
43 | ## Demo source-code
44 |
45 | ```vue
46 |
61 |
62 |
63 | Single select:
64 |
65 |
69 |
70 | Multi select:
71 |
72 |
77 |
78 | ```
79 |
--------------------------------------------------------------------------------
/docs/demo/single-select.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Single Select'
3 | ---
4 |
5 | # Single Select
6 |
7 | The following example demonstrates how to use the `VueSelect` component to create a single select dropdown.
8 |
9 |
16 |
17 |
18 |
26 |
27 |
28 | Selected value: **{{ selected || "none" }}**
29 |
30 | ## Demo source-code
31 |
32 | ```vue
33 |
39 |
40 |
41 |
49 |
50 | ```
51 |
--------------------------------------------------------------------------------
/docs/events.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Events'
3 | ---
4 |
5 | # Events
6 |
7 | ## `@option-selected`
8 |
9 | Emitted when an option is selected, in the same tick where the `v-model` is updated.
10 |
11 | **Payload**: `Option` - The selected option.
12 |
13 | ```vue
14 |
15 | console.log(option.label, option.value)"
19 | />
20 |
21 | ```
22 |
23 | **Note**: this is emitted on the same tick as the v-model is updated, before a DOM re-render.
24 |
25 | ::: info
26 | For keeping track of the selected option, it's recommended to use a `computed` property combined with the `v-model` instead of relying on the `@option-selected` event. This approach is more efficient and aligns better with Vue's reactivity system. Here's an example:
27 |
28 | ```ts
29 | const options = [{ label: "France", value: "FR" }];
30 | const activeValue = ref("FR");
31 | const selectedOption = computed(
32 | () => options.find((option) => option.value === activeValue.value),
33 | );
34 | ```
35 | :::
36 |
37 | ## `@option-deselected`
38 |
39 | Emitted when an option is deselected, in the same tick where the `v-model` is updated.
40 |
41 | **Payload**: `Option` - The deselected option.
42 |
43 | ```vue
44 |
45 | console.log(option.label, option.value)"
49 | />
50 |
51 | ```
52 |
53 | **Note**: this is emitted on the same tick as the v-model is updated, before a DOM re-render.
54 |
55 | ## `@option-created`
56 |
57 | Emitted when a new option is created with the `:taggable="true"` prop.
58 |
59 | **Payload**: `string` - The search content value.
60 |
61 | ```vue
62 |
63 | console.log('New option created:', value)"
68 | />
69 |
70 | ```
71 |
72 | ## `@search`
73 |
74 | Emitted when the search value is updated.
75 |
76 | **Payload**: `string` - The search content value.
77 |
78 | ::: warning
79 | Search value is cleared when the menu is closed. This will trigger an empty string emit event. See tests implementations for more details.
80 | :::
81 |
82 | ```vue
83 |
84 | console.log('search value:', search)"
88 | />
89 |
90 | ```
91 |
92 | ## `@menu-opened`
93 |
94 | Emitted when the menu is opened.
95 |
96 | ```vue
97 |
98 | console.log('menu opened')"
102 | />
103 |
104 | ```
105 |
106 | ## `@menu-closed`
107 |
108 | Emitted when the menu is closed.
109 |
110 | ```vue
111 |
112 | console.log('menu closed')"
116 | />
117 |
118 | ```
119 |
--------------------------------------------------------------------------------
/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Getting Started'
3 | ---
4 |
5 | # Getting Started
6 |
7 | ## Installation
8 |
9 | Vue 3 Select Component can be installed using your favorite package manager:
10 |
11 | ::: code-group
12 | ```sh [npm]
13 | $ npm add vue3-select-component
14 | ```
15 |
16 | ```sh [pnpm]
17 | $ pnpm add vue3-select-component
18 | ```
19 |
20 | ```sh [yarn]
21 | $ yarn add vue3-select-component
22 | ```
23 |
24 | ```sh [bun]
25 | $ bun add vue3-select-component
26 | ```
27 | :::
28 |
29 | ::: tip
30 | [Vue.js](https://vuejs.org) 3.5+ is required to use this component.
31 | :::
32 |
33 | ## Single select usage
34 |
35 | Import the component with the styling, and use it in your Vue 3 application:
36 |
37 | ```vue
38 |
44 |
45 |
46 |
55 |
56 | ```
57 |
58 | Since the component is built with TypeScript, your IDE will provide you with autocompletion and type checking automatically.
59 |
60 | ## Multiselect usage
61 |
62 | Import the component with the styling, and use it in your Vue 3 application.
63 |
64 | To enable the multiselect feature, all you need to do is:
65 |
66 | - set the `is-multi` prop to `true`
67 | - use an array for the `v-model`
68 |
69 | ```vue
70 |
76 |
77 |
86 | ```
87 |
88 | ## Data binding
89 |
90 | Vue 3 Select Component takes advantage of Vue's `v-model`, which means you can use it with `v-model` to bind the selected value(s) to a variable.
91 |
92 | This makes it easy to use the component anywhere in your application, while being reactive and easy to work with.
93 |
94 | [Learn more about `v-model`](https://vuejs.org/guide/components/v-model.html).
95 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: home
3 |
4 | title: Vue 3 Select Component
5 | titleTemplate: The Selecting solution for Vue 3.
6 |
7 | hero:
8 | name: Vue 3 Select Component
9 | text: The Selecting solution for Vue 3.
10 | tagline: Best-in-class select component for Vue 3. Great DX, easy to use, and highly customizable.
11 | actions:
12 | - theme: brand
13 | text: Get Started
14 | link: /getting-started
15 | - theme: alt
16 | text: View on GitHub
17 | link: https://github.com/TotomInc/vue3-select-component
18 |
19 | features:
20 | - title: Best DX
21 | details: Built with TypeScript and Vue 3, the component provides a great developer experience with autocompletion and type checking.
22 | - title: Easy to Use
23 | details: The component leverage Vue's v-model to make it easy to use and integrate with your application.
24 | - title: Highly Customizable
25 | details: The component is highly customizable with a lot of options and slots to fit your needs.
26 | - title: Accessible
27 | details: The component is built with accessibility in mind and has been tested with screen readers.
28 | ---
29 |
--------------------------------------------------------------------------------
/docs/multiselect.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: home
3 |
4 | title: Vue 3 Multiselect Component
5 |
6 | head:
7 | - - meta
8 | - name: title
9 | content: Vue 3 Multiselect Component | The selecting solution for Vue 3
10 | - name: description
11 | content: Best-in-class multiselect component with Vue 3. Great DX with TypeScript, easy to use & easily customizable.
12 |
13 | hero:
14 | name: Vue 3 Multiselect Component
15 | text: The Selecting solution for Vue 3.
16 | tagline: Best-in-class multiselect component for Vue 3. Great DX, easy to use, and highly customizable.
17 | actions:
18 | - theme: brand
19 | text: Get Started
20 | link: /getting-started#multiselect-usage
21 | - theme: alt
22 | text: View on GitHub
23 | link: https://github.com/TotomInc/vue3-select-component
24 |
25 | features:
26 | - title: Best DX
27 | details: Built with TypeScript and Vue 3, the component provides a great developer experience with autocompletion and type checking.
28 | - title: Easy to Use
29 | details: The component leverage Vue's v-model to make it easy to use and integrate with your application.
30 | - title: Highly Customizable
31 | details: The component is highly customizable with a lot of options and slots to fit your needs.
32 | - title: Accessible
33 | details: The component is built with accessibility in mind and has been tested with screen readers.
34 | ---
35 |
--------------------------------------------------------------------------------
/docs/options.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Options'
3 | ---
4 |
5 | # Options
6 |
7 | The `options` prop is the core configuration for populating the dropdown menu. Understanding how to structure your options is essential for effective component usage.
8 |
9 | ## Basic structure
10 |
11 | Each option requires two key properties:
12 |
13 | ```vue
14 |
15 |
21 |
22 | ```
23 |
24 | **Properties:**
25 |
26 | - `label`: Text displayed in the dropdown menu
27 | - `value`: Data bound to `v-model` when the option is selected
28 |
29 | ::: info
30 | For TypeScript users, import the `Option` type to ensure proper type checking:
31 |
32 | ```ts
33 | import type { Option } from "vue3-select-component";
34 |
35 | const options: Option[] = [{ label: "JavaScript", value: "js" }];
36 | ```
37 | :::
38 |
39 | ## Disabling options
40 |
41 | Individual options can be disabled by adding the `disabled` property:
42 |
43 | ```vue
44 |
45 |
51 |
52 | ```
53 |
54 | Disabled options:
55 |
56 | - Cannot be selected
57 | - Cannot be focused with keyboard navigation
58 | - Have distinct visual styling
59 | - Include proper ARIA attributes for accessibility
60 |
61 | ## Extended Properties
62 |
63 | Options can include additional properties beyond the standard `label`/`value` pair:
64 |
65 | ```vue
66 |
67 |
78 |
79 | {{ option.label }} - Created by {{ option.creator }}
80 |
81 |
82 |
83 | ```
84 |
85 | ::: info
86 | When using extended properties with TypeScript, extend the `Option` type:
87 |
88 | ```ts
89 | type LanguageOption = Option & {
90 | version: string;
91 | creator: string;
92 | };
93 | ```
94 |
95 | For more details, see the [Extending option properties guide](./typescript.md#extending-option-properties).
96 | :::
97 |
--------------------------------------------------------------------------------
/docs/props.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Props'
3 | ---
4 |
5 | # Props
6 |
7 | ::: info
8 | This component is **ready to be used in production**. However, if there is a feature you would like to see implemented, feel free to open an issue or submit a pull request.
9 | :::
10 |
11 | ## v-model
12 |
13 | **Type**: `any | any[]`
14 |
15 | **Required**: `true`
16 |
17 | The value of the select. If `isMulti` is `true`, the `v-model` should be an array of any `any[]`.
18 |
19 | ::: info
20 | If using TypeScript, you can leverage proper type-safety between `option.value` & `v-model`. By doing this, you don't have an `any` type. Read more about [TypeScript usage](/typescript).
21 | :::
22 |
23 | ## options
24 |
25 | **Type**: `Option[]`
26 |
27 | **Required**: `true`
28 |
29 | A list of all possible options to choose from. Each option should have a `label` and a `value` property. You can add any other properties to the options, which will be passed to the `option` slot.
30 |
31 | **Type interface**:
32 |
33 | ```ts
34 | type Option = {
35 | label: string;
36 | value: T;
37 | disabled?: boolean;
38 | };
39 | ```
40 |
41 | ::: tip
42 | This type is exported from the component and can be imported in your application.
43 | :::
44 |
45 | ::: info
46 | If you are using TypeScript, you can leverage proper type-safety between `option.value` & `v-model`. Read more about [TypeScript usage](/typescript).
47 | :::
48 |
49 | ### option.disabled
50 |
51 | **Type**: `boolean`
52 |
53 | **Required**: `false`
54 |
55 | **Default**: `undefined`
56 |
57 | Whether the option should be disabled. When an option is disabled, it cannot be selected nor focused/navigated to using the keyboard. It also benefits from extra aria attributes to improve accessibility.
58 |
59 | ## displayedOptions
60 |
61 | **Type**: `Option[]`
62 |
63 | **Required**: `false`
64 |
65 | A list of specific options to display inside the option menu. This is useful when you want to create a complex filter logic inside the options menu.
66 |
67 | ::: warning
68 | When this prop is passed to the component, the `options` prop won't be used anymore for the rendering of the options menu.
69 |
70 | However, **it is still used internally to keep track of selected value(s)**.
71 |
72 | You should pass a list of all possible options to the `options` prop, and a list of specific options to display inside the option menu to the `displayedOptions` prop.
73 | :::
74 |
75 | For more details, see the [custom displayed options](/demo/custom-displayed-options) demo.
76 |
77 | ## placeholder
78 |
79 | **Type**: `string`
80 |
81 | **Default**: `Select an option`
82 |
83 | The placeholder text to show when no option is selected.
84 |
85 | ## isClearable
86 |
87 | **Type**: `boolean`
88 |
89 | **Default**: `true`
90 |
91 | Whether the select should have a clear button to reset the selected value.
92 |
93 | ## isDisabled
94 |
95 | **Type**: `boolean`
96 |
97 | **Default**: `false`
98 |
99 | Whether the select should be disabled.
100 |
101 | ## isSearchable
102 |
103 | **Type**: `boolean`
104 |
105 | **Default**: `true`
106 |
107 | Whether the select should have a search input to filter the options.
108 |
109 | ## isMulti
110 |
111 | **Type**: `boolean`
112 |
113 | **Default**: `false`
114 |
115 | Whether the select should allow multiple selections. If `true`, the `v-model` should be an array of string `string[]`.
116 |
117 | ## isTaggable
118 |
119 | **Type**: `boolean`
120 |
121 | **Default**: `false`
122 |
123 | Whether the select should allow creating a new option if it doesn't exist. When `true`, if the user searches for an option that isn't part of the list, the menu will display a text to ask if the user wants to create this option.
124 |
125 | ::: info
126 | It is up to the user to intercept the new option (using `@option-created` event) and manipulate its array of options provided with the `:options` prop. It is also recommended to slugify the value received and ensure it is unique.
127 | For more details, see the [Multiple Select Taggable](/demo/multiple-select-taggable) demo.
128 | :::
129 |
130 | ## isLoading
131 |
132 | **Type**: `boolean`
133 |
134 | **Default**: `false`
135 |
136 | Whether the select should display a loading state. When `true`, the select will show a loading spinner or custom loading content provided via the `loading` slot.
137 |
138 | ## isMenuOpen
139 |
140 | **Type**: `boolean`
141 |
142 | **Default**: `undefined`
143 |
144 | A prop to control the menu open state programmatically. When set to `true`, the menu will be open. When set to `false`, the menu will be closed.
145 |
146 | ## hideSelectedOptions
147 |
148 | **Type**: `boolean`
149 |
150 | **Default**: `true`
151 |
152 | When set to `true` with `isMulti`, selected options won't appear in the options menu. Set it to `false` to show selected options in the menu.
153 |
154 | ## shouldAutofocusOption
155 |
156 | **Type**: `boolean`
157 |
158 | **Default**: `true`
159 |
160 | Whether the first option should be focused when the dropdown is opened. If set to `false`, the first option will not be focused, and the user will have to navigate through the options using the keyboard.
161 |
162 | ## closeOnSelect
163 |
164 | **Type**: `boolean`
165 |
166 | **Default**: `true`
167 |
168 | Whether the dropdown should close after an option is selected.
169 |
170 | ## teleport
171 |
172 | **Type**: `string`
173 |
174 | **Default**: `undefined`
175 |
176 | Teleport the menu outside of the component DOM tree. You can pass a valid string according to the official Vue 3 Teleport documentation (e.g. `teleport="body"` will teleport the menu into the `` tree). This can be used in case you are having `z-index` issues within your DOM tree structure.
177 |
178 | ::: info
179 | Top and left properties are calculated using a ref on the `.vue-select` with a `container.getBoundingClientRect()`.
180 | :::
181 |
182 | ## inputId
183 |
184 | **Type**: `string`
185 |
186 | **Default**: `undefined`
187 |
188 | The `id` attribute to be passed to the ` ` element. This is useful for accessibility or forms.
189 |
190 | ## classes
191 |
192 | **Type**:
193 |
194 | ```ts
195 | type SelectClasses = {
196 | container?: string;
197 | control?: string;
198 | valueContainer?: string;
199 | placeholder?: string;
200 | singleValue?: string;
201 | multiValue?: string;
202 | multiValueLabel?: string;
203 | multiValueRemove?: string;
204 | inputContainer?: string;
205 | searchInput?: string;
206 | menuContainer?: string;
207 | menuOption?: string;
208 | noResults?: string;
209 | taggableNoOptions?: string;
210 | };
211 | ```
212 |
213 | **Default**: `undefined`
214 |
215 | CSS classes to be applied at multiple places in the select component. Useful when using TailwindCSS to customize the component.
216 |
217 | ## uid
218 |
219 | **Type**: `string | number`
220 |
221 | **Default**: `number`
222 |
223 | A unique identifier to be passed to the select control. Will be used on multiple `id` attributes for accessibility purposes such as `aria-owns`, `aria-controls`, etc.
224 |
225 | ## aria
226 |
227 | **Type**: `{ labelledby?: string; required?: boolean; }`
228 |
229 | **Default**: `undefined`
230 |
231 | Aria attributes to be passed to the select control to improve accessibility.
232 |
233 | ## disableInvalidVModelWarn
234 |
235 | **Type**: `boolean`
236 |
237 | **Default**: `false`
238 |
239 | When set to true, the component will not emit a `console.warn` because of an invalid `v-model` type when using `isMulti`. This is useful when using the component with dynamic `v-model` references.
240 |
241 | ## filterBy
242 |
243 | **Type**:
244 |
245 | ```ts
246 | (option: Option, label: string, search: string) => boolean;
247 | ```
248 |
249 | **Default**:
250 |
251 | ```ts
252 | (option, label, search) => label.toLowerCase().includes(search.toLowerCase());
253 | ```
254 |
255 | Callback function to determine if the current option should match the search query. This function is called for each option and should return a boolean.
256 |
257 | The `label` is provided as a convenience, processed from `getOptionLabel` prop.
258 |
259 | ::: info
260 | By default, the following callback function is used `(option, label, search) => label.toLowerCase().includes(search.toLowerCase())`
261 | :::
262 |
263 | ## getOptionLabel
264 |
265 | **Type**:
266 |
267 | ```ts
268 | (option: Option) => string;
269 | ```
270 |
271 | **Default**:
272 |
273 | ```ts
274 | (option) => option.label;
275 | ```
276 |
277 | Resolves option data to a string to render the option label.
278 |
279 | This function can be used if you don't want to use the standard `option.label` as the label of the option.
280 |
281 | The label of an option is displayed in the dropdown and as the selected option (**single-value**) in the select.
282 |
283 | ## getOptionValue
284 |
285 | **Type**:
286 |
287 | ```ts
288 | (option: Option) => string;
289 | ```
290 |
291 | **Default**:
292 |
293 | ```ts
294 | (option) => option.value;
295 | ```
296 |
297 | Resolves option data to a string to compare options and specify value attributes.
298 |
299 | This function can be used if you don't want to use the standard `option.value` as the value of the option.
300 |
--------------------------------------------------------------------------------
/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TotomInc/vue3-select-component/cb800b4548514312bec16cc81e8621827b9758e8/docs/public/favicon.ico
--------------------------------------------------------------------------------
/docs/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TotomInc/vue3-select-component/cb800b4548514312bec16cc81e8621827b9758e8/docs/public/favicon.png
--------------------------------------------------------------------------------
/docs/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TotomInc/vue3-select-component/cb800b4548514312bec16cc81e8621827b9758e8/docs/public/logo.png
--------------------------------------------------------------------------------
/docs/slots.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Slots'
3 | ---
4 |
5 | # Slots
6 |
7 | To ensure maximum flexibility, this component provides multiple ` `s in order to allow for more customization.
8 |
9 | ::: info
10 | If you are not familiar with Vue's slots, you can read more about them [here](https://vuejs.org/guide/components/slots.html).
11 | :::
12 |
13 | ## option
14 |
15 | **Type**:
16 |
17 | ```ts
18 | slotProps: {
19 | option: Option;
20 | index: number;
21 | isFocused: boolean;
22 | isSelected: boolean;
23 | isDisabled: boolean;
24 | }
25 | ```
26 |
27 | Customize the rendered template of an option inside the menu. You can use the slot props to retrieve the current menu option that will be rendered in order to have more context and flexbility.
28 |
29 | ```vue
30 |
31 |
32 |
33 | {{ option.label }} - {{ option.value }} (#{{ index }})
34 |
35 |
36 |
37 | ```
38 |
39 | ## value
40 |
41 | **Type**: `slotProps: { option: Option }`
42 |
43 | Customize the rendered template if a selected option (inside the select control). You can use the slot props to retrieve the current selected option.
44 |
45 | ```vue
46 |
47 |
48 |
49 | My value is: {{ option.value }}
50 |
51 |
52 |
53 | ```
54 |
55 | ## tag
56 |
57 | **Type**: `slotProps: { option: Option, removeOption: () => void }`
58 |
59 | When using `isMulti` prop, customize the rendered template of a selected option. You can use the slot props to retrieve the current selected option and a function to remove it.
60 |
61 | ```vue
62 |
63 |
68 |
69 | {{ option.label }} ×
70 |
71 |
72 |
73 | ```
74 |
75 | ## menu-header
76 |
77 | **Type**: `slotProps: {}`
78 |
79 | Customize the rendered template for the menu header. This slot is placed **before** the options.
80 |
81 | ```vue
82 |
83 |
84 |
85 |
86 |
My custom header
87 |
88 |
89 |
90 |
91 | ```
92 |
93 | ## menu-container
94 |
95 | **Type**: `slotProps: { defaultContent: JSX.Element }`
96 |
97 | Wrap the entire menu content with a custom container without disrupting the default behavior. This slot is particularly useful for implementing advanced scrolling techniques such as:
98 |
99 | - Virtual scrolling for large option lists
100 | - Infinite scrolling to dynamically load more options
101 | - Custom scrollbars or scroll behavior
102 | - Any other UI enhancements that need to wrap the menu options
103 |
104 | The `defaultContent` prop is a render function that returns all the default menu content (options, no-options message, etc.). You must call this function within your custom implementation to preserve the component's original content and functionality.
105 |
106 | ```vue
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | ```
118 |
119 | ::: tip
120 | This slot doesn't replace individual option customization. For that, use the `option` slot. The `menu-container` slot is specifically for wrapping the entire menu content.
121 | :::
122 |
123 | ## no-options
124 |
125 | **Type**: `slotProps: {}`
126 |
127 | Customize the rendered template when there are no options matching the search, inside the menu.
128 |
129 | ```vue
130 |
131 |
132 |
133 | No options found.
134 |
135 |
136 |
137 | ```
138 |
139 | ## dropdown
140 |
141 | **Type**: `slotProps: {}`
142 |
143 | Customize the rendered template for the dropdown icon. Please note that the slot is placed **inside the button**, so you don't have to deal with attaching event-listeners.
144 |
145 | ```vue
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 | ```
154 |
155 | ## clear
156 |
157 | **Type**: `slotProps: {}`
158 |
159 | Customize the rendered template for the clear icon. Please note that the slot is placed **inside the button**, so you don't have to deal with attaching event-listeners.
160 |
161 | ```vue
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 | ```
170 |
171 | ## loading
172 |
173 | **Type**: `slotProps: {}`
174 |
175 | Customize the rendered template when the select component is in a loading state. By default, it displays a ` ` component.
176 |
177 | ```vue
178 |
179 |
184 |
185 |
186 |
187 |
188 |
189 | ```
190 |
191 | ## taggable-no-options
192 |
193 | **Type**: `slotProps: { value: string }`
194 |
195 | Customize the rendered template when there are no matching options and the `taggable` prop is set to `true`. You can use the slot props to retrieve the current search value.
196 |
197 | ```vue
198 |
199 |
204 |
205 | Press enter to add {{ value }} option
206 |
207 |
208 |
209 | ```
210 |
--------------------------------------------------------------------------------
/docs/styling.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Styling'
3 | ---
4 |
5 | # Styling
6 |
7 | Vue 3 Select Component provides multiple types of customization.
8 |
9 | ::: tip
10 | The default component styling is already included and bundled with the ` ` component.
11 | You don't need to import any other CSS file to make it work, by default.
12 | :::
13 |
14 | ## CSS variables
15 |
16 | Using CSS variables, it is possible to customize the component style easily _but_ this method provides less flexibility over your design. When importing the component, you will notice that CSS variables are injected into the `:root` scope and are prefixed with `--vs-[...]`.
17 |
18 | For a complete list of CSS variables, we recommend to take a look at the source-code ([`/src/Select.vue`](https://github.com/TotomInc/vue3-select-component/blob/master/src/Select.vue)) or look at your DevTools _(open DevTools => `Elements` tab => pick ` ` node => view all CSS variables inside the `:root` scope)_.
19 |
20 | ### List of CSS variables
21 |
22 | ```css
23 | :root {
24 | --vs-width: 100%;
25 | --vs-min-height: 38px;
26 | --vs-padding: 4px 8px;
27 | --vs-border: 1px solid #e4e4e7;
28 | --vs-border-radius: 4px;
29 | --vs-font-size: 16px;
30 | --vs-font-weight: 400;
31 | --vs-font-family: inherit;
32 | --vs-text-color: #18181b;
33 | --vs-line-height: 1.5;
34 | --vs-placeholder-color: #52525b;
35 | --vs-background-color: #fff;
36 | --vs-disabled-background-color: #f4f4f5;
37 | --vs-outline-width: 1px;
38 | --vs-outline-color: #3b82f6;
39 |
40 | --vs-menu-offset-top: 8px;
41 | --vs-menu-height: 200px;
42 | --vs-menu-border: var(--vs-border);
43 | --vs-menu-background-color: var(--vs-background-color);
44 | --vs-menu-box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
45 | --vs-menu-z-index: 2;
46 |
47 | --vs-option-width: 100%;
48 | --vs-option-padding: 8px 12px;
49 | --vs-option-cursor: pointer;
50 | --vs-option-font-size: var(--vs-font-size);
51 | --vs-option-font-weight: var(--vs-font-weight);
52 | --vs-option-text-align: -webkit-auto;
53 | --vs-option-text-color: var(--vs-text-color);
54 | --vs-option-hover-text-color: var(--vs-text-color);
55 | --vs-option-focused-text-color: var(--vs-text-color);
56 | --vs-option-selected-text-color: var(--vs-text-color);
57 | --vs-option-disabled-text-color: #52525b;
58 | --vs-option-background-color: var(--vs-menu-background);
59 | --vs-option-hover-background-color: #dbeafe;
60 | --vs-option-focused-background-color: var(--vs-option-hover-background-color);
61 | --vs-option-selected-background-color: #93c5fd;
62 | --vs-option-disabled-background-color: #f4f4f5;
63 | --vs-option-opacity-menu-open: 0.4;
64 |
65 | --vs-multi-value-margin: 2px;
66 | --vs-multi-value-border: 0px;
67 | --vs-multi-value-border-radius: 2px;
68 | --vs-multi-value-background-color: #f4f4f5;
69 |
70 | --vs-multi-value-label-padding: 4px 4px 4px 8px;
71 | --vs-multi-value-label-font-size: 12px;
72 | --vs-multi-value-label-font-weight: 400;
73 | --vs-multi-value-label-line-height: 1;
74 | --vs-multi-value-label-text-color: #3f3f46;
75 |
76 | --vs-multi-value-delete-padding: 0 3px;
77 | --vs-multi-value-delete-hover-background-color: #FF6467;
78 | --vs-multi-value-xmark-size: 16px;
79 | --vs-multi-value-xmark-cursor: pointer;
80 | --vs-multi-value-xmark-color: var(--vs-multi-value-label-text-color);
81 | --vs-multi-value-xmark-hover-color: #fff;
82 |
83 | --vs-indicators-gap: 0px;
84 | --vs-indicator-icon-size: 20px;
85 | --vs-indicator-icon-color: var(--vs-text-color);
86 | --vs-indicator-icon-cursor: pointer;
87 | --vs-indicator-dropdown-icon-transition: transform 0.2s ease-out;
88 |
89 | --vs-spinner-color: var(--vs-text-color);
90 | --vs-spinner-size: 16px;
91 | }
92 | ```
93 |
94 | ### Editing CSS variables
95 |
96 | Inside the SFC (`.vue`) that is using the ` ` component, you can add a class to the component and edit the CSS variables to that class.
97 |
98 | ```vue
99 |
100 |
101 |
102 |
103 |
109 | ```
110 |
111 | You can also use the `:deep` selector to apply the CSS variables to the component's children if you prefer to no add a custom class:
112 |
113 | ```vue
114 |
115 |
116 |
117 |
118 |
124 | ```
125 |
126 | ## Custom classes with TailwindCSS
127 |
128 | The component provides a `classes` prop that allows you to apply custom TailwindCSS classes to different parts of the select component. This is particularly useful when you want to customize the appearance without overriding the default CSS variables.
129 |
130 | Here's an example of how to use TailwindCSS classes with the component:
131 |
132 | ```vue
133 |
134 |
154 |
155 | ```
156 |
157 | ::: warning
158 | When using TailwindCSS classes, be careful not to break the component's functionality by overriding essential styles like `display`, `position`, or `z-index` properties that are crucial for the component's layout and behavior.
159 | :::
160 |
161 | ## Scoped styling inside SFC
162 |
163 | You can apply any custom styling using [the `:deep` selector](https://vuejs.org/api/sfc-css-features.html#deep-selectors) inside a `
179 | ```
180 |
--------------------------------------------------------------------------------
/docs/typescript.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'TypeScript Guide'
3 | ---
4 |
5 | # TypeScript Integration
6 |
7 | Vue 3 Select Component is built with TypeScript to provide complete type safety and excellent IDE support through autocompletion and type checking.
8 |
9 | ## Understanding generic types
10 |
11 | This component leverages Vue 3.3's [Generics](https://vuejs.org/api/sfc-script-setup.html#generics) feature for enhanced type flexibility.
12 |
13 | Generics enable type reusability across different data types, making the component highly adaptable to various use cases.
14 |
15 | The core `Option` type demonstrates this through its generic implementation:
16 |
17 | ```ts
18 | type Option = {
19 | label: string;
20 | value: T;
21 | disabled?: boolean;
22 | };
23 | ```
24 |
25 | ## Configuring value types
26 |
27 | ::: info
28 | Please review the [`:options` prop](/props#options) documentation first.
29 | :::
30 |
31 | While the default type for `option.value` is `string`, you can specify different types like `number`. Import and extend the `Option` type with your preferred generic type:
32 |
33 | ```vue
34 |
52 |
53 |
54 |
59 |
60 | ```
61 |
62 | ## Extending option properties
63 |
64 | The `Option` type can be extended with additional properties while maintaining type safety throughout slots and props:
65 |
66 | ```vue
67 |
84 |
85 |
86 |
92 |
93 | {{ option.label }} - {{ option.username }}
94 |
95 |
96 |
97 | ```
98 |
99 | ## `v-model` type validation
100 |
101 | The component enforces type consistency between `option.value` and `v-model`:
102 |
103 | ```vue
104 |
120 |
121 |
122 |
123 |
128 |
129 | ```
130 |
131 | ## Custom value mapping
132 |
133 | ::: warning
134 | Using `getOptionValue` and `getOptionLabel` props bypasses component type-safety. Use these as a last resort.
135 | :::
136 |
137 | When using custom label/value functions, keep in mind:
138 |
139 | - Don't type local options array as `Option[]`
140 | - Cast options to `Option[]` at the component level
141 |
142 | Example implementation:
143 |
144 | ```vue
145 |
156 |
157 |
158 |
166 |
167 | ```
168 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import antfu from "@antfu/eslint-config";
2 |
3 | export default antfu({
4 | vue: true,
5 | typescript: true,
6 |
7 | stylistic: {
8 | indent: 2,
9 | jsx: false,
10 | quotes: "double",
11 | semi: true,
12 | },
13 |
14 | rules: {
15 | "curly": ["error", "multi-line"],
16 | "antfu/top-level-function": "off",
17 | "ts/consistent-type-definitions": ["error", "type"],
18 | "vue/max-attributes-per-line": ["error", { singleline: 2, multiline: 1 }],
19 | "style/arrow-parens": ["error", "always"],
20 | },
21 | });
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue3-select-component",
3 | "type": "module",
4 | "version": "0.11.7",
5 | "description": "A flexible & modern select-input control for Vue 3.",
6 | "author": "Thomas Cazade ",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/TotomInc/vue3-select-component.git"
11 | },
12 | "keywords": [
13 | "vue3",
14 | "select",
15 | "dropdown"
16 | ],
17 | "exports": {
18 | ".": {
19 | "types": "./dist/index.d.ts",
20 | "import": "./dist/index.es.js",
21 | "require": "./dist/index.umd.js"
22 | }
23 | },
24 | "main": "dist/index.umd.js",
25 | "module": "dist/index.es.js",
26 | "types": "dist/index.d.ts",
27 | "files": [
28 | "dist"
29 | ],
30 | "scripts": {
31 | "dev:playground": "vite --mode development:playground",
32 | "test": "vitest --coverage",
33 | "build": "run-p type-check \"build-only {@}\" --",
34 | "build-only": "vite build",
35 | "lint": "eslint .",
36 | "lint:fix": "eslint . --fix",
37 | "type-check": "vue-tsc --build",
38 | "bumpp": "bumpp package.json package-lock.json",
39 | "docs:dev": "vitepress dev docs",
40 | "docs:preview": "vitepress preview docs",
41 | "docs:build": "vitepress build docs"
42 | },
43 | "peerDependencies": {
44 | "vue": "^3.5.0"
45 | },
46 | "optionalDependencies": {
47 | "@rollup/rollup-linux-x64-gnu": "4.41.1"
48 | },
49 | "devDependencies": {
50 | "@antfu/eslint-config": "4.13.3",
51 | "@tsconfig/node22": "22.0.2",
52 | "@types/jsdom": "21.1.7",
53 | "@types/node": "22.15.29",
54 | "@vitejs/plugin-vue": "5.2.4",
55 | "@vitejs/plugin-vue-jsx": "4.2.0",
56 | "@vitest/coverage-v8": "3.2.2",
57 | "@vue/test-utils": "2.4.6",
58 | "@vue/tsconfig": "0.7.0",
59 | "autoprefixer": "10.4.21",
60 | "bumpp": "10.1.1",
61 | "eslint": "9.22.0",
62 | "jsdom": "26.1.0",
63 | "npm-run-all2": "8.0.4",
64 | "postcss": "8.5.4",
65 | "typescript": "5.8.2",
66 | "vite": "6.3.5",
67 | "vite-plugin-css-injected-by-js": "3.5.2",
68 | "vite-plugin-dts": "4.5.4",
69 | "vite-plugin-vue-devtools": "7.7.6",
70 | "vitepress": "1.6.3",
71 | "vitest": "3.2.2",
72 | "vue": "3.5.16",
73 | "vue-component-type-helpers": "2.2.10",
74 | "vue-router": "4.5.1",
75 | "vue-tsc": "2.2.10"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/playground/PlaygroundLayout.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
58 |
59 |
88 |
--------------------------------------------------------------------------------
/playground/demos/ControlledMenu.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 | Toggle menu ({{ isMenuOpen ? "opened" : "closed" }})
26 |
27 |
28 |
37 |
38 |
39 | Selected book value: {{ selected || "none" }}
40 |
41 |
42 |
--------------------------------------------------------------------------------
/playground/demos/CustomMenuContainer.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
23 |
24 |
29 |
30 |
31 |
32 |
33 | Selected book value: {{ selected || "none" }}
34 |
35 |
36 |
37 |
43 |
--------------------------------------------------------------------------------
/playground/demos/CustomMenuOption.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
32 |
33 | {{ option.label }} {{ option.countryFlag }} ({{ option.value }})
34 |
35 |
36 |
37 | {{ option.label }} {{ option.countryFlag }}
38 |
39 |
40 |
41 |
42 | Selected country value: {{ selected || "none" }}
43 |
44 |
45 |
--------------------------------------------------------------------------------
/playground/demos/CustomOptionLabelValue.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
24 |
25 |
26 | Selected role: {{ selected || "none" }}
27 |
28 |
29 |
--------------------------------------------------------------------------------
/playground/demos/CustomSearchFilter.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
36 |
37 | {{ option.label }} by {{ option.author }} {{ option.year }}
38 |
39 |
40 |
41 | {{ option.label }} {{ option.year }}
42 |
43 |
44 |
45 |
46 | Selected book value: {{ selected || "none" }}
47 |
48 |
49 |
--------------------------------------------------------------------------------
/playground/demos/InfiniteScroll.vue:
--------------------------------------------------------------------------------
1 |
52 |
53 |
54 |
61 |
62 |
72 |
73 |
74 |
75 |
76 | Selected book value: {{ selected || "none" }}
77 |
78 |
79 |
80 |
97 |
--------------------------------------------------------------------------------
/playground/demos/MenuHeader.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
22 |
23 | User roles
24 |
25 |
26 |
27 |
28 | Selected role value: {{ selected || "none" }}
29 |
30 |
31 |
--------------------------------------------------------------------------------
/playground/demos/MultiSelect.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
23 |
24 |
25 | Selected users: {{ selected || "none" }}
26 |
27 |
28 |
--------------------------------------------------------------------------------
/playground/demos/MultiSelectTaggable.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 | handleCreateOption(value)"
29 | />
30 |
31 |
32 | Selected programming languages: {{ selected || "none" }}
33 |
34 |
35 |
--------------------------------------------------------------------------------
/playground/demos/SelectIsLoading.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 | Toggle isLoading
20 |
21 |
22 |
29 |
30 |
31 | Selected book value: {{ selected || "none" }}
32 |
33 |
34 |
--------------------------------------------------------------------------------
/playground/demos/SingleSelect.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
23 |
24 |
25 | Selected book value: {{ selected || "none" }}
26 |
27 |
28 |
--------------------------------------------------------------------------------
/playground/demos/TaggableNoOptionsSlot.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 | handleCreateOption(value)"
29 | >
30 |
31 | {{ option }} doesn't exist, add it?
32 |
33 |
34 |
35 |
36 | Selected programming languages: {{ selected || "none" }}
37 |
38 |
39 |
--------------------------------------------------------------------------------
/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/playground/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from "vue";
2 | import { createRouter, createWebHistory } from "vue-router";
3 |
4 | import ControlledMenu from "./demos/ControlledMenu.vue";
5 | import CustomMenuContainer from "./demos/CustomMenuContainer.vue";
6 | import CustomMenuOption from "./demos/CustomMenuOption.vue";
7 | import CustomOptionLabelValue from "./demos/CustomOptionLabelValue.vue";
8 | import CustomSearchFilter from "./demos/CustomSearchFilter.vue";
9 | import InfiniteScroll from "./demos/InfiniteScroll.vue";
10 | import MenuHeader from "./demos/MenuHeader.vue";
11 | import MultiSelect from "./demos/MultiSelect.vue";
12 | import MultiSelectTaggable from "./demos/MultiSelectTaggable.vue";
13 | import SelectIsLoading from "./demos/SelectIsLoading.vue";
14 | import SingleSelect from "./demos/SingleSelect.vue";
15 | import TaggableNoOptionsSlot from "./demos/TaggableNoOptionsSlot.vue";
16 | import PlaygroundLayout from "./PlaygroundLayout.vue";
17 |
18 | const router = createRouter({
19 | history: createWebHistory(),
20 | routes: [
21 | { path: "/", redirect: "/single-select" },
22 | { path: "/single-select", component: SingleSelect },
23 | { path: "/multi-select", component: MultiSelect },
24 | { path: "/multi-select-taggable", component: MultiSelectTaggable },
25 | { path: "/custom-menu-container", component: CustomMenuContainer },
26 | { path: "/custom-menu-option", component: CustomMenuOption },
27 | { path: "/custom-option-label-value", component: CustomOptionLabelValue },
28 | { path: "/custom-search-filter", component: CustomSearchFilter },
29 | { path: "/select-is-loading", component: SelectIsLoading },
30 | { path: "/taggable-no-options-slot", component: TaggableNoOptionsSlot },
31 | { path: "/controlled-menu", component: ControlledMenu },
32 | { path: "/menu-header", component: MenuHeader },
33 | { path: "/infinite-scroll", component: InfiniteScroll },
34 | ],
35 | });
36 |
37 | createApp(PlaygroundLayout).use(router).mount("#app");
38 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended"
5 | ],
6 | "groupName": "all dependencies",
7 | "groupSlug": "all",
8 | "lockFileMaintenance": {
9 | "enabled": false
10 | },
11 | "packageRules": [
12 | {
13 | "groupName": "all dependencies",
14 | "groupSlug": "all",
15 | "matchPackagePatterns": [
16 | "*"
17 | ]
18 | }
19 | ],
20 | "ignoreDeps": ["eslint", "typescript"],
21 | "separateMajorMinor": false
22 | }
23 |
--------------------------------------------------------------------------------
/src/Indicators.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
123 |
--------------------------------------------------------------------------------
/src/Menu.module.css:
--------------------------------------------------------------------------------
1 | :global(*) {
2 | box-sizing: border-box;
3 | }
4 |
5 | .menu {
6 | position: absolute;
7 | margin-top: var(--vs-menu-offset-top);
8 | max-height: var(--vs-menu-height);
9 | overflow-y: auto;
10 | border: var(--vs-menu-border);
11 | border-radius: var(--vs-border-radius);
12 | box-shadow: var(--vs-menu-box-shadow);
13 | background-color: var(--vs-menu-background-color);
14 | z-index: var(--vs-menu-z-index);
15 | }
16 |
17 | .no-results {
18 | padding: var(--vs-option-padding);
19 | font-size: var(--vs-font-size);
20 | font-family: var(--vs-font-family);
21 | color: var(--vs-text-color);
22 | }
23 |
24 | .taggable-no-options {
25 | padding: var(--vs-option-padding);
26 | font-size: var(--vs-font-size);
27 | font-family: var(--vs-font-family);
28 | color: var(--vs-text-color);
29 | cursor: pointer;
30 | }
31 |
--------------------------------------------------------------------------------
/src/Menu.tsx:
--------------------------------------------------------------------------------
1 | import type { DataInjection, PropsInjection } from "./lib/provide-inject";
2 | import type { Option } from "./types/option";
3 | import type { MenuSlots } from "./types/slots";
4 | import { computed, defineComponent, inject, onBeforeUnmount, onMounted, useTemplateRef } from "vue";
5 | import { DATA_KEY, PROPS_KEY } from "./lib/provide-inject";
6 | import classes from "./Menu.module.css";
7 | import MenuOption from "./MenuOption.vue";
8 |
9 | export const Menu = defineComponent(
10 | , OptionValue = string>(props: {
11 | slots: MenuSlots;
12 | modelValue: OptionValue | OptionValue[];
13 | }, { emit }: { emit: {
14 | (e: "update:modelValue", value: OptionValue | OptionValue[]): void;
15 | }; }) => {
16 | // Emulate v-model without defineModel because of defineComponent.
17 | const selected = computed({
18 | get: () => props.modelValue,
19 | set: (val) => {
20 | emit("update:modelValue", val);
21 | },
22 | });
23 |
24 | const sharedProps = inject>(PROPS_KEY)!;
25 | const sharedData = inject>(DATA_KEY)!;
26 |
27 | const menuRef = useTemplateRef("menu");
28 |
29 | const calculateMenuPosition = () => {
30 | if (sharedData.containerRef.value) {
31 | const rect = sharedData.containerRef.value.getBoundingClientRect();
32 |
33 | return {
34 | left: `${rect.x}px`,
35 | top: `${rect.y + rect.height}px`,
36 | };
37 | }
38 |
39 | console.warn("Unable to calculate dynamic menu position because of missing internal DOM reference.");
40 |
41 | return { top: "0px", left: "0px" };
42 | };
43 |
44 | const handleNavigation = (e: KeyboardEvent) => {
45 | if (sharedData.menuOpen.value) {
46 | const currentIndex = sharedData.focusedOption.value;
47 |
48 | if (e.key === "ArrowDown") {
49 | e.preventDefault();
50 |
51 | const nextOptionIndex = sharedData.availableOptions.value.findIndex((option, i) => !option.disabled && i > currentIndex);
52 | const firstOptionIndex = sharedData.availableOptions.value.findIndex((option) => !option.disabled);
53 |
54 | sharedData.focusedOption.value = nextOptionIndex === -1 ? firstOptionIndex : nextOptionIndex;
55 | }
56 |
57 | if (e.key === "ArrowUp") {
58 | e.preventDefault();
59 |
60 | const prevOptionIndex = sharedData.availableOptions.value.reduce(
61 | (acc, option, i) => (!option.disabled && i < currentIndex ? i : acc),
62 | -1,
63 | );
64 |
65 | const lastOptionIndex = sharedData.availableOptions.value.reduce(
66 | (acc, option, i) => (!option.disabled ? i : acc),
67 | -1,
68 | );
69 |
70 | sharedData.focusedOption.value = prevOptionIndex === -1 ? lastOptionIndex : prevOptionIndex;
71 | }
72 |
73 | if (e.key === "Enter") {
74 | const selectedOption = sharedData.availableOptions.value[currentIndex];
75 |
76 | e.preventDefault();
77 |
78 | if (selectedOption) {
79 | sharedData.setOption(selectedOption);
80 | }
81 | else if (sharedProps.isTaggable && sharedData.search.value) {
82 | sharedData.createOption();
83 | }
84 | }
85 |
86 | // When pressing space with menu open but no search, select the focused option.
87 | if (e.code === "Space" && sharedData.search.value.length === 0) {
88 | const selectedOption = sharedData.availableOptions.value[currentIndex];
89 |
90 | e.preventDefault();
91 |
92 | if (selectedOption) {
93 | sharedData.setOption(selectedOption);
94 | }
95 | }
96 |
97 | if (e.key === "Escape") {
98 | e.preventDefault();
99 | sharedData.closeMenu();
100 | }
101 |
102 | const hasSelectedValue = sharedProps.isMulti && Array.isArray(selected.value) ? selected.value.length > 0 : !!selected.value;
103 |
104 | // When pressing backspace with no search, remove the last selected option.
105 | if (e.key === "Backspace" && sharedData.search.value.length === 0 && hasSelectedValue) {
106 | e.preventDefault();
107 |
108 | if (sharedProps.isMulti && Array.isArray(selected.value)) {
109 | selected.value = selected.value.slice(0, -1);
110 | }
111 | else {
112 | selected.value = undefined as OptionValue;
113 | }
114 | }
115 | }
116 | };
117 |
118 | const handleClickOutside = (event: MouseEvent) => {
119 | const target = event.target as Node;
120 | const isInsideContainer = sharedData.containerRef.value && sharedData.containerRef.value.contains(target);
121 | const isInsideMenu = menuRef.value && menuRef.value.contains(target);
122 |
123 | if (!isInsideContainer && !isInsideMenu) {
124 | sharedData.closeMenu();
125 | }
126 | };
127 |
128 | onMounted(() => {
129 | document.addEventListener("keydown", handleNavigation);
130 | document.addEventListener("click", handleClickOutside);
131 | });
132 |
133 | onBeforeUnmount(() => {
134 | document.removeEventListener("keydown", handleNavigation);
135 | document.removeEventListener("click", handleClickOutside);
136 | });
137 |
138 | const DefaultMenuContent = () => (
139 | <>
140 | {props.slots["menu-header"] ? props.slots["menu-header"]() : null}
141 |
142 | {sharedData.availableOptions.value.map((option, i) => (
143 |
167 | ))}
168 |
169 | {!sharedProps.isTaggable && sharedData.availableOptions.value.length === 0 && (
170 |
171 | {
172 | props.slots["no-options"]
173 | ? props.slots["no-options"]()
174 | : "No results found"
175 | }
176 |
177 | )}
178 |
179 | {sharedProps.isTaggable && sharedData.search.value && (
180 |
184 | {
185 | props.slots["taggable-no-options"]
186 | ? props.slots["taggable-no-options"]({ value: sharedData.search.value })
187 | : `Press enter to add ${sharedData.search.value} option`
188 | }
189 |
190 | )}
191 | >
192 | );
193 |
194 | return () => (
195 |
214 | );
215 | },
216 | { name: "Menu", props: ["slots", "modelValue"], emits: ["update:modelValue"] },
217 | );
218 |
--------------------------------------------------------------------------------
/src/MenuOption.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
58 |
59 |
60 |
101 |
--------------------------------------------------------------------------------
/src/MultiValue.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
23 |
24 | {{ props.label }}
25 |
26 |
27 |
34 |
35 |
36 |
37 |
38 |
39 |
87 |
--------------------------------------------------------------------------------
/src/Placeholder.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 | {{ text }}
10 |
11 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/src/Select.vue:
--------------------------------------------------------------------------------
1 |
288 |
289 |
290 |
297 |
302 |
317 |
322 |
323 |
329 |
330 |
331 |
332 |
333 |
334 | {{ getOptionLabel(selectedOptions[0]) }}
335 |
336 |
337 |
338 |
343 |
344 |
349 |
350 |
351 |
361 |
362 |
363 |
368 |
387 |
388 |
389 |
390 |
401 |
402 |
403 |
408 |
419 |
420 |
421 |
422 |
423 |
494 |
495 |
600 |
--------------------------------------------------------------------------------
/src/Spinner.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
138 |
--------------------------------------------------------------------------------
/src/icons/ChevronDownIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/icons/XMarkIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import VueSelect from "./Select.vue";
2 |
3 | export default VueSelect;
4 | export type { Option } from "./types/option";
5 |
--------------------------------------------------------------------------------
/src/lib/provide-inject.ts:
--------------------------------------------------------------------------------
1 | import type { InjectionKey, Ref, ShallowRef } from "vue";
2 | import type { Option } from "../types/option";
3 |
4 | import type { Props } from "../types/props";
5 |
6 | /**
7 | * This type allows you to inject the props with the correct generics
8 | *
9 | * @example
10 | * const props = inject>(PROPS_KEY);
11 | */
12 | export type PropsInjection, OptionValue = string> =
13 | Props;
14 |
15 | /**
16 | * This type allows you to inject the data with the correct generics
17 | *
18 | * @example
19 | * const data = inject>(DATA_KEY);
20 | */
21 | export type DataInjection, OptionValue = string> = {
22 | vmodel: Ref;
23 | availableOptions: Ref;
24 | selectedOptions: Ref;
25 | menuOpen: Ref;
26 | focusedOption: Ref;
27 | containerRef: Readonly>;
28 | search: Ref;
29 | openMenu: () => void;
30 | closeMenu: () => void;
31 | toggleMenu: () => void;
32 | handleControlClick: (event: MouseEvent) => void;
33 | setOption: (option: GenericOption) => void;
34 | removeOption: (option: GenericOption) => void;
35 | createOption: () => void;
36 | };
37 |
38 | export const PROPS_KEY = Symbol("props") as InjectionKey>;
39 | export const DATA_KEY = Symbol("data") as InjectionKey>;
40 |
--------------------------------------------------------------------------------
/src/lib/uid.ts:
--------------------------------------------------------------------------------
1 | let idCount = 0;
2 |
3 | export function uniqueId() {
4 | return ++idCount;
5 | }
6 |
--------------------------------------------------------------------------------
/src/types/option.ts:
--------------------------------------------------------------------------------
1 | export type Option = {
2 | label: string;
3 | value: T;
4 | disabled?: boolean;
5 | [key: string]: unknown;
6 | };
7 |
--------------------------------------------------------------------------------
/src/types/props.ts:
--------------------------------------------------------------------------------
1 | export type SelectClasses = {
2 | container?: string;
3 | control?: string;
4 | valueContainer?: string;
5 | placeholder?: string;
6 | singleValue?: string;
7 | multiValue?: string;
8 | multiValueLabel?: string;
9 | multiValueRemove?: string;
10 | inputContainer?: string;
11 | searchInput?: string;
12 | menuContainer?: string;
13 | menuOption?: string;
14 | noResults?: string;
15 | taggableNoOptions?: string;
16 | };
17 |
18 | export type Props = {
19 | /**
20 | * A list of options to render on the select component.
21 | */
22 | options: GenericOption[];
23 |
24 | /**
25 | * When passed to the component, only these specific options will be rendered
26 | * on the list of options.
27 | */
28 | displayedOptions?: GenericOption[];
29 |
30 | /**
31 | * The placeholder text to display when no option is selected.
32 | */
33 | placeholder?: string;
34 |
35 | /**
36 | * When set to true, the input can be cleared by clicking the clear button.
37 | */
38 | isClearable?: boolean;
39 |
40 | /**
41 | * When set to true, disable the select component.
42 | */
43 | isDisabled?: boolean;
44 |
45 | /**
46 | * When set to true, allow the user to filter the options by typing in the search input.
47 | */
48 | isSearchable?: boolean;
49 |
50 | /**
51 | * When set to true, allow the user to select multiple options. This will change the
52 | * `selected` model to an array of strings. You should pass an array of strings to the
53 | * `v-model` directive when using this prop.
54 | */
55 | isMulti?: boolean;
56 |
57 | /**
58 | * When set to true, allow the user to create a new option if it doesn't exist.
59 | */
60 | isTaggable?: boolean;
61 |
62 | /**
63 | * When set to true, show a loading spinner inside the select component. This is useful
64 | * when fetching the options asynchronously.
65 | */
66 | isLoading?: boolean;
67 |
68 | /**
69 | * Control the menu open state programmatically.
70 | */
71 | isMenuOpen?: boolean;
72 |
73 | /**
74 | * When set to true with `isMulti`, selected options won't be displayed in
75 | * the menu.
76 | */
77 | hideSelectedOptions?: boolean;
78 |
79 | /**
80 | * When set to true, focus the first option when the menu is opened.
81 | * When set to false, no option will be focused.
82 | */
83 | shouldAutofocusOption?: boolean;
84 |
85 | /**
86 | * When set to true, clear the search input when an option is selected.
87 | */
88 | closeOnSelect?: boolean;
89 |
90 | /**
91 | * Teleport the menu to another part of the DOM with higher priority such as `body`.
92 | * This way, you can avoid z-index issues. Menu position will be calculated using
93 | * JavaScript, instead of using CSS absolute & relative positioning.
94 | */
95 | teleport?: string;
96 |
97 | /**
98 | * The ID of the input element. This is useful for accessibility or forms.
99 | */
100 | inputId?: string;
101 |
102 | /**
103 | * CSS classes to apply to the select component.
104 | */
105 | classes?: SelectClasses;
106 |
107 | /**
108 | * Unique identifier to identify the select component, using `id` attribute.
109 | * This is useful for accessibility.
110 | */
111 | uid?: string | number;
112 |
113 | /**
114 | * ARIA attributes to describe the select component. This is useful for accessibility.
115 | */
116 | aria?: {
117 | labelledby?: string;
118 | required?: boolean;
119 | };
120 |
121 | /**
122 | * When set to true, the component will not emit a `console.warn` because of an invalid
123 | * `v-model` type when using `isMulti`. This is useful when using the component with
124 | * dynamic `v-model` references.
125 | */
126 | disableInvalidVModelWarn?: boolean;
127 |
128 | /**
129 | * Callback to filter the options based on the search input. By default, it filters
130 | * the options based on the `label` property of the option. The label is retrieved
131 | * using `getOptionLabel`.
132 | *
133 | * @param option The option to filter.
134 | * @param label The label of the option.
135 | * @param search The search input value.
136 | */
137 | filterBy?: (option: GenericOption, label: string, search: string) => boolean;
138 |
139 | /**
140 | * Resolves option data to a string to compare options and specify value attributes.
141 | *
142 | * @param option The option to render.
143 | */
144 | getOptionValue?: (option: GenericOption) => OptionValue;
145 |
146 | /**
147 | * Resolves option data to a string to render the option label.
148 | *
149 | * @param option The option to render.
150 | */
151 | getOptionLabel?: (option: GenericOption) => string;
152 | };
153 |
--------------------------------------------------------------------------------
/src/types/slots.ts:
--------------------------------------------------------------------------------
1 | import type { JSX } from "vue/jsx-runtime";
2 | import type { Option } from "./option";
3 |
4 | /**
5 | * Define all existing slots across all internal components.
6 | *
7 | * @see https://vuejs.org/api/sfc-script-setup#defineslots
8 | */
9 | export type Slots, OptionValue> = {
10 | "value"?: (props: { option: GenericOption }) => any;
11 | "tag"?: (props: { option: GenericOption; removeOption: () => void }) => any;
12 | "clear"?: () => any;
13 | "dropdown"?: () => any;
14 | "loading"?: () => any;
15 | "menu-header"?: () => any;
16 | "menu-container"?: (props: { defaultContent: JSX.Element }) => any;
17 | "option"?: (props: { option: GenericOption; index: number; isFocused: boolean; isSelected: boolean; isDisabled: boolean }) => any;
18 | "no-options"?: () => any;
19 | "taggable-no-options"?: (props: { value: string }) => any;
20 | };
21 |
22 | export type IndicatorsSlots, OptionValue> = Pick<
23 | Slots,
24 | "clear" | "dropdown" | "loading"
25 | >;
26 |
27 | export type MenuSlots, OptionValue> = Pick<
28 | Slots,
29 | "menu-header" | "menu-container" | "option" | "no-options" | "taggable-no-options"
30 | >;
31 |
--------------------------------------------------------------------------------
/tests/Indicators.spec.ts:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from "vue-component-type-helpers";
2 | import { mount } from "@vue/test-utils";
3 | import { describe, expect, it } from "vitest";
4 | import ChevronDownIcon from "../src/icons/ChevronDownIcon.vue";
5 | import XMarkIcon from "../src/icons/XMarkIcon.vue";
6 | import Select from "../src/Select.vue";
7 | import Spinner from "../src/Spinner.vue";
8 | import { dispatchEvent } from "./utils";
9 |
10 | const options = [
11 | { label: "France", value: "FR" },
12 | { label: "United Kingdom", value: "GB" },
13 | { label: "United States", value: "US" },
14 | { label: "Germany", value: "DE" },
15 | ];
16 |
17 | const defaultProps: ComponentProps = {
18 | options,
19 | modelValue: null,
20 | isClearable: true,
21 | isLoading: false,
22 | isDisabled: false,
23 | };
24 |
25 | describe("component setup and initialization", () => {
26 | it("should render with default imports", () => {
27 | const wrapper = mount(Select, { props: defaultProps });
28 |
29 | expect(wrapper.findComponent(ChevronDownIcon).exists()).toBe(true);
30 | expect(wrapper.findComponent(XMarkIcon).exists()).toBe(false);
31 | expect(wrapper.findComponent(Spinner).exists()).toBe(false);
32 | });
33 | });
34 |
35 | describe("dropdown button rendering", () => {
36 | it("should render dropdown button when not loading", () => {
37 | const wrapper = mount(Select, { props: { ...defaultProps, isLoading: false } });
38 |
39 | expect(wrapper.find(".dropdown-icon").exists()).toBe(true);
40 | });
41 |
42 | it("should not render dropdown button when loading", () => {
43 | const wrapper = mount(Select, { props: { ...defaultProps, isLoading: true } });
44 |
45 | expect(wrapper.find(".dropdown-icon").exists()).toBe(false);
46 | });
47 |
48 | it("should add active class to dropdown button when menu is open", async () => {
49 | const wrapper = mount(Select, { props: { ...defaultProps } });
50 |
51 | await wrapper.get("input").trigger("mousedown");
52 | expect(wrapper.find(".dropdown-icon").classes()).toContain("active");
53 | });
54 |
55 | it("should disable dropdown button when component is disabled", () => {
56 | const wrapper = mount(Select, { props: { ...defaultProps, isDisabled: true } });
57 |
58 | expect(wrapper.find(".dropdown-icon").attributes("disabled")).toBe("");
59 | });
60 |
61 | it("should emit toggle event when dropdown button is clicked", async () => {
62 | const wrapper = mount(Select, { props: defaultProps });
63 | const indicators = wrapper.getComponent({ name: "Indicators" });
64 |
65 | await wrapper.find(".dropdown-icon").trigger("click");
66 | expect(indicators.emitted("toggle")).toStrictEqual([[]]);
67 | });
68 | });
69 |
70 | describe("loading state handling", () => {
71 | it("should render spinner when loading", () => {
72 | const wrapper = mount(Select, { props: { ...defaultProps, isLoading: true } });
73 |
74 | expect(wrapper.findComponent(Spinner).exists()).toBe(true);
75 | });
76 |
77 | it("should not render spinner when not loading", () => {
78 | const wrapper = mount(Select, { props: { ...defaultProps, isLoading: false } });
79 |
80 | expect(wrapper.findComponent(Spinner).exists()).toBe(false);
81 | });
82 |
83 | it("should use custom loading slot content when provided", () => {
84 | const wrapper = mount(Select, {
85 | props: { ...defaultProps, isLoading: true },
86 | slots: { loading: "Loading...
" },
87 | });
88 |
89 | expect(wrapper.find(".custom-loader").exists()).toBe(true);
90 | expect(wrapper.findComponent(Spinner).exists()).toBe(false);
91 | });
92 | });
93 |
94 | describe("clear button behavior", () => {
95 | it("should render clear button when there is a selected option and isClearable is true", async () => {
96 | const wrapper = mount(Select, { props: { ...defaultProps } });
97 |
98 | await wrapper.get("input").trigger("mousedown");
99 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
100 |
101 | expect(wrapper.find(".clear-button").exists()).toBe(true);
102 | });
103 |
104 | it("should not render clear button when there is no selected option", () => {
105 | const wrapper = mount(Select, {
106 | props: { ...defaultProps, isClearable: true },
107 | });
108 |
109 | expect(wrapper.find(".clear-button").exists()).toBe(false);
110 | });
111 |
112 | it("should not render clear button when isClearable is false", async () => {
113 | const wrapper = mount(Select, {
114 | props: { ...defaultProps, isClearable: false },
115 | });
116 |
117 | await wrapper.get("input").trigger("mousedown");
118 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
119 |
120 | expect(wrapper.find(".clear-button").exists()).toBe(false);
121 | });
122 |
123 | it("should not render clear button when loading", async () => {
124 | const wrapper = mount(Select, {
125 | props: { ...defaultProps, isClearable: true, isLoading: true },
126 | });
127 |
128 | await wrapper.get("input").trigger("mousedown");
129 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
130 |
131 | expect(wrapper.find(".clear-button").exists()).toBe(false);
132 | });
133 |
134 | it("should emit clear event when clear button is clicked", async () => {
135 | const wrapper = mount(Select, { props: defaultProps });
136 | const indicators = wrapper.getComponent({ name: "Indicators" });
137 |
138 | await wrapper.get("input").trigger("mousedown");
139 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
140 | await wrapper.find(".clear-button").trigger("click");
141 |
142 | expect(indicators.emitted("clear")).toBeTruthy();
143 | expect(indicators.emitted("clear")!.length).toBe(1);
144 | });
145 | });
146 |
147 | describe("custom slots", () => {
148 | it("should use custom clear button slot content when provided", async () => {
149 | const wrapper = mount(Select, {
150 | props: { ...defaultProps },
151 | slots: {
152 | clear: "✕ ",
153 | },
154 | });
155 |
156 | await wrapper.get("input").trigger("mousedown");
157 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
158 |
159 | expect(wrapper.find(".custom-clear").exists()).toBe(true);
160 | expect(wrapper.findComponent(XMarkIcon).exists()).toBe(false);
161 | });
162 |
163 | it("should use custom dropdown slot content when provided", async () => {
164 | const wrapper = mount(Select, {
165 | props: defaultProps,
166 | slots: {
167 | dropdown: "▼ ",
168 | },
169 | });
170 |
171 | await wrapper.get("input").trigger("mousedown");
172 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
173 |
174 | expect(wrapper.find(".custom-dropdown").exists()).toBe(true);
175 | expect(wrapper.findComponent(ChevronDownIcon).exists()).toBe(false);
176 | });
177 | });
178 |
--------------------------------------------------------------------------------
/tests/Menu.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount } from "@vue/test-utils";
2 | import { describe, expect, it } from "vitest";
3 | import { h } from "vue";
4 | import VueSelect from "../src/Select.vue";
5 | import { dispatchEvent, inputSearch, openMenu } from "./utils";
6 |
7 | const options = [
8 | { label: "France", value: "FR" },
9 | { label: "United Kingdom", value: "GB" },
10 | { label: "United States", value: "US" },
11 | { label: "Germany", value: "DE" },
12 | ];
13 |
14 | describe("menu keyboard navigation", () => {
15 | it("should navigate through the options with the arrow keys", async () => {
16 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
17 |
18 | await openMenu(wrapper);
19 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" }));
20 |
21 | expect(wrapper.get(".focused[role='option']").text()).toBe(options[1].label);
22 |
23 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowUp" }));
24 |
25 | expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
26 | });
27 |
28 | it("should navigate through the options with the arrow keys and skip disabled options", async () => {
29 | const options = [
30 | { label: "France", value: "FR" },
31 | { label: "Spain", value: "ES", disabled: true },
32 | { label: "United Kingdom", value: "GB" },
33 | ];
34 |
35 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
36 |
37 | await openMenu(wrapper);
38 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowDown" }));
39 |
40 | expect(wrapper.get(".focused[role='option']").text()).toBe(options[2].label);
41 |
42 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "ArrowUp" }));
43 |
44 | expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
45 | });
46 |
47 | it("should handle space key to select focused option when no search", async () => {
48 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
49 |
50 | await openMenu(wrapper);
51 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { code: "Space" }));
52 |
53 | expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([options[0].value]);
54 | });
55 |
56 | it("should handle backspace key to remove last selected option in multi-select", async () => {
57 | const wrapper = mount(VueSelect, {
58 | props: { modelValue: ["FR", "GB"], isMulti: true, options },
59 | });
60 |
61 | await openMenu(wrapper);
62 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Backspace" }));
63 |
64 | expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([["FR"]]);
65 | });
66 |
67 | it("should handle backspace key to clear value in single-select", async () => {
68 | const wrapper = mount(VueSelect, {
69 | props: { modelValue: "FR", options },
70 | });
71 |
72 | await openMenu(wrapper);
73 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Backspace" }));
74 |
75 | expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([undefined]);
76 | });
77 |
78 | it("should not handle backspace when there's search input", async () => {
79 | const wrapper = mount(VueSelect, {
80 | props: { modelValue: ["FR"], isMulti: true, options },
81 | });
82 |
83 | await openMenu(wrapper);
84 | await inputSearch(wrapper, "test");
85 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Backspace" }));
86 |
87 | // Should not emit update:modelValue since there's search input
88 | expect(wrapper.emitted("update:modelValue")).toBeFalsy();
89 | });
90 |
91 | it("should handle enter key with taggable and search input", async () => {
92 | const wrapper = mount(VueSelect, {
93 | props: { modelValue: null, options: [], isTaggable: true },
94 | });
95 |
96 | await openMenu(wrapper);
97 | await inputSearch(wrapper, "new-option");
98 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
99 |
100 | expect(wrapper.emitted("optionCreated")?.[0]).toEqual(["new-option"]);
101 | });
102 |
103 | it("should handle enter key when no focused option and no search (taggable)", async () => {
104 | const wrapper = mount(VueSelect, {
105 | props: { modelValue: null, options: [], isTaggable: true },
106 | });
107 |
108 | await openMenu(wrapper);
109 | // No search input, no focused option
110 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
111 |
112 | // Should not emit anything
113 | expect(wrapper.emitted("optionCreated")).toBeFalsy();
114 | expect(wrapper.emitted("update:modelValue")).toBeFalsy();
115 | });
116 | });
117 |
118 | describe("menu opening behavior", () => {
119 | it("should open menu with different triggers", async () => {
120 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
121 |
122 | const triggers = [
123 | { name: "mousedown on input", action: async () => await openMenu(wrapper, "mousedown") },
124 | { name: "space after focus", action: async () => await openMenu(wrapper, "focus-space") },
125 | { name: "dropdown button click", action: async () => await wrapper.get(".dropdown-icon").trigger("click") },
126 | ];
127 |
128 | for (const trigger of triggers) {
129 | await trigger.action();
130 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
131 | await wrapper.get(".dropdown-icon").trigger("click");
132 | }
133 | });
134 | });
135 |
136 | describe("menu closing behavior", () => {
137 | it("should close menu with different triggers", async () => {
138 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
139 |
140 | const closeTriggers = [
141 | { name: "tab key", action: async () => await wrapper.get("input").trigger("keydown", { key: "Tab" }) },
142 | { name: "escape key", action: async () => await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Escape" })) },
143 | { name: "dropdown button", action: async () => await wrapper.get(".dropdown-icon").trigger("click") },
144 | ];
145 |
146 | for (const trigger of closeTriggers) {
147 | await openMenu(wrapper);
148 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
149 | await trigger.action();
150 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
151 | }
152 | });
153 |
154 | it("should close menu when clicking outside", async () => {
155 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
156 |
157 | await openMenu(wrapper);
158 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
159 |
160 | // Simulate click outside
161 | const outsideElement = document.createElement("div");
162 | document.body.appendChild(outsideElement);
163 | await dispatchEvent(wrapper, new MouseEvent("click", { bubbles: true }));
164 |
165 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
166 | document.body.removeChild(outsideElement);
167 | });
168 |
169 | it("should not close menu when clicking inside menu", async () => {
170 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
171 |
172 | await openMenu(wrapper);
173 | const menuElement = wrapper.get("[role='listbox']").element;
174 |
175 | // Simulate click inside menu
176 | const clickEvent = new MouseEvent("click", { bubbles: true });
177 | Object.defineProperty(clickEvent, "target", { value: menuElement });
178 | await dispatchEvent(wrapper, clickEvent);
179 |
180 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
181 | });
182 | });
183 |
184 | describe("menu filtering", () => {
185 | it("should filter the options when typing in the input", async () => {
186 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
187 |
188 | await openMenu(wrapper);
189 | await inputSearch(wrapper, "United");
190 |
191 | expect(wrapper.findAll("div[role='option']").length).toBe(2);
192 |
193 | await inputSearch(wrapper, "United States");
194 |
195 | expect(wrapper.findAll("div[role='option']").length).toBe(1);
196 | });
197 |
198 | it("should show 'no results found' when no options match search", async () => {
199 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
200 |
201 | await openMenu(wrapper);
202 | await inputSearch(wrapper, "xyz-nonexistent");
203 |
204 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
205 | expect(wrapper.text()).toContain("No results found");
206 | });
207 |
208 | it("should use custom no-options slot", async () => {
209 | const wrapper = mount(VueSelect, {
210 | props: { modelValue: null, options },
211 | slots: {
212 | "no-options": () => h("div", { class: "custom-no-options" }, "Custom no results message"),
213 | },
214 | });
215 |
216 | await openMenu(wrapper);
217 | await inputSearch(wrapper, "xyz-nonexistent");
218 |
219 | expect(wrapper.find(".custom-no-options").exists()).toBe(true);
220 | expect(wrapper.get(".custom-no-options").text()).toBe("Custom no results message");
221 | });
222 | });
223 |
224 | describe("taggable functionality", () => {
225 | it("should show taggable option when searching with no matches", async () => {
226 | const wrapper = mount(VueSelect, {
227 | props: { modelValue: null, options, isTaggable: true },
228 | });
229 |
230 | await openMenu(wrapper);
231 | await inputSearch(wrapper, "new-tag");
232 |
233 | expect(wrapper.text()).toContain("Press enter to add new-tag option");
234 | });
235 |
236 | it("should use custom taggable-no-options slot", async () => {
237 | const wrapper = mount(VueSelect, {
238 | props: { modelValue: null, options, isTaggable: true },
239 | slots: {
240 | "taggable-no-options": ({ value }) => h("div", { class: "custom-taggable" }, `Add "${value}" as new option`),
241 | },
242 | });
243 |
244 | await openMenu(wrapper);
245 | await inputSearch(wrapper, "new-tag");
246 |
247 | expect(wrapper.find(".custom-taggable").exists()).toBe(true);
248 | expect(wrapper.get(".custom-taggable").text()).toBe("Add \"new-tag\" as new option");
249 | });
250 |
251 | it("should not show no-results message when isTaggable is true", async () => {
252 | const wrapper = mount(VueSelect, {
253 | props: { modelValue: null, options, isTaggable: true },
254 | });
255 |
256 | await openMenu(wrapper);
257 | await inputSearch(wrapper, "xyz-nonexistent");
258 |
259 | expect(wrapper.text()).not.toContain("No results found");
260 | expect(wrapper.text()).toContain("Press enter to add xyz-nonexistent option");
261 | });
262 | });
263 |
264 | describe("hideSelectedOptions prop", () => {
265 | it("should hide selected options from menu when hideSelectedOptions is true", async () => {
266 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: true } });
267 |
268 | await openMenu(wrapper);
269 | await wrapper.get("div[role='option']").trigger("click");
270 | await openMenu(wrapper);
271 |
272 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length - 1);
273 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).not.toContain(options[0].label);
274 | });
275 |
276 | it("should show selected options in menu when hideSelectedOptions is false", async () => {
277 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: false } });
278 |
279 | await openMenu(wrapper);
280 | await wrapper.get("div[role='option']").trigger("click");
281 | await openMenu(wrapper);
282 |
283 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
284 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain(options[0].label);
285 | });
286 |
287 | it("should show all options when in single-select mode regardless of hideSelectedOptions", async () => {
288 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, hideSelectedOptions: true } });
289 |
290 | await openMenu(wrapper);
291 | await wrapper.get("div[role='option']").trigger("click");
292 | await openMenu(wrapper);
293 |
294 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
295 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain(options[0].label);
296 | });
297 |
298 | it("should correctly restore hidden options when they are deselected", async () => {
299 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: true } });
300 |
301 | // Select first option
302 | await openMenu(wrapper);
303 | await wrapper.get("div[role='option']").trigger("click");
304 | await openMenu(wrapper);
305 |
306 | // Verify it's hidden from dropdown
307 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length - 1);
308 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).not.toContain(options[0].label);
309 |
310 | // Remove the option
311 | await wrapper.get(".multi-value-remove").trigger("click");
312 | await openMenu(wrapper);
313 |
314 | // Verify it's back in the dropdown
315 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
316 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain(options[0].label);
317 | });
318 |
319 | it("should correctly filter options when searching with hideSelectedOptions enabled", async () => {
320 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options, hideSelectedOptions: true } });
321 |
322 | // Select first option (France)
323 | await openMenu(wrapper);
324 | await wrapper.get("div[role='option']").trigger("click");
325 |
326 | // Open menu and search for "United"
327 | await openMenu(wrapper);
328 | await inputSearch(wrapper, "United");
329 |
330 | // Should only show United Kingdom and United States (not France)
331 | expect(wrapper.findAll("div[role='option']").length).toBe(2);
332 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain("United Kingdom");
333 | expect(wrapper.findAll("div[role='option']").map((option) => option.text())).toContain("United States");
334 | });
335 | });
336 |
337 | describe("menu autofocus behavior", () => {
338 | it("should autofocus first option when opening menu", async () => {
339 | const testCases = [
340 | { name: "single-select", props: { modelValue: null, options } },
341 | { name: "multi-select", props: { modelValue: [], isMulti: true, options } },
342 | ];
343 |
344 | for (const testCase of testCases) {
345 | // @ts-expect-error -- ignore type error
346 | const wrapper = mount(VueSelect, { props: testCase.props });
347 | await openMenu(wrapper);
348 | expect(wrapper.get(".focused[role='option']").text()).toBe(options[0].label);
349 | }
350 | });
351 |
352 | it("should focus first available option when first option is disabled", async () => {
353 | const options = [
354 | { label: "Spain", value: "ES", disabled: true },
355 | { label: "France", value: "FR" },
356 | ];
357 |
358 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
359 | await openMenu(wrapper);
360 | expect(wrapper.get(".focused[role='option']").text()).toBe("France");
361 | });
362 | });
363 |
364 | describe("menu-header slot", () => {
365 | it("should render menu-header slot content", async () => {
366 | const wrapper = mount(VueSelect, {
367 | props: { modelValue: null, options },
368 | slots: {
369 | "menu-header": () => h("div", { class: "custom-header" }, "Custom Header"),
370 | },
371 | });
372 |
373 | await openMenu(wrapper);
374 |
375 | expect(wrapper.find(".custom-header").exists()).toBe(true);
376 | expect(wrapper.get(".custom-header").text()).toBe("Custom Header");
377 | });
378 | });
379 |
380 | describe("menu-container slot", () => {
381 | it("should render custom container while preserving default content", async () => {
382 | const wrapper = mount(VueSelect, {
383 | props: { modelValue: null, options },
384 | slots: {
385 | "menu-container": ({ defaultContent }) => (
386 | h("div", { class: "custom-container" }, [defaultContent])
387 | ),
388 | },
389 | });
390 |
391 | await openMenu(wrapper);
392 |
393 | // Verify custom container is rendered
394 | expect(wrapper.find(".custom-container").exists()).toBe(true);
395 |
396 | // Verify default content is preserved inside custom container
397 | const customContainer = wrapper.get(".custom-container");
398 | expect(customContainer.findAll("div[role='option']").length).toBe(options.length);
399 | });
400 |
401 | it("should maintain menu functionality when using custom container", async () => {
402 | const wrapper = mount(VueSelect, {
403 | props: { modelValue: null, options },
404 | slots: {
405 | "menu-container": ({ defaultContent }) => (
406 | h("div", { class: "custom-container" }, [defaultContent])
407 | ),
408 | },
409 | });
410 |
411 | await openMenu(wrapper);
412 |
413 | // Test option selection still works
414 | await wrapper.get("div[role='option']").trigger("click");
415 | expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([options[0].value]);
416 |
417 | // Test search filtering still works
418 | await openMenu(wrapper);
419 | await inputSearch(wrapper, "United");
420 |
421 | const customContainer = wrapper.get(".custom-container");
422 | expect(customContainer.findAll("div[role='option']").length).toBe(2);
423 | expect(customContainer.findAll("div[role='option']").map((option) => option.text()))
424 | .toEqual(expect.arrayContaining(["United Kingdom", "United States"]));
425 | });
426 | });
427 |
428 | describe("option slot", () => {
429 | it("should render custom option content", async () => {
430 | const wrapper = mount(VueSelect, {
431 | props: { modelValue: null, options },
432 | slots: {
433 | option: ({ option, index, isFocused, isSelected, isDisabled }) =>
434 | h("div", {
435 | "class": "custom-option",
436 | "data-index": index,
437 | "data-focused": isFocused,
438 | "data-selected": isSelected,
439 | "data-disabled": isDisabled,
440 | }, `Custom: ${option.label}`),
441 | },
442 | });
443 |
444 | await openMenu(wrapper);
445 |
446 | const customOptions = wrapper.findAll(".custom-option");
447 | expect(customOptions.length).toBe(options.length);
448 | expect(customOptions[0].text()).toBe("Custom: France");
449 | expect(customOptions[0].attributes("data-index")).toBe("0");
450 | expect(customOptions[0].attributes("data-focused")).toBe("true");
451 | expect(customOptions[0].attributes("data-selected")).toBe("false");
452 | expect(customOptions[0].attributes("data-disabled")).toBe("false");
453 | });
454 |
455 | it("should fall back to getOptionLabel when no option slot is provided", async () => {
456 | const customOptions = [
457 | { label: "Custom France", value: "FR", customProp: "test" },
458 | { label: "Custom UK", value: "GB", customProp: "test2" },
459 | ];
460 |
461 | const wrapper = mount(VueSelect, {
462 | props: {
463 | modelValue: null,
464 | options: customOptions,
465 | getOptionLabel: (option) => `Label: ${option.customProp}`,
466 | },
467 | });
468 |
469 | await openMenu(wrapper);
470 |
471 | const optionElements = wrapper.findAll("div[role='option']");
472 | expect(optionElements[0].text()).toBe("Label: test");
473 | expect(optionElements[1].text()).toBe("Label: test2");
474 | });
475 |
476 | it("should fall back to option.label when no option slot or getOptionLabel is provided", async () => {
477 | const wrapper = mount(VueSelect, {
478 | props: { modelValue: null, options },
479 | });
480 |
481 | await openMenu(wrapper);
482 |
483 | const optionElements = wrapper.findAll("div[role='option']");
484 | expect(optionElements[0].text()).toBe("France");
485 | expect(optionElements[1].text()).toBe("United Kingdom");
486 | });
487 | });
488 |
--------------------------------------------------------------------------------
/tests/MenuOption.spec.ts:
--------------------------------------------------------------------------------
1 | import { mount } from "@vue/test-utils";
2 | import { describe, expect, it } from "vitest";
3 | import MenuOption from "../src/MenuOption.vue";
4 |
5 | describe("scrolling behavior when option is above viewport", () => {
6 | it("should scroll the menu to show the focused option when it's above the viewport", async () => {
7 | const mockMenu = {
8 | scrollTop: 100, // Simulate menu scrolled down
9 | clientHeight: 200,
10 | children: [
11 | { offsetTop: 50, clientHeight: 40 }, // Option is above visible area (offsetTop < scrollTop)
12 | ] as HTMLDivElement[],
13 | } as unknown as HTMLDivElement;
14 |
15 | const wrapper = mount(MenuOption, {
16 | props: {
17 | menu: mockMenu,
18 | index: 0,
19 | isFocused: false,
20 | isSelected: false,
21 | isDisabled: false,
22 | },
23 | });
24 |
25 | // Initial state - menu scroll position hasn't changed
26 | expect(mockMenu.scrollTop).toBe(100);
27 |
28 | // Update props to set focus to true
29 | await wrapper.setProps({ isFocused: true });
30 |
31 | // Verify menu was scrolled to show the option
32 | expect(mockMenu.scrollTop).toBe(50); // Should match the offsetTop of the option
33 | });
34 | });
35 |
36 | describe("scrolling behavior when option is below viewport", () => {
37 | it("should scroll the menu to show the focused option when it's below the viewport", async () => {
38 | const mockMenu = {
39 | scrollTop: 50,
40 | clientHeight: 200, // Viewport height
41 | children: [
42 | { offsetTop: 300, clientHeight: 40 }, // Option is below visible area (offsetTop + clientHeight > scrollTop + clientHeight)
43 | ] as HTMLDivElement[],
44 | } as unknown as HTMLDivElement;
45 |
46 | // Mount the component with the menu and set up props
47 | const wrapper = mount(MenuOption, {
48 | props: {
49 | menu: mockMenu,
50 | index: 0,
51 | isFocused: false,
52 | isSelected: false,
53 | isDisabled: false,
54 | },
55 | });
56 |
57 | // Initial state - menu scroll position hasn't changed
58 | expect(mockMenu.scrollTop).toBe(50);
59 |
60 | // Update props to set focus to true
61 | await wrapper.setProps({ isFocused: true });
62 |
63 | // Verify menu was scrolled to show the option
64 | // The formula is: scrollTop = optionBottom - menuHeight
65 | const expectedScrollTop = (300 + 40) - 200;
66 | expect(mockMenu.scrollTop).toBe(expectedScrollTop);
67 | });
68 |
69 | it("should not scroll the menu when the focused option is already visible", async () => {
70 | const mockMenu = {
71 | scrollTop: 100,
72 | clientHeight: 200,
73 | children: [
74 | { offsetTop: 150, clientHeight: 40 }, // Option is within visible area
75 | ] as HTMLDivElement[],
76 | } as unknown as HTMLDivElement;
77 |
78 | // Mount the component with the menu and set up props
79 | const wrapper = mount(MenuOption, {
80 | props: {
81 | menu: mockMenu,
82 | index: 0,
83 | isFocused: false,
84 | isSelected: false,
85 | isDisabled: false,
86 | },
87 | });
88 |
89 | // Initial state
90 | const initialScrollTop = mockMenu.scrollTop;
91 |
92 | // Update props to set focus to true
93 | await wrapper.setProps({ isFocused: true });
94 |
95 | // Verify menu scroll position didn't change
96 | expect(mockMenu.scrollTop).toBe(initialScrollTop);
97 | });
98 | });
99 |
100 | describe("keyboard event handling", () => {
101 | it("should emit 'select' event when Enter key is pressed", async () => {
102 | const wrapper = mount(MenuOption, {
103 | props: {
104 | menu: null,
105 | index: 0,
106 | isFocused: true,
107 | isSelected: false,
108 | isDisabled: false,
109 | },
110 | });
111 |
112 | // Simulate a keydown event with Enter key
113 | await wrapper.trigger("keydown.enter");
114 |
115 | // Verify the 'select' event was emitted
116 | expect(wrapper.emitted("select")).toBeTruthy();
117 | expect(wrapper.emitted("select")).toHaveLength(1);
118 | });
119 |
120 | it("should not emit 'select' event when disabled", async () => {
121 | const wrapper = mount(MenuOption, {
122 | props: {
123 | menu: null,
124 | index: 0,
125 | isFocused: true,
126 | isSelected: false,
127 | isDisabled: true,
128 | },
129 | });
130 |
131 | // Try to select the option via click
132 | await wrapper.trigger("click");
133 |
134 | // Verify the 'select' event was not emitted
135 | expect(wrapper.emitted("select")).toStrictEqual([[]]);
136 |
137 | // Try to select with keyboard
138 | await wrapper.trigger("keydown.enter");
139 |
140 | // Still should not emit
141 | expect(wrapper.emitted("select")).toStrictEqual([[], []]);
142 | });
143 |
144 | it("should emit 'select' event when clicked", async () => {
145 | const wrapper = mount(MenuOption, {
146 | props: {
147 | menu: null,
148 | index: 0,
149 | isFocused: false,
150 | isSelected: false,
151 | isDisabled: false,
152 | },
153 | });
154 |
155 | await wrapper.trigger("click");
156 |
157 | // Verify the 'select' event was emitted
158 | expect(wrapper.emitted("select")).toBeTruthy();
159 | expect(wrapper.emitted("select")).toHaveLength(1);
160 | });
161 | });
162 |
--------------------------------------------------------------------------------
/tests/Select.spec.ts:
--------------------------------------------------------------------------------
1 | import type { Option } from "../src/types/option";
2 | import { mount } from "@vue/test-utils";
3 |
4 | import { describe, expect, it } from "vitest";
5 | import VueSelect from "../src/Select.vue";
6 | import { dispatchEvent, inputSearch, openMenu } from "./utils";
7 |
8 | const options = [
9 | { label: "France", value: "FR" },
10 | { label: "United Kingdom", value: "GB" },
11 | { label: "United States", value: "US" },
12 | { label: "Germany", value: "DE" },
13 | ];
14 |
15 | describe("input + menu interactions behavior", () => {
16 | it("should display the placeholder when no option is selected", () => {
17 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, placeholder: "Select an option" } });
18 |
19 | expect(wrapper.find(".input-placeholder").text()).toBe("Select an option");
20 | });
21 |
22 | it("should not open the menu when focusing the input", async () => {
23 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
24 |
25 | await wrapper.get("input").trigger("focus");
26 |
27 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
28 | });
29 |
30 | it("should not open the menu when is-disabled and an option is selected", async () => {
31 | const wrapper = mount(VueSelect, { props: { modelValue: options[0].value, options, isDisabled: true } });
32 |
33 | await openMenu(wrapper, "single-value");
34 |
35 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
36 | });
37 |
38 | it("should open the menu when isMenuOpen prop is set to true", async () => {
39 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, isMenuOpen: true } });
40 |
41 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
42 | });
43 |
44 | it("should close the menu when isMenuOpen prop is set to false", async () => {
45 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, isMenuOpen: true } });
46 |
47 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
48 |
49 | await wrapper.setProps({ isMenuOpen: false });
50 |
51 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
52 | });
53 | });
54 |
55 | describe("single-select option", () => {
56 | it("should select an option when clicking on it", async () => {
57 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
58 |
59 | await openMenu(wrapper);
60 | await wrapper.get("div[role='option']").trigger("click");
61 |
62 | expect(wrapper.emitted("update:modelValue")).toStrictEqual([[options[0].value]]);
63 | expect(wrapper.get(".single-value").element.textContent).toBe(options[0].label);
64 | });
65 |
66 | it("should not remove the selected option when pressing backspace after typing", async () => {
67 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
68 |
69 | await openMenu(wrapper);
70 | await wrapper.get("div[role='option']").trigger("click");
71 |
72 | expect(wrapper.emitted("update:modelValue")).toStrictEqual([[options[0].value]]);
73 | expect(wrapper.get(".single-value").element.textContent).toBe(options[0].label);
74 |
75 | await inputSearch(wrapper, "F");
76 | await wrapper.get("input").trigger("keydown", { key: "Backspace" });
77 |
78 | expect(wrapper.emitted("update:modelValue")).toStrictEqual([[options[0].value]]);
79 | expect(wrapper.get(".single-value").element.textContent).toBe(options[0].label);
80 | });
81 |
82 | it("cannot select an option when there are no matching options", async () => {
83 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
84 |
85 | await openMenu(wrapper);
86 | await inputSearch(wrapper, "Foo");
87 |
88 | expect(wrapper.findAll("div[role='option']").length).toBe(0);
89 |
90 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Enter" }));
91 |
92 | expect(wrapper.emitted("update:modelValue")).toBeUndefined();
93 | });
94 |
95 | it("cannot select a disabled option", async () => {
96 | const options = [{ label: "Spain", value: "ES", disabled: true }];
97 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
98 |
99 | await openMenu(wrapper);
100 | await wrapper.get("div[role='option']").trigger("click");
101 |
102 | expect(wrapper.emitted("update:modelValue")).toBeUndefined();
103 | });
104 | });
105 |
106 | describe("multi-select options", () => {
107 | it("should select an option when clicking on it", async () => {
108 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options } });
109 |
110 | await openMenu(wrapper);
111 | await wrapper.get("div[role='option']").trigger("click");
112 |
113 | expect(wrapper.emitted("update:modelValue")).toStrictEqual([[[options[0].value]]]);
114 | expect(wrapper.get(".multi-value").element.textContent).toBe(options[0].label);
115 | });
116 |
117 | it("should display non-selected remaining options on the list", async () => {
118 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options } });
119 |
120 | await openMenu(wrapper);
121 | await wrapper.get("div[role='option']").trigger("click");
122 | await openMenu(wrapper);
123 |
124 | expect(wrapper.findAll(".menu-option").length).toBe(options.length - 1);
125 | });
126 |
127 | it("should remove a selected option and be able to select it again", async () => {
128 | const wrapper = mount(VueSelect, { props: { modelValue: [], isMulti: true, options } });
129 |
130 | await openMenu(wrapper);
131 | await wrapper.get("div[role='option']").trigger("click");
132 | await openMenu(wrapper);
133 |
134 | expect(wrapper.findAll(".menu-option").length).toBe(options.length - 1);
135 |
136 | await wrapper.get(".multi-value-remove").trigger("click");
137 | await openMenu(wrapper);
138 |
139 | expect(wrapper.findAll(".menu-option").length).toBe(options.length);
140 | expect(wrapper.findAll(".multi-value").length).toBe(0);
141 | });
142 | });
143 |
144 | describe("search emit", () => {
145 | it("should emit the search event when typing in the input", async () => {
146 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
147 |
148 | await inputSearch(wrapper, "United");
149 |
150 | expect(wrapper.emitted("search")).toStrictEqual([["United"]]);
151 | });
152 |
153 | it("should emit an empty string for the search when the menu is closed", async () => {
154 | const wrapper = mount(VueSelect, { props: { modelValue: null, options } });
155 |
156 | await inputSearch(wrapper, "United");
157 | await dispatchEvent(wrapper, new KeyboardEvent("keydown", { key: "Escape" }));
158 |
159 | expect(wrapper.emitted("search")).toStrictEqual([["United"], [""]]);
160 | });
161 | });
162 |
163 | describe("custom option/value mapping", async () => {
164 | it("retrieve custom option value with getOptionValue prop", async () => {
165 | const options = [
166 | { id: "Admin", key: "admin" },
167 | { id: "User", key: "user" },
168 | ];
169 |
170 | const wrapper = mount(VueSelect, {
171 | props: {
172 | modelValue: null,
173 | options: options as unknown as Option[],
174 | getOptionValue: (option: any) => option.key,
175 | },
176 | });
177 |
178 | await openMenu(wrapper);
179 | await wrapper.get("div[role='option']").trigger("click");
180 |
181 | expect(wrapper.emitted("update:modelValue")).toStrictEqual([["admin"]]);
182 | });
183 |
184 | it("retrieve custom option label with getOptionLabel prop", async () => {
185 | const options = [
186 | { id: "Admin", key: "admin" },
187 | { id: "User", key: "user" },
188 | ];
189 |
190 | const wrapper = mount(VueSelect, {
191 | props: {
192 | modelValue: null,
193 | options: options as unknown as Option[],
194 | getOptionLabel: (option: any) => option.id,
195 | },
196 | });
197 |
198 | await openMenu(wrapper);
199 |
200 | const optionElements = wrapper.findAll("div[role='option']");
201 | expect(optionElements[0].text()).toBe("Admin");
202 | expect(optionElements[1].text()).toBe("User");
203 |
204 | await wrapper.get("div[role='option']").trigger("click");
205 |
206 | expect(wrapper.get(".single-value").text()).toBe("Admin");
207 | });
208 | });
209 |
210 | describe("misc props", () => {
211 | it("should disable the input when passing the isDisabled prop", () => {
212 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, isDisabled: true } });
213 |
214 | expect(wrapper.get("input").attributes("disabled")).toBe("");
215 | });
216 |
217 | it("should not filter menu options when isSearchable prop is set to false", async () => {
218 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, isSearchable: false } });
219 |
220 | await openMenu(wrapper);
221 | await inputSearch(wrapper, "United");
222 |
223 | expect(wrapper.findAll("div[role='option']").length).toBe(options.length);
224 | });
225 |
226 | it("should not autofocus an option when passing the autofocus prop", async () => {
227 | const wrapper = mount(VueSelect, { props: { modelValue: null, options, shouldAutofocusOption: false } });
228 |
229 | await openMenu(wrapper);
230 |
231 | expect(wrapper.findAll(".focused[role='option']")).toHaveLength(0);
232 | });
233 | });
234 |
--------------------------------------------------------------------------------
/tests/index.spec.ts:
--------------------------------------------------------------------------------
1 | import type { Option } from "../src/index";
2 | import { describe, expect, it } from "vitest";
3 | import DefaultExport from "../src/index";
4 | import VueSelect from "../src/Select.vue";
5 |
6 | describe("index.ts exports", () => {
7 | it("should export VueSelect as the default export", () => {
8 | expect(DefaultExport).toBe(VueSelect);
9 | });
10 |
11 | it("should export the Option type", () => {
12 | const option: Option = {
13 | label: "Test Option",
14 | value: "test",
15 | disabled: false,
16 | };
17 |
18 | // If this compiles, it confirms the Option type is exported correctly
19 | expect(option.label).toBe("Test Option");
20 | expect(option.value).toBe("test");
21 | expect(option.disabled).toBe(false);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import type { mount } from "@vue/test-utils";
2 |
3 | export async function openMenu(wrapper: ReturnType, method: "mousedown" | "focus-space" | "single-value" = "mousedown") {
4 | if (method === "mousedown") {
5 | await wrapper.get("input").trigger("mousedown");
6 | }
7 | else if (method === "focus-space") {
8 | await wrapper.get("input").trigger("focus");
9 | await wrapper.get("input").trigger("keydown", { code: "Space" });
10 | }
11 | else if (method === "single-value") {
12 | await wrapper.get(".single-value").trigger("click");
13 | }
14 | }
15 |
16 | export async function dispatchEvent(wrapper: ReturnType, event: Event) {
17 | document.dispatchEvent(event);
18 | await wrapper.vm.$nextTick();
19 | };
20 |
21 | export async function inputSearch(wrapper: ReturnType, search: string) {
22 | await wrapper.get("input").setValue(search);
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "compilerOptions": {
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "jsx": "preserve",
6 | "jsxImportSource": "vue",
7 |
8 | "paths": {
9 | "@/*": ["./src/*"]
10 | }
11 | },
12 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
13 | "exclude": ["src/**/__tests__/*"]
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "lib": ["ESNext", "DOM"],
6 | "module": "ESNext",
7 | "moduleResolution": "Bundler",
8 | "paths": {
9 | "@/*": ["src/*"]
10 | },
11 | "strict": true,
12 | "declaration": false,
13 | "outDir": "dist",
14 | "esModuleInterop": true,
15 | "skipLibCheck": true
16 | },
17 | "include": [
18 | "env.d.ts",
19 | "src/**/*",
20 | "src/**/*.ts",
21 | "src/**/*.vue"
22 | ],
23 | "exclude": [
24 | "src/**/*.test.ts"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "NodeNext"
4 | },
5 | "references": [
6 | { "path": "./tsconfig.node.json" },
7 | { "path": "./tsconfig.app.json" },
8 | { "path": "./tsconfig.vitest.json" }
9 | ],
10 | "files": []
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node22/tsconfig.json",
3 | "compilerOptions": {
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "module": "ESNext",
6 | "moduleResolution": "Bundler",
7 | "types": ["node"],
8 | "noEmit": true
9 | },
10 | "include": [
11 | "vite.config.*",
12 | "vitest.config.*",
13 | "cypress.config.*",
14 | "nightwatch.conf.*",
15 | "playwright.config.*",
16 | "eslint.config.*"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "compilerOptions": {
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
5 |
6 | "lib": [],
7 | "types": ["node", "jsdom"]
8 | },
9 | "include": ["tests/**/*", "env.d.ts"],
10 | "exclude": []
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import type { UserConfig } from "vite";
2 |
3 | import { resolve as pathResolve } from "node:path";
4 | import { fileURLToPath, URL } from "node:url";
5 | import vue from "@vitejs/plugin-vue";
6 | import vueJsx from "@vitejs/plugin-vue-jsx";
7 | import { defineConfig } from "vite";
8 | import cssInject from "vite-plugin-css-injected-by-js";
9 | import dts from "vite-plugin-dts";
10 | import vueDevtools from "vite-plugin-vue-devtools";
11 |
12 | const resolve = (path: string) => fileURLToPath(new URL(path, import.meta.url));
13 |
14 | export default defineConfig((configEnv) => {
15 | // Default config shared config by all modes.
16 | const config: UserConfig = {
17 | plugins: [vue(), vueJsx()],
18 |
19 | resolve: {
20 | alias: {
21 | "@": pathResolve(__dirname, "./src"),
22 | },
23 | },
24 | };
25 |
26 | // Build library when in production mode (npm run build).
27 | if (configEnv.mode === "production") {
28 | return {
29 | ...config,
30 |
31 | plugins: [
32 | ...config.plugins!,
33 | cssInject(),
34 | dts({ tsconfigPath: "tsconfig.build.json", cleanVueFileName: true }),
35 | ],
36 |
37 | build: {
38 | // Official @vue/tsconfig compiles down to ES2020, let's do the same.
39 | // See: https://github.com/vuejs/tsconfig/blob/main/tsconfig.dom.json
40 | target: "es2020",
41 | lib: {
42 | name: "vue3-select-component",
43 | entry: resolve("./src/index.ts"),
44 | formats: ["es", "umd"],
45 | fileName: (format) => `index.${format}.js`,
46 | },
47 | rollupOptions: {
48 | external: ["vue"],
49 | output: { globals: { vue: "Vue" } },
50 | },
51 | },
52 | };
53 | }
54 |
55 | if (["development", "development:playground"].includes(configEnv.mode)) {
56 | config.plugins!.push(vueDevtools());
57 | }
58 |
59 | if (configEnv.mode === "development:playground") {
60 | config.root = resolve("./playground");
61 | }
62 |
63 | return config;
64 | });
65 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, mergeConfig } from "vitest/config";
2 |
3 | import viteConfig from "./vite.config";
4 |
5 | export default defineConfig((configEnv) => mergeConfig(
6 | viteConfig(configEnv),
7 | defineConfig({
8 | test: {
9 | environment: "jsdom",
10 | coverage: {
11 | provider: "v8",
12 | include: ["src/**/*.vue", "src/**/*.ts", "src/**/*.tsx"],
13 | },
14 | },
15 | }),
16 | ));
17 |
--------------------------------------------------------------------------------