├── .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
├── components
│ └── ThemeToggle.vue
├── index.html
├── main.ts
├── tsconfig.json
├── utils.ts
├── 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
├── styles
│ └── variables.css
├── utils
│ ├── color.ts
│ ├── dom.ts
│ ├── log.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
└── vue2
├── README.md
├── babel.config.js
├── demo
├── App.vue
├── index.html
├── main.ts
├── module.d.ts
└── vite.config.ts
├── jest.config.ts
├── jest.setup.ts
├── package-lock.json
├── package.json
├── 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
│ │ ├── EditableInput.spec.ts
│ │ └── SaturationSlider.spec.ts
└── tool.ts
├── tsconfig.json
└── vite.config.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 | # ❌ Removed schedule to prevent auto-trigger
8 | # schedule:
9 | # interval: "monthly"
10 | labels:
11 | - "dependencies"
12 | groups:
13 | all-updates:
14 | patterns:
15 | - "*"
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: Install and build for Vue 2.7
41 | working-directory: vue2
42 | run: |
43 | npm ci
44 | npm run build
45 |
46 | - name: Publish package
47 | run: npm publish
48 | env:
49 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
50 |
--------------------------------------------------------------------------------
/.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
35 |
36 | - name: Install and build for Vue 2.7
37 | working-directory: vue2
38 | run: |
39 | npm ci
40 | npm run 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 (root)
29 | run: npm ci
30 |
31 | - name: Install Playwright browsers
32 | run: npx playwright install
33 |
34 | - name: Run root tests
35 | run: npm run coverage
36 |
37 | - name: Install and run Vue 2.7 tests
38 | working-directory: vue2
39 | run: |
40 | npm ci
41 | npm run test
42 |
43 | - name: Upload results to Codecov
44 | uses: codecov/codecov-action@v5
45 | with:
46 | token: ${{ secrets.CODECOV_TOKEN }}
47 |
48 |
--------------------------------------------------------------------------------
/.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 | A collection of efficient and customizable color pickers, designed for modern web development.
12 |
13 | ## 🧪 Live Demo
14 |
15 | Explore the components in action: 👉 [Open Live Demo](https://linx4200.github.io/vue-color/)
16 |
17 |
18 |
19 | ## ✨ Features
20 |
21 | - **Dual Vue Compatibility** – Supports both Vue 2.7 and Vue 3 out of the box
22 |
23 | - **Modular & Tree-Shakable** – Import only what you use
24 |
25 | - **TypeScript Ready** – Full typings for better DX
26 |
27 | - **SSR-Friendly** – Compatible with Nuxt and other SSR frameworks
28 |
29 | - **Optimized for Accessibility** – Built with keyboard navigation and screen readers in mind
30 |
31 | - **Dark Mode Support** – Built-in dark theme
32 |
33 | ## 📦 Installation
34 |
35 | ```bash
36 | npm install vue-color
37 | # or
38 | yarn add vue-color
39 | ```
40 |
41 | ## 🚀 Quick Start
42 |
43 | ### 1. Import styles
44 |
45 | ```ts
46 | // main.ts
47 | import { createApp } from 'vue'
48 | import App from './App.vue'
49 |
50 | // Import styles
51 | import 'vue-color/style.css';
52 |
53 | createApp(App).mount('#app')
54 | ```
55 |
56 | ### 2. Use a color picker component
57 |
58 | ```vue
59 |
60 |
61 |
62 |
63 |
70 | ```
71 |
72 | If you plan to use `vue-color` with Vue 2.7, please refer to [Use with Vue 2.7](#use-with-vue-27).
73 |
74 | > 📘 For a full list of available components, see the [Documentation](#all-available-pickers).
75 |
76 | ## 📚 Documentation
77 |
78 | ### All Available Pickers
79 |
80 | All color pickers listed below can be imported as named exports from `vue-color`.
81 |
82 | ```ts
83 | import { ChromePicker, CompactPicker, HueSlider /** ...etc */ } from 'vue-color';
84 | ```
85 |
86 | | Component Name | Docs |
87 | | ------- | ------- |
88 | | ChromePicker | [View](./docs/components/ChromePicker.md) |
89 | | CompactPicker | [View](./docs/components/CompactPicker.md) |
90 | | GrayscalePicker | [View](./docs/components/GrayscalePicker.md) |
91 | | MaterialPicker | - |
92 | | PhotoshopPicker | [View](./docs/components/PhotoshopPicker.md) |
93 | | SketchPicker | [View](./docs/components/SketchPicker.md) |
94 | | SliderPicker | [View](./docs/components/SliderPicker.md) |
95 | | SwatchesPicker | [View](./docs/components/SwatchesPicker.md) |
96 | | TwitterPicker | [View](./docs/components/TwitterPicker.md) |
97 | | HueSlider | [View](./docs/components/HueSlider.md) |
98 | | AlphaSlider | - |
99 |
100 | ### Props & Events
101 |
102 | All color picker components (expect for ``) in `vue-color` share a set of common props and events for handling color updates and synchronization.
103 | Below we'll take `` as an example to illustrate how to work with `v-model`.
104 |
105 | #### `v-model`
106 |
107 | ```vue
108 |
109 |
110 |
111 |
112 |
119 | ```
120 |
121 | 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.
122 |
123 | ```ts
124 | const color = defineModel({
125 | default: 'hsl(136, 54%, 43%)'
126 | // or
127 | default: '#32a852'
128 | // or
129 | default: '#32a852ff'
130 | // or
131 | default: { r: 255, g: 255, b: 255, a: 1 }
132 | });
133 | ```
134 |
135 | Under the hood, `vue-color` uses [`tinycolor2`](https://www.npmjs.com/package/tinycolor2) to handle color parsing and conversion.
136 | This means you can pass in any color format that `tinycolor2` supports—and it will just work.
137 |
138 | #### `v-model:tinyColor`
139 |
140 | ```vue
141 |
142 |
143 |
144 |
145 |
152 | ```
153 |
154 | In addition to plain color values, you can also bind a `tinycolor2` instance using `v-model:tinyColor`.
155 | This gives you full control and utility of the `tinycolor` API.
156 |
157 | > ⚠️ Note: You must use the `tinycolor` exported from `vue-color` to ensure compatibility with the library's internal handling.
158 |
159 | ### SSR Compatibility
160 |
161 | Since `vue-color` relies on DOM interaction, components must be rendered client-side. Example for Nuxt:
162 |
163 | ```vue
164 |
165 |
166 |
167 |
168 |
169 |
170 |
174 | ```
175 |
176 | ### Dark Mode Support
177 |
178 | By default, `vue-color` uses CSS variables defined under the :root scope. To enable dark mode, simply add a `.dark` class to your HTML element:
179 |
180 | ```html
181 |
182 |
183 |
184 | ```
185 |
186 | ### Use with Vue 2.7
187 |
188 | To use `vue-color` with Vue 2.7:
189 |
190 | ```vue
191 |
192 |
193 |
194 |
195 |
207 | ```
208 |
209 | The Vue 2.7 build is fully compatible with the Vue Composition API introduced in 2.7.
210 |
211 | Make sure to use `vue-color/vue2` as the import path, and include the correct stylesheet:
212 | import `vue-color/vue2/style.css` in your main entry file.
213 |
214 | #### TypeScript Support in Vue 2.7
215 |
216 | Vue 2.7 has full TypeScript support, but `vue-color` does **not include type declarations** for the Vue 2.7 build.
217 |
218 | You can work around this by manually adding the following shim:
219 |
220 | ```ts
221 | // vue-color-shims.d.ts
222 | declare module 'vue-color/vue2' {
223 | import { Component } from 'vue';
224 | import tinycolor from 'tinycolor2';
225 |
226 | export const ChromePicker: Component;
227 | export const SketchPicker: Component;
228 | export const PhotoshopPicker: Component;
229 | export const CompactPicker: Component;
230 | export const GrayscalePicker: Component;
231 | export const MaterialPicker: Component;
232 | export const SliderPicker: Component;
233 | export const TwitterPicker: Component;
234 | export const SwatchesPicker: Component;
235 | export const HueSlider: Component;
236 | export const tinycolor: typeof tinycolor;
237 | }
238 |
239 | declare module '*.css' {
240 | const content: { [className: string]: string };
241 | export default content;
242 | }
243 | ```
244 |
245 | ## 🧩 FAQ / Issue Guide
246 |
247 | | Error / Symptom | Related Issue |
248 | |--------|----------------|
249 | | `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) |
250 |
251 | ## 🤝 Contributing
252 |
253 | Contributions are welcome! Please open issues or pull requests as needed.
254 |
255 | ## 📄 License
256 |
257 | [MIT](./LICENSE)
258 |
--------------------------------------------------------------------------------
/demo/App.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
100 |
101 |
102 |
103 |
104 |
105 |
Vue-color
106 |
107 |
108 |
109 | A collection of efficient color pickers designed for modern web development.
110 |
111 | - ✅ Supports both Vue 2.7 and Vue 3
112 | - ✅ Modular & Tree-Shakable
113 | - ✅ TypeScript Ready
114 | - ✅ SSR-Friendly
115 | - ✅ Optimized for Accessibility
116 | - ✅ Supports Dark Theme
117 |
118 |
119 |
126 | Get Started 🚀
127 |
128 |
129 |
130 |
131 |
132 |
133 | {{ hex }}
134 | {{ tinyColor.toRgbString() }}
135 | {{ tinyColor.toHsvString() }}
136 |
137 |
138 |
139 |
<ChromePicker />
140 |
141 |
142 |
143 |
144 |
145 |
<SketchPicker />
146 |
147 |
148 |
149 |
150 |
<PhotoshopPicker />
151 |
152 |
153 |
154 |
155 |
156 |
157 |
<CompactPicker />
158 |
159 |
160 |
161 |
<GrayscalePicker />
162 |
163 |
164 |
165 |
<MaterialPicker />
166 |
167 |
168 |
169 |
170 |
171 |
172 |
<HueSlider />
173 |
174 |
175 |
176 |
177 |
<SliderPicker />
178 |
179 |
180 |
181 |
182 |
<TwitterPicker />
183 |
184 |
185 |
186 |
187 |
188 |
189 |
<SwatchesPicker />
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
290 |
--------------------------------------------------------------------------------
/demo/components/ThemeToggle.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
17 |
18 |
19 |
20 |
42 |
43 |
85 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vue-color v3.0
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/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/utils.ts:
--------------------------------------------------------------------------------
1 | export function parseSearchParams(search: string): Record {
2 | const params = new URLSearchParams(search);
3 | const result: Record = {};
4 |
5 | for (const [key, value] of params.entries()) {
6 | result[key] = value;
7 | }
8 |
9 | return result;
10 | }
11 |
--------------------------------------------------------------------------------
/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 | define: {
9 | __IS_DEBUG__: !!process.env.VITE_DEBUG
10 | },
11 | })
12 |
--------------------------------------------------------------------------------
/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/648adf85fedf2326d5d96adbf509d4efd0197bf0/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.2.0",
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 | "./vue2": {
21 | "import": "./dist/vue2/vue-color.js",
22 | "require": "./dist/vue2/vue-color.umd.cjs"
23 | },
24 | "./vue2/style.css": {
25 | "import": "./dist/vue2/vue-color.css",
26 | "require": "./dist/vue2/vue-color.css"
27 | }
28 | },
29 | "types": "./dist/types/index.d.ts",
30 | "scripts": {
31 | "build": "vite build && vue-tsc --project tsconfig.lib.json --declaration --emitDeclarationOnly",
32 | "demo": "vite demo",
33 | "demo:build": "vite build demo",
34 | "demo:debug": "VITE_DEBUG=true vite demo",
35 | "test": "vitest --workspace=vitest.workspace.ts",
36 | "coverage": "vitest run --coverage --coverage.include=src/components --coverage.include=src/composable --coverage.include=src/utils",
37 | "lint": "eslint"
38 | },
39 | "repository": {
40 | "type": "git",
41 | "url": "git+https://github.com/linx4200/vue-color.git"
42 | },
43 | "author": "Xinran , xiaokai ",
44 | "peerDependencies": {
45 | "vue": ">=2.7.0 <4.0.0"
46 | },
47 | "devDependencies": {
48 | "@eslint/js": "^9.27.0",
49 | "@types/material-colors": "^1.2.3",
50 | "@types/node": "^22.13.11",
51 | "@types/tinycolor2": "^1.4.6",
52 | "@vitejs/plugin-vue": "^5.2.4",
53 | "@vitest/browser": "^3.1.3",
54 | "@vitest/coverage-v8": "^3.1.4",
55 | "eslint": "^9.21.0",
56 | "eslint-plugin-vue": "^9.32.0",
57 | "eslint-plugin-vuejs-accessibility": "^2.4.1",
58 | "globals": "^16.0.0",
59 | "playwright": "^1.52.0",
60 | "typescript": "~5.8.3",
61 | "typescript-eslint": "^8.25.0",
62 | "vite": "^6.3.5",
63 | "vitest": "^3.0.5",
64 | "vitest-browser-vue": "^0.2.0",
65 | "vue-tsc": "^2.2.10"
66 | },
67 | "dependencies": {
68 | "material-colors": "^1.2.6",
69 | "tinycolor2": "^1.6.0"
70 | },
71 | "publishConfig": {
72 | "registry": "https://registry.npmjs.org/"
73 | },
74 | "homepage": "https://linx4200.github.io/vue-color/"
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/CompactPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
23 |
33 |
34 |
77 |
78 |
123 |
--------------------------------------------------------------------------------
/src/components/GrayscalePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
23 |
30 |
31 |
73 |
74 |
--------------------------------------------------------------------------------
/src/components/HueSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
34 |
35 |
55 |
--------------------------------------------------------------------------------
/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 |
62 |
63 |
--------------------------------------------------------------------------------
/src/components/SliderPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
40 |
41 |
42 |
52 |
53 |
134 |
135 |
192 |
--------------------------------------------------------------------------------
/src/components/SwatchesPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
60 |
61 |
106 |
107 |
152 |
--------------------------------------------------------------------------------
/src/components/TwitterPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
17 |
18 |
34 |
35 |
#
36 |
37 |
38 |
39 |
40 |
41 |
42 |
48 |
49 |
116 |
117 |
227 |
--------------------------------------------------------------------------------
/src/components/common/AlphaSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
25 |
26 |
27 |
145 |
146 |
192 |
--------------------------------------------------------------------------------
/src/components/common/CheckerboardBG.vue:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 |
58 |
68 |
--------------------------------------------------------------------------------
/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 |
27 |
28 |
29 |
209 |
210 |
--------------------------------------------------------------------------------
/src/components/common/SaturationSlider.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
29 |
30 |
31 |
32 |
33 |
215 |
216 |
--------------------------------------------------------------------------------
/src/composable/colorModel.ts:
--------------------------------------------------------------------------------
1 | import { computed, type EmitFn } from 'vue';
2 | import tinycolor from 'tinycolor2';
3 | import { log } from '../utils/log';
4 |
5 | /** extracted from function `inputToRGB` of tinycolor2 */
6 | type TinyColorFormat = 'name' | 'hex8' | 'hex' | 'prgb' | 'rgb' | 'hsv' | 'hsl';
7 |
8 | const transformToOriginalInputFormat = (color: tinycolor.Instance, originalFormat?: TinyColorFormat, isObjectOriginally = false) => {
9 | if (isObjectOriginally) {
10 | switch (originalFormat) {
11 | case 'rgb': {
12 | return color.toRgb();
13 | }
14 | case 'prgb': {
15 | return color.toPercentageRgb();
16 | }
17 | case 'hsl': {
18 | return color.toHsl();
19 | }
20 | case 'hsv': {
21 | return color.toHsv();
22 | }
23 | default: {
24 | /* v8 ignore next 2 */
25 | return null;
26 | }
27 | }
28 | } else {
29 | // transform back to the original format
30 | // Only 'hex' with alpha needs to be handled specifically
31 | // tinycolor2 handles alpha correctly for all other formats internally.
32 | let format = originalFormat;
33 | if (originalFormat === 'hex' && color.getAlpha() < 1) {
34 | format = 'hex8';
35 | }
36 | let newValue = color.toString(format);
37 | try {
38 | newValue = JSON.parse(newValue);
39 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
40 | } catch (error) { /* no need to handle */ }
41 | return newValue;
42 | }
43 | }
44 |
45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
46 | const hasActualValueOwnProperty = (obj: Record, keyName: string) => {
47 | if (Object.prototype.hasOwnProperty.call(obj, keyName)) {
48 | if (typeof obj[keyName] !== 'undefined') {
49 | return true;
50 | }
51 | }
52 | return false;
53 | }
54 |
55 | const isUndefined = (value: unknown) => typeof value === 'undefined';
56 |
57 | /**
58 | * Props used to bind color values via v-model in Vue 3 and Vue 2.7.
59 | *
60 | * ⚠️ Note: Due to a known limitation in Vue 2.7 (see https://github.com/vuejs/core/issues/4294#issuecomment-1025210436),
61 | * `defineProps` does not support type extension. As a result, this type definition is currently duplicated
62 | * where needed instead of being reused via extends.
63 | */
64 | export interface defineColorModelProps {
65 | /**
66 | * Used with `v-model:tinyColor`. Accepts any valid TinyColor input format.
67 | */
68 | tinyColor?: tinycolor.ColorInput;
69 | /**
70 | * Used with `v-model`. Accepts any valid TinyColor input format.
71 | */
72 | modelValue?: tinycolor.ColorInput;
73 | /**
74 | * Fallback for `v-model` compatibility in Vue 2.7.
75 | * Accepts any valid TinyColor input.
76 | */
77 | value?: tinycolor.ColorInput;
78 | }
79 |
80 | export const EmitEventNames = ['update:tinyColor', 'update:modelValue', 'input'];
81 |
82 | export function defineColorModel(props: defineColorModelProps, emit: EmitFn, name?: string) {
83 |
84 | let isObjectOriginally: boolean;
85 | let originalFormat: TinyColorFormat;
86 |
87 | const logName = name ?? 'unknown';
88 |
89 | const tinyColorRef = computed({
90 | get: () => {
91 |
92 | const { modelValue, tinyColor, value } = props;
93 |
94 | // props.value is used to be compatible for v-model in Vue 2.7
95 | const colorInput = tinyColor ?? modelValue ?? value;
96 |
97 | log(logName, 'Received modelValue:', modelValue, 'tinyColor:', tinyColor, 'value:', value);
98 |
99 | if (isUndefined(originalFormat)) {
100 | if (!isUndefined(value)) {
101 | originalFormat = tinycolor(value).getFormat() as TinyColorFormat;
102 | }
103 | if (!isUndefined(modelValue)) {
104 | originalFormat = tinycolor(modelValue).getFormat() as TinyColorFormat;
105 | }
106 | }
107 |
108 | if (isUndefined(isObjectOriginally)) {
109 | if (typeof value === 'object' && !(value instanceof tinycolor)) {
110 | isObjectOriginally = true;
111 | }
112 | if (typeof modelValue === 'object') {
113 | isObjectOriginally = true;
114 | }
115 | }
116 | return tinycolor(colorInput);
117 | },
118 | set: (newValue: tinycolor.ColorInput) => {
119 | updateColor(newValue);
120 | }
121 | });
122 |
123 | const updateColor = (value: tinycolor.ColorInput) => {
124 | log(logName, 'got updated value`', value);
125 |
126 | const tinycolorValue = tinycolor(value);
127 |
128 | if (hasActualValueOwnProperty(props, 'tinyColor')) {
129 | log(logName, 'emit `update:tinyColor`', tinycolorValue);
130 | emit('update:tinyColor', tinycolorValue);
131 | }
132 |
133 | if (hasActualValueOwnProperty(props, 'modelValue')) {
134 | const newValue = transformToOriginalInputFormat(tinycolorValue, originalFormat, isObjectOriginally);
135 |
136 | log(logName, 'emit `update:modelValue`', newValue);
137 | emit('update:modelValue', newValue);
138 | }
139 |
140 | // backward compatible for v-model in Vue 2.7
141 | if (hasActualValueOwnProperty(props, 'value')) {
142 | const newValue = transformToOriginalInputFormat(tinycolorValue, originalFormat, isObjectOriginally);
143 |
144 | log(logName, 'emit `input`', newValue);
145 | emit('input', newValue);
146 | }
147 | }
148 |
149 | return tinyColorRef;
150 | }
151 |
--------------------------------------------------------------------------------
/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 | import './styles/variables.css';
2 |
3 | export { default as ChromePicker } from './components/ChromePicker.vue';
4 | export { default as CompactPicker } from './components/CompactPicker.vue';
5 | export { default as GrayscalePicker } from './components/GrayscalePicker.vue';
6 | export { default as MaterialPicker } from './components/MaterialPicker.vue';
7 | export { default as PhotoshopPicker } from './components/PhotoshopPicker.vue';
8 | export { default as SketchPicker } from './components/SketchPicker.vue';
9 | export { default as SliderPicker } from './components/SliderPicker.vue';
10 | export { default as SwatchesPicker } from './components/SwatchesPicker.vue';
11 | export { default as TwitterPicker } from './components/TwitterPicker.vue';
12 | export { default as HueSlider } from './components/HueSlider.vue';
13 |
14 | export { default as AlphaSlider } from './components/common/AlphaSlider.vue';
15 |
16 | export { default as tinycolor } from 'tinycolor2';
--------------------------------------------------------------------------------
/src/styles/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --vc-picker-bg: #f8f8f8;
3 |
4 | --vc-body-bg: #fff;
5 |
6 | --vc-input-bg: #fff;
7 | --vc-input-text: #333;
8 | --vc-input-label: #969696;
9 | --vc-input-border: #dadada;
10 |
11 | --vc-chrome-toggle-btn-highlighted: #eee;
12 |
13 | --vc-ps-bg: #dcdcdc;
14 | --vc-ps-title-bg-gradient-start: #f0f0f0;
15 | --vc-ps-title-bg-gradient-end: #d4d4d4;
16 | --vc-ps-title-border: #B1B1B1;
17 | --vc-ps-title-color: #4d4d4d;
18 | --vc-ps-slider-border: #b3b3b3;
19 | --vc-ps-slider-border-bottom: #f0f0f0;
20 | --vs-ps-picker-border-dark: #555;
21 | --vs-ps-picker-border-white: #fff;
22 |
23 | --vc-ps-btn-gradient-start: #fff;
24 | --vc-ps-btn-gradient-end: #e6e6e6;
25 | --vc-ps-btn-border: #878787;
26 | --vc-ps-btn-shadow: #EAEAEA;
27 | --vc-ps-btn-color: #000;
28 |
29 | --vc-ps-preview-border: #000;
30 | --vc-ps-label: #000;
31 |
32 | --vc-ps-input-border: #888;
33 | --vc-ps-input-shadow-dark: rgba(0,0,0,.1);
34 | --vc-ps-input-shadow-light: #ececec;
35 |
36 | --vc-sketch-input-label: #222;
37 | --vc-sketch-presets-border: #eee;
38 |
39 | --vc-twitter-input-bg: #fff;
40 | --vc-twitter-input-border: #f0f0f0;
41 | --vc-twitter-input-color: #666;
42 | --vc-twitter-hash-bg: #f0f0f0;
43 | --vc-twitter-hash-color: #98A1A4;
44 | }
45 |
46 | :root.dark {
47 | --vc-body-bg: #424242;
48 |
49 | --vc-picker-bg: #d0d0d0;
50 |
51 | --vc-input-bg: #2c2c2c;
52 | --vc-input-text: #d0d0d0;
53 | --vc-input-label: #bbbbbb;
54 | --vc-input-border: #555555;
55 |
56 | --vc-chrome-toggle-btn-highlighted: #5c5c5c;
57 |
58 | --vc-sketch-input-label: #bbbbbb;
59 | --vc-sketch-presets-border: #5a5a5a;
60 |
61 | --vc-twitter-input-border: #383838;
62 | --vc-twitter-input-color: #bbbbbb;
63 | --vc-twitter-hash-bg: #383838;
64 | --vc-twitter-hash-color: #a0acaf;
65 | --vc-twitter-input-bg: #555;
66 |
67 | --vc-ps-bg: #424242;
68 |
69 | --vc-ps-title-bg-gradient-start: #4e4e4e;
70 | --vc-ps-title-bg-gradient-end: #3a3a3a;
71 | --vc-ps-title-border: #5a5a5a;
72 | --vc-ps-title-color: #bbbbbb;
73 |
74 | --vc-ps-slider-border: #5c5c5c;
75 | --vc-ps-slider-border-bottom: #4a4a4a;
76 |
77 | --vs-ps-picker-border-dark: #b8b8b8;
78 | --vs-ps-picker-border-white: #bbbbbb;
79 |
80 | --vc-ps-btn-gradient-start: #505050;
81 | --vc-ps-btn-gradient-end: #3a3a3a;
82 | --vc-ps-btn-border: #6a6a6a;
83 | --vc-ps-btn-shadow: #2a2a2a;
84 | --vc-ps-btn-color: #bbbbbb;
85 |
86 | --vc-ps-preview-border: #3a3a3a;
87 | --vc-ps-label: #bbbbbb;
88 |
89 | --vc-ps-input-border: #666666;
90 | --vc-ps-input-shadow-dark: rgba(0, 0, 0, 0.45);
91 | --vc-ps-input-shadow-light: #2d2d2d;
92 | }
--------------------------------------------------------------------------------
/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?.[0] ? e.touches[0].pageX : e.changedTouches?.[0] ? e.changedTouches[0].pageX : 0);
10 | res.y = (e.touches?.[0] ? e.touches[0].pageY : e.changedTouches?.[0] ? 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/log.ts:
--------------------------------------------------------------------------------
1 | const _log = (category: string, ...msg: unknown[]) => {
2 | const prefix = `[${category.toUpperCase()}]`;
3 | console.log(prefix, msg);
4 | }
5 |
6 | function noop(): void {};
7 |
8 | const log = __IS_DEBUG__ ? _log : noop;
9 |
10 | export { log };
--------------------------------------------------------------------------------
/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 |
3 | declare const __IS_DEBUG__: boolean;
--------------------------------------------------------------------------------
/tests/components/ChromePicker.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test, vi } 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 |
49 | const consoleWarningSpy = vi.spyOn(console, 'warn');
50 | // make it silent for once
51 | consoleWarningSpy.mockImplementationOnce(() => undefined);
52 |
53 | await waitForRerender();
54 | await expect.element(getByTestId('fields')).not.toBeInTheDocument();
55 | // will throw an error says "[Vue warn]: Invalid prop: type check failed for prop "formats". Expected Array, got String with value "a"."
56 | expect(consoleWarningSpy).toHaveBeenCalledTimes(1);
57 |
58 | rerender({
59 | // @ts-expect-error test wrong format
60 | formats: ['rgb', 'a']
61 | });
62 | await waitForRerender();
63 | expect(getByTestId('fields').element().children.length).toBe(1);
64 |
65 | rerender({
66 | formats: ['hex', 'rgb']
67 | });
68 | await waitForRerender();
69 | // hex + rgb + btn
70 | expect(getByTestId('fields').element().children.length).toBe(3);
71 | await expect.element(getByRole('textbox', { name: 'Red' })).not.toBeInTheDocument();
72 | await expect.element(getByRole('textbox', { name: 'Hex' })).toBeVisible();
73 |
74 | rerender({
75 | formats: ['hsl']
76 | });
77 | await waitForRerender();
78 | expect(getByTestId('fields').element().children.length).toBe(1);
79 | await expect.element(getByRole('textbox', { name: 'Red' })).not.toBeInTheDocument();
80 | await expect.element(getByRole('textbox', { name: 'Hex' })).not.toBeInTheDocument();
81 | await expect.element(getByRole('textbox', { name: 'Hue' })).toBeVisible();
82 |
83 | });
84 |
85 | test('toggle button works fine', async () => {
86 | const { getByRole } = render(ChromePicker, {
87 | props: {
88 | modelValue: 'rgba(133, 115, 68, 0.5)'
89 | }
90 | });
91 | const bInput = getByRole('textbox', { name: 'Blue' });
92 | await expect.element(getByRole('textbox', { name: 'Transparency' })).toBeVisible();
93 | await expect.element(getByRole('textbox', { name: 'Red' })).toBeVisible();
94 | await expect.element(getByRole('textbox', { name: 'Green' })).toBeVisible();
95 | await expect.element(bInput).toBeVisible();
96 |
97 | const btn = getByRole('button', { name: 'Change color format' });
98 | await btn.click();
99 |
100 | const hexInput = getByRole('textbox', { name: 'Hex' });
101 | await expect.element(bInput).not.toBeInTheDocument();
102 | await expect.element(hexInput).toBeVisible();
103 |
104 | await btn.click();
105 | const hueInput = getByRole('textbox', { name: 'Hue' });
106 | await expect.element(hexInput).not.toBeInTheDocument();
107 | await expect.element(hueInput).toBeVisible();
108 | await expect.element(getByRole('textbox', { name: 'Saturation' })).toBeVisible();
109 | await expect.element(getByRole('textbox', { name: 'Lightness' })).toBeVisible();
110 | await expect.element(getByRole('textbox', { name: 'Transparency' })).toBeVisible();
111 |
112 | await btn.click();
113 | await expect.element(hueInput).not.toBeInTheDocument();
114 | await expect.element(bInput).toBeVisible();
115 | });
116 |
117 | test('change color by rgba inputs', async () => {
118 | const modelValue = { r: 130, g: 140, b: 150, a: 1 };
119 | const { getByRole, emitted } = render(ChromePicker, {
120 | props: {
121 | modelValue
122 | }
123 | });
124 | const rInput = getByRole('textbox', { name: 'Red' });
125 | const gInput = getByRole('textbox', { name: 'Green' });
126 | const bInput = getByRole('textbox', { name: 'Blue' });
127 | const aInput = getByRole('textbox', { name: 'Transparency' });
128 |
129 | // invalid value: ''
130 | await rInput.fill('');
131 | expect(emitted()['update:modelValue']).toBeUndefined();
132 |
133 | // invalid value: string
134 | await rInput.fill('foo');
135 | expect(emitted()['update:modelValue']).toBeUndefined();
136 |
137 | // r
138 | await rInput.fill('135');
139 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 135, g: 140, b: 150, a: 1 });
140 |
141 | // g
142 | await gInput.fill('145');
143 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 145, b: 150, a: 1 });
144 |
145 | // b
146 | await bInput.fill('155');
147 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 155, a: 1 });
148 |
149 | // a
150 | await aInput.fill('0.6');
151 | expect((emitted()['update:modelValue'][3] as [typeof modelValue])[0]).toStrictEqual({ r: 130, g: 140, b: 150, a: 0.6 });
152 | });
153 |
154 | test('change color by hex inputs', async () => {
155 | const modelValue = { r: 130, g: 140, b: 150, a: 1 };
156 | const { getByRole, emitted, rerender } = render(ChromePicker, {
157 | props: {
158 | modelValue
159 | }
160 | });
161 | // change to hex inputs first
162 | const btn = getByRole('button', { name: 'Change color format' });
163 | await btn.click();
164 |
165 | const hexInput = getByRole('textbox', { name: 'Hex' });
166 |
167 | // invalid value: ''
168 | await hexInput.fill('');
169 | expect(emitted()['update:modelValue']).toBeUndefined();
170 |
171 | // invalid value: 'foo'
172 | await hexInput.fill('foo');
173 | expect(emitted()['update:modelValue']).toBeUndefined();
174 |
175 | await hexInput.fill('#32a852');
176 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 });
177 |
178 | rerender({
179 | modelValue: {
180 | ...modelValue,
181 | a: 0.5
182 | }
183 | });
184 | await hexInput.fill('#32a85299');
185 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 0.6 });
186 | });
187 |
188 | test('change color by hsla inputs', async () => {
189 | const modelValue = { h: 130, s: 0.5, l: 0.5, a: 0.5 };
190 | const { getByRole, emitted } = render(ChromePicker, {
191 | props: {
192 | modelValue
193 | }
194 | });
195 | // change to hsla inputs first
196 | const btn = getByRole('button', { name: 'Change color format' });
197 | await btn.click();
198 | await btn.click();
199 |
200 | const hInput = getByRole('textbox', { name: 'Hue' });
201 | const sInput = getByRole('textbox', { name: 'Saturation' });
202 | const lInput = getByRole('textbox', { name: 'Lightness' });
203 | const aInput = getByRole('textbox', { name: 'Transparency' });
204 |
205 | // invalid value: ''
206 | await hInput.fill('');
207 | expect(emitted()['update:modelValue']).toBeUndefined();
208 |
209 | await hInput.fill('200');
210 | expect((emitted()['update:modelValue'][0] as [typeof modelValue])[0].h).toBeCloseTo(200);
211 |
212 | await sInput.fill('60');
213 | expect((emitted()['update:modelValue'][1] as [typeof modelValue])[0].s).toBeCloseTo(0.6);
214 |
215 | await lInput.fill('70');
216 | expect((emitted()['update:modelValue'][2] as [typeof modelValue])[0].l).toBeCloseTo(0.7);
217 |
218 | await aInput.fill('0.8');
219 | expect((emitted()['update:modelValue'][3] as [typeof modelValue])[0].a).toBeCloseTo(0.8);
220 | });
--------------------------------------------------------------------------------
/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 | import { tinycolor } from '../../src/index';
5 |
6 | test('render with different palette', async () => {
7 | const { getByRole } = render(CompactPicker, {
8 | props: {
9 | palette: ['#a83292', '#a8ff92', '#263d1e'],
10 | tinyColor: '#a83292'
11 | }
12 | });
13 | const paletteListElE = getByRole('listbox').element();
14 | expect(paletteListElE.childElementCount).toBe(3);
15 |
16 | await expect.element(getByRole('option').nth(0)).toHaveAttribute('aria-selected', "true");
17 | await expect.element(getByRole('option').nth(1)).toHaveAttribute('aria-selected', "false");
18 | await expect.element(getByRole('option').nth(2)).toHaveAttribute('aria-selected', "false");
19 | });
20 |
21 | test('click one of the color in the palette', async () => {
22 | const { getByRole, emitted, rerender } = render(CompactPicker, {
23 | props: {
24 | modelValue: '#a83292'
25 | } as { modelValue?: string, tinyColor?: tinycolor.ColorInput }
26 | });
27 | const options = getByRole('option');
28 | await options.nth(3).click();
29 |
30 | expect((emitted()['update:modelValue'][0] as [string])[0]).toBe('#F44E3B'.toLowerCase());
31 |
32 | // press Space bar
33 | options.nth(4).element().dispatchEvent(new KeyboardEvent('keydown', { key: ' ' }));
34 | expect((emitted()['update:modelValue'][1] as [string])[0]).toBe('#FE9200'.toLowerCase());
35 |
36 | rerender({
37 | tinyColor: tinycolor('#333')
38 | });
39 | await options.nth(7).click();
40 | expect((emitted()['update:tinyColor'][0] as [tinycolor.Instance])[0].toHexString().toUpperCase()).toBe('#A4DD00');
41 | });
42 |
43 | describe('The output value should follow the same format as the input value', async () => {
44 | const cases = [
45 | {
46 | format: 'hex8',
47 | input: '#ffffff00',
48 | expectFunc: (toBeChecked: string) => {
49 | expect(toBeChecked).toBe('#68cccaff');
50 | }
51 | },
52 | {
53 | format: 'hex',
54 | input: '#ffffff',
55 | expectFunc: (toBeChecked: string) => {
56 | expect(toBeChecked).toBe('#68ccca');
57 | }
58 | },
59 | {
60 | format: 'prgb',
61 | input: { r: '50%', g: '50%', b: '50%' },
62 | expectFunc: (toBeChecked: { r: string, g: string, b: string}) => {
63 | expect(Number(toBeChecked.r.replace('%', ''))).toBeCloseTo(41);
64 | expect(Number(toBeChecked.g.replace('%', ''))).toBeCloseTo(80);
65 | expect(Number(toBeChecked.b.replace('%', ''))).toBeCloseTo(79);
66 | },
67 | },
68 | {
69 | format: 'prgb(string)',
70 | input: 'rgb(1%, 1%, 1%)',
71 | expectFunc: (toBeChecked: string) => {
72 | expect(toBeChecked).toBe('rgb(41%, 80%, 79%)');
73 | }
74 | },
75 | {
76 | format: 'rgb',
77 | input: { r: 10, g: 10, b: 10 },
78 | expectFunc: (toBeChecked: { r: number, g: number, b: number}) => {
79 | expect(toBeChecked.r).toBeCloseTo(104);
80 | expect(toBeChecked.g).toBeCloseTo(204);
81 | expect(toBeChecked.b).toBeCloseTo(202);
82 | },
83 | },
84 | {
85 | format: 'rgb(string)',
86 | input: 'rgb(1, 1, 1)',
87 | expectFunc: (toBeChecked: string) => {
88 | expect(toBeChecked).toBe('rgb(104, 204, 202)');
89 | },
90 | },
91 | {
92 | format: 'hsv',
93 | input: { h: 0, s: 0, v: 0 },
94 | expectFunc: (toBeChecked: { h: number, s: number, v: number}) => {
95 | expect(toBeChecked.h).toBeCloseTo(179, 0);
96 | expect(toBeChecked.s).toBeCloseTo(0.49);
97 | expect(toBeChecked.v).toBeCloseTo(0.8);
98 | },
99 | },
100 | {
101 | format: 'hsl',
102 | input: { h: 0, s: 0, l: 0 },
103 | expectFunc: (toBeChecked: { h: number, s: number, l: number}) => {
104 | expect(toBeChecked.h).toBeCloseTo(179, 0);
105 | expect(toBeChecked.s).toBeCloseTo(0.5);
106 | expect(toBeChecked.l).toBeCloseTo(0.6);
107 | },
108 | },
109 | {
110 | format: 'hsv(string)',
111 | input: 'hsva(1, 1%, 1%, 1)',
112 | expectFunc: (toBeChecked: string) => {
113 | expect(toBeChecked).toBe('hsv(179, 49%, 80%)');
114 | },
115 | },
116 | {
117 | format: 'hsl(string)',
118 | input: 'hsl(1, 1%, 1%)',
119 | expectFunc: (toBeChecked: string) => {
120 | expect(toBeChecked).toBe('hsl(179, 50%, 60%)');
121 | },
122 | },
123 | ];
124 | test.each(cases)('$format', async ({ input, expectFunc }) => {
125 | const { getByRole, emitted } = render(CompactPicker, {
126 | props: {
127 | modelValue: input
128 | }
129 | });
130 | const presetColors = getByRole('option');
131 | await presetColors.nth(8).click();
132 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
133 | expectFunc((emitted()['update:modelValue'][0] as [string])[0] as any);
134 | })
135 | });
--------------------------------------------------------------------------------
/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 | });
20 |
21 | test('The class should be passed down to the inner Hue Slider', async () => {
22 | const { getByRole } = render(Hue, {
23 | props: {
24 | modelValue: 99,
25 | direction: 'vertical',
26 | class: 'test-class'
27 | },
28 | });
29 |
30 | const sliderElement = getByRole('slider').element() as HTMLElement;
31 | expect(sliderElement.classList.contains('vertical')).toBe(true);
32 | expect(sliderElement.parentElement?.classList.contains('test-class')).toBe(true);
33 | });
34 |
--------------------------------------------------------------------------------
/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('#00000000');
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('#00000000');
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, vi } 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 touch or mouse events are finished, should remove all event listeners', () => {
56 | const { getByRole } = render(Alpha, {
57 | props: {
58 | modelValue: { r: 100, g: 100, b: 100, a: 0.3 }
59 | },
60 | });
61 |
62 | const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
63 |
64 | const containerELE = getByRole('slider').element();
65 | containerELE.dispatchEvent(new MouseEvent('touchstart'));
66 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(0);
67 |
68 | window.dispatchEvent(new MouseEvent('touchend'));
69 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(4);
70 |
71 | containerELE.dispatchEvent(new MouseEvent('mousedown'));
72 | window.dispatchEvent(new MouseEvent('mouseup'));
73 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(8);
74 | });
75 |
76 | test('When up and down keyboard events are fired then update color events should be emitted with correct alpha value', async () => {
77 |
78 | const { getByRole, emitted, rerender } = render(Alpha, {
79 | props: {
80 | modelValue: { r: 100, g: 100, b: 100, a: 0.2 }
81 | }
82 | });
83 |
84 | const slider = getByRole('slider').element();
85 | const keyboardEvent1 = new KeyboardEvent('keydown', { code: 'ArrowLeft' });
86 | slider.dispatchEvent(keyboardEvent1);
87 |
88 | expect(emitted()).toHaveProperty('update:modelValue');
89 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
90 | // @ts-expect-error
91 | expect(emitted()['update:modelValue'][0]?.[0]?.a).toBeCloseTo(0.1, 0);
92 |
93 | await rerender({modelValue : { r: 100, g: 100, b: 100, a: 0 }});
94 | const keyboardEvent2 = new KeyboardEvent('keydown', { code: 'ArrowLeft' });
95 | slider.dispatchEvent(keyboardEvent2);
96 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
97 | // @ts-expect-error
98 | expect(emitted()['update:modelValue'][1]?.[0]?.a).toBe(0);
99 |
100 |
101 | const keyboardEvent3 = new KeyboardEvent('keydown', { code: 'ArrowRight' });
102 | slider.dispatchEvent(keyboardEvent3);
103 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
104 | // @ts-expect-error
105 | expect(emitted()['update:modelValue'][2]?.[0]?.a).toBe(0.1);
106 |
107 | await rerender({modelValue : { r: 100, g: 100, b: 100, a: 1 }});
108 | const keyboardEvent4 = new KeyboardEvent('keydown', { code: 'ArrowRight' });
109 | slider.dispatchEvent(keyboardEvent4);
110 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
111 | // @ts-expect-error
112 | expect(emitted()['update:modelValue'][3]?.[0]?.a).toBe(1);
113 | });
114 |
--------------------------------------------------------------------------------
/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, vi } 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 | // test invalid input format
27 | rerender({ modelValue: 'abc' });
28 | await waitForRerender();
29 | expect(pointerElement?.style.left).toBe('100%');
30 |
31 |
32 | // ======= vertical =======
33 |
34 | rerender({ direction: 'vertical', modelValue: 180 });
35 | await waitForRerender();
36 | expect(pointerElement?.style.top).toBe('50%');
37 | expect(pointerElement?.style.left).toBe('0px');
38 |
39 | rerender({ direction: 'vertical', modelValue: 200 });
40 | await waitForRerender();
41 | rerender({ direction: 'vertical', modelValue: 0 });
42 | await waitForRerender();
43 | expect(pointerElement?.style.top).toBe('0px');
44 | expect(pointerElement?.style.left).toBe('0px');
45 | });
46 |
47 | test('Click the pointer and update color events should be emitted with correct alpha value (horizontally)', () => {
48 | const { getByRole, emitted } = render(Hue, {
49 | props: {
50 | modelValue: 10,
51 | direction: 'horizontal',
52 | },
53 | });
54 |
55 | const slider = getByRole('slider');
56 | const box = (slider.element() as HTMLElement).getBoundingClientRect();
57 | // click the middle position of the slider
58 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }));
59 | expect(emitted()['update:modelValue'][0]).toEqual([180]);
60 |
61 | // click the left outer space of the slider
62 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: -10, clientY: box.top + box.height / 2 }));
63 | expect(emitted()['update:modelValue'][1]).toEqual([0]);
64 |
65 | // click the right outer space of the slider
66 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width + 100, clientY: box.top + box.height / 2 }));
67 | expect(emitted()['update:modelValue'][2]).toEqual([360]);
68 | });
69 |
70 | test('Click the pointer and update color events should be emitted with correct alpha value (vertically)', () => {
71 | const { getByRole, emitted } = render(Hue, {
72 | props: {
73 | modelValue: 10,
74 | direction: 'vertical',
75 | },
76 | });
77 |
78 | const slider = getByRole('slider');
79 | const box = (slider.element() as HTMLElement).getBoundingClientRect();
80 | // click the middle position of the slider
81 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }));
82 | expect(emitted()['update:modelValue'][0]).toEqual([180]);
83 |
84 | // click the top outer space of the slider
85 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: - 10 }));
86 | expect(emitted()['update:modelValue'][1]).toEqual([360]);
87 |
88 | // click the bottom outer space of the slider
89 | slider.element().dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: box.left + box.width / 2, clientY: box.top + box.height + 100 }));
90 | expect(emitted()['update:modelValue'][2]).toEqual([0]);
91 | });
92 |
93 | test('When touch or mouse events are finished, should remove all event listeners', () => {
94 | const { getByRole } = render(Hue, {
95 | props: {
96 | modelValue: 10,
97 | direction: 'vertical',
98 | },
99 | });
100 |
101 | const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
102 |
103 | const containerELE = getByRole('slider').element();
104 | containerELE.dispatchEvent(new MouseEvent('touchstart'));
105 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(0);
106 |
107 | window.dispatchEvent(new MouseEvent('touchend'));
108 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(4);
109 |
110 | containerELE.dispatchEvent(new MouseEvent('mousedown'));
111 | window.dispatchEvent(new MouseEvent('mouseup'));
112 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(8);
113 | });
114 |
115 | const keyboardEventCases = [
116 | {
117 | keyboardEventCode: 'ArrowUp',
118 | direction: 'vertical' as const,
119 | oppositeDirection: 'horizontal' as const,
120 | initialValue: 11.1,
121 | changedValueNormally: 13,
122 | valueOfLimitation: 360
123 | },
124 | {
125 | keyboardEventCode: 'ArrowDown',
126 | direction: 'vertical' as const,
127 | oppositeDirection: 'horizontal' as const,
128 | initialValue: 11.1,
129 | changedValueNormally: 10,
130 | valueOfLimitation: 0
131 | },
132 | {
133 | keyboardEventCode: 'ArrowLeft',
134 | direction: 'horizontal' as const,
135 | oppositeDirection: 'vertical' as const,
136 | initialValue: 15.1,
137 | changedValueNormally: 14,
138 | valueOfLimitation: 0
139 | },
140 | {
141 | keyboardEventCode: 'ArrowRight',
142 | direction: 'horizontal' as const,
143 | oppositeDirection: 'vertical' as const,
144 | initialValue: 50.6,
145 | changedValueNormally: 52,
146 | valueOfLimitation: 360
147 | }
148 | ];
149 |
150 | describe('When keyboard events is fired, update color events should be emitted with correct value', () => {
151 | test.each(keyboardEventCases)('$keyboardEventCode', async ({ direction, oppositeDirection, initialValue, keyboardEventCode, changedValueNormally, valueOfLimitation}) => {
152 | const { getByRole, emitted, rerender } = render(Hue, {
153 | props: {
154 | modelValue: initialValue,
155 | direction: oppositeDirection as 'horizontal' | 'vertical',
156 | },
157 | });
158 | const slider = getByRole('slider').element();
159 |
160 | // scene 1: different direction
161 | slider.dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode }));
162 | expect((emitted()['update:modelValue'])).toBeUndefined();
163 |
164 | // scene 2: changes value normally
165 | rerender({
166 | direction
167 | })
168 | await waitForRerender();
169 | slider.dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode }));
170 | expect((emitted()['update:modelValue'][0] as [number])[0]).toEqual(changedValueNormally);
171 |
172 |
173 | // scene 3: exceed limitation
174 | rerender({
175 | modelValue: valueOfLimitation
176 | });
177 | await waitForRerender();
178 | slider.dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode }));
179 | expect((emitted()['update:modelValue'][1] as [number])[0]).toEqual(valueOfLimitation);
180 | });
181 | })
--------------------------------------------------------------------------------
/tests/components/common/SaturationSlider.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test, describe, vi } 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 | vi.useFakeTimers();
73 |
74 | containerELE.dispatchEvent(new MouseEvent('touchstart'));
75 | window.dispatchEvent(new MouseEvent('touchmove', { button: 0, clientX: box.left + box.width / 4, clientY: box.top + box.height / 4 }));
76 | expect(emitted()['update:modelValue'][0]).toEqual([{h: 100, a: 1, s: 0.25, v: 0.75}]);
77 |
78 | // special handling when reaching to the bottom edge of the container
79 | window.dispatchEvent(new MouseEvent('touchmove', { button: 0, clientX: box.left + box.width / 4, clientY: box.top + box.height - 1 }));
80 | vi.advanceTimersByTime(20);
81 | expect((emitted()['update:modelValue'][1] as [{s: number}])[0].s).toBeCloseTo(0.25);
82 | expect((emitted()['update:modelValue'][1] as [{v: number}])[0].v).toBeCloseTo(0.01);
83 |
84 | // special handling when reaching to the left edge of the container
85 | window.dispatchEvent(new MouseEvent('touchmove', { button: 0, clientX: 1, clientY: box.top + box.height / 4 }));
86 | vi.advanceTimersByTime(20);
87 | expect((emitted()['update:modelValue'][2] as [{s: number}])[0].s).toBeCloseTo(0.01);
88 | expect((emitted()['update:modelValue'][2] as [{v: number}])[0].v).toBeCloseTo(0.75);
89 |
90 | // out of container
91 | window.dispatchEvent(new MouseEvent('touchmove', { button: 0, clientX: box.width + 10, clientY: box.height + 10 }));
92 | vi.advanceTimersByTime(20);
93 | expect(emitted()['update:modelValue'][3]).toEqual([{h: 0, a: 1, s: 0, v: 0}]);
94 |
95 | // out of container
96 | window.dispatchEvent(new MouseEvent('touchmove', { button: 0, clientX: -10, clientY: -10 }));
97 | vi.advanceTimersByTime(20);
98 | expect(emitted()['update:modelValue'][4]).toEqual([{h: 0, a: 1, s: 0, v: 1}]);
99 |
100 | vi.useRealTimers();
101 | });
102 |
103 | test('When touch or mouse events are finished, should remove all event listeners', () => {
104 | const { getByRole } = render(Saturation, { props: {
105 | modelValue: {
106 | h: 100,
107 | s: 0.1,
108 | v: 0.1,
109 | a: 1
110 | }
111 | } });
112 |
113 | const containerELE = getByRole('application').element();
114 | const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
115 |
116 | containerELE.dispatchEvent(new MouseEvent('touchstart'));
117 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(0);
118 |
119 | window.dispatchEvent(new MouseEvent('touchend'));
120 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(6);
121 |
122 | containerELE.dispatchEvent(new MouseEvent('mousedown'));
123 | window.dispatchEvent(new MouseEvent('mouseup'));
124 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(12);
125 | });
126 |
127 | const initialValueOfKeyboardEventCases = { h: 100, s: 0.1, v: 0.1, a: 1 };
128 | const keyboardEventCases = [
129 | {
130 | keyboardEventCode: 'ArrowUp',
131 | initialValue: initialValueOfKeyboardEventCases,
132 | expectedValue: {
133 | ...initialValueOfKeyboardEventCases,
134 | v: initialValueOfKeyboardEventCases.v + 0.01
135 | }
136 | },
137 | {
138 | keyboardEventCode: 'ArrowDown',
139 | initialValue: initialValueOfKeyboardEventCases,
140 | expectedValue: {
141 | ...initialValueOfKeyboardEventCases,
142 | v: initialValueOfKeyboardEventCases.v - 0.01
143 | }
144 | },
145 | {
146 | keyboardEventCode: 'ArrowLeft',
147 | initialValue: initialValueOfKeyboardEventCases,
148 | expectedValue: {
149 | ...initialValueOfKeyboardEventCases,
150 | s: initialValueOfKeyboardEventCases.s - 0.01
151 | }
152 | },
153 | {
154 | keyboardEventCode: 'ArrowRight',
155 | initialValue: initialValueOfKeyboardEventCases,
156 | expectedValue: {
157 | ...initialValueOfKeyboardEventCases,
158 | s: initialValueOfKeyboardEventCases.s + 0.01
159 | }
160 | },
161 | // start to test edge cases
162 | {
163 | keyboardEventCode: 'ArrowUp',
164 | initialValue: { h: 100, s: 0.1, v: 1, a: 1 },
165 | expectedValue: { h: 100, s: 0.1, v: 1, a: 1 }
166 | },
167 | {
168 | keyboardEventCode: 'ArrowDown',
169 | initialValue: { h: 0, s: 0, v: 0, a: 1 },
170 | expectedValue: { h: 0, s: 0, v: 0, a: 1 },
171 | },
172 | {
173 | keyboardEventCode: 'ArrowLeft',
174 | initialValue: { h: 0, s: 0, v: 0.1, a: 1 },
175 | expectedValue: { h: 0, s: 0, v: 0.1, a: 1 },
176 | },
177 | {
178 | keyboardEventCode: 'ArrowRight',
179 | initialValue: { h: 100, s: 1, v: 0.1, a: 1 },
180 | expectedValue: { h: 100, s: 1, v: 0.1, a: 1 },
181 | }
182 | ];
183 |
184 | describe('When keyboard event is fired, update color events should be emitted with correct value', () => {
185 | test.each(keyboardEventCases)('$keyboardEventCode', async ({ keyboardEventCode, initialValue, expectedValue}) => {
186 | const container = document.createElement('div');
187 | document.body.appendChild(container);
188 | container.style.width = '100px';
189 | container.style.height = '100px';
190 | container.style.position = 'relative';
191 |
192 | const { getByRole, emitted } = render(Saturation, { props: {
193 | modelValue: initialValue
194 | }, container });
195 |
196 | getByRole('slider').element().dispatchEvent(new KeyboardEvent('keydown', { code: keyboardEventCode }));
197 | const returnedValue = (emitted()['update:modelValue'][0] as [typeof expectedValue])[0];
198 | (Object.keys(returnedValue) as [keyof typeof initialValue]).forEach((k) => {
199 | expect(returnedValue[k]).toBeCloseTo(expectedValue[k]);
200 | });
201 | });
202 | });
--------------------------------------------------------------------------------
/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 { defineConfig } from 'vite';
2 | import vue from '@vitejs/plugin-vue';
3 |
4 | import { dirname, resolve } from 'path';
5 | import { fileURLToPath } from 'url';
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = dirname(__filename);
8 |
9 | // https://vite.dev/config/
10 | export default defineConfig({
11 | plugins: [vue()],
12 | define: {
13 | __IS_DEBUG__: !!process.env.VITE_DEBUG
14 | },
15 | build: {
16 | lib: {
17 | entry: resolve(__dirname, 'src/index.ts'),
18 | name: 'VueColor',
19 | // the proper extensions will be added
20 | fileName: 'vue-color',
21 | },
22 | rollupOptions: {
23 | // make sure to externalize deps that shouldn't be bundled
24 | // into your library
25 | external: ['vue'],
26 | output: {
27 | // Provide global variables to use in the UMD build
28 | // for externalized deps
29 | globals: {
30 | vue: 'Vue',
31 | },
32 | },
33 | },
34 | },
35 | })
36 |
--------------------------------------------------------------------------------
/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 | define: {
32 | __IS_DEBUG__: false
33 | },
34 | },
35 | ])
36 |
--------------------------------------------------------------------------------
/vue2/README.md:
--------------------------------------------------------------------------------
1 | # Vue 2.7 Compatibility Layer
2 |
3 | This folder provides backward compatibility for Vue 2.7.
4 | It acts as a separate project but reuses the same `vue-color` source components.
5 |
6 | ## Build
7 |
8 | Vue 2.7 and Vue 3 have different APIs, so a standalone build process is required.
9 |
10 | We use **Vite** with the [`@vitejs/plugin-vue2`](https://www.npmjs.com/package/@vitejs/plugin-vue2) plugin.
11 |
12 | - **Output directory**: `./dist/vue2`
13 |
14 | ## Testing
15 |
16 | **Vitest** does not work well with Vue 2.7 in this project and was primarily designed for Vue 3.
17 |
18 | Therefore, we use **Jest**, which requires **Babel** setup.
19 |
20 | This folder includes only essential test cases to validate `v-model` behavior for Vue 2.7 components.
21 |
--------------------------------------------------------------------------------
/vue2/babel.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | presets: [
3 | ['@babel/preset-env', {targets: {node: 'current'}}],
4 | '@babel/preset-typescript',
5 | ]
6 | };
--------------------------------------------------------------------------------
/vue2/demo/App.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
36 |
▸ This demo is running on Vue {{version}} ◂
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/vue2/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vue-color + Vue 2.7
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/vue2/demo/main.ts:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import App from './App.vue';
3 |
4 | new Vue({
5 | render: h => h(App),
6 | }).$mount('#app');
7 |
--------------------------------------------------------------------------------
/vue2/demo/module.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'vue-color/vue2' {
2 | import { Component } from 'vue';
3 | import tinycolor from 'tinycolor2';
4 |
5 | export const ChromePicker: Component;
6 | export const SketchPicker: Component;
7 | export const PhotoshopPicker: Component;
8 | export const CompactPicker: Component;
9 | export const GrayscalePicker: Component;
10 | export const MaterialPicker: Component;
11 | export const SliderPicker: Component;
12 | export const TwitterPicker: Component;
13 | export const SwatchesPicker: Component;
14 | export const HueSlider: Component;
15 | export const tinycolor: tinycolor;
16 | }
17 |
18 | // shims-css.d.ts
19 | declare module '*.css' {
20 | const content: { [className: string]: string };
21 | export default content;
22 | }
23 |
--------------------------------------------------------------------------------
/vue2/demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import vue from '@vitejs/plugin-vue2';
3 |
4 | import { dirname, resolve } from 'path';
5 | import { fileURLToPath } from 'url';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = dirname(__filename);
9 |
10 | // https://vite.dev/config/
11 | export default defineConfig({
12 | plugins: [vue()],
13 | define: {
14 | __IS_DEBUG__: !!process.env.VITE_DEBUG
15 | },
16 | resolve: {
17 | alias: [
18 | {
19 | find: 'tinycolor2',
20 | replacement: resolve(__dirname, '../node_modules/tinycolor2/esm/tinycolor.js'),
21 | },
22 | {
23 | find:'material-colors',
24 | replacement: resolve(__dirname, '../node_modules/material-colors/dist/colors.es2015.js'),
25 | },
26 | {
27 | find: /^vue-color\/vue2$/,
28 | replacement: resolve(__dirname, '../../dist/vue2/vue-color.js')
29 | // replacement: resolve(__dirname, '../../src/index.ts')
30 | },
31 | {
32 | find: /^vue-color\/vue2\/style.css$/,
33 | replacement: resolve(__dirname, '../../dist/vue2/vue-color.css')
34 | }
35 | ]
36 | }
37 | })
38 |
--------------------------------------------------------------------------------
/vue2/jest.config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | import type { Config } from 'jest';
7 |
8 |
9 | const config: Config = {
10 |
11 | // All imported modules in your tests should be mocked automatically
12 | // automock: false,
13 |
14 | // Stop running tests after `n` failures
15 | // bail: 0,
16 |
17 | // The directory where Jest should store its cached dependency information
18 | // cacheDirectory: "/private/var/folders/b6/dmm2k_4j3md3lqt32676fzdr0000gn/T/jest_dx",
19 |
20 | // Automatically clear mock calls, instances, contexts and results before every test
21 | clearMocks: true,
22 |
23 | // Indicates whether the coverage information should be collected while executing the test
24 | // collectCoverage: false,
25 |
26 | // An array of glob patterns indicating a set of files for which coverage information should be collected
27 | // collectCoverageFrom: undefined,
28 |
29 | // The directory where Jest should output its coverage files
30 | // coverageDirectory: undefined,
31 |
32 | // An array of regexp pattern strings used to skip coverage collection
33 | // coveragePathIgnorePatterns: [
34 | // "/node_modules/"
35 | // ],
36 |
37 | // Indicates which provider should be used to instrument code for coverage
38 | coverageProvider: "v8",
39 |
40 | // A list of reporter names that Jest uses when writing coverage reports
41 | // coverageReporters: [
42 | // "json",
43 | // "text",
44 | // "lcov",
45 | // "clover"
46 | // ],
47 |
48 | // An object that configures minimum threshold enforcement for coverage results
49 | // coverageThreshold: undefined,
50 |
51 | // A path to a custom dependency extractor
52 | // dependencyExtractor: undefined,
53 |
54 | // Make calling deprecated APIs throw helpful error messages
55 | // errorOnDeprecated: false,
56 |
57 | // The default configuration for fake timers
58 | // fakeTimers: {
59 | // "enableGlobally": false
60 | // },
61 |
62 | // Force coverage collection from ignored files using an array of glob patterns
63 | // forceCoverageMatch: [],
64 |
65 | // A path to a module which exports an async function that is triggered once before all test suites
66 | // globalSetup: undefined,
67 |
68 | // A path to a module which exports an async function that is triggered once after all test suites
69 | // globalTeardown: undefined,
70 |
71 | // A set of global variables that need to be available in all test environments
72 | // globals: {},
73 |
74 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
75 | // maxWorkers: "50%",
76 |
77 | // An array of directory names to be searched recursively up from the requiring module's location
78 | // moduleDirectories: [
79 | // "node_modules"
80 | // ],
81 |
82 | // An array of file extensions your modules use
83 | moduleFileExtensions: ["js", "ts", "vue"],
84 |
85 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
86 | moduleNameMapper: {
87 | '^@components/(.*)$': '/../src/components/$1',
88 | '^vue$': '/node_modules/vue/dist/vue.runtime.common.js',
89 | '^tinycolor2$': '/node_modules/tinycolor2/cjs/tinycolor.js',
90 | '^material-colors$': '/node_modules/material-colors/dist/colors.js',
91 | },
92 |
93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
94 | // modulePathIgnorePatterns: [],
95 |
96 | // Activates notifications for test results
97 | // notify: false,
98 |
99 | // An enum that specifies notification mode. Requires { notify: true }
100 | // notifyMode: "failure-change",
101 |
102 | // A preset that is used as a base for Jest's configuration
103 | // preset: undefined,
104 |
105 | // Run tests from one or more projects
106 | // projects: undefined,
107 |
108 | // Use this configuration option to add custom reporters to Jest
109 | // reporters: undefined,
110 |
111 | // Automatically reset mock state before every test
112 | // resetMocks: false,
113 |
114 | // Reset the module registry before running each individual test
115 | // resetModules: false,
116 |
117 | // A path to a custom resolver
118 | // resolver: undefined,
119 |
120 | // Automatically restore mock state and implementation before every test
121 | // restoreMocks: false,
122 |
123 | // The root directory that Jest should scan for tests and modules within
124 | // rootDir: undefined,
125 |
126 | // A list of paths to directories that Jest should use to search for files in
127 | // roots: [
128 | // ""
129 | // ],
130 |
131 | // Allows you to use a custom runner instead of Jest's default test runner
132 | // runner: "jest-runner",
133 |
134 | // The paths to modules that run some code to configure or set up the testing environment before each test
135 | setupFiles: ['/jest.setup.ts'],
136 |
137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
138 | // setupFilesAfterEnv: [],
139 |
140 | // The number of seconds after which a test is considered as slow and reported as such in the results.
141 | // slowTestThreshold: 5,
142 |
143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
144 | // snapshotSerializers: [],
145 |
146 | // The test environment that will be used for testing
147 | testEnvironment: "jsdom",
148 |
149 | // Options that will be passed to the testEnvironment
150 | // testEnvironmentOptions: {},
151 |
152 | // Adds a location field to test results
153 | // testLocationInResults: false,
154 |
155 | // The glob patterns Jest uses to detect test files
156 | testMatch: [
157 | "**/__tests__/**/*.?([mc])[jt]s?(x)",
158 | "**/?(*.)+(spec|test).?([mc])[jt]s?(x)"
159 | ],
160 |
161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
162 | // testPathIgnorePatterns: [
163 | // "/node_modules/"
164 | // ],
165 |
166 | // The regexp pattern or array of patterns that Jest uses to detect test files
167 | // testRegex: [],
168 |
169 | // This option allows the use of a custom results processor
170 | // testResultsProcessor: undefined,
171 |
172 | // This option allows use of a custom test runner
173 | // testRunner: "jest-circus/runner",
174 |
175 | // A map from regular expressions to paths to transformers
176 | transform: {
177 | "^.+\\.(j|t)s$": "babel-jest",
178 | "^.+\\.vue$": "@vue/vue2-jest"
179 | }
180 |
181 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
182 | // transformIgnorePatterns: [
183 | // "/node_modules/",
184 | // "\\.pnp\\.[^\\/]+$"
185 | // ],
186 |
187 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
188 | // unmockedModulePathPatterns: undefined,
189 |
190 | // Indicates whether each individual test should be reported during the run
191 | // verbose: undefined,
192 |
193 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
194 | // watchPathIgnorePatterns: [],
195 |
196 | // Whether to use watchman for file crawling
197 | // watchman: true,
198 | };
199 |
200 | export default config;
201 |
--------------------------------------------------------------------------------
/vue2/jest.setup.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
2 | (globalThis as any).__IS_DEBUG__ = false;
3 |
--------------------------------------------------------------------------------
/vue2/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-color-vue2",
3 | "version": "1.0.0",
4 | "description": "Testing project of Vue-color with the compatibility of Vue 2.7",
5 | "type": "module",
6 | "peerDependencies": {
7 | "vue": "^2.7.16"
8 | },
9 | "devDependencies": {
10 | "@babel/core": "^7.27.4",
11 | "@babel/preset-env": "^7.27.2",
12 | "@babel/preset-typescript": "^7.27.1",
13 | "@testing-library/vue": "^5.9.0",
14 | "@types/jest": "^30.0.0",
15 | "@types/node": "^24.0.4",
16 | "@types/tinycolor2": "^1.4.6",
17 | "@vitejs/plugin-vue2": "^2.3.3",
18 | "@vue/vue2-jest": "^29.2.6",
19 | "babel-jest": "^29.0.0",
20 | "canvas": "^3.1.0",
21 | "jest": "^29.0.0",
22 | "jest-environment-jsdom": "^30.0.0",
23 | "ts-node": "^10.9.2",
24 | "vite": "^6.3.5"
25 | },
26 | "scripts": {
27 | "build": "vite build",
28 | "demo": "vite demo",
29 | "demo:debug": "VITE_DEBUG=true npm run demo",
30 | "test": "jest"
31 | },
32 | "dependencies": {
33 | "material-colors": "^1.2.6",
34 | "tinycolor2": "^1.6.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/vue2/tests/components/ChromePicker.spec.ts:
--------------------------------------------------------------------------------
1 | import { render, fireEvent } from '@testing-library/vue';
2 | import ChromePicker from '@components/ChromePicker.vue';
3 | import { wait } from '../tool';
4 |
5 | test('change color by hex inputs', async () => {
6 | const value = { r: 130, g: 140, b: 150, a: 1 };
7 | const { getByRole, emitted } = render(ChromePicker, {
8 | props: {
9 | value
10 | }
11 | });
12 |
13 | // change to hex inputs first
14 | const btn = getByRole('button', { name: 'Change color format' });
15 | fireEvent.click(btn);
16 |
17 | // wait for click event to take effect
18 | await wait();
19 |
20 | const hexInput = getByRole('textbox', { name: 'Hex' });
21 |
22 | fireEvent.update(hexInput, '#32a85299');
23 |
24 | expect(emitted()['input'][0][0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 0.6 });
25 | });
26 |
--------------------------------------------------------------------------------
/vue2/tests/components/CompactPicker.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/vue';
2 | import CompactPicker from '@components/CompactPicker.vue';
3 | import { wait } from '../tool';
4 |
5 | test('click one of the color in the palette', async () => {
6 | const { getAllByRole, emitted } = render(CompactPicker, {
7 | props: {
8 | value: '#a83292'
9 | }
10 | });
11 | const options = getAllByRole('option');
12 | options[3].click();
13 |
14 | // wait for click event to take effect
15 | await wait();
16 |
17 | expect((emitted()['input'][0] as [string])[0]).toBe('#F44E3B'.toLowerCase());
18 | });
19 |
20 | test('he output value should follow the same format as the input value', async () => {
21 | const { getAllByRole, emitted } = render(CompactPicker, {
22 | props: {
23 | value: { r: '50%', g: '50%', b: '50%' }
24 | }
25 | });
26 | const options = getAllByRole('option');
27 |
28 | options[8].click();
29 | // wait for click event to take effect
30 | await wait();
31 |
32 | const toBeChecked = (emitted()['input'][0] as [{ r: string, g: string, b: string}])[0];
33 | expect(Number(toBeChecked.r.replace('%', ''))).toBeCloseTo(41);
34 | expect(Number(toBeChecked.g.replace('%', ''))).toBeCloseTo(80);
35 | expect(Number(toBeChecked.b.replace('%', ''))).toBeCloseTo(79);
36 | });
--------------------------------------------------------------------------------
/vue2/tests/components/GrayscalePicker.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/vue';
2 | import GrayscalePicker from '@components/GrayscalePicker.vue';
3 | import { wait } from '../tool';
4 |
5 |
6 | test('click one of the color in the palette', async () => {
7 | const { getAllByRole, emitted } = render(GrayscalePicker, {
8 | props: {
9 | value: '#E6E6E6'
10 | }
11 | });
12 | const options = getAllByRole('option');
13 | options[3].click();
14 |
15 | await wait();
16 |
17 | expect((emitted()['input'][0] as [string])[0]).toBe('#D9D9D9'.toLowerCase());
18 | });
--------------------------------------------------------------------------------
/vue2/tests/components/HueSlider.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render } from '@testing-library/vue';
3 | import Hue from '@components/HueSlider.vue';
4 | import { mockClickPosition } from '../tool';
5 |
6 | test('click and response with right value', async () => {
7 | const { getByRole, emitted } = render(Hue, {
8 | props: {
9 | value: 66
10 | },
11 | });
12 |
13 | const container = getByRole('slider');
14 |
15 | mockClickPosition({ container, left: 0.3});
16 | expect(emitted()['input'][0][0]).toBeCloseTo(108);
17 | });
18 |
19 | test('The class should be passed down to the inner Hue Slider', async () => {
20 | const { getByRole } = render(Hue, {
21 | props: {
22 | modelValue: 99,
23 | direction: 'vertical',
24 | class: 'test-class'
25 | },
26 | });
27 |
28 | const sliderElement = getByRole('slider');
29 | expect(sliderElement.classList.contains('vertical')).toBe(true);
30 | expect(sliderElement.parentElement?.classList.contains('test-class')).toBe(true);
31 | });
32 |
--------------------------------------------------------------------------------
/vue2/tests/components/MaterialPicker.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render, fireEvent } from '@testing-library/vue';
3 | import MaterialPicker from '@components/MaterialPicker.vue';
4 |
5 | test('change hex value and update color events should be emitted with correct value', async () => {
6 | const { getByLabelText, emitted } = render(MaterialPicker, {
7 | props: {
8 | value: {
9 | r: 100,
10 | g: 101,
11 | b: 102
12 | }
13 | }
14 | });
15 | const hexInput = getByLabelText('Hex');
16 |
17 | fireEvent.update(hexInput, '#49b3b1');
18 | expect((emitted()['input'][0] as [{r: number, b: number, g: number}])[0]).toStrictEqual({r: 73, g: 179, b: 177, a: 1});
19 | });
--------------------------------------------------------------------------------
/vue2/tests/components/PhotoshopPicker.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render, fireEvent } from '@testing-library/vue';
3 | import PhotoshopPicker from '@components/PhotoshopPicker.vue';
4 | import { wait } from '../tool';
5 |
6 | test('change back to current color', async () => {
7 | const { getByRole, emitted } = render(PhotoshopPicker, {
8 | props: {
9 | value: '#536da3',
10 | currentColor: '#333'
11 | }
12 | });
13 | const btn = getByRole('button', { name: 'Current color is #333' });
14 | btn.click();
15 | await wait();
16 | expect((emitted()['input'][0] as [string])[0]).toBe('#333333');
17 | });
18 |
19 | test('change color by hex inputs', async () => {
20 | const value = { r: 130, g: 140, b: 150, a: 1 };
21 | const { getByRole, emitted } = render(PhotoshopPicker, {
22 | props: {
23 | value
24 | }
25 | });
26 |
27 | const hexInput = getByRole('textbox', { name: 'Hex' });
28 |
29 | fireEvent.update(hexInput, '#32a852');
30 | expect(emitted()['input'][0][0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 });
31 | });
--------------------------------------------------------------------------------
/vue2/tests/components/SketchPicker.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render, fireEvent } from '@testing-library/vue';
3 | import SketchPicker from '@components/SketchPicker.vue';
4 | import { wait } from '../tool';
5 |
6 | test('change color by alpha input', async () => {
7 | const value = { r: 130, g: 140, b: 150, a: 1 };
8 | const { getByRole, emitted } = render(SketchPicker, {
9 | props: {
10 | value
11 | }
12 | });
13 | const alphaInput = getByRole('textbox', { name: 'Transparency' });
14 |
15 | fireEvent.update(alphaInput, '0.3');
16 | expect(emitted()['input'][0][0]).toStrictEqual({ r: 130, g: 140, b: 150, a: 0.3 });
17 | });
18 |
19 | test('change color by clicking preset color', async () => {
20 | const { getAllByRole, emitted } = render(SketchPicker, {
21 | props: {
22 | value: '#fff'
23 | }
24 | });
25 |
26 | const presetColors = getAllByRole('option');
27 |
28 | presetColors[presetColors.length - 1].click();
29 | await wait();
30 | expect(emitted()['input'][0][0]).toBe('#00000000');
31 | });
--------------------------------------------------------------------------------
/vue2/tests/components/SliderPicker.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render } from '@testing-library/vue';
3 | import SliderPicker from '@components/SliderPicker.vue';
4 | import { wait } from '../tool';
5 |
6 | test('click the swatches and emit event is fired with correct color', async () => {
7 | const { getAllByRole, emitted } = render(SliderPicker, {
8 | props: {
9 | value: {
10 | h: 120,
11 | s: 0.1,
12 | l: 0.1
13 | }
14 | }
15 | });
16 |
17 | const swatches = getAllByRole('option');
18 | swatches[1].click();
19 |
20 | await wait();
21 |
22 | const emittedColor = (emitted()['input'][0] as [{ h: number, s: number, l: number }])[0];
23 | expect(emittedColor.h).toBeCloseTo(120);
24 | expect(emittedColor.s).toBeCloseTo(0.5);
25 | expect(emittedColor.l).toBeCloseTo(0.65);
26 | });
--------------------------------------------------------------------------------
/vue2/tests/components/SwatchesPicker.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render } from '@testing-library/vue';
3 | import SwatchesPicker from '@components/SwatchesPicker.vue';
4 | import { wait } from '../tool';
5 |
6 | test('click the swatches and emit event is fired with correct color', async () => {
7 | const { getAllByRole, emitted } = render(SwatchesPicker, {
8 | props: {
9 | value: '#fff'
10 | }
11 | });
12 |
13 | const swatches = getAllByRole('option');
14 | swatches[3].click();
15 |
16 | await wait();
17 |
18 | expect((emitted()['input'][0] as [string])[0]).toBe('#e57373');
19 | });
--------------------------------------------------------------------------------
/vue2/tests/components/TwitterPicker.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | import { render, fireEvent } from '@testing-library/vue';
3 | import TwitterPicker from '@components/TwitterPicker.vue';
4 | import { wait } from '../tool';
5 |
6 | test('change color by hex input', () => {
7 | const value = { r: 130, g: 140, b: 150, a: 1 };
8 | const { getByRole, emitted } = render(TwitterPicker, {
9 | props: {
10 | value
11 | }
12 | });
13 |
14 | const hexInput = getByRole('textbox', { name: 'Hex' });
15 |
16 | expect((hexInput as HTMLInputElement).value).toBe('828c96');
17 |
18 | fireEvent.update(hexInput, '#32a852');
19 | expect((emitted()['input'][0] as [typeof value])[0]).toStrictEqual({ r: 50, g: 168, b: 82, a: 1 });
20 | });
21 |
22 | test('click the swatches and emit event is fired with correct color', async () => {
23 | const { getAllByRole, emitted } = render(TwitterPicker, {
24 | props: {
25 | value: '#fff'
26 | }
27 | });
28 |
29 | const swatches = getAllByRole('option');
30 | swatches[6].click();
31 | await wait();
32 |
33 | expect((emitted()['input'][0] as [string])[0]).toBe('#abb8c3');
34 | });
--------------------------------------------------------------------------------
/vue2/tests/components/common/AlphaSlider.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/vue';
2 | import Alpha from '@components/common/AlphaSlider.vue';
3 | import { mockClickPosition } from '../../tool';
4 |
5 | test('picker should reflect alpha value', () => {
6 | const { getByRole } = render(Alpha, {
7 | props: {
8 | value: { r: 100, g: 100, b: 100, a: 0.3 },
9 | },
10 | });
11 | const slider = getByRole('slider');
12 | const picker = slider.querySelector('div');
13 | expect(picker?.style.left).toBe('30%');
14 | });
15 |
16 | test('Click the pointer and update color events should be emitted with correct alpha value', () => {
17 |
18 | // jsdom doesn't do any rendering, so getBoundingClientRect() always returns 0,0,0,0
19 | // reference: https://github.com/jsdom/jsdom/issues/1590#issuecomment-243228840
20 |
21 | const { getByRole, emitted } = render(Alpha, {
22 | props: {
23 | value: { r: 100, g: 100, b: 100, a: 0.3 }
24 | },
25 | });
26 |
27 |
28 | const slider = getByRole('slider');
29 | mockClickPosition({container: slider, left: 0.5})
30 |
31 | expect(emitted()['input'][0][0]).toEqual({ r: 100, g: 100, b: 100, a: 0.5 });
32 | });
--------------------------------------------------------------------------------
/vue2/tests/components/common/EditableInput.spec.ts:
--------------------------------------------------------------------------------
1 | import { render, fireEvent } from '@testing-library/vue';
2 | import EditableInput from '@components/common/EditableInput.vue';
3 |
4 | test('Change the value and emit the event', () => {
5 | const { getByRole, emitted } = render(EditableInput, {
6 | props: {
7 | value: 'value',
8 | label: 'label',
9 | },
10 | });
11 |
12 | const textbox = getByRole('textbox');
13 | fireEvent.update(textbox, 'changed value');
14 |
15 | expect((textbox as HTMLInputElement).value).toBe('changed value');
16 | expect(emitted()['change'][0]).toEqual(['changed value']);
17 | });
--------------------------------------------------------------------------------
/vue2/tests/components/common/SaturationSlider.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/vue';
2 | import Saturation from '@components/common/SaturationSlider.vue';
3 | import { mockClickPosition } from '../../tool';
4 |
5 | test('The position of the picker should be correct', () => {
6 |
7 | const { getByRole } = render(Saturation, {
8 | props: {
9 | value: {
10 | h: 38,
11 | s: 0.35,
12 | l: 0.4
13 | }
14 | },
15 | });
16 | const slider = getByRole('slider');
17 | const styleString = slider.getAttribute('style');
18 | const styleJSON = styleString?.split(';').map(s => s.trim()).reduce((result, style) => {
19 | const [k, v] = style.split(':');
20 | if (k) {
21 | result[k] = Number(v.trim().replace('%', ''));
22 | }
23 | return result;
24 | }, {} as Record);
25 | expect(styleJSON?.left).toBeCloseTo(50, -1);
26 | expect(styleJSON?.top).toBeCloseTo(50, -1);
27 | });
28 |
29 | test('Click the pointer and update color events should be emitted with correct value', () => {
30 | const { getByRole, emitted } = render(Saturation, { props: {
31 | value: {
32 | h: 100,
33 | s: 0.1,
34 | v: 0.1,
35 | a: 1
36 | }
37 | }});
38 |
39 | const container = getByRole('application');
40 |
41 | mockClickPosition({ container });
42 | mockClickPosition({ container, top: 0.25, left: 0.25, event: 'mouseMove'});
43 |
44 | expect(emitted()['input'][0]).toEqual([{h: 100, a: 1, s: 0.25, v: 0.75}]);
45 | });
--------------------------------------------------------------------------------
/vue2/tests/tool.ts:
--------------------------------------------------------------------------------
1 | import { EventType, fireEvent } from '@testing-library/vue';
2 |
3 | /**
4 | * Simulate a mousedown event using the given `left` and `top` percentages.
5 | *
6 | * @param Object Be aware that the values of `left` and `top` are percentages.
7 | */
8 | export const mockClickPosition = ({ container, left = 0, top = 0, event = 'mouseDown' }: { container: HTMLElement, left?: number, top?: number, event?: EventType }) => {
9 |
10 | const mockContainerWidth = 100;
11 | const mockContainerHeight = 100;
12 |
13 | Object.defineProperty(container, 'clientWidth', {
14 | writable: true,
15 | configurable: true,
16 | value: mockContainerWidth,
17 | });
18 |
19 | Object.defineProperty(container, 'clientHeight', {
20 | writable: true,
21 | configurable: true,
22 | value: mockContainerHeight,
23 | });
24 |
25 | const clientX = left * mockContainerWidth;
26 | const clientY = top * mockContainerHeight;
27 |
28 | fireEvent[event](container, {
29 | clientX,
30 | clientY
31 | });
32 | }
33 |
34 | export const wait = () => {
35 | return new Promise(resolve => setTimeout(resolve));
36 | }
--------------------------------------------------------------------------------
/vue2/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 | "esModuleInterop": true,
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true,
25 |
26 | "paths": {
27 | "@components/*": ["../src/components/*"],
28 | }
29 | },
30 | "include": [
31 | "./**/*.ts", "./**/*.tsx", "./**/*.vue",
32 | "../src/**/*.ts", "../src/**/*.tsx", "../src/**/*.vue",
33 | "../tests/**/*.ts"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/vue2/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import vue from '@vitejs/plugin-vue2';
3 |
4 | import { dirname, resolve } from 'path';
5 | import { fileURLToPath } from 'url';
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = dirname(__filename);
8 |
9 | // https://vite.dev/config/
10 | export default defineConfig({
11 | plugins: [vue()],
12 | define: {
13 | __IS_DEBUG__: !!process.env.VITE_DEBUG
14 | },
15 | resolve: {
16 | alias: {
17 | 'tinycolor2': resolve(__dirname, 'node_modules/tinycolor2/esm/tinycolor.js'),
18 | 'material-colors': resolve(__dirname, 'node_modules/material-colors/dist/colors.es2015.js')
19 | }
20 | },
21 | build: {
22 | outDir: '../dist/vue2',
23 | lib: {
24 | entry: '../src/index.ts',
25 | name: 'VueColor',
26 | // the proper extensions will be added
27 | fileName: 'vue-color',
28 | },
29 | rollupOptions: {
30 | // make sure to externalize deps that shouldn't be bundled
31 | // into your library
32 | external: ['vue'],
33 | output: {
34 | // Provide global variables to use in the UMD build
35 | // for externalized deps
36 | globals: {
37 | vue: 'Vue',
38 | },
39 | },
40 | },
41 | }
42 | })
43 |
--------------------------------------------------------------------------------