├── .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 | NPM monthly downloads 5 | Github stars 6 |

7 | 8 | ![Package Size](https://img.shields.io/bundlephobia/minzip/vue-color) 9 | ![Test Coverage](https://codecov.io/gh/linx4200/vue-color/branch/main/graph/badge.svg) 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 | 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 | 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 | 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 | 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 | 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 | 196 | 197 | 290 | -------------------------------------------------------------------------------- /demo/components/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 22 | 23 | 33 | 34 | 77 | 78 | 123 | -------------------------------------------------------------------------------- /src/components/GrayscalePicker.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 30 | 31 | 73 | 74 | -------------------------------------------------------------------------------- /src/components/HueSlider.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 34 | 35 | 55 | -------------------------------------------------------------------------------- /src/components/MaterialPicker.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/SliderPicker.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 52 | 53 | 134 | 135 | 192 | -------------------------------------------------------------------------------- /src/components/SwatchesPicker.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 60 | 61 | 106 | 107 | 152 | -------------------------------------------------------------------------------- /src/components/TwitterPicker.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 48 | 49 | 116 | 117 | 227 | -------------------------------------------------------------------------------- /src/components/common/AlphaSlider.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 145 | 146 | 192 | -------------------------------------------------------------------------------- /src/components/common/CheckerboardBG.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 57 | 58 | 68 | -------------------------------------------------------------------------------- /src/components/common/EditableInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 87 | 88 | 101 | -------------------------------------------------------------------------------- /src/components/common/HueSlider.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 209 | 210 | -------------------------------------------------------------------------------- /src/components/common/SaturationSlider.vue: -------------------------------------------------------------------------------- 1 | 2 | 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 |
10 |
15 |
16 | `); 17 | }); 18 | 19 | test('render correctly by with props', async () => { 20 | const { container } = render(Checkerboard, { 21 | props: { size: 100, grey: '#333', white: '#ddd' } 22 | }) 23 | 24 | expect(container).toMatchInlineSnapshot(` 25 |
26 |
31 |
32 | `); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/components/common/EditableInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { render } from 'vitest-browser-vue'; 3 | import EditableInput from '../../../src/components/common/EditableInput.vue'; 4 | 5 | test('Render correctly with value, label and desc', async () => { 6 | const { getByText, getByRole } = render(EditableInput, { 7 | props: { 8 | value: 'value', 9 | label: 'label', 10 | desc: 'desc', 11 | }, 12 | }); 13 | 14 | expect(getByText('label')).not.toBeNull(); 15 | expect(getByText('desc')).not.toBeNull(); 16 | await expect.element(getByRole('textbox')).toHaveValue('value'); 17 | }); 18 | 19 | test('render correct aria-label with props.label', async () => { 20 | const { getByRole } = render(EditableInput, { 21 | props: { 22 | value: 'value', 23 | label: 'foo' 24 | }, 25 | }); 26 | const textbox = getByRole('textbox'); 27 | await expect.element(textbox).toHaveAccessibleName('foo'); 28 | await expect.element(textbox).toHaveAttribute('id', expect.stringContaining('input__label__foo__')); 29 | }); 30 | 31 | test('render correct aria-label with props.a11y.label', async () => { 32 | const { getByRole } = render(EditableInput, { 33 | props: { 34 | value: 'value', 35 | label: 'foo', 36 | a11y: { 37 | label: 'bar' 38 | } 39 | }, 40 | }); 41 | const textbox = getByRole('textbox'); 42 | await expect.element(textbox).toHaveAccessibleName('bar'); 43 | await expect.element(textbox).toHaveAttribute('id', expect.stringContaining('input__label__bar__')); 44 | }); 45 | 46 | test('Change the value and emit the event', async () => { 47 | const { getByRole, emitted } = render(EditableInput, { 48 | props: { 49 | value: 'value', 50 | label: 'label', 51 | }, 52 | }); 53 | const textbox = getByRole('textbox'); 54 | await textbox.fill('changed value'); 55 | await expect.element(textbox).toHaveValue('changed value'); 56 | expect(emitted()['change'][0]).toEqual(['changed value']); 57 | }); 58 | 59 | test('Input value should be within max and min range', async () => { 60 | const max = 10; 61 | const min = -10; 62 | const { getByRole, emitted } = render(EditableInput, { 63 | props: { 64 | value: 1, 65 | label: 'label', 66 | max, 67 | min, 68 | }, 69 | }); 70 | const textbox = getByRole('textbox'); 71 | await textbox.fill(`${max + 1}`); 72 | expect(emitted()['change'][0]).toEqual([max]); 73 | await textbox.fill(`${min - 1}`); 74 | expect(emitted()['change'][1]).toEqual([min]); 75 | }); 76 | 77 | test('Handle the key down event with step', async () => { 78 | const step = 2; 79 | const initialValue = 1; 80 | const { getByRole, emitted, rerender } = render(EditableInput, { 81 | props: { 82 | value: initialValue, 83 | label: 'label', 84 | step, 85 | }, 86 | }); 87 | const textbox = getByRole('textbox'); 88 | 89 | textbox.element().dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowUp' })); 90 | expect(emitted()['change'][0]).toEqual([`${initialValue + step}`]); 91 | 92 | textbox.element().dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown' })); 93 | expect(emitted()['change'][1]).toEqual([`${initialValue - step}`]); 94 | 95 | rerender({ step: 2.2, value: 1.111 }); 96 | await Promise.resolve(); 97 | textbox.element().dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowUp' })); 98 | expect(emitted()['change'][2]).toEqual(['3.3']); 99 | }); -------------------------------------------------------------------------------- /tests/components/common/HueSlider.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, 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 | 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 | --------------------------------------------------------------------------------