diff --git a/packages/utils/src/calc/calc.test.ts b/packages/utils/src/calc/calc.test.ts new file mode 100644 index 000000000..8c2a66ea3 --- /dev/null +++ b/packages/utils/src/calc/calc.test.ts @@ -0,0 +1,159 @@ +import { calc } from './calc'; + +const css = 'var(--some-css-var)'; + +describe('class UnitCalc', () => { + describe('add()', () => { + it.each` + args | expectedExpressions | expectedBuilt + ${[]} | ${['1']} | ${'calc(1)'} + ${[2]} | ${['1', '2']} | ${'calc(1 + 2)'} + ${[2, '3']} | ${['1', '2', '3']} | ${'calc(1 + 2 + 3)'} + ${[calc(2)]} | ${['1', '2']} | ${'calc(1 + 2)'} + ${[calc(2).add(3)]} | ${['1', '2 + 3']} | ${'calc(1 + 2 + 3)'} + ${[calc(2).subtract(3)]} | ${['1', '2 - 3']} | ${'calc(1 + 2 - 3)'} + ${[calc(2).multiply(3)]} | ${['1', '2 * 3']} | ${'calc(1 + 2 * 3)'} + ${[calc(2).divide(3)]} | ${['1', '2 / 3']} | ${'calc(1 + 2 / 3)'} + ${[css]} | ${['1', css]} | ${'calc(1 + var(--some-css-var))'} + `( + 'should add $args to 1 to get $expectedBuilt', + ({ args, expectedExpressions, expectedBuilt }) => { + const result = calc(1).add(...args); + + expect(result).toMatchObject({ + expressions: expectedExpressions, + operator: '+', + }); + expect(result.toString()).toBe(expectedBuilt); + }, + ); + }); + + describe('subtract()', () => { + it.each` + args | expectedExpressions | expectedBuilt + ${[]} | ${['1']} | ${'calc(1)'} + ${[2]} | ${['1', '2']} | ${'calc(1 - 2)'} + ${[2, '3']} | ${['1', '2', '3']} | ${'calc(1 - 2 - 3)'} + ${[calc(2)]} | ${['1', '2']} | ${'calc(1 - 2)'} + ${[calc(2).add(3)]} | ${['1', '2 + 3']} | ${'calc(1 - 2 + 3)'} + ${[calc(2).subtract(3)]} | ${['1', '2 - 3']} | ${'calc(1 - 2 - 3)'} + ${[calc(2).multiply(3)]} | ${['1', '2 * 3']} | ${'calc(1 - 2 * 3)'} + ${[calc(2).divide(3)]} | ${['1', '2 / 3']} | ${'calc(1 - 2 / 3)'} + ${[css]} | ${['1', css]} | ${'calc(1 - var(--some-css-var))'} + `( + 'should subtract $args from 1 to get $expectedBuilt', + ({ args, expectedExpressions, expectedBuilt }) => { + const result = calc(1).subtract(...args); + + expect(result).toMatchObject({ + expressions: expectedExpressions, + operator: '-', + }); + expect(result.toString()).toBe(expectedBuilt); + }, + ); + }); + + describe('multiply()', () => { + it.each` + args | expectedExpressions | expectedBuilt + ${[]} | ${['1']} | ${'calc(1)'} + ${[2]} | ${['1', '2']} | ${'calc(1 * 2)'} + ${[2, '3']} | ${['1', '2', '3']} | ${'calc(1 * 2 * 3)'} + ${[calc(2)]} | ${['1', '2']} | ${'calc(1 * 2)'} + ${[calc(2).add(3)]} | ${['1', '(2 + 3)']} | ${'calc(1 * (2 + 3))'} + ${[calc(2).subtract(3)]} | ${['1', '(2 - 3)']} | ${'calc(1 * (2 - 3))'} + ${[calc(2).multiply(3)]} | ${['1', '2 * 3']} | ${'calc(1 * 2 * 3)'} + ${[calc(2).divide(3)]} | ${['1', '2 / 3']} | ${'calc(1 * 2 / 3)'} + ${[css]} | ${['1', css]} | ${'calc(1 * var(--some-css-var))'} + `( + 'should multiply 1 by $args to get $expectedBuilt', + ({ args, expectedExpressions, expectedBuilt }) => { + const result = calc(1).multiply(...args); + + expect(result).toMatchObject({ + expressions: expectedExpressions, + operator: '*', + }); + expect(result.toString()).toBe(expectedBuilt); + }, + ); + }); + + describe('divide()', () => { + it.each` + args | expectedExpressions | expectedBuilt + ${[]} | ${['1']} | ${'calc(1)'} + ${[2]} | ${['1', '2']} | ${'calc(1 / 2)'} + ${[2, '3']} | ${['1', '2', '3']} | ${'calc(1 / 2 / 3)'} + ${[calc(2)]} | ${['1', '2']} | ${'calc(1 / 2)'} + ${[calc(2).add(3)]} | ${['1', '(2 + 3)']} | ${'calc(1 / (2 + 3))'} + ${[calc(2).subtract(3)]} | ${['1', '(2 - 3)']} | ${'calc(1 / (2 - 3))'} + ${[calc(2).multiply(3)]} | ${['1', '2 * 3']} | ${'calc(1 / 2 * 3)'} + ${[calc(2).divide(3)]} | ${['1', '2 / 3']} | ${'calc(1 / 2 / 3)'} + ${[css]} | ${['1', css]} | ${'calc(1 / var(--some-css-var))'} + `( + 'should divide 1 by $args to get $expectedBuilt', + ({ args, expectedExpressions, expectedBuilt }) => { + const result = calc(1).divide(...args); + + expect(result).toMatchObject({ + expressions: expectedExpressions, + operator: '/', + }); + expect(result.toString()).toBe(expectedBuilt); + }, + ); + }); + + describe('negate()', () => { + it.each` + calc | expectedExpressions | expectedBuilt + ${calc(1)} | ${['-1', '1']} | ${'calc(-1 * 1)'} + ${calc(1).add(3)} | ${['-1', '(1 + 3)']} | ${'calc(-1 * (1 + 3))'} + ${calc(1).subtract(3)} | ${['-1', '(1 - 3)']} | ${'calc(-1 * (1 - 3))'} + ${calc(1).multiply(3)} | ${['-1', '1 * 3']} | ${'calc(-1 * 1 * 3)'} + ${calc(1).divide(3)} | ${['-1', '1 / 3']} | ${'calc(-1 * 1 / 3)'} + ${calc(css)} | ${['-1', css]} | ${'calc(-1 * var(--some-css-var))'} + `( + 'should negate $calc to get $expectedBuilt', + ({ calc, expectedExpressions, expectedBuilt }) => { + const result = calc.negate(); + + expect(result).toMatchObject({ + expressions: expectedExpressions, + operator: '*', + }); + expect(result.toString()).toBe(expectedBuilt); + }, + ); + }); + + describe('toString()', () => { + it.each` + calc | expected + ${calc(1)} | ${'calc(1)'} + ${calc(1).add(3)} | ${'calc(1 + 3)'} + ${calc(1).subtract(3)} | ${'calc(1 - 3)'} + ${calc(1).multiply(3)} | ${'calc(1 * 3)'} + ${calc(1).divide(3)} | ${'calc(1 / 3)'} + ${calc(1).negate()} | ${'calc(-1 * 1)'} + `('should build $calc to get $expected', ({ calc, expected }) => { + expect(calc.toString()).toBe(expected); + }); + }); + + describe('build()', () => { + it.each` + calc | expected + ${calc(1)} | ${'1'} + ${calc(1).add(3)} | ${'1 + 3'} + ${calc(1).subtract(3)} | ${'1 - 3'} + ${calc(1).multiply(3)} | ${'1 * 3'} + ${calc(1).divide(3)} | ${'1 / 3'} + `('should convert $calc to string $expected', ({ calc, expected }) => { + expect(calc.build()).toBe(expected); + }); + }); +}); diff --git a/packages/utils/src/calc/calc.ts b/packages/utils/src/calc/calc.ts new file mode 100644 index 000000000..b3b5516cd --- /dev/null +++ b/packages/utils/src/calc/calc.ts @@ -0,0 +1,208 @@ +/** + * @file Defines a class for creating CSS calc() functions. This class allows + * for chaining operations to build up a calc() function. + * @author rowan-gud + * @author michaeltaranto + * @author markdalgleish + +/** + * An input to a calc function. Can be a number, string, or another Calc + * instance. + * + * NOTE: Unlike a lot of other places in this library, numbers will not be + * interpreted as pixel values since they can be used to represent unitless + * values. + */ +type Operand = string | number | Calc; + +class Calc { + private static isTerm(op: Operand): op is Calc { + return op instanceof Calc && (op.operator === '+' || op.operator === '-'); + } + + private static isFactor(op: Operand): op is Calc { + return op instanceof Calc && (op.operator === '*' || op.operator === '/'); + } + + /** + * The expressions to combine with the operator + */ + private readonly expressions: string[]; + + constructor( + exprs: Operand[], + /** + * The operator to use when combining the expressions. If undefined, the + * Calc instance only has one expression. + */ + private readonly operator?: '+' | '-' | '*' | '/', + ) { + const thisIsFactor = Calc.isFactor(this); + + this.expressions = exprs.map((e) => { + if (thisIsFactor && Calc.isTerm(e)) { + return `(${e.build()})`; + } else if (e instanceof Calc) { + return e.build(); + } + + return e.toString(); + }); + } + + /** + * Add operands to the current Calc instance + * + * @param operands - The operands to add + * @returns A new Calc instance with the added operands + * @example + * ```ts + * const result = calc(1).add(3); + * + * expect(result.build()).toBe('1 + 3'); + * expect(result.toString()).toBe('calc(1 + 3)'); + * ``` + */ + public add(...operands: Operand[]): Calc { + return new Calc([this, ...operands], '+'); + } + + /** + * Subtract operands from the current Calc instance + * + * @param operands - The operands to subtract + * @returns A new Calc instance with the subtracted operands + * @example + * ```ts + * const result = calc(1).subtract(3); + * + * expect(result.build()).toBe('1 - 3'); + * expect(result.toString()).toBe('calc(1 - 3)'); + * ``` + */ + public subtract(...operands: Operand[]): Calc { + return new Calc([this, ...operands], '-'); + } + + /** + * Multiply operands with the current Calc instance. If any of the operands + * are Calc instances with the operator '+' or '-', they will be wrapped in + * parentheses. + * + * @param operands - The operands to multiply + * @returns A new Calc instance with the multiplied operands + * @example + * ```ts + * const result = calc(1).multiply(3); + * + * expect(result.build()).toBe('1 * 3'); + * expect(result.toString()).toBe('calc(1 * 3)'); + * + * const result2 = calc(1).add(2, 3).multiply(4); + * + * expect(result2.build()).toBe('(1 + 2 + 3) * 4'); + * expect(result2.toString()).toBe('calc((1 + 2 + 3) * 4)'); + * ``` + */ + public multiply(...operands: Operand[]): Calc { + return new Calc([this, ...operands], '*'); + } + + /** + * Divide the current Calc instance by the operands. If any of the operands + * are Calc instances with the operator '+' or '-', they will be wrapped in + * parentheses. + * + * @param operands - The operands to divide by + * @returns A new Calc instance with the divided operands + * @example + * ```ts + * const result = calc(1).divide(2); + * + * expect(result.build()).toBe('1 / 2'); + * expect(result.toString()).toBe('calc(1 / 2)'); + * ``` + */ + public divide(...operands: Operand[]): Calc { + return new Calc([this, ...operands], '/'); + } + + /** + * Negate the current Calc instance. If the current Calc instance has the + * operator '+' or '-', it will be wrapped in parentheses. + * + * @returns A new Calc instance with the negated value + * @example + * ```ts + * const result = calc(1).negate(); + * + * expect(result.build()).toBe('1 * -1'); + * expect(result.toString()).toBe('calc(1 * -1)'); + * + * const result2 = calc(1).add(3).negate(); + * + * expect(result2.build()).toBe('(1 + 3) * -1'); + * expect(result2.toString()).toBe('calc((1 + 3) * -1)'); + * ``` + */ + public negate(): Calc { + return new Calc([-1, this], '*'); + } + + /** + * Build the current Calc instance into a CSS calc() function + * + * @returns The CSS calc() function + * @example + * ```ts + * const result = calc(1).add(3); + * + * expect(result.toString()).toBe('calc(1 + 3)'); + * ``` + */ + public toString(): string { + return `calc(${this.build()})`; + } + + /** + * Convert the current Calc instance to a string + * + * NOTE: This is not the same as calling build(). The resulting string will + * not be wrapped in a calc() function + * + * @returns The string representation of the Calc instance + * @example + * ```ts + * const result = calc(1).add(3); + * + * expect(result.build()).toBe('1 + 3'); + * ``` + */ + public build(): string { + if (this.operator === undefined) { + return this.expressions[0] ?? ''; + } + + return this.expressions.join(` ${this.operator} `); + } +} + +/** + * Create a new Calc instance with the given operand + * + * @param x - The operand to create the Calc instance with + * @returns A new Calc instance + * @example + * ```ts + * const headerHeight = createVar() + * + * const root = style({ + * height: calc(headerHeight) + * .divide(2) + * .build(), + * }) + * ``` + */ +export function calc(x: Operand): Calc { + return new Calc([x]); +} diff --git a/packages/utils/src/calc/index.ts b/packages/utils/src/calc/index.ts new file mode 100644 index 000000000..80f9b1774 --- /dev/null +++ b/packages/utils/src/calc/index.ts @@ -0,0 +1 @@ +export * from './calc'; diff --git a/packages/utils/src/index.test.ts b/packages/utils/src/index.test.ts deleted file mode 100644 index 9de8ca419..000000000 --- a/packages/utils/src/index.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { calc } from './'; - -describe('utils', () => { - describe('calc', () => { - it('standard usage', () => { - expect(calc('10px').add('20px').toString()).toMatchInlineSnapshot( - `"calc(10px + 20px)"`, - ); - expect(calc('10px').add('20px', '30px').toString()).toMatchInlineSnapshot( - `"calc(10px + 20px + 30px)"`, - ); - expect(calc('20px').subtract('10px').toString()).toMatchInlineSnapshot( - `"calc(20px - 10px)"`, - ); - expect( - calc('20px').subtract('5px', '5px').toString(), - ).toMatchInlineSnapshot(`"calc(20px - 5px - 5px)"`); - expect(calc('10px').multiply(10).toString()).toMatchInlineSnapshot( - `"calc(10px * 10)"`, - ); - expect(calc('10px').multiply(10, 2).toString()).toMatchInlineSnapshot( - `"calc(10px * 10 * 2)"`, - ); - expect(calc('10px').divide(10).toString()).toMatchInlineSnapshot( - `"calc(10px / 10)"`, - ); - expect(calc('10px').divide(10, 2).toString()).toMatchInlineSnapshot( - `"calc(10px / 10 / 2)"`, - ); - expect( - calc('10px').add('20px').multiply(2).toString(), - ).toMatchInlineSnapshot(`"calc((10px + 20px) * 2)"`); - expect( - calc('10px').add('20px').divide(2).toString(), - ).toMatchInlineSnapshot(`"calc((10px + 20px) / 2)"`); - expect( - calc('20px').subtract('10px').negate().toString(), - ).toMatchInlineSnapshot(`"calc((20px - 10px) * -1)"`); - expect( - calc('10px').multiply(100).divide(2).negate().toString(), - ).toMatchInlineSnapshot(`"calc(((10px * 100) / 2) * -1)"`); - expect( - calc('10px') - .add('50px') - .subtract('20px') - .multiply(100) - .divide(2) - .negate() - .toString(), - ).toMatchInlineSnapshot( - `"calc(((((10px + 50px) - 20px) * 100) / 2) * -1)"`, - ); - }); - - it('bailing early', () => { - expect(calc('10px').toString()).toMatchInlineSnapshot(`"10px"`); - }); - - it('string coercion', () => { - expect(calc('10px').toString()).toMatchInlineSnapshot(`"10px"`); - expect(calc('10px').add('20px').toString()).toMatchInlineSnapshot( - `"calc(10px + 20px)"`, - ); - expect(`${calc('10px').add('20px')}`).toMatchInlineSnapshot( - `"calc(10px + 20px)"`, - ); - expect( - `${calc('10px').add(calc('20px').subtract('4em'))}`, - ).toMatchInlineSnapshot(`"calc(10px + (20px - 4em))"`); - expect(`${calc('10px').add(calc('20px'))}`).toMatchInlineSnapshot( - `"calc(10px + 20px)"`, - ); - }); - }); - - it('add', () => { - expect(calc.add(1, 2)).toMatchInlineSnapshot(`"calc(1 + 2)"`); - expect(calc.add(1, 2, 3)).toMatchInlineSnapshot(`"calc(1 + 2 + 3)"`); - expect(calc.add('1', 2, 3 - 4)).toMatchInlineSnapshot(`"calc(1 + 2 + -1)"`); - expect(calc.add('10px', '2em')).toMatchInlineSnapshot(`"calc(10px + 2em)"`); - expect( - calc.add('10px', '2em', calc.add('2', '6rem')), - ).toMatchInlineSnapshot(`"calc(10px + 2em + (2 + 6rem))"`); - expect( - calc.add( - calc.multiply( - calc.subtract('10px', '2em'), - calc.add('2', '6rem'), - '4px', - ), - ), - ).toMatchInlineSnapshot(`"calc(((10px - 2em) * (2 + 6rem) * 4px))"`); - }); - - it('subtract', () => { - expect(calc.subtract(1, 2)).toMatchInlineSnapshot(`"calc(1 - 2)"`); - expect(calc.subtract(1, 2, 3)).toMatchInlineSnapshot(`"calc(1 - 2 - 3)"`); - expect(calc.subtract('1', 2, 3 - 4)).toMatchInlineSnapshot( - `"calc(1 - 2 - -1)"`, - ); - expect(calc.subtract('10px', '2em')).toMatchInlineSnapshot( - `"calc(10px - 2em)"`, - ); - expect( - calc.subtract('10px', '2em', calc.add('2', '6rem')), - ).toMatchInlineSnapshot(`"calc(10px - 2em - (2 + 6rem))"`); - }); - - it('multiply', () => { - expect(calc.multiply(1, 2)).toMatchInlineSnapshot(`"calc(1 * 2)"`); - expect(calc.multiply(1, 2, 3)).toMatchInlineSnapshot(`"calc(1 * 2 * 3)"`); - expect(calc.multiply('1', 2, 3 - 4)).toMatchInlineSnapshot( - `"calc(1 * 2 * -1)"`, - ); - expect(calc.multiply('10px', '2em')).toMatchInlineSnapshot( - `"calc(10px * 2em)"`, - ); - expect( - calc.multiply('10px', '2em', calc.add('2', '6rem')), - ).toMatchInlineSnapshot(`"calc(10px * 2em * (2 + 6rem))"`); - }); - - it('divide', () => { - expect(calc.divide(1, 2)).toMatchInlineSnapshot(`"calc(1 / 2)"`); - expect(calc.divide(1, 2, 3)).toMatchInlineSnapshot(`"calc(1 / 2 / 3)"`); - expect(calc.divide('1', 2, 3 - 4)).toMatchInlineSnapshot( - `"calc(1 / 2 / -1)"`, - ); - expect(calc.divide('10px', '2em')).toMatchInlineSnapshot( - `"calc(10px / 2em)"`, - ); - expect( - calc.divide('10px', '2em', calc.add('2', '6rem')), - ).toMatchInlineSnapshot(`"calc(10px / 2em / (2 + 6rem))"`); - }); - - it('negate', () => { - expect(calc.negate(2)).toMatchInlineSnapshot(`"calc(2 * -1)"`); - expect(calc.negate(3 - 4)).toMatchInlineSnapshot(`"calc(-1 * -1)"`); - expect(calc.negate(calc.add('10px', '2em'))).toMatchInlineSnapshot( - `"calc((10px + 2em) * -1)"`, - ); - }); -}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 47d1f4db3..6fb10b9b7 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) => 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 { calc } from './calc'; +export { transition } from './transition';