diff --git a/.changeset/slow-students-smile.md b/.changeset/slow-students-smile.md new file mode 100644 index 000000000..591e8e2c1 --- /dev/null +++ b/.changeset/slow-students-smile.md @@ -0,0 +1,5 @@ +--- +'@vanilla-extract/css-utils': minor +--- + +Added a color utility to enable type-safe usage of the CSS color-mix() function diff --git a/packages/utils/src/index.test.ts b/packages/utils/src/calc.test.ts similarity index 100% rename from packages/utils/src/index.test.ts rename to packages/utils/src/calc.test.ts diff --git a/packages/utils/src/calc.ts b/packages/utils/src/calc.ts new file mode 100644 index 000000000..9943dde29 --- /dev/null +++ b/packages/utils/src/calc.ts @@ -0,0 +1,60 @@ +type Operator = '+' | '-' | '*' | '/'; +type Operand = string | number | CalcChain; + +const toExpression = (operator: Operator, ...operands: Array) => + operands + .map((o) => `${o}`) + .join(` ${operator} `) + .replace(/calc/g, ''); + +const add = (...operands: Array) => + `calc(${toExpression('+', ...operands)})`; + +const subtract = (...operands: Array) => + `calc(${toExpression('-', ...operands)})`; + +const multiply = (...operands: Array) => + `calc(${toExpression('*', ...operands)})`; + +const divide = (...operands: Array) => + `calc(${toExpression('/', ...operands)})`; + +const negate = (x: Operand): string => multiply(x, -1); + +type CalcChain = { + add: (...operands: Array) => CalcChain; + subtract: (...operands: Array) => CalcChain; + multiply: (...operands: Array) => CalcChain; + divide: (...operands: Array) => CalcChain; + negate: () => CalcChain; + toString: () => string; +}; + +interface Calc { + (x: Operand): CalcChain; + add: typeof add; + subtract: typeof subtract; + multiply: typeof multiply; + divide: typeof divide; + negate: typeof negate; +} + +export const calc: Calc = Object.assign( + (x: Operand): CalcChain => { + return { + add: (...operands) => calc(add(x, ...operands)), + subtract: (...operands) => calc(subtract(x, ...operands)), + multiply: (...operands) => calc(multiply(x, ...operands)), + divide: (...operands) => calc(divide(x, ...operands)), + negate: () => calc(negate(x)), + toString: () => x.toString(), + }; + }, + { + add, + subtract, + multiply, + divide, + negate, + }, +); diff --git a/packages/utils/src/color.test.ts b/packages/utils/src/color.test.ts new file mode 100644 index 000000000..a41c7ed7a --- /dev/null +++ b/packages/utils/src/color.test.ts @@ -0,0 +1,336 @@ +import { color } from './'; + +describe('utils', () => { + describe('color', () => { + describe('basic color space mixing', () => { + it('srgb', () => { + expect( + color.srgb('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in srgb, red 50%, blue)"`); + expect(color.srgb('red').mix('blue').toString()).toMatchInlineSnapshot( + `"color-mix(in srgb, red, blue)"`, + ); + }); + + it('oklch', () => { + expect( + color.oklch('red').mix('blue', 30).toString(), + ).toMatchInlineSnapshot(`"color-mix(in oklch, red 30%, blue)"`); + expect( + color.oklch('#ff0000').mix('#0000ff').toString(), + ).toMatchInlineSnapshot(`"color-mix(in oklch, #ff0000, #0000ff)"`); + }); + + it('lab', () => { + expect( + color.lab('red').mix('blue', 25).toString(), + ).toMatchInlineSnapshot(`"color-mix(in lab, red 25%, blue)"`); + }); + + it('oklab', () => { + expect( + color.oklab('red').mix('blue', 75).toString(), + ).toMatchInlineSnapshot(`"color-mix(in oklab, red 75%, blue)"`); + }); + + it('hsl', () => { + expect( + color.hsl('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in hsl, red 50%, blue)"`); + }); + + it('hwb', () => { + expect( + color.hwb('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in hwb, red 50%, blue)"`); + }); + + it('lch', () => { + expect( + color.lch('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in lch, red 50%, blue)"`); + }); + + it('srgbLinear', () => { + expect( + color.srgbLinear('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in srgb-linear, red 50%, blue)"`); + }); + + it('xyz', () => { + expect( + color.xyz('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in xyz, red 50%, blue)"`); + }); + + it('xyzD50', () => { + expect( + color.xyzD50('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in xyz-d50, red 50%, blue)"`); + }); + + it('xyzD65', () => { + expect( + color.xyzD65('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot(`"color-mix(in xyz-d65, red 50%, blue)"`); + }); + }); + + describe('hue interpolation methods', () => { + it('hsl with shorter hue', () => { + expect( + color.hslShorter('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hsl shorter hue, red 50%, blue)"`, + ); + }); + + it('hsl with longer hue', () => { + expect( + color.hslLonger('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hsl longer hue, red 50%, blue)"`, + ); + }); + + it('hsl with increasing hue', () => { + expect( + color.hslIncreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hsl increasing hue, red 50%, blue)"`, + ); + }); + + it('hsl with decreasing hue', () => { + expect( + color.hslDecreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hsl decreasing hue, red 50%, blue)"`, + ); + }); + + it('hwb with shorter hue', () => { + expect( + color.hwbShorter('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hwb shorter hue, red 50%, blue)"`, + ); + }); + + it('hwb with longer hue', () => { + expect( + color.hwbLonger('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hwb longer hue, red 50%, blue)"`, + ); + }); + + it('hwb with increasing hue', () => { + expect( + color.hwbIncreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hwb increasing hue, red 50%, blue)"`, + ); + }); + + it('hwb with decreasing hue', () => { + expect( + color.hwbDecreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hwb decreasing hue, red 50%, blue)"`, + ); + }); + + it('lch with shorter hue', () => { + expect( + color.lchShorter('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in lch shorter hue, red 50%, blue)"`, + ); + }); + + it('lch with longer hue', () => { + expect( + color.lchLonger('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in lch longer hue, red 50%, blue)"`, + ); + }); + + it('lch with increasing hue', () => { + expect( + color.lchIncreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in lch increasing hue, red 50%, blue)"`, + ); + }); + + it('lch with decreasing hue', () => { + expect( + color.lchDecreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in lch decreasing hue, red 50%, blue)"`, + ); + }); + + it('oklch with shorter hue', () => { + expect( + color.oklchShorter('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch shorter hue, red 50%, blue)"`, + ); + }); + + it('oklch with longer hue', () => { + expect( + color.oklchLonger('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch longer hue, red 50%, blue)"`, + ); + }); + + it('oklch with increasing hue', () => { + expect( + color.oklchIncreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch increasing hue, red 50%, blue)"`, + ); + }); + + it('oklch with decreasing hue', () => { + expect( + color.oklchDecreasing('red').mix('blue', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch decreasing hue, red 50%, blue)"`, + ); + }); + }); + + describe('chaining', () => { + it('same color space', () => { + expect( + color.srgb('red').mix('blue', 50).mix('green', 25).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in srgb, color-mix(in srgb, red 50%, blue) 25%, green)"`, + ); + }); + + it('different color spaces', () => { + expect( + color + .srgb('red') + .mix('blue', 50) + .oklch('green', 25) + .mix('yellow') + .toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch, color-mix(in oklch, color-mix(in srgb, red 50%, blue) 25%, green), yellow)"`, + ); + }); + + it('multiple chaining with hue interpolation', () => { + expect( + color + .oklchShorter('red') + .mix('blue', 50) + .hslLonger('green', 30) + .mix('yellow', 10) + .toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hsl longer hue, color-mix(in hsl longer hue, color-mix(in oklch shorter hue, red 50%, blue) 30%, green) 10%, yellow)"`, + ); + }); + + it('switching between methods', () => { + expect( + color + .srgb('red') + .mix('blue', 50) + .oklch('green') + .mix('yellow', 25) + .srgb('purple') + .mix('orange', 75) + .toString(), + ).toMatchInlineSnapshot( + `"color-mix(in srgb, color-mix(in srgb, color-mix(in oklch, color-mix(in oklch, color-mix(in srgb, red 50%, blue), green) 25%, yellow), purple) 75%, orange)"`, + ); + }); + }); + + describe('string coercion', () => { + it('basic toString', () => { + expect(color.srgb('red').toString()).toMatchInlineSnapshot(`"red"`); + expect(color.oklch('blue').toString()).toMatchInlineSnapshot(`"blue"`); + }); + + it('template literal', () => { + expect(`${color.srgb('red').mix('blue', 50)}`).toMatchInlineSnapshot( + `"color-mix(in srgb, red 50%, blue)"`, + ); + expect(`${color.oklch('red').mix('blue')}`).toMatchInlineSnapshot( + `"color-mix(in oklch, red, blue)"`, + ); + }); + + it('chained template literal', () => { + expect( + `${color + .srgb('red') + .mix('blue', 50) + .oklch('green', 25) + .mix('yellow')}`, + ).toMatchInlineSnapshot( + `"color-mix(in oklch, color-mix(in oklch, color-mix(in srgb, red 50%, blue) 25%, green), yellow)"`, + ); + }); + }); + + describe('nested mixing', () => { + it('using mix result as operand', () => { + const intermediate = color.srgb('red').mix('blue', 50); + expect( + color.oklch(intermediate).mix('green', 25).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch, color-mix(in srgb, red 50%, blue) 25%, green)"`, + ); + }); + + it('multiple nested levels', () => { + const level1 = color.srgb('red').mix('blue', 50); + const level2 = color.oklch(level1).mix('green', 30); + expect( + color.hsl(level2).mix('yellow', 10).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in hsl, color-mix(in oklch, color-mix(in srgb, red 50%, blue) 30%, green) 10%, yellow)"`, + ); + }); + }); + + describe('edge cases', () => { + it('0% percentage', () => { + expect( + color.srgb('red').mix('blue', 0).toString(), + ).toMatchInlineSnapshot(`"color-mix(in srgb, red 0%, blue)"`); + }); + + it('100% percentage', () => { + expect( + color.srgb('red').mix('blue', 100).toString(), + ).toMatchInlineSnapshot(`"color-mix(in srgb, red 100%, blue)"`); + }); + + it('various color formats', () => { + expect( + color.srgb('#ff0000').mix('rgb(0, 0, 255)', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in srgb, #ff0000 50%, rgb(0, 0, 255))"`, + ); + expect( + color.oklch('hsl(0, 100%, 50%)').mix('hwb(240 0% 0%)', 50).toString(), + ).toMatchInlineSnapshot( + `"color-mix(in oklch, hsl(0, 100%, 50%) 50%, hwb(240 0% 0%))"`, + ); + }); + }); + }); +}); diff --git a/packages/utils/src/color.ts b/packages/utils/src/color.ts new file mode 100644 index 000000000..b77bade9f --- /dev/null +++ b/packages/utils/src/color.ts @@ -0,0 +1,163 @@ +type SimpleColorSpace = + | 'srgb' + | 'lab' + | 'oklab' + | 'xyz' + | 'hsl' + | 'hwb' + | 'lch' + | 'oklch'; + +type DashedColorSpace = 'srgb-linear' | 'xyz-d50' | 'xyz-d65'; + +type ColorSpace = SimpleColorSpace | DashedColorSpace; + +type HueInterpolation = 'shorter' | 'longer' | 'increasing' | 'decreasing'; +type Operand = string | ColorChain; + +const colorSpaces: ColorSpace[] = [ + 'srgb', + 'srgb-linear', + 'lab', + 'oklab', + 'xyz', + 'xyz-d50', + 'xyz-d65', + 'hsl', + 'hwb', + 'lch', + 'oklch', +]; + +const hueInterpolations: HueInterpolation[] = [ + 'shorter', + 'longer', + 'increasing', + 'decreasing', +]; + +const toCamelCase = (str: string) => + str.replace(/-([a-z0-9])/g, (g) => g[1].toUpperCase()); + +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +const colorMix = ( + colorSpace: ColorSpace, + hueInterpolation: HueInterpolation | undefined, + color1: Operand, + color2: Operand, + percentage?: number, +) => { + const interpolationMethod = hueInterpolation + ? `in ${colorSpace} ${hueInterpolation} hue` + : `in ${colorSpace}`; + + const percentageStr = percentage !== undefined ? ` ${percentage}%` : ''; + + return `color-mix(${interpolationMethod}, ${color1}${percentageStr}, ${color2})`; +}; + +type ColorMethod = (color: Operand, percentage?: number) => ColorChain; +type ColorFactory = (color: Operand) => ColorChain; + +type BaseMethods = { + srgb: ColorMethod; + srgbLinear: ColorMethod; + lab: ColorMethod; + oklab: ColorMethod; + xyz: ColorMethod; + xyzD50: ColorMethod; + xyzD65: ColorMethod; + hsl: ColorMethod; + hwb: ColorMethod; + lch: ColorMethod; + oklch: ColorMethod; +}; + +type HueMethods = { + [K in `${'hsl' | 'hwb' | 'lch' | 'oklch'}${ + | 'Shorter' + | 'Longer' + | 'Increasing' + | 'Decreasing'}`]: ColorMethod; +}; + +type ColorChain = { + mix: ColorMethod; + toString: () => string; +} & BaseMethods & + HueMethods; + +type Color = { + [K in keyof BaseMethods]: ColorFactory; +} & { + [K in keyof HueMethods]: ColorFactory; +}; + +const chain = ( + currentValue: string, + lastColorSpace?: ColorSpace, + lastHueInterpolation?: HueInterpolation, +): ColorChain => { + const mixWith = + (colorSpace: ColorSpace, hueInterpolation?: HueInterpolation) => + (color: Operand, percentage?: number) => + chain( + colorMix(colorSpace, hueInterpolation, currentValue, color, percentage), + colorSpace, + hueInterpolation, + ); + + const methods: any = { + mix: (color: Operand, percentage?: number) => + chain( + colorMix( + lastColorSpace || 'srgb', + lastHueInterpolation, + currentValue, + color, + percentage, + ), + lastColorSpace || 'srgb', + lastHueInterpolation, + ), + toString: () => currentValue, + }; + + for (const space of colorSpaces) { + const method = toCamelCase(space); + methods[method] = mixWith(space); + + if (['hsl', 'hwb', 'lch', 'oklch'].includes(space)) { + for (const hue of hueInterpolations) { + methods[method + capitalize(hue)] = mixWith(space, hue); + } + } + } + + return methods as ColorChain; +}; + +const colorImpl: any = {}; + +const addFactory = ( + name: string, + space: ColorSpace, + hue?: HueInterpolation, +) => { + colorImpl[name] = (color: Operand) => + chain(typeof color === 'string' ? color : color.toString(), space, hue); +}; + +for (const space of colorSpaces) { + const method = toCamelCase(space); + addFactory(method, space); + + if (['hsl', 'hwb', 'lch', 'oklch'].includes(space)) { + for (const hue of hueInterpolations) { + addFactory(method + capitalize(hue), space, hue); + } + } +} + +export const color: Color = colorImpl; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 9943dde29..7faf71878 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,60 +1,2 @@ -type Operator = '+' | '-' | '*' | '/'; -type Operand = string | number | CalcChain; - -const toExpression = (operator: Operator, ...operands: Array) => - operands - .map((o) => `${o}`) - .join(` ${operator} `) - .replace(/calc/g, ''); - -const add = (...operands: Array) => - `calc(${toExpression('+', ...operands)})`; - -const subtract = (...operands: Array) => - `calc(${toExpression('-', ...operands)})`; - -const multiply = (...operands: Array) => - `calc(${toExpression('*', ...operands)})`; - -const divide = (...operands: Array) => - `calc(${toExpression('/', ...operands)})`; - -const negate = (x: Operand): string => multiply(x, -1); - -type CalcChain = { - add: (...operands: Array) => CalcChain; - subtract: (...operands: Array) => CalcChain; - multiply: (...operands: Array) => CalcChain; - divide: (...operands: Array) => CalcChain; - negate: () => CalcChain; - toString: () => string; -}; - -interface Calc { - (x: Operand): CalcChain; - add: typeof add; - subtract: typeof subtract; - multiply: typeof multiply; - divide: typeof divide; - negate: typeof negate; -} - -export const calc: Calc = Object.assign( - (x: Operand): CalcChain => { - return { - add: (...operands) => calc(add(x, ...operands)), - subtract: (...operands) => calc(subtract(x, ...operands)), - multiply: (...operands) => calc(multiply(x, ...operands)), - divide: (...operands) => calc(divide(x, ...operands)), - negate: () => calc(negate(x)), - toString: () => x.toString(), - }; - }, - { - add, - subtract, - multiply, - divide, - negate, - }, -); +export * from './calc'; +export * from './color';