├── .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 |
5 |
6 |
7 |
8 | 
9 | 
10 | 
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 |
57 |
58 |
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 |
104 |
105 |
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 |
137 |
138 |
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 |
160 |
161 |
162 |
163 |
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 |
69 |
70 |
71 |
72 |
73 |
Vue-color
v3.0
74 |
75 |
76 |
77 | A collection of efficient color pickers designed for modern web development.
78 |
79 | - ✅ Modular & Tree-Shakable
80 | - ✅ TypeScript Ready
81 | - ✅ SSR-Friendly
82 | - ✅ Optimized for Accessibility
83 |
84 |
85 |
92 | Get Started 🚀
93 |
94 |
95 |
96 |
97 |
98 |
99 | {{ hex }}
100 | {{ color }}
101 | {{ hsva }}
102 |
103 |
104 |
105 |
<ChromePicker />
106 |
107 |
108 |
109 |
110 |
111 |
<SketchPicker />
112 |
113 |
114 |
115 |
116 |
<PhotoshopPicker />
117 |
118 |
119 |
120 |
121 |
122 |
123 |
<CompactPicker />
124 |
125 |
126 |
127 |
<GrayscalePicker />
128 |
129 |
130 |
131 |
<MaterialPicker />
132 |
133 |
134 |
135 |
136 |
137 |
138 |
<HueSlider />
139 |
140 |
141 |
142 |
143 |
<SliderPicker />
144 |
145 |
146 |
147 |
148 |
<TwitterPicker />
149 |
150 |
151 |
152 |
153 |
154 |
155 |
<SwatchesPicker />
156 |
157 |
158 |
159 |
160 |
161 |
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 |
26 |
27 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
28 |
29 |
30 |
31 |
32 |
33 | inputChangeRGBA('r', v)" :a11y="{label: 'Red'}">
34 |
35 |
36 | inputChangeRGBA('g', v)" :a11y="{label: 'Green'}">
37 |
38 |
39 | inputChangeRGBA('b', v)" :a11y="{label: 'Blue'}">
40 |
41 |
42 | inputChangeRGBA('a', v)" :a11y="{label: 'Transparency'}">
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | inputChangeHSLA('h', v)" :a11y="{label: 'Hue'}">
58 |
59 |
60 | inputChangeHSLA('s', v)" :a11y="{label: 'Saturation'}">
61 |
62 |
63 | inputChangeHSLA('l', v)" :a11y="{label: 'Lightness'}">
64 |
65 |
66 | inputChangeHSLA('a', v)" :a11y="{label: 'Transparency'}">
67 |
68 |
69 |
70 |
71 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
259 |
260 |
--------------------------------------------------------------------------------
/src/components/CompactPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
23 |
33 |
34 |
59 |
60 |
103 |
--------------------------------------------------------------------------------
/src/components/GrayscalePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
23 |
30 |
31 |
55 |
56 |
--------------------------------------------------------------------------------
/src/components/HueSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
17 |
18 |
38 |
--------------------------------------------------------------------------------
/src/components/MaterialPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
8 | onChange('r', v)" :a11y="{label: 'Red'}">
9 |
10 |
11 | onChange('g', v)" :a11y="{label: 'Green'}">
12 |
13 |
14 | onChange('b', v)" :a11y="{label: 'Blue'}">
15 |
16 |
17 |
18 |
19 |
20 |
46 |
47 |
--------------------------------------------------------------------------------
/src/components/PhotoshopPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{title}}
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
{{ newLabel }}
18 |
30 |
{{ currentLabel }}
31 |
32 |
33 |
{{ okLabel }}
34 |
{{ cancelLabel }}
35 |
36 |
37 |
38 |
inputChangeHSV('h', v)" :a11y="{label: 'Hue'}">
39 |
inputChangeHSV('s', v)" :a11y="{label: 'Saturation'}">
40 |
inputChangeHSV('v', v)" :a11y="{label: 'Value'}">
41 |
42 |
43 |
inputChangeRGBA('r', v)" :a11y="{label: 'Red'}">
44 |
inputChangeRGBA('g', v)" :a11y="{label: 'Green'}">
45 |
inputChangeRGBA('b', v)" :a11y="{label: 'Blue'}">
46 |
47 |
48 |
49 |
50 |
51 |
{{ resetLabel }}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
163 |
164 |
--------------------------------------------------------------------------------
/src/components/SketchPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | inputChangeRGBA('r', v)" :a11y="{label: 'Red'}">
27 |
28 |
29 | inputChangeRGBA('g', v)" :a11y="{label: 'Green'}">
30 |
31 |
32 | inputChangeRGBA('b', v)" :a11y="{label: 'Blue'}">
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
53 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
79 |
80 |
153 |
154 |
--------------------------------------------------------------------------------
/src/components/SliderPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
40 |
41 |
42 |
52 |
53 |
109 |
110 |
168 |
--------------------------------------------------------------------------------
/src/components/SwatchesPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
60 |
61 |
86 |
87 |
131 |
--------------------------------------------------------------------------------
/src/components/TwitterPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
17 |
18 |
34 |
35 |
#
36 |
37 |
38 |
39 |
40 |
41 |
42 |
48 |
49 |
86 |
87 |
196 |
--------------------------------------------------------------------------------
/src/components/common/AlphaSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
26 |
27 |
28 |
122 |
123 |
164 |
--------------------------------------------------------------------------------
/src/components/common/CheckerboardBG.vue:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
57 |
58 |
59 |
69 |
--------------------------------------------------------------------------------
/src/components/common/EditableInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 | {{desc}}
13 |
14 |
15 |
16 |
87 |
88 |
101 |
--------------------------------------------------------------------------------
/src/components/common/HueSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
28 |
29 |
30 |
197 |
198 |
--------------------------------------------------------------------------------
/src/components/common/SaturationSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
16 |
30 |
31 |
32 |
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 |
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 |
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 |
--------------------------------------------------------------------------------