├── .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 | Vue3 Select Component 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 | npm package 20 | 21 | 22 | 23 | npm package 24 | 25 | 26 | 27 | GitHub stars 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 | 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 | 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 | 21 | 22 | 23 | 34 | 35 | 36 | ## Demo source-code 37 | 38 | ```vue 39 | 46 | 47 | 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 | 57 | 58 | 59 | 60 | 67 | 68 | ## Demo source-code 69 | 70 | ```vue 71 | 99 | 100 | 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 | 40 | 41 | 46 | 47 | 48 | 49 | 74 | 75 | ## Demo source-code 76 | 77 | ```vue 78 | 84 | 85 | 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 | 36 | 37 | 38 | 39 | 60 | 61 | ## Demo source-code 62 | 63 | ```vue 64 | 70 | 71 | 80 | 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 | 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 | 35 | 36 | 37 | 38 | 51 | 52 | ## Demo source-code 53 | 54 | ```vue 55 | 61 | 62 | 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 | 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 | 41 | 42 | 43 | Selected value(s): **{{ selected.length ? selected.join(", ") : "none" }}** 44 | 45 | ## Demo source-code 46 | 47 | ```vue 48 | 68 | 69 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 90 | ``` 91 | 92 | ## `@menu-opened` 93 | 94 | Emitted when the menu is opened. 95 | 96 | ```vue 97 | 104 | ``` 105 | 106 | ## `@menu-closed` 107 | 108 | Emitted when the menu is closed. 109 | 110 | ```vue 111 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 47 | 48 | 58 | 59 | 88 | -------------------------------------------------------------------------------- /playground/demos/ControlledMenu.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 42 | -------------------------------------------------------------------------------- /playground/demos/CustomMenuContainer.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 36 | 37 | 43 | -------------------------------------------------------------------------------- /playground/demos/CustomMenuOption.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 45 | -------------------------------------------------------------------------------- /playground/demos/CustomOptionLabelValue.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /playground/demos/CustomSearchFilter.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 49 | -------------------------------------------------------------------------------- /playground/demos/InfiniteScroll.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 79 | 80 | 97 | -------------------------------------------------------------------------------- /playground/demos/MenuHeader.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 31 | -------------------------------------------------------------------------------- /playground/demos/MultiSelect.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /playground/demos/MultiSelectTaggable.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 35 | -------------------------------------------------------------------------------- /playground/demos/SelectIsLoading.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /playground/demos/SingleSelect.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /playground/demos/TaggableNoOptionsSlot.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 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 | 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 | sharedData.setOption(option)} 152 | > 153 | { 154 | props.slots.option 155 | ? props.slots.option({ 156 | option, 157 | index: i, 158 | isFocused: sharedData.focusedOption.value === i, 159 | isSelected: Array.isArray(selected.value) ? selected.value.includes(option.value) : option.value === selected.value, 160 | isDisabled: option.disabled || false, 161 | }) 162 | : sharedProps.getOptionLabel 163 | ? sharedProps.getOptionLabel(option) 164 | : option.label 165 | } 166 | 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 |
208 | { 209 | props.slots["menu-container"] 210 | ? props.slots["menu-container"]({ defaultContent: }) 211 | : 212 | } 213 |
214 | ); 215 | }, 216 | { name: "Menu", props: ["slots", "modelValue"], emits: ["update:modelValue"] }, 217 | ); 218 | -------------------------------------------------------------------------------- /src/MenuOption.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 59 | 60 | 101 | -------------------------------------------------------------------------------- /src/MultiValue.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 38 | 39 | 87 | -------------------------------------------------------------------------------- /src/Placeholder.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /src/Select.vue: -------------------------------------------------------------------------------- 1 | 288 | 289 | 422 | 423 | 494 | 495 | 600 | -------------------------------------------------------------------------------- /src/Spinner.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 138 | -------------------------------------------------------------------------------- /src/icons/ChevronDownIcon.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/icons/XMarkIcon.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------