├── .babelrc.js ├── .editorconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── docs └── assets │ ├── analogue.png │ ├── complement.png │ ├── harmony-palettes.png │ ├── palette.png │ ├── rotation.png │ ├── saturation.png │ └── triad.png ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── colorScheme.spec.ts │ ├── colorSets.spec.ts │ ├── normalize.spec.ts │ ├── palette.spec.ts │ └── utils.spec.ts ├── color │ ├── __tests__ │ │ ├── angle.spec.ts │ │ ├── colorString.spec.ts │ │ ├── hsl.spec.ts │ │ ├── named.spec.ts │ │ └── rgb.spec.ts │ ├── angle.ts │ ├── colorString.ts │ ├── hsl.ts │ ├── named.ts │ ├── parsers │ │ ├── _tests_ │ │ │ ├── hexString.spec.ts │ │ │ ├── hslString.spec.ts │ │ │ ├── rgbString.spec.ts │ │ │ └── utils.spec.ts │ │ ├── hexString.ts │ │ ├── hslString.ts │ │ ├── rgbString.ts │ │ └── utils.ts │ ├── rgb.ts │ └── transforms │ │ ├── __tests__ │ │ ├── hslToRgb.spec.ts │ │ └── rgbToHsl.spec.ts │ │ ├── hslToRgb.ts │ │ └── rgbToHsl.ts ├── colorScheme.ts ├── colorSet.ts ├── index.ts ├── mappings │ ├── __tests__ │ │ ├── analogue.spec.ts │ │ ├── complement.spec.ts │ │ ├── lightness.spec.ts │ │ ├── opacity.spec.ts │ │ ├── rotation.spec.ts │ │ ├── saturation.spec.ts │ │ └── triad.spec.ts │ ├── analogue.ts │ ├── complement.ts │ ├── lightness.ts │ ├── opacity.ts │ ├── rotation.ts │ ├── saturation.ts │ └── triad.ts ├── normalize.ts ├── palette.ts ├── presets │ ├── __tests__ │ │ └── harmony.spec.ts │ └── harmony.ts └── utils.ts └── tsconfig.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV, BABEL_ENV } = process.env 2 | const commonjs = NODE_ENV === 'test' || BABEL_ENV === 'commonjs' 3 | const loose = true 4 | 5 | module.exports = { 6 | presets: [ 7 | [ 8 | '@babel/env', 9 | { 10 | loose, 11 | modules: false, 12 | targets: commonjs ? { ie: 11 } : { node: 'current' }, 13 | }, 14 | ], 15 | ], 16 | plugins: [ 17 | ['@babel/proposal-object-rest-spread', { loose }], 18 | commonjs && ['@babel/transform-modules-commonjs', { loose }], 19 | ['@babel/transform-runtime', { useESModules: !commonjs }], 20 | ].filter(Boolean), 21 | comments: false, 22 | } 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = off 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # build 12 | /build 13 | /lib 14 | /es 15 | /types 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .vscode 29 | .local 30 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "jsxBracketSameLine": false, 6 | "jsxSingleQuote": false, 7 | "overrides": [ 8 | { 9 | "files": [".prettierrc", ".babelrc", ".eslintrc"], 10 | "options": { 11 | "parser": "json" 12 | } 13 | } 14 | ], 15 | "printWidth": 80, 16 | "singleQuote": true, 17 | "semi": false, 18 | "tabWidth": 2, 19 | "trailingComma": "all", 20 | "useTabs": false 21 | } 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 16 3 | cache: npm 4 | script: 5 | - npm test 6 | after_success: 7 | - npm run test:cov -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Arnel Enero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simpler Color 2 | 3 | [![npm](https://img.shields.io/npm/v/simpler-color)](https://www.npmjs.com/package/simpler-color) 4 | [![coverage](https://img.shields.io/coverallsCoverage/github/arnelenero/simpler-color)](https://coveralls.io/github/arnelenero/simpler-color) 5 | [![license](https://img.shields.io/github/license/arnelenero/simpler-color)](https://opensource.org/licenses/MIT) 6 | 7 | Create your own **complete Web color system** fast and easy, from **as little as one base color**! 8 | 9 | Color is at the heart of every UI design system. A cohesive color system enables your application to: 10 | 11 | - **consistently** express brand identity and style 12 | - **effectively** communicate intent and meaning 13 | 14 | Simpler Color makes it super easy to implement your own CSS-compliant color system for any JavaScript/TypeScript project, no matter what platform, framework, or UI library you are using. It works in the browser, server (Node), mobile (React Native) and desktop (Electron). 15 | 16 | ## Easy as 1-2-3! 17 | 18 | **Step 1:** Install Simpler Color 19 | 20 | ``` 21 | npm install simpler-color 22 | ``` 23 | 24 | **Step 2:** Define your color palettes and their corresponding base colors 25 | 26 | ```js 27 | const baseColors = { 28 | primary: '#609E3F', 29 | secondary: '#5D745D', 30 | neutral: '#5E5F5A', 31 | ...etc, 32 | } 33 | ``` 34 | 35 | **—OR—** just give Simpler Color ONE base color, and it will generate the rest! 36 | 37 | ```js 38 | import { harmony } from 'simpler-color' 39 | 40 | // Generate 5 harmonious base colors from your main brand color! 41 | const baseColors = harmony('#609E3F') 42 | ``` 43 | 44 | Harmony preset 45 | 46 | **Step 3:** Create your color scheme(s) by mapping UI roles to specific colors from the auto-generated palettes 47 | 48 | ```js 49 | import { colorScheme } from 'simpler-color' 50 | 51 | const scheme = colorScheme( 52 | baseColors, // 👈 From these base colors... 53 | // 👇 ...your color palettes are auto-generated 54 | colors => ({ 55 | // 👇 which you then map to UI roles. 56 | primaryButton: colors.primary(40), 57 | primaryButtonText: colors.primary(95), 58 | surface: colors.neutral(98), 59 | text: colors.neutral(10), 60 | ...etc, 61 | }), 62 | ) 63 | // Access various UI colors as `scheme.primaryButton` and so on. 64 | ``` 65 | 66 | If some of those terms sound alien to you, read on... 67 | 68 | > **BUT FIRST, if you like this library, the concept, and its simplicity, please give it a star ⭐️ on the [GitHub repo](https://github.com/arnelenero/simpler-color) to let me know.** 😀 69 | 70 | ## Key Concepts 71 | 72 | We're not gonna discuss Color Theory here, but let's talk a bit about what a proper color system comprises. 73 | 74 | ### Color Palette 75 | 76 | Creating your color system begins with building your _color palettes_. Each palette consists of a group of related colors, generated from one _base color_. 77 | 78 | You decide what sort of relationship should be between colors in the palette. The most common type is the _tonal palette_ (also called _monochromatic_), which is made up of various "tones" of the same general hue. For example, various shades of green is a tonal palette. 79 | 80 | shades of green with varying lightness 81 | 82 | Each color in a palette is accessed by a unique _color key_, which is a string or number that indicates its relationship with the base color. The color values are determined by a _color mapping function_, which returns a specific color value for a given color key. 83 | 84 | Palettes are automatically created by Simpler Color based on your specified base colors. By default, it generates **tonal** palettes, with specific tones accessed by passing a numeric key between 0 and 100, which represents % _lightness_ (0 = black, 100 = white). Any value in between generates a specific shade of the base color. So, for example, if your `primary` palette is based on green (like in the illustration above), `primary(40)` gives you green with 40% lightness. 85 | 86 | You can, of course, define your own color mapping function to override the default. This also means that you can define a completely different set of color keys, which can be any of these common alternatives: 87 | 88 | - string values, e.g. 'darker', 'dark', 'base', 'light', 'lighter' 89 | - discrete numeric values, e.g. 0, 10, 20, ..., 90, 95, 98, 100 (like Material Design 3 does) 90 | 91 | ### Color Set 92 | 93 | The _color set_ is simply the collective term for all the color palettes built. 94 | 95 | Typically a color set would have a _primary_ palette. This represents the main "brand" color of your app. This is the most prominent hue across your UI. 96 | 97 | Common additional palettes can be any of (but not limited to) these: 98 | 99 | - _secondary_: less prominent, usually more muted 100 | - _accent_: usually complementary (opposite) to primary, to provide contrast 101 | - _neutral_: typically shades of gray or similar neutral tones 102 | - _error_: normally a brilliant red hue, to indicate errors 103 | 104 | To ensure consistency of your color set, Simpler Color enforces that you use the same set of color keys (and thus the same color mapping function) across all your palettes. 105 | 106 | ### Color Scheme 107 | 108 | A color system consists of one or several _color schemes_. These days, it's quite common to implement both Light and Dark color schemes. You can also choose to add some High Contrast schemes for accessibility. 109 | 110 | To create a color scheme, you first identify the various _UI roles_ in your design system. Each role indicates a specific use or purpose of color as it applies to specific elements of the UI. 111 | 112 | Some common examples of UI role: 113 | 114 | - primary button 115 | - primary button text 116 | - surface/background color 117 | - text color 118 | 119 | The final step is to map each UI role to a specific color value from one of the palettes in your color set. Each such mapping gives us one color scheme. By using a consistent set of color roles, Simpler Color helps ensure that your UI can easily and safely switch between color schemes. 120 | 121 | ## Recipes 122 | 123 | - [Using the built-in color mapping functions](#built-ins) 124 | - [Lightness](#lightness) 125 | - [Saturation](#saturation) 126 | - [Rotation](#rotation) 127 | - [Analogue](#analogue) 128 | - [Complement](#complement) 129 | - [Triad](#triad) 130 | - [Opacity](#opacity) 131 | - [Defining a custom color mapping function](#custom-colormap) 132 | 133 | 134 | 135 | ### Using the built-in color mapping functions 136 | 137 | Color mapping functions are not only used to generate entire palettes, but also to calculate individual colors, such as palette base colors, based on another. This helps you ensure that base colors for your other palettes are "visually harmonious" with your primary palette's. 138 | 139 | The format is always `fn(baseColor, key)` where the valid `key` values vary depending on the function. They can also be nested to perform more complex calculations. 140 | 141 | ```js 142 | import { analogue, complement, saturation } from 'simpler-color' 143 | 144 | const brandColor = '#336699' 145 | const baseColors = { 146 | primary: brandColor, 147 | secondary: analogue(brandColor, 2), 148 | accent: saturation(complement(brandColor, 1), 80), 149 | } 150 | ``` 151 | 152 | Below is the description of each of the built-in color mapping functions: 153 | 154 | #### Lightness 155 | 156 | lightness scale of green 157 | 158 | ```js 159 | lightness(baseColor, percentLightness) 160 | ``` 161 | 162 | Generates a new color value by adjusting the base color's % lightness (the "L" value in HSL color). This is the default color mapping used to generate tonal palettes. 163 | 164 | #### Saturation 165 | 166 | saturation scale of green 167 | 168 | ```js 169 | saturation(baseColor, percentSaturation) 170 | ``` 171 | 172 | Generates a new color value by adjusting the base color's % saturation (the "S" value in HSL color). 173 | 174 | #### Rotation 175 | 176 | rotation scale of green 177 | 178 | ```js 179 | rotation(baseColor, rotationAngle) 180 | ``` 181 | 182 | Rotates the hue of the base color by a specified angle around the color wheel. A negative angle reverses the direction of rotation. 183 | 184 | #### Analogue 185 | 186 | analogue scale of green 187 | 188 | ```js 189 | analogue(baseColor, step) 190 | ``` 191 | 192 | Generates a color that is analogous to the base color. An _analogous_ color is one that is located adjacent to the base color around the color wheel, i.e. at around 30˚ angle. It is visually similar to the base. 193 | 194 | This mapping function rotates the hue in steps of 30˚. A negative step value rotates in the opposite direction. 195 | 196 | #### Complement 197 | 198 | complementary scale of green 199 | 200 | ```js 201 | complement(baseColor, step) 202 | ``` 203 | 204 | Generates a color that is complementary to the base color. A _complementary_ color is one that is located at the opposite side of the color wheel, i.e. at 180˚ angle. This provides excellent color contrast. 205 | 206 | This mapping function cycles through multiple sets of "double complementary" hue rotation. The algorithm loops from 1 to `step`, rotates Hue by 180˚ on every odd iteration, and 30˚ on even. A negative step value rotates in the opposite direction. 207 | 208 | #### Triad 209 | 210 | triad scale of green 211 | 212 | ```js 213 | triad(baseColor, step) 214 | ``` 215 | 216 | Generates a triadic complementary color to the base color. A _triadic_ palette consists of 3 colors that are equally spaced around the color wheel. Therefore, producing a triadic complementary color means rotating the hue by 120˚ angle. This provides a more subtle contrast. 217 | 218 | This mapping function is cyclic. A negative step value rotates in the opposite direction. 219 | 220 | #### Opacity 221 | 222 | ```js 223 | opacity(baseColor, alpha) 224 | ``` 225 | 226 | Generates a new color value by adjusting the base color's opacity (the alpha or "A" value in RGBA) between 0 (transparent) and 1 (opaque). 227 | 228 | [Back to recipes](#recipes) 229 | 230 | 231 | 232 | ### Defining a custom color mapping function 233 | 234 | Although the default color mapping function already gives you great looking tonal palettes, sometimes your color system might require a different approach, such as: 235 | 236 | - a different set of color keys (e.g. strings or discrete numbers) 237 | - a different formula for calculating colors in your palettes 238 | 239 | This is where a custom color mapping function comes in. For example, here is a modified version of the default (`lightness`) color mapping function that accepts a string for `key` instead of a number from 0-100. 240 | 241 | ```js 242 | import { lightness } from 'simpler-color' 243 | 244 | function shade(baseColor, key) { 245 | const lightnessValues = { 246 | darker: 10, 247 | dark: 30, 248 | main: 40, 249 | light: 60, 250 | lighter: 80, 251 | } 252 | return lightness(baseColor, lightnessValues[key]) 253 | } 254 | ``` 255 | 256 | You can then tell Simpler Color to use this custom color mapping instead of the default: 257 | 258 | ```js 259 | const uiColors = colorScheme( 260 | 'blue', 261 | colors => ({ 262 | // notice the color keys used 👇 263 | primaryButton: colors.primary('main'), 264 | floatingActionButton: colors.accent('light'), 265 | navBar: colors.secondary('lighter'), 266 | ...etc, 267 | }), 268 | { 269 | colorMapping: shade, // 👈 custom color mapping 270 | }, 271 | ) 272 | ``` 273 | 274 | [Back to recipes](#recipes) 275 | -------------------------------------------------------------------------------- /docs/assets/analogue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/analogue.png -------------------------------------------------------------------------------- /docs/assets/complement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/complement.png -------------------------------------------------------------------------------- /docs/assets/harmony-palettes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/harmony-palettes.png -------------------------------------------------------------------------------- /docs/assets/palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/palette.png -------------------------------------------------------------------------------- /docs/assets/rotation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/rotation.png -------------------------------------------------------------------------------- /docs/assets/saturation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/saturation.png -------------------------------------------------------------------------------- /docs/assets/triad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arnelenero/simpler-color/f7daad50b250d285242cba75723b3fae99965622/docs/assets/triad.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simpler-color", 3 | "version": "1.0.2", 4 | "description": "Simpler Color - Create your own complete color system fast and easy!", 5 | "keywords": [ 6 | "javascript", 7 | "color", 8 | "system", 9 | "helper", 10 | "CSS", 11 | "style" 12 | ], 13 | "author": "Arnel Enero ", 14 | "license": "MIT", 15 | "repository": "github:arnelenero/simpler-color", 16 | "main": "lib/index.js", 17 | "module": "es/index.js", 18 | "typings": "types/index.d.ts", 19 | "files": [ 20 | "lib", 21 | "es", 22 | "types" 23 | ], 24 | "sideEffects": false, 25 | "scripts": { 26 | "build:lib": "cross-env BABEL_ENV=commonjs babel build --out-dir lib", 27 | "build:es": "babel build --out-dir es", 28 | "build": "npm run clean && npm run compile && npm run build:lib && npm run build:es", 29 | "clean": "rimraf build types lib es", 30 | "compile": "tsc", 31 | "prepare": "npm test", 32 | "pretest": "npm run build", 33 | "test": "jest", 34 | "test:cov": "npm test -- --coverage && coveralls < coverage/lcov.info", 35 | "test:cov-local": "npm test -- --coverage" 36 | }, 37 | "dependencies": { 38 | "@babel/runtime": "^7.17.8" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.17.6", 42 | "@babel/core": "^7.17.5", 43 | "@babel/plugin-proposal-object-rest-spread": "^7.17.3", 44 | "@babel/plugin-transform-runtime": "^7.17.0", 45 | "@babel/preset-env": "^7.16.11", 46 | "@types/color": "^3.0.3", 47 | "@types/jest": "^27.4.1", 48 | "@types/node": "^17.0.21", 49 | "babel-jest": "^27.5.1", 50 | "coveralls": "^3.1.1", 51 | "cross-env": "^5.2.0", 52 | "eslint": "^8.8.0", 53 | "jest": "^27.5.1", 54 | "prettier": "^2.5.1", 55 | "rimraf": "^2.6.2", 56 | "ts-jest": "^27.1.3", 57 | "typescript": "^4.6.2" 58 | }, 59 | "jest": { 60 | "preset": "ts-jest", 61 | "testPathIgnorePatterns": [ 62 | "/node_modules/", 63 | "/build/", 64 | "/es/", 65 | "/lib/", 66 | "/types/" 67 | ], 68 | "transformIgnorePatterns": [ 69 | "/node_modules/", 70 | "/build/", 71 | "/es/", 72 | "/lib/", 73 | "/types/" 74 | ], 75 | "modulePathIgnorePatterns": [ 76 | "/node_modules/", 77 | "/build/", 78 | "/es/", 79 | "/lib/", 80 | "/types/" 81 | ], 82 | "collectCoverageFrom": [ 83 | "src/**/*.ts", 84 | "!src/**/index.ts", 85 | "!src/index.ts" 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/__tests__/colorScheme.spec.ts: -------------------------------------------------------------------------------- 1 | import colorScheme from '../colorScheme' 2 | 3 | describe('colorScheme', () => { 4 | const baseColors = { 5 | blues: 'blue', 6 | greens: 'green', 7 | } 8 | const colorRoles = (colors: Record) => ({ 9 | blueButton: colors.blues(40), 10 | greenButton: colors.greens(40), 11 | }) 12 | 13 | it('returns an object with color roles as keys', () => { 14 | const scheme = colorScheme(baseColors, colorRoles) 15 | expect(scheme).toHaveProperty('blueButton') 16 | expect(scheme).toHaveProperty('greenButton') 17 | }) 18 | 19 | it('creates a color set from base colors and passes it to the role-mapping function', () => { 20 | const spiedColorRoles = jest.fn(colorRoles) 21 | colorScheme(baseColors, spiedColorRoles) 22 | const argsToColorRoles = spiedColorRoles.mock.calls[0][0] 23 | expect(spiedColorRoles).toHaveBeenCalled() 24 | expect(argsToColorRoles.blues).toBeInstanceOf(Function) 25 | expect(argsToColorRoles.greens).toBeInstanceOf(Function) 26 | }) 27 | 28 | it('returns the result of role-mapping function', () => { 29 | const spiedColorRoles = jest.fn(colorRoles) 30 | const scheme = colorScheme(baseColors, spiedColorRoles) 31 | const resultOfColorRoles = spiedColorRoles.mock.results[0].value 32 | expect(scheme).toEqual(resultOfColorRoles) 33 | }) 34 | 35 | it('supports optional custom color-mapping function for generating the color set', () => { 36 | const fooColor = () => 'foo' 37 | const scheme = colorScheme(baseColors, colorRoles, { 38 | colorMapping: fooColor, 39 | }) 40 | expect(scheme.blueButton).toBe('foo') 41 | expect(scheme.greenButton).toBe('foo') 42 | }) 43 | 44 | it('defaults to enable caching for color-mapping while generating the color set', () => { 45 | const fooColor = jest.fn().mockReturnValue('foo') 46 | const colorRoles = (colors: Record) => ({ 47 | blueButton: colors.blues(40), 48 | blueBar: colors.blues(40), 49 | blueText: colors.blues(40), 50 | }) 51 | colorScheme(baseColors, colorRoles, { 52 | colorMapping: fooColor, 53 | }) 54 | expect(fooColor).toHaveBeenCalledTimes(1) 55 | }) 56 | 57 | it('supports disabling the caching for color-mapping while generating the color set', () => { 58 | const fooColor = jest.fn().mockReturnValue('foo') 59 | const colorRoles = (colors: Record) => ({ 60 | blueButton: colors.blues(40), 61 | blueBar: colors.blues(40), 62 | blueText: colors.blues(40), 63 | }) 64 | colorScheme(baseColors, colorRoles, { 65 | colorMapping: fooColor, 66 | noCache: true, 67 | }) 68 | expect(fooColor).toHaveBeenCalledTimes(3) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /src/__tests__/colorSets.spec.ts: -------------------------------------------------------------------------------- 1 | import colorSet from '../colorSet' 2 | 3 | describe('colorSets', () => { 4 | const baseColors = { 5 | blues: 'blue', 6 | reds: 'red', 7 | greens: 'green', 8 | } 9 | 10 | it('returns a keyed list of palette getters for all the base colors', () => { 11 | const colors = colorSet(baseColors) 12 | expect(colors).toBeInstanceOf(Object) 13 | expect(colors.blues).toBeInstanceOf(Function) 14 | expect(colors.reds).toBeInstanceOf(Function) 15 | expect(colors.greens).toBeInstanceOf(Function) 16 | }) 17 | 18 | it('uses `lightness` color mapping function by default for all palettes', () => { 19 | const colors = colorSet(baseColors) 20 | expect(colors.blues(40)).toBe('#0000CC') 21 | expect(colors.reds(40)).toBe('#CC0000') 22 | expect(colors.greens(40)).toBe('#00CC00') 23 | }) 24 | 25 | it('can apply a custom color mapping function to all palettes', () => { 26 | const fooColor = () => 'foo' 27 | const colors = colorSet(baseColors, fooColor) 28 | expect(colors.blues(40)).toBe('foo') 29 | expect(colors.reds(40)).toBe('foo') 30 | expect(colors.greens(40)).toBe('foo') 31 | }) 32 | 33 | it('enables caching of color mapping for all palettes by default', () => { 34 | const fooColor = jest.fn().mockReturnValue('foo') 35 | const colors = colorSet(baseColors, fooColor) 36 | for (let i = 0; i < 3; i++) colors.blues(40) 37 | for (let i = 0; i < 3; i++) colors.reds(40) 38 | for (let i = 0; i < 3; i++) colors.greens(40) 39 | expect(fooColor).toHaveBeenCalledTimes(3) 40 | }) 41 | 42 | it('supports disabling the caching of color mapping for all palettes', () => { 43 | const fooColor = jest.fn().mockReturnValue('foo') 44 | const colors = colorSet(baseColors, fooColor, true) 45 | for (let i = 0; i < 3; i++) colors.blues(40) 46 | for (let i = 0; i < 3; i++) colors.reds(40) 47 | for (let i = 0; i < 3; i++) colors.greens(40) 48 | expect(fooColor).toHaveBeenCalledTimes(9) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/__tests__/normalize.spec.ts: -------------------------------------------------------------------------------- 1 | import normalize from '../normalize' 2 | 3 | describe('normalize', () => { 4 | it('returns hex value if the color is opaque', () => { 5 | const opaques = ['blue', '#0000FF', 'rgb(0, 0, 255)', 'hsl(240, 100%, 50%)'] 6 | opaques.forEach(color => { 7 | expect(normalize(color)).toBe('#0000FF') 8 | }) 9 | }) 10 | 11 | it('returns rgba value if the color is translucent/transparent', () => { 12 | const nonOpaques = [ 13 | 'transparent', 14 | '#0000FF88', 15 | 'rgba(0, 0, 255, 0.5)', 16 | 'hsla(240, 100%, 100%, 0.5)', 17 | ] 18 | nonOpaques.forEach(color => { 19 | const isRgba = normalize(color).indexOf('rgba(') > -1 20 | expect(isRgba).toBe(true) 21 | }) 22 | }) 23 | 24 | it('throws if color value is invalid', () => { 25 | expect(() => { 26 | return normalize('bluish') 27 | }).toThrow() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/__tests__/palette.spec.ts: -------------------------------------------------------------------------------- 1 | import palette from '../palette' 2 | 3 | describe('palette', () => { 4 | it('returns a function', () => { 5 | const blues = palette('blue') 6 | expect(blues).toBeInstanceOf(Function) 7 | }) 8 | 9 | it('returns a palette color getter', () => { 10 | const blues = palette('blue') 11 | const blue40 = blues(40) 12 | expect(typeof blue40).toBe('string') 13 | }) 14 | 15 | it('uses `lightness` color mapping function by default', () => { 16 | const blues = palette('blue') 17 | const blue40 = blues(40) 18 | expect(blue40).toBe('#0000CC') 19 | }) 20 | 21 | it('defaults the return value of getter to base color if no key is passed', () => { 22 | const blues = palette('blue') 23 | const justBlue = blues() 24 | expect(justBlue).toBe('blue') 25 | }) 26 | 27 | it('supports a custom color mapping function', () => { 28 | const fooMapper = jest.fn().mockReturnValue('foobar') 29 | const foo = palette('blue', fooMapper) 30 | const foobar = foo('bar') 31 | expect(fooMapper).toHaveBeenCalledWith('blue', 'bar') 32 | expect(foobar).toBe('foobar') 33 | }) 34 | 35 | it('caches the generated color by default', () => { 36 | const fooMapper = jest.fn().mockReturnValue('foobar') 37 | const foo = palette('blue', fooMapper) 38 | const foobar = foo('bar') 39 | const foobarToo = foo('bar') 40 | expect(fooMapper).toHaveBeenCalledTimes(1) 41 | expect(foobar).toEqual(foobarToo) 42 | }) 43 | 44 | it('provides the option to disable caching', () => { 45 | const fooMapper = jest.fn().mockReturnValue('foobar') 46 | const foo = palette('blue', fooMapper, true) 47 | const foobar = foo('bar') 48 | const foobarToo = foo('bar') 49 | expect(fooMapper).toHaveBeenCalledTimes(2) 50 | expect(foobar).toEqual(foobarToo) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '../utils' 2 | 3 | describe('clamp', () => { 4 | it('returns the same value if it is within range', () => { 5 | expect(clamp(50, 0, 100)).toBe(50) 6 | }) 7 | 8 | it('limits the value to the minimum', () => { 9 | expect(clamp(-1, 0, 100)).toBe(0) 10 | }) 11 | 12 | it('limits the value to the maximum', () => { 13 | expect(clamp(101, 0, 100)).toBe(100) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/color/__tests__/angle.spec.ts: -------------------------------------------------------------------------------- 1 | import angle from '../angle' 2 | 3 | describe('angle', () => { 4 | let values: string[] 5 | 6 | values = ['180deg', '180'] 7 | values.forEach(val => { 8 | it(`extracts straight value if angle string is in degrees (default): ${val}`, () => { 9 | expect(angle(val)).toBe(180) 10 | }) 11 | }) 12 | 13 | values = ['3.14159265rad', '200grad', '0.5turn'] 14 | values.forEach(val => { 15 | it(`converts value to degrees if angle string is in a different unit: ${val}`, () => { 16 | expect(Math.round(angle(val))).toBe(180) 17 | }) 18 | }) 19 | 20 | it('normalizes value to range [0..360) degrees', () => { 21 | expect(angle('-90deg')).toBe(270) 22 | expect(angle('360deg')).toBe(0) 23 | }) 24 | 25 | it('returns NaN if argument is not a valid numeric value', () => { 26 | expect(angle('invalid')).toBeNaN() 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/color/__tests__/colorString.spec.ts: -------------------------------------------------------------------------------- 1 | import colorString from '../colorString' 2 | 3 | describe('colorString', () => { 4 | it('returns a hex string from RGB object when alpha = 1', () => { 5 | const obj = { r: 255, g: 0, b: 255, a: 1 } 6 | expect(colorString(obj)).toBe('#FF00FF') 7 | }) 8 | 9 | it('returns a hex string from HSL object when alpha = 1', () => { 10 | const obj = { h: 240, s: 100, l: 50, a: 1 } 11 | expect(colorString(obj)).toBe('#0000FF') 12 | }) 13 | 14 | it('returns an rgba() string from RGB object when alpha < 1', () => { 15 | const obj = { r: 255, g: 0, b: 255, a: 0.6 } 16 | expect(colorString(obj)).toBe('rgba(255, 0, 255, 0.6)') 17 | }) 18 | 19 | it('returns an rgba() string from HSL object when alpha < 1', () => { 20 | const obj = { h: 240, s: 100, l: 50, a: 0.8 } 21 | expect(colorString(obj)).toBe('rgba(0, 0, 255, 0.8)') 22 | }) 23 | 24 | it('rounds off r,g,b values in rgba() strings', () => { 25 | const obj = { r: 127.5, g: 64.3333, b: 255, a: 0.6 } 26 | expect(colorString(obj)).toBe('rgba(128, 64, 255, 0.6)') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/color/__tests__/hsl.spec.ts: -------------------------------------------------------------------------------- 1 | import hsl, { isHsl, normalizeHsl } from '../hsl' 2 | 3 | describe('isHsl', () => { 4 | it('returns true if value is a valid HSL object', () => { 5 | expect(isHsl({ h: 240, s: 100, l: 50, a: 1 })).toBe(true) 6 | }) 7 | 8 | const invalid = [ 9 | { h: 240, s: 100, l: 50 }, 10 | { h: '240', s: '100', l: '50', a: '1' }, 11 | { r: 255, g: 0, b: 64, a: 1 }, 12 | [240, 100, 50, 1], 13 | 'hsla(240, 100%, 50%, 1)', 14 | '#FF0033', 15 | 'blue', 16 | ] 17 | invalid.forEach(val => { 18 | it(`returns false if value is not a valid HSL object: ${val}`, () => { 19 | expect(isHsl(val)).toBe(false) 20 | }) 21 | }) 22 | }) 23 | 24 | describe('normalizeHsl', () => { 25 | it('returns a new HSL object and does not mutate the original', () => { 26 | const obj = { h: 240, s: 100, l: 50, a: 1 } 27 | expect(normalizeHsl(obj)).not.toBe(obj) 28 | }) 29 | 30 | it('clamps h to [0..360), s,l to [0..100] and alpha to [0..1]', () => { 31 | const belowMin = { h: -90, s: -1, l: -1, a: -1 } 32 | const aboveMax = { h: 360, s: 101, l: 101, a: 10 } 33 | expect(normalizeHsl(belowMin)).toEqual({ h: 270, s: 0, l: 0, a: 0 }) 34 | expect(normalizeHsl(aboveMax)).toEqual({ h: 0, s: 100, l: 100, a: 1 }) 35 | }) 36 | 37 | it('does not modify h value if it is NaN', () => { 38 | expect(normalizeHsl({ h: NaN, s: 0, l: 0, a: 1 }).h).toBeNaN() 39 | }) 40 | 41 | it('does not round off h,s,l values that are within range', () => { 42 | const nonInteger = { h: 239.5, s: 0.01, l: 33.333, a: 0.9 } 43 | expect(normalizeHsl(nonInteger)).toEqual(nonInteger) 44 | }) 45 | }) 46 | 47 | describe('hsl', () => { 48 | let values: string[] 49 | 50 | it('returns an {h,s,l,a} object containing numeric values', () => { 51 | const obj = hsl('hsl(180, 100%, 50%, 0.6)') 52 | expect(typeof obj?.h).toBe('number') 53 | expect(typeof obj?.s).toBe('number') 54 | expect(typeof obj?.l).toBe('number') 55 | expect(typeof obj?.a).toBe('number') 56 | }) 57 | 58 | values = [ 59 | 'hsla(180, 100%, 50%, 0.6)', 60 | 'hsl(180deg 100% 50% / 0.6)', 61 | 'hsl(3.1416rad 100% 50% / 0.6)', 62 | 'hsl(200grad 100% 50% / 0.6)', 63 | 'hsl(0.5turn 100% 50% / 0.6)', 64 | ] 65 | values.forEach(color => { 66 | it(`returns hue value in degrees for: ${color}`, () => { 67 | // Round off because radian value won't yield integer 68 | expect(hsl(color)?.h).toBeCloseTo(180) 69 | }) 70 | }) 71 | 72 | values = ['hsla(180, 100%, 50%, 0.6)', 'hsl(180 100% 50% / 60%)'] 73 | values.forEach(color => { 74 | it(`returns alpha value as fraction for: ${color}`, () => { 75 | expect(hsl(color)).toHaveProperty('a', 0.6) 76 | }) 77 | }) 78 | 79 | it('returns default alpha value of 1', () => { 80 | expect(hsl('hsl(240, 50%, 50%)')).toHaveProperty('a', 1) 81 | }) 82 | 83 | it('normalizes the hue value to [0..360) degrees', () => { 84 | expect(hsl('hsl(-90, 50%, 50%)')).toHaveProperty('h', 270) 85 | expect(hsl('hsl(360, 50%, 50%)')).toHaveProperty('h', 0) 86 | }) 87 | 88 | it('clamps the saturation value to [0..100]', () => { 89 | expect(hsl('hsl(240, -10%, 50%)')).toHaveProperty('s', 0) 90 | expect(hsl('hsl(240, 100.1%, 50%)')).toHaveProperty('s', 100) 91 | }) 92 | 93 | it('clamps the lightness value to [0..100]', () => { 94 | expect(hsl('hsl(240, 50%, -10%)')).toHaveProperty('l', 0) 95 | expect(hsl('hsl(240, 50%, 100.1%)')).toHaveProperty('l', 100) 96 | }) 97 | 98 | it('clamps the alpha value to [0..1]', () => { 99 | expect(hsl('hsla(240, 50%, 50%, -1)')).toHaveProperty('a', 0) 100 | expect(hsl('hsla(240, 50%, 50%, 10)')).toHaveProperty('a', 1) 101 | }) 102 | 103 | values = [ 104 | '#FF33CC', 105 | '#FF33CCFF', 106 | 'rgb(255, 51, 204)', 107 | 'rgba(255, 51, 204, 1.0)', 108 | 'rgb(100% 20% 80% / 100%)', 109 | ] 110 | values.forEach(color => { 111 | it(`converts values from RGB string: ${color}`, () => { 112 | expect(hsl(color)).toEqual({ h: 315, s: 100, l: 60, a: 1 }) 113 | }) 114 | }) 115 | 116 | it('converts CSS color names/keywords into HSL', () => { 117 | expect(hsl('blue')).toEqual({ h: 240, s: 100, l: 50, a: 1 }) 118 | expect(hsl('transparent')).toEqual({ h: NaN, s: 0, l: 0, a: 0 }) 119 | }) 120 | 121 | it('throws if argument is not a valid color string', () => { 122 | expect(() => hsl('foo')).toThrow() 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /src/color/__tests__/named.spec.ts: -------------------------------------------------------------------------------- 1 | import named, { namedColors } from '../named' 2 | 3 | describe('named', () => { 4 | Object.keys(namedColors).forEach(colorName => { 5 | it(`returns the hex string value for recognized color name: ${colorName}`, () => { 6 | const color = named(colorName) 7 | expect(typeof color).toBe('string') 8 | expect(color?.charAt(0)).toBe('#') 9 | }) 10 | }) 11 | 12 | it('is not case sensitive', () => { 13 | expect(named('royalblue')).toBeDefined() 14 | expect(named('RoyalBlue')).toBeDefined() 15 | }) 16 | 17 | const invalid = [ 18 | '#33CCFF', // hex color value 19 | 'rgb(127, 255, 255)', // non-hex color value 20 | ' blue ', // untrimmed spaces 21 | 'transparent', // special keyword 22 | 'rainbow', // invalid color string 23 | ] 24 | invalid.forEach(str => { 25 | it(`returns undefined for invalid color name: ${str}`, () => { 26 | expect(named(str)).toBeUndefined() 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/color/__tests__/rgb.spec.ts: -------------------------------------------------------------------------------- 1 | import rgb, { isRgb, normalizeRgb } from '../rgb' 2 | 3 | describe('isRgb', () => { 4 | it('returns true if value is a valid RGB object', () => { 5 | expect(isRgb({ r: 255, g: 0, b: 64, a: 1 })).toBe(true) 6 | }) 7 | 8 | const invalid = [ 9 | { r: 255, g: 0, b: 64 }, 10 | { r: '255', g: '0', b: '64', a: '1' }, 11 | { h: 240, s: 100, l: 50, a: 1 }, 12 | [255, 0, 64, 1], 13 | 'rgba(255, 0, 64, 1)', 14 | '#FF0033', 15 | 'blue', 16 | ] 17 | invalid.forEach(val => { 18 | it(`returns false if value is not a valid RGB object: ${val}`, () => { 19 | expect(isRgb(val)).toBe(false) 20 | }) 21 | }) 22 | }) 23 | 24 | describe('normalizeRgb', () => { 25 | it('returns a new RGB object and does not mutate the original', () => { 26 | const obj = { r: 255, g: 0, b: 64, a: 1 } 27 | expect(normalizeRgb(obj)).not.toBe(obj) 28 | }) 29 | 30 | it('clamps r,g,b values to [0..255] and alpha to [0..1]', () => { 31 | const belowMin = { r: -1, g: -1, b: -1, a: -1 } 32 | const aboveMax = { r: 256, g: 256, b: 256, a: 10 } 33 | expect(normalizeRgb(belowMin)).toEqual({ r: 0, g: 0, b: 0, a: 0 }) 34 | expect(normalizeRgb(aboveMax)).toEqual({ r: 255, g: 255, b: 255, a: 1 }) 35 | }) 36 | 37 | it('does not round off r,g,b values that are within range', () => { 38 | const nonInteger = { r: 254.9, g: 0.01, b: 33.333, a: 0.9 } 39 | expect(normalizeRgb(nonInteger)).toEqual(nonInteger) 40 | }) 41 | }) 42 | 43 | describe('rgb', () => { 44 | let values: string[] 45 | 46 | it('returns an {r,g,b,a} object containing numeric values', () => { 47 | const obj = rgb('rgba(255, 51, 204, 0.6)') 48 | expect(typeof obj?.r).toBe('number') 49 | expect(typeof obj?.g).toBe('number') 50 | expect(typeof obj?.b).toBe('number') 51 | expect(typeof obj?.a).toBe('number') 52 | }) 53 | 54 | values = ['#FF33CC99', 'rgba(255, 51, 204, 0.6)', 'rgb(100% 20% 80% / 60%)'] 55 | values.forEach(color => { 56 | it(`returns red, green, blue values in decimal for: ${color}`, () => { 57 | const obj = rgb(color) 58 | expect(obj).toHaveProperty('r', 255) 59 | expect(obj).toHaveProperty('g', 51) 60 | expect(obj).toHaveProperty('b', 204) 61 | }) 62 | }) 63 | 64 | values = ['#FF33CC99', 'rgba(255, 51, 204, 0.6)', 'rgb(100% 20% 80% / 60%)'] 65 | values.forEach(color => { 66 | it(`returns alpha value as fraction for: ${color}`, () => { 67 | expect(rgb(color)).toHaveProperty('a', 0.6) 68 | }) 69 | }) 70 | 71 | values = ['#FFAACC', 'rgb(255, 170, 204)'] 72 | values.forEach(color => { 73 | it(`returns default alpha value of 1 for: ${color}`, () => { 74 | expect(rgb(color)).toHaveProperty('a', 1) 75 | }) 76 | }) 77 | 78 | it('expands shorthand hex value', () => { 79 | expect(rgb('#FAC3')).toEqual({ r: 255, g: 170, b: 204, a: 0.2 }) 80 | }) 81 | 82 | it('clamps the red value to [0..255]', () => { 83 | expect(rgb('rgb(-1, 255, 255)')).toHaveProperty('r', 0) 84 | expect(rgb('rgb(256, 255, 255)')).toHaveProperty('r', 255) 85 | }) 86 | 87 | it('clamps the green value to [0..255]', () => { 88 | expect(rgb('rgb(255, -1, 255)')).toHaveProperty('g', 0) 89 | expect(rgb('rgb(255, 256, 255)')).toHaveProperty('g', 255) 90 | }) 91 | 92 | it('clamps the blue value to [0..255]', () => { 93 | expect(rgb('rgb(255, 255, -1)')).toHaveProperty('b', 0) 94 | expect(rgb('rgb(255, 255, 256)')).toHaveProperty('b', 255) 95 | }) 96 | 97 | it('clamps the alpha value to [0..1]', () => { 98 | expect(rgb('rgb(255, 255, 0, -1)')).toHaveProperty('a', 0) 99 | expect(rgb('rgb(255, 255, 0, 10)')).toHaveProperty('a', 1) 100 | }) 101 | 102 | it('returns RGB values from CSS color names', () => { 103 | expect(rgb('blue')).toEqual({ r: 0, g: 0, b: 255, a: 1 }) 104 | expect(rgb('yellow')).toEqual({ r: 255, g: 255, b: 0, a: 1 }) 105 | }) 106 | 107 | it('treats the `transparent` keyword as black with zero opacity', () => { 108 | expect(rgb('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 }) 109 | }) 110 | 111 | values = [ 112 | 'hsl(315, 100%, 60%)', 113 | 'hsla(315, 100%, 60%, 1.0)', 114 | 'hsl(315deg 100% 60% / 100%)', 115 | ] 116 | values.forEach(color => { 117 | it(`converts values from HSL string: ${color}`, () => { 118 | const obj = rgb(color) 119 | expect(obj?.r).toBe(255) 120 | expect(obj?.g).toBeCloseTo(51) 121 | expect(obj?.b).toBeCloseTo(204) 122 | expect(obj?.a).toBe(1) 123 | }) 124 | }) 125 | 126 | it('throws if argument is not a valid color string', () => { 127 | expect(() => rgb('foo')).toThrow() 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /src/color/angle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts an angle value to degrees 3 | * 4 | * @param angle - numeric angle value 5 | * @param unit - angle unit to convert from 6 | * @returns converted angle value in degrees 7 | */ 8 | export function toDegrees(angle: number, unit: string): number { 9 | const multiplier: Record = { 10 | rad: 180 / Math.PI, 11 | grad: 0.9, 12 | turn: 360, 13 | } 14 | return angle * (multiplier[unit.toLowerCase()] ?? 1) 15 | } 16 | 17 | /** 18 | * Normalizes angle value (in degrees) to range [0..360) 19 | * 20 | * @param degrees - numeric angle value 21 | * @returns normalized angle value 22 | */ 23 | export function normalizeAngle(degrees: number): number { 24 | return ((degrees % 360) + 360) % 360 25 | } 26 | 27 | /** 28 | * Extracts the numeric value (in degrees) from an angle 29 | * string 30 | * 31 | * The following CSS angle units are supported: `deg`, 32 | * `rad`, `grad`, and `turn`. Default unit is `deg`. 33 | * Unit conversion is done as necessary. 34 | * 35 | * @param str - CSS angle string 36 | * @returns numeric value normalized to range [0..360), or `NaN` 37 | */ 38 | export default function angle(str: string): number { 39 | const num = parseFloat(str) 40 | const unit = str.match(/deg|rad|grad|turn/i)?.[0] ?? 'deg' 41 | return normalizeAngle(toDegrees(num, unit)) 42 | } 43 | -------------------------------------------------------------------------------- /src/color/colorString.ts: -------------------------------------------------------------------------------- 1 | import { isHsl } from './hsl' 2 | import { normalizeRgb } from './rgb' 3 | import hslToRgb from './transforms/hslToRgb' 4 | 5 | import type { HSL } from './hsl' 6 | import type { RGB } from './rgb' 7 | 8 | function roundRgb(rgb: RGB): RGB { 9 | return { 10 | r: Math.round(rgb.r), 11 | g: Math.round(rgb.g), 12 | b: Math.round(rgb.b), 13 | a: rgb.a, // do not round off alpha 14 | } 15 | } 16 | 17 | function rgbToHexString(rgb: RGB): string { 18 | const int = 19 | ((Math.round(rgb.r) & 0xff) << 16) + 20 | ((Math.round(rgb.g) & 0xff) << 8) + 21 | (Math.round(rgb.b) & 0xff) 22 | 23 | const str = int.toString(16).toUpperCase() 24 | const padLeft = '000000'.substring(str.length) 25 | return `#${padLeft}${str}` 26 | } 27 | 28 | function rgbToRgbaString(rgb: RGB): string { 29 | rgb = roundRgb(rgb) 30 | return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})` 31 | } 32 | 33 | /** 34 | * Builds a color string from a color model object 35 | * 36 | * For maximum CSS compatibility, the output is in `#rrggbb` 37 | * hex format when opacity (alpha) is 1, while in `rgba()` 38 | * otherwise. 39 | * 40 | * @param color - either RGB or HSL color object 41 | * @returns a CSS color string 42 | */ 43 | export default function colorString(color: RGB | HSL): string { 44 | const rgbColor = isHsl(color) ? hslToRgb(color) : normalizeRgb(color) 45 | return rgbColor.a === 1 ? rgbToHexString(rgbColor) : rgbToRgbaString(rgbColor) 46 | } 47 | -------------------------------------------------------------------------------- /src/color/hsl.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '../utils' 2 | import angle, { normalizeAngle } from './angle' 3 | import { matchHslString } from './parsers/hslString' 4 | import { rgbFromColorString } from './rgb' 5 | import rgbToHsl from './transforms/rgbToHsl' 6 | 7 | /** Object model of a color in the HSL space */ 8 | export interface HSL { 9 | h: number 10 | s: number 11 | l: number 12 | a: number 13 | } 14 | 15 | /** 16 | * Checks if the given value is an HSL object 17 | * 18 | * @param color - value to inspect 19 | * @returns true/false (type predicate for `HSL` in TS) 20 | */ 21 | export function isHsl(color: any): color is HSL { 22 | return ( 23 | typeof color.h === 'number' && 24 | typeof color.s === 'number' && 25 | typeof color.l === 'number' && 26 | typeof color.a === 'number' 27 | ) 28 | } 29 | 30 | /** 31 | * Normalizes the color component values of an HSL object 32 | * to range [0..360) for h, [0..100] for s,l and [0..1] 33 | * for alpha 34 | * 35 | * @param hsl - HSL object 36 | * @returns a new HSL object with the normalized values 37 | */ 38 | export function normalizeHsl(hsl: HSL): HSL { 39 | return { 40 | h: normalizeAngle(hsl.h), 41 | s: clamp(hsl.s, 0, 100), 42 | l: clamp(hsl.l, 0, 100), 43 | a: clamp(hsl.a, 0, 1), 44 | } 45 | } 46 | 47 | function hslFromParsedHslString(match: string[]): HSL { 48 | const hslValues = match.map(val => parseFloat(val)) 49 | 50 | let alpha = hslValues[3] ?? 1 51 | // Convert % alpha to fraction 52 | if (match[3]?.indexOf('%') > -1) { 53 | alpha *= 0.01 54 | } 55 | 56 | return normalizeHsl({ 57 | h: angle(match[0]), 58 | s: hslValues[1], 59 | l: hslValues[2], 60 | a: alpha, 61 | }) 62 | } 63 | 64 | function hslFromRgbString(colorString: string): HSL | null { 65 | const rgbColor = rgbFromColorString(colorString) 66 | return rgbColor ? rgbToHsl(rgbColor) : null 67 | } 68 | 69 | /** 70 | * Creates an HSL model from a given HSL-based color string 71 | * 72 | * @param colorString - CSS color string 73 | * @returns an `{h,s,l,a}` color object (or `null` if invalid color string) 74 | */ 75 | export function hslFromColorString(colorString: string): HSL | null { 76 | colorString = colorString.trim() 77 | 78 | let match: string[] | null 79 | if ((match = matchHslString(colorString)) !== null) 80 | return hslFromParsedHslString(match) 81 | 82 | return null 83 | } 84 | 85 | /** 86 | * Creates an HSL model from a given color string 87 | * 88 | * @param colorString - CSS color string 89 | * @returns an `{h,s,l,a}` color object 90 | * @throws if argument is not a valid color string 91 | */ 92 | export default function hsl(colorString: string): HSL { 93 | const hslObj = 94 | hslFromColorString(colorString) ?? hslFromRgbString(colorString) 95 | 96 | if (hslObj === null) throw new Error('Invalid color string') 97 | 98 | return hslObj 99 | } 100 | -------------------------------------------------------------------------------- /src/color/named.ts: -------------------------------------------------------------------------------- 1 | import type { HexString } from './parsers/hexString' 2 | 3 | /** CSS color names and corresponding hex string values */ 4 | export const namedColors: Record = { 5 | aliceblue: '#F0F8FF', 6 | antiquewhite: '#FAEBD7', 7 | aqua: '#00FFFF', 8 | aquamarine: '#7FFFD4', 9 | azure: '#F0FFFF', 10 | beige: '#F5F5DC', 11 | bisque: '#FFE4C4', 12 | black: '#000000', 13 | blanchedalmond: '#FFEBCD', 14 | blue: '#0000FF', 15 | blueviolet: '#8A2BE2', 16 | brown: '#A52A2A', 17 | burlywood: '#DEB887', 18 | cadetblue: '#5F9EA0', 19 | chartreuse: '#7FFF00', 20 | chocolate: '#D2691E', 21 | coral: '#FF7F50', 22 | cornflowerblue: '#6495ED', 23 | cornsilk: '#FFF8DC', 24 | crimson: '#DC143C', 25 | cyan: '#00FFFF', 26 | darkblue: '#00008B', 27 | darkcyan: '#008B8B', 28 | darkgoldenrod: '#B8860B', 29 | darkgray: '#A9A9A9', 30 | darkgreen: '#006400', 31 | darkgrey: '#A9A9A9', 32 | darkkhaki: '#BDB76B', 33 | darkmagenta: '#8B008B', 34 | darkolivegreen: '#556B2F', 35 | darkorange: '#FF8C00', 36 | darkorchid: '#9932CC', 37 | darkred: '#8B0000', 38 | darksalmon: '#E9967A', 39 | darkseagreen: '#8FBC8F', 40 | darkslateblue: '#483D8B', 41 | darkslategray: '#2F4F4F', 42 | darkslategrey: '#2F4F4F', 43 | darkturquoise: '#00CED1', 44 | darkviolet: '#9400D3', 45 | deeppink: '#FF1493', 46 | deepskyblue: '#00BFFF', 47 | dimgray: '#696969', 48 | dimgrey: '#696969', 49 | dodgerblue: '#1E90FF', 50 | firebrick: '#B22222', 51 | floralwhite: '#FFFAF0', 52 | forestgreen: '#228B22', 53 | fuchsia: '#FF00FF', 54 | gainsboro: '#DCDCDC', 55 | ghostwhite: '#F8F8FF', 56 | goldenrod: '#DAA520', 57 | gold: '#FFD700', 58 | gray: '#808080', 59 | green: '#008000', 60 | greenyellow: '#ADFF2F', 61 | grey: '#808080', 62 | honeydew: '#F0FFF0', 63 | hotpink: '#FF69B4', 64 | indianred: '#CD5C5C', 65 | indigo: '#4B0082', 66 | ivory: '#FFFFF0', 67 | khaki: '#F0E68C', 68 | lavenderblush: '#FFF0F5', 69 | lavender: '#E6E6FA', 70 | lawngreen: '#7CFC00', 71 | lemonchiffon: '#FFFACD', 72 | lightblue: '#ADD8E6', 73 | lightcoral: '#F08080', 74 | lightcyan: '#E0FFFF', 75 | lightgoldenrodyellow: '#FAFAD2', 76 | lightgray: '#D3D3D3', 77 | lightgreen: '#90EE90', 78 | lightgrey: '#D3D3D3', 79 | lightpink: '#FFB6C1', 80 | lightsalmon: '#FFA07A', 81 | lightseagreen: '#20B2AA', 82 | lightskyblue: '#87CEFA', 83 | lightslategray: '#778899', 84 | lightslategrey: '#778899', 85 | lightsteelblue: '#B0C4DE', 86 | lightyellow: '#FFFFE0', 87 | lime: '#00FF00', 88 | limegreen: '#32CD32', 89 | linen: '#FAF0E6', 90 | magenta: '#FF00FF', 91 | maroon: '#800000', 92 | mediumaquamarine: '#66CDAA', 93 | mediumblue: '#0000CD', 94 | mediumorchid: '#BA55D3', 95 | mediumpurple: '#9370DB', 96 | mediumseagreen: '#3CB371', 97 | mediumslateblue: '#7B68EE', 98 | mediumspringgreen: '#00FA9A', 99 | mediumturquoise: '#48D1CC', 100 | mediumvioletred: '#C71585', 101 | midnightblue: '#191970', 102 | mintcream: '#F5FFFA', 103 | mistyrose: '#FFE4E1', 104 | moccasin: '#FFE4B5', 105 | navajowhite: '#FFDEAD', 106 | navy: '#000080', 107 | oldlace: '#FDF5E6', 108 | olive: '#808000', 109 | olivedrab: '#6B8E23', 110 | orange: '#FFA500', 111 | orangered: '#FF4500', 112 | orchid: '#DA70D6', 113 | palegoldenrod: '#EEE8AA', 114 | palegreen: '#98FB98', 115 | paleturquoise: '#AFEEEE', 116 | palevioletred: '#DB7093', 117 | papayawhip: '#FFEFD5', 118 | peachpuff: '#FFDAB9', 119 | peru: '#CD853F', 120 | pink: '#FFC0CB', 121 | plum: '#DDA0DD', 122 | powderblue: '#B0E0E6', 123 | purple: '#800080', 124 | rebeccapurple: '#663399', 125 | red: '#FF0000', 126 | rosybrown: '#BC8F8F', 127 | royalblue: '#4169E1', 128 | saddlebrown: '#8B4513', 129 | salmon: '#FA8072', 130 | sandybrown: '#F4A460', 131 | seagreen: '#2E8B57', 132 | seashell: '#FFF5EE', 133 | sienna: '#A0522D', 134 | silver: '#C0C0C0', 135 | skyblue: '#87CEEB', 136 | slateblue: '#6A5ACD', 137 | slategray: '#708090', 138 | slategrey: '#708090', 139 | snow: '#FFFAFA', 140 | springgreen: '#00FF7F', 141 | steelblue: '#4682B4', 142 | tan: '#D2B48C', 143 | teal: '#008080', 144 | thistle: '#D8BFD8', 145 | tomato: '#FF6347', 146 | turquoise: '#40E0D0', 147 | violet: '#EE82EE', 148 | wheat: '#F5DEB3', 149 | white: '#FFFFFF', 150 | whitesmoke: '#F5F5F5', 151 | yellow: '#FFFF00', 152 | yellowgreen: '#9ACD32', 153 | } 154 | 155 | /** 156 | * Looks up the hex value of a given color name 157 | * 158 | * @param colorName - CSS color name 159 | * @returns color hex string (or `undefined` if invalid color name) 160 | */ 161 | export default function named(colorName: string): HexString | undefined { 162 | return namedColors[colorName.toLowerCase()] 163 | } 164 | -------------------------------------------------------------------------------- /src/color/parsers/_tests_/hexString.spec.ts: -------------------------------------------------------------------------------- 1 | import { isHexString, matchHexString } from '../hexString' 2 | 3 | describe('isHexString', () => { 4 | const valid = [ 5 | '#33FFAA', // uppercase hex digits 6 | '#ff33aa', // lowercase 7 | '#FFaa33', // mixed uppercase and lowercase 8 | '#33FFAABB', // with alpha 9 | '#33ffaabb', // lowercase with alpha 10 | '#3FA', // shorthand (3-digit) 11 | '#3fab', // shorthand with alpha 12 | ] 13 | valid.forEach(str => { 14 | it(`returns true for valid hex string: ${str}`, () => { 15 | expect(isHexString(str)).toBe(true) 16 | }) 17 | }) 18 | 19 | const invalid = [ 20 | 'rgb(127, 255, 255)', // non-hex color string 21 | 'blue', // color name 22 | 'BBAADD', // missing # prefix 23 | ' #BBAADD ', // untrimmed spaces 24 | '#BB AA DD', // internal spacing 25 | 'rainbow', // invalid color string 26 | ] 27 | invalid.forEach(str => { 28 | it(`returns false for invalid hex string: ${str}`, () => { 29 | expect(isHexString(str)).toBe(false) 30 | }) 31 | }) 32 | }) 33 | 34 | describe('matchHexString', () => { 35 | it('returns the RGB and alpha (opacity) hex values in an array', () => { 36 | const str = '#FFAACCEE' 37 | expect(matchHexString(str)).toEqual(['FF', 'AA', 'CC', 'EE']) 38 | }) 39 | 40 | it('returns only a 3-item array if color has no alpha value', () => { 41 | const str = '#ffaabb' 42 | expect(matchHexString(str)).toEqual(['ff', 'aa', 'bb']) 43 | }) 44 | 45 | it('captures shorthand RGB and alpha hex values', () => { 46 | const str = '#FACE' 47 | expect(matchHexString(str)).toEqual(['F', 'A', 'C', 'E']) 48 | }) 49 | 50 | it('returns null if string is not a valid hex string', () => { 51 | const str = 'rgb(127, 255, 64)' 52 | expect(matchHexString(str)).toBeNull() 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/color/parsers/_tests_/hslString.spec.ts: -------------------------------------------------------------------------------- 1 | import { isHslString, matchHslString } from '../hslString' 2 | 3 | describe('isHslString', () => { 4 | const valid = [ 5 | 'hsl(240, 100%, 50%)', // comma separated 6 | 'hsl(240, 100%, 50%, 0.1)', // comma separated with opacity 7 | 'hsl(240, 100%, 50%, 10%)', // % opacity 8 | 'hsl(240,100%,50%,0.1)', // comma separated without spaces 9 | 'hsl(180deg, 100%, 50%, 0.1)', // hue with 'deg' 10 | 'hsl(3.14rad, 100%, 50%, 0.1)', // hue with 'rad' 11 | 'hsl(200grad, 100%, 50%, 0.1)', // hue with 'grad' 12 | 'hsl(0.5turn, 100%, 50%, 0.1)', // hue with 'turn' 13 | 'hsl(480, 100.5%, -50%, 10)', // out of range values 14 | 'hsl(240 100% 50%)', // space separated (CSS Color Level 4) 15 | 'hsl(240 100% 50% / 0.1)', // space separated with opacity 16 | 'hsl(240 100% 50% / 10%)', // space separated with % opacity 17 | 'hsl(240deg 100% 50% / 0.1)', // space separated with hue unit 18 | 'hsl(240 100% 50%/0.1)', // no spaces around slash 19 | 'hsla(240, 100%, 50%)', // hsla() alias 20 | 'hsla(240, 100%, 50%, 0.1)', // hsla() with opacity 21 | 'HSL(240Deg, 100%, 50%)', // case insensitive 22 | ] 23 | valid.forEach(str => { 24 | it(`returns true for valid hsl string: ${str}`, () => { 25 | expect(isHslString(str)).toBe(true) 26 | }) 27 | }) 28 | 29 | const invalid = [ 30 | 'rgb(127, 255, 255)', // different color model 31 | '#88FFFF', // hex string 32 | 'blue', // color name 33 | 'hsl(240, 1, 0.5, 0.1)', // missing % sign 34 | ' hsl(240, 100%, 50%, 0.1) ', // untrimmed spaces 35 | 'hsl(240 100% 50% 0.1)', // missing slash in space separated alpha 36 | 'rainbow', // invalid color string 37 | ] 38 | invalid.forEach(str => { 39 | it(`returns false for invalid hsl string: ${str}`, () => { 40 | expect(isHslString(str)).toBe(false) 41 | }) 42 | }) 43 | }) 44 | 45 | describe('matchHslString', () => { 46 | it('returns HSL and alpha (opacity) values in an array', () => { 47 | const str = 'hsl(240, 100%, 50%, 0.1)' 48 | expect(matchHslString(str)).toEqual(['240', '100', '50', '0.1']) 49 | }) 50 | 51 | it('captures percentage opacity', () => { 52 | const str = 'hsl(240, 100%, 50%, 10%)' 53 | expect(matchHslString(str)).toEqual(['240', '100', '50', '10%']) 54 | }) 55 | 56 | const hueWithUnit = ['180deg', '3.14rad', '200grad', '0.5turn'] 57 | hueWithUnit.forEach(hue => { 58 | it(`captures hue angle with unit: ${hue}`, () => { 59 | const str = `hsl(${hue} 100% 50% / 0.1)` 60 | expect(matchHslString(str)).toEqual([hue, '100', '50', '0.1']) 61 | }) 62 | }) 63 | 64 | it('captures negative values', () => { 65 | const str = 'hsl(-240, -100%, -50%, -0.1)' 66 | expect(matchHslString(str)).toEqual(['-240', '-100', '-50', '-0.1']) 67 | }) 68 | 69 | it('returns only a 3-item array if color has no alpha value', () => { 70 | const str = 'hsl(240, 100%, 50%)' 71 | expect(matchHslString(str)).toEqual(['240', '100', '50']) 72 | }) 73 | 74 | it('returns null if string is not a valid hsl string', () => { 75 | const str = 'rgb(127, 255, 64)' 76 | expect(matchHslString(str)).toBeNull() 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/color/parsers/_tests_/rgbString.spec.ts: -------------------------------------------------------------------------------- 1 | import { isRgbString, matchRgbString } from '../rgbString' 2 | 3 | describe('isRgbString', () => { 4 | const valid = [ 5 | 'rgb(127, 255, 64)', // comma separated 6 | 'rgb(127, 255, 64, 0.1)', // comma separated with opacity 7 | 'rgb(127, 255, 64, 10%)', // % opacity 8 | 'rgb(50%, 100%, 25%, 0.1)', // % values 9 | 'rgb(240,255,64,0.1)', // comma separated without spaces 10 | 'rgb(320, 255.5, -64, 10)', // out of range values 11 | 'rgb(127 255 64)', // space separated (CSS Color Level 4) 12 | 'rgb(127 255 64 / 0.1)', // space separated with opacity 13 | 'rgb(127 255 64 / 10%)', // space separated % opacity 14 | 'rgb(50% 100% 25% / 0.1)', // space separated % values 15 | 'rgb(127 255 64/0.1)', // no spaces around slash 16 | 'rgba(127, 255, 64, 0.1)', // rgba() alias 17 | 'rgba(127, 255, 64)', // rgba() without opacity 18 | 'rgba(127 255 64 / 0.1)', // rgba() space separated 19 | 'RGB(127, 255, 64)', // case insensitive 20 | ] 21 | valid.forEach(str => { 22 | it(`returns true for valid rgb string: ${str}`, () => { 23 | expect(isRgbString(str)).toBe(true) 24 | }) 25 | }) 26 | 27 | const invalid = [ 28 | 'hsl(240, 100%, 50%)', // different color model 29 | '#88FFFF', // hex string 30 | 'blue', // color name 31 | ' rgb(127, 255, 64, 0.1) ', // untrimmed spaces 32 | 'rgb(127 255 64 0.1)', // missing slash in space separated alpha 33 | 'rainbow', // invalid color string 34 | ] 35 | invalid.forEach(str => { 36 | it(`returns false for invalid rgb string: ${str}`, () => { 37 | expect(isRgbString(str)).toBe(false) 38 | }) 39 | }) 40 | }) 41 | 42 | describe('matchRgbString', () => { 43 | it('returns RGB and alpha (opacity) values in an array', () => { 44 | const str = 'rgb(127, 255, 64, 0.1)' 45 | expect(matchRgbString(str)).toEqual(['127', '255', '64', '0.1']) 46 | }) 47 | 48 | it('captures percentage opacity', () => { 49 | const str = 'rgb(127, 255, 64, 100%)' 50 | expect(matchRgbString(str)).toEqual(['127', '255', '64', '100%']) 51 | }) 52 | 53 | it('captures percentage values', () => { 54 | const str = 'rgb(50%, 100%, 25%, 0.1)' 55 | expect(matchRgbString(str)).toEqual(['50%', '100%', '25%', '0.1']) 56 | }) 57 | 58 | it('captures negative values', () => { 59 | const str = 'rgb(-127, -255, -64, -0.1)' 60 | expect(matchRgbString(str)).toEqual(['-127', '-255', '-64', '-0.1']) 61 | }) 62 | 63 | it('returns only a 3-item array if color has no alpha value', () => { 64 | const str = 'rgb(127, 255, 64)' 65 | expect(matchRgbString(str)).toEqual(['127', '255', '64']) 66 | }) 67 | 68 | it('returns null if string is not a valid rgb string', () => { 69 | const str = 'hsl(240 100% 50%)' 70 | expect(matchRgbString(str)).toBeNull() 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/color/parsers/_tests_/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alphaSeparatorMatcher, 3 | cssNumberMatcher, 4 | exact, 5 | extractValuesFromMatch, 6 | separatorMatcher, 7 | } from '../utils' 8 | 9 | describe('exact', () => { 10 | it('returns a new regex that is restricted to exact matches only', () => { 11 | const regex = /[0-9]+/ 12 | const exactRegex = exact(regex) 13 | expect(regex.test(' 123 ')).toBe(true) 14 | expect(exactRegex.test(' 123 ')).toBe(false) 15 | expect(exactRegex.test('123')).toBe(true) 16 | }) 17 | 18 | it('retains the original flags (if any)', () => { 19 | const regex = /[a-z]+/i 20 | expect(exact(regex).test('Abc')).toBe(true) 21 | }) 22 | }) 23 | 24 | describe('extractValuesFromMatch', () => { 25 | it('returns an array containing only the color components', () => { 26 | const match = ['#ffaaddee', 'ff', 'aa', 'dd', 'ee'] as RegExpExecArray 27 | expect(extractValuesFromMatch(match)).toEqual(['ff', 'aa', 'dd', 'ee']) 28 | }) 29 | 30 | it('removes undefined items', () => { 31 | const match = ['#ffaadd', 'ff', 'aa', 'dd', undefined] as RegExpExecArray 32 | expect(extractValuesFromMatch(match)).toEqual(['ff', 'aa', 'dd']) 33 | }) 34 | }) 35 | 36 | describe('cssNumberMatcher', () => { 37 | const matcher = exact(cssNumberMatcher) 38 | 39 | const valid = [ 40 | '255', // integer 41 | '4.5', // non-integer 42 | '0.1', // fraction 43 | '.1', // fraction with no leading zero 44 | '007', // leading zeros 45 | '1.000', // trailing decimal zeros 46 | '-255', // negative 47 | '+255', // explicit positive sign 48 | '1.28e+2', // scientific notation 49 | '1.28E-2', // uppercase scientific notation 50 | '-01.2800e+02', // combination of above 51 | ] 52 | valid.forEach(str => { 53 | it(`tests true for exact match with valid CSS number string: ${str}`, () => { 54 | expect(matcher.test(str)).toBe(true) 55 | }) 56 | }) 57 | 58 | const invalid = [ 59 | '1,000', // comma 60 | '1.', // missing digit following decimal point 61 | '1.0.0', // excess decimal points 62 | '1_000', // numeric separator 63 | 'FF', // hexadecimal 64 | 'foo', // totally not a number 65 | ] 66 | invalid.forEach(str => { 67 | it(`tests false for exact match with invalid CSS number string: ${str}`, () => { 68 | expect(matcher.test(str)).toBe(false) 69 | }) 70 | }) 71 | }) 72 | 73 | describe('separatorMatcher', () => { 74 | const matcher = exact(separatorMatcher) 75 | 76 | const valid = [',', ' ,', ', ', ' , ', ' '] 77 | valid.forEach(str => { 78 | it(`tests true for exact match with valid separator: '${str}'`, () => { 79 | expect(matcher.test(str)).toBe(true) 80 | }) 81 | }) 82 | 83 | const invalid = [',,', ', ,', ', , ', '/'] 84 | invalid.forEach(str => { 85 | it(`tests false for exact match with invalid separator: '${str}'`, () => { 86 | expect(matcher.test(str)).toBe(false) 87 | }) 88 | }) 89 | }) 90 | 91 | describe('alphaSeparatorMatcher', () => { 92 | const matcher = exact(alphaSeparatorMatcher) 93 | 94 | const valid = [',', ', ', ' ,', ' , ', '/', '/ ', ' /', ' / '] 95 | valid.forEach(str => { 96 | it(`tests true for exact match with valid separator: '${str}'`, () => { 97 | expect(matcher.test(str)).toBe(true) 98 | }) 99 | }) 100 | 101 | const invalid = [',,', ', ,', ', , ', ',/', ', /', ',/ ', ', / '] 102 | invalid.forEach(str => { 103 | it(`tests false for exact match with invalid separator: '${str}'`, () => { 104 | expect(matcher.test(str)).toBe(false) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /src/color/parsers/hexString.ts: -------------------------------------------------------------------------------- 1 | import { exact, extractValuesFromMatch } from './utils' 2 | 3 | /** Color string in `#rrggbb(aa)` or `#rgb(a)` hex format */ 4 | export type HexString = `#${string}` 5 | 6 | const hex = /[0-9a-fA-F]/.source 7 | 8 | /** Regular expression for hex color string */ 9 | export const hexColorMatcher = new RegExp( 10 | `#(${hex}{2})(${hex}{2})(${hex}{2})(${hex}{2})?`, 11 | ) 12 | 13 | /** Regular expression for shorthand hex color string */ 14 | export const shortHexColorMatcher = new RegExp( 15 | `#(${hex})(${hex})(${hex})(${hex})?`, 16 | ) 17 | 18 | /** 19 | * Checks if a given string is a valid hex color string 20 | * 21 | * @param colorString 22 | * @returns true/false (type predicate for `HexString` in TS) 23 | */ 24 | export function isHexString(colorString: string): colorString is HexString { 25 | return ( 26 | exact(hexColorMatcher).test(colorString) || 27 | exact(shortHexColorMatcher).test(colorString) 28 | ) 29 | } 30 | 31 | /** 32 | * Attempts to match the given color string with the hex 33 | * string pattern, and extracts the hex values of the RGB 34 | * color components (and alpha, if any) 35 | * 36 | * @param colorString 37 | * @returns an array containing the matched values, or `null` 38 | */ 39 | export function matchHexString(colorString: string): string[] | null { 40 | const match = 41 | exact(hexColorMatcher).exec(colorString) ?? 42 | exact(shortHexColorMatcher).exec(colorString) 43 | return match ? extractValuesFromMatch(match) : null 44 | } 45 | -------------------------------------------------------------------------------- /src/color/parsers/hslString.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alphaSeparatorMatcher, 3 | cssNumberMatcher, 4 | exact, 5 | extractValuesFromMatch, 6 | separatorMatcher, 7 | } from './utils' 8 | 9 | /** Color string in `hsl()` or `hsla()` format */ 10 | export type HslString = `hsl(${string})` | `hsla(${string})` 11 | 12 | const num = cssNumberMatcher.source 13 | const sep = separatorMatcher.source 14 | const asep = alphaSeparatorMatcher.source 15 | 16 | /** 17 | * Regular expression for HSL color string 18 | * 19 | * The pattern is less strict than actual CSS, mainly for 20 | * performance reasons. Notably, it does NOT impose 21 | * consistent separator (comma vs. space). 22 | */ 23 | export const hslMatcher = new RegExp( 24 | `hsla?\\(\\s*(${num}(?:deg|rad|grad|turn)?)${sep}(${num})%${sep}(${num})%(?:${asep}(${num}%?))?\\s*\\)`, 25 | 'i', 26 | ) 27 | 28 | /** 29 | * Checks if a given string is a valid HSL color string 30 | * 31 | * @param colorString 32 | * @returns true/false (type predicate for `HslString` in TS) 33 | */ 34 | export function isHslString(colorString: string): colorString is HslString { 35 | return exact(hslMatcher).test(colorString) 36 | } 37 | 38 | /** 39 | * Attempts to match the given color string with the HSL 40 | * string pattern, and extracts the color components 41 | * 42 | * Since the standard unit for S and L values is percent, 43 | * the % sign is not included in the captured values. 44 | * 45 | * @param colorString 46 | * @returns an array containing the matched HSL values, or `null` 47 | */ 48 | export function matchHslString(colorString: string): string[] | null { 49 | const match = exact(hslMatcher).exec(colorString) 50 | return match ? extractValuesFromMatch(match) : null 51 | } 52 | -------------------------------------------------------------------------------- /src/color/parsers/rgbString.ts: -------------------------------------------------------------------------------- 1 | import { 2 | alphaSeparatorMatcher, 3 | cssNumberMatcher, 4 | exact, 5 | extractValuesFromMatch, 6 | separatorMatcher, 7 | } from './utils' 8 | 9 | /** Color string in `rgb()` or `rgba()` format */ 10 | export type RgbString = `rgb(${string})` | `rgba(${string})` 11 | 12 | const num = cssNumberMatcher.source 13 | const sep = separatorMatcher.source 14 | const asep = alphaSeparatorMatcher.source 15 | 16 | /** 17 | * Regular expression for RGB color string 18 | * 19 | * The pattern is less strict than actual CSS, mainly for 20 | * performance reasons. Notably, it does NOT impose: 21 | * - consistent separator (comma vs. space) 22 | * - consistent unit of color components (number value vs. percentage) 23 | */ 24 | export const rgbMatcher = new RegExp( 25 | `rgba?\\(\\s*(${num}%?)${sep}(${num}%?)${sep}(${num}%?)(?:${asep}(${num}%?))?\\s*\\)`, 26 | 'i', 27 | ) 28 | 29 | /** 30 | * Checks if a given string is a valid RGB color string 31 | * 32 | * @param colorString 33 | * @returns true/false (type predicate for `RgbString` in TS) 34 | */ 35 | export function isRgbString(colorString: string): colorString is RgbString { 36 | return exact(rgbMatcher).test(colorString) 37 | } 38 | 39 | /** 40 | * Attempts to match the given color string with the RGB 41 | * string pattern, and extracts the color components 42 | * 43 | * @param colorString 44 | * @returns an array containing the matched RGB values, or `null` 45 | */ 46 | export function matchRgbString(colorString: string): string[] | null { 47 | const match = exact(rgbMatcher).exec(colorString) 48 | return match ? extractValuesFromMatch(match) : null 49 | } 50 | -------------------------------------------------------------------------------- /src/color/parsers/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a modified version of the regular expression 3 | * that is restricted to exact matches only 4 | * 5 | * @param regex 6 | * @returns modified regex including original flags (if any) 7 | */ 8 | export function exact(regex: RegExp): RegExp { 9 | return new RegExp(`^${regex.source}$`, regex.flags) 10 | } 11 | 12 | /** 13 | * Extracts individual color components from a color string 14 | * match array 15 | * 16 | * @param match 17 | * @returns a string array containing the extracted values 18 | */ 19 | export function extractValuesFromMatch(match: RegExpExecArray): string[] { 20 | return match 21 | .slice(1) // get only the values from regex capturing groups 22 | .filter(val => val !== undefined) // remove undefined items (e.g. alpha) 23 | } 24 | 25 | /** Regular expression for valid CSS number */ 26 | export const cssNumberMatcher = /[+-]?(?=\.\d|\d)\d*(?:\.\d+)?(?:[eE][+-]?\d+)?/ 27 | 28 | /** Regular expression for color component separator */ 29 | export const separatorMatcher = /(?=[,\s])\s*(?:,\s*)?/ 30 | 31 | /** Regular expression for alpha separator */ 32 | export const alphaSeparatorMatcher = /\s*[,\/]\s*/ 33 | -------------------------------------------------------------------------------- /src/color/rgb.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '../utils' 2 | import { hslFromColorString } from './hsl' 3 | import named from './named' 4 | import { matchHexString } from './parsers/hexString' 5 | import { matchRgbString } from './parsers/rgbString' 6 | import hslToRgb from './transforms/hslToRgb' 7 | 8 | /** Object model of a color in the RGB space */ 9 | export interface RGB { 10 | r: number 11 | g: number 12 | b: number 13 | a: number 14 | } 15 | 16 | /** 17 | * Checks if the given value is an RGB object 18 | * 19 | * @param color - value to inspect 20 | * @returns true/false (type predicate for `RGB` in TS) 21 | */ 22 | export function isRgb(color: any): color is RGB { 23 | return ( 24 | typeof color.r === 'number' && 25 | typeof color.g === 'number' && 26 | typeof color.b === 'number' && 27 | typeof color.a === 'number' 28 | ) 29 | } 30 | 31 | /** 32 | * Normalizes the color component values of an RGB object 33 | * to range [0..255] for r,g,b and [0..1] for alpha 34 | * 35 | * @param rgb - RGB object 36 | * @returns a new RGB object with the normalized values 37 | */ 38 | export function normalizeRgb(rgb: RGB): RGB { 39 | return { 40 | r: clamp(rgb.r, 0, 255), 41 | g: clamp(rgb.g, 0, 255), 42 | b: clamp(rgb.b, 0, 255), 43 | a: clamp(rgb.a, 0, 1), 44 | } 45 | } 46 | 47 | function rgbFromParsedHexString(match: string[]): RGB { 48 | const rgbValues = match.map(val => { 49 | // Expand if value is shorthand (single digit) hex 50 | if (val.length === 1) { 51 | val = `${val}${val}` 52 | } 53 | // Convert hex to decimal 54 | return parseInt(val, 16) 55 | }) 56 | 57 | // Compute alpha as fraction of 255, defaulting to 1 58 | const alpha = (rgbValues[3] ?? 255) / 255 59 | 60 | return { r: rgbValues[0], g: rgbValues[1], b: rgbValues[2], a: alpha } 61 | } 62 | 63 | function rgbFromParsedRgbString(match: string[]): RGB { 64 | const rgbValues = match.map((val, index) => { 65 | let num = parseFloat(val) 66 | if (val.indexOf('%') > -1) { 67 | num *= 0.01 68 | // Except for alpha, value should equal % of 255 69 | if (index < 3) { 70 | num *= 255 71 | } 72 | } 73 | return num 74 | }) 75 | 76 | return normalizeRgb({ 77 | r: rgbValues[0], 78 | g: rgbValues[1], 79 | b: rgbValues[2], 80 | a: rgbValues[3] ?? 1, 81 | }) 82 | } 83 | 84 | function rgbFromHslString(colorString: string): RGB | null { 85 | const hslColor = hslFromColorString(colorString) 86 | return hslColor ? hslToRgb(hslColor) : null 87 | } 88 | 89 | /** 90 | * Creates an RGB model from a given RGB-based color string 91 | * 92 | * @param colorString - CSS color string 93 | * @returns an `{r,g,b,a}` color object (or `null` if invalid color string) 94 | */ 95 | export function rgbFromColorString(colorString: string): RGB | null { 96 | colorString = colorString.trim() 97 | 98 | if (colorString.toLowerCase() === 'transparent') 99 | return { r: 0, g: 0, b: 0, a: 0 } 100 | 101 | // Get hex value if string is a color name 102 | const hexFromName = named(colorString) 103 | if (hexFromName) { 104 | colorString = hexFromName 105 | } 106 | 107 | let match: string[] | null 108 | if ((match = matchHexString(colorString)) !== null) 109 | return rgbFromParsedHexString(match) 110 | else if ((match = matchRgbString(colorString)) !== null) 111 | return rgbFromParsedRgbString(match) 112 | 113 | return null 114 | } 115 | 116 | /** 117 | * Creates an RGB model from a given color string 118 | * 119 | * @param colorString - CSS color string 120 | * @returns an `{r,g,b,a}` color object (or `null` if invalid color string) 121 | * @throws if argument is not a valid color string 122 | */ 123 | export default function rgb(colorString: string): RGB { 124 | const rgbObj = 125 | rgbFromColorString(colorString) ?? rgbFromHslString(colorString) 126 | 127 | if (rgbObj === null) throw new Error('Invalid color string') 128 | 129 | return rgbObj 130 | } 131 | -------------------------------------------------------------------------------- /src/color/transforms/__tests__/hslToRgb.spec.ts: -------------------------------------------------------------------------------- 1 | import hslToRgb from '../hslToRgb' 2 | 3 | describe('hslToRgb', () => { 4 | const hsl = { 5 | red: { h: 0, s: 100, l: 50, a: 1 }, 6 | green: { h: 120, s: 100, l: 50, a: 1 }, 7 | blue: { h: 240, s: 100, l: 50, a: 1 }, 8 | white: { h: NaN, s: 0, l: 100, a: 1 }, 9 | black: { h: NaN, s: 0, l: 0, a: 1 }, 10 | gray: { h: NaN, s: 0, l: 50, a: 1 }, 11 | bluish: { h: 240, s: 30, l: 40, a: 1 }, 12 | } 13 | 14 | it('converts {h,s,l} object to {r,g,b}', () => { 15 | expect(hslToRgb(hsl.red)).toEqual({ r: 255, g: 0, b: 0, a: 1 }) 16 | expect(hslToRgb(hsl.green)).toEqual({ r: 0, g: 255, b: 0, a: 1 }) 17 | expect(hslToRgb(hsl.blue)).toEqual({ r: 0, g: 0, b: 255, a: 1 }) 18 | expect(hslToRgb(hsl.bluish)).toEqual({ r: 71.4, g: 71.4, b: 132.6, a: 1 }) 19 | }) 20 | 21 | it('recognizes a hue value of NaN (to mean "powerless") in grayscale', () => { 22 | expect(hslToRgb(hsl.white)).toEqual({ r: 255, g: 255, b: 255, a: 1 }) 23 | expect(hslToRgb(hsl.black)).toEqual({ r: 0, g: 0, b: 0, a: 1 }) 24 | expect(hslToRgb(hsl.gray)).toEqual({ r: 127.5, g: 127.5, b: 127.5, a: 1 }) 25 | }) 26 | 27 | it('carries over any alpha value', () => { 28 | expect(hslToRgb({ h: 240, s: 100, l: 50, a: 0.8 })).toHaveProperty('a', 0.8) 29 | }) 30 | 31 | it('normalizes out-of-range h,s,l,a values', () => { 32 | const belowMin = { h: -120, s: -1, l: -1, a: -1 } 33 | const aboveMax = { h: 360, s: 101, l: 101, a: 10 } 34 | expect(hslToRgb(belowMin)).toEqual({ r: 0, g: 0, b: 0, a: 0 }) 35 | expect(hslToRgb(aboveMax)).toEqual({ r: 255, g: 255, b: 255, a: 1 }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/color/transforms/__tests__/rgbToHsl.spec.ts: -------------------------------------------------------------------------------- 1 | import rgbToHsl from '../rgbToHsl' 2 | 3 | describe('rgbToHsl', () => { 4 | const rgb = { 5 | red: { r: 255, g: 0, b: 0, a: 1 }, 6 | green: { r: 0, g: 255, b: 0, a: 1 }, 7 | blue: { r: 0, g: 0, b: 255, a: 1 }, 8 | white: { r: 255, g: 255, b: 255, a: 1 }, 9 | black: { r: 0, g: 0, b: 0, a: 1 }, 10 | gray: { r: 128, g: 128, b: 128, a: 1 }, 11 | palegreen: { r: 152, g: 251, b: 152, a: 1 }, 12 | } 13 | 14 | it('converts {r,g,b} object to {h,s,l}', () => { 15 | expect(rgbToHsl(rgb.red)).toEqual({ h: 0, s: 100, l: 50, a: 1 }) 16 | expect(rgbToHsl(rgb.green)).toEqual({ h: 120, s: 100, l: 50, a: 1 }) 17 | expect(rgbToHsl(rgb.blue)).toEqual({ h: 240, s: 100, l: 50, a: 1 }) 18 | }) 19 | 20 | it('assigns a hue value of NaN (to mean "powerless") in grayscale', () => { 21 | expect(rgbToHsl(rgb.white).h).toBeNaN() 22 | expect(rgbToHsl(rgb.black).h).toBeNaN() 23 | expect(rgbToHsl(rgb.gray).h).toBeNaN() 24 | }) 25 | 26 | it('does NOT round off saturation and lightness into integer', () => { 27 | const paleGreen = rgbToHsl(rgb.palegreen) 28 | expect(paleGreen.h).toBe(120) 29 | expect(paleGreen.s).toBeCloseTo(92.52) 30 | expect(paleGreen.l).toBeCloseTo(79.02) 31 | }) 32 | 33 | it('carries over any alpha value', () => { 34 | expect(rgbToHsl({ r: 255, g: 0, b: 128, a: 0.8 })).toHaveProperty('a', 0.8) 35 | }) 36 | 37 | it('clamps r,g,b values to [0..255]', () => { 38 | const hsl = rgbToHsl({ r: 256, g: -1, b: -0.5, a: 1 }) 39 | expect(hsl).toEqual({ h: 0, s: 100, l: 50, a: 1 }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/color/transforms/hslToRgb.ts: -------------------------------------------------------------------------------- 1 | import { normalizeHsl } from '../hsl' 2 | 3 | import type { HSL } from '../hsl' 4 | import type { RGB } from '../rgb' 5 | 6 | /** 7 | * Converts from HSL to RGB color model 8 | * 9 | * This implementation is based on official algorithm: 10 | * https://drafts.csswg.org/css-color/#the-hsl-notation 11 | * 12 | * @param hsl - HSL color object 13 | * @returns the equivalent RGB color object 14 | */ 15 | export default function hslToRgb(hsl: HSL): RGB { 16 | const { h, s, l, a } = normalizeHsl(hsl) 17 | 18 | // If hue is NaN (i.e. grayscale), assign arbitrary value of 0 19 | const hue = h || 0 20 | const sat = s / 100 21 | const light = l / 100 22 | 23 | function f(n: number) { 24 | const k = (n + hue / 30) % 12 25 | const a = sat * Math.min(light, 1 - light) 26 | return light - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)) 27 | } 28 | 29 | return { r: f(0) * 255, g: f(8) * 255, b: f(4) * 255, a } 30 | } 31 | -------------------------------------------------------------------------------- /src/color/transforms/rgbToHsl.ts: -------------------------------------------------------------------------------- 1 | import { normalizeRgb, RGB } from '../rgb' 2 | 3 | import type { HSL } from '../hsl' 4 | 5 | /** 6 | * Converts from RGB to HSL color model 7 | * 8 | * This implementation is based on official algorithm: 9 | * https://drafts.csswg.org/css-color/#the-hsl-notation 10 | * 11 | * @param rgb - RGB color object 12 | * @returns the equivalent HSL color object 13 | */ 14 | export default function rgbToHsl(rgb: RGB): HSL { 15 | const { r, g, b, a } = normalizeRgb(rgb) 16 | 17 | const red = r / 255 18 | const green = g / 255 19 | const blue = b / 255 20 | 21 | const max = Math.max(red, green, blue) 22 | const min = Math.min(red, green, blue) 23 | const d = max - min 24 | const light = (min + max) / 2 25 | 26 | let hue = NaN // "powerless" h-value is represented here as NaN 27 | let sat = 0 28 | 29 | if (d !== 0) { 30 | // Improbable scenario from official algo removed: 31 | // (light === 0 || light === 1) is never true when d !== 0 32 | // and r,g,b values are properly clamped 33 | sat = (max - light) / Math.min(light, 1 - light) 34 | 35 | switch (max) { 36 | case red: 37 | hue = (green - blue) / d + (green < blue ? 6 : 0) 38 | break 39 | case green: 40 | hue = (blue - red) / d + 2 41 | break 42 | case blue: 43 | hue = (red - green) / d + 4 44 | } 45 | hue *= 60 46 | } 47 | 48 | return { h: hue, s: sat * 100, l: light * 100, a } 49 | } 50 | -------------------------------------------------------------------------------- /src/colorScheme.ts: -------------------------------------------------------------------------------- 1 | import colorSet from './colorSet' 2 | 3 | import type { BaseColors, ColorSet } from './colorSet' 4 | import type { ColorMapping } from './palette' 5 | import type { StringOrNumber } from './utils' 6 | 7 | /** Function that maps color roles to specific colors from palettes */ 8 | export type ColorRoleMapping< 9 | P extends string, 10 | K extends StringOrNumber, 11 | R extends string, 12 | > = (colors: ColorSet) => ColorScheme 13 | 14 | /** Optional configuration for color scheme */ 15 | export interface ColorSchemeOptions { 16 | /** Custom function for generating palette colors */ 17 | colorMapping?: ColorMapping 18 | /** Disables caching of color mapping */ 19 | noCache?: boolean 20 | } 21 | 22 | /** Mapping of color roles to specific colors */ 23 | export type ColorScheme = Record 24 | 25 | /** 26 | * Builds a color scheme by creating palettes from the 27 | * given base colors, and mapping color roles to specific 28 | * palette colors 29 | * 30 | * @param baseColors - list of base colors keyed by palette name 31 | * @param roleMapping - function that maps roles to colors 32 | * @param options - optional config for creating palettes 33 | * @returns color scheme object 34 | */ 35 | export default function colorScheme< 36 | P extends string, 37 | K extends StringOrNumber, 38 | R extends string, 39 | >( 40 | baseColors: BaseColors

, 41 | roleMapping: ColorRoleMapping, 42 | options?: ColorSchemeOptions, 43 | ): ColorScheme { 44 | const { colorMapping, noCache } = options ?? {} 45 | const colors = colorSet(baseColors, colorMapping, noCache) 46 | 47 | return roleMapping(colors) 48 | } 49 | -------------------------------------------------------------------------------- /src/colorSet.ts: -------------------------------------------------------------------------------- 1 | import palette from './palette' 2 | 3 | import type { ColorMapping, Palette } from './palette' 4 | import type { StringOrNumber } from './utils' 5 | 6 | /** Base colors for palettes */ 7 | export type BaseColors

= Record 8 | 9 | /** Keyed list of color palettes */ 10 | export type ColorSet< 11 | P extends string = string, 12 | K extends StringOrNumber = StringOrNumber, 13 | > = Record> 14 | 15 | /** 16 | * Creates color palettes from their corresponding base 17 | * colors 18 | * 19 | * @param baseColors - keyed list of base colors 20 | * @param colorMapping - function for generating the colors 21 | * @param noCache - option to disable caching of color mapping 22 | * @returns a keyed list of palettes 23 | */ 24 | export default function colorSet

( 25 | baseColors: BaseColors

, 26 | colorMapping?: ColorMapping, 27 | noCache?: boolean, 28 | ): ColorSet { 29 | const colorClasses = Object.keys(baseColors) as P[] 30 | 31 | return colorClasses.reduce>>( 32 | (palettes, colorClass) => { 33 | return { 34 | ...palettes, 35 | [colorClass]: palette(baseColors[colorClass], colorMapping, noCache), 36 | } 37 | }, 38 | {}, 39 | ) as ColorSet 40 | } 41 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as colorScheme } from './colorScheme' 2 | export { default as colorSet } from './colorSet' 3 | export { default as normalize } from './normalize' 4 | export { default as palette } from './palette' 5 | 6 | export { default as analogue } from './mappings/analogue' 7 | export { default as complement } from './mappings/complement' 8 | export { default as lightness } from './mappings/lightness' 9 | export { default as opacity } from './mappings/opacity' 10 | export { default as rotation } from './mappings/rotation' 11 | export { default as saturation } from './mappings/saturation' 12 | export { default as triad } from './mappings/triad' 13 | 14 | export { default as harmony } from './presets/harmony' 15 | 16 | export { default as hsl, isHsl } from './color/hsl' 17 | export { default as rgb, isRgb } from './color/rgb' 18 | export { default as hslToRgb } from './color/transforms/hslToRgb' 19 | export { default as rgbToHsl } from './color/transforms/rgbToHsl' 20 | 21 | export type { 22 | ColorRoleMapping, 23 | ColorScheme, 24 | ColorSchemeOptions, 25 | } from './colorScheme' 26 | export type { BaseColors, ColorSet } from './colorSet' 27 | export type { ColorMapping, Palette } from './palette' 28 | export type { HarmonyBaseColors, HarmonyColors } from './presets/harmony' 29 | export type { HSL } from './color/hsl' 30 | export type { RGB } from './color/rgb' 31 | -------------------------------------------------------------------------------- /src/mappings/__tests__/analogue.spec.ts: -------------------------------------------------------------------------------- 1 | import analogue from '../analogue' 2 | 3 | describe('analogue', () => { 4 | it('returns a new hex color value with hue rotated in steps of 30˚', () => { 5 | const color = analogue('blue', 1) 6 | expect(color).toBe('#8000FF') 7 | }) 8 | 9 | it('rotates the hue in the opposite direction if key is negative', () => { 10 | const color = analogue('blue', -1) 11 | expect(color).toBe('#0080FF') 12 | }) 13 | 14 | it('returns the base color if key is not a number', () => { 15 | const color = analogue('blue', 'foo') 16 | expect(color).toBe('blue') 17 | }) 18 | 19 | it('returns the base color if key is 0', () => { 20 | const color = analogue('blue', 0) 21 | expect(color).toBe('blue') 22 | }) 23 | 24 | it('throws if base color is invalid', () => { 25 | expect(() => { 26 | return analogue('bluish', 10) 27 | }).toThrow() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/mappings/__tests__/complement.spec.ts: -------------------------------------------------------------------------------- 1 | import complement from '../complement' 2 | 3 | describe('complement', () => { 4 | it('returns a new hex color value with hue rotated by 180˚ at index 1', () => { 5 | const color = complement('blue', 1) 6 | expect(color).toBe('#FFFF00') 7 | }) 8 | 9 | it('returns a new hex color value with hue rotated by 180˚ + 30˚ at index 2', () => { 10 | const color = complement('blue', 2) 11 | expect(color).toBe('#80FF00') 12 | }) 13 | 14 | it('rotates hue in the opposite direction if key is negative', () => { 15 | const color = complement('blue', -2) 16 | expect(color).toBe('#FF8000') 17 | }) 18 | 19 | it('returns the base color if key is not a number', () => { 20 | const color = complement('blue', 'foo') 21 | expect(color).toBe('blue') 22 | }) 23 | 24 | it('returns the base color if key is 0', () => { 25 | const color = complement('blue', 0) 26 | expect(color).toBe('blue') 27 | }) 28 | 29 | it('throws if base color is invalid', () => { 30 | expect(() => { 31 | return complement('bluish', 10) 32 | }).toThrow() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/mappings/__tests__/lightness.spec.ts: -------------------------------------------------------------------------------- 1 | import lightness from '../lightness' 2 | 3 | describe('lightness', () => { 4 | it('returns a new hex color value with adjusted % lightness', () => { 5 | const color = lightness('blue', 40) 6 | expect(color).toBe('#0000CC') 7 | }) 8 | 9 | it('returns the base color if key is not a number', () => { 10 | const color = lightness('blue', 'foo') 11 | expect(color).toBe('blue') 12 | }) 13 | 14 | it('sets the L value to 100% if key exceeds 100', () => { 15 | const color = lightness('blue', 150) 16 | expect(color).toBe('#FFFFFF') 17 | }) 18 | 19 | it('sets the L value to 0% if key is negative', () => { 20 | const color = lightness('blue', -50) 21 | expect(color).toBe('#000000') 22 | }) 23 | 24 | it('supports translucent base color', () => { 25 | const color = lightness('rgba(0, 0, 255, 0.5)', 40) 26 | expect(color).toBe('rgba(0, 0, 204, 0.5)') 27 | }) 28 | 29 | it('throws if base color is invalid', () => { 30 | expect(() => { 31 | return lightness('bluish', 10) 32 | }).toThrow() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/mappings/__tests__/opacity.spec.ts: -------------------------------------------------------------------------------- 1 | import opacity from '../opacity' 2 | 3 | describe('opacity', () => { 4 | it('returns an RGBA value with A equal to key', () => { 5 | const color = opacity('blue', 0.4) 6 | expect(color).toBe('rgba(0, 0, 255, 0.4)') 7 | }) 8 | 9 | it('returns the base color if key is not a number', () => { 10 | const color = opacity('blue', 'foo') 11 | expect(color).toBe('blue') 12 | }) 13 | 14 | it('discards the A value, and returns RGB value instead, if key equals or exceeds 1', () => { 15 | const color = opacity('blue', 40) 16 | expect(color).toBe('#0000FF') 17 | }) 18 | 19 | it('sets the A value to 0 if key is negative', () => { 20 | const color = opacity('blue', -1) 21 | expect(color).toBe('rgba(0, 0, 255, 0)') 22 | }) 23 | 24 | it('supports translucent base color', () => { 25 | const color = opacity('rgba(0, 0, 255, 0.5)', 0.1) 26 | expect(color).toBe('rgba(0, 0, 255, 0.1)') 27 | }) 28 | 29 | it('throws if base color is invalid', () => { 30 | expect(() => { 31 | return opacity('bluish', 0.4) 32 | }).toThrow() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/mappings/__tests__/rotation.spec.ts: -------------------------------------------------------------------------------- 1 | import rotation from '../rotation' 2 | 3 | describe('rotation', () => { 4 | it('returns a new hex color value with hue rotated by the specified angle', () => { 5 | const color = rotation('blue', 180) 6 | expect(color).toBe('#FFFF00') 7 | }) 8 | 9 | it('rotates the hue in the opposite direction if key is negative', () => { 10 | const color = rotation('blue', -90) 11 | expect(color).toBe('#00FF80') 12 | }) 13 | 14 | it('returns the base color if key is not a number', () => { 15 | const color = rotation('blue', 'foo') 16 | expect(color).toBe('blue') 17 | }) 18 | 19 | it('returns the base color if key is 0', () => { 20 | const color = rotation('blue', 0) 21 | expect(color).toBe('blue') 22 | }) 23 | 24 | it('supports translucent base color', () => { 25 | const color = rotation('rgba(0, 0, 255, 0.5)', 180) 26 | expect(color).toBe('rgba(255, 255, 0, 0.5)') 27 | }) 28 | 29 | it('throws if base color is invalid', () => { 30 | expect(() => { 31 | return rotation('bluish', 10) 32 | }).toThrow() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/mappings/__tests__/saturation.spec.ts: -------------------------------------------------------------------------------- 1 | import saturation from '../saturation' 2 | 3 | describe('saturation', () => { 4 | it('returns a new hex color value with adjusted % saturation', () => { 5 | const color = saturation('blue', 40) 6 | expect(color).toBe('#4D4DB3') 7 | }) 8 | 9 | it('returns the base color if key is not a number', () => { 10 | const color = saturation('blue', 'foo') 11 | expect(color).toBe('blue') 12 | }) 13 | 14 | it('sets the S value to 100% if key exceeds 100', () => { 15 | const color = saturation('blue', 150) 16 | expect(color).toBe('#0000FF') 17 | }) 18 | 19 | it('sets the S value to 0% if key is negative', () => { 20 | const color = saturation('blue', -50) 21 | expect(color).toBe('#808080') 22 | }) 23 | 24 | it('supports translucent base color', () => { 25 | const color = saturation('rgba(0, 0, 255, 0.5)', 40) 26 | expect(color).toBe('rgba(77, 77, 179, 0.5)') 27 | }) 28 | 29 | it('throws if base color is invalid', () => { 30 | expect(() => { 31 | return saturation('bluish', 10) 32 | }).toThrow() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/mappings/__tests__/triad.spec.ts: -------------------------------------------------------------------------------- 1 | import triad from '../triad' 2 | 3 | describe('triad', () => { 4 | it('returns a new hex color value with hue rotated in steps of 120˚', () => { 5 | const color = triad('blue', 1) 6 | expect(color).toBe('#FF0000') 7 | }) 8 | 9 | it('rotates the hue in the opposite direction if key is negative', () => { 10 | const color = triad('blue', -1) 11 | expect(color).toBe('#00FF00') 12 | }) 13 | 14 | it('returns the base color if key is not a number', () => { 15 | const color = triad('blue', 'foo') 16 | expect(color).toBe('blue') 17 | }) 18 | 19 | it('returns the base color if key is 0', () => { 20 | const color = triad('blue', 0) 21 | expect(color).toBe('blue') 22 | }) 23 | 24 | it('throws if base color is invalid', () => { 25 | expect(() => { 26 | return triad('bluish', 10) 27 | }).toThrow() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/mappings/analogue.ts: -------------------------------------------------------------------------------- 1 | import rotation from './rotation' 2 | 3 | import type { ColorMapping } from '../palette' 4 | 5 | /** 6 | * Generates a color that is analogous to the base color 7 | * 8 | * An analogous color is one that is located adjacent to 9 | * the base color around the color wheel, i.e. at around 10 | * 30˚ angle. It is visually similar to the base. 11 | * 12 | * This mapping function rotates the hue in steps of 30˚. 13 | * A negative `key` value rotates in the opposite 14 | * direction. 15 | * 16 | * @param baseColor 17 | * @param key - rotation steps 18 | * @returns new color value in hex 19 | * @throws if `baseColor` is not a valid color value 20 | */ 21 | const analogue: ColorMapping = (baseColor, key) => { 22 | if (typeof key !== 'number' || key === 0) return baseColor 23 | 24 | return rotation(baseColor, 30 * key) 25 | } 26 | 27 | export default analogue 28 | -------------------------------------------------------------------------------- /src/mappings/complement.ts: -------------------------------------------------------------------------------- 1 | import rotation from './rotation' 2 | 3 | import type { ColorMapping } from '../palette' 4 | 5 | /** 6 | * Generates a color that is complementary to the base color 7 | * 8 | * A complementary color is one that is located at the 9 | * opposite side of the color wheel, i.e. at 180˚ angle. 10 | * This provides excellent color contrast. 11 | * 12 | * This mapping function cycles through multiple sets of 13 | * "double complementary" hue rotation. The algorithm loops 14 | * from 1 to `key`, rotates Hue by 180˚ on every odd 15 | * iteration, and 30˚ on even. A negative `key` value 16 | * rotates in the opposite direction. 17 | * 18 | * @param baseColor 19 | * @param key - rotation steps 20 | * @returns new color value in hex 21 | * @throws if `baseColor` is not a valid color value 22 | */ 23 | const complement: ColorMapping = (baseColor, key) => { 24 | if (typeof key !== 'number' || key === 0) return baseColor 25 | 26 | let angle = 0 27 | let direction = key < 0 ? -1 : 1 28 | let i = 0 29 | while (i !== key) { 30 | i += direction 31 | 32 | if (i % 2 !== 0) angle += 180 * direction 33 | else angle += 30 * direction 34 | } 35 | 36 | return rotation(baseColor, angle) 37 | } 38 | 39 | export default complement 40 | -------------------------------------------------------------------------------- /src/mappings/lightness.ts: -------------------------------------------------------------------------------- 1 | import colorString from '../color/colorString' 2 | import hsl from '../color/hsl' 3 | 4 | import type { ColorMapping } from '../palette' 5 | 6 | /** 7 | * Generates new color value by adjusting the base color's 8 | * lightness (the "L" value in HSL color) 9 | * 10 | * @param baseColor 11 | * @param key - percent lightness [0..100] 12 | * @returns new color value in hex 13 | * @throws if `baseColor` is not a valid color value 14 | */ 15 | const lightness: ColorMapping = (baseColor, key) => { 16 | if (typeof key !== 'number') return baseColor 17 | 18 | const base = hsl(baseColor) 19 | 20 | const targetL = Math.min(Math.max(key, 0), 100) 21 | 22 | return colorString({ ...base, l: targetL }) 23 | } 24 | 25 | export default lightness 26 | -------------------------------------------------------------------------------- /src/mappings/opacity.ts: -------------------------------------------------------------------------------- 1 | import colorString from '../color/colorString' 2 | import rgb from '../color/rgb' 3 | 4 | import type { ColorMapping } from '../palette' 5 | 6 | /** 7 | * Generates new color value by adjusting the base color's 8 | * opacity (the alpha or "A" value in RGBA) 9 | * 10 | * @param baseColor 11 | * @param key - opacity value [0..1] 12 | * @returns new color value in `rgba(...)` format 13 | * @throws if `baseColor` is not a valid color value 14 | */ 15 | const opacity: ColorMapping = (baseColor, key) => { 16 | if (typeof key !== 'number') return baseColor 17 | 18 | const base = rgb(baseColor) 19 | 20 | const targetA = Math.min(Math.max(key, 0), 1) 21 | 22 | return colorString({ ...base, a: targetA }) 23 | } 24 | 25 | export default opacity 26 | -------------------------------------------------------------------------------- /src/mappings/rotation.ts: -------------------------------------------------------------------------------- 1 | import colorString from '../color/colorString' 2 | import hsl from '../color/hsl' 3 | 4 | import type { ColorMapping } from '../palette' 5 | 6 | /** 7 | * Rotates the hue of the base color by a specified angle 8 | * around the color wheel 9 | * 10 | * A negative `key` value reverses the direction of rotation. 11 | * 12 | * @param baseColor 13 | * @param key - rotation angle in degrees 14 | * @returns new color value in hex 15 | * @throws if `baseColor` is not a valid color value 16 | */ 17 | const rotation: ColorMapping = (baseColor, key) => { 18 | if (typeof key !== 'number' || key === 0) return baseColor 19 | 20 | const base = hsl(baseColor) 21 | 22 | const targetH = (base.h + key) % 360 23 | 24 | return colorString({ ...base, h: targetH }) 25 | } 26 | 27 | export default rotation 28 | -------------------------------------------------------------------------------- /src/mappings/saturation.ts: -------------------------------------------------------------------------------- 1 | import colorString from '../color/colorString' 2 | import hsl from '../color/hsl' 3 | 4 | import type { ColorMapping } from '../palette' 5 | 6 | /** 7 | * Generates new color value by adjusting the base color's 8 | * saturation (the "S" value in HSL color) 9 | * 10 | * @param baseColor 11 | * @param key - percent saturation [0..100] 12 | * @returns new color value in hex 13 | * @throws if `baseColor` is not a valid color value 14 | */ 15 | const saturation: ColorMapping = (baseColor, key) => { 16 | if (typeof key !== 'number') return baseColor 17 | 18 | const base = hsl(baseColor) 19 | 20 | const targetS = Math.min(Math.max(key, 0), 100) 21 | 22 | return colorString({ ...base, s: targetS }) 23 | } 24 | 25 | export default saturation 26 | -------------------------------------------------------------------------------- /src/mappings/triad.ts: -------------------------------------------------------------------------------- 1 | import rotation from './rotation' 2 | 3 | import type { ColorMapping } from '../palette' 4 | 5 | /** 6 | * Generates a triadic complementary color to the base color 7 | * 8 | * A triadic palette consists of 3 colors that are equally 9 | * spaced around the color wheel. Therefore, producing a 10 | * triadic complementary color means rotating the hue by 11 | * 120˚ angle. This provides a more subtle contrast. 12 | * 13 | * This mapping function is cyclic. A negative key value 14 | * rotates in the opposite direction. 15 | * 16 | * @param baseColor 17 | * @param key - rotation steps 18 | * @returns new color value in hex 19 | */ 20 | const triad: ColorMapping = (baseColor, key) => { 21 | if (typeof key !== 'number' || key === 0) return baseColor 22 | 23 | return rotation(baseColor, 120 * key) 24 | } 25 | 26 | export default triad 27 | -------------------------------------------------------------------------------- /src/normalize.ts: -------------------------------------------------------------------------------- 1 | import colorString from './color/colorString' 2 | import rgb from './color/rgb' 3 | 4 | /** 5 | * Normalizes the color string format into either hex or 6 | * rgba 7 | * 8 | * If the color is translucent, i.e. alpha/opacity value 9 | * is less than 1, it is returned as rgba. The 8-digit hex 10 | * format is not preferred, to support older browsers. 11 | * 12 | * @param color 13 | * @returns normalized color string 14 | * @throws if `color` is not a valid color value 15 | */ 16 | export default function normalize(color: string): string { 17 | return colorString(rgb(color)) 18 | } 19 | -------------------------------------------------------------------------------- /src/palette.ts: -------------------------------------------------------------------------------- 1 | import lightness from './mappings/lightness' 2 | 3 | import type { StringOrNumber } from './utils' 4 | 5 | /** Function for generating palette colors from base color */ 6 | export type ColorMapping = ( 7 | baseColor: string, 8 | key: K, 9 | ) => string 10 | 11 | /** Getter function for palette colors */ 12 | export type Palette = ( 13 | key?: K, 14 | ) => string 15 | 16 | /** 17 | * Builds a color palette and returns a getter function for 18 | * accessing palette colors 19 | * 20 | * @param baseColor 21 | * @param colorMapping - optional custom function for generating colors 22 | * @param noCache - option to disable caching of color mapping 23 | * @returns getter function for palette color 24 | */ 25 | export default function palette( 26 | baseColor: string, 27 | colorMapping: ColorMapping = lightness, 28 | noCache?: boolean, 29 | ): Palette { 30 | // Use cache to avoid potentially expensive recalculations 31 | const cache: Record = {} 32 | const cachedColorMapping: ColorMapping = (baseColor, key) => { 33 | if (cache[key] === undefined) { 34 | cache[key] = colorMapping(baseColor, key) 35 | } 36 | return cache[key] 37 | } 38 | 39 | const generate = noCache ? colorMapping : cachedColorMapping 40 | 41 | return (key?: K) => (key !== undefined ? generate(baseColor, key) : baseColor) 42 | } 43 | -------------------------------------------------------------------------------- /src/presets/__tests__/harmony.spec.ts: -------------------------------------------------------------------------------- 1 | import harmony from '../harmony' 2 | 3 | describe('harmony', () => { 4 | it('returns a standardized set of base colors', () => { 5 | const baseColors = harmony('#0000FF') 6 | expect(baseColors).toEqual({ 7 | primary: '#0000FF', 8 | secondary: '#806C93', 9 | accent: '#E61980', 10 | neutral: '#797986', 11 | error: '#BB2211', 12 | }) 13 | }) 14 | 15 | it('normalizes primary color if base is not in hex format', () => { 16 | const baseColors = harmony('blue') 17 | expect(baseColors.primary).toBe('#0000FF') 18 | }) 19 | 20 | it('supports translucent base color', () => { 21 | const baseColors = harmony('rgba(0, 0, 255, 0.5)') 22 | expect(baseColors).toEqual({ 23 | primary: 'rgba(0, 0, 255, 0.5)', 24 | secondary: 'rgba(128, 108, 147, 0.5)', 25 | accent: 'rgba(230, 25, 128, 0.5)', 26 | neutral: 'rgba(121, 121, 134, 0.5)', 27 | error: '#BB2211', 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/presets/harmony.ts: -------------------------------------------------------------------------------- 1 | import rotation from '../mappings/rotation' 2 | import saturation from '../mappings/saturation' 3 | import normalize from '../normalize' 4 | 5 | import type { ColorSet } from '../colorSet' 6 | import type { StringOrNumber } from '../utils' 7 | 8 | /** 9 | * Generates a set of base colors that goes well with 10 | * the given primary base color 11 | * 12 | * @param baseColor 13 | * @returns a keyed list of base colors 14 | */ 15 | export default function harmony(baseColor: string) { 16 | return { 17 | primary: normalize(baseColor), 18 | secondary: saturation(rotation(baseColor, 30), 15), 19 | accent: saturation(rotation(baseColor, 90), 80), 20 | neutral: saturation(baseColor, 5), 21 | error: '#BB2211', 22 | } 23 | } 24 | 25 | /** Harmony generated base colors */ 26 | export type HarmonyBaseColors = ReturnType 27 | 28 | type HarmonyColorClass = keyof HarmonyBaseColors 29 | 30 | type HarmonyColorKey = 31 | | 0 32 | | 10 33 | | 20 34 | | 30 35 | | 40 36 | | 50 37 | | 60 38 | | 70 39 | | 80 40 | | 90 41 | | 95 42 | | 98 43 | | 100 44 | 45 | /** Color set built with Harmony base colors */ 46 | export type HarmonyColors = 47 | ColorSet 48 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type StringOrNumber = string | number 2 | 3 | /** 4 | * Restricts a numerical value between a minimum and 5 | * a maximum 6 | * 7 | * @param value - expression to be evaluated 8 | * @param min - the minimum allowed value 9 | * @param max - the maximum allowed value 10 | * @returns either the same value, the min or the max 11 | */ 12 | export function clamp(value: number, min: number, max: number): number { 13 | return Math.max(min, Math.min(value, max)) 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["esnext"], 5 | "declaration": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "es2015", 13 | "moduleResolution": "node", 14 | "isolatedModules": true, 15 | "jsx": "react-jsx", 16 | "importsNotUsedAsValues": "error", 17 | "outDir": "./build", 18 | "declarationDir": "./types", 19 | "rootDir": "./src" 20 | }, 21 | "include": ["src"], 22 | "exclude": ["src/**/__tests__"] 23 | } 24 | --------------------------------------------------------------------------------