├── .github ├── dependabot.yml └── workflows │ ├── npm-publish.yml │ ├── prerelease-check.yml │ ├── static.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── demo ├── App.vue ├── index.html ├── main.ts ├── tsconfig.json ├── vite-env.d.ts └── vite.config.ts ├── docs ├── components │ ├── ChromePicker.md │ ├── CompactPicker.md │ ├── GrayscalePicker.md │ ├── HueSlider.md │ ├── PhotoshopPicker.md │ ├── SketchPicker.md │ ├── SliderPicker.md │ ├── SwatchesPicker.md │ └── TwitterPicker.md └── pickers.png ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── ChromePicker.vue │ ├── CompactPicker.vue │ ├── GrayscalePicker.vue │ ├── HueSlider.vue │ ├── MaterialPicker.vue │ ├── PhotoshopPicker.vue │ ├── SketchPicker.vue │ ├── SliderPicker.vue │ ├── SwatchesPicker.vue │ ├── TwitterPicker.vue │ └── common │ │ ├── AlphaSlider.vue │ │ ├── CheckerboardBG.vue │ │ ├── EditableInput.vue │ │ ├── HueSlider.vue │ │ └── SaturationSlider.vue ├── composable │ ├── colorModel.ts │ └── hue.ts ├── index.ts ├── utils │ ├── color.ts │ ├── dom.ts │ ├── math.ts │ └── throttle.ts └── vite-env.d.ts ├── tests ├── components │ ├── ChromePicker.spec.ts │ ├── CompactPicker.spec.ts │ ├── GrayscalePicker.spec.ts │ ├── HueSlider.spec.ts │ ├── MaterialPicker.spec.ts │ ├── PhotoshopPicker.spec.ts │ ├── SketchPicker.spec.ts │ ├── SliderPicker.spec.ts │ ├── SwatchesPicker.spec.ts │ ├── TwitterPicker.spec.ts │ └── common │ │ ├── AlphaSlider.spec.ts │ │ ├── CheckerboardBG.spec.ts │ │ ├── EditableInput.spec.ts │ │ ├── HueSlider.spec.ts │ │ └── SaturationSlider.spec.ts ├── tools.ts ├── utils │ ├── dom.browser.spec.ts │ ├── math.unit.spec.ts │ └── throttle.unit.spec.ts └── vitest.shims.d.ts ├── tsconfig.json ├── tsconfig.lib.json ├── tsconfig.node.json ├── tsconfig.test.json ├── vite.config.ts └── vitest.workspace.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Check the npm registry for updates every day (weekdays) 8 | schedule: 9 | interval: "weekly" 10 | labels: 11 | - "dependencies" 12 | assignees: 13 | - "linx4200" 14 | reviewers: 15 | - "linx4200" 16 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | if: github.actor != 'dependabot[bot]' 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node 19 | uses: actions/setup-node@v4 20 | with: 21 | registry-url: 'https://registry.npmjs.org/' 22 | 23 | - name: Verify NPM token 24 | run: if [ -z "${{ secrets.NPM_TOKEN }}" ]; then echo "NPM_TOKEN is not set"; exit 1; fi 25 | 26 | - name: Check if version is published 27 | run: | 28 | VERSION=$(node -p "require('./package.json').version") 29 | if npm view vue-color@$VERSION > /dev/null 2>&1; then 30 | echo "Version $VERSION already exists. Skipping publish." 31 | exit 1 32 | fi 33 | 34 | - name: Install dependencies 35 | run: npm ci 36 | 37 | - name: Build 38 | run: npm run build 39 | 40 | - name: Publish package 41 | run: npm publish 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | -------------------------------------------------------------------------------- /.github/workflows/prerelease-check.yml: -------------------------------------------------------------------------------- 1 | name: Run a few checks to make sure the release goes smoothly 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | 7 | # run on pull_request events that target the main branch 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 2 20 | 21 | - name: Set up Node 22 | uses: actions/setup-node@v4 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Lint 28 | run: npm run lint 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Demo Build 34 | run: npm run demo:build -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | # 1. Checkout the code from next/3.x 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | 36 | # 2. Install dependencies 37 | - name: Install dependencies 38 | run: npm ci 39 | 40 | # 3. Build the Vite demo project 41 | - name: Build demo 42 | run: npm run demo:build 43 | 44 | - name: Setup Pages 45 | uses: actions/configure-pages@v5 46 | 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | # Upload entire repository 51 | path: './demo/dist' 52 | 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v4 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests and upload coverage 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # run on pull_request events that target the main branch 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 2 24 | 25 | - name: Set up Node 26 | uses: actions/setup-node@v4 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Install Playwright browsers 32 | run: npx playwright install 33 | 34 | - name: Run tests 35 | run: npm run coverage 36 | 37 | - name: Upload results to Codecov 38 | uses: codecov/codecov-action@v5 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | -------------------------------------------------------------------------------- /.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 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # vitest 27 | coverage -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 greyby 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎨 Vue Color v3.0 2 | 3 |

4 | NPM monthly downloads 5 | Github stars 6 |

