├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── 01_bug_report.md │ └── 02_feature_request.md └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── api-extractor.json ├── changelog-template-commit.hbs ├── commitlint.config.js ├── dist ├── ColorPicker.css ├── ColorPicker.d.ts └── ColorPicker.js ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── release.config.js ├── src ├── ColorPicker.test.ts ├── ColorPicker.vue ├── index.test.ts ├── index.ts ├── types.ts ├── utilities │ ├── CssValues.test.ts │ ├── CssValues.ts │ ├── clamp.test.ts │ ├── clamp.ts │ ├── colorConversions │ │ ├── convertHexToRgb.test.ts │ │ ├── convertHexToRgb.ts │ │ ├── convertHslToHsv.test.ts │ │ ├── convertHslToHsv.ts │ │ ├── convertHslToRgb.test.ts │ │ ├── convertHslToRgb.ts │ │ ├── convertHsvToHsl.test.ts │ │ ├── convertHsvToHsl.ts │ │ ├── convertHsvToHwb.test.ts │ │ ├── convertHsvToHwb.ts │ │ ├── convertHsvToRgb.test.ts │ │ ├── convertHsvToRgb.ts │ │ ├── convertHwbToHsv.test.ts │ │ ├── convertHwbToHsv.ts │ │ ├── convertRgbToHex.test.ts │ │ ├── convertRgbToHex.ts │ │ ├── convertRgbToHsl.test.ts │ │ ├── convertRgbToHsl.ts │ │ ├── convertRgbToHwb.test.ts │ │ └── convertRgbToHwb.ts │ ├── colorsAreValueEqual.test.ts │ ├── colorsAreValueEqual.ts │ ├── convert.ts │ ├── detectFormat.test.ts │ ├── detectFormat.ts │ ├── formatAsCssColor.test.ts │ ├── formatAsCssColor.ts │ ├── getNewThumbPosition.test.ts │ ├── getNewThumbPosition.ts │ ├── isValidHexColor.test.ts │ ├── isValidHexColor.ts │ ├── parsePropsColor.test.ts │ ├── parsePropsColor.ts │ ├── round.test.ts │ └── round.ts └── vue-shim.d.ts ├── tsconfig.build-types.json ├── tsconfig.json └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something isn’t working as expected 4 | --- 5 | 6 | ## Steps to reproduce 7 | 8 | 9 | 10 | ## Current result 11 | 12 | 13 | 14 | ## Expected result 15 | 16 | 17 | 18 | ## Environment 19 | 20 | - **vue-accessible-color-picker** version: 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Something is missing 4 | --- 5 | 6 | ## Description 7 | 8 | 9 | 10 | ## Motivation 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | run-name: "Release (branch: ${{ github.event.workflow_run.head_branch }})" 3 | 4 | on: 5 | workflow_run: 6 | workflows: [Tests] 7 | types: [completed] 8 | branches: [main] 9 | 10 | permissions: 11 | contents: read 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-release 15 | 16 | jobs: 17 | release: 18 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | issues: write 23 | pull-requests: write 24 | id-token: write 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | ref: ${{ github.event.workflow_run.head_branch }} 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version-file: .nvmrc 33 | - uses: actions/cache/restore@v4 34 | with: 35 | path: | 36 | **/node_modules 37 | key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }} 38 | - run: npm run build 39 | - env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 42 | run: npm run release 43 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: [opened, reopened, synchronize] 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | cancel-in-progress: true 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | 16 | jobs: 17 | tests: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 # https://github.com/actions/checkout 21 | - uses: actions/setup-node@v4 # https://github.com/actions/setup-node 22 | with: 23 | node-version-file: .nvmrc 24 | - uses: actions/cache@v4 # https://github.com/actions/cache 25 | id: node-modules-cache 26 | with: 27 | path: | 28 | **/node_modules 29 | key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }} 30 | - if: steps.node-modules-cache.outputs.cache-hit != 'true' 31 | run: npm clean-install 32 | - run: npm audit signatures 33 | - run: npm run lint 34 | - run: npm run test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | coverage 3 | node_modules 4 | temp 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run fix 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.12.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [5.2.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v5.1.1...v5.2.0) (2025-01-15) 2 | 3 | ### Features 4 | 5 | * add copy prop to customize copy function ([0c2bcde](https://github.com/kleinfreund/vue-accessible-color-picker/commit/0c2bcdec018668f2375912af10226d9bc92600e7)), closes [#29](https://github.com/kleinfreund/vue-accessible-color-picker/issues/) 6 | 7 | Add the optional prop `copy` that allows customization of the copy function used by the color picker's copy interactions. This can be useful if `window.navigator.clipboard.writeText` isn't available/usable. 8 | 9 | ## [5.1.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v5.1.0...v5.1.1) (2024-12-20) 10 | 11 | ### Bug Fixes 12 | 13 | * not using fully opaque color for alpha slider ([cd0772b](https://github.com/kleinfreund/vue-accessible-color-picker/commit/cd0772b76375679d65f6834f92879a9d56dd4e1c)) 14 | 15 | ## [5.1.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v5.0.1...v5.1.0) (2024-10-20) 16 | 17 | ### Features 18 | 19 | * add color-copy event ([910f12c](https://github.com/kleinfreund/vue-accessible-color-picker/commit/910f12c5f48d9a8475a8dd73e46792248d2bf214)) 20 | 21 | Add a new event `copy-color` that is fired once a copy operation succeeded. Its event data is the same as that of the `color-change` event. 22 | 23 | ## [5.0.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v5.0.0...v5.0.1) (2023-11-23) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * cannot find module TypeScript error ([577b4b4](https://github.com/kleinfreund/vue-accessible-color-picker/commit/577b4b4286d2d909c87d8b027bb073c6e8a1ae0d)) 29 | 30 | Adds the types field back to the package.json file to prevent the "Cannot find module 'vue-accessible-color-picker' or its corresponding type declarations." error. 31 | 32 | ## [5.0.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.1.4...v5.0.0) (2023-11-23) 33 | 34 | 35 | ### ⚠ BREAKING CHANGES 36 | 37 | * Renames the following CSS custom properties: `--vacp-border-color` → `--vacp-color-border`, `--vacp-border-width` → `--vacp-width-border`, `--vacp-color-space-width` → `--vacp-width-color-space`, `--vacp-focus-color` → `--vacp-color-focus` (see README.md for the full list of supported custom properties). 38 | * Removes the following CSS custom properties: `--vacp-border` (direct replacement: `var(--vacp-width-border) solid var(--vacp-color-border)`), `--vacp-focus-outline` (direct replacement: `2px solid var(--vacp-color-focus)`). 39 | * Changes how color objects provided to the `color` prop are handled. Color objects no longer use values that are constrained to the range [0, 1] (except for any alpha channel values). **How to update**: Multiply any value of objects you pass to the `color` prop by the number in parentheses corresponding to the right color channel: For HSL: h (360), s (100), l (100). For HWB: h (360), w (100), b (100). For RGB: r (255), g (255), b (255). 40 | * Changes the data emitted by the `color-change` event such that the values on the `colors` object are no longer constrained to the range [0, 1] (except for any alpha channel values). **How to update**: Divide any value of objects from the `colors` object you mkae use of by the number in parentheses corresponding to the right color channel: For HSL: h (360), s (100), l (100). For HWB: h (360), w (100), b (100). For RGB: r (255), g (255), b (255). 41 | * The component, when imported using the default module specifier `vue-accessible-color-picker`, no longer injects styles into the document. **How to update**: Import the styles either locally inside a Vue single file component's `style` block (using `@import url('vue-accessible-color-picker/styles');`) or globally in your application's entry point (commonly a main.js file somewhere). 42 | * Removes the module specifier `vue-accessible-color-picker/unstyled`. It's no longer needed because `vue-accessible-color-picker` now resolves to a component without styles. **How to update**: Import from `vue-accessible-color-picker` instead. 43 | * Removes the module specifier `vue-accessible-color-picker/types/index.d.ts`. **How to update**: Import from `vue-accessible-color-picker` instead. 44 | * Renames the type `ColorChangeEvent` to `ColorChangeDetail`. 45 | 46 | 47 | ### Features 48 | 49 | * make theming using custom properties easier ([e3147aa](https://github.com/kleinfreund/vue-accessible-color-picker/commit/e3147aa944a7e30bbe4256b74f44bb2bb14e2dbe)) 50 | 51 | Simplifies theming of the color picker GUI with CSS custom properties by making better use of the CSS cascade. Customizing the custom properties (e.g. `--vacp-focus-color`) can now be done on any ancestor element of `.vacp-color-picker` in addition to `.vacp-color-picker` itself. For example, you can set `--vacp-focus-color: orange` on `:root` and it will work. 52 | 53 | Adds the following CSS custom properties for theming: `--vacp-color-background-input`, `--vacp-color-background`, `--vacp-color-text-input`, `--vacp-color-text`, `--vacp-font-family`, `--vacp-font-size` (see README.md for the full list of supported custom properties). 54 | 55 | **BREAKING CHANGE**: Renames the following CSS custom properties: `--vacp-border-color` → `--vacp-color-border`, `--vacp-border-width` → `--vacp-width-border`, `--vacp-color-space-width` → `--vacp-width-color-space`, `--vacp-focus-color` → `--vacp-color-focus` (see README.md for the full list of supported custom properties). 56 | 57 | **BREAKING CHANGE**: Removes the following CSS custom properties: `--vacp-border` (direct replacement: `var(--vacp-width-border) solid var(--vacp-color-border)`), `--vacp-focus-outline` (direct replacement: `2px solid var(--vacp-color-focus)`). 58 | 59 | * support all angle values as input ([3fac65e](https://github.com/kleinfreund/vue-accessible-color-picker/commit/3fac65ed1b2fcf8f1c19dbce38410e5ccfae943b)) 60 | 61 | Adds support for the angle value units `deg`, `grad`, `rad`, and `turn` when entering hues (see https://www.w3.org/TR/css-values-4/#angle-value). 62 | 63 | Stops normalizing angle values to the range [0, 360) (e.g. a hue value of 450 will no longer be processed, stored, and emitted as 90). 64 | 65 | 66 | ### Code Refactoring 67 | 68 | * change color channels to not be constrained to the range [0, 1] ([93fce2c](https://github.com/kleinfreund/vue-accessible-color-picker/commit/93fce2cc442f5158b4d3fac7b2f9d13711e785a7)) 69 | 70 | Changes how color objects are processed (via the `color` prop), stored, and emitted (via the `color-change` event) such that the representation of the current color doesn't have its values constrained to the range [0, 1] (inclusive) anymore. Instead, the values are now stored as close as possible to the native representation in CSS (e.g. the hue value 270 is now stored as 270 instead of 0.75). Alpha channel values continue to be stored in the range [0, 1]. 71 | 72 | **BREAKING CHANGE**: Changes how color objects provided to the `color` prop are handled. Color objects no longer use values that are constrained to the range [0, 1] (except for any alpha channel values). **How to update**: Multiply any value of objects you pass to the `color` prop by the number in parentheses corresponding to the right color channel: For HSL: h (360), s (100), l (100). For HWB: h (360), w (100), b (100). For RGB: r (255), g (255), b (255). 73 | 74 | **BREAKING CHANGE**: Changes the data emitted by the `color-change` event such that the values on the `colors` object are no longer constrained to the range [0, 1] (except for any alpha channel values). **How to update**: Divide any value of objects from the `colors` object you mkae use of by the number in parentheses corresponding to the right color channel: For HSL: h (360), s (100), l (100). For HWB: h (360), w (100), b (100). For RGB: r (255), g (255), b (255). 75 | * migrate code base to TypeScript ([18a2a98](https://github.com/kleinfreund/vue-accessible-color-picker/commit/18a2a98d894d859a248031aa9ef950001be8f843)) 76 | 77 | Migrates the code base to TypeScript. This required a fundamental change in the build chain as some of the previously used Rollup plugins (`rollup-plugin-vue`, `rollup-plugin-typescript`, `rollup-plugin-typescript2`) are either not being maintained anymore and/or don't work well with the combination of Vue 3 and TypeScript. The project is now built using `vite` and `@vitejs/plugin-vue` (for building the component) and `vue-tsc` and `@microsoft/api-extractor` (for bundling the type definitions). 78 | 79 | **BREAKING CHANGE**: The component, when imported using the default module specifier `vue-accessible-color-picker`, no longer injects styles into the document. **How to update**: Import the styles either locally inside a Vue single file component's `style` block (using `@import url('vue-accessible-color-picker/styles');`) or globally in your application's entry point (commonly a main.js file somewhere). 80 | 81 | **BREAKING CHANGE**: Removes the module specifier `vue-accessible-color-picker/unstyled`. It's no longer needed because `vue-accessible-color-picker` now resolves to a component without styles. **How to update**: Import from `vue-accessible-color-picker` instead. 82 | 83 | **BREAKING CHANGE**: Removes the module specifier `vue-accessible-color-picker/types/index.d.ts`. **How to update**: Import from `vue-accessible-color-picker` instead. 84 | 85 | **BREAKING CHANGE**: Renames the type `ColorChangeEvent` to `ColorChangeDetail`. 86 | 87 | ## [4.1.4](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.1.3...v4.1.4) (2023-08-02) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * not setting box-sizing on the color picker element ([651a0fd](https://github.com/kleinfreund/vue-accessible-color-picker/commit/651a0fd5fae87d3306f3c90fa50c8e94de88da28)) 93 | 94 | ## [4.1.3](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.1.2...v4.1.3) (2023-05-18) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * types being misconfigured in pkg.exports ([c56cb99](https://github.com/kleinfreund/vue-accessible-color-picker/commit/c56cb994b4a6a93263dc088390e952a25b2c86c9)) 100 | 101 | ## [4.1.2](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.1.1...v4.1.2) (2022-11-18) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * more duplicate ID attributes ([fec5ff3](https://github.com/kleinfreund/vue-accessible-color-picker/commit/fec5ff3d44c53b81c962df5aba01ddd5d075a1d9)) 107 | 108 | ## [4.1.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.1.0...v4.1.1) (2022-11-18) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * duplicate ID attributes ([e8b5e00](https://github.com/kleinfreund/vue-accessible-color-picker/commit/e8b5e0022352b972d6b7c1282ee4918496682be4)) 114 | 115 | ## [4.1.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.7...v4.1.0) (2022-10-06) 116 | 117 | 118 | ### Features 119 | 120 | * reworks color picker styles ([ad9afb8](https://github.com/kleinfreund/vue-accessible-color-picker/commit/ad9afb813720515e9e64afa22b15b7ab7a3d1eac)) 121 | 122 | ## [4.0.7](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.6...v4.0.7) (2022-10-06) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * not falling back to visible format correctly ([9530d37](https://github.com/kleinfreund/vue-accessible-color-picker/commit/9530d37455933a58af8718527bea6844effa8fdd)), closes [#23](https://github.com/kleinfreund/vue-accessible-color-picker/issues/23) 128 | 129 | ## [4.0.6](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.5...v4.0.6) (2022-09-02) 130 | 131 | 132 | ### Bug Fixes 133 | 134 | * css not being minified ([307e46b](https://github.com/kleinfreund/vue-accessible-color-picker/commit/307e46baf660e936c674a62185fb8585d4feb08c)) 135 | 136 | ## [4.0.5](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.4...v4.0.5) (2022-08-27) 137 | 138 | 139 | ### Bug Fixes 140 | 141 | * color picker initializing incorrectly without color prop ([065b5b1](https://github.com/kleinfreund/vue-accessible-color-picker/commit/065b5b16dc02af0d9740dd2b838f2050f6a3a7d5)) 142 | 143 | ## [4.0.4](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.3...v4.0.4) (2022-08-07) 144 | 145 | 146 | ### Bug Fixes 147 | 148 | * not always initially setting custom properties ([9ec0b64](https://github.com/kleinfreund/vue-accessible-color-picker/commit/9ec0b6442344a1553974f9a05594765bc69dc40a)) 149 | 150 | ## [4.0.3](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.2...v4.0.3) (2022-05-30) 151 | 152 | 153 | ### Bug Fixes 154 | 155 | * incorrectly reading hex color from prop ([9f31c3e](https://github.com/kleinfreund/vue-accessible-color-picker/commit/9f31c3eb39d4960b66fb097d083cc8c79c4b3e12)) 156 | 157 | ## [4.0.2](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.1...v4.0.2) (2022-05-24) 158 | 159 | 160 | ### Bug Fixes 161 | 162 | * incorrectly formatting hex colors with hidden alpha channel ([a849207](https://github.com/kleinfreund/vue-accessible-color-picker/commit/a8492073afe0f84f04f056ca1ea76bc27d94ec99)), closes [#112233](https://github.com/kleinfreund/vue-accessible-color-picker/issues/112233) [#1122](https://github.com/kleinfreund/vue-accessible-color-picker/issues/1122) 163 | 164 | ## [4.0.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v4.0.0...v4.0.1) (2022-05-23) 165 | 166 | 167 | ### Bug Fixes 168 | 169 | * showing alpha channel of hex color in wrong scenario ([b301c5b](https://github.com/kleinfreund/vue-accessible-color-picker/commit/b301c5bee128600fc2141906deaeeb7272cb5b2a)) 170 | 171 | ## [4.0.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v3.3.1...v4.0.0) (2022-04-03) 172 | 173 | 174 | ### Code Refactoring 175 | 176 | * **dist:** make package ES module only ([3ab745b](https://github.com/kleinfreund/vue-accessible-color-picker/commit/3ab745b2d5d2e1c3f406ec0959a62743f2001e36)) 177 | 178 | 179 | ### BREAKING CHANGES 180 | 181 | * **dist:** Adds `"type": "package"` to the package.json file which indicates that this package is now distributed primarily (and solely) in the ES module format. Previously, the package was distributed in both ES and UMD module formats. Below is a list of the individual breaking changes regarding the package’s exposed module specifiers. If you want to know what module specifiers are, you can read up on the matter in the article “Publishing and consuming ECMAScript modules via packages – the big picture” by Dr. Axel Rauschmayer (https://2ality.com/2022/01/esm-specifiers.html#referring-to-ecmascript-modules-via-specifiers). 182 | * **dist:** Changes the bare module specifiers “vue-accessible-color-picker” and “vue-accessible-color-picker/unstyled” to refer to ES modules instead of UMD modules. 183 | * **dist:** Removes the bare module specifier “vue-accessible-color-picker/esm”. The same module is now referred to as “vue-accessible-color-picker”. 184 | * **dist:** Removes the bare module specifier “vue-accessible-color-picker/esm/unstyled”. The same module is now referred to as “vue-accessible-color-picker/unstyled”. 185 | * **dist:** Removes the bare module specifier “vue-accessible-color-picker/dist/vue-accessible-color-picker-unstyled.js”. Use “vue-accessible-color-picker/unstyled” instead. 186 | 187 | ## [3.3.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v3.3.0...v3.3.1) (2022-02-14) 188 | 189 | 190 | ### Bug Fixes 191 | 192 | * **types:** declare ColorPicker type in type definitions ([dfe11f3](https://github.com/kleinfreund/vue-accessible-color-picker/commit/dfe11f3569dd945953a66196be0a5a6fc4d63532)), closes [#14](https://github.com/kleinfreund/vue-accessible-color-picker/issues/14) 193 | 194 | ## [3.3.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v3.2.0...v3.3.0) (2022-02-05) 195 | 196 | 197 | ### Features 198 | 199 | * provide untranspiled ES module bundles ([ce1dc59](https://github.com/kleinfreund/vue-accessible-color-picker/commit/ce1dc59ab364329fd41db33127762dbacf4bce21)) 200 | 201 | ## [3.2.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v3.1.0...v3.2.0) (2021-12-16) 202 | 203 | 204 | ### Features 205 | 206 | * add prop for hiding alpha channel ([cdfce86](https://github.com/kleinfreund/vue-accessible-color-picker/commit/cdfce862dcee843ae8b07531f1bfe271c5107dbb)) 207 | 208 | ## [3.1.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v3.0.1...v3.1.0) (2021-12-10) 209 | 210 | 211 | ### Features 212 | 213 | * convert to script setup syntax ([70c59a5](https://github.com/kleinfreund/vue-accessible-color-picker/commit/70c59a5a1881e4899779dd46c7ae9cd4991a460b)) 214 | 215 | ## [3.0.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v3.0.0...v3.0.1) (2021-11-04) 216 | 217 | 218 | ### Bug Fixes 219 | 220 | * clicking color space not emitting color ([c68ea8a](https://github.com/kleinfreund/vue-accessible-color-picker/commit/c68ea8a0bc838c0578b7dba4fe58f5a63025b21b)), closes [#13](https://github.com/kleinfreund/vue-accessible-color-picker/issues/13) 221 | 222 | ## [3.0.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v2.1.0...v3.0.0) (2021-03-21) 223 | 224 | 225 | ### chore 226 | 227 | * **types:** remove VueAccessibleColorPicker namespace ([696814f](https://github.com/kleinfreund/vue-accessible-color-picker/commit/696814f8d8f119499b535aba17808b0bd185215f)) 228 | * change default format to HSL ([2d746bc](https://github.com/kleinfreund/vue-accessible-color-picker/commit/2d746bc7aa28a9f7cb4c3535999c26bac9741e7e)) 229 | 230 | 231 | ### Features 232 | 233 | * improve color prop parsing ([8b74dbd](https://github.com/kleinfreund/vue-accessible-color-picker/commit/8b74dbd0d3a6bd502d62e1c367c999c8bc8d54d6)) 234 | 235 | 236 | ### BREAKING CHANGES 237 | 238 | * **types:** Removes the `VueAccessibleColorPicker` namespace from types. 239 | * Changes the default color format from `'rgb'` to `'hsl'`. To upgrade without changing this in your application, you can pass `'rgb'` to the `defaultFormat` prop. 240 | * Updates browser support on account of using `Object.fromEntries`. Most notably, Edge using the EdgeHTML engine and Safari versions before 12.2 are no longer supported. Please refer to the README.md file for the complete list. 241 | 242 | ## [2.1.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v2.0.0...v2.1.0) (2021-03-21) 243 | 244 | 245 | ### Features 246 | 247 | * set different default format via prop ([4291e05](https://github.com/kleinfreund/vue-accessible-color-picker/commit/4291e058e19784d99060ad6354d159831da7628d)) 248 | 249 | ## [2.0.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v1.1.2...v2.0.0) (2021-01-17) 250 | 251 | 252 | ### Features 253 | 254 | * migrate to Vue.js version 3 ([26b8eb2](https://github.com/kleinfreund/vue-accessible-color-picker/commit/26b8eb2f30b8c57d65b26b71b62395a7e6295786)) 255 | 256 | 257 | ### BREAKING CHANGES 258 | 259 | * Migrates this package to use and be compatible with Vue.js 3. Upcoming versions of this package therefor no longer support Vue.js 2. Use the new application instance APIs to register components via `app.component`. The README.md file was updated to take these changes into account. Detailed instructions on the the general migration process to Vue.js 3 can be found in the [Vue 3 migration guide](https://v3.vuejs.org/guide/migration/introduction.html). 260 | * Deprecates global component registration via side effect. 261 | * Renames type `SupportedColorFormat` to `ColorFormat`. 262 | * Removes type `ColorChannel` because it’s not a useful type. 263 | 264 | Adds the vue package (`vue@^3.x`) as a peer dependency. 265 | 266 | Removes some tests from index.test.js because they were testing the behavior of Vue.js itself rather than that of the index.js file. 267 | 268 | ## [1.1.2](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v1.1.1...v1.1.2) (2020-12-20) 269 | 270 | 271 | ### Bug Fixes 272 | 273 | * **package:** bundle dist files ([9b15741](https://github.com/kleinfreund/vue-accessible-color-picker/commit/9b157413af303e749f8f9d70faef051f6af11f7b)) 274 | 275 | Fixes the dist files missing from the published npm package. It seems that the `files` field in the package.json must not contain paths that start with `./`. 276 | 277 | ## [1.1.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v1.1.0...v1.1.1) (2020-12-20) 278 | 279 | **Note**: This version cannot be used. Use version [1.1.2](https://github.com/kleinfreund/vue-accessible-color-picker/releases/tag/v1.1.2) instead. 280 | 281 | Due to an issue with the package.json file’s `files` field, version [1.1.1](https://github.com/kleinfreund/vue-accessible-color-picker/releases/tag/v1.1.1) **does not** include the dist files in the published npm package. The issue was fixed in [9b15741](https://github.com/kleinfreund/vue-accessible-color-picker/commit/9b157413af303e749f8f9d70faef051f6af11f7b) and a new version of the package was released. 282 | 283 | 284 | ### Bug Fixes 285 | 286 | * **package:** add exports and module fields to package.json ([5a9eda3](https://github.com/kleinfreund/vue-accessible-color-picker/commit/5a9eda391f437f99b7922e36894463f30d35a1fa)) 287 | 288 | Adds the “exports” and “module” fields to the package.json file. Their values refer to the package’s main entry point (i.e. `./dist/vue-accessible-color-picker.js`). 289 | 290 | ## [1.1.0](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v1.0.1...v1.1.0) 291 | 292 | **Note**: This version cannot be used. Use version [1.1.2](https://github.com/kleinfreund/vue-accessible-color-picker/releases/tag/v1.1.2) instead. 293 | 294 | Due to an issue with the package.json file’s `files` field, version [1.1.0](https://github.com/kleinfreund/vue-accessible-color-picker/releases/tag/v1.1.0) **does not** include the dist files in the published npm package. The issue was fixed in [9b15741](https://github.com/kleinfreund/vue-accessible-color-picker/commit/9b157413af303e749f8f9d70faef051f6af11f7b) and a new version of the package was released. 295 | 296 | 297 | ### Features 298 | 299 | * **types:** add basic type definitions ([37b425e](https://github.com/kleinfreund/vue-accessible-color-picker/commit/37b425ed19f248017a65eaedd2c783de5f19ae7d)) 300 | 301 | Adds type definitions file index.d.ts within the types directory and moves existing JSDoc-based type definitions into this file. 302 | 303 | Points the types field in the package.json file to the newly added type definition file and adds it to the bundled package files. 304 | 305 | Configures the project to check JavaScript for TypeScript errors via a jsconfig.json file in the project’s root directory. 306 | 307 | Adds type annotations to several parts of the codebase. 308 | 309 | ## [1.0.1](https://github.com/kleinfreund/vue-accessible-color-picker/compare/v1.0.0...v1.0.1) 310 | 311 | 312 | ### Bug Fixes 313 | 314 | * **import:** safe-guard Vue.use call ([b4b829a](https://github.com/kleinfreund/vue-accessible-color-picker/commit/b4b829a096111e89290d6d3daf04012c3041a965)) 315 | 316 | Adds an additional check to the index.js side effect that causes Vue.use only to be called when it is a function. 317 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [philipprudloff@fastmail.com](mailto:philipprudloff@fastmail.com). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | Hello there! 4 | 5 | This project follows a [code of conduct](https://github.com/kleinfreund/vue-accessible-color-picker/blob/main/CODE_OF_CONDUCT.md). Please read it. All contributions are subject to it. 6 | 7 | ## Prerequisites 8 | 9 | The following software will be required to contribute to this project: 10 | 11 | - git 12 | - Node.js (version 22.12.0 or higher) 13 | - npm (version 10 or higher) 14 | 15 | ## Development 16 | 17 | ### Install dependencies 18 | 19 | ```sh 20 | npm install 21 | ``` 22 | 23 | ### Stard development server with a small demo 24 | 25 | ```sh 26 | npm start 27 | ``` 28 | 29 | ### Run tests 30 | 31 | ```sh 32 | npm test 33 | ``` 34 | 35 | ### Build the contents of the `dist` directory 36 | 37 | ```sh 38 | npm run build 39 | ``` 40 | 41 | ### Committing changes 42 | 43 | This project follows the [Angular convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular) for naming commits. 44 | 45 | **Examples**: 46 | 47 | ``` 48 | feat: adds support for Lab/LCH color formats 49 | ``` 50 | 51 | ``` 52 | fix: fixes a bug with color conversions to RGB 53 | ``` 54 | 55 | ``` 56 | docs: expands examples 57 | ``` 58 | 59 | ## Pull request guidelines 60 | 61 | - In case of submitting a contribution for a new feature, please explain briefly why you think the feature is necessary. Ideally, an issue for a feature request was submitted and approved beforehand, but this is not a requirement. 62 | - In case of submitting a contribution that changes or introduces a user interface, ensure that the user interface remains accessible: It must be navigable using a pointer device (e.g. mouse, track pad), a keyboard, and a screen reader. This can be tested manually and with the help of automated accessibility checkers such as axe. 63 | - Please provide unit tests for feature or bug fix contributions. 64 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 Philipp Rudloff 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-accessible-color-picker 2 | 3 | [![Tests passing](https://github.com/kleinfreund/vue-accessible-color-picker/workflows/Tests/badge.svg)](https://github.com/kleinfreund/vue-accessible-color-picker/actions) 4 | 5 | A color picker component for Vue.js. 6 | 7 | This package’s files are distributed in the ES module format and have not been transpiled. 8 | 9 | Links: 10 | 11 | - [demo](https://vue-accessible-color-picker.netlify.app) 12 | - [**npmjs.com**/package/vue-accessible-color-picker](https://www.npmjs.com/package/vue-accessible-color-picker) 13 | - [**github.com**/kleinfreund/vue-accessible-color-picker](https://github.com/kleinfreund/vue-accessible-color-picker) 14 | - [code of conduct](https://github.com/kleinfreund/vue-accessible-color-picker/blob/main/CODE_OF_CONDUCT.md) 15 | - [contributing guidelines](https://github.com/kleinfreund/vue-accessible-color-picker/blob/main/CONTRIBUTING.md) 16 | - as a web component: [yet-another-color-picker](https://www.npmjs.com/package/yet-another-color-picker) 17 | 18 | ## Contents 19 | 20 | - [Installation](#installation) 21 | - [Usage](#usage) 22 | - [Documentation](#documentation) 23 | - [Props](#props) 24 | - [`alphaChannel`](#alphachannel) 25 | - [`color`](#color) 26 | - [`copy`](#copy) 27 | - [`defaultFormat`](#defaultformat) 28 | - [`id`](#id) 29 | - [`visibleFormats`](#visibleformats) 30 | - [Events](#events) 31 | - [`color-change`](#color-change) 32 | - [Slots](#slots) 33 | - [alpha-range-input-label](#alpha-range-input-label) 34 | - [copy-button](#copy-button) 35 | - [format-switch-button](#format-switch-button) 36 | - [hue-range-input-label](#hue-range-input-label) 37 | - [Theming](#theming) 38 | - [Versioning](#versioning) 39 | - [Design](#design) 40 | 41 | ## Installation 42 | 43 | ```sh 44 | npm install vue-accessible-color-picker 45 | ``` 46 | 47 | ## Usage 48 | 49 | In a Vue single file component, import the `ColorPicker` component and its styles (if you want to use them). 50 | 51 | ```vue 52 | 55 | 56 | 59 | 60 | 63 | ``` 64 | 65 | When using [Vue’s composition API](https://vuejs.org/guide/extras/composition-api-faq.html), you can directly use the component in the file’s `template` section once you import it. 66 | 67 | You can also register the component and import the styles globally. 68 | 69 | ## Documentation 70 | 71 | ### Props 72 | 73 | #### `alphaChannel` 74 | 75 | - **Description**: Whether to show input controls for a color’s alpha channel. If set to `'hide'`, the alpha range input and the alpha channel input are hidden, the “Copy color” button will copy a CSS color value without alpha channel, and the object emitted in a `color-change` event will have a `cssColor` property value without alpha channel. 76 | - **Type**: `'show'` or `'hide'` 77 | - **Required**: No 78 | - **Default**: `'show'` 79 | - **Usage**: 80 | 81 | ```vue 82 | 83 | ``` 84 | 85 | #### `color` 86 | 87 | - **Description**: Sets the color of the color picker. You can pass any valid CSS color string or an object matching the internal color representation for an HSL, HSV, HWB, or RGB color. 88 | - **Type**: `string`, `ColorHsl`, `ColorHwb`, or `ColorRgb` 89 | - **Required**: No 90 | - **Default**: `'#ffffffff'` 91 | - **Usage**: 92 | 93 | ```vue 94 | 95 | ``` 96 | 97 | ```vue 98 | 99 | ``` 100 | 101 | ```vue 102 | 103 | ``` 104 | 105 | ```vue 106 | 112 | 113 | 123 | ``` 124 | #### `copy` 125 | 126 | - **Description**: A function that will be used in place of `window.navigator.clipboard.writeText` to 127 | - **Type**: `(cssString: string) => Promise | void` 128 | - **Required**: No 129 | - **Default**: `window.navigator.clipboard.writeText` 130 | - **Usage**: 131 | 132 | ```vue 133 | 134 | ``` 135 | 136 | #### `defaultFormat` 137 | 138 | - **Description**: The color format to show by default when rendering the color picker. Must be one of the formats specified in `visibleFormats`. 139 | - **Type**: `VisibleColorFormat` 140 | - **Required**: No 141 | - **Default**: `'hsl'` 142 | - **Usage**: 143 | 144 | ```vue 145 | 146 | ``` 147 | 148 | #### `id` 149 | 150 | - **Description**: The ID value will be used to prefix all `input` elements’ `id` and `label` elements’ `for` attribute values. Set this prop if you use multiple instances of the `color-picker` component on one page. 151 | - **Type**: `string` 152 | - **Required**: No 153 | - **Default**: `'color-picker'` 154 | - **Usage**: 155 | 156 | ```vue 157 | 158 | ``` 159 | 160 | #### `visibleFormats` 161 | 162 | - **Description**: A list of visible color formats. Controls for which formats the color `input` elements are shown and in which order the formats will be cycled through when activating the format switch button. 163 | - **Type**: `VisibleColorFormat[]` 164 | - **Required**: No 165 | - **Default**: `['hex', 'hsl', 'hwb', 'rgb']` 166 | - **Usage**: 167 | 168 | ```vue 169 | 170 | ``` 171 | 172 | ### Events 173 | 174 | #### `color-change` 175 | 176 | - **Description**: The event that is emitted each time the internal colors object is updated. 177 | - **Data**: The event emits an object containing both the internal colors object and a CSS color value as a string based on the currently active format. The `cssColor` property will respect `alphaChannel`. 178 | 179 | ```ts 180 | { 181 | colors: { 182 | hex: string 183 | hsl: { h: number, s: number, l: number, a: number } 184 | hsv: { h: number, s: number, v: number, a: number } 185 | hwb: { h: number, w: number, b: number, a: number } 186 | rgb: { r: number, g: number, b: number, a: number } 187 | } 188 | cssColor: string 189 | } 190 | ``` 191 | 192 | - **Usage**: 193 | 194 | ```vue 195 | 201 | 202 | 209 | ``` 210 | 211 | #### `color-copy` 212 | 213 | - **Description**: The `color-copy` event is fired once a copy operation succeeded. 214 | - **Data**: Emits the same event data as [the `color-change` event](#color-change). 215 | - **Usage**: 216 | 217 | ```vue 218 | 224 | 225 | 232 | ``` 233 | 234 | ### Slots 235 | 236 | #### alpha-range-input-label 237 | 238 | - **Description**: Overrides the content of the alpha range input’s `label` element. 239 | - **Default content**: Alpha 240 | 241 | #### copy-button 242 | 243 | - **Description**: Overrides the content of the copy button element. 244 | - **Default content**: Copy color (and SVG icon) 245 | 246 | #### format-switch-button 247 | 248 | - **Description**: Overrides the content of the format switch button element. 249 | - **Default content**: Switch format (and SVG icon) 250 | 251 | #### hue-range-input-label 252 | 253 | - **Description**: Overrides the content of the hue range input’s `label` element. 254 | - **Default content**: Hue 255 | 256 | ### Theming 257 | 258 | You can customize the GUI of the color picker using CSS custom properties: 259 | 260 | ```css 261 | :root { 262 | --vacp-color-focus: tomato; 263 | --vacp-width-border: 2px; 264 | } 265 | ``` 266 | 267 | Available custom properties and their default values: 268 | 269 | | Custom property | Default value | 270 | | ------------------------------- | ------------- | 271 | | `--vacp-color-background-input` | `#fff` 272 | | `--vacp-color-background` | `#fff` 273 | | `--vacp-color-border` | `#000` 274 | | `--vacp-color-focus` | `#19f` 275 | | `--vacp-color-text-input` | `currentColor` 276 | | `--vacp-color-text` | `currentColor` 277 | | `--vacp-font-family` | `-apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif` 278 | | `--vacp-font-size` | `0.8em` 279 | | `--vacp-spacing` | `6px` 280 | | `--vacp-width-border` | `1px` 281 | | `--vacp-width-color-space` | `300px` 282 | 283 | ## Versioning 284 | 285 | This package uses [semantic versioning](https://semver.org). 286 | 287 | ## Design 288 | 289 | The color picker consists of the following main elements: 290 | 291 | - **Color space**: 292 | 293 | For fine-tuning the saturation and lightness/value, a slice of the HSV cylinder for the currently selected hue is shown. 294 | 295 | The HSV cylinder is more convenient for this task than the HSL cylinder as it shows a color at 100% saturation and 100% value in the top right corner (i.e. one can drag the color space thumb into the corner as a quasi shortcut). The HSL cylinder’s slice has this color at the halfway point of the Y axis (i.e. at 50% lightness) which isn’t easy to select. 296 | 297 | - **Hue slider**: 298 | 299 | A slider for selecting the current hue. This rotates the HSV cylinder; thus, it changes the slice of the HSV cylinder that’s shown in the color space. 300 | 301 | - **Alpha slider**: 302 | 303 | A slider for selecting the current alpha value. 304 | 305 | - **Copy button**: 306 | 307 | Copies the color formatted as a CSS color string in the active format. 308 | 309 | - **Color inputs**: 310 | 311 | A set of text fields which allow you to enter the individual components of each color. The text fields are shown based on the active format. 312 | 313 | - **Switch format button**: 314 | 315 | Cycles through the available color formats (currently HEX, HSL, HWB, and RGB). 316 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "mainEntryPointFilePath": "./dist/src/index.d.ts", 4 | "apiReport": { 5 | "enabled": true, 6 | "reportFolder": "/temp/" 7 | }, 8 | "docModel": { 9 | "enabled": true 10 | }, 11 | "dtsRollup": { 12 | "enabled": true, 13 | "untrimmedFilePath": "./dist/ColorPicker.d.ts" 14 | }, 15 | "tsdocMetadata": { 16 | "enabled": false 17 | }, 18 | "messages": { 19 | "extractorMessageReporting": { 20 | "ae-missing-release-tag": { 21 | "logLevel": "none" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /changelog-template-commit.hbs: -------------------------------------------------------------------------------- 1 | {{!-- 2 | Copy of https://github.com/conventional-changelog/conventional-changelog/blob/master/packages/conventional-changelog-conventionalcommits/templates/commit.hbs 3 | 4 | The following have been replaced: 5 | 6 | - `commitUrlFormat` with `{{@root.host}}/{{@root.owner}}/{{@root.repository}}/commit/{{hash}})` 7 | - `issueUrlFormat` with `{{@root.host}}/{{@root.owner}}/{{@root.repository}}/issues/{{this.id}}` 8 | 9 | As they won't be replaced when overriding the commitPartial 10 | --}} 11 | *{{#if scope}} **{{scope}}:** 12 | {{~/if}} {{#if subject}} 13 | {{~subject}} 14 | {{~else}} 15 | {{~header}} 16 | {{~/if}} 17 | 18 | {{~!-- commit link --}}{{~#if hash}} {{#if @root.linkReferences~}} 19 | ([{{shortHash}}]({{@root.host}}/{{@root.owner}}/{{@root.repository}}/commit/{{hash}})) 20 | {{~else}} 21 | {{~shortHash}} 22 | {{~/if}}{{~/if}} 23 | 24 | {{~!-- commit references --}} 25 | {{~#if references~}} 26 | , closes 27 | {{~#each references}} {{#if @root.linkReferences~}} 28 | [ 29 | {{~#if this.owner}} 30 | {{~this.owner}}/ 31 | {{~/if}} 32 | {{~this.repository}}{{this.prefix}}{{this.issue}}]({{@root.host}}/{{@root.owner}}/{{@root.repository}}/issues/{{this.id}}) 33 | {{~else}} 34 | {{~#if this.owner}} 35 | {{~this.owner}}/ 36 | {{~/if}} 37 | {{~this.repository}}{{this.prefix}}{{this.issue}} 38 | {{~/if}}{{/each}} 39 | {{~/if}} 40 | {{!-- End of copy --}} 41 | 42 | {{!-- Start of custom additions --}} 43 | {{#each bodyLines}} 44 | 45 | {{this}} 46 | {{/each}}{{#each notes}} 47 | **BREAKING CHANGE**: {{text}} 48 | {{/each}} -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@commitlint/types').UserConfig} */ const config = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'body-max-line-length': [0, 'always', Infinity], 5 | 'footer-max-line-length': [0, 'always', Infinity], 6 | }, 7 | } 8 | 9 | export default config 10 | -------------------------------------------------------------------------------- /dist/ColorPicker.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";.vacp-color-picker{max-width:var(--vacp-width-color-space, 300px);padding:var(--vacp-spacing, 6px);display:grid;grid-gap:var(--vacp-spacing, 6px);grid-template-columns:1fr min-content;font-size:var(--vacp-font-size, .8em);font-family:var(--vacp-font-family, -apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif);color:var(--vacp-color-text, currentColor);background-color:var(--vacp-color-background, #fff)}.vacp-color-picker,.vacp-color-picker *,.vacp-color-picker *:before,.vacp-color-picker *:after{box-sizing:border-box}.vacp-color-picker button::-moz-focus-inner{border:none;padding:0}.vacp-color-picker :focus{outline:2px solid var(--vacp-color-focus, #19f)}.vacp-color-space{grid-column:1/-1;overflow:hidden;aspect-ratio:1/.6}.vacp-color-space-thumb{--vacp-thumb-size: calc(var(--vacp-spacing, 6px) * 4);width:var(--vacp-thumb-size);height:var(--vacp-thumb-size);margin-left:calc(-1 * var(--vacp-thumb-size) / 2);margin-bottom:calc(-1 * var(--vacp-thumb-size) / 2);border:3px solid #fff;border-radius:50%;box-shadow:0 0 0 var(--vacp-width-border, 1px) var(--vacp-color-border, #000);transform:rotate(0)}.vacp-color-space-thumb:focus{outline-color:transparent;box-shadow:0 0 0 var(--vacp-width-border, 1px) var(--vacp-color-border, #000),0 0 0 calc(var(--vacp-width-border, 1px) + 2px) var(--vacp-color-focus, #19f)}.vacp-range-input-label{--vacp-slider-track-height: calc(var(--vacp-spacing, 6px) * 3);--vacp-slider-thumb-size: calc(var(--vacp-spacing, 6px) * 4 - var(--vacp-width-border, 1px) * 2);display:block}.vacp-range-input-group{display:flex;flex-direction:column;justify-content:center}.vacp-range-input-group>:not(:first-child){margin-top:var(--vacp-spacing, 6px)}.vacp-range-input,.vacp-range-input::-webkit-slider-thumb{-webkit-appearance:none}.vacp-range-input{display:block;width:100%;height:var(--vacp-slider-track-height);margin-right:0;margin-left:0;margin-top:calc(var(--vacp-spacing, 6px) / 2 + 1px);margin-bottom:calc(var(--vacp-spacing, 6px) / 2 + 1px);padding:0;border:none;background:none}.vacp-range-input:focus{outline:none}.vacp-range-input::-moz-focus-outer{border:none}.vacp-range-input--alpha{background-color:#fff;background-image:linear-gradient(45deg,#eee 25%,transparent 25%,transparent 75%,#eee 75%,#eee),linear-gradient(45deg,#eee 25%,transparent 25%,transparent 75%,#eee 75%,#eee);background-size:calc(var(--vacp-spacing, 6px) * 2) calc(var(--vacp-spacing, 6px) * 2);background-position:0 0,var(--vacp-spacing, 6px) var(--vacp-spacing, 6px)}.vacp-range-input::-moz-range-track{box-sizing:border-box;width:100%;height:var(--vacp-slider-track-height);border:var(--vacp-width-border, 1px) solid var(--vacp-color-border, #000)}.vacp-range-input::-webkit-slider-runnable-track{box-sizing:border-box;width:100%;height:var(--vacp-slider-track-height);border:var(--vacp-width-border, 1px) solid var(--vacp-color-border, #000)}.vacp-range-input::-ms-track{box-sizing:border-box;width:100%;height:var(--vacp-slider-track-height);border:var(--vacp-width-border, 1px) solid var(--vacp-color-border, #000)}.vacp-range-input:focus::-moz-range-track{outline:2px solid var(--vacp-color-focus, #19f)}.vacp-range-input:focus::-webkit-slider-runnable-track{outline:2px solid var(--vacp-color-focus, #19f)}.vacp-range-input:focus::-ms-track{outline:2px solid var(--vacp-color-focus, #19f)}.vacp-range-input--alpha::-moz-range-track{background-image:linear-gradient(to right,transparent,var(--vacp-color))}.vacp-range-input--alpha::-webkit-slider-runnable-track{background-image:linear-gradient(to right,transparent,var(--vacp-color))}.vacp-range-input--alpha::-ms-track{background-image:linear-gradient(to right,transparent,var(--vacp-color))}.vacp-range-input--hue::-moz-range-track{background-image:linear-gradient(to right,red,#ff0,#0f0,#0ff,#00f,#f0f,red)}.vacp-range-input--hue::-webkit-slider-runnable-track{background-image:linear-gradient(to right,red,#ff0,#0f0,#0ff,#00f,#f0f,red)}.vacp-range-input--hue::-ms-track{background-image:linear-gradient(to right,red,#ff0,#0f0,#0ff,#00f,#f0f,red)}.vacp-range-input::-moz-range-thumb{box-sizing:border-box;width:var(--vacp-slider-thumb-size);height:var(--vacp-slider-thumb-size);border:3px solid #fff;border-radius:50%;background-color:transparent;box-shadow:0 0 0 var(--vacp-width-border, 1px) var(--vacp-color-border, #000);isolation:isolate}.vacp-range-input::-webkit-slider-thumb{box-sizing:border-box;width:var(--vacp-slider-thumb-size);height:var(--vacp-slider-thumb-size);margin-top:calc(-1 * var(--vacp-spacing, 6px) / 2);border:3px solid #fff;border-radius:50%;background-color:transparent;box-shadow:0 0 0 var(--vacp-width-border, 1px) var(--vacp-color-border, #000);isolation:isolate}.vacp-range-input::-ms-thumb{box-sizing:border-box;width:var(--vacp-slider-thumb-size);height:var(--vacp-slider-thumb-size);margin-top:calc(-1 * var(--vacp-spacing, 6px) / 2);border:3px solid #fff;border-radius:50%;background-color:transparent;box-shadow:0 0 0 var(--vacp-width-border, 1px) var(--vacp-color-border, #000);isolation:isolate}.vacp-copy-button{justify-self:center;align-self:center;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;width:calc(var(--vacp-spacing, 6px) * 6);height:calc(var(--vacp-spacing, 6px) * 6);border:var(--vacp-width-border, 1px) solid transparent;border-radius:50%;color:var(--vacp-color-text-input, currentColor);background-color:var(--vacp-color-background-input, #fff)}.vacp-copy-button:focus{outline:none;border-color:var(--vacp-color-border, #000);box-shadow:0 0 0 2px var(--vacp-color-focus, #19f)}.vacp-copy-button:enabled:hover{background-color:#0002}.vacp-color-inputs{grid-column:1/-1;display:flex;align-items:center}.vacp-color-inputs>:not(:first-child){margin-left:var(--vacp-spacing, 6px)}.vacp-color-input-group{flex-grow:1;display:grid;grid-auto-flow:column;column-gap:var(--vacp-spacing, 6px)}.vacp-color-input-label{text-align:center}.vacp-color-input{width:100%;margin:0;margin-top:calc(var(--vacp-spacing, 6px) / 2);padding:var(--vacp-spacing, 6px);border:var(--vacp-width-border, 1px) solid var(--vacp-color-border, #000);font:inherit;text-align:center;color:inherit;color:var(--vacp-color-text-input, currentColor);background-color:var(--vacp-color-background-input, #fff)}.vacp-format-switch-button{display:flex;justify-content:center;align-items:center;margin:0;padding:var(--vacp-spacing, 6px);border:var(--vacp-width-border, 1px) solid transparent;border-radius:50%;font:inherit;color:inherit;color:var(--vacp-color-text-input, currentColor);background-color:var(--vacp-color-background-input, #fff)}.vacp-format-switch-button:focus{border-color:var(--vacp-color-border, #000)}.vacp-format-switch-button:enabled:hover{background-color:#0002}.vacp-visually-hidden{position:absolute;overflow:hidden;clip:rect(0 0 0 0);width:1px;height:1px;margin:-1px;padding:0;border:0;white-space:nowrap} 2 | -------------------------------------------------------------------------------- /dist/ColorPicker.d.ts: -------------------------------------------------------------------------------- 1 | import { AlphaChannelProp as AlphaChannelProp_2 } from './types.js'; 2 | import { ComponentOptionsMixin } from 'vue'; 3 | import { ComponentProvideOptions } from 'vue'; 4 | import { DefineComponent } from 'vue'; 5 | import { Plugin as Plugin_2 } from 'vue'; 6 | import { PublicProps } from 'vue'; 7 | 8 | declare const __VLS_component: DefineComponent any; 12 | "color-copy": (data: ColorChangeDetail) => any; 13 | }, string, PublicProps, Readonly & Readonly<{ 14 | "onColor-change"?: ((data: ColorChangeDetail) => any) | undefined; 15 | "onColor-copy"?: ((data: ColorChangeDetail) => any) | undefined; 16 | }>, { 17 | id: string; 18 | color: string | ColorHsl | ColorHwb | ColorRgb; 19 | copy: (cssColor: string) => Promise | void; 20 | visibleFormats: VisibleColorFormat[]; 21 | defaultFormat: VisibleColorFormat; 22 | alphaChannel: AlphaChannelProp_2; 23 | }, {}, {}, {}, string, ComponentProvideOptions, false, { 24 | colorPicker: HTMLDivElement; 25 | colorSpace: HTMLDivElement; 26 | thumb: HTMLDivElement; 27 | }, HTMLDivElement>; 28 | 29 | declare function __VLS_template(): { 30 | attrs: Partial<{}>; 31 | slots: { 32 | 'hue-range-input-label'?(_: {}): any; 33 | 'alpha-range-input-label'?(_: {}): any; 34 | 'copy-button'?(_: {}): any; 35 | 'format-switch-button'?(_: {}): any; 36 | }; 37 | refs: { 38 | colorPicker: HTMLDivElement; 39 | colorSpace: HTMLDivElement; 40 | thumb: HTMLDivElement; 41 | }; 42 | rootEl: HTMLDivElement; 43 | }; 44 | 45 | declare type __VLS_TemplateResult = ReturnType; 46 | 47 | declare type __VLS_WithTemplateSlots = T & { 48 | new (): { 49 | $slots: S; 50 | }; 51 | }; 52 | 53 | export declare type AlphaChannelProp = 'show' | 'hide'; 54 | 55 | export declare type ColorChangeDetail = { 56 | colors: ColorMap; 57 | cssColor: string; 58 | }; 59 | 60 | export declare type ColorFormat = 'hex' | 'hsl' | 'hsv' | 'hwb' | 'rgb'; 61 | 62 | export declare type ColorHsl = { 63 | h: number; 64 | s: number; 65 | l: number; 66 | a: number; 67 | }; 68 | 69 | export declare type ColorHsv = { 70 | h: number; 71 | s: number; 72 | v: number; 73 | a: number; 74 | }; 75 | 76 | export declare type ColorHwb = { 77 | h: number; 78 | w: number; 79 | b: number; 80 | a: number; 81 | }; 82 | 83 | export declare type ColorMap = { 84 | hex: string; 85 | hsl: ColorHsl; 86 | hsv: ColorHsv; 87 | hwb: ColorHwb; 88 | rgb: ColorRgb; 89 | }; 90 | 91 | export declare const ColorPicker: __VLS_WithTemplateSlots; 92 | 93 | export declare interface ColorPickerProps { 94 | /** 95 | * The initially rendered color. 96 | */ 97 | color?: string | ColorHsl | ColorHwb | ColorRgb; 98 | /** 99 | * Takes a function that will be used in place of `window.navigator.clipboard.writeText` when triggering the color picker's copy color functionality (programmatically or via the UI). 100 | */ 101 | copy?: (cssColor: string) => Promise | void; 102 | /** 103 | * The prefix for all ID attribute values used by the color picker. 104 | */ 105 | id?: string; 106 | /** 107 | * The list of visible color formats. 108 | */ 109 | visibleFormats?: VisibleColorFormat[]; 110 | /** 111 | * The initially visible color format. 112 | */ 113 | defaultFormat?: VisibleColorFormat; 114 | /** 115 | * Controls whether the control related to a color’s alpha channel are rendered in the color picker. 116 | * 117 | * The following settings are available: 118 | * 119 | * - **show**: Default. The alpha channel range input and the alpha channel value input are rendered. 120 | * - **hide**: The alpha channel range input and the alpha channel value input are not rendered. The `color-change` event emits a `cssColor` property without the alpha channel part. 121 | */ 122 | alphaChannel?: AlphaChannelProp; 123 | } 124 | 125 | export declare type ColorRgb = { 126 | r: number; 127 | g: number; 128 | b: number; 129 | a: number; 130 | }; 131 | 132 | /** 133 | * Copies the current color (determined by the active color format). 134 | * 135 | * For example, if the active color format is HSL, the copied text will be a valid CSS color in HSL format. 136 | * 137 | * Only works in secure browsing contexts (i.e. HTTPS). 138 | */ 139 | declare function copyColor(): Promise; 140 | 141 | declare const plugin: Plugin_2; 142 | export default plugin; 143 | 144 | export declare type VisibleColorFormat = Exclude; 145 | 146 | export { } 147 | -------------------------------------------------------------------------------- /dist/ColorPicker.js: -------------------------------------------------------------------------------- 1 | import { defineComponent as pt, ref as N, reactive as vt, computed as G, watch as dt, onMounted as mt, onBeforeUnmount as gt, openBlock as x, createElementBlock as C, createElementVNode as h, renderSlot as V, createTextVNode as J, createCommentVNode as Q, Fragment as bt, renderList as wt, toDisplayString as yt } from "vue"; 2 | function k(t, n, r) { 3 | return Math.max(n, Math.min(t, r)); 4 | } 5 | function xt(t, n) { 6 | if (typeof t == "string" || typeof n == "string") 7 | return t === n; 8 | for (const r in t) 9 | if (t[r] !== n[r]) 10 | return !1; 11 | return !0; 12 | } 13 | function A(t) { 14 | const n = [], r = t.length > 5 ? 2 : 1; 15 | for (let s = 1; s < t.length; s += r) { 16 | const a = t.substring(s, s + r).repeat(r % 2 + 1), f = parseInt(a, 16); 17 | n.push(s === 3 * r + 1 ? f / 255 : f); 18 | } 19 | return n.length === 3 && n.push(1), { 20 | r: n[0], 21 | g: n[1], 22 | b: n[2], 23 | a: n[3] 24 | }; 25 | } 26 | function et(t) { 27 | const n = t.l / 100, r = n + t.s / 100 * Math.min(n, 1 - n), s = r === 0 ? 0 : 200 * (1 - n / r); 28 | return { 29 | h: t.h, 30 | s, 31 | v: r * 100, 32 | a: t.a 33 | }; 34 | } 35 | function E(t) { 36 | let n = t.h % 360; 37 | n < 0 && (n += 360); 38 | const r = t.s / 100, s = t.l / 100; 39 | return { 40 | r: j(0, n, r, s) * 255, 41 | g: j(8, n, r, s) * 255, 42 | b: j(4, n, r, s) * 255, 43 | a: t.a 44 | }; 45 | } 46 | function j(t, n, r, s) { 47 | const a = (t + n / 30) % 12, f = r * Math.min(s, 1 - s); 48 | return s - f * Math.max(-1, Math.min(a - 3, 9 - a, 1)); 49 | } 50 | function nt(t) { 51 | const n = t.s / 100, r = t.v / 100, s = r * (1 - n / 2); 52 | return { 53 | h: t.h, 54 | s: s === 0 || s === 1 ? 0 : (r - s) / Math.min(s, 1 - s) * 100, 55 | l: s * 100, 56 | a: t.a 57 | }; 58 | } 59 | function ot(t) { 60 | return { 61 | h: t.h, 62 | w: t.v * (100 - t.s) / 100, 63 | b: 100 - t.v, 64 | a: t.a 65 | }; 66 | } 67 | function M(t) { 68 | return E(nt(t)); 69 | } 70 | function T(t) { 71 | const n = t.w / 100, r = t.b / 100; 72 | let s, a; 73 | const f = n + r; 74 | return f >= 1 ? (s = 0, a = n / f) : (a = 1 - r, s = (1 - n / a) * 100), { 75 | h: t.h, 76 | s, 77 | v: a * 100, 78 | a: t.a 79 | }; 80 | } 81 | function L(t) { 82 | const { r: n, g: r, b: s, a } = t, f = Math.min(n, r, s), d = Math.max(n, r, s), c = d - f, v = (d + f) / 2; 83 | let m = 0; 84 | c !== 0 && (d === n ? m = (r - s) / c + (r < s ? 6 : 0) : d === r ? m = (s - n) / c + 2 : d === s && (m = (n - r) / c + 4), m *= 60); 85 | let p = 0; 86 | return v !== 0 && v !== 255 && (p = (d - v) / Math.min(v, 255 - v)), { 87 | h: m, 88 | s: p * 100, 89 | l: v / 255 * 100, 90 | a 91 | }; 92 | } 93 | function H(t) { 94 | return "#" + Object.values(t).map((n, r) => Math.round(r === 3 ? n * 255 : n).toString(16).padStart(2, "0")).join(""); 95 | } 96 | function $(t) { 97 | return ot(et(L(t))); 98 | } 99 | const Ct = { 100 | hex: { 101 | hex: (t) => t, 102 | hsl: (t) => L(A(t)), 103 | hsv: (t) => T($(A(t))), 104 | hwb: (t) => $(A(t)), 105 | rgb: A 106 | }, 107 | hsl: { 108 | hex: (t) => H(E(t)), 109 | hsl: (t) => t, 110 | hsv: et, 111 | hwb: (t) => $(E(t)), 112 | rgb: E 113 | }, 114 | hsv: { 115 | hex: (t) => H(M(t)), 116 | hsl: nt, 117 | hsv: (t) => t, 118 | hwb: ot, 119 | rgb: M 120 | }, 121 | hwb: { 122 | hex: (t) => H(M(T(t))), 123 | hsl: (t) => L(M(T(t))), 124 | hsv: T, 125 | hwb: (t) => t, 126 | rgb: (t) => M(T(t)) 127 | }, 128 | rgb: { 129 | hex: H, 130 | hsl: L, 131 | hsv: (t) => T($(t)), 132 | hwb: $, 133 | rgb: (t) => t 134 | } 135 | }; 136 | function Tt(t, n, r) { 137 | return Ct[t][n](r); 138 | } 139 | function Ft(t, n) { 140 | const r = t.toFixed(n); 141 | return r.includes(".") ? r.replace(/\.?0+$/, "") : r; 142 | } 143 | const Mt = { 144 | deg: 1, 145 | grad: 0.9, 146 | rad: 180 / Math.PI, 147 | turn: 360 148 | }, P = { 149 | from(t) { 150 | return t.endsWith("%") ? F.from(t, { referenceValue: 1 }) : w.from(t, { min: 0, max: 1 }); 151 | }, 152 | to(t) { 153 | return w.to(t); 154 | } 155 | }, Z = { 156 | from(t) { 157 | const n = t.match(/deg|g?rad|turn$/); 158 | if (n === null) 159 | return w.from(t); 160 | const r = n[0]; 161 | return w.from(t.slice(0, -r.length)) * Mt[r]; 162 | }, 163 | to(t) { 164 | return w.to(t); 165 | } 166 | }, w = { 167 | from(t, { min: n = Number.NEGATIVE_INFINITY, max: r = Number.POSITIVE_INFINITY } = {}) { 168 | return t.endsWith(".") ? NaN : k(Number(t), n, r); 169 | }, 170 | to(t) { 171 | return Ft(t, 2); 172 | } 173 | }, F = { 174 | from(t, { referenceValue: n = 100, min: r = 0, max: s = 100 } = {}) { 175 | return t.endsWith("%") ? w.from(t.slice(0, -1), { min: r, max: s }) * n / 100 : NaN; 176 | }, 177 | to(t) { 178 | return w.to(t) + "%"; 179 | } 180 | }, D = { 181 | from(t) { 182 | return t.endsWith("%") ? F.from(t, { referenceValue: 255 }) : w.from(t, { min: 0, max: 255 }); 183 | }, 184 | to(t) { 185 | return w.to(t); 186 | } 187 | }, $t = { 188 | hsl: { 189 | h: Z, 190 | s: F, 191 | l: F 192 | }, 193 | hwb: { 194 | h: Z, 195 | w: F, 196 | b: F 197 | }, 198 | rgb: { 199 | r: D, 200 | g: D, 201 | b: D 202 | } 203 | }; 204 | function S(t, n) { 205 | return $t[t][n]; 206 | } 207 | function _({ format: t, color: n }, r) { 208 | if (t === "hex") 209 | return r && [5, 9].includes(n.length) ? n.substring(0, n.length - (n.length - 1) / 4) : n; 210 | const s = Object.entries(n).slice(0, r ? 3 : 4).map(([a, f]) => { 211 | const d = a === "a" ? P : S(t, a); 212 | return (a === "a" ? "/ " : "") + d.to(f); 213 | }); 214 | return `${t}(${s.join(" ")})`; 215 | } 216 | function rt(t) { 217 | return /^#(?:(?:[A-F0-9]{2}){3,4}|[A-F0-9]{3,4})$/i.test(t); 218 | } 219 | function kt(t) { 220 | return "r" in t ? "rgb" : "w" in t ? "hwb" : "v" in t ? "hsv" : "s" in t ? "hsl" : null; 221 | } 222 | const tt = { 223 | hsl: ["h", "s", "l", "a"], 224 | hwb: ["h", "w", "b", "a"], 225 | rgb: ["r", "g", "b", "a"] 226 | }; 227 | function It(t) { 228 | if (typeof t != "string") { 229 | const c = kt(t); 230 | return c === null ? null : { format: c, color: t }; 231 | } 232 | if (t.startsWith("#")) 233 | return rt(t) ? { format: "hex", color: t } : null; 234 | if (!t.includes("(")) { 235 | const c = document.createElement("canvas").getContext("2d"); 236 | c.fillStyle = t; 237 | const v = c.fillStyle; 238 | return v === "#000000" && t !== "black" ? null : { format: "hex", color: v }; 239 | } 240 | const [n, r] = t.split("("), s = n.substring(0, 3); 241 | if (!(s in tt)) 242 | return null; 243 | const a = r.replace(/[,/)]/g, " ").replace(/\s+/g, " ").trim().split(" "); 244 | a.length === 3 && a.push("1"); 245 | const f = tt[s], d = Object.fromEntries(f.map((c, v) => { 246 | const m = c === "a" ? P : S(s, c); 247 | return [ 248 | c, 249 | m.from(a[v]) 250 | ]; 251 | })); 252 | return { format: s, color: d }; 253 | } 254 | function Nt(t, n, r) { 255 | const s = t.getBoundingClientRect(), a = n - s.left, f = r - s.top; 256 | return { 257 | x: s.width === 0 ? 0 : k(a / s.width * 100, 0, 100), 258 | y: s.height === 0 ? 0 : k((1 - f / s.height) * 100, 0, 100) 259 | }; 260 | } 261 | const Vt = { class: "vacp-range-input-group" }, At = ["for"], Ht = { class: "vacp-range-input-label-text vacp-range-input-label-text--hue" }, Et = ["id", "value"], Lt = ["for"], Pt = { class: "vacp-range-input-label-text vacp-range-input-label-text--alpha" }, St = ["id", "value"], Ot = { class: "vacp-color-inputs" }, Rt = { class: "vacp-color-input-group" }, Wt = ["for"], jt = ["id", "value"], Dt = ["id", "for", "onInput"], _t = { class: "vacp-color-input-label-text" }, zt = ["id", "value", "onInput"], Kt = /* @__PURE__ */ pt({ 262 | __name: "ColorPicker", 263 | props: { 264 | color: { default: "#ffffffff" }, 265 | copy: { type: Function, default: void 0 }, 266 | id: { default: "color-picker" }, 267 | visibleFormats: { default: () => ["hex", "hsl", "hwb", "rgb"] }, 268 | defaultFormat: { default: "hsl" }, 269 | alphaChannel: { default: "show" } 270 | }, 271 | emits: ["color-change", "color-copy"], 272 | setup(t, { expose: n, emit: r }) { 273 | const s = ["hex", "hsl", "hsv", "hwb", "rgb"], a = t, f = r; 274 | n({ 275 | copyColor: U 276 | }); 277 | const d = N(null), c = N(null), v = N(null); 278 | let m = !1; 279 | const p = N(a.visibleFormats.includes(a.defaultFormat) ? a.defaultFormat : a.visibleFormats[0]), u = vt({ 280 | hex: "#ffffffff", 281 | hsl: { h: 0, s: 0, l: 100, a: 1 }, 282 | hsv: { h: 0, s: 0, v: 100, a: 1 }, 283 | hwb: { h: 0, w: 100, b: 0, a: 1 }, 284 | rgb: { r: 255, g: 255, b: 255, a: 1 } 285 | }), st = G(function() { 286 | const o = p.value, e = u[o]; 287 | return o.split("").map((l) => { 288 | const i = e[l]; 289 | return { 290 | value: S(o, l).to(i), 291 | channel: l, 292 | label: l.toUpperCase() 293 | }; 294 | }).concat(a.alphaChannel === "show" ? [{ 295 | value: P.to(e.a), 296 | channel: "a", 297 | label: "Alpha" 298 | }] : []); 299 | }), at = G(function() { 300 | return a.alphaChannel === "hide" && [5, 9].includes(u.hex.length) ? u.hex.substring(0, u.hex.length - (u.hex.length - 1) / 4) : u.hex; 301 | }); 302 | dt(() => a.color, K), mt(function() { 303 | document.addEventListener("mousemove", O, { passive: !1 }), document.addEventListener("touchmove", R, { passive: !1 }), document.addEventListener("mouseup", I), document.addEventListener("touchend", I), K(a.color); 304 | }), gt(function() { 305 | document.removeEventListener("mousemove", O), document.removeEventListener("touchmove", R), document.removeEventListener("mouseup", I), document.removeEventListener("touchend", I); 306 | }); 307 | function lt() { 308 | const e = (a.visibleFormats.findIndex((l) => l === p.value) + 1) % a.visibleFormats.length; 309 | p.value = a.visibleFormats[e]; 310 | } 311 | function it(o) { 312 | m = !0, O(o); 313 | } 314 | function ut(o) { 315 | m = !0, R(o); 316 | } 317 | function I() { 318 | m = !1; 319 | } 320 | function O(o) { 321 | o.buttons !== 1 || m === !1 || !(c.value instanceof HTMLElement) || z(c.value, o.clientX, o.clientY); 322 | } 323 | function R(o) { 324 | if (m === !1 || !(c.value instanceof HTMLElement)) 325 | return; 326 | o.preventDefault(); 327 | const e = o.touches[0]; 328 | z(c.value, e.clientX, e.clientY); 329 | } 330 | function z(o, e, l) { 331 | const i = Nt(o, e, l), g = Object.assign({}, u.hsv); 332 | g.s = i.x, g.v = i.y, y("hsv", g); 333 | } 334 | function ct(o) { 335 | if (!["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"].includes(o.key)) 336 | return; 337 | o.preventDefault(); 338 | const e = ["ArrowLeft", "ArrowDown"].includes(o.key) ? -1 : 1, l = ["ArrowLeft", "ArrowRight"].includes(o.key) ? "s" : "v", i = o.shiftKey ? 10 : 1, g = u.hsv[l] + e * i, b = Object.assign({}, u.hsv); 339 | b[l] = k(g, 0, 100), y("hsv", b); 340 | } 341 | function K(o) { 342 | const e = It(o); 343 | e !== null && y(e.format, e.color); 344 | } 345 | function Y(o, e) { 346 | const l = o.currentTarget, i = Object.assign({}, u.hsv); 347 | i[e] = Number(l.value), y("hsv", i); 348 | } 349 | function ht(o) { 350 | const e = o.target; 351 | rt(e.value) && y("hex", e.value); 352 | } 353 | function B(o, e) { 354 | const l = o.target, i = p.value, g = Object.assign({}, u[i]), W = (e === "a" ? P : S(i, e)).from(l.value); 355 | Number.isNaN(W) || W === void 0 || (g[e] = W, y(i, g)); 356 | } 357 | function y(o, e) { 358 | let l = e; 359 | if (a.alphaChannel === "hide") 360 | if (typeof e != "string") 361 | e.a = 1, l = e; 362 | else if ([5, 9].includes(e.length)) { 363 | const i = (e.length - 1) / 4; 364 | l = e.substring(0, e.length - i) + "f".repeat(i); 365 | } else [4, 7].includes(e.length) && (l = e + "f".repeat((e.length - 1) / 3)); 366 | if (!xt(u[o], l)) { 367 | u[o] = l; 368 | for (const i of s) 369 | i !== o && (u[i] = Tt(o, i, l)); 370 | f("color-change", q()); 371 | } 372 | d.value instanceof HTMLElement && c.value instanceof HTMLElement && v.value instanceof HTMLElement && ft(d.value, c.value, v.value); 373 | } 374 | async function U() { 375 | const o = u[p.value], e = a.alphaChannel === "hide", l = _({ color: o, format: p.value }, e); 376 | await (a.copy ? a.copy : window.navigator.clipboard.writeText)(l), f("color-copy", q()); 377 | } 378 | function ft(o, e, l) { 379 | const i = _({ format: "hsl", color: u.hsl }, !0); 380 | o.style.setProperty("--vacp-color", i), e.style.position = "relative", e.style.backgroundColor = `hsl(${u.hsl.h} 100% 50%)`, e.style.backgroundImage = "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)", l.style.boxSizing = "border-box", l.style.position = "absolute", l.style.left = `${u.hsv.s}%`, l.style.bottom = `${u.hsv.v}%`; 381 | } 382 | function q() { 383 | const o = a.alphaChannel === "hide", e = _({ color: u[p.value], format: p.value }, o); 384 | return { 385 | colors: u, 386 | cssColor: e 387 | }; 388 | } 389 | function X(o) { 390 | if (!["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"].includes(o.key) || !o.shiftKey) 391 | return; 392 | const e = o.currentTarget, l = Number(e.step), i = ["ArrowLeft", "ArrowDown"].includes(o.key) ? -1 : 1, g = Number(e.value) + i * l * 10, b = k(g, Number(e.min), Number(e.max)); 393 | e.value = String(b - i * l); 394 | } 395 | return (o, e) => (x(), C("div", { 396 | ref_key: "colorPicker", 397 | ref: d, 398 | class: "vacp-color-picker" 399 | }, [ 400 | h("div", { 401 | ref_key: "colorSpace", 402 | ref: c, 403 | class: "vacp-color-space", 404 | onMousedown: it, 405 | onTouchstart: ut 406 | }, [ 407 | h("div", { 408 | ref_key: "thumb", 409 | ref: v, 410 | class: "vacp-color-space-thumb", 411 | tabindex: "0", 412 | "aria-label": "Color space thumb", 413 | onKeydown: ct 414 | }, null, 544) 415 | ], 544), 416 | h("div", Vt, [ 417 | h("label", { 418 | class: "vacp-range-input-label vacp-range-input-label--hue", 419 | for: `${o.id}-hue-slider` 420 | }, [ 421 | h("span", Ht, [ 422 | V(o.$slots, "hue-range-input-label", {}, () => [ 423 | e[2] || (e[2] = J("Hue")) 424 | ]) 425 | ]), 426 | h("input", { 427 | id: `${o.id}-hue-slider`, 428 | class: "vacp-range-input vacp-range-input--hue", 429 | value: u.hsv.h, 430 | type: "range", 431 | min: "0", 432 | max: "360", 433 | step: "1", 434 | onKeydownPassive: X, 435 | onInput: e[0] || (e[0] = (l) => Y(l, "h")) 436 | }, null, 40, Et) 437 | ], 8, At), 438 | o.alphaChannel === "show" ? (x(), C("label", { 439 | key: 0, 440 | class: "vacp-range-input-label vacp-range-input-label--alpha", 441 | for: `${o.id}-alpha-slider` 442 | }, [ 443 | h("span", Pt, [ 444 | V(o.$slots, "alpha-range-input-label", {}, () => [ 445 | e[3] || (e[3] = J("Alpha")) 446 | ]) 447 | ]), 448 | h("input", { 449 | id: `${o.id}-alpha-slider`, 450 | class: "vacp-range-input vacp-range-input--alpha", 451 | value: u.hsv.a, 452 | type: "range", 453 | min: "0", 454 | max: "1", 455 | step: "0.01", 456 | onKeydownPassive: X, 457 | onInput: e[1] || (e[1] = (l) => Y(l, "a")) 458 | }, null, 40, St) 459 | ], 8, Lt)) : Q("", !0) 460 | ]), 461 | h("button", { 462 | class: "vacp-copy-button", 463 | type: "button", 464 | onClick: U 465 | }, [ 466 | V(o.$slots, "copy-button", {}, () => [ 467 | e[4] || (e[4] = h("span", { class: "vacp-visually-hidden" }, "Copy color", -1)), 468 | e[5] || (e[5] = h("svg", { 469 | class: "vacp-icon", 470 | xmlns: "http://www.w3.org/2000/svg", 471 | "aria-hidden": "true", 472 | width: "24", 473 | height: "24", 474 | viewBox: "0 0 32 32" 475 | }, [ 476 | h("path", { 477 | d: "M25.313 28v-18.688h-14.625v18.688h14.625zM25.313 6.688c1.438 0 2.688 1.188 2.688 2.625v18.688c0 1.438-1.25 2.688-2.688 2.688h-14.625c-1.438 0-2.688-1.25-2.688-2.688v-18.688c0-1.438 1.25-2.625 2.688-2.625h14.625zM21.313 1.313v2.688h-16v18.688h-2.625v-18.688c0-1.438 1.188-2.688 2.625-2.688h16z", 478 | fill: "currentColor" 479 | }) 480 | ], -1)) 481 | ]) 482 | ]), 483 | h("div", Ot, [ 484 | h("div", Rt, [ 485 | p.value === "hex" ? (x(), C("label", { 486 | key: 0, 487 | class: "vacp-color-input-label", 488 | for: `${o.id}-color-hex` 489 | }, [ 490 | e[6] || (e[6] = h("span", { class: "vacp-color-input-label-text" }, " Hex ", -1)), 491 | h("input", { 492 | id: `${o.id}-color-hex`, 493 | class: "vacp-color-input", 494 | type: "text", 495 | value: at.value, 496 | onInput: ht 497 | }, null, 40, jt) 498 | ], 8, Wt)) : (x(!0), C(bt, { key: 1 }, wt(st.value, ({ value: l, channel: i, label: g }) => (x(), C("label", { 499 | id: `${o.id}-color-${p.value}-${i}-label`, 500 | key: `${o.id}-color-${p.value}-${i}-label`, 501 | class: "vacp-color-input-label", 502 | for: `${o.id}-color-${p.value}-${i}`, 503 | onInput: (b) => B(b, i) 504 | }, [ 505 | h("span", _t, yt(g), 1), 506 | h("input", { 507 | id: `${o.id}-color-${p.value}-${i}`, 508 | class: "vacp-color-input", 509 | type: "text", 510 | value: l, 511 | onInput: (b) => B(b, i) 512 | }, null, 40, zt) 513 | ], 40, Dt))), 128)) 514 | ]), 515 | o.visibleFormats.length > 1 ? (x(), C("button", { 516 | key: 0, 517 | class: "vacp-format-switch-button", 518 | type: "button", 519 | onClick: lt 520 | }, [ 521 | V(o.$slots, "format-switch-button", {}, () => [ 522 | e[7] || (e[7] = h("span", { class: "vacp-visually-hidden" }, "Switch format", -1)), 523 | e[8] || (e[8] = h("svg", { 524 | class: "vacp-icon", 525 | "aria-hidden": "true", 526 | xmlns: "http://www.w3.org/2000/svg", 527 | width: "16", 528 | height: "15" 529 | }, [ 530 | h("path", { 531 | d: "M8 15l5-5-1-1-4 2-4-2-1 1zm4-9l1-1-5-5-5 5 1 1 4-2z", 532 | fill: "currentColor" 533 | }) 534 | ], -1)) 535 | ]) 536 | ])) : Q("", !0) 537 | ]) 538 | ], 512)); 539 | } 540 | }), Bt = { 541 | install(t) { 542 | t.component("ColorPicker", Kt); 543 | } 544 | }; 545 | export { 546 | Kt as ColorPicker, 547 | Bt as default 548 | }; 549 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import stylistic from '@stylistic/eslint-plugin' 3 | import vueTsEslintConfig from '@vue/eslint-config-typescript' 4 | import pluginVue from 'eslint-plugin-vue' 5 | import globals from 'globals' 6 | import tseslint from 'typescript-eslint' 7 | 8 | export default tseslint.config( 9 | { 10 | ignores: ['coverage/', 'dist/', 'temp/'], 11 | }, 12 | eslint.configs.recommended, 13 | ...tseslint.configs.strict, 14 | ...tseslint.configs.stylistic, 15 | ...pluginVue.configs['flat/recommended'], 16 | ...vueTsEslintConfig(), 17 | { 18 | files: ['**/*.{js,ts,vue}'], 19 | languageOptions: { 20 | globals: { 21 | ...globals.browser, 22 | }, 23 | }, 24 | plugins: { 25 | '@stylistic': stylistic, 26 | }, 27 | rules: { 28 | // Interferes with `get [Symbol.toStringTag]`. 29 | '@typescript-eslint/class-literal-property-style': 'off', 30 | // Don't care. 31 | '@typescript-eslint/no-non-null-assertion': 'off', 32 | // Intereferes with assigning Color* object types to `Record`. 33 | '@typescript-eslint/consistent-type-definitions': 'off', 34 | 35 | '@stylistic/comma-dangle': ['error', 'always-multiline'], 36 | '@stylistic/indent': ['error', 'tab'], 37 | 'vue/html-indent': ['error', 'tab'], 38 | '@stylistic/semi': ['error', 'never'], 39 | '@stylistic/space-before-function-paren': ['error', 'always'], 40 | '@stylistic/quotes': ['error', 'single'], 41 | }, 42 | }, 43 | { 44 | files: ['**/*.test.{js,ts}'], 45 | rules: { 46 | '@typescript-eslint/ban-ts-comment': 'off', 47 | }, 48 | }, 49 | ) 50 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | color picker demo 9 | 10 | 15 | 16 | 17 | 18 |
19 | 20 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-accessible-color-picker", 3 | "version": "5.2.0", 4 | "license": "MIT", 5 | "description": "Color picker component for Vue.js", 6 | "author": { 7 | "name": "Philipp Rudloff", 8 | "url": "https://kleinfreund.de" 9 | }, 10 | "homepage": "https://vue-accessible-color-picker.netlify.app", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/kleinfreund/vue-accessible-color-picker.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/kleinfreund/vue-accessible-color-picker/issues" 17 | }, 18 | "keywords": [ 19 | "color picker", 20 | "component", 21 | "hsl", 22 | "hwb", 23 | "vue" 24 | ], 25 | "type": "module", 26 | "exports": { 27 | ".": { 28 | "types": "./dist/ColorPicker.d.ts", 29 | "default": "./dist/ColorPicker.js" 30 | }, 31 | "./styles": "./dist/ColorPicker.css" 32 | }, 33 | "types": "./dist/ColorPicker.d.ts", 34 | "files": [ 35 | "dist/ColorPicker.css", 36 | "dist/ColorPicker.d.ts", 37 | "dist/ColorPicker.js" 38 | ], 39 | "sideEffects": false, 40 | "scripts": { 41 | "clean:dist": "rimraf dist", 42 | "clean:dist-source": "rimraf dist/src", 43 | "build": "run-s clean:dist build:lib build:types build:consolidate-types clean:dist-source", 44 | "build:lib": "vite build", 45 | "build:types": "vue-tsc --project tsconfig.build-types.json", 46 | "build:consolidate-types": "api-extractor run --local --verbose", 47 | "start": "vite", 48 | "test": "vitest run --coverage", 49 | "test:watch": "vitest watch", 50 | "lint": "run-p lint:*", 51 | "lint:code": "eslint", 52 | "lint:lockfile": "lockfile-lint --path package-lock.json --validate-hosts --allowed-hosts npm", 53 | "lint:package": "publint", 54 | "lint:types": "vue-tsc --noEmit", 55 | "fix": "run-p fix:*", 56 | "fix:code": "npm run lint:code -- --fix", 57 | "release": "semantic-release", 58 | "prepare": "husky", 59 | "prepublishOnly": "npm run build" 60 | }, 61 | "peerDependencies": { 62 | "vue": "^3.2.x" 63 | }, 64 | "devDependencies": { 65 | "@babel/types": "^7.26.5", 66 | "@commitlint/cli": "19.6.1", 67 | "@commitlint/config-conventional": "19.6.0", 68 | "@eslint/js": "^9.18.0", 69 | "@microsoft/api-extractor": "^7.49.1", 70 | "@semantic-release/changelog": "^6.0.3", 71 | "@semantic-release/commit-analyzer": "^13.0.1", 72 | "@semantic-release/git": "^10.0.1", 73 | "@semantic-release/github": "^11.0.1", 74 | "@semantic-release/npm": "^12.0.1", 75 | "@semantic-release/release-notes-generator": "^14.0.3", 76 | "@stylistic/eslint-plugin": "^2.13.0", 77 | "@types/eslint__js": "^8.42.3", 78 | "@vitejs/plugin-vue": "^5.2.1", 79 | "@vitest/coverage-v8": "^2.1.8", 80 | "@vue/eslint-config-typescript": "^14.3.0", 81 | "@vue/test-utils": "^2.4.6", 82 | "eslint": "^9.18.0", 83 | "eslint-plugin-vue": "^9.32.0", 84 | "globals": "^15.14.0", 85 | "husky": "^9.1.7", 86 | "jsdom": "^25.0.1", 87 | "lockfile-lint": "^4.14.0", 88 | "npm-run-all2": "^7.0.2", 89 | "postcss": "^8.5.1", 90 | "publint": "^0.3.2", 91 | "rimraf": "^6.0.1", 92 | "sass": "^1.83.4", 93 | "semantic-release": "^24.2.1", 94 | "standard": "^17.1.2", 95 | "typescript": "~5.7.3", 96 | "typescript-eslint": "^8.20.0", 97 | "vite": "^5.4.11", 98 | "vitest": "^2.1.8", 99 | "vue": "^3.5.13", 100 | "vue-tsc": "^2.2.0" 101 | }, 102 | "overrides": { 103 | "conventional-changelog-conventionalcommits": ">= 8.0.0" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs' 2 | 3 | const commitPartial = readFileSync('./changelog-template-commit.hbs', { encoding: 'utf-8' }) 4 | 5 | /** 6 | * Adds the commit body line by line so I can add it with the correct indentation in `changelog-template-commit.hbs`. 7 | */ 8 | function finalizeContext (context) { 9 | for (const commitGroup of context.commitGroups) { 10 | for (const commit of commitGroup.commits) { 11 | commit.bodyLines = commit.body?.split('\n').filter((line) => line !== '') ?? [] 12 | } 13 | } 14 | 15 | return context 16 | } 17 | 18 | /** @type {import('semantic-release').Options} */ const options = { 19 | branches: [ 20 | 'main', 21 | ], 22 | plugins: [ 23 | // This analyzes all new commits and determines whether to release a new version. 24 | // https://github.com/semantic-release/commit-analyzer 25 | '@semantic-release/commit-analyzer', 26 | 27 | // This takes the releasing commits’ messages and compiles release notes to be used for the CHANGELOG.md file and the GitHub release. 28 | // https://github.com/semantic-release/release-notes-generator 29 | ['@semantic-release/release-notes-generator', { 30 | preset: 'conventionalcommits', 31 | writerOpts: { 32 | commitPartial, 33 | finalizeContext, 34 | }, 35 | }], 36 | 37 | // This creates/updates the CHANGELOG.md file. 38 | // https://github.com/semantic-release/changelog 39 | '@semantic-release/changelog', 40 | 41 | // This updates the package.json and package-lock.json files with the new version number. 42 | // https://github.com/semantic-release/npm 43 | '@semantic-release/npm', 44 | 45 | // This creates a GitHub release and attaches assets to it. 46 | // https://github.com/semantic-release/github 47 | ['@semantic-release/github', { 48 | assets: [ 49 | { path: 'dist/ColorPicker.css' }, 50 | { path: 'dist/ColorPicker.d.ts' }, 51 | { path: 'dist/ColorPicker.js' }, 52 | ], 53 | }], 54 | 55 | // This creates a release commit in the git repository. 56 | // https://github.com/semantic-release/git 57 | '@semantic-release/git', 58 | ], 59 | } 60 | 61 | export default options 62 | -------------------------------------------------------------------------------- /src/ColorPicker.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, beforeEach, describe, test, expect, vi } from 'vitest' 2 | import { shallowMount, flushPromises, ComponentMountingOptions } from '@vue/test-utils' 3 | 4 | import ColorPicker from './ColorPicker.vue' 5 | import { ColorChangeDetail, ColorPickerProps } from './types.js' 6 | 7 | type MountingOptions = ComponentMountingOptions & { 8 | // Overrides the props in `ComponentMountingOptions` to be more accurate. 9 | props?: ColorPickerProps 10 | } 11 | 12 | function createWrapper (options: MountingOptions = {}) { 13 | return shallowMount(ColorPicker, options) 14 | } 15 | 16 | /** 17 | * These tests make use of [Vitest][1] and [Vue Test Utils][2]. 18 | * 19 | * [1]: https://vitest.dev/ 20 | * [2]: https://vue-test-utils.vuejs.org/ 21 | */ 22 | 23 | describe('ColorPicker', () => { 24 | beforeEach(() => { 25 | vi.restoreAllMocks() 26 | }) 27 | 28 | test('can be mounted', () => { 29 | const wrapper = createWrapper() 30 | 31 | expect(wrapper.html()).toBeTruthy() 32 | }) 33 | 34 | test.each([ 35 | {}, 36 | { 37 | color: '#ffffffff', 38 | alphaChannel: 'show', 39 | }, 40 | { 41 | color: '#ffffffff', 42 | alphaChannel: 'hide', 43 | }, 44 | { 45 | color: '#ffffff', 46 | alphaChannel: 'show', 47 | }, 48 | { 49 | color: '#ffffff', 50 | alphaChannel: 'hide', 51 | }, 52 | { 53 | color: '#fff', 54 | alphaChannel: 'show', 55 | }, 56 | { 57 | color: '#fff', 58 | alphaChannel: 'hide', 59 | }, 60 | { 61 | color: 'hsl(0 0% 100% / 1)', 62 | alphaChannel: 'show', 63 | }, 64 | { 65 | color: 'hsl(0 0% 100% / 1)', 66 | alphaChannel: 'hide', 67 | }, 68 | ])('initializes color space and thumb correctly with default color value', (props) => { 69 | const wrapper = createWrapper({ 70 | props: { 71 | defaultFormat: 'hex', 72 | ...props, 73 | }, 74 | }) 75 | 76 | const colorPicker = wrapper.find('.vacp-color-picker').element 77 | expect(colorPicker.style.getPropertyValue('--vacp-color')).toBe('hsl(0 0% 100%)') 78 | 79 | const thumb = wrapper.find('.vacp-color-space-thumb').element 80 | expect(thumb.style.left).toBe('0%') 81 | expect(thumb.style.bottom).toBe('100%') 82 | }) 83 | 84 | test('removes event listeners on unmount', async () => { 85 | const wrapper = createWrapper() 86 | 87 | const colorSpace = wrapper.find('.vacp-color-space') 88 | 89 | colorSpace.element.getBoundingClientRect = vi.fn(() => ({ 90 | width: 200, 91 | height: 200, 92 | x: 0, 93 | y: 0, 94 | top: 0, 95 | left: 0, 96 | bottom: 0, 97 | right: 0, 98 | toJSON: vi.fn(), 99 | })) 100 | 101 | // await colorSpace.trigger('mousedown', { buttons: 1, clientX: 0 }) 102 | await colorSpace.element.dispatchEvent(new MouseEvent('mousedown', { buttons: 1, clientX: 0 })) 103 | 104 | document.dispatchEvent(new MouseEvent('mousemove', { buttons: 1, clientX: 0 })) 105 | expect(wrapper.emitted('color-change')).toBe(undefined) 106 | 107 | document.dispatchEvent(new MouseEvent('mousemove', { buttons: 1, clientX: 1 })) 108 | expect(wrapper.emitted('color-change')?.length).toBe(1) 109 | 110 | wrapper.unmount() 111 | 112 | document.dispatchEvent(new MouseEvent('mousemove', { buttons: 1, clientX: 2 })) 113 | // Note that we assert here that the method hasn’t been called *again*. 114 | expect(wrapper.emitted('color-change')).toBe(undefined) 115 | }) 116 | 117 | describe('props & attributes', () => { 118 | test.each<[ColorPickerProps, string]>([ 119 | [ 120 | { color: '#f00' }, 121 | '#f00', 122 | ], 123 | [ 124 | { color: { r: 255, g: 127.5, b: 0, a: 0.5 } }, 125 | '#ff800080', 126 | ], 127 | [ 128 | { color: { h: 0, s: 100, l: 50, a: 1 } }, 129 | '#ff0000ff', 130 | ], 131 | [ 132 | { color: { h: 180, w: 33, b: 50, a: 1 } }, 133 | '#548080ff', 134 | ], 135 | ])('mounts correctly with a valid color prop', async (props, expectedHexInputValue) => { 136 | const wrapper = createWrapper({ 137 | props: { 138 | defaultFormat: 'hex', 139 | ...props, 140 | }, 141 | }) 142 | 143 | // We need to wait one tick before Vue will have re-rendered the component. 144 | await flushPromises() 145 | 146 | const input = wrapper.find('.vacp-color-input').element 147 | expect(input.value).toBe(expectedHexInputValue) 148 | }) 149 | 150 | test('mounts correctly with an invalid color prop', () => { 151 | const wrapper = createWrapper({ 152 | props: { 153 | color: '#ff', 154 | defaultFormat: 'hex', 155 | }, 156 | }) 157 | 158 | const input = wrapper.find('.vacp-color-input').element 159 | expect(input.value).toBe('#ffffffff') 160 | }) 161 | 162 | test('falls back to visible color format when defaultFormat isn\'t a visible format', () => { 163 | const wrapper = createWrapper({ 164 | props: { 165 | color: '#fff', 166 | defaultFormat: 'hsl', 167 | visibleFormats: ['hex'], 168 | }, 169 | }) 170 | 171 | const input = wrapper.find('.vacp-color-input').element 172 | expect(input.value).toBe('#ffffffff') 173 | }) 174 | 175 | test.each<[ColorPickerProps, string[]]>([ 176 | [ 177 | { defaultFormat: undefined }, 178 | ['H', 'S', 'L'], 179 | ], 180 | [ 181 | { defaultFormat: 'hex' }, 182 | ['Hex'], 183 | ], 184 | [ 185 | { defaultFormat: 'hsl' }, 186 | ['H', 'S', 'L'], 187 | ], 188 | [ 189 | { defaultFormat: 'hwb' }, 190 | ['H', 'W', 'B'], 191 | ], 192 | [ 193 | { defaultFormat: 'rgb' }, 194 | ['R', 'G', 'B'], 195 | ], 196 | ])('sets active color format to “%s” when providing default format prop', (props, expectedLabels) => { 197 | const wrapper = createWrapper({ props }) 198 | 199 | const inputGroupMarkup = wrapper.find('.vacp-color-input-group').html() 200 | for (const expectedLabel of expectedLabels) { 201 | expect(inputGroupMarkup).toContain(expectedLabel) 202 | } 203 | }) 204 | 205 | test.each([ 206 | [ 207 | '#f80c', 208 | { r: 255, g: 136, b: 0, a: 0.8 }, 209 | ], 210 | [ 211 | { h: 180, s: 50, l: 50, a: 1 }, 212 | { r: 63.75, g: 191.25, b: 191.25, a: 1 }, 213 | ], 214 | ])('recomputes colors when color prop changes', async (colorProp, expectedColorChangePayload) => { 215 | const wrapper = createWrapper() 216 | 217 | await wrapper.setProps({ color: colorProp }) 218 | let emittedColorChangeEvents = wrapper.emitted('color-change') 219 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 220 | let emittedRgbColor = emittedColorChangeEvents[emittedColorChangeEvents.length - 1][0].colors.rgb 221 | expect(emittedRgbColor).toEqual(expectedColorChangePayload) 222 | 223 | await wrapper.setProps({ color: '#fffc' }) 224 | emittedColorChangeEvents = wrapper.emitted('color-change') 225 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 226 | emittedRgbColor = emittedColorChangeEvents[emittedColorChangeEvents.length - 1][0].colors.rgb 227 | expect(emittedRgbColor).toEqual({ r: 255, g: 255, b: 255, a: 0.8 }) 228 | }) 229 | 230 | test('id attributes are set correctly', async () => { 231 | const id = 'test-color-picker' 232 | const wrapper = createWrapper({ 233 | props: { 234 | id, 235 | }, 236 | }) 237 | const formatSwitchButton = wrapper.find('.vacp-format-switch-button') 238 | 239 | const hueInput = wrapper.find(`#${id}-hue-slider`) 240 | expect(hueInput.exists()).toBe(true) 241 | const alphaInput = wrapper.find(`#${id}-alpha-slider`) 242 | expect(alphaInput.exists()).toBe(true) 243 | 244 | const formats = ['hsl', 'hwb', 'rgb'] 245 | 246 | for (const format of formats) { 247 | const channels = format.split('') 248 | expect(wrapper.find(`[id="${id}-color-${format}-${channels[0]}-label"]`).exists()).toBe(true) 249 | expect(wrapper.find(`[id="${id}-color-${format}-${channels[0]}"]`).exists()).toBe(true) 250 | expect(wrapper.find(`[for="${id}-color-${format}-${channels[0]}"]`).exists()).toBe(true) 251 | expect(wrapper.find(`[id="${id}-color-${format}-${channels[1]}-label"]`).exists()).toBe(true) 252 | expect(wrapper.find(`[id="${id}-color-${format}-${channels[1]}"]`).exists()).toBe(true) 253 | expect(wrapper.find(`[for="${id}-color-${format}-${channels[1]}"]`).exists()).toBe(true) 254 | expect(wrapper.find(`[id="${id}-color-${format}-${channels[2]}-label"]`).exists()).toBe(true) 255 | expect(wrapper.find(`[id="${id}-color-${format}-${channels[2]}"]`).exists()).toBe(true) 256 | expect(wrapper.find(`[for="${id}-color-${format}-${channels[2]}"]`).exists()).toBe(true) 257 | expect(wrapper.find(`[id="${id}-color-${format}-a"]`).exists()).toBe(true) 258 | expect(wrapper.find(`[for="${id}-color-${format}-a"]`).exists()).toBe(true) 259 | 260 | await formatSwitchButton.trigger('click') 261 | } 262 | }) 263 | 264 | test.each<[ColorPickerProps, boolean, string]>([ 265 | [{ alphaChannel: 'show' }, true, 'hsl(180 0% 100% / 1)'], 266 | [{ alphaChannel: 'hide' }, false, 'hsl(180 0% 100%)'], 267 | ])('shows/hides correct elements when setting alphaChannel', async (props, isElementVisible, expectedCssColor) => { 268 | const id = 'test-color-picker' 269 | const wrapper = createWrapper({ 270 | attachTo: document.body, 271 | props: { 272 | id, 273 | ...props, 274 | }, 275 | }) 276 | 277 | const alphaInput = wrapper.find(`#${id}-alpha-slider`) 278 | expect(alphaInput.exists()).toBe(isElementVisible) 279 | 280 | const colorHslAlphaInput = wrapper.find(`#${id}-color-hsl-a`) 281 | expect(colorHslAlphaInput.exists()).toBe(isElementVisible) 282 | 283 | const inputElement = wrapper.find(`#${id}-color-hsl-h`).element 284 | inputElement.value = '180' 285 | inputElement.dispatchEvent(new InputEvent('input')) 286 | 287 | const emittedColorChangeEvents = wrapper.emitted('color-change') 288 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 289 | const emittedCssColor = emittedColorChangeEvents[emittedColorChangeEvents.length - 1][0].cssColor 290 | expect(emittedCssColor).toEqual(expectedCssColor) 291 | }) 292 | 293 | test('sets fully-opaque “--vacp-color” custom property', async () => { 294 | const wrapper = createWrapper({ 295 | attachTo: document.body, 296 | }) 297 | await flushPromises() 298 | expect(wrapper.element.style.getPropertyValue('--vacp-color')).toBe('hsl(0 0% 100%)') 299 | 300 | wrapper.setProps({ 301 | color: '#f60c', 302 | }) 303 | await flushPromises() 304 | expect(wrapper.element.style.getPropertyValue('--vacp-color')).toBe('hsl(24 100% 50%)') 305 | }) 306 | }) 307 | 308 | describe('color space thumb interactions', () => { 309 | test('can initiate moving the color space thumb with a mouse', async () => { 310 | const wrapper = createWrapper({ 311 | attachTo: document.body, 312 | props: { 313 | color: '#f80c', 314 | }, 315 | }) 316 | 317 | expect(wrapper.emitted('color-change')?.length).toBe(1) 318 | 319 | const colorSpace = wrapper.find('.vacp-color-space') 320 | 321 | colorSpace.element.getBoundingClientRect = vi.fn(() => ({ 322 | width: 200, 323 | height: 200, 324 | x: 0, 325 | y: 0, 326 | top: 0, 327 | left: 0, 328 | bottom: 0, 329 | right: 0, 330 | toJSON: vi.fn(), 331 | })) 332 | 333 | await colorSpace.trigger('mousedown', { buttons: 1 }) 334 | colorSpace.trigger('mousemove', { 335 | buttons: 1, 336 | preventDefault: vi.fn(), 337 | }) 338 | 339 | expect(wrapper.emitted('color-change')?.length).toBe(2) 340 | 341 | // Remove test HTML injected via the `attachTo` option during mount. 342 | wrapper.unmount() 343 | }) 344 | 345 | test('can initiate moving the color space thumb with a touch-based device', async () => { 346 | const wrapper = createWrapper({ 347 | attachTo: document.body, 348 | props: { 349 | color: '#f80c', 350 | }, 351 | }) 352 | 353 | expect(wrapper.emitted('color-change')?.length).toBe(1) 354 | 355 | const colorSpace = wrapper.find('.vacp-color-space') 356 | 357 | colorSpace.element.getBoundingClientRect = vi.fn(() => ({ 358 | width: 200, 359 | height: 200, 360 | x: 0, 361 | y: 0, 362 | top: 0, 363 | left: 0, 364 | bottom: 0, 365 | right: 0, 366 | toJSON: vi.fn(), 367 | })) 368 | 369 | await colorSpace.trigger('touchstart', { 370 | preventDefault: vi.fn(), 371 | touches: [{ clientX: 0, clientY: 0 }], 372 | }) 373 | await colorSpace.trigger('touchmove', { 374 | preventDefault: vi.fn(), 375 | touches: [{ clientX: 1, clientY: 0 }], 376 | }) 377 | 378 | expect(wrapper.emitted('color-change')?.length).toBe(3) 379 | 380 | await colorSpace.trigger('touchstart', { 381 | preventDefault: vi.fn(), 382 | touches: [{ clientX: 2, clientY: 0 }], 383 | }) 384 | await colorSpace.trigger('touchmove', { 385 | preventDefault: vi.fn(), 386 | touches: [{ clientX: 3, clientY: 0 }], 387 | }) 388 | 389 | expect(wrapper.emitted('color-change')?.length).toBe(5) 390 | 391 | // Remove test HTML injected via the `attachTo` option during mount. 392 | wrapper.unmount() 393 | }) 394 | 395 | test('can not move the color space thumb with the wrong key', async () => { 396 | const keydownEvent = { 397 | key: 'a', 398 | preventDefault: vi.fn(), 399 | } 400 | 401 | const wrapper = createWrapper() 402 | 403 | const thumb = wrapper.find('.vacp-color-space-thumb') 404 | await thumb.trigger('keydown', keydownEvent) 405 | 406 | expect(keydownEvent.preventDefault).not.toHaveBeenCalled() 407 | }) 408 | 409 | test.each([ 410 | ['ArrowDown', false, 'v', 49], 411 | ['ArrowDown', true, 'v', 40], 412 | ['ArrowUp', false, 'v', 51], 413 | ['ArrowUp', true, 'v', 60], 414 | ['ArrowRight', false, 's', 51], 415 | ['ArrowRight', true, 's', 60], 416 | ['ArrowLeft', false, 's', 49], 417 | ['ArrowLeft', true, 's', 40], 418 | ])('can move the color space thumb with the %s key (holding shift: %s)', async (key, shiftKey, channel, expectedColorValue) => { 419 | const keydownEvent = { 420 | key, 421 | shiftKey, 422 | preventDefault: vi.fn(), 423 | } 424 | 425 | const wrapper = createWrapper({ 426 | props: { 427 | color: 'hwb(180 25% 50% / 1)', 428 | }, 429 | }) 430 | expect(keydownEvent.preventDefault).not.toHaveBeenCalled() 431 | 432 | const thumb = wrapper.find('.vacp-color-space-thumb') 433 | await thumb.trigger('keydown', keydownEvent) 434 | 435 | expect(keydownEvent.preventDefault).toHaveBeenCalled() 436 | const emittedColorChangeEvents = wrapper.emitted('color-change') 437 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 438 | const emittedHsvColor = emittedColorChangeEvents[emittedColorChangeEvents.length - 1][0].colors.hsv 439 | expect(emittedHsvColor[channel]).toEqual(expectedColorValue) 440 | }) 441 | }) 442 | 443 | describe('hue & alpha range inputs', () => { 444 | test('can not increment/decrement in big steps without holding down shift', async () => { 445 | const keydownEvent = { 446 | key: 'ArrowRight', 447 | shiftKey: false, 448 | } 449 | 450 | const wrapper = createWrapper({ 451 | props: { 452 | id: 'color-picker', 453 | }, 454 | }) 455 | const hueRangeInput = wrapper.find('#color-picker-hue-slider') 456 | const hueRangeInputElement = hueRangeInput.element 457 | const originalInputValue = hueRangeInputElement.value 458 | 459 | await hueRangeInput.trigger('keydown', keydownEvent) 460 | 461 | expect(hueRangeInputElement.value).toBe(originalInputValue) 462 | }) 463 | 464 | test.each([ 465 | ['decrement', 1, 'ArrowDown', '1'], 466 | ['decrement', 3, 'ArrowDown', '1'], 467 | ['decrement', 1, 'ArrowLeft', '1'], 468 | ['increment', 1, 'ArrowUp', '9'], 469 | ['increment', 1, 'ArrowRight', '9'], 470 | ['increment', 3, 'ArrowRight', '27'], 471 | ])('can %s range inputs %dx in big steps with %s', async (_, numberOfPresses, key, expectedValue) => { 472 | const wrapper = createWrapper({ 473 | props: { 474 | id: 'color-picker', 475 | }, 476 | }) 477 | const hueRangeInput = wrapper.find('#color-picker-hue-slider') 478 | const hueRangeInputElement = hueRangeInput.element 479 | const keydownEvent = { 480 | key, 481 | shiftKey: true, 482 | } 483 | 484 | expect(hueRangeInput.exists()).toBe(true) 485 | 486 | while (numberOfPresses--) { 487 | await hueRangeInput.trigger('keydown', keydownEvent) 488 | } 489 | 490 | expect(hueRangeInputElement.value).toBe(expectedValue) 491 | }) 492 | 493 | test('hue slider updates internal colors', async () => { 494 | const expectedHueValue = 30 495 | 496 | const wrapper = createWrapper({ 497 | props: { 498 | id: 'color-picker', 499 | }, 500 | }) 501 | const hueRangeInput = wrapper.find('#color-picker-hue-slider') 502 | const hueRangeInputElement = hueRangeInput.element 503 | hueRangeInputElement.value = String(expectedHueValue) 504 | const hueInputEvent = { currentTarget: hueRangeInputElement } 505 | 506 | await hueRangeInput.trigger('input', hueInputEvent) 507 | 508 | let emittedColorChangeEvents = wrapper.emitted('color-change') 509 | expect(emittedColorChangeEvents?.length).toBe(1) 510 | 511 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 512 | let emittedHsvColor = emittedColorChangeEvents[emittedColorChangeEvents.length - 1][0].colors.hsv 513 | expect(emittedHsvColor.h).toEqual(expectedHueValue) 514 | 515 | const expectedAlphaValue = 0.9 516 | 517 | const alphaRangeInput = wrapper.find('#color-picker-alpha-slider') 518 | const alphaRangeInputElement = alphaRangeInput.element 519 | alphaRangeInputElement.value = String(expectedAlphaValue) 520 | const alphaInputEvent = { currentTarget: alphaRangeInputElement } 521 | 522 | await alphaRangeInput.trigger('input', alphaInputEvent) 523 | 524 | emittedColorChangeEvents = wrapper.emitted('color-change') 525 | expect(emittedColorChangeEvents?.length).toBe(2) 526 | 527 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 528 | emittedHsvColor = emittedColorChangeEvents[emittedColorChangeEvents.length - 1][0].colors.hsv 529 | expect(emittedHsvColor.a).toEqual(expectedAlphaValue) 530 | }) 531 | }) 532 | 533 | describe('copy button', () => { 534 | beforeAll(() => { 535 | Object.defineProperty(global.navigator, 'clipboard', { 536 | value: { 537 | // eslint-disable-next-line @typescript-eslint/no-empty-function 538 | writeText: () => {}, 539 | }, 540 | }) 541 | }) 542 | 543 | test.each<[ColorPickerProps, string]>([ 544 | [ 545 | { defaultFormat: 'rgb', alphaChannel: 'show' }, 546 | 'rgb(255 255 255 / 1)', 547 | ], 548 | [ 549 | { defaultFormat: 'hsl', alphaChannel: 'show' }, 550 | 'hsl(0 0% 100% / 1)', 551 | ], 552 | [ 553 | { defaultFormat: 'hwb', alphaChannel: 'show' }, 554 | 'hwb(0 100% 0% / 1)', 555 | ], 556 | [ 557 | { defaultFormat: 'hex', alphaChannel: 'show' }, 558 | '#ffffffff', 559 | ], 560 | [ 561 | { defaultFormat: 'hex', alphaChannel: 'hide' }, 562 | '#ffffff', 563 | ], 564 | ])('copy button copies %s format as %s', async (props, cssColor) => { 565 | vi.spyOn(global.navigator.clipboard, 'writeText').mockImplementation(vi.fn(() => Promise.resolve())) 566 | 567 | const wrapper = createWrapper({ props }) 568 | 569 | const copyButton = wrapper.find('.vacp-copy-button') 570 | await copyButton.trigger('click') 571 | 572 | expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(cssColor) 573 | }) 574 | 575 | test('works with alternative copy function', async () => { 576 | const spy = vi.fn() 577 | const wrapper = createWrapper({ 578 | props: { 579 | copy: spy, 580 | }, 581 | }) 582 | 583 | await wrapper.vm.copyColor() 584 | expect(spy).toHaveBeenCalledTimes(1) 585 | }) 586 | }) 587 | 588 | describe('switch format button', () => { 589 | test('clicking switch format button cycles through active formats correctly', async () => { 590 | const wrapper = createWrapper() 591 | const formatSwitchButton = wrapper.find('.vacp-format-switch-button') 592 | 593 | expect(wrapper.find('#color-picker-color-hsl-l').exists()).toBe(true) 594 | 595 | await formatSwitchButton.trigger('click') 596 | expect(wrapper.find('#color-picker-color-hwb-w').exists()).toBe(true) 597 | 598 | await formatSwitchButton.trigger('click') 599 | expect(wrapper.find('#color-picker-color-rgb-r').exists()).toBe(true) 600 | 601 | await formatSwitchButton.trigger('click') 602 | expect(wrapper.find('#color-picker-color-hex').exists()).toBe(true) 603 | 604 | await formatSwitchButton.trigger('click') 605 | expect(wrapper.find('#color-picker-color-hsl-l').exists()).toBe(true) 606 | }) 607 | }) 608 | 609 | describe('color value inputs', () => { 610 | test.each<[ColorPickerProps, string, string]>([ 611 | [ 612 | { id: 'color-picker', defaultFormat: 'rgb' }, 613 | 'r', 614 | '127.', 615 | ], 616 | [ 617 | { id: 'color-picker', defaultFormat: 'hsl' }, 618 | 's', 619 | 'a', 620 | ], 621 | [ 622 | { id: 'color-picker', defaultFormat: 'hwb' }, 623 | 'b', 624 | '25.%', 625 | ], 626 | ])('updating a color input with an invalid value does not update the internal color data', async (props, channel, channelValue) => { 627 | const wrapper = createWrapper({ props }) 628 | 629 | const input = wrapper.find(`#${props.id}-color-${props.defaultFormat}-${channel}`) 630 | const inputElement = input.element 631 | inputElement.value = channelValue 632 | 633 | await input.trigger('input') 634 | 635 | const emittedColorChangeEvents = wrapper.emitted('color-change') 636 | expect(emittedColorChangeEvents).toBe(undefined) 637 | }) 638 | 639 | test.each([ 640 | ['abc'], 641 | ['25%'], 642 | ])('updating a hex color input with an invalid value does not update the internal color data', async (invalidHexColorString) => { 643 | const wrapper = createWrapper({ 644 | props: { 645 | id: 'color-picker', 646 | defaultFormat: 'hex', 647 | }, 648 | }) 649 | 650 | const input = wrapper.find('#color-picker-color-hex') 651 | const inputElement = input.element 652 | inputElement.value = invalidHexColorString 653 | 654 | await input.trigger('input') 655 | 656 | const emittedColorChangeEvents = wrapper.emitted('color-change') 657 | expect(emittedColorChangeEvents).toBe(undefined) 658 | }) 659 | 660 | test.each<[ColorPickerProps, string, string]>([ 661 | [ 662 | { id: 'color-picker', defaultFormat: 'rgb' }, 663 | 'r', 664 | '127.5', 665 | ], 666 | [ 667 | { id: 'color-picker', defaultFormat: 'hsl' }, 668 | 's', 669 | '75%', 670 | ], 671 | [ 672 | { id: 'color-picker', defaultFormat: 'hwb' }, 673 | 'b', 674 | '25.5%', 675 | ], 676 | ])('updating a %s color input with a valid value updates the internal color data', async (props, channel, channelValue) => { 677 | const wrapper = createWrapper({ props }) 678 | 679 | const input = wrapper.find(`#${props.id}-color-${props.defaultFormat}-${channel}`) 680 | const inputElement = input.element 681 | inputElement.value = channelValue 682 | 683 | await input.trigger('input') 684 | 685 | const emittedColorChangeEvents = wrapper.emitted('color-change') 686 | expect(emittedColorChangeEvents?.length).toBe(1) 687 | }) 688 | 689 | test.each([ 690 | ['#ff8800cc'], 691 | ])('updating a %s color input with a valid value updates the internal color data', async (channelValue) => { 692 | const wrapper = createWrapper({ 693 | props: { 694 | id: 'color-picker', 695 | defaultFormat: 'hex', 696 | }, 697 | }) 698 | 699 | const input = wrapper.find('#color-picker-color-hex') 700 | const inputElement = input.element 701 | inputElement.value = channelValue 702 | 703 | await input.trigger('input') 704 | 705 | const emittedColorChangeEvents = wrapper.emitted('color-change') 706 | expect(emittedColorChangeEvents?.length).toBe(1) 707 | }) 708 | }) 709 | 710 | describe('color-change event', () => { 711 | test.each<[ColorPickerProps, ColorChangeDetail]>([ 712 | [ 713 | { color: '#ff99aacc', defaultFormat: 'hsl', alphaChannel: 'show' }, 714 | { 715 | cssColor: 'hsl(350 100% 80% / 0.8)', 716 | colors: { 717 | hex: '#ff99aacc', 718 | hsl: { h: 350, s: 100, l: 80, a: 0.8 }, 719 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 0.8 }, 720 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 0.8 }, 721 | rgb: { r: 255, g: 153, b: 170, a: 0.8 }, 722 | }, 723 | }, 724 | ], 725 | [ 726 | { color: '#f9ac', defaultFormat: 'hsl', alphaChannel: 'show' }, 727 | { 728 | cssColor: 'hsl(350 100% 80% / 0.8)', 729 | colors: { 730 | hex: '#f9ac', 731 | hsl: { h: 350, s: 100, l: 80, a: 0.8 }, 732 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 0.8 }, 733 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 0.8 }, 734 | rgb: { r: 255, g: 153, b: 170, a: 0.8 }, 735 | }, 736 | }, 737 | ], 738 | [ 739 | { color: '#ff99aacc', defaultFormat: 'hex', alphaChannel: 'show' }, 740 | { 741 | cssColor: '#ff99aacc', 742 | colors: { 743 | hex: '#ff99aacc', 744 | hsl: { h: 350, s: 100, l: 80, a: 0.8 }, 745 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 0.8 }, 746 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 0.8 }, 747 | rgb: { r: 255, g: 153, b: 170, a: 0.8 }, 748 | }, 749 | }, 750 | ], 751 | [ 752 | { color: '#f9ac', defaultFormat: 'hex', alphaChannel: 'show' }, 753 | { 754 | cssColor: '#f9ac', 755 | colors: { 756 | hex: '#f9ac', 757 | hsl: { h: 350, s: 100, l: 80, a: 0.8 }, 758 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 0.8 }, 759 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 0.8 }, 760 | rgb: { r: 255, g: 153, b: 170, a: 0.8 }, 761 | }, 762 | }, 763 | ], 764 | [ 765 | { color: '#ff99aacc', defaultFormat: 'hsl', alphaChannel: 'hide' }, 766 | { 767 | cssColor: 'hsl(350 100% 80%)', 768 | colors: { 769 | hex: '#ff99aaff', 770 | hsl: { h: 350, s: 100, l: 80, a: 1 }, 771 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 1 }, 772 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 1 }, 773 | rgb: { r: 255, g: 153, b: 170, a: 1 }, 774 | }, 775 | }, 776 | ], 777 | [ 778 | { color: '#f9ac', defaultFormat: 'hsl', alphaChannel: 'hide' }, 779 | { 780 | cssColor: 'hsl(350 100% 80%)', 781 | colors: { 782 | hex: '#f9af', 783 | hsl: { h: 350, s: 100, l: 80, a: 1 }, 784 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 1 }, 785 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 1 }, 786 | rgb: { r: 255, g: 153, b: 170, a: 1 }, 787 | }, 788 | }, 789 | ], 790 | [ 791 | { color: '#ff99aacc', defaultFormat: 'hex', alphaChannel: 'hide' }, 792 | { 793 | cssColor: '#ff99aa', 794 | colors: { 795 | hex: '#ff99aaff', 796 | hsl: { h: 350, s: 100, l: 80, a: 1 }, 797 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 1 }, 798 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 1 }, 799 | rgb: { r: 255, g: 153, b: 170, a: 1 }, 800 | }, 801 | }, 802 | ], 803 | [ 804 | { color: '#f9ac', defaultFormat: 'hex', alphaChannel: 'hide' }, 805 | { 806 | cssColor: '#f9a', 807 | colors: { 808 | hex: '#f9af', 809 | hsl: { h: 350, s: 100, l: 80, a: 1 }, 810 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 1 }, 811 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 1 }, 812 | rgb: { r: 255, g: 153, b: 170, a: 1 }, 813 | }, 814 | }, 815 | ], 816 | [ 817 | { color: '#23a96a', defaultFormat: 'hex', alphaChannel: 'hide' }, 818 | { 819 | cssColor: '#23a96a', 820 | colors: { 821 | hex: '#23a96aff', 822 | hsl: { h: 151.7910447761194, s: 65.68627450980392, l: 40, a: 1 }, 823 | hsv: { h: 151.7910447761194, s: 79.28994082840237, v: 66.27450980392157, a: 1 }, 824 | hwb: { h: 151.7910447761194, w: 13.725490196078432, b: 33.725490196078425, a: 1 }, 825 | rgb: { r: 35, g: 169, b: 106, a: 1 }, 826 | }, 827 | }, 828 | ], 829 | ])('emits correct data', async (props, expectedData) => { 830 | const wrapper = createWrapper({ props }) 831 | 832 | await wrapper.setProps({ color: props.color }) 833 | 834 | const emittedEvents = wrapper.emitted('color-change') 835 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 836 | const data = emittedEvents[emittedEvents.length - 1][0] 837 | expect(data).toEqual(expectedData) 838 | }) 839 | }) 840 | 841 | describe('color-copy event', () => { 842 | test.each<[ColorPickerProps, ColorChangeDetail]>([ 843 | [ 844 | { color: '#ff99aacc', defaultFormat: 'hsl', alphaChannel: 'show' }, 845 | { 846 | cssColor: 'hsl(350 100% 80% / 0.8)', 847 | colors: { 848 | hex: '#ff99aacc', 849 | hsl: { h: 350, s: 100, l: 80, a: 0.8 }, 850 | hsv: { h: 350, s: 39.99999999999999, v: 100, a: 0.8 }, 851 | hwb: { h: 350, w: 60.00000000000001, b: 0, a: 0.8 }, 852 | rgb: { r: 255, g: 153, b: 170, a: 0.8 }, 853 | }, 854 | }, 855 | ], 856 | ])('emits correct data', async (props, expectedData) => { 857 | const wrapper = createWrapper({ props }) 858 | 859 | const copyButton = wrapper.find('.vacp-copy-button') 860 | await copyButton.trigger('click') 861 | 862 | const emittedEvents = wrapper.emitted('color-copy') 863 | // @ts-ignore because `unknown` is clearly not a correct type for emitted records. 864 | const data = emittedEvents[emittedEvents.length - 1][0] 865 | expect(data).toEqual(expectedData) 866 | }) 867 | }) 868 | 869 | describe('color inputs', () => { 870 | test.each<[ColorPickerProps, string]>([ 871 | [ 872 | { color: '#12345678', alphaChannel: 'show' }, 873 | '#12345678', 874 | ], 875 | [ 876 | { color: '#12345678', alphaChannel: 'hide' }, 877 | '#123456', 878 | ], 879 | [ 880 | { color: '#123456', alphaChannel: 'show' }, 881 | '#123456', 882 | ], 883 | [ 884 | { color: '#123456', alphaChannel: 'hide' }, 885 | '#123456', 886 | ], 887 | [ 888 | { color: '#123a', alphaChannel: 'show' }, 889 | '#123a', 890 | ], 891 | [ 892 | { color: '#123a', alphaChannel: 'hide' }, 893 | '#123', 894 | ], 895 | [ 896 | { color: '#123', alphaChannel: 'show' }, 897 | '#123', 898 | ], 899 | [ 900 | { color: '#123', alphaChannel: 'hide' }, 901 | '#123', 902 | ], 903 | ])('shows expected color for hex colors', async (props, expectedHexColor) => { 904 | const wrapper = createWrapper({ 905 | props: { 906 | defaultFormat: 'hex', 907 | ...props, 908 | }, 909 | }) 910 | 911 | await flushPromises() 912 | 913 | const input = wrapper.find('#color-picker-color-hex').element 914 | expect(input.value).toBe(expectedHexColor) 915 | }) 916 | }) 917 | }) 918 | -------------------------------------------------------------------------------- /src/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 160 | 161 | 521 | 522 | 863 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi } from 'vitest' 2 | 3 | import plugin from './index.js' 4 | 5 | describe('index.js', () => { 6 | test('default export has “install” function', () => { 7 | expect(typeof plugin.install).toBe('function') 8 | }) 9 | 10 | test('install function calls component function on argument', () => { 11 | const app = { component: vi.fn() } 12 | 13 | // @ts-ignore 14 | plugin.install(app) 15 | 16 | expect(app.component).toHaveBeenCalled() 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { App, Plugin } from 'vue' 2 | 3 | import ColorPicker from './ColorPicker.vue' 4 | 5 | const plugin: Plugin = { 6 | install (app: App) { 7 | app.component('ColorPicker', ColorPicker) 8 | }, 9 | } 10 | 11 | export { ColorPicker } 12 | 13 | export default plugin 14 | 15 | export type { 16 | AlphaChannelProp, 17 | ColorChangeDetail, 18 | ColorFormat, 19 | ColorHsl, 20 | ColorHsv, 21 | ColorHwb, 22 | ColorMap, 23 | ColorPickerProps, 24 | ColorRgb, 25 | VisibleColorFormat, 26 | } from './types.js' 27 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type AlphaChannelProp = 'show' | 'hide' 2 | 3 | export type ColorHsl = { 4 | h: number 5 | s: number 6 | l: number 7 | a: number 8 | } 9 | 10 | export type ColorHsv = { 11 | h: number 12 | s: number 13 | v: number 14 | a: number 15 | } 16 | 17 | export type ColorHwb = { 18 | h: number 19 | w: number 20 | b: number 21 | a: number 22 | } 23 | 24 | export type ColorRgb = { 25 | r: number 26 | g: number 27 | b: number 28 | a: number 29 | } 30 | 31 | export type ColorMap = { 32 | hex: string 33 | hsl: ColorHsl 34 | hsv: ColorHsv 35 | hwb: ColorHwb 36 | rgb: ColorRgb 37 | } 38 | 39 | export type ColorChangeDetail = { 40 | colors: ColorMap 41 | cssColor: string 42 | } 43 | 44 | export type ColorFormat = 'hex' | 'hsl' | 'hsv' | 'hwb' | 'rgb' 45 | export type VisibleColorFormat = Exclude 46 | 47 | export interface ColorPairHex { format: 'hex', color: string } 48 | export interface ColorPairHsl { format: 'hsl', color: ColorHsl } 49 | export interface ColorPairHsv { format: 'hsv', color: ColorHsv } 50 | export interface ColorPairHwb { format: 'hwb', color: ColorHwb } 51 | export interface ColorPairRgb { format: 'rgb', color: ColorRgb } 52 | 53 | export type ColorPair = ColorPairHex | ColorPairHsl | ColorPairHsv | ColorPairHwb | ColorPairRgb 54 | export type VisibleColorPair = Exclude 55 | 56 | export interface ColorPickerProps { 57 | /** 58 | * The initially rendered color. 59 | */ 60 | color?: string | ColorHsl | ColorHwb | ColorRgb 61 | 62 | /** 63 | * Takes a function that will be used in place of `window.navigator.clipboard.writeText` when triggering the color picker's copy color functionality (programmatically or via the UI). 64 | */ 65 | copy?: (cssColor: string) => Promise | void 66 | 67 | /** 68 | * The prefix for all ID attribute values used by the color picker. 69 | */ 70 | id?: string 71 | 72 | /** 73 | * The list of visible color formats. 74 | */ 75 | visibleFormats?: VisibleColorFormat[] 76 | 77 | /** 78 | * The initially visible color format. 79 | */ 80 | defaultFormat?: VisibleColorFormat 81 | 82 | /** 83 | * Controls whether the control related to a color’s alpha channel are rendered in the color picker. 84 | * 85 | * The following settings are available: 86 | * 87 | * - **show**: Default. The alpha channel range input and the alpha channel value input are rendered. 88 | * - **hide**: The alpha channel range input and the alpha channel value input are not rendered. The `color-change` event emits a `cssColor` property without the alpha channel part. 89 | */ 90 | alphaChannel?: AlphaChannelProp 91 | } 92 | -------------------------------------------------------------------------------- /src/utilities/CssValues.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { 4 | alpha, 5 | angle, 6 | number, 7 | percentage, 8 | rgbNumber, 9 | } from './CssValues.js' 10 | 11 | describe('CssValues', () => { 12 | describe('alpha', () => { 13 | test.each([ 14 | ['0', 0], 15 | ['0.5555', 0.5555], 16 | ['1', 1], 17 | ['0%', 0], 18 | ['55.55%', 0.5555], 19 | ['100%', 1], 20 | ])('alpha.from(%s) = %s', (value, expected) => { 21 | expect(alpha.from(value)).toEqual(expected) 22 | }) 23 | 24 | test.each([ 25 | [0, '0'], 26 | [0.5555, '0.56'], 27 | [1, '1'], 28 | ])('alpha.to(%s) = %s', (value, expected) => { 29 | expect(alpha.to(value)).toEqual(expected) 30 | }) 31 | }) 32 | 33 | describe('angle', () => { 34 | test.each([ 35 | ['-30', -30], 36 | ['0', 0], 37 | ['360', 360], 38 | ['450', 450], 39 | ['270', 270], 40 | ['270.', NaN], 41 | ['90deg', 90], 42 | ['100grad', 90], 43 | ['1.5707963267948966rad', 90], 44 | ['0.25turn', 90], 45 | ['90xdeg', NaN], 46 | ['90.deg', NaN], 47 | ])('hue.from(%s) = %s', (value, expected) => { 48 | expect(angle.from(value)).toEqual(expected) 49 | }) 50 | 51 | test.each([ 52 | [0, '0'], 53 | [60, '60'], 54 | [90, '90'], 55 | [120, '120'], 56 | [180, '180'], 57 | [270, '270'], 58 | ])('hue.to(%s) = %s', (value, expected) => { 59 | expect(angle.to(value)).toEqual(expected) 60 | }) 61 | }) 62 | 63 | describe('number', () => { 64 | test.each([ 65 | ['0', 0], 66 | ['10.', NaN], 67 | ['a', NaN], 68 | ['-13', -13], 69 | ['55.55', 55.55], 70 | ['100', 100], 71 | ['1300', 1300], 72 | ])('number.from(%s) = %s', (value, expected) => { 73 | expect(number.from(value)).toEqual(expected) 74 | }) 75 | 76 | test.each([ 77 | [0, '0'], 78 | [55.55, '55.55'], 79 | [100, '100'], 80 | ])('number.to(%s) = %s', (value, expected) => { 81 | expect(number.to(value)).toEqual(expected) 82 | }) 83 | }) 84 | 85 | describe('percentage', () => { 86 | test.each([ 87 | ['0%', 100, 0], 88 | ['0', 100, NaN], 89 | ['10.%', 100, NaN], 90 | ['a%', 100, NaN], 91 | ['-13%', 100, 0], 92 | ['55.55%', 100, 55.55], 93 | ['100%', 100, 100], 94 | ['1300%', 100, 100], 95 | ['100%', 255, 255], 96 | ['50%', 255, 127.5], 97 | ['0%', 255, 0], 98 | ['100%', 1, 1], 99 | ['50%', 1, 0.5], 100 | ['0%', 1, 0], 101 | ])('percentage.from(%s, %s) = %s', (value, referenceValue, expected) => { 102 | expect(percentage.from(value, { referenceValue })).toEqual(expected) 103 | }) 104 | 105 | test.each([ 106 | [0, '0%'], 107 | [55.55, '55.55%'], 108 | [100, '100%'], 109 | ])('percentage.to(%s) = %s', (value, expected) => { 110 | expect(percentage.to(value)).toEqual(expected) 111 | }) 112 | }) 113 | 114 | describe('rgbNumber', () => { 115 | test.each([ 116 | ['0', 0], 117 | ['0%', 0], 118 | ['10.', NaN], 119 | ['10.%', NaN], 120 | ['a', NaN], 121 | ['141.65', 141.65], 122 | ['255', 255], 123 | ['100%', 255], 124 | ['50%', 127.5], 125 | ])('rgbNumber.from(%s) = %s', (value, expected) => { 126 | expect(rgbNumber.from(value)).toEqual(expected) 127 | }) 128 | 129 | test.each([ 130 | [0, '0'], 131 | [141.6525, '141.65'], 132 | [255, '255'], 133 | ])('rgbNumber.to(%s) = %s', (value, expected) => { 134 | expect(rgbNumber.to(value)).toEqual(expected) 135 | }) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /src/utilities/CssValues.ts: -------------------------------------------------------------------------------- 1 | import { VisibleColorFormat } from '../types.js' 2 | import { clamp } from './clamp.js' 3 | import { round } from './round.js' 4 | 5 | interface CssValueNumberFromOptions { 6 | /** 7 | * Minimum to clamp a number to. 8 | * 9 | * **Default**: `Number.NEGATIVE_INFINITY` 10 | */ 11 | min?: number 12 | 13 | /** 14 | * Maximum to clamp a number to. 15 | * 16 | * **Default**: `Number.POSITIVE_INFINITY` 17 | */ 18 | max?: number 19 | } 20 | 21 | interface CssValuePercentageFromOptions extends CssValueNumberFromOptions { 22 | /** 23 | * Value to which the percentage is in reference with (e.g. for RGB number values, this would be `255` meaning that `100%` will correspond to 255). 24 | * 25 | * **Default**: `100` 26 | */ 27 | referenceValue?: number 28 | } 29 | 30 | interface CssValue< 31 | FromOptions = Record, 32 | ToOptions = Record, 33 | > { 34 | from: (value: string, options?: FromOptions) => number 35 | to: (value: number, options?: ToOptions) => string 36 | } 37 | 38 | export type CssValueNumber = CssValue 39 | export type CssValuePercentage = CssValue 40 | 41 | const angleFactor = { 42 | deg: 1, 43 | grad: 0.9, 44 | rad: 180/Math.PI, 45 | turn: 360, 46 | } 47 | 48 | /** 49 | * Reference: https://www.w3.org/TR/css-color-4/#typedef-alpha-value 50 | */ 51 | export const alpha: CssValueNumber = { 52 | from (value) { 53 | if (value.endsWith('%')) { 54 | return percentage.from(value, { referenceValue: 1 }) 55 | } 56 | 57 | return number.from(value, { min: 0, max: 1 }) 58 | }, 59 | 60 | to (value) { 61 | return number.to(value) 62 | }, 63 | } 64 | 65 | /** 66 | * Reference: https://www.w3.org/TR/css-values-4/#angle-value 67 | */ 68 | export const angle: CssValue = { 69 | from (value) { 70 | const match = value.match(/deg|g?rad|turn$/) 71 | if (match === null) { 72 | return number.from(value) 73 | } 74 | 75 | const unit = match[0] as 'deg' | 'grad' | 'rad' | 'turn' 76 | const numberValue = number.from(value.slice(0, -unit.length)) 77 | 78 | return numberValue*angleFactor[unit] 79 | }, 80 | 81 | to (value) { 82 | return number.to(value) 83 | }, 84 | } 85 | 86 | export const number: CssValueNumber = { 87 | from (value, { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY } = {}) { 88 | if (value.endsWith('.')) { 89 | // Returns `NaN` so we can avoid processing something as a color while the user is making an input. For example, typing "1" and then "." should only commit a color value at the input of "1" but not the input of ".". This allows us to avoid changing the corresponding input element's value while the user is typing. 90 | return NaN 91 | } 92 | 93 | return clamp(Number(value), min, max) 94 | }, 95 | 96 | to (value) { 97 | return round(value, 2) 98 | }, 99 | } 100 | 101 | /** 102 | * Reference: https://www.w3.org/TR/css-values-4/#percentage-value 103 | */ 104 | export const percentage: CssValuePercentage = { 105 | from (value, { referenceValue = 100, min = 0, max = 100 } = {}) { 106 | if (!value.endsWith('%')) { 107 | return NaN 108 | } 109 | 110 | const numberValue = number.from(value.slice(0, -1), { min, max }) 111 | 112 | return numberValue*referenceValue/100 113 | }, 114 | 115 | to (value) { 116 | return number.to(value) + '%' 117 | }, 118 | } 119 | 120 | /** 121 | * Reference: https://www.w3.org/TR/css-color-4/#funcdef-rgb 122 | */ 123 | export const rgbNumber: CssValue = { 124 | from (value) { 125 | if (value.endsWith('%')) { 126 | return percentage.from(value, { referenceValue: 255 }) 127 | } 128 | 129 | return number.from(value, { min: 0, max: 255 }) 130 | }, 131 | 132 | to (value) { 133 | return number.to(value) 134 | }, 135 | } 136 | 137 | 138 | const colorChannels: Record, Record> = { 139 | hsl: { 140 | h: angle, 141 | s: percentage, 142 | l: percentage, 143 | }, 144 | hwb: { 145 | h: angle, 146 | w: percentage, 147 | b: percentage, 148 | }, 149 | rgb: { 150 | r: rgbNumber, 151 | g: rgbNumber, 152 | b: rgbNumber, 153 | }, 154 | } 155 | 156 | export function getCssValue (format: Exclude, channel: string): CssValue { 157 | return colorChannels[format][channel] as CssValue 158 | } 159 | -------------------------------------------------------------------------------- /src/utilities/clamp.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { clamp } from './clamp.js' 4 | 5 | describe('clamp', () => { 6 | test.each([ 7 | [1, 1, 3, 1], 8 | [2, 1, 3, 2], 9 | [3, 1, 3, 3], 10 | ])('doesn’t change value', (value, min, max, expectedValue) => { 11 | expect(clamp(value, min, max)).toBe(expectedValue) 12 | }) 13 | 14 | test.each([ 15 | [0, 1, 3, 1], 16 | [-1, 1, 3, 1], 17 | [-100000, 1, 3, 1], 18 | ])('changes value to minimum', (value, min, max, expectedValue) => { 19 | expect(clamp(value, min, max)).toBe(expectedValue) 20 | }) 21 | 22 | test.each([ 23 | [3, 1, 3, 3], 24 | [4, 1, 3, 3], 25 | [100000, 1, 3, 3], 26 | ])('changes value to maximum', (value, min, max, expectedValue) => { 27 | expect(clamp(value, min, max)).toBe(expectedValue) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/utilities/clamp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Clamps the given value between the min and max boundaries. 3 | * 4 | * @returns - `value` if `min <= value <= max` 5 | * - `min` if `value < min` 6 | * - `max` if `value > max` 7 | */ 8 | export function clamp (value: number, min: number, max: number): number { 9 | return Math.max(min, Math.min(value, max)) 10 | } 11 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHexToRgb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHexToRgb } from './convertHexToRgb.js' 4 | 5 | describe('convertHexToRgb', () => { 6 | test.each([ 7 | ['#fff', { r: 255, g: 255, b: 255, a: 1 }], 8 | ['#ffff', { r: 255, g: 255, b: 255, a: 1 }], 9 | ['#ffffff', { r: 255, g: 255, b: 255, a: 1 }], 10 | ['#ffffffff', { r: 255, g: 255, b: 255, a: 1 }], 11 | ['#f00', { r: 255, g: 0, b: 0, a: 1 }], 12 | ['#f00f', { r: 255, g: 0, b: 0, a: 1 }], 13 | ['#ff0000', { r: 255, g: 0, b: 0, a: 1 }], 14 | ['#ff0000ff', { r: 255, g: 0, b: 0, a: 1 }], 15 | ['#00ff0054', { r: 0, g: 255, b: 0, a: 0.32941176470588235 }], 16 | ['#0000ffa8', { r: 0, g: 0, b: 255, a: 0.6588235294117647 }], 17 | ['#000000ff', { r: 0, g: 0, b: 0, a: 1 }], 18 | ['#cc0000cc', { r: 204, g: 0, b: 0, a: 0.8 }], 19 | ['#B000B135', { r: 176, g: 0, b: 177, a: 0.20784313725490197 }], 20 | ])('works', (hex, rgb) => { 21 | expect(convertHexToRgb(hex)).toEqual(rgb) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHexToRgb.ts: -------------------------------------------------------------------------------- 1 | import { ColorRgb } from '../../types.js' 2 | 3 | /** 4 | * Converts a HEX color string to an RGB color object. 5 | * 6 | * Supports HEX color strings with length 3, 4, 6, and 8. 7 | */ 8 | export function convertHexToRgb (hex: string): ColorRgb { 9 | const channels: number[] = [] 10 | 11 | // Slice hex color string into two characters each. 12 | // For longhand hex color strings, two characters can be consumed at a time. 13 | const step = hex.length > 5 ? 2 : 1 14 | for (let i = 1; i < hex.length; i += step) { 15 | // Repeat the character twice for shorthand and once for longhand hex color strings. 16 | const channel = hex.substring(i, i + step).repeat(step % 2 + 1) 17 | const value = parseInt(channel, 16) 18 | channels.push(i === 3 * step + 1 ? value / 255 : value) 19 | } 20 | 21 | if (channels.length === 3) { 22 | channels.push(1) 23 | } 24 | 25 | return { 26 | r: channels[0] as number, 27 | g: channels[1] as number, 28 | b: channels[2] as number, 29 | a: channels[3] as number, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHslToHsv.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHslToHsv } from './convertHslToHsv.js' 4 | 5 | describe('convertHslToHsv', () => { 6 | test.each([ 7 | [{ h: 0, s: 0, l: 100, a: 1 }, { h: 0, s: 0, v: 100, a: 1 }], 8 | [{ h: 0, s: 100, l: 50, a: 1 }, { h: 0, s: 100, v: 100, a: 1 }], 9 | [{ h: 120, s: 100, l: 50, a: 0.33 }, { h: 120, s: 100, v: 100, a: 0.33 }], 10 | [{ h: 240, s: 100, l: 50, a: 0.66 }, { h: 240, s: 100, v: 100, a: 0.66 }], 11 | [{ h: 324, s: 25, l: 75, a: 0.9 }, { h: 324, s: 15.384615384615374, v: 81.25, a: 0.9 }], 12 | [{ h: 0, s: 0, l: 0, a: 1 }, { h: 0, s: 0, v: 0, a: 1 }], 13 | [{ h: 0, s: 100, l: 40, a: 0.8 }, { h: 0, s: 100, v: 80, a: 0.8 }], 14 | ])('works', (hsl, hsv) => { 15 | expect(convertHslToHsv(hsl)).toEqual(hsv) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHslToHsv.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsl, ColorHsv } from '../../types.js' 2 | 3 | /** 4 | * Converts an HSL color object to an HSV color object. 5 | * 6 | * Source: https://github.com/LeaVerou/color.js/blob/2bd19b0a913da3f3310b9d8c1daa859ceb123c37/src/spaces/hsv.js#L30-L42 7 | */ 8 | export function convertHslToHsv (hsl: ColorHsl): ColorHsv { 9 | const l = hsl.l / 100 10 | const v = l + hsl.s/100*Math.min(l, 1 - l) 11 | const s = v === 0 ? 0 : 200*(1 - l/v) 12 | 13 | return { 14 | h: hsl.h, 15 | s, 16 | v: v*100, 17 | a: hsl.a, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHslToRgb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHslToRgb } from './convertHslToRgb.js' 4 | 5 | describe('convertHslToRgb', () => { 6 | test.each([ 7 | [{ h: 0, s: 0, l: 100, a: 1 }, { r: 255, g: 255, b: 255, a: 1 }], 8 | [{ h: 0, s: 100, l: 50, a: 1 }, { r: 255, g: 0, b: 0, a: 1 }], 9 | [{ h: 120, s: 100, l: 50, a: 0.33 }, { r: 0, g: 255, b: 0, a: 0.33 }], 10 | [{ h: 240, s: 100, l: 50, a: 0.66 }, { r: 0, g: 0, b: 255, a: 0.66 }], 11 | [{ h: 324, s: 25, l: 75, a: 0.9 }, { r: 207.1875, g: 175.3125, b: 194.4375, a: 0.9 }], 12 | [{ h: 0, s: 0, l: 0, a: 1 }, { r: 0, g: 0, b: 0, a: 1 }], 13 | [{ h: 0, s: 100, l: 40, a: 0.8 }, { r: 204, g: 0, b: 0, a: 0.8 }], 14 | [{ h: 270, s: 100, l: 50, a: 0.8 }, { r: 127.5, g: 0, b: 255, a: 0.8 }], 15 | [{ h: -90, s: 100, l: 50, a: 0.8 }, { r: 127.5, g: 0, b: 255, a: 0.8 }], 16 | ])('works', (hsl, rgb) => { 17 | expect(convertHslToRgb(hsl)).toEqual(rgb) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHslToRgb.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsl, ColorRgb } from '../../types.js' 2 | 3 | /** 4 | * Converts an HSL color object to an RGB color object. 5 | * 6 | * Source: https://github.com/LeaVerou/color.js/blob/2bd19b0a913da3f3310b9d8c1daa859ceb123c37/src/spaces/hsl.js#L49-L67 7 | */ 8 | export function convertHslToRgb (hsl: ColorHsl): ColorRgb { 9 | let h = hsl.h % 360 10 | if (h < 0) { 11 | h += 360 12 | } 13 | 14 | const s = hsl.s/100 15 | const l = hsl.l/100 16 | 17 | return { 18 | r: fn(0, h, s, l) * 255, 19 | g: fn(8, h, s, l) * 255, 20 | b: fn(4, h, s, l) * 255, 21 | a: hsl.a, 22 | } 23 | } 24 | 25 | function fn (n: number, h: number, s: number, l: number) { 26 | const k = (n + h/30) % 12 27 | const a = s*Math.min(l, 1 - l) 28 | 29 | return l - a*Math.max(-1, Math.min(k - 3, 9 - k, 1)) 30 | } 31 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHsvToHsl.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHsvToHsl } from './convertHsvToHsl.js' 4 | 5 | describe('convertHsvToHsl', () => { 6 | test.each([ 7 | [{ h: 0, s: 0, v: 100, a: 1 }, { h: 0, s: 0, l: 100, a: 1 }], 8 | [{ h: 0, s: 100, v: 100, a: 1 }, { h: 0, s: 100, l: 50, a: 1 }], 9 | [{ h: 120, s: 100, v: 100, a: 0.33 }, { h: 120, s: 100, l: 50, a: 0.33 }], 10 | [{ h: 240, s: 100, v: 100, a: 0.66 }, { h: 240, s: 100, l: 50, a: 0.66 }], 11 | [{ h: 324, s: 15.384615384615374, v: 81.25, a: 0.9 }, { h: 324, s: 25, l: 75, a: 0.9 }], 12 | [{ h: 0, s: 0, v: 0, a: 1 }, { h: 0, s: 0, l: 0, a: 1 }], 13 | [{ h: 0, s: 100, v: 80, a: 0.8 }, { h: 0, s: 100, l: 40, a: 0.8 }], 14 | ])('works', (hsv, hsl) => { 15 | expect(convertHsvToHsl(hsv)).toEqual(hsl) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHsvToHsl.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsl, ColorHsv } from '../../types.js' 2 | 3 | /** 4 | * Converts an HSV color object to an HSL color object. 5 | * 6 | * Source: https://en.m.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL 7 | */ 8 | export function convertHsvToHsl (hsv: ColorHsv): ColorHsl { 9 | const s = hsv.s/100 10 | const v = hsv.v/100 11 | const l = v*(1 - s/2) 12 | 13 | return { 14 | h: hsv.h, 15 | s: (l === 0 || l === 1) ? 0 : ((v - l)/Math.min(l, 1 - l)) * 100, 16 | l: l * 100, 17 | a: hsv.a, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHsvToHwb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHsvToHwb } from './convertHsvToHwb.js' 4 | 5 | describe('convertHsvToHwb', () => { 6 | test.each([ 7 | [{ h: 0, s: 0, v: 100, a: 1 }, { h: 0, w: 100, b: 0, a: 1 }], 8 | [{ h: 0, s: 100, v: 100, a: 1 }, { h: 0, w: 0, b: 0, a: 1 }], 9 | [{ h: 120, s: 100, v: 100, a: 0.33 }, { h: 120, w: 0, b: 0, a: 0.33 }], 10 | [{ h: 240, s: 100, v: 100, a: 0.66 }, { h: 240, w: 0, b: 0, a: 0.66 }], 11 | [{ h: 324, s: 15, v: 81.25, a: 0.9 }, { h: 324, w: 69.0625, b: 18.75, a: 0.9 }], 12 | [{ h: 0, s: 0, v: 0, a: 1 }, { h: 0, w: 0, b: 100, a: 1 }], 13 | [{ h: 0, s: 100, v: 80, a: 0.8 }, { h: 0, w: 0, b: 20, a: 0.8 }], 14 | ])('works', (hsv, hwb) => { 15 | expect(convertHsvToHwb(hsv)).toEqual(hwb) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHsvToHwb.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsv, ColorHwb } from '../../types.js' 2 | 3 | /** 4 | * Converts an HSV color object to an HWB color object. 5 | */ 6 | export function convertHsvToHwb (hsv: ColorHsv): ColorHwb { 7 | return { 8 | h: hsv.h, 9 | w: hsv.v*(100 - hsv.s)/100, 10 | b: 100 - hsv.v, 11 | a: hsv.a, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHsvToRgb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHsvToRgb } from './convertHsvToRgb.js' 4 | 5 | describe('convertHsvToRgb', () => { 6 | test.each([ 7 | [{ h: 0, s: 0, v: 100, a: 1 }, { r: 255, g: 255, b: 255, a: 1 }], 8 | [{ h: 0, s: 100, v: 100, a: 1 }, { r: 255, g: 0, b: 0, a: 1 }], 9 | [{ h: 120, s: 100, v: 100, a: 0.33 }, { r: 0, g: 255, b: 0, a: 0.33 }], 10 | [{ h: 240, s: 100, v: 100, a: 0.66 }, { r: 0, g: 0, b: 255, a: 0.66 }], 11 | [{ h: 324, s: 15.384615384615374, v: 81.25, a: 0.9 }, { r: 207.1875, g: 175.3125, b: 194.4375, a: 0.9 }], 12 | [{ h: 0, s: 0, v: 0, a: 1 }, { r: 0, g: 0, b: 0, a: 1 }], 13 | [{ h: 0, s: 100, v: 80, a: 0.8 }, { r: 204, g: 0, b: 0, a: 0.8 }], 14 | ])('works', (hsv, rgb) => { 15 | expect(convertHsvToRgb(hsv)).toEqual(rgb) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHsvToRgb.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsv, ColorRgb } from '../../types.js' 2 | import { convertHslToRgb } from './convertHslToRgb.js' 3 | import { convertHsvToHsl } from './convertHsvToHsl.js' 4 | 5 | /** 6 | * Converts an HSV color object to an RGB color object. 7 | * 8 | * Source: https://en.m.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB 9 | */ 10 | export function convertHsvToRgb (hsv: ColorHsv): ColorRgb { 11 | return convertHslToRgb(convertHsvToHsl(hsv)) 12 | } 13 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHwbToHsv.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertHwbToHsv } from './convertHwbToHsv.js' 4 | 5 | describe('convertHwbToHsv', () => { 6 | test.each([ 7 | [{ h: 0, w: 100, b: 0, a: 1 }, { h: 0, s: 0, v: 100, a: 1 }], 8 | [{ h: 0, w: 0, b: 0, a: 1 }, { h: 0, s: 100, v: 100, a: 1 }], 9 | [{ h: 120, w: 0, b: 0, a: 0.33 }, { h: 120, s: 100, v: 100, a: 0.33 }], 10 | [{ h: 240, w: 0, b: 0, a: 0.66 }, { h: 240, s: 100, v: 100, a: 0.66 }], 11 | [{ h: 324, w: 69.0625, b: 18.75, a: 0.9 }, { h: 324, s: 14.999999999999991, v: 81.25, a: 0.9 }], 12 | [{ h: 0, w: 0, b: 100, a: 1 }, { h: 0, s: 0, v: 0, a: 1 }], 13 | [{ h: 0, w: 0, b: 20, a: 0.8 }, { h: 0, s: 100, v: 80, a: 0.8 }], 14 | ])('works', (hwb, hsv) => { 15 | expect(convertHwbToHsv(hwb)).toEqual(hsv) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertHwbToHsv.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsv, ColorHwb } from '../../types.js' 2 | 3 | /** 4 | * Converts an HWB color object to an HSV color object. 5 | * 6 | * Source: https://github.com/LeaVerou/color.js/blob/2bd19b0a913da3f3310b9d8c1daa859ceb123c37/src/spaces/hwb.js#L34-L51 7 | */ 8 | export function convertHwbToHsv (hwb: ColorHwb): ColorHsv { 9 | const w = hwb.w/100 10 | const b = hwb.b/100 11 | 12 | let s 13 | let v 14 | const sum = w + b 15 | if (sum >= 1) { 16 | s = 0 17 | v = w/sum 18 | } else { 19 | v = 1 - b 20 | s = (1 - w/v)*100 21 | } 22 | 23 | return { 24 | h: hwb.h, 25 | s, 26 | v: v*100, 27 | a: hwb.a, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertRgbToHex.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertRgbToHex } from './convertRgbToHex.js' 4 | 5 | describe('convertRgbToHex', () => { 6 | test.each([ 7 | [{ r: 255, g: 255, b: 255, a: 1 }, '#ffffffff'], 8 | [{ r: 255, g: 0, b: 0, a: 1 }, '#ff0000ff'], 9 | [{ r: 0, g: 255, b: 0, a: 0.33 }, '#00ff0054'], 10 | [{ r: 0, g: 0, b: 255, a: 0.66 }, '#0000ffa8'], 11 | [{ r: 0, g: 0, b: 0, a: 1 }, '#000000ff'], 12 | [{ r: 204, g: 0, b: 0, a: 0.8 }, '#cc0000cc'], 13 | [{ r: 176, g: 0, b: 177, a: 0.20784313725490197 }, '#b000b135'], 14 | ])('works', (rgb, hex) => { 15 | expect(convertRgbToHex(rgb)).toEqual(hex) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertRgbToHex.ts: -------------------------------------------------------------------------------- 1 | import { ColorRgb } from '../../types.js' 2 | 3 | /** 4 | * Converts an RGB color object to an HEX color string. 5 | */ 6 | export function convertRgbToHex (rgb: ColorRgb): string { 7 | return '#' + Object.values(rgb) 8 | .map((channel, i) => Math.round(i === 3 ? channel * 255 : channel).toString(16).padStart(2, '0')) 9 | .join('') 10 | } 11 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertRgbToHsl.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertRgbToHsl } from './convertRgbToHsl.js' 4 | 5 | describe('convertRgbToHsl', () => { 6 | test.each([ 7 | [{ r: 255, g: 255, b: 255, a: 1 }, { h: 0, s: 0, l: 100, a: 1 }], 8 | [{ r: 255, g: 0, b: 0, a: 1 }, { h: 0, s: 100, l: 50, a: 1 }], 9 | [{ r: 0, g: 255, b: 0, a: 0.33 }, { h: 120, s: 100, l: 50, a: 0.33 }], 10 | [{ r: 0, g: 0, b: 255, a: 0.66 }, { h: 240, s: 100, l: 50, a: 0.66 }], 11 | [{ r: 207.1875, g: 175.3125, b: 194.4375, a: 0.9 }, { h: 324, s: 25, l: 75, a: 0.9 }], 12 | [{ r: 0, g: 0, b: 0, a: 1 }, { h: 0, s: 0, l: 0, a: 1 }], 13 | [{ r: 204, g: 0, b: 0, a: 0.8 }, { h: 0, s: 100, l: 40, a: 0.8 }], 14 | [{ r: 127.5, g: 0, b: 255, a: 0.8 }, { h: 270, s: 100, l: 50, a: 0.8 }], 15 | ])('works', (rgb, hsl) => { 16 | expect(convertRgbToHsl(rgb)).toEqual(hsl) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertRgbToHsl.ts: -------------------------------------------------------------------------------- 1 | import { ColorHsl, ColorRgb } from '../../types.js' 2 | 3 | /** 4 | * Converts an RGB color object to an HSL color object. 5 | * 6 | * Source: https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB 7 | */ 8 | export function convertRgbToHsl (rgb: ColorRgb): ColorHsl { 9 | const { r, g, b, a } = rgb 10 | const min = Math.min(r, g, b) 11 | const max = Math.max(r, g, b) 12 | const chroma = max - min 13 | const l = (max + min)/2 14 | 15 | let h = 0 16 | if (chroma !== 0) { 17 | if (max === r) { 18 | h = (g - b)/chroma + (g < b ? 6 : 0) 19 | } else if (max === g) { 20 | h = (b - r)/chroma + 2 21 | } else if (max === b) { 22 | h = (r - g)/chroma + 4 23 | } 24 | 25 | h *= 60 26 | } 27 | 28 | let s = 0 29 | if (l !== 0 && l !== 255) { 30 | s = (max - l)/Math.min(l, 255 - l) 31 | } 32 | 33 | return { 34 | h, 35 | s: s*100, 36 | l: l/255*100, 37 | a, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertRgbToHwb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { convertRgbToHwb } from './convertRgbToHwb.js' 4 | 5 | describe('convertRgbToHwb', () => { 6 | test.each([ 7 | [{ r: 255, g: 255, b: 255, a: 1 }, { h: 0, w: 100, b: 0, a: 1 }], 8 | [{ r: 255, g: 0, b: 0, a: 1 }, { h: 0, w: 0, b: 0, a: 1 }], 9 | [{ r: 0, g: 255, b: 0, a: 0.33 }, { h: 120, w: 0, b: 0, a: 0.33 }], 10 | [{ r: 0, g: 0, b: 255, a: 0.66 }, { h: 240, w: 0, b: 0, a: 0.66 }], 11 | [{ r: 207.1875, g: 175.3125, b: 194.4375, a: 0.9 }, { h: 324, w: 68.75000000000001, b: 18.75, a: 0.9 }], 12 | [{ r: 0, g: 0, b: 0, a: 1 }, { h: 0, w: 0, b: 100, a: 1 }], 13 | [{ r: 204, g: 0, b: 0, a: 0.8 }, { h: 0, w: 0, b: 20, a: 0.8 }], 14 | [{ r: 85, g: 127.5, b: 127.5, a: 1 }, { h: 180, w: 33.33333333333334, b: 49.999999999999986, a: 1 }], 15 | [{ r: 85, g: 128, b: 128, a: 1 }, { h: 180, w: 33.333333333333336, b: 49.80392156862745, a: 1 }], 16 | ])('works', (rgb, hwb) => { 17 | expect(convertRgbToHwb(rgb)).toEqual(hwb) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/utilities/colorConversions/convertRgbToHwb.ts: -------------------------------------------------------------------------------- 1 | import { ColorHwb, ColorRgb } from '../../types.js' 2 | import { convertHslToHsv } from './convertHslToHsv.js' 3 | import { convertHsvToHwb } from './convertHsvToHwb.js' 4 | import { convertRgbToHsl } from './convertRgbToHsl.js' 5 | 6 | /** 7 | * Converts an RGB color object to an HWB color object. 8 | */ 9 | export function convertRgbToHwb (rgb: ColorRgb): ColorHwb { 10 | return convertHsvToHwb(convertHslToHsv(convertRgbToHsl(rgb))) 11 | } 12 | -------------------------------------------------------------------------------- /src/utilities/colorsAreValueEqual.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { colorsAreValueEqual } from './colorsAreValueEqual.js' 4 | 5 | const testColorObject = { r: 1, g: 1, b: 1, a: 1 } 6 | 7 | describe('colors-are-equal', () => { 8 | test.each([ 9 | ['#ffffffcc', '#ff0000cc', false], 10 | ['#ffffffcc', '#ffffffcc', true], 11 | [testColorObject, testColorObject, true], 12 | [{ r: 1, g: 1, b: 1, a: 1 }, { r: 1, g: 1, b: 1, a: 1 }, true], 13 | [{ r: 1, g: 1, b: 1, a: 1 }, { r: 1, g: 1, b: 1, a: 0 }, false], 14 | [{ r: 1, g: 1, b: 1, a: 1 }, { h: 1, s: 1, l: 1, a: 0 }, false], 15 | ])('works', (colorA, colorB, expectedResult) => { 16 | expect(colorsAreValueEqual(colorA, colorB)).toBe(expectedResult) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/utilities/colorsAreValueEqual.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks whether two objects are value equal. 3 | */ 4 | export function colorsAreValueEqual> (colorA: T, colorB: T): boolean { 5 | if (typeof colorA === 'string' || typeof colorB === 'string') { 6 | return colorA === colorB 7 | } 8 | 9 | for (const channelA in colorA) { 10 | if (colorA[channelA] !== colorB[channelA]) { 11 | return false 12 | } 13 | } 14 | 15 | return true 16 | } 17 | -------------------------------------------------------------------------------- /src/utilities/convert.ts: -------------------------------------------------------------------------------- 1 | import { convertHexToRgb } from './colorConversions/convertHexToRgb.js' 2 | import { convertHslToHsv } from './colorConversions/convertHslToHsv.js' 3 | import { convertHslToRgb } from './colorConversions/convertHslToRgb.js' 4 | import { convertHsvToHsl } from './colorConversions/convertHsvToHsl.js' 5 | import { convertHsvToHwb } from './colorConversions/convertHsvToHwb.js' 6 | import { convertHsvToRgb } from './colorConversions/convertHsvToRgb.js' 7 | import { convertHwbToHsv } from './colorConversions/convertHwbToHsv.js' 8 | import { convertRgbToHsl } from './colorConversions/convertRgbToHsl.js' 9 | import { convertRgbToHex } from './colorConversions/convertRgbToHex.js' 10 | import { convertRgbToHwb } from './colorConversions/convertRgbToHwb.js' 11 | import { ColorFormat, ColorMap } from '../types.js' 12 | 13 | type Conversions = { 14 | [SourceFormat in ColorFormat]: { 15 | [TargetFormat in ColorFormat]: (color: ColorMap[SourceFormat]) => ColorMap[TargetFormat] 16 | } 17 | } 18 | 19 | const conversions: Conversions = { 20 | hex: { 21 | hex: (hex) => hex, 22 | hsl: (hex) => convertRgbToHsl(convertHexToRgb(hex)), 23 | hsv: (hex) => convertHwbToHsv(convertRgbToHwb(convertHexToRgb(hex))), 24 | hwb: (hex) => convertRgbToHwb(convertHexToRgb(hex)), 25 | rgb: convertHexToRgb, 26 | }, 27 | hsl: { 28 | hex: (hsl) => convertRgbToHex(convertHslToRgb(hsl)), 29 | hsl: (hsl) => hsl, 30 | hsv: convertHslToHsv, 31 | hwb: (hsl) => convertRgbToHwb(convertHslToRgb(hsl)), 32 | rgb: convertHslToRgb, 33 | }, 34 | hsv: { 35 | hex: (hsv) => convertRgbToHex(convertHsvToRgb(hsv)), 36 | hsl: convertHsvToHsl, 37 | hsv: (hsv) => hsv, 38 | hwb: convertHsvToHwb, 39 | rgb: convertHsvToRgb, 40 | }, 41 | hwb: { 42 | hex: (hwb) => convertRgbToHex(convertHsvToRgb(convertHwbToHsv(hwb))), 43 | hsl: (hwb) => convertRgbToHsl(convertHsvToRgb(convertHwbToHsv(hwb))), 44 | hsv: convertHwbToHsv, 45 | hwb: (hwb) => hwb, 46 | rgb: (hwb) => convertHsvToRgb(convertHwbToHsv(hwb)), 47 | }, 48 | rgb: { 49 | hex: convertRgbToHex, 50 | hsl: convertRgbToHsl, 51 | hsv: (rgb) => convertHwbToHsv(convertRgbToHwb(rgb)), 52 | hwb: convertRgbToHwb, 53 | rgb: (rgb) => rgb, 54 | }, 55 | } 56 | 57 | export function convert (sourceFormat: SourceFormat, targetFormat: TargetFormat, color: ColorMap[SourceFormat]): ColorMap[TargetFormat] { 58 | // I tried my best typing this correctly, but that seems almost impossible. Leaving things with this type assertion. 59 | return conversions[sourceFormat][targetFormat](color) as ColorMap[TargetFormat] 60 | } 61 | -------------------------------------------------------------------------------- /src/utilities/detectFormat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { detectFormat } from './detectFormat.js' 4 | 5 | describe('detectFormat', () => { 6 | test.each([ 7 | [{ h: 0, s: 0, l: 0, a: 0 }, 'hsl'], 8 | [{ h: 0, s: 0, v: 0, a: 0 }, 'hsv'], 9 | [{ h: 0, w: 0, b: 0, a: 0 }, 'hwb'], 10 | [{ l: 0, aAxis: 0, bAxis: 0, a: 0 }, null], 11 | [{ l: 0, c: 0, h: 0, a: 0 }, null], 12 | [{ r: 0, g: 0, b: 0, a: 0 }, 'rgb'], 13 | ])('works', (color, expectedResult) => { 14 | expect(detectFormat(color)).toBe(expectedResult) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/utilities/detectFormat.ts: -------------------------------------------------------------------------------- 1 | import { ColorFormat } from '../types.js' 2 | 3 | /** 4 | * Lazy function that returns the format of a given color object. 5 | * 6 | * Doesn’t handle invalid formats. 7 | */ 8 | export function detectFormat (color: Record): Exclude | null { 9 | if ('r' in color) { 10 | return 'rgb' 11 | } 12 | 13 | if ('w' in color) { 14 | return 'hwb' 15 | } 16 | 17 | if ('v' in color) { 18 | return 'hsv' 19 | } 20 | 21 | if ('s' in color) { 22 | return 'hsl' 23 | } 24 | 25 | return null 26 | } 27 | -------------------------------------------------------------------------------- /src/utilities/formatAsCssColor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { formatAsCssColor } from './formatAsCssColor.js' 4 | 5 | describe('formatAsCssColor', () => { 6 | test.each<[...Parameters, string]>([ 7 | [{ color: '#fff', format: 'hex' }, false, '#fff'], 8 | [{ color: '#FFF', format: 'hex' }, false, '#FFF'], 9 | [{ color: '#000', format: 'hex' }, false, '#000'], 10 | [{ color: '#000000', format: 'hex' }, false, '#000000'], 11 | [{ color: '#000000aa', format: 'hex' }, false, '#000000aa'], 12 | [{ color: '#000000aa', format: 'hex' }, true, '#000000'], 13 | [{ color: '#112233', format: 'hex' }, true, '#112233'], 14 | [{ color: '#123a', format: 'hex' }, true, '#123'], 15 | [{ color: '#123', format: 'hex' }, true, '#123'], 16 | ])('works for HEX colors', (pair, excludeAlphaChannel, cssColorString) => { 17 | expect(formatAsCssColor(pair, excludeAlphaChannel)).toEqual(cssColorString) 18 | }) 19 | 20 | test.each<[...Parameters, string]>([ 21 | [{ color: { h: 360, s: 100, l: 50, a: 1 }, format: 'hsl' }, false, 'hsl(360 100% 50% / 1)'], 22 | [{ color: { h: 270, s: 100, l: 50, a: 1 }, format: 'hsl' }, false, 'hsl(270 100% 50% / 1)'], 23 | [{ color: { h: 360, s: 100, l: 50, a: 1 }, format: 'hsl' }, true, 'hsl(360 100% 50%)'], 24 | [{ color: { h: 270, s: 100, l: 50, a: 1 }, format: 'hsl' }, true, 'hsl(270 100% 50%)'], 25 | ])('works for HSL colors', (pair, excludeAlphaChannel, cssColorString) => { 26 | expect(formatAsCssColor(pair, excludeAlphaChannel)).toEqual(cssColorString) 27 | }) 28 | 29 | test.each<[...Parameters, string]>([ 30 | [{ color: { h: 360, w: 100, b: 100, a: 1 }, format: 'hwb' }, false, 'hwb(360 100% 100% / 1)'], 31 | [{ color: { h: 270, w: 100, b: 100, a: 1 }, format: 'hwb' }, false, 'hwb(270 100% 100% / 1)'], 32 | [{ color: { h: 360, w: 100, b: 100, a: 1 }, format: 'hwb' }, true, 'hwb(360 100% 100%)'], 33 | [{ color: { h: 270, w: 100, b: 100, a: 1 }, format: 'hwb' }, true, 'hwb(270 100% 100%)'], 34 | ])('works for HWB colors', (pair, excludeAlphaChannel, cssColorString) => { 35 | expect(formatAsCssColor(pair, excludeAlphaChannel)).toEqual(cssColorString) 36 | }) 37 | 38 | test.each<[...Parameters, string]>([ 39 | [{ color: { r: 255, g: 255, b: 255, a: 1 }, format: 'rgb' }, false, 'rgb(255 255 255 / 1)'], 40 | [{ color: { r: 255, g: 0, b: 0, a: 1 }, format: 'rgb' }, false, 'rgb(255 0 0 / 1)'], 41 | [{ color: { r: 255, g: 255, b: 255, a: 1 }, format: 'rgb' }, true, 'rgb(255 255 255)'], 42 | [{ color: { r: 255, g: 0, b: 0, a: 1 }, format: 'rgb' }, true, 'rgb(255 0 0)'], 43 | [{ color: { r: 255, g: 0, b: 0, a: 1 }, format: 'rgb' }, false, 'rgb(255 0 0 / 1)'], 44 | [{ color: { r: 255, g: 0, b: 0, a: 1 }, format: 'rgb' }, false, 'rgb(255 0 0 / 1)'], 45 | [{ color: { r: 255, g: 0, b: 0, a: 1 }, format: 'rgb' }, false, 'rgb(255 0 0 / 1)'], 46 | [{ color: { r: 255, g: 0, b: 0, a: 0.333 }, format: 'rgb' }, false, 'rgb(255 0 0 / 0.33)'], 47 | [{ color: { r: 127.5, g: 127.5, b: 63.75, a: 1 }, format: 'rgb' }, false, 'rgb(127.5 127.5 63.75 / 1)'], 48 | [{ color: { r: 127.5, g: 191.25, b: 31.88, a: 1 }, format: 'rgb' }, false, 'rgb(127.5 191.25 31.88 / 1)'], 49 | ])('works for RGB colors', (pair, excludeAlphaChannel, cssColorString) => { 50 | expect(formatAsCssColor(pair, excludeAlphaChannel)).toEqual(cssColorString) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/utilities/formatAsCssColor.ts: -------------------------------------------------------------------------------- 1 | import { VisibleColorPair } from '../types.js' 2 | import { alpha, getCssValue } from './CssValues.js' 3 | 4 | /** 5 | * Formats a given color object as a CSS color string. 6 | */ 7 | export function formatAsCssColor ({ format, color }: VisibleColorPair, excludeAlphaChannel: boolean): string { 8 | if (format === 'hex') { 9 | return excludeAlphaChannel && [5, 9].includes(color.length) 10 | ? color.substring(0, color.length - (color.length - 1)/4) 11 | : color 12 | } 13 | 14 | const parameters = Object.entries(color) 15 | .slice(0, excludeAlphaChannel ? 3 : 4) 16 | .map(([channel, value]) => { 17 | const cssValue = channel === 'a' ? alpha : getCssValue(format, channel) 18 | return (channel === 'a' ? '/ ' : '') + cssValue.to(value) 19 | }) 20 | 21 | return `${format}(${parameters.join(' ')})` 22 | } 23 | -------------------------------------------------------------------------------- /src/utilities/getNewThumbPosition.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, vi } from 'vitest' 2 | 3 | import { getNewThumbPosition } from './getNewThumbPosition.js' 4 | 5 | const el = document.createElement('div') 6 | el.getBoundingClientRect = vi.fn(() => ({ 7 | width: 300, 8 | height: 180, 9 | x: 100, 10 | y: 10, 11 | top: 10, 12 | left: 100, 13 | bottom: 0, 14 | right: 0, 15 | toJSON: vi.fn(), 16 | })) 17 | 18 | describe('getNewThumbPosition', () => { 19 | test.each([ 20 | [ 21 | { clientX: 0, clientY: 0 }, 22 | { x: 0, y: 100 }, 23 | ], 24 | [ 25 | { clientX: 1137, clientY: 17 }, 26 | { x: 100, y: 96.11111111111111 }, 27 | ], 28 | [ 29 | { clientX: 100, clientY: 20 }, 30 | { x: 0, y: 94.44444444444444 }, 31 | ], 32 | ])('works', ({ clientX, clientY }, expectedThumbPosition) => { 33 | expect(getNewThumbPosition(el, clientX, clientY)).toEqual(expectedThumbPosition) 34 | }) 35 | 36 | test('handles division by zero edge case', () => { 37 | const el = document.createElement('div') 38 | el.getBoundingClientRect = vi.fn(() => ({ 39 | width: 0, 40 | height: 0, 41 | x: 1, 42 | y: 1, 43 | top: 1, 44 | left: 1, 45 | bottom: 1, 46 | right: 1, 47 | toJSON: vi.fn(), 48 | })) 49 | expect(getNewThumbPosition(el, 1, 1)).toEqual({ x: 0, y: 0 }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/utilities/getNewThumbPosition.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from './clamp.js' 2 | 3 | export function getNewThumbPosition (colorSpace: HTMLElement, clientX: number, clientY: number): { x: number, y: number } { 4 | const rect = colorSpace.getBoundingClientRect() 5 | const x = clientX - rect.left 6 | const y = clientY - rect.top 7 | 8 | return { 9 | x: rect.width === 0 ? 0 : clamp((x / rect.width)*100, 0, 100), 10 | y: rect.height === 0 ? 0 : clamp((1 - y / rect.height)*100, 0, 100), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utilities/isValidHexColor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { isValidHexColor } from './isValidHexColor.js' 4 | 5 | describe('isValidHexColor', () => { 6 | test.each([ 7 | ['#f', false], 8 | ['#ff', false], 9 | ['#fff', true], 10 | ['#ffff', true], 11 | ['#fffff', false], 12 | ['#ffffff', true], 13 | ['#fffffff', false], 14 | ['#ffffffff', true], 15 | ['#', false], 16 | ['', false], 17 | ['#aaa', true], 18 | ['#ggg', false], 19 | ['#01234567', true], 20 | ['#012345678', false], 21 | ])('of “%s” returns %s', (hex, expectedResult) => { 22 | expect(isValidHexColor(hex)).toBe(expectedResult) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/utilities/isValidHexColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns whether a given HEX color string is a valid CSS color. 3 | */ 4 | export function isValidHexColor (hexColor: string): boolean { 5 | return /^#(?:(?:[A-F0-9]{2}){3,4}|[A-F0-9]{3,4})$/i.test(hexColor) 6 | } 7 | -------------------------------------------------------------------------------- /src/utilities/parsePropsColor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi } from 'vitest' 2 | 3 | import { parsePropsColor } from './parsePropsColor.js' 4 | 5 | describe('parsePropsColor', () => { 6 | test.each([ 7 | ['rgb(255, 0, 0)', { format: 'rgb', color: { r: 255, g: 0, b: 0, a: 1 } }], 8 | ['rgb(255,0,0)', { format: 'rgb', color: { r: 255, g: 0, b: 0, a: 1 } }], 9 | ['rgba(255, 0, 0, 1)', { format: 'rgb', color: { r: 255, g: 0, b: 0, a: 1 } }], 10 | ['rgba(255,0,0,1)', { format: 'rgb', color: { r: 255, g: 0, b: 0, a: 1 } }], 11 | ['rgba( 255, 0, 0, 1 )', { format: 'rgb', color: { r: 255, g: 0, b: 0, a: 1 } }], 12 | ['rgb(127.5, 0, 255)', { format: 'rgb', color: { r: 127.5, g: 0, b: 255, a: 1 } }], 13 | ['rgb(127.5 0 255)', { format: 'rgb', color: { r: 127.5, g: 0, b: 255, a: 1 } }], 14 | ['rgb(127.5 0 255 / 0.5)', { format: 'rgb', color: { r: 127.5, g: 0, b: 255, a: 0.5 } }], 15 | ['rgb(50% 0% 100% / 0.5)', { format: 'rgb', color: { r: 127.5, g: 0, b: 255, a: 0.5 } }], 16 | ['hsl(255, 0%, 0%)', { format: 'hsl', color: { h: 255, s: 0, l: 0, a: 1 } }], 17 | ['hsla(255, 0%, 0%, 1)', { format: 'hsl', color: { h: 255, s: 0, l: 0, a: 1 } }], 18 | ['hsl(127.5, 0%, 100%)', { format: 'hsl', color: { h: 127.5, s: 0, l: 100, a: 1 } }], 19 | ['hsl(127.5 0% 100%)', { format: 'hsl', color: { h: 127.5, s: 0, l: 100, a: 1 } }], 20 | ['hsl(127.5 0% 100% / 0.5)', { format: 'hsl', color: { h: 127.5, s: 0, l: 100, a: 0.5 } }], 21 | ['hsl(360 0% 100% / 0.5)', { format: 'hsl', color: { h: 360, s: 0, l: 100, a: 0.5 } }], 22 | ['hsl(90deg 0% 100% / 0.5)', { format: 'hsl', color: { h: 90, s: 0, l: 100, a: 0.5 } }], 23 | ['hsl(100grad 0% 100% / 0.5)', { format: 'hsl', color: { h: 90, s: 0, l: 100, a: 0.5 } }], 24 | ['hsl(1.5707963267948966rad 0% 100% / 0.5)', { format: 'hsl', color: { h: 90, s: 0, l: 100, a: 0.5 } }], 25 | ['hsl(0.25turn 0% 100% / 0.5)', { format: 'hsl', color: { h: 90, s: 0, l: 100, a: 0.5 } }], 26 | ['lab(100% 62.5 0 / 0.5)', null], 27 | ['lch(50% 75 180 / 0.5)', null], 28 | [{ r: 127.5, g: 0, b: 255, a: 1 }, { format: 'rgb', color: { r: 127.5, g: 0, b: 255, a: 1 } }], 29 | [{ h: 127.5, s: 0, l: 255, a: 0.5 }, { format: 'hsl', color: { h: 127.5, s: 0, l: 255, a: 0.5 } }], 30 | [{ l: 100, aAxis: 62.5, bAxis: 0, a: 0.5 }, null], 31 | [{ l: 50, c: 75, h: 180, a: 0.5 }, null], 32 | ['#', null], 33 | ['#1', null], 34 | ['#12', null], 35 | ['#123', { format: 'hex', color: '#123' }], 36 | ['#1234', { format: 'hex', color: '#1234' }], 37 | ['#12345', null], 38 | ['#123456', { format: 'hex', color: '#123456' }], 39 | ['#1234567', null], 40 | ['#12345678', { format: 'hex', color: '#12345678' }], 41 | ['#123456789', null], 42 | ['bla(1 1 1 / 1)', null], 43 | ])('parses “%s” correctly', (cssColor, rgbColorString) => { 44 | // @ts-expect-error because we're deliberately testing invalid inputs 45 | expect(parsePropsColor(cssColor)).toEqual(rgbColorString) 46 | }) 47 | 48 | test('handles valid named color correctly', () => { 49 | class FillStyle { 50 | get fillStyle () { 51 | return '#663399' 52 | } 53 | 54 | // eslint-disable-next-line @typescript-eslint/no-empty-function 55 | set fillStyle (_fillStyle) {} 56 | } 57 | // @ts-ignore 58 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => new FillStyle()) 59 | 60 | expect(parsePropsColor('rebeccapurple')).toEqual({ format: 'hex', color: '#663399' }) 61 | }) 62 | 63 | test('handles invalid named color correctly', () => { 64 | class FillStyle { 65 | get fillStyle () { 66 | return '#000000' 67 | } 68 | 69 | // eslint-disable-next-line @typescript-eslint/no-empty-function 70 | set fillStyle (_fillStyle) {} 71 | } 72 | // @ts-ignore 73 | vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => new FillStyle()) 74 | 75 | expect(parsePropsColor('invalid')).toEqual(null) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/utilities/parsePropsColor.ts: -------------------------------------------------------------------------------- 1 | import { detectFormat } from './detectFormat.js' 2 | import { isValidHexColor } from './isValidHexColor.js' 3 | import { 4 | ColorHsl, 5 | ColorHsv, 6 | ColorHwb, 7 | ColorPair, 8 | ColorRgb, 9 | VisibleColorFormat, 10 | VisibleColorPair, 11 | } from '../types.js' 12 | import { alpha, getCssValue } from './CssValues.js' 13 | 14 | const CHANNELS_BY_FORMAT = { 15 | hsl: ['h', 's', 'l', 'a'], 16 | hwb: ['h', 'w', 'b', 'a'], 17 | rgb: ['r', 'g', 'b', 'a'], 18 | } satisfies Record, string[]> 19 | 20 | /** 21 | * Parses a color as it can be provided to the color picker’s `color` prop. 22 | * 23 | * Supports all valid CSS colors in string form (e.g. tomato, #f80c, hsl(266.66 50% 100% / 0.8), hwb(0.9 0.9 0.9 / 1), etc.) as well as the color formats used for internal storage by the color picker. 24 | */ 25 | export function parsePropsColor (propsColor: string | ColorHsl | ColorHsv | ColorHwb | ColorRgb): ColorPair | null { 26 | // 1. Objects 27 | if (typeof propsColor !== 'string') { 28 | const format = detectFormat(propsColor) 29 | 30 | if (format === null) { 31 | return null 32 | } 33 | 34 | return { format, color: propsColor } as ColorPair 35 | } 36 | 37 | // 2. Strings: hexadecimal 38 | if (propsColor.startsWith('#')) { 39 | if (isValidHexColor(propsColor)) { 40 | return { format: 'hex', color: propsColor } 41 | } else { 42 | return null 43 | } 44 | } 45 | 46 | // 3. Strings: named 47 | if (!propsColor.includes('(')) { 48 | const context = document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D 49 | context.fillStyle = propsColor 50 | const color = context.fillStyle 51 | 52 | // Invalid color names yield `'#000000'` which we only know to have come from an invalid color name if it was *not* `'black'` 53 | if (color === '#000000' && propsColor !== 'black') { 54 | return null 55 | } 56 | 57 | return { format: 'hex', color } 58 | } 59 | 60 | // 4. Strings: functional 61 | // Split a color string like `rgba(255 255 128 / .5)` into `rgba` and `255 255 128 / .5)`. 62 | const [cssFormat, rest] = propsColor.split('(') as [string, string] 63 | const format = cssFormat.substring(0, 3) as Exclude 64 | if (!(format in CHANNELS_BY_FORMAT)) { 65 | return null 66 | } 67 | 68 | const parameters = rest 69 | // Replace all characters that aren’t needed any more, leaving a string like `255 255 128 .5`. 70 | .replace(/[,/)]/g, ' ') 71 | // Replace consecutive spaces with one space. 72 | .replace(/\s+/g, ' ') 73 | .trim() 74 | .split(' ') 75 | 76 | // Normalize color to always have an alpha channel in its internal representation. 77 | if (parameters.length === 3) { 78 | parameters.push('1') 79 | } 80 | 81 | const channels = CHANNELS_BY_FORMAT[format] 82 | const color = Object.fromEntries(channels.map((channel, index) => { 83 | const cssValue = channel === 'a' ? alpha : getCssValue(format, channel) 84 | 85 | return [ 86 | channel, 87 | cssValue.from(parameters[index] as string), 88 | ] 89 | })) 90 | 91 | return { format, color } as VisibleColorPair 92 | } 93 | -------------------------------------------------------------------------------- /src/utilities/round.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | 3 | import { round } from './round.js' 4 | 5 | describe('round', () => { 6 | test.each([ 7 | [10.000000, 0, '10'], 8 | [10.000000, 5, '10'], 9 | [10.499999, 0, '10'], 10 | [10.5, 0, '11'], 11 | [10.499999, 1, '10.5'], 12 | [10.5, 1, '10.5'], 13 | [10.3, 2, '10.3'], 14 | [10.3001, 2, '10.3'], 15 | [10.33, 2, '10.33'], 16 | [10.333, 2, '10.33'], 17 | [10.333333333, 2, '10.33'], 18 | [10.00333333333, 2, '10'], 19 | [10, 2, '10'], 20 | [10.3, 4, '10.3'], 21 | [10.33, 4, '10.33'], 22 | [10.333, 4, '10.333'], 23 | [10.333333333, 4, '10.3333'], 24 | [10.00333333333, 4, '10.0033'], 25 | [10, 4, '10'], 26 | ])('round(%s, %s) = %s', (value, precision, expectedValue) => { 27 | expect(round(value, precision)).toBe(expectedValue) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/utilities/round.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Rounds a given number to a certain level of precision after the decimal point. 3 | * 4 | * If the input value is an integer, no decimal point will be shown (e.g. `10` results in `'10'`). 5 | */ 6 | export function round (value: number, decimalPrecision: number): string { 7 | const string = value.toFixed(decimalPrecision) 8 | return string.includes('.') ? string.replace(/\.?0+$/, '') : string 9 | } 10 | -------------------------------------------------------------------------------- /src/vue-shim.d.ts: -------------------------------------------------------------------------------- 1 | // Fixes the following TypeScript error when importing a Vue single file component: 2 | // Cannot find module './ColorPicker.vue' or its corresponding type declarations. 3 | 4 | declare module '*.vue' { 5 | import { DefineComponent } from 'vue' 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | const component: DefineComponent 8 | export default component 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "declaration": true, 6 | "emitDeclarationOnly": true 7 | }, 8 | "include": [ 9 | "src/index.ts" 10 | ], 11 | "exclude": [ 12 | "src/**/*.test.ts" 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "rootDir": ".", 5 | "sourceMap": false, 6 | "target": "es2022", 7 | "module": "es2022", 8 | "moduleResolution": "Bundler", 9 | "lib": [ 10 | "dom", 11 | "dom.iterable", 12 | "es2022" 13 | ], 14 | "allowUnreachableCode": false, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUncheckedIndexedAccess": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "jsx": "preserve" 24 | }, 25 | "include": [ 26 | "./src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { fileURLToPath, URL } from 'node:url' 4 | import { defineConfig } from 'vite' 5 | import vue from '@vitejs/plugin-vue' 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | 12 | build: { 13 | emptyOutDir: false, 14 | lib: { 15 | entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)), 16 | fileName: 'ColorPicker', 17 | // Only emits an ESM bundle. 18 | formats: ['es'], 19 | }, 20 | rollupOptions: { 21 | output: { 22 | // Controls the file name of the CSS file. 23 | assetFileNames: 'ColorPicker.[ext]', 24 | }, 25 | // Prevents bundling vue. 26 | external: ['vue'], 27 | }, 28 | }, 29 | 30 | test: { 31 | environment: 'jsdom', 32 | coverage: { 33 | include: ['src'], 34 | }, 35 | }, 36 | }) 37 | --------------------------------------------------------------------------------