Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions packages/utils/src/calc/calc.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
208 changes: 208 additions & 0 deletions packages/utils/src/calc/calc.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
1 change: 1 addition & 0 deletions packages/utils/src/calc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './calc';
Loading