7 | 8 | ![Vue 3](https://img.shields.io/badge/Vue-3.0-brightgreen) 9 | ![Package Size](https://img.shields.io/bundlephobia/minzip/vue-color) 10 | ![Test Coverage](https://codecov.io/gh/linx4200/vue-color/branch/main/graph/badge.svg) 11 | 12 | A collection of efficient and customizable color pickers built with [Vue 3](https://vuejs.org/), designed for modern web development. 13 | 14 | ## 🧪 Live Demo 15 | 16 | Explore the components in action: 👉 [Open Live Demo](https://linx4200.github.io/vue-color/) 17 | 18 | 19 | 20 | ## ✨ Features 21 | 22 | - **Modular & Tree-Shakable** – Import only what you use 23 | 24 | - **TypeScript Ready** – Full typings for better DX 25 | 26 | - **SSR-Friendly** – Compatible with Nuxt and other SSR frameworks 27 | 28 | - **Optimized for Accessibility** – Built with keyboard navigation and screen readers in mind. 29 | 30 | ## 📦 Installation 31 | 32 | ```bash 33 | npm install vue-color 34 | # or 35 | yarn add vue-color 36 | ``` 37 | 38 | ## 🚀 Quick Start 39 | 40 | ### 1. Import styles 41 | 42 | ```ts 43 | // main.ts 44 | import { createApp } from 'vue' 45 | import App from './App.vue' 46 | 47 | // Import styles 48 | import 'vue-color/style.css'; 49 | 50 | createApp(App).mount('#app') 51 | ``` 52 | 53 | ### 2. Use a color picker component 54 | 55 | ```vue 56 | 59 | 60 | 67 | ``` 68 | 69 | > 📘 For a full list of available components, see the [Documentation](#all-available-pickers). 70 | 71 | ## 📚 Documentation 72 | 73 | ### All Available Pickers 74 | 75 | All color pickers listed below can be imported as named exports from `vue-color`. 76 | 77 | ```ts 78 | import { ChromePicker, CompactPicker, HueSlider /** ...etc */ } from 'vue-color'; 79 | ``` 80 | 81 | | Component Name | Docs | 82 | | ------- | ------- | 83 | | ChromePicker | [View](./docs/components/ChromePicker.md) | 84 | | CompactPicker | [View](./docs/components/CompactPicker.md) | 85 | | GrayscalePicker | [View](./docs/components/GrayscalePicker.md) | 86 | | MaterialPicker | - | 87 | | PhotoshopPicker | [View](./docs/components/PhotoshopPicker.md) | 88 | | SketchPicker | [View](./docs/components/SketchPicker.md) | 89 | | SliderPicker | [View](./docs/components/SliderPicker.md) | 90 | | SwatchesPicker | [View](./docs/components/SwatchesPicker.md) | 91 | | TwitterPicker | [View](./docs/components/TwitterPicker.md) | 92 | | HueSlider | [View](./docs/components/HueSlider.md) | 93 | | AlphaSlider | - | 94 | 95 | ### Props & Events 96 | 97 | All color picker components (expect for ``) in `vue-color` share a set of common props and events for handling color updates and synchronization. 98 | Below we'll take `` as an example to illustrate how to work with `v-model`. 99 | 100 | #### `v-model` 101 | 102 | ```vue 103 | 106 | 107 | 114 | ``` 115 | 116 | The `v-model` of `vue-color` accepts a variety of color formats as input. **It will preserve the format you provide**, which is especially useful if you need format consistency throughout your app. 117 | 118 | ```ts 119 | const color = defineModel({ 120 | default: 'hsl(136, 54%, 43%)' 121 | // or 122 | default: '#32a852' 123 | // or 124 | default: '#32a852ff' 125 | // or 126 | default: { r: 255, g: 255, b: 255, a: 1 } 127 | }); 128 | ``` 129 | 130 | Under the hood, `vue-color` uses [`tinycolor2`](https://www.npmjs.com/package/tinycolor2) to handle color parsing and conversion. 131 | This means you can pass in any color format that `tinycolor2` supports—and it will just work. 132 | 133 | #### `v-model:tinyColor` 134 | 135 | ```vue 136 | 139 | 140 | 147 | ``` 148 | 149 | In addition to plain color values, you can also bind a `tinycolor2` instance using `v-model:tinyColor`. 150 | This gives you full control and utility of the `tinycolor` API. 151 | 152 | > ⚠️ Note: You must use the `tinycolor` exported from `vue-color` to ensure compatibility with the library's internal handling. 153 | 154 | ### SSR Compatibility 155 | 156 | Since `vue-color` relies on DOM interaction, components must be rendered client-side. Example for Nuxt: 157 | 158 | ```vue 159 | 164 | 165 | 169 | ``` 170 | 171 | ## 🧩 FAQ / Issue Guide 172 | 173 | | Error / Symptom | Related Issue | 174 | |--------|----------------| 175 | | `TS2742: The inferred type of 'default' cannot be named without a reference to ...` (commonly triggered when using `pnpm`) | [#278](https://github.com/linx4200/vue-color/issues/278) | 176 | 177 | ## 🤝 Contributing 178 | 179 | Contributions are welcome! Please open issues or pull requests as needed. 180 | 181 | ## 📄 License 182 | 183 | [MIT](./LICENSE) 184 | -------------------------------------------------------------------------------- /demo/App.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 162 | 163 | 272 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue-color v3.0 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "../node_modules/.tmp/tsconfig.demo.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["./**/*.ts", "./**/*.tsx", "./**/*.vue"] 26 | } 27 | -------------------------------------------------------------------------------- /demo/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __USE_PRODUCTION__: string; -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | base: 'https://linx4200.github.io/vue-color/' 8 | }) 9 | -------------------------------------------------------------------------------- /docs/components/ChromePicker.md: -------------------------------------------------------------------------------- 1 | # ChromePicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |-----------------|------------------------------|--------------------------|-------------| 9 | | `disableAlpha` | `Boolean` | `false` | Hides the alpha (opacity) slider and input when set to `true`. | 10 | | `disableFields` | `Boolean` | `false` | Hides all color input fields when set to `true`. | 11 | | `formats` | `Array<'hex' \| 'rgb' \| 'hsl'>` | `['rgb', 'hex', 'hsl']` | Controls which color formats are shown. Also defines their display order. | 12 | 13 | ## Events 14 | 15 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 16 | -------------------------------------------------------------------------------- /docs/components/CompactPicker.md: -------------------------------------------------------------------------------- 1 | # CompactPicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |-----------------|------------------------------|--------------------------|-------------| 9 | | `palette` | `string[]` | | Defines the color palette displayed as preset swatches in the component. | 10 | 11 | ## Events 12 | 13 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 14 | -------------------------------------------------------------------------------- /docs/components/GrayscalePicker.md: -------------------------------------------------------------------------------- 1 | # GrayscalePicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |-----------------|------------------------------|--------------------------|-------------| 9 | | `palette` | `string[]` | | Defines the color palette displayed as preset swatches in the component. | 10 | 11 | ## Events 12 | 13 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 14 | -------------------------------------------------------------------------------- /docs/components/HueSlider.md: -------------------------------------------------------------------------------- 1 | # HueSlider 2 | 3 | ## Props 4 | 5 | | Prop | Type | Default | Description | 6 | |------------|-------------------------------------|--------------|---------------------------------------------------------------| 7 | | `direction`| `'horizontal'` | `'vertical'` | `"horizontal"` | Determines the layout orientation of the component. | 8 | |`modelValue`| `number` | `0` | The hue value. The value range is `[0, 360]`.| 9 | 10 | ## Events 11 | 12 | | Event | Payload | Description | 13 | |---------------|-----------|----------------------------------------------------------------------------------------| 14 | | `update:modelValue` | `number` | Emitted when the `hue` value changes. | 15 | 16 | ## Usage Example 17 | 18 | ```vue 19 | 24 | 25 | 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/components/PhotoshopPicker.md: -------------------------------------------------------------------------------- 1 | # PhotoshopPicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |------------------|----------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| 9 | | `title` | `string` | `"Color picker"` | The title displayed at the top of the picker dialog. | 10 | | `disableFields` | `boolean`| `false` | If set to `true`, the color input fields (HSV, RGB and HEX inputs) are disabled. | 11 | | `hasResetButton` | `boolean`| `false` | When `true`, a Reset button is displayed in the picker, allowing users to revert to the original color. | 12 | | `okLabel` | `string` | `"OK"` | The label text for the OK button, which confirms the selected color. | 13 | | `cancelLabel` | `string` | `"Cancel"` | The label text for the Cancel button, which closes the dialog without applying changes. | 14 | | `resetLabel` | `string` | `"Reset"` | The label text for the Reset button. | 15 | | `newLabel` | `string` | `"new"` | Text label used to denote the newly selected color preview. | 16 | | `currentLabel` | `string` | `"current"` | Text label used to denote the currently active color ( `currentColor`). | 17 | | `currentColor` | `string` | `"#fff"` | The initial current color value used as a reference for the original color (useful for the Reset functionality). | 18 | 19 | ## Events 20 | 21 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 22 | 23 | | Event | Payload | Description | 24 | |-----------------------|----------------|--------------------------------------------------------------------------------------------------------------------| 25 | | `ok` | — | Emitted when the user clicks the OK button to confirm the selected color. | 26 | | `cancel` | — | Emitted when the user clicks the Cancel button to close the dialog without applying changes. | 27 | | `reset` | — | Emitted when the user clicks the Reset button. Usually it's used to restore the original color (as defined by `currentColor`). | 28 | -------------------------------------------------------------------------------- /docs/components/SketchPicker.md: -------------------------------------------------------------------------------- 1 | # SketchPicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |-----------------|------------------------------|--------------------------|-------------| 9 | | `disableAlpha` | `Boolean` | `false` | Hides the alpha (opacity) slider and input when set to `true`. | 10 | | `disableFields` | `Boolean` | `false` | Hides all color input fields when set to `true`. | 11 | | `presetColors` | `string[]` | | Defines the color palette displayed as preset swatches in the component. | 12 | 13 | ## Events 14 | 15 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 16 | -------------------------------------------------------------------------------- /docs/components/SliderPicker.md: -------------------------------------------------------------------------------- 1 | # SliderPicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |-----------------|------------------------------|--------------------------|-------------| 9 | | `swatches` | `Array` | | Specifies an array of preset color swatches. | 10 | | `alpha` | `Boolean` | `false` | Determines whether an alpha (opacity) slider is displayed. | 11 | 12 | ## Events 13 | 14 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 15 | -------------------------------------------------------------------------------- /docs/components/SwatchesPicker.md: -------------------------------------------------------------------------------- 1 | # SwatchesPicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |-----------------|------------------------------|--------------------------|-------------| 9 | | `palette` | `string[][]` | | Defines the color palette displayed as preset swatches in the component. | 10 | 11 | ## Events 12 | 13 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 14 | -------------------------------------------------------------------------------- /docs/components/TwitterPicker.md: -------------------------------------------------------------------------------- 1 | # TwitterPicker 2 | 3 | ## Props 4 | 5 | Besides `modelValue` and `tinyColor` (used with `v-model` and `v-model:tinyColor` respectively), `` also supports the following props: 6 | 7 | | Prop | Type | Default | Description | 8 | |----------------|---------------------------------------------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| 9 | | `width` | `number` | `string` | `276` | Specifies the component's width. If a number is given, it's interpreted as pixels; otherwise, you can provide a valid CSS width string. | 10 | | `presetColors` | `string[]` | | An array of preset color strings used as available options in the component. | 11 | | `triangle` | `'hide'` | `'top-left'` | `'top-right'` | `'top-left'` | Controls the triangle pointer's display. Use `hide` to omit it; otherwise, specify `top-left` or `top-right` to set its position. | 12 | 13 | ## Events 14 | 15 | `` emits `update:modelValue` and `update:tinyColor` events, which are used by `v-model` and `v-model:tinyColor` respectively. 16 | -------------------------------------------------------------------------------- /docs/pickers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linx4200/vue-color/aef9c3309670346e74ce9339585c3b778ea3e112/docs/pickers.png -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginVue from "eslint-plugin-vue"; 5 | import pluginVueA11y from "eslint-plugin-vuejs-accessibility"; 6 | 7 | /** @type {import('eslint').Linter.Config[]} */ 8 | export default [ 9 | {files: ["src/**/*.{js,mjs,cjs,ts,vue}","demo/**/*.{js,mjs,cjs,ts,vue}"]}, 10 | {ignores: ["node_modules", "dist", "coverage", "demo/dist"]}, 11 | {languageOptions: { globals: globals.browser }}, 12 | pluginJs.configs.recommended, 13 | ...tseslint.configs.recommended, 14 | ...pluginVue.configs["flat/essential"], 15 | ...pluginVueA11y.configs["flat/recommended"], 16 | {files: ["**/*.vue"], languageOptions: {parserOptions: {parser: tseslint.parser}}}, 17 | { "rules": { 18 | "vuejs-accessibility/label-has-for": [ 19 | "error", 20 | { 21 | "components": ["VLabel"], 22 | "controlComponents": ["VInput"], 23 | "required": { 24 | "some": ["nesting", "id"] 25 | }, 26 | } 27 | ] 28 | } 29 | } 30 | ]; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-color", 3 | "version": "3.0.2", 4 | "type": "module", 5 | "files": [ 6 | "dist" 7 | ], 8 | "main": "./dist/vue-color.umd.cjs", 9 | "module": "./dist/vue-color.js", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/types/index.d.ts", 13 | "import": "./dist/vue-color.js", 14 | "require": "./dist/vue-color.umd.cjs" 15 | }, 16 | "./style.css": { 17 | "import": "./dist/vue-color.css", 18 | "require": "./dist/vue-color.css" 19 | } 20 | }, 21 | "types": "./dist/types/index.d.ts", 22 | "scripts": { 23 | "build": "vite build && vue-tsc --project tsconfig.lib.json --declaration --emitDeclarationOnly", 24 | "demo": "vite demo", 25 | "demo:build": "vite build demo", 26 | "test": "vitest --workspace=vitest.workspace.ts", 27 | "coverage": "vitest run --coverage --coverage.include=src/components --coverage.include=src/composable --coverage.include=src/utils", 28 | "lint": "eslint" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/linx4200/vue-color.git" 33 | }, 34 | "author": "Xinran , xiaokai ", 35 | "peerDependencies": { 36 | "vue": "^3.5.13" 37 | }, 38 | "devDependencies": { 39 | "@eslint/js": "^9.21.0", 40 | "@types/material-colors": "^1.2.3", 41 | "@types/node": "^22.13.11", 42 | "@types/tinycolor2": "^1.4.6", 43 | "@vitejs/plugin-vue": "^5.2.1", 44 | "@vitest/browser": "^3.0.5", 45 | "@vitest/coverage-v8": "^3.0.5", 46 | "eslint": "^9.21.0", 47 | "eslint-plugin-vue": "^9.32.0", 48 | "eslint-plugin-vuejs-accessibility": "^2.4.1", 49 | "globals": "^16.0.0", 50 | "playwright": "^1.50.1", 51 | "typescript": "~5.7.3", 52 | "typescript-eslint": "^8.25.0", 53 | "vite": "^6.3.4", 54 | "vitest": "^3.0.5", 55 | "vitest-browser-vue": "^0.1.0", 56 | "vue-tsc": "^2.2.0" 57 | }, 58 | "dependencies": { 59 | "material-colors": "^1.2.6", 60 | "tinycolor2": "^1.6.0" 61 | }, 62 | "publishConfig": { 63 | "registry": "https://registry.npmjs.org/" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/ChromePicker.vue: -------------------------------------------------------------------------------- 1 | 101 | 102 | 259 | 260 | -------------------------------------------------------------------------------- /src/components/CompactPicker.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 33 | 34 | 59 | 60 | 103 | -------------------------------------------------------------------------------- /src/components/GrayscalePicker.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 30 | 31 | 55 | 56 | -------------------------------------------------------------------------------- /src/components/HueSlider.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | 18 | 38 | -------------------------------------------------------------------------------- /src/components/MaterialPicker.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/PhotoshopPicker.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 163 | 164 | -------------------------------------------------------------------------------- /src/components/SketchPicker.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 79 | 80 | 153 | 154 | -------------------------------------------------------------------------------- /src/components/SliderPicker.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 52 | 53 | 109 | 110 | 168 | -------------------------------------------------------------------------------- /src/components/SwatchesPicker.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 60 | 61 | 86 | 87 | 131 | -------------------------------------------------------------------------------- /src/components/TwitterPicker.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 48 | 49 | 86 | 87 | 196 | -------------------------------------------------------------------------------- /src/components/common/AlphaSlider.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 122 | 123 | 164 | -------------------------------------------------------------------------------- /src/components/common/CheckerboardBG.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 58 | 59 | 69 | -------------------------------------------------------------------------------- /src/components/common/EditableInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 87 | 88 | 101 | -------------------------------------------------------------------------------- /src/components/common/HueSlider.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 197 | 198 | -------------------------------------------------------------------------------- /src/components/common/SaturationSlider.vue: -------------------------------------------------------------------------------- 1 | 2 | 33 | 34 | 193 | 194 | -------------------------------------------------------------------------------- /src/composable/colorModel.ts: -------------------------------------------------------------------------------- 1 | import { computed, type EmitFn } from 'vue'; 2 | import tinycolor from 'tinycolor2'; 3 | 4 | type TinyColorFormat = 'name' | 'hex8' | 'hex' | 'prgb' | 'rgb' | 'hsv' | 'hsl'; 5 | 6 | const transformToOriginalInputFormat = (color: tinycolor.Instance, originalFormat?: TinyColorFormat, isObjectOriginally = false) => { 7 | if (isObjectOriginally) { 8 | switch (originalFormat) { 9 | case 'rgb': { 10 | return color.toRgb(); 11 | } 12 | case 'prgb': { 13 | return color.toPercentageRgb(); 14 | } 15 | case 'hsl': { 16 | return color.toHsl(); 17 | } 18 | case 'hsv': { 19 | return color.toHsv(); 20 | } 21 | default: { 22 | return null; 23 | } 24 | } 25 | } else { 26 | // transform back to the original format 27 | let newValue = color.toString(originalFormat); 28 | try { 29 | newValue = JSON.parse(newValue); 30 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 31 | } catch (error) { /* no need to handle */ } 32 | return newValue; 33 | } 34 | } 35 | 36 | export type useTinyColorModelProps = { 37 | tinyColor?: tinycolor.ColorInput; 38 | modelValue?: tinycolor.ColorInput; 39 | } 40 | 41 | export const EmitEventNames = ['update:tinyColor', 'update:modelValue']; 42 | 43 | export function defineColorModel(props: useTinyColorModelProps, emit: EmitFn) { 44 | 45 | let isObjectOriginally: boolean; 46 | let originalFormat: TinyColorFormat; 47 | 48 | const tinyColorRef = computed({ 49 | get: () => { 50 | const colorInput = props.tinyColor ?? props.modelValue; 51 | const value = tinycolor(colorInput); 52 | if (typeof originalFormat === 'undefined') { 53 | originalFormat = value.getFormat() as TinyColorFormat; 54 | } 55 | if (typeof isObjectOriginally === 'undefined') { 56 | if (typeof props.modelValue === 'object') { 57 | isObjectOriginally = true; 58 | } 59 | } 60 | return value; 61 | }, 62 | set: (newValue: tinycolor.ColorInput) => { 63 | updateColor(newValue); 64 | } 65 | }); 66 | 67 | const updateColor = (value: tinycolor.ColorInput) => { 68 | const newValue = tinycolor(value); 69 | if (Object.prototype.hasOwnProperty.call(props, 'tinyColor')) { 70 | emit('update:tinyColor', newValue); 71 | } 72 | if (Object.prototype.hasOwnProperty.call(props, 'modelValue')) { 73 | emit('update:modelValue', transformToOriginalInputFormat(newValue, originalFormat, isObjectOriginally)); 74 | } 75 | } 76 | 77 | return tinyColorRef; 78 | } 79 | -------------------------------------------------------------------------------- /src/composable/hue.ts: -------------------------------------------------------------------------------- 1 | import tinycolor from "tinycolor2"; 2 | import { ref, watch, type WritableComputedRef } from "vue"; 3 | 4 | function random2Char(): string { 5 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 6 | return chars.charAt(Math.floor(Math.random() * chars.length)) + 7 | chars.charAt(Math.floor(Math.random() * chars.length)); 8 | } 9 | 10 | export const useHueRef = (tinyColorRef: WritableComputedRef) => { 11 | const hueRef = ref(0); 12 | const sourceLabel = `__from__vc__hue__${random2Char()}`; 13 | 14 | watch(tinyColorRef, (tinyColorInstance) => { 15 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 16 | // @ts-expect-error 17 | if (tinyColorInstance[sourceLabel]) { 18 | // Don’t update if the change originated from itself. 19 | return; 20 | } 21 | const newHue = tinyColorInstance.toHsl().h; 22 | // The hue value is likely to be lost when TinyColor converts between color formats, especially when the color is grayscale 23 | if (newHue === 0 && hueRef.value !== 0) { 24 | return; 25 | } 26 | hueRef.value = newHue; 27 | }, { immediate: true }); 28 | 29 | const updateHueRef = (newHue: number) => { 30 | const newColorInstance = tinycolor({ 31 | ...tinyColorRef.value.toHsl(), 32 | h: newHue 33 | }); 34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 35 | // @ts-expect-error 36 | newColorInstance[sourceLabel] = true; 37 | tinyColorRef.value = newColorInstance; 38 | 39 | hueRef.value = newHue; 40 | } 41 | 42 | return { hueRef, updateHueRef }; 43 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ChromePicker } from './components/ChromePicker.vue'; 2 | export { default as CompactPicker } from './components/CompactPicker.vue'; 3 | export { default as GrayscalePicker } from './components/GrayscalePicker.vue'; 4 | export { default as MaterialPicker } from './components/MaterialPicker.vue'; 5 | export { default as PhotoshopPicker } from './components/PhotoshopPicker.vue'; 6 | export { default as SketchPicker } from './components/SketchPicker.vue'; 7 | export { default as SliderPicker } from './components/SliderPicker.vue'; 8 | export { default as SwatchesPicker } from './components/SwatchesPicker.vue'; 9 | export { default as TwitterPicker } from './components/TwitterPicker.vue'; 10 | export { default as HueSlider } from './components/HueSlider.vue'; 11 | 12 | export { default as AlphaSlider } from './components/common/AlphaSlider.vue'; 13 | 14 | export { default as tinycolor } from 'tinycolor2'; -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | import tinycolor from 'tinycolor2'; 2 | 3 | export const isValid = (color: tinycolor.ColorInput) => { 4 | return tinycolor(color).isValid(); 5 | }; 6 | 7 | export const isTransparent = (color: tinycolor.ColorInput) => { 8 | return tinycolor(color).getAlpha() === 0; 9 | } -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | export const getPageXYFromEvent = (e: MouseEvent | TouchEvent) => { 2 | // including scroll offset 3 | const res: {x: number, y: number} = { x: 0, y: 0 }; 4 | if (e instanceof MouseEvent) { 5 | res.x = e.pageX; 6 | res.y = e.pageY; 7 | } 8 | if (typeof TouchEvent !== 'undefined' && e instanceof TouchEvent) { 9 | res.x = (e.touches ? e.touches[0].pageX : e.changedTouches ? e.changedTouches[0].pageX : 0); 10 | res.y = (e.touches ? e.touches[0].pageY : e.changedTouches ? e.changedTouches[0].pageY : 0); 11 | } 12 | return res; 13 | } 14 | 15 | export const getScrollXY = () => { 16 | const x = window.scrollX || window.pageXOffset || document.documentElement.scrollLeft || 0; 17 | const y = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0; 18 | return { x, y } 19 | } 20 | 21 | /** get the position of the container relative to the document’s edge, regardless of any scrolling that has occurred */ 22 | export const getAbsolutePosition = (container: HTMLElement) => { 23 | const {x: scrollX, y: scrollY } = getScrollXY(); 24 | 25 | const rect = container.getBoundingClientRect(); 26 | return { 27 | x: rect.left + scrollX, 28 | y: rect.top + scrollY 29 | } 30 | } 31 | 32 | export const resolveArrowDirection = (e: KeyboardEvent) => { 33 | if (e.code === 'ArrowUp' || e.keyCode === 38) { 34 | return 'up'; 35 | } 36 | if (e.code === 'ArrowDown' || e.keyCode === 40) { 37 | return 'down'; 38 | } 39 | if (e.code === 'ArrowLeft' || e.keyCode === 37) { 40 | return 'left'; 41 | } 42 | if (e.code === 'ArrowRight' || e.keyCode === 39) { 43 | return 'right'; 44 | } 45 | return null; 46 | } -------------------------------------------------------------------------------- /src/utils/math.ts: -------------------------------------------------------------------------------- 1 | export function getFractionDigit(data: number | string) { 2 | const str = data.toString(); 3 | if (str.indexOf('.') !== -1) { 4 | return str.split('.')[1].length; 5 | } 6 | return 0; 7 | } 8 | 9 | export function clamp(value: number, min: number, max: number): number { 10 | return Math.min(Math.max(value, min), max); 11 | } -------------------------------------------------------------------------------- /src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export const throttle = any)>(fn: T, wait = 20) => { 3 | let inThrottle: boolean, 4 | lastFn: ReturnType, 5 | lastTime: number; 6 | return (...args: unknown[]) => { 7 | if (!inThrottle) { 8 | fn(...args); 9 | lastTime = Date.now(); 10 | inThrottle = true; 11 | } else { 12 | clearTimeout(lastFn); 13 | lastFn = setTimeout(() => { 14 | if (Date.now() - lastTime >= wait) { 15 | fn(...args); 16 | lastTime = Date.now(); 17 | } 18 | }, Math.max(wait - (Date.now() - lastTime), 0)); 19 | } 20 | }; 21 | }; -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tests/components/ChromePicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import ChromePicker from '../../src/components/ChromePicker.vue'; 4 | import { waitForRerender } from '../tools'; 5 | 6 | test('props.disableAlpha', async () => { 7 | const { getByRole } = render(ChromePicker, { 8 | props: { 9 | disableAlpha: true 10 | }, 11 | }); 12 | await expect.element(getByRole('textbox', { name: 'Transparency' })).not.toBeInTheDocument(); 13 | await expect.element(getByRole('slider', { name: 'Transparency' })).not.toBeInTheDocument(); 14 | 15 | const picker = getByRole('application', { name: 'Chrome Color Picker' }); 16 | await expect.element(picker).toBeInTheDocument(); 17 | expect(picker.element().querySelector('.vc-checkerboard')).toBeNull(); 18 | }); 19 | 20 | test('props.disableFields', async () => { 21 | const { getByTestId } = render(ChromePicker, { 22 | props: { 23 | disableFields: true 24 | }, 25 | }); 26 | await expect.element(getByTestId('fields')).not.toBeInTheDocument(); 27 | }); 28 | 29 | test('props.formats', async () => { 30 | const { getByTestId, getByRole, rerender } = render(ChromePicker, { 31 | props: { 32 | formats: [] as Array<'rgb' | 'hex' | 'hsl'> 33 | }, 34 | }); 35 | await expect.element(getByTestId('fields')).not.toBeInTheDocument(); 36 | 37 | rerender({ 38 | // @ts-expect-error test wrong format 39 | formats: ['a'] 40 | }); 41 | await waitForRerender(); 42 | await expect.element(getByTestId('fields')).not.toBeInTheDocument(); 43 | 44 | rerender({ 45 | // @ts-expect-error test wrong format 46 | formats: 'a' 47 | }); 48 | await waitForRerender(); 49 | await expect.element(getByTestId('fields')).not.toBeInTheDocument(); 50 | 51 | rerender({ 52 | // @ts-expect-error test wrong format 53 | formats: ['rgb', 'a'] 54 | }); 55 | await waitForRerender(); 56 | expect(getByTestId('fields').element().children.length).toBe(1); 57 | 58 | rerender({ 59 | formats: ['hex', 'rgb'] 60 | }); 61 | await waitForRerender(); 62 | // hex + rgb + btn 63 | expect(getByTestId('fields').element().children.length).toBe(3); 64 | await expect.element(getByRole('textbox', { name: 'Red' })).not.toBeInTheDocument(); 65 | await expect.element(getByRole('textbox', { name: 'Hex' })).toBeVisible(); 66 | 67 | }); 68 | 69 | test('toggle button works fine', async () => { 70 | const { getByRole } = render(ChromePicker, { 71 | props: { 72 | modelValue: 'rgba(133, 115, 68, 0.5)' 73 | } 74 | }); 75 | const bInput = getByRole('textbox', { name: 'Blue' }); 76 | await expect.element(getByRole('textbox', { name: 'Transparency' })).toBeVisible(); 77 | await expect.element(getByRole('textbox', { name: 'Red' })).toBeVisible(); 78 | await expect.element(getByRole('textbox', { name: 'Green' })).toBeVisible(); 79 | await expect.element(bInput).toBeVisible(); 80 | 81 | const btn = getByRole('button', { name: 'Change color format' }); 82 | await btn.click(); 83 | 84 | const hexInput = getByRole('textbox', { name: 'Hex' }); 85 | await expect.element(bInput).not.toBeInTheDocument(); 86 | await expect.element(hexInput).toBeVisible(); 87 | 88 | await btn.click(); 89 | const hueInput = getByRole('textbox', { name: 'Hue' }); 90 | await expect.element(hexInput).not.toBeInTheDocument(); 91 | await expect.element(hueInput).toBeVisible(); 92 | await expect.element(getByRole('textbox', { name: 'Saturation' })).toBeVisible(); 93 | await expect.element(getByRole('textbox', { name: 'Lightness' })).toBeVisible(); 94 | await expect.element(getByRole('textbox', { name: 'Transparency' })).toBeVisible(); 95 | 96 | await btn.click(); 97 | await expect.element(hueInput).not.toBeInTheDocument(); 98 | await expect.element(bInput).toBeVisible(); 99 | }); 100 | 101 | test('change color by rgba inputs', async () => { 102 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 103 | const { getByRole, emitted } = render(ChromePicker, { 104 | props: { 105 | modelValue 106 | } 107 | }); 108 | const rInput = getByRole('textbox', { name: 'Red' }); 109 | const gInput = getByRole('textbox', { name: 'Green' }); 110 | const bInput = getByRole('textbox', { name: 'Blue' }); 111 | const aInput = getByRole('textbox', { name: 'Transparency' }); 112 | 113 | // invalid value: '' 114 | await rInput.fill(''); 115 | expect(emitted()['update:modelValue']).toBeUndefined(); 116 | 117 | // invalid value: string 118 | await rInput.fill('foo'); 119 | expect(emitted()['update:modelValue']).toBeUndefined(); 120 | 121 | // r 122 | await rInput.fill('135'); 123 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 135, g: 140, b: 150, a: 1 }); 124 | 125 | // g 126 | await gInput.fill('145'); 127 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 145, b: 150, a: 1 }); 128 | 129 | // b 130 | await bInput.fill('155'); 131 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 155, a: 1 }); 132 | 133 | // a 134 | await aInput.fill('0.6'); 135 | expect((emitted()['update:modelValue'][3] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 150, a: 0.6 }); 136 | }); 137 | 138 | test('change color by hex inputs', async () => { 139 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 140 | const { getByRole, emitted, rerender } = render(ChromePicker, { 141 | props: { 142 | modelValue 143 | } 144 | }); 145 | // change to hex inputs first 146 | const btn = getByRole('button', { name: 'Change color format' }); 147 | await btn.click(); 148 | 149 | const hexInput = getByRole('textbox', { name: 'Hex' }); 150 | 151 | // invalid value: '' 152 | await hexInput.fill(''); 153 | expect(emitted()['update:modelValue']).toBeUndefined(); 154 | 155 | // invalid value: 'foo' 156 | await hexInput.fill('foo'); 157 | expect(emitted()['update:modelValue']).toBeUndefined(); 158 | 159 | await hexInput.fill('#32a852'); 160 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 }); 161 | 162 | rerender({ 163 | modelValue: { 164 | ...modelValue, 165 | a: 0.5 166 | } 167 | }); 168 | await hexInput.fill('#32a85299'); 169 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 0.6 }); 170 | }); 171 | 172 | test('change color by hsla inputs', async () => { 173 | const modelValue = { h: 130, s: 0.5, l: 0.5, a: 0.5 }; 174 | const { getByRole, emitted } = render(ChromePicker, { 175 | props: { 176 | modelValue 177 | } 178 | }); 179 | // change to hsla inputs first 180 | const btn = getByRole('button', { name: 'Change color format' }); 181 | await btn.click(); 182 | await btn.click(); 183 | 184 | const hInput = getByRole('textbox', { name: 'Hue' }); 185 | const sInput = getByRole('textbox', { name: 'Saturation' }); 186 | const lInput = getByRole('textbox', { name: 'Lightness' }); 187 | const aInput = getByRole('textbox', { name: 'Transparency' }); 188 | 189 | // invalid value: '' 190 | await hInput.fill(''); 191 | expect(emitted()['update:modelValue']).toBeUndefined(); 192 | 193 | await hInput.fill('200'); 194 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0].h).toBeCloseTo(200); 195 | 196 | await sInput.fill('60'); 197 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0].s).toBeCloseTo(0.6); 198 | 199 | await lInput.fill('70'); 200 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0].l).toBeCloseTo(0.7); 201 | 202 | await aInput.fill('0.8'); 203 | expect((emitted()['update:modelValue'][3] as [typeof modelValue])[0].a).toBeCloseTo(0.8); 204 | }); -------------------------------------------------------------------------------- /tests/components/CompactPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import CompactPicker from '../../src/components/CompactPicker.vue'; 4 | 5 | test('render with different palette', async () => { 6 | const { getByRole } = render(CompactPicker, { 7 | props: { 8 | palette: ['#a83292', '#a8ff92', '#263d1e'], 9 | tinyColor: '#a83292' 10 | } 11 | }); 12 | const paletteListElE = getByRole('listbox').element(); 13 | expect(paletteListElE.childElementCount).toBe(3); 14 | 15 | await expect.element(getByRole('option').nth(0)).toHaveAttribute('aria-selected', "true"); 16 | await expect.element(getByRole('option').nth(1)).toHaveAttribute('aria-selected', "false"); 17 | await expect.element(getByRole('option').nth(2)).toHaveAttribute('aria-selected', "false"); 18 | }); 19 | 20 | test('click one of the color in the palette', async () => { 21 | const { getByRole, emitted } = render(CompactPicker, { 22 | props: { 23 | modelValue: '#a83292' 24 | } 25 | }); 26 | const options = getByRole('option'); 27 | await options.nth(3).click(); 28 | 29 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#F44E3B'.toLowerCase()); 30 | 31 | // press Space bar 32 | options.nth(4).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 33 | expect((emitted()['update:modelValue'][1] as [string])[0]).toBe('#FE9200'.toLowerCase()); 34 | }); 35 | 36 | describe('The output value should follow the same format as the input value', async () => { 37 | const cases = [ 38 | { 39 | format: 'hex8', 40 | input: '#ffffff00', 41 | expectFunc: (toBeChecked: string) => { 42 | expect(toBeChecked).toBe('#68cccaff'); 43 | } 44 | }, 45 | { 46 | format: 'hex', 47 | input: '#ffffff', 48 | expectFunc: (toBeChecked: string) => { 49 | expect(toBeChecked).toBe('#68ccca'); 50 | } 51 | }, 52 | { 53 | format: 'prgb', 54 | input: { r: '50%', g: '50%', b: '50%' }, 55 | expectFunc: (toBeChecked: { r: string, g: string, b: string}) => { 56 | expect(Number(toBeChecked.r.replace('%', ''))).toBeCloseTo(41); 57 | expect(Number(toBeChecked.g.replace('%', ''))).toBeCloseTo(80); 58 | expect(Number(toBeChecked.b.replace('%', ''))).toBeCloseTo(79); 59 | }, 60 | }, 61 | { 62 | format: 'prgb(string)', 63 | input: 'rgb(1%, 1%, 1%)', 64 | expectFunc: (toBeChecked: string) => { 65 | expect(toBeChecked).toBe('rgb(41%, 80%, 79%)'); 66 | } 67 | }, 68 | { 69 | format: 'rgb', 70 | input: { r: 10, g: 10, b: 10 }, 71 | expectFunc: (toBeChecked: { r: number, g: number, b: number}) => { 72 | expect(toBeChecked.r).toBeCloseTo(104); 73 | expect(toBeChecked.g).toBeCloseTo(204); 74 | expect(toBeChecked.b).toBeCloseTo(202); 75 | }, 76 | }, 77 | { 78 | format: 'rgb(string)', 79 | input: 'rgb(1, 1, 1)', 80 | expectFunc: (toBeChecked: string) => { 81 | expect(toBeChecked).toBe('rgb(104, 204, 202)'); 82 | }, 83 | }, 84 | { 85 | format: 'hsv', 86 | input: { h: 0, s: 0, v: 0 }, 87 | expectFunc: (toBeChecked: { h: number, s: number, v: number}) => { 88 | expect(toBeChecked.h).toBeCloseTo(179, 0); 89 | expect(toBeChecked.s).toBeCloseTo(0.49); 90 | expect(toBeChecked.v).toBeCloseTo(0.8); 91 | }, 92 | }, 93 | { 94 | format: 'hsl', 95 | input: { h: 0, s: 0, l: 0 }, 96 | expectFunc: (toBeChecked: { h: number, s: number, l: number}) => { 97 | expect(toBeChecked.h).toBeCloseTo(179, 0); 98 | expect(toBeChecked.s).toBeCloseTo(0.5); 99 | expect(toBeChecked.l).toBeCloseTo(0.6); 100 | }, 101 | }, 102 | { 103 | format: 'hsv(string)', 104 | input: 'hsva(1, 1%, 1%, 1)', 105 | expectFunc: (toBeChecked: string) => { 106 | expect(toBeChecked).toBe('hsv(179, 49%, 80%)'); 107 | }, 108 | }, 109 | { 110 | format: 'hsl(string)', 111 | input: 'hsl(1, 1%, 1%)', 112 | expectFunc: (toBeChecked: string) => { 113 | expect(toBeChecked).toBe('hsl(179, 50%, 60%)'); 114 | }, 115 | }, 116 | ]; 117 | test.each(cases)('$format', async ({ input, expectFunc }) => { 118 | const { getByRole, emitted } = render(CompactPicker, { 119 | props: { 120 | modelValue: input 121 | } 122 | }); 123 | const presetColors = getByRole('option'); 124 | await presetColors.nth(8).click(); 125 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 126 | expectFunc((emitted()['update:modelValue'][0] as [string])[0] as any); 127 | }) 128 | }); -------------------------------------------------------------------------------- /tests/components/GrayscalePicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import GrayscalePicker from '../../src/components/GrayscalePicker.vue'; 4 | 5 | test('render with different palette', async () => { 6 | const { getByRole } = render(GrayscalePicker, { 7 | props: { 8 | palette: ['#E6E6E6', '#8C8C8C', '#333333'], 9 | tinyColor: '#E6E6E6' 10 | } 11 | }); 12 | const paletteListElE = getByRole('listbox').element(); 13 | expect(paletteListElE.childElementCount).toBe(3); 14 | 15 | await expect.element(getByRole('option').nth(0)).toHaveAttribute('aria-selected', "true"); 16 | await expect.element(getByRole('option').nth(1)).toHaveAttribute('aria-selected', "false"); 17 | await expect.element(getByRole('option').nth(2)).toHaveAttribute('aria-selected', "false"); 18 | }); 19 | 20 | test('click one of the color in the palette', async () => { 21 | const { getByRole, emitted } = render(GrayscalePicker, { 22 | props: { 23 | modelValue: '#E6E6E6' 24 | } 25 | }); 26 | const options = getByRole('option'); 27 | await options.nth(3).click(); 28 | 29 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#D9D9D9'.toLowerCase()); 30 | 31 | // press Space bar 32 | options.nth(4).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 33 | expect((emitted()['update:modelValue'][1] as [string])[0]).toBe('#CCCCCC'.toLowerCase()); 34 | }); -------------------------------------------------------------------------------- /tests/components/HueSlider.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import Hue from '../../src/components/HueSlider.vue'; 4 | 5 | test('The background color of the picker', async () => { 6 | const { getByRole } = render(Hue, { 7 | props: { 8 | modelValue: 66, 9 | direction: 'horizontal', 10 | }, 11 | }); 12 | 13 | const sliderElement = getByRole('slider').element() as HTMLElement; 14 | const pickerElement = sliderElement.querySelectorAll('div')?.[1]; 15 | 16 | const bgColor = window.getComputedStyle(pickerElement, null).getPropertyValue('background-color'); 17 | 18 | expect(bgColor).toBe('rgb(230, 255, 0)'); 19 | }); -------------------------------------------------------------------------------- /tests/components/MaterialPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import MaterialPicker from '../../src/components/MaterialPicker.vue'; 4 | 5 | test('render correctly', async () => { 6 | const { getByLabelText } = render(MaterialPicker, { 7 | props: { 8 | modelValue: { 9 | r: 100, 10 | g: 101, 11 | b: 102 12 | } 13 | } 14 | }); 15 | 16 | const rInput = getByLabelText('Red'); 17 | await expect.element(rInput).toHaveValue('100'); 18 | 19 | const gInput = getByLabelText('Green'); 20 | await expect.element(gInput).toHaveValue('101'); 21 | 22 | const bInput = getByLabelText('Blue'); 23 | await expect.element(bInput).toHaveValue('102'); 24 | 25 | const hexInput = getByLabelText('Hex'); 26 | await expect.element(hexInput).toHaveValue('#646566'); 27 | }); 28 | 29 | test('change hex value and update color events should be emitted with correct value', async () => { 30 | const { getByLabelText, emitted } = render(MaterialPicker, { 31 | props: { 32 | modelValue: { 33 | r: 100, 34 | g: 101, 35 | b: 102 36 | } 37 | } 38 | }); 39 | const hexInput = getByLabelText('Hex'); 40 | 41 | // invalid value 42 | await hexInput.fill('foo'); 43 | expect(emitted()[0]).toBeUndefined(); 44 | 45 | await hexInput.fill('#49b3b1'); 46 | expect((emitted()['update:modelValue'][0] as [{r: number, b: number, g: number}])[0]).toStrictEqual({r: 73, g: 179, b: 177, a: 1}); 47 | }); 48 | 49 | test('change RGB value and update color events should be emitted with correct value', async () => { 50 | const { getByLabelText, emitted } = render(MaterialPicker, { 51 | props: { 52 | modelValue: { 53 | r: 100, 54 | g: 101, 55 | b: 102 56 | } 57 | } 58 | }); 59 | 60 | const rInput = getByLabelText('Red'); 61 | await rInput.fill('200'); 62 | expect((emitted()['update:modelValue'][0] as [{r: number, b: number, g: number}])[0]).toStrictEqual({r: 200, g: 101, b: 102, a: 1}); 63 | 64 | const gInput = getByLabelText('Green'); 65 | await gInput.fill('200'); 66 | expect((emitted()['update:modelValue'][1] as [{r: number, b: number, g: number}])[0]).toStrictEqual({r: 100, g: 200, b: 102, a: 1}); 67 | 68 | const bInput = getByLabelText('Blue'); 69 | await bInput.fill('200'); 70 | expect((emitted()['update:modelValue'][2] as [{r: number, b: number, g: number}])[0]).toStrictEqual({r: 100, g: 101, b: 200, a: 1}); 71 | }); -------------------------------------------------------------------------------- /tests/components/PhotoshopPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import PhotoshopPicker from '../../src/components/PhotoshopPicker.vue'; 4 | 5 | test('render correctly (props.disableFields = true)', async () => { 6 | const { getByRole } = render(PhotoshopPicker, { 7 | props: { 8 | disableFields: true 9 | } 10 | }) 11 | expect(getByRole('textbox').elements()).toEqual([]); 12 | }); 13 | 14 | test('render correctly (props.hasResetButton = true)', async () => { 15 | const { getByRole } = render(PhotoshopPicker, { 16 | props: { 17 | hasResetButton: true 18 | } 19 | }) 20 | await expect.element(getByRole('button', { name: 'reset' })).toBeInTheDocument(); 21 | }); 22 | 23 | test('render correctly with givin color', async () => { 24 | const { getByLabelText, getByRole } = render(PhotoshopPicker, { 25 | props: { 26 | tinyColor: '#536da3', 27 | initialColor: '#000' 28 | } 29 | }); 30 | 31 | await expect.element(getByRole('textbox', { name: 'Hue' })).toHaveValue('220'); 32 | await expect.element(getByRole('textbox', { name: 'Saturation' })).toHaveValue('49'); 33 | await expect.element(getByLabelText('Value')).toHaveValue('64'); 34 | await expect.element(getByLabelText('Red')).toHaveValue('83'); 35 | await expect.element(getByLabelText('Green')).toHaveValue('109'); 36 | await expect.element(getByLabelText('Blue')).toHaveValue('163'); 37 | await expect.element(getByLabelText('Hex')).toHaveValue('536da3'); 38 | 39 | expect(getByLabelText('New color is').element().getAttribute('style')).toBe('background: rgb(83, 109, 163);'); 40 | expect(getByRole('button', { name: 'Current color is' }).element().getAttribute('style')).toBe('background: rgb(255, 255, 255);'); 41 | }); 42 | 43 | test('change back to current color', async () => { 44 | const { getByRole, emitted } = render(PhotoshopPicker, { 45 | props: { 46 | modelValue: '#536da3', 47 | currentColor: '#333' 48 | } 49 | }); 50 | const btn = getByRole('button', { name: 'Current color is' }); 51 | await btn.click(); 52 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#333333'); 53 | }); 54 | 55 | test('buttons work fine', async () => { 56 | const { getByLabelText, emitted } = render(PhotoshopPicker, { 57 | props: { 58 | hasResetButton: true 59 | } 60 | }); 61 | 62 | const okBtn = getByLabelText('Click to apply'); 63 | await okBtn.click(); 64 | expect(emitted()['ok'][0]).not.toBeUndefined(); 65 | 66 | const cancelBtn = getByLabelText('Cancel'); 67 | await cancelBtn.click(); 68 | expect(emitted()['cancel'][0]).not.toBeUndefined(); 69 | 70 | const resetBtn = getByLabelText('Reset'); 71 | await resetBtn.click(); 72 | expect(emitted()['reset'][0]).not.toBeUndefined(); 73 | }); 74 | 75 | test('change color by rgba inputs', async () => { 76 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 77 | const { getByRole, emitted } = render(PhotoshopPicker, { 78 | props: { 79 | modelValue 80 | } 81 | }); 82 | const rInput = getByRole('textbox', { name: 'Red' }); 83 | const gInput = getByRole('textbox', { name: 'Green' }); 84 | const bInput = getByRole('textbox', { name: 'Blue' }); 85 | 86 | // invalid value: '' 87 | await rInput.fill(''); 88 | expect(emitted()['update:modelValue']).toBeUndefined(); 89 | 90 | // invalid value: string 91 | await rInput.fill('foo'); 92 | expect(emitted()['update:modelValue']).toBeUndefined(); 93 | 94 | // r 95 | await rInput.fill('135'); 96 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 135, g: 140, b: 150, a: 1 }); 97 | 98 | // g 99 | await gInput.fill('145'); 100 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 145, b: 150, a: 1 }); 101 | 102 | // b 103 | await bInput.fill('155'); 104 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 155, a: 1 }); 105 | }); 106 | 107 | test('change color by hex inputs', async () => { 108 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 109 | const { getByRole, emitted } = render(PhotoshopPicker, { 110 | props: { 111 | modelValue 112 | } 113 | }); 114 | 115 | const hexInput = getByRole('textbox', { name: 'Hex' }); 116 | 117 | // invalid value: '' 118 | await hexInput.fill(''); 119 | expect(emitted()['update:modelValue']).toBeUndefined(); 120 | 121 | // invalid value: 'foo' 122 | await hexInput.fill('foo'); 123 | expect(emitted()['update:modelValue']).toBeUndefined(); 124 | 125 | await hexInput.fill('#32a852'); 126 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 }); 127 | }); 128 | 129 | test('change color by hsv inputs', async () => { 130 | const modelValue = { h: 130, s: 0.5, v: 0.5, a: 0.5 }; 131 | const { getByRole, emitted } = render(PhotoshopPicker, { 132 | props: { 133 | modelValue 134 | } 135 | }); 136 | 137 | const hInput = getByRole('textbox', { name: 'Hue' }); 138 | const sInput = getByRole('textbox', { name: 'Saturation' }); 139 | const vInput = getByRole('textbox', { name: 'Value' }); 140 | 141 | // invalid value: '' 142 | await hInput.fill(''); 143 | expect(emitted()['update:modelValue']).toBeUndefined(); 144 | 145 | // invalid value: 'foo' 146 | await hInput.fill('foo'); 147 | expect(emitted()['update:modelValue']).toBeUndefined(); 148 | 149 | await hInput.fill('200'); 150 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0].h).toBeCloseTo(200); 151 | 152 | await sInput.fill('60'); 153 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0].s).toBeCloseTo(0.6); 154 | 155 | await vInput.fill('70'); 156 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0].v).toBeCloseTo(0.7); 157 | }); -------------------------------------------------------------------------------- /tests/components/SketchPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import SketchPicker from '../../src/components/SketchPicker.vue'; 4 | import { waitForRerender } from '../tools'; 5 | 6 | test('render with `props.disableAlpha = true`', async () => { 7 | const { getByRole } = render(SketchPicker, { 8 | props: { 9 | disableAlpha: true 10 | } 11 | }); 12 | await expect.element(getByRole('textbox', { name: 'Transparency' })).not.toBeInTheDocument(); 13 | await expect.element(getByRole('slider', { name: 'Transparency' })).not.toBeInTheDocument(); 14 | }); 15 | 16 | test('render with `props.disableFields = true`', async () => { 17 | const { getByRole } = render(SketchPicker, { 18 | props: { 19 | disableFields: true 20 | } 21 | }); 22 | await expect.element(getByRole('textbox', { name: 'Hex' })).not.toBeInTheDocument(); 23 | await expect.element(getByRole('textbox', { name: 'Red' })).not.toBeInTheDocument(); 24 | await expect.element(getByRole('textbox', { name: 'Green' })).not.toBeInTheDocument(); 25 | await expect.element(getByRole('textbox', { name: 'Transparency' })).not.toBeInTheDocument(); 26 | }); 27 | 28 | test('render correctly with certain input', async () => { 29 | const { getByRole, getByLabelText, rerender } = render(SketchPicker, { 30 | props: { 31 | modelValue: { r: 64, g: 64, b: 191, a: 1 } 32 | } 33 | }); 34 | expect(getByRole('textbox', { name: 'Hex' }).element()).toHaveValue('4040bf'); 35 | expect(getByRole('textbox', { name: 'Red' }).element()).toHaveValue('64'); 36 | expect(getByRole('textbox', { name: 'Green' }).element()).toHaveValue('64'); 37 | expect(getByRole('textbox', { name: 'Blue' }).element()).toHaveValue('191'); 38 | expect(getByRole('textbox', { name: 'Transparency' }).element()).toHaveValue('1'); 39 | 40 | expect(getByLabelText('Current color is').element().getAttribute('style')).toBe('background: rgb(64, 64, 191);'); 41 | 42 | rerender({ 43 | modelValue: { r: 66, g: 245, b: 176, a: 0.5 } 44 | }); 45 | await waitForRerender(); 46 | expect(getByRole('textbox', { name: 'Hex' }).element()).toHaveValue('42f5b080'); 47 | }); 48 | 49 | test('change color by rgb inputs', async () => { 50 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 51 | const { getByRole, emitted } = render(SketchPicker, { 52 | props: { 53 | modelValue 54 | } 55 | }); 56 | const rInput = getByRole('textbox', { name: 'Red' }); 57 | const gInput = getByRole('textbox', { name: 'Green' }); 58 | const bInput = getByRole('textbox', { name: 'Blue' }); 59 | 60 | // invalid value: '' 61 | await rInput.fill(''); 62 | expect(emitted()['update:modelValue']).toBeUndefined(); 63 | 64 | // invalid value: string 65 | await rInput.fill('foo'); 66 | expect(emitted()['update:modelValue']).toBeUndefined(); 67 | 68 | // r 69 | await rInput.fill('135'); 70 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 135, g: 140, b: 150, a: 1 }); 71 | 72 | // g 73 | await gInput.fill('145'); 74 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 145, b: 150, a: 1 }); 75 | 76 | // b 77 | await bInput.fill('155'); 78 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 155, a: 1 }); 79 | }); 80 | 81 | test('change color by hex inputs', async () => { 82 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 83 | const { getByRole, emitted } = render(SketchPicker, { 84 | props: { 85 | modelValue 86 | } 87 | }); 88 | 89 | const hexInput = getByRole('textbox', { name: 'Hex' }); 90 | 91 | // invalid value: '' 92 | await hexInput.fill(''); 93 | expect(emitted()['update:modelValue']).toBeUndefined(); 94 | 95 | // invalid value: 'foo' 96 | await hexInput.fill('foo'); 97 | expect(emitted()['update:modelValue']).toBeUndefined(); 98 | 99 | await hexInput.fill('#32a852'); 100 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 }); 101 | }); 102 | 103 | test('change color by alpha input', async () => { 104 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 105 | const { getByRole, emitted } = render(SketchPicker, { 106 | props: { 107 | modelValue 108 | } 109 | }); 110 | const alphaInput = getByRole('textbox', { name: 'Transparency' }); 111 | 112 | // invalid value: '' 113 | await alphaInput.fill(''); 114 | expect(emitted()['update:modelValue']).toBeUndefined(); 115 | 116 | // invalid value: string 117 | await alphaInput.fill('foo'); 118 | expect(emitted()['update:modelValue']).toBeUndefined(); 119 | 120 | await alphaInput.fill('0.3'); 121 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 150, a: 0.3 }); 122 | }); 123 | 124 | test('change color by clicking preset color', async () => { 125 | const { getByRole, emitted } = render(SketchPicker, { 126 | props: { 127 | modelValue: '#fff' 128 | } 129 | }); 130 | 131 | const presetColors = getByRole('option'); 132 | await presetColors.nth(5).click(); 133 | 134 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#417505'); 135 | 136 | await presetColors.last().click(); 137 | expect((emitted()['update:modelValue'][1] as [string])[0]).toBe('#000000'); 138 | 139 | presetColors.nth(10).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 140 | expect((emitted()['update:modelValue'][2] as [string])[0]).toBe('#b8e986'); 141 | 142 | presetColors.last().element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 143 | expect((emitted()['update:modelValue'][3] as [string])[0]).toBe('#000000'); 144 | }); -------------------------------------------------------------------------------- /tests/components/SliderPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import SliderPicker from '../../src/components/SliderPicker.vue'; 4 | import { waitForRerender } from '../tools'; 5 | 6 | test('render with various swatches and props.alpha', async () => { 7 | const { getByRole, rerender } = render(SliderPicker, { 8 | props: { 9 | swatches: ['0.1', '0.4', '0.7'] 10 | } as { swatches?: ({ s: number, l: number} | string)[], alpha?: boolean } 11 | }); 12 | expect(getByRole('option').elements().length).toBe(3); 13 | await expect.element(getByRole('slider', { name: 'Transparency' })).not.toBeInTheDocument(); 14 | 15 | rerender({ 16 | swatches: [{ s: 0.1, l: 0.2 }, { s: 0.1, l: 0.4 }, { s: 0.1, l: 0.6 }, { s: 0.1, l: 0.8 }] 17 | }); 18 | await waitForRerender(); 19 | 20 | expect(getByRole('option').elements().length).toBe(4); 21 | 22 | rerender({ 23 | swatches: [] 24 | }); 25 | await waitForRerender(); 26 | await expect.element(getByRole('listbox')).not.toBeInTheDocument(); 27 | 28 | rerender({ 29 | alpha: true 30 | }); 31 | await waitForRerender(); 32 | await expect.element(getByRole('slider', { name: 'Transparency' })).toBeInTheDocument(); 33 | }); 34 | 35 | test('render with certain inputs', async () => { 36 | const { getByRole, rerender } = render(SliderPicker, { 37 | props: { 38 | modelValue: { s: 0.5005, l: 0.8005, h: 100 } 39 | } 40 | }); 41 | await expect.element(getByRole('option').nth(0)).toHaveAttribute('aria-selected', 'true'); 42 | 43 | rerender({ 44 | modelValue: { s: 0.5005, l: 1, h: 100 }, 45 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 46 | // @ts-expect-error 47 | swatches: ['0.5', '1'] 48 | }); 49 | await waitForRerender(); 50 | await expect.element(getByRole('option').nth(1)).toHaveAttribute('aria-selected', 'true'); 51 | 52 | rerender({ 53 | modelValue: { s: 0.5005, l: 0, h: 100 }, 54 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 55 | // @ts-expect-error 56 | swatches: ['0.5', '1', '0'] 57 | }); 58 | await waitForRerender(); 59 | await expect.element(getByRole('option').nth(2)).toHaveAttribute('aria-selected', 'true'); 60 | }); 61 | 62 | test('click the swatches and emit event is fired with correct color', async () => { 63 | const { getByRole, emitted } = render(SliderPicker, { 64 | props: { 65 | modelValue: { 66 | h: 120, 67 | s: 0.1, 68 | l: 0.1 69 | } 70 | } 71 | }); 72 | 73 | const swatches = getByRole('option'); 74 | await swatches.nth(1).click(); 75 | 76 | const emittedColor1 = (emitted()['update:modelValue'][0] as [{ h: number, s: number, l: number }])[0]; 77 | expect(emittedColor1.h).toBeCloseTo(120); 78 | expect(emittedColor1.s).toBeCloseTo(0.5); 79 | expect(emittedColor1.l).toBeCloseTo(0.65); 80 | 81 | swatches.nth(3).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 82 | 83 | const emittedColor2 = (emitted()['update:modelValue'][1] as [{ h: number, s: number, l: number }])[0]; 84 | expect(emittedColor2.h).toBeCloseTo(120); 85 | expect(emittedColor2.s).toBeCloseTo(0.5); 86 | expect(emittedColor2.l).toBeCloseTo(0.35); 87 | }); -------------------------------------------------------------------------------- /tests/components/SwatchesPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import SwatchesPicker from '../../src/components/SwatchesPicker.vue'; 4 | 5 | test('render with other palette', async () => { 6 | const { getByRole } = render(SwatchesPicker, { 7 | props: { 8 | palette: [['#4f64f2', '#927c91', '#d05e66'], 9 | ['#849520', '#8f02cc', '#9c6f3d'], 10 | ['#6aaefe', '#cd5a75', '#9b0d6b']], 11 | tinyColor: '#cd5a75' 12 | } 13 | }); 14 | 15 | expect(getByRole('option').elements().length).toBe(9); 16 | await expect.element(getByRole('option').nth(7)).toHaveAttribute('aria-selected', 'true'); 17 | }); 18 | 19 | test('click the swatches and emit event is fired with correct color', async () => { 20 | const { getByRole, emitted } = render(SwatchesPicker, { 21 | props: { 22 | modelValue: '#fff' 23 | } 24 | }); 25 | 26 | const swatches = getByRole('option'); 27 | await swatches.nth(3).click(); 28 | 29 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#e57373'); 30 | 31 | swatches.nth(6).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 32 | 33 | expect((emitted()['update:modelValue'][1] as [string])[0]).toBe('#c2185b'); 34 | }); -------------------------------------------------------------------------------- /tests/components/TwitterPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import TwitterPicker from '../../src/components/TwitterPicker.vue'; 4 | 5 | test('render correctly', async () => { 6 | const { getByRole } = render(TwitterPicker, { 7 | props: { 8 | tinyColor: '#7BDCB5' 9 | } 10 | }); 11 | await expect.element(getByRole('option').nth(2)).toHaveAttribute('aria-selected', 'true'); 12 | }); 13 | 14 | test('change color by hex input', async () => { 15 | const modelValue = { r: 130, g: 140, b: 150, a: 1 }; 16 | const { getByRole, emitted } = render(TwitterPicker, { 17 | props: { 18 | modelValue 19 | } 20 | }); 21 | 22 | const hexInput = getByRole('textbox', { name: 'Hex' }); 23 | 24 | await expect.element(hexInput).toHaveValue('828c96'); 25 | 26 | // invalid value: '' 27 | await hexInput.fill(''); 28 | expect(emitted()['update:modelValue']).toBeUndefined(); 29 | 30 | // invalid value: 'foo' 31 | await hexInput.fill('foo'); 32 | expect(emitted()['update:modelValue']).toBeUndefined(); 33 | 34 | await hexInput.fill('#32a852'); 35 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 }); 36 | }); 37 | 38 | test('click the swatches and emit event is fired with correct color', async () => { 39 | const { getByRole, emitted } = render(TwitterPicker, { 40 | props: { 41 | modelValue: '#fff' 42 | } 43 | }); 44 | 45 | const swatches = getByRole('option'); 46 | await swatches.nth(6).click(); 47 | 48 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#abb8c3'); 49 | 50 | swatches.nth(9).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); 51 | 52 | expect((emitted()['update:modelValue'][1] as [string])[0]).toBe('#9900ef'); 53 | }); -------------------------------------------------------------------------------- /tests/components/common/AlphaSlider.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import Alpha from '../../../src/components/common/AlphaSlider.vue'; 4 | 5 | test('The position of the picker should be correct when rendered with a color with alpha value.', async () => { 6 | const { getByRole } = render(Alpha, { 7 | props: { 8 | modelValue: { r: 100, g: 100, b: 100, a: 0.3 } 9 | }, 10 | }); 11 | const slider = getByRole('slider'); 12 | const picker = slider.element().querySelector('div'); 13 | const left = picker?.style.left; 14 | expect(left).toBe('30%'); 15 | }); 16 | 17 | test('Click the pointer and update color events should be emitted with correct alpha value', async () => { 18 | 19 | const container = document.createElement('div'); 20 | document.body.appendChild(container); 21 | container.style.width = '100px'; 22 | container.style.height = '10px'; 23 | 24 | const { getByRole, emitted } = render(Alpha, { 25 | props: { 26 | modelValue: { r: 100, g: 100, b: 100, a: 0.3 } 27 | }, 28 | container 29 | }); 30 | const slider = getByRole('slider'); 31 | const box = (slider.element() as HTMLElement).getBoundingClientRect(); 32 | 33 | // click the middle of the slider 34 | const mouseEvent1 = new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + 5 }); 35 | slider.element().dispatchEvent(mouseEvent1); 36 | 37 | expect(emitted()).toHaveProperty('update:modelValue'); 38 | expect(emitted()['update:modelValue'][0]).toEqual([{ r: 100, g: 100, b: 100, a: 0.5 }]); 39 | 40 | // click the left outer space of the slider 41 | const mouseEvent2 = new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: box.top + 5 }); 42 | slider.element().dispatchEvent(mouseEvent2); 43 | 44 | expect(emitted()).toHaveProperty('update:modelValue'); 45 | expect(emitted()['update:modelValue'][1]).toEqual([{ r: 100, g: 100, b: 100, a: 0 }]); 46 | 47 | // click the right outer space of the slider 48 | const mouseEvent3 = new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width + 10, clientY: box.top + 5 }); 49 | slider.element().dispatchEvent(mouseEvent3); 50 | 51 | expect(emitted()).toHaveProperty('update:modelValue'); 52 | expect(emitted()['update:modelValue'][2]).toEqual([{ r: 100, g: 100, b: 100, a: 1 }]); 53 | }); 54 | 55 | test('When up and down keyboard events are fired then update color events should be emitted with correct alpha value', async () => { 56 | 57 | const { getByRole, emitted, rerender } = render(Alpha, { 58 | props: { 59 | modelValue: { r: 100, g: 100, b: 100, a: 0.2 } 60 | } 61 | }); 62 | 63 | const slider = getByRole('slider').element(); 64 | const keyboardEvent1 = new KeyboardEvent('keydown', { code: 'ArrowLeft' }); 65 | slider.dispatchEvent(keyboardEvent1); 66 | 67 | expect(emitted()).toHaveProperty('update:modelValue'); 68 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 69 | // @ts-expect-error 70 | expect(emitted()['update:modelValue'][0]?.[0]?.a).toBeCloseTo(0.1, 0); 71 | 72 | await rerender({modelValue : { r: 100, g: 100, b: 100, a: 0 }}); 73 | const keyboardEvent2 = new KeyboardEvent('keydown', { code: 'ArrowLeft' }); 74 | slider.dispatchEvent(keyboardEvent2); 75 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 76 | // @ts-expect-error 77 | expect(emitted()['update:modelValue'][1]?.[0]?.a).toBe(0); 78 | 79 | 80 | const keyboardEvent3 = new KeyboardEvent('keydown', { code: 'ArrowRight' }); 81 | slider.dispatchEvent(keyboardEvent3); 82 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 83 | // @ts-expect-error 84 | expect(emitted()['update:modelValue'][2]?.[0]?.a).toBe(0.1); 85 | 86 | await rerender({modelValue : { r: 100, g: 100, b: 100, a: 1 }}); 87 | const keyboardEvent4 = new KeyboardEvent('keydown', { code: 'ArrowRight' }); 88 | slider.dispatchEvent(keyboardEvent4); 89 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 90 | // @ts-expect-error 91 | expect(emitted()['update:modelValue'][3]?.[0]?.a).toBe(1); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/components/common/CheckerboardBG.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import Checkerboard from '../../../src/components/common/CheckerboardBG.vue'; 4 | 5 | test('render correctly by default', async () => { 6 | const { container } = render(Checkerboard) 7 | 8 | expect(container).toMatchInlineSnapshot(` 9 |
10 |
15 |
16 | `); 17 | }); 18 | 19 | test('render correctly by with props', async () => { 20 | const { container } = render(Checkerboard, { 21 | props: { size: 100, grey: '#333', white: '#ddd' } 22 | }) 23 | 24 | expect(container).toMatchInlineSnapshot(` 25 |
26 |
31 |
32 | `); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/components/common/EditableInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import EditableInput from '../../../src/components/common/EditableInput.vue'; 4 | 5 | test('Render correctly with value, label and desc', async () => { 6 | const { getByText, getByRole } = render(EditableInput, { 7 | props: { 8 | value: 'value', 9 | label: 'label', 10 | desc: 'desc', 11 | }, 12 | }); 13 | 14 | expect(getByText('label')).not.toBeNull(); 15 | expect(getByText('desc')).not.toBeNull(); 16 | await expect.element(getByRole('textbox')).toHaveValue('value'); 17 | }); 18 | 19 | test('render correct aria-label with props.label', async () => { 20 | const { getByRole } = render(EditableInput, { 21 | props: { 22 | value: 'value', 23 | label: 'foo' 24 | }, 25 | }); 26 | const textbox = getByRole('textbox'); 27 | await expect.element(textbox).toHaveAccessibleName('foo'); 28 | await expect.element(textbox).toHaveAttribute('id', expect.stringContaining('input__label__foo__')); 29 | }); 30 | 31 | test('render correct aria-label with props.a11y.label', async () => { 32 | const { getByRole } = render(EditableInput, { 33 | props: { 34 | value: 'value', 35 | label: 'foo', 36 | a11y: { 37 | label: 'bar' 38 | } 39 | }, 40 | }); 41 | const textbox = getByRole('textbox'); 42 | await expect.element(textbox).toHaveAccessibleName('bar'); 43 | await expect.element(textbox).toHaveAttribute('id', expect.stringContaining('input__label__bar__')); 44 | }); 45 | 46 | test('Change the value and emit the event', async () => { 47 | const { getByRole, emitted } = render(EditableInput, { 48 | props: { 49 | value: 'value', 50 | label: 'label', 51 | }, 52 | }); 53 | const textbox = getByRole('textbox'); 54 | await textbox.fill('changed value'); 55 | await expect.element(textbox).toHaveValue('changed value'); 56 | expect(emitted()['change'][0]).toEqual(['changed value']); 57 | }); 58 | 59 | test('Input value should be within max and min range', async () => { 60 | const max = 10; 61 | const min = -10; 62 | const { getByRole, emitted } = render(EditableInput, { 63 | props: { 64 | value: 1, 65 | label: 'label', 66 | max, 67 | min, 68 | }, 69 | }); 70 | const textbox = getByRole('textbox'); 71 | await textbox.fill(`${max + 1}`); 72 | expect(emitted()['change'][0]).toEqual([max]); 73 | await textbox.fill(`${min - 1}`); 74 | expect(emitted()['change'][1]).toEqual([min]); 75 | }); 76 | 77 | test('Handle the key down event with step', async () => { 78 | const step = 2; 79 | const initialValue = 1; 80 | const { getByRole, emitted, rerender } = render(EditableInput, { 81 | props: { 82 | value: initialValue, 83 | label: 'label', 84 | step, 85 | }, 86 | }); 87 | const textbox = getByRole('textbox'); 88 | 89 | textbox.element().dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowUp' })); 90 | expect(emitted()['change'][0]).toEqual([`${initialValue + step}`]); 91 | 92 | textbox.element().dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' })); 93 | expect(emitted()['change'][1]).toEqual([`${initialValue - step}`]); 94 | 95 | rerender({ step: 2.2, value: 1.111 }); 96 | await Promise.resolve(); 97 | textbox.element().dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowUp' })); 98 | expect(emitted()['change'][2]).toEqual(['3.3']); 99 | }); -------------------------------------------------------------------------------- /tests/components/common/HueSlider.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import Hue from '../../../src/components/common/HueSlider.vue'; 4 | import { waitForRerender } from '../../tools'; 5 | 6 | test('The position of the picker should be correct', async () => { 7 | const { getByRole, rerender } = render(Hue, { 8 | props: { 9 | modelValue: 180, 10 | direction: 'horizontal', 11 | }, 12 | }); 13 | 14 | const sliderElement = getByRole('slider').element() as HTMLElement; 15 | const pointerElement = sliderElement.querySelector('div'); 16 | expect(pointerElement?.style.left).toBe('50%'); 17 | expect(pointerElement?.style.top).toBe('0px'); 18 | 19 | rerender({ modelValue: 200 }); // pull to right 20 | await waitForRerender(); 21 | rerender({ modelValue: 0 }); 22 | await waitForRerender(); 23 | expect(pointerElement?.style.left).toBe('100%'); 24 | expect(pointerElement?.style.top).toBe('0px'); 25 | 26 | 27 | // ======= vertical ======= 28 | 29 | rerender({ direction: 'vertical', modelValue: 180 }); 30 | await waitForRerender(); 31 | expect(pointerElement?.style.top).toBe('50%'); 32 | expect(pointerElement?.style.left).toBe('0px'); 33 | 34 | rerender({ direction: 'vertical', modelValue: 200 }); 35 | await waitForRerender(); 36 | rerender({ direction: 'vertical', modelValue: 0 }); 37 | await waitForRerender(); 38 | expect(pointerElement?.style.top).toBe('0px'); 39 | expect(pointerElement?.style.left).toBe('0px'); 40 | }); 41 | 42 | test('Click the pointer and update color events should be emitted with correct alpha value (horizontally)', () => { 43 | const { getByRole, emitted } = render(Hue, { 44 | props: { 45 | modelValue: 10, 46 | direction: 'horizontal', 47 | }, 48 | }); 49 | 50 | const slider = getByRole('slider'); 51 | const box = (slider.element() as HTMLElement).getBoundingClientRect(); 52 | // click the middle position of the slider 53 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 })); 54 | expect(emitted()['update:modelValue'][0]).toEqual([180]); 55 | 56 | // click the left outer space of the slider 57 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: -10, clientY: box.top + box.height / 2 })); 58 | expect(emitted()['update:modelValue'][1]).toEqual([0]); 59 | 60 | // click the right outer space of the slider 61 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width + 100, clientY: box.top + box.height / 2 })); 62 | expect(emitted()['update:modelValue'][2]).toEqual([360]); 63 | }); 64 | 65 | test('Click the pointer and update color events should be emitted with correct alpha value (vertically)', () => { 66 | const { getByRole, emitted } = render(Hue, { 67 | props: { 68 | modelValue: 10, 69 | direction: 'vertical', 70 | }, 71 | }); 72 | 73 | const slider = getByRole('slider'); 74 | const box = (slider.element() as HTMLElement).getBoundingClientRect(); 75 | // click the middle position of the slider 76 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 })); 77 | expect(emitted()['update:modelValue'][0]).toEqual([180]); 78 | 79 | // click the top outer space of the slider 80 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: - 10 })); 81 | expect(emitted()['update:modelValue'][1]).toEqual([360]); 82 | 83 | // click the bottom outer space of the slider 84 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + box.height + 100 })); 85 | expect(emitted()['update:modelValue'][2]).toEqual([0]); 86 | }); 87 | 88 | const keyboardEventCases = [ 89 | { 90 | keyboardEventCode: 'ArrowUp', 91 | direction: 'vertical' as const, 92 | oppositeDirection: 'horizontal' as const, 93 | initialValue: 11.1, 94 | changedValueNormally: 13, 95 | valueOfLimitation: 360 96 | }, 97 | { 98 | keyboardEventCode: 'ArrowDown', 99 | direction: 'vertical' as const, 100 | oppositeDirection: 'horizontal' as const, 101 | initialValue: 11.1, 102 | changedValueNormally: 10, 103 | valueOfLimitation: 0 104 | }, 105 | { 106 | keyboardEventCode: 'ArrowLeft', 107 | direction: 'horizontal' as const, 108 | oppositeDirection: 'vertical' as const, 109 | initialValue: 15.1, 110 | changedValueNormally: 14, 111 | valueOfLimitation: 0 112 | }, 113 | { 114 | keyboardEventCode: 'ArrowRight', 115 | direction: 'horizontal' as const, 116 | oppositeDirection: 'vertical' as const, 117 | initialValue: 50.6, 118 | changedValueNormally: 52, 119 | valueOfLimitation: 360 120 | } 121 | ]; 122 | 123 | describe('When keyboard events is fired, update color events should be emitted with correct value', () => { 124 | test.each(keyboardEventCases)('$keyboardEventCode', async ({ direction, oppositeDirection, initialValue, keyboardEventCode, changedValueNormally, valueOfLimitation}) => { 125 | const { getByRole, emitted, rerender } = render(Hue, { 126 | props: { 127 | modelValue: initialValue, 128 | direction: oppositeDirection as 'horizontal' | 'vertical', 129 | }, 130 | }); 131 | const slider = getByRole('slider').element(); 132 | 133 | // scene 1: different direction 134 | slider.dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode })); 135 | expect((emitted()['update:modelValue'])).toBeUndefined(); 136 | 137 | // scene 2: changes value normally 138 | rerender({ 139 | direction 140 | }) 141 | await waitForRerender(); 142 | slider.dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode })); 143 | expect((emitted()['update:modelValue'][0] as [number])[0]).toEqual(changedValueNormally); 144 | 145 | 146 | // scene 3: exceed limitation 147 | rerender({ 148 | modelValue: valueOfLimitation 149 | }); 150 | await waitForRerender(); 151 | slider.dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode })); 152 | expect((emitted()['update:modelValue'][1] as [number])[0]).toEqual(valueOfLimitation); 153 | }); 154 | }) -------------------------------------------------------------------------------- /tests/components/common/SaturationSlider.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import Saturation from '../../../src/components/common/SaturationSlider.vue'; 4 | import { waitForRerender } from '../../tools'; 5 | 6 | test('Render background with given hue value', async () => { 7 | const { getByRole, rerender } = render(Saturation, { 8 | props: { 9 | hue: 180, 10 | }, 11 | }); 12 | 13 | const background = getByRole('application').element() as HTMLElement; 14 | // hsl(180, 100%, 50%) 15 | expect(background.style.backgroundColor).toEqual('rgb(0, 255, 255)'); 16 | 17 | // @ts-expect-error ts type issue, not a big deal 18 | rerender({ tinyColor: { h: 282, s: 1, l: 0.5 }, hue: undefined}); 19 | await waitForRerender(); 20 | expect(background.style.background).toBe('rgb(178, 0, 255)'); 21 | }); 22 | 23 | test('The position of the picker should be correct', async () => { 24 | const container = document.createElement('div'); 25 | document.body.appendChild(container); 26 | container.style.width = '100px'; 27 | container.style.height = '100px'; 28 | container.style.position = 'relative'; 29 | 30 | const { getByRole } = render(Saturation, { 31 | props: { 32 | modelValue: { 33 | h: 38, 34 | s: 0.35, 35 | l: 0.4 36 | } 37 | }, 38 | container 39 | }); 40 | const slider = getByRole('slider').element(); 41 | const styleString = slider.getAttribute('style'); 42 | const styleJSON = styleString?.split(';').map(s => s.trim()).reduce((result, style) => { 43 | const [k, v] = style.split(':'); 44 | if (k) { 45 | result[k] = Number(v.trim().replace('%', '')); 46 | } 47 | return result; 48 | }, {} as Record); 49 | expect(styleJSON?.left).toBeCloseTo(50, -1); 50 | expect(styleJSON?.top).toBeCloseTo(50, -1); 51 | }); 52 | 53 | test('Click the pointer and update color events should be emitted with correct value', () => { 54 | const container = document.createElement('div'); 55 | document.body.appendChild(container); 56 | container.style.width = '100px'; 57 | container.style.height = '100px'; 58 | container.style.position = 'relative'; 59 | 60 | const { getByRole, emitted } = render(Saturation, { props: { 61 | modelValue: { 62 | h: 100, 63 | s: 0.1, 64 | v: 0.1, 65 | a: 1 66 | } 67 | }, container }); 68 | 69 | const containerELE = getByRole('application').element(); 70 | const box = containerELE.getBoundingClientRect(); 71 | 72 | containerELE.dispatchEvent(new MouseEvent('touchstart', { button: 0, clientX: box.left + box.width / 4, clientY: box.top + box.height / 4 })); 73 | expect(emitted()['update:modelValue'][0]).toEqual([{h: 100, a: 1, s: 0.25, v: 0.75}]); 74 | 75 | // special handling when reaching to the bottom edge of the container 76 | containerELE.dispatchEvent(new MouseEvent('touchstart', { button: 0, clientX: box.left + box.width / 4, clientY: box.top + box.height - 1 })); 77 | expect((emitted()['update:modelValue'][1] as [{s: number}])[0].s).toBeCloseTo(0.25); 78 | expect((emitted()['update:modelValue'][1] as [{v: number}])[0].v).toBeCloseTo(0.01); 79 | 80 | // special handling when reaching to the left edge of the container 81 | containerELE.dispatchEvent(new MouseEvent('touchstart', { button: 0, clientX: 1, clientY: box.top + box.height / 4 })); 82 | expect((emitted()['update:modelValue'][2] as [{s: number}])[0].s).toBeCloseTo(0.01); 83 | expect((emitted()['update:modelValue'][2] as [{v: number}])[0].v).toBeCloseTo(0.75); 84 | 85 | // out of container 86 | containerELE.dispatchEvent(new MouseEvent('touchstart', { button: 0, clientX: box.width + 10, clientY: box.height + 10 })); 87 | expect(emitted()['update:modelValue'][3]).toEqual([{h: 0, a: 1, s: 0, v: 0}]); 88 | 89 | // out of container 90 | containerELE.dispatchEvent(new MouseEvent('touchstart', { button: 0, clientX: -10, clientY: -10 })); 91 | expect(emitted()['update:modelValue'][4]).toEqual([{h: 0, a: 1, s: 0, v: 1}]); 92 | }); 93 | 94 | 95 | const initialValueOfKeyboardEventCases = { h: 100, s: 0.1, v: 0.1, a: 1 }; 96 | const keyboardEventCases = [ 97 | { 98 | keyboardEventCode: 'ArrowUp', 99 | expectedValue: { 100 | ...initialValueOfKeyboardEventCases, 101 | v: initialValueOfKeyboardEventCases.v + 0.01 102 | } 103 | }, 104 | { 105 | keyboardEventCode: 'ArrowDown', 106 | expectedValue: { 107 | ...initialValueOfKeyboardEventCases, 108 | v: initialValueOfKeyboardEventCases.v - 0.01 109 | } 110 | }, 111 | { 112 | keyboardEventCode: 'ArrowLeft', 113 | expectedValue: { 114 | ...initialValueOfKeyboardEventCases, 115 | s: initialValueOfKeyboardEventCases.s - 0.01 116 | } 117 | }, 118 | { 119 | keyboardEventCode: 'ArrowRight', 120 | expectedValue: { 121 | ...initialValueOfKeyboardEventCases, 122 | s: initialValueOfKeyboardEventCases.s + 0.01 123 | } 124 | } 125 | ]; 126 | 127 | describe('When keyboard event is fired, update color events should be emitted with correct value', () => { 128 | test.each(keyboardEventCases)('$keyboardEventCode', async ({ keyboardEventCode, expectedValue}) => { 129 | const container = document.createElement('div'); 130 | document.body.appendChild(container); 131 | container.style.width = '100px'; 132 | container.style.height = '100px'; 133 | container.style.position = 'relative'; 134 | 135 | const { getByRole, emitted } = render(Saturation, { props: { 136 | modelValue: initialValueOfKeyboardEventCases 137 | }, container }); 138 | 139 | getByRole('slider').element().dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode })); 140 | const returnedValue = (emitted()['update:modelValue'][0] as [typeof expectedValue])[0]; 141 | (Object.keys(returnedValue) as [keyof typeof initialValueOfKeyboardEventCases]).forEach((k) => { 142 | expect(returnedValue[k]).toBeCloseTo(expectedValue[k]); 143 | }); 144 | }); 145 | }); -------------------------------------------------------------------------------- /tests/tools.ts: -------------------------------------------------------------------------------- 1 | export const waitForRerender = () => Promise.resolve(); -------------------------------------------------------------------------------- /tests/utils/dom.browser.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { getPageXYFromEvent, getScrollXY, getAbsolutePosition } from '../../src/utils/dom'; // 替换为实际路径 3 | 4 | describe('getPageXYFromEvent', () => { 5 | it('should return correct coordinates for MouseEvent', () => { 6 | const event = new MouseEvent('click', { clientX: 100, clientY: 200 }); 7 | expect(getPageXYFromEvent(event)).toEqual({ x: 100, y: 200 }); 8 | }); 9 | 10 | it('should return correct coordinates for TouchEvent', () => { 11 | const touch = new Touch({ identifier: 1, target: new EventTarget(), pageX: 150, pageY: 250 }); 12 | const touchEvent = new TouchEvent('touchstart', { 13 | touches: [touch] 14 | }); 15 | expect(getPageXYFromEvent(touchEvent)).toEqual({ x: 150, y: 250 }); 16 | }); 17 | }); 18 | 19 | describe('getScrollXY', () => { 20 | it('should return correct scroll values', () => { 21 | vi.stubGlobal('scrollX', 50); 22 | vi.stubGlobal('scrollY', 100); 23 | expect(getScrollXY()).toEqual({ x: 50, y: 100 }); 24 | }); 25 | }); 26 | 27 | describe('getAbsolutePosition', () => { 28 | it('should return correct absolute position of an element', () => { 29 | const mockElement = { 30 | getBoundingClientRect: () => ({ left: 30, top: 40 }), 31 | } as HTMLElement; 32 | 33 | vi.stubGlobal('scrollX', 50); 34 | vi.stubGlobal('scrollY', 100); 35 | 36 | expect(getAbsolutePosition(mockElement)).toEqual({ x: 80, y: 140 }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/utils/math.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { getFractionDigit, clamp } from '../../src/utils/math'; 3 | 4 | describe('getFractionDigit', () => { 5 | it('should return 0 for integer values', () => { 6 | expect(getFractionDigit(10)).toBe(0); 7 | expect(getFractionDigit('100')).toBe(0); 8 | }); 9 | 10 | it('should return correct fraction digit count', () => { 11 | expect(getFractionDigit(10.5)).toBe(1); 12 | expect(getFractionDigit('3.1415')).toBe(4); 13 | expect(getFractionDigit(0.123456)).toBe(6); 14 | }); 15 | }); 16 | 17 | describe('clamp', () => { 18 | it('should return the value itself if it is within range', () => { 19 | expect(clamp(5, 1, 10)).toBe(5); 20 | expect(clamp(10, 0, 20)).toBe(10); 21 | }); 22 | 23 | it('should return min if value is less than min', () => { 24 | expect(clamp(-5, 0, 10)).toBe(0); 25 | expect(clamp(1, 5, 15)).toBe(5); 26 | }); 27 | 28 | it('should return max if value is greater than max', () => { 29 | expect(clamp(50, 0, 10)).toBe(10); 30 | expect(clamp(100, 30, 80)).toBe(80); 31 | }); 32 | }); -------------------------------------------------------------------------------- /tests/utils/throttle.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { throttle } from '../../src/utils/throttle'; 3 | 4 | describe('throttle', () => { 5 | it('should call function immediately', () => { 6 | const mockFn = vi.fn(); 7 | const throttledFn = throttle(mockFn, 100); 8 | 9 | throttledFn(); 10 | expect(mockFn).toHaveBeenCalledTimes(1); 11 | }); 12 | 13 | it('should call function only once if called multiple times rapidly', () => { 14 | vi.useFakeTimers(); 15 | const mockFn = vi.fn(); 16 | const throttledFn = throttle(mockFn, 100); 17 | 18 | throttledFn(); 19 | throttledFn(); 20 | throttledFn(); 21 | 22 | expect(mockFn).toHaveBeenCalledTimes(1); 23 | vi.advanceTimersByTime(100); 24 | expect(mockFn).toHaveBeenCalledTimes(2); 25 | vi.useRealTimers(); 26 | }); 27 | }); -------------------------------------------------------------------------------- /tests/vitest.shims.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.lib.json" }, 5 | { "path": "./tsconfig.node.json" }, 6 | { "path": "./tsconfig.test.json" } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | "outDir": "dist/types", 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "jsx": "preserve", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["tests/**/*.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import vue from '@vitejs/plugin-vue'; 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | build: { 9 | lib: { 10 | entry: resolve(__dirname, 'src/index.ts'), 11 | name: 'VueColor', 12 | // the proper extensions will be added 13 | fileName: 'vue-color', 14 | }, 15 | rollupOptions: { 16 | // make sure to externalize deps that shouldn't be bundled 17 | // into your library 18 | external: ['vue'], 19 | output: { 20 | // Provide global variables to use in the UMD build 21 | // for externalized deps 22 | globals: { 23 | vue: 'Vue', 24 | }, 25 | }, 26 | }, 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from 'vitest/config' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | export default defineWorkspace([ 5 | { 6 | test: { 7 | include: [ 8 | 'tests/utils/**/*.unit.{test,spec}.ts', 9 | ], 10 | name: 'unit', 11 | environment: 'node', 12 | }, 13 | }, 14 | { 15 | plugins: [vue()], 16 | test: { 17 | include: [ 18 | 'tests/components/**/*.{test,spec}.ts', 19 | 'tests/utils/**/*.browser.{test,spec}.ts', 20 | ], 21 | name: 'browser', 22 | browser: { 23 | provider: 'playwright', 24 | enabled: true, 25 | // at least one instance is required 26 | instances: [ 27 | { browser: 'chromium' }, 28 | ], 29 | }, 30 | }, 31 | }, 32 | ]) 33 | --------------------------------------------------------------------------------