|
| 1 | +import chroma, { InterpolationMode } from 'chroma-js'; |
| 2 | +import builtInColors from 'tailwindcss/colors'; |
| 3 | + |
| 4 | +function keys<T extends object>(obj: T): (keyof T)[] { |
| 5 | + return Object.keys(obj) as (keyof T)[]; |
| 6 | +} |
| 7 | + |
| 8 | +function entries<T extends object>(obj: T): [keyof T, T[keyof T]][] { |
| 9 | + return Object.entries(obj) as [keyof T, T[keyof T]][]; |
| 10 | +} |
| 11 | + |
| 12 | +function hasOwn<T extends object>(obj: T, key: keyof T): key is keyof T { |
| 13 | + return Object.prototype.hasOwnProperty.call(obj, key); |
| 14 | +} |
| 15 | + |
| 16 | +// valid color modes for chroma-js |
| 17 | +const validColorModes = [ |
| 18 | + 'rgb', |
| 19 | + 'lab', |
| 20 | + 'lch', |
| 21 | + 'lrgb', |
| 22 | + 'hcl', |
| 23 | + 'num', |
| 24 | + 'hcg', |
| 25 | + 'oklch', |
| 26 | + 'hsi', |
| 27 | + 'hsl', |
| 28 | + 'hsv', |
| 29 | + 'oklab', |
| 30 | +] as const; |
| 31 | + |
| 32 | +// types for tailwind-lerp-colors |
| 33 | +type NumericObjKey = number | `${number}`; |
| 34 | +type Shades = Record<NumericObjKey, string>; |
| 35 | +type Colors = Record<string, Shades>; |
| 36 | +type ColorMode = (typeof validColorModes)[number]; |
| 37 | +type Options = { |
| 38 | + includeBase?: boolean; |
| 39 | + includeLegacy?: boolean; |
| 40 | + lerpEnds?: boolean; |
| 41 | + interval?: number; |
| 42 | + mode?: ColorMode; |
| 43 | +}; |
| 44 | +type OptionName = keyof Options; |
| 45 | +type Option<T extends OptionName> = Options[T]; |
| 46 | +type SingularOptions = Pick<Options, 'lerpEnds' | 'interval' | 'mode'>; |
| 47 | + |
| 48 | +// default options for tailwind-lerp-colors -> lerpColor |
| 49 | +const defaultSingleOptions: Required<SingularOptions> = { |
| 50 | + lerpEnds: true, |
| 51 | + interval: 25, |
| 52 | + mode: 'lrgb', |
| 53 | +}; |
| 54 | + |
| 55 | +// default options for tailwind-lerp-colors -> lerpColors |
| 56 | +const defaultOptions = { |
| 57 | + includeBase: true, |
| 58 | + includeLegacy: false, |
| 59 | + ...defaultSingleOptions, |
| 60 | +}; |
| 61 | + |
| 62 | +const isOptionInvalid = <T extends OptionName>(options: Options, optionName: T, test: (k: Option<T>) => boolean) => { |
| 63 | + return options && hasOwn(options, optionName) && !test(options[optionName]); |
| 64 | +}; |
| 65 | + |
| 66 | +const throwError = (message: string) => { |
| 67 | + throw new Error(message); |
| 68 | +}; |
| 69 | + |
| 70 | +export const lerpColor = (shades: Shades, options: SingularOptions = {}) => { |
| 71 | + if (isOptionInvalid(options, 'lerpEnds', (v) => typeof v === 'boolean')) |
| 72 | + throwError('tailwind-lerp-colors option `lerpEnds` must be a boolean.'); |
| 73 | + |
| 74 | + if (isOptionInvalid(options, 'interval', (v) => Number.isInteger(v) && typeof v === 'number' && v > 0)) |
| 75 | + throwError('tailwind-lerp-colors option `interval` must be a positive integer greater than 0.'); |
| 76 | + if (isOptionInvalid(options, 'mode', (v) => typeof v === 'string' && validColorModes.includes(v))) |
| 77 | + throwError( |
| 78 | + `tailwind-lerp-colors option \`mode\` must be one of the following values: ${validColorModes.join(', ')}.` |
| 79 | + ); |
| 80 | + |
| 81 | + const { lerpEnds, interval, mode } = { |
| 82 | + ...defaultSingleOptions, |
| 83 | + ...(options ?? {}), |
| 84 | + }; |
| 85 | + |
| 86 | + const sortByNumericFirstIndex = ([numericKeyA]: [number, string], [numericKeyB]: [number, string]) => { |
| 87 | + return numericKeyA - numericKeyB; |
| 88 | + }; |
| 89 | + |
| 90 | + if ( |
| 91 | + ['null', 'undefined'].includes(typeof shades) || |
| 92 | + !shades.toString || |
| 93 | + typeof shades === 'string' || |
| 94 | + Array.isArray(shades) || |
| 95 | + shades.toString() !== '[object Object]' || |
| 96 | + !keys(shades).every((key) => { |
| 97 | + return !isNaN(+key); |
| 98 | + }) |
| 99 | + ) { |
| 100 | + throwError( |
| 101 | + 'tailwind-lerp-colors object `shades` must be an object with numeric keys.\n\nvalue used: ' + |
| 102 | + JSON.stringify(shades, null, 2) |
| 103 | + ); |
| 104 | + } |
| 105 | + const shadesArray = entries(shades) |
| 106 | + .map(([numericStringKey, color]) => { |
| 107 | + return [Number(numericStringKey), color] as [number, string]; |
| 108 | + }) |
| 109 | + .sort(sortByNumericFirstIndex); |
| 110 | + if (lerpEnds) { |
| 111 | + shadesArray.unshift([0, '#ffffff']); |
| 112 | + shadesArray.push([1000, '#000000']); |
| 113 | + } |
| 114 | + const finalShades = [...shadesArray]; |
| 115 | + for (let i = 0; i < shadesArray.length - 1; i++) { |
| 116 | + const [shade, color] = shadesArray[i]; |
| 117 | + const [nextShade, nextColor] = shadesArray[i + 1]; |
| 118 | + |
| 119 | + // check to make sure both shades being compared |
| 120 | + // are evenly divisible by the set interval |
| 121 | + const interpolations = (nextShade - shade) / interval - 1; |
| 122 | + if (interpolations <= 0 || !Number.isInteger(interpolations)) continue; |
| 123 | + |
| 124 | + const scale = chroma.scale([color, nextColor]).mode(mode as InterpolationMode); |
| 125 | + const getColorAt = (percent: number) => scale(percent).hex(); |
| 126 | + for (let run = 1; run <= interpolations; run++) { |
| 127 | + const percent = run / (interpolations + 1); |
| 128 | + finalShades.push([shade + interval * run, getColorAt(percent)]); |
| 129 | + } |
| 130 | + } |
| 131 | + finalShades.sort(sortByNumericFirstIndex); |
| 132 | + |
| 133 | + return Object.fromEntries(finalShades); |
| 134 | +}; |
| 135 | + |
| 136 | +export const lerpColors = (colorsObj: Colors = {}, options: Options = {}) => { |
| 137 | + const legacyNames = ['lightBlue', 'warmGray', 'trueGray', 'coolGray', 'blueGray']; |
| 138 | + |
| 139 | + if (isOptionInvalid(options, 'includeBase', (v) => typeof v === 'boolean')) |
| 140 | + throwError('tailwind-lerp-colors option `includeBase` must be a boolean.'); |
| 141 | + if (isOptionInvalid(options, 'includeLegacy', (v) => typeof v === 'boolean')) |
| 142 | + throwError('tailwind-lerp-colors option `includeLegacy` must be a boolean.'); |
| 143 | + |
| 144 | + const { includeBase, includeLegacy, lerpEnds, interval, mode } = { |
| 145 | + ...defaultOptions, |
| 146 | + ...options, |
| 147 | + }; |
| 148 | + const baseColors: Colors = {}; |
| 149 | + if (includeBase) { |
| 150 | + const builtInColorKeys = keys(builtInColors); |
| 151 | + for (const key of builtInColorKeys) { |
| 152 | + if (!legacyNames.includes(key) || includeLegacy) { |
| 153 | + baseColors[key] = builtInColors[key]; |
| 154 | + } |
| 155 | + } |
| 156 | + } |
| 157 | + const initialColors = entries({ |
| 158 | + ...baseColors, |
| 159 | + ...colorsObj, |
| 160 | + }); |
| 161 | + |
| 162 | + const finalColors: Colors = {}; |
| 163 | + |
| 164 | + for (const [name, shades] of initialColors) { |
| 165 | + if (['null', 'undefined'].includes(typeof shades) || !shades.toString) { |
| 166 | + continue; |
| 167 | + } |
| 168 | + finalColors[`${name}`] = shades; |
| 169 | + if ( |
| 170 | + typeof shades === 'string' || |
| 171 | + Array.isArray(shades) || |
| 172 | + shades.toString() !== '[object Object]' || |
| 173 | + !keys(shades).every((key) => { |
| 174 | + return !isNaN(+key); |
| 175 | + }) |
| 176 | + ) { |
| 177 | + continue; |
| 178 | + } |
| 179 | + finalColors[name] = lerpColor(shades, { lerpEnds, interval, mode }); |
| 180 | + } |
| 181 | + |
| 182 | + return finalColors; |
| 183 | +}; |
| 184 | + |
| 185 | +export type { |
| 186 | + Shades as LerpColorsShades, |
| 187 | + Colors as LerpColorsColors, |
| 188 | + ColorMode as LerpColorsColorMode, |
| 189 | + Options as LerpColorsOptions, |
| 190 | + OptionName as LerpColorsOptionName, |
| 191 | + Option as LerpColorsOption, |
| 192 | + SingularOptions as LerpColorsSingularOptions, |
| 193 | +}; |
0 commit comments