diff --git a/src/__tests__/native/calc.test.tsx b/src/__tests__/native/calc.test.tsx index e31bf886..6ce3fdeb 100644 --- a/src/__tests__/native/calc.test.tsx +++ b/src/__tests__/native/calc.test.tsx @@ -224,3 +224,70 @@ test("dynamic clamp prefers the minimum when bounds cross and follows new values expect(screen.getByTestId(testID).props.style.width).toBe(expected); } }); + +test("calc(var(--radius) + 8px)", () => { + registerCSS( + `.my-class { + --radius: 0.5rem; + border-radius: calc(var(--radius) + 8px); + }`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.type).toBe("View"); + expect(component.props.style).toStrictEqual({ + borderRadius: 15, + }); +}); + +test("calc(1rem - 4px)", () => { + registerCSS( + `.my-class { + width: calc(1rem - 4px); + }`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.type).toBe("View"); + expect(component.props.style).toStrictEqual({ + width: 10, + }); +}); + +test("min & max with mixed units", () => { + registerCSS( + `.my-class { + width: min(1rem, 20px); + height: max(10px, 0.5rem); + }`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.type).toBe("View"); + expect(component.props.style).toStrictEqual({ + width: 14, + height: 10, + }); +}); + +test("clamp with mixed units", () => { + registerCSS( + `.my-class { + width: clamp(8px, 1rem, 20px); + }`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.type).toBe("View"); + expect(component.props.style).toStrictEqual({ + width: 14, + }); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..c0ecc179 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -5,6 +5,8 @@ import type { Angle, BorderSideWidth, BorderStyle, + CalcFor_DimensionPercentageFor_LengthValue, + CalcFor_Length, ColorOrAuto, CssColor, CustomProperty, @@ -1508,8 +1510,7 @@ export function parseLength( } else { switch (length.type) { case "calc": { - // TODO: Add the calc polyfill - return undefined; + return evaluateCalc(length.value, builder); } case "number": { return round(length.value); @@ -1527,6 +1528,90 @@ export function parseLength( return; } +type CalcNode = + | number + | CalcFor_Length + | CalcFor_DimensionPercentageFor_LengthValue; + +function evaluateCalc( + calc: CalcNode, + builder: StylesheetBuilder, +): StyleDescriptor { + if (typeof calc === "number") { + return round(calc); + } + + switch (calc.type) { + case "number": + return round(calc.value); + case "value": + return parseLength(calc.value, builder); + case "sum": { + const values: StyleDescriptor[] = [ + evaluateCalc(calc.value[0], builder), + evaluateCalc(calc.value[1], builder), + ]; + if (values.some((v) => v === undefined)) return undefined; + + const hasPercent = values.some( + (v) => typeof v === "string" && v.endsWith("%"), + ); + const hasNumber = values.some((v) => typeof v === "number"); + + // React Native cannot mix percentage and length + if (hasPercent && hasNumber) { + return undefined; + } + + if (values.every((v): v is number => typeof v === "number")) { + return round(values.reduce((a, b) => a + b, 0)); + } + return [{}, "calc", values]; + } + case "product": { + const a = evaluateCalc(calc.value[0], builder); + const b = evaluateCalc(calc.value[1], builder); + if (a === undefined || b === undefined) return undefined; + if (typeof a === "number" && typeof b === "number") { + return round(a * b); + } + return [{}, "calc", [a, "*", b]]; + } + case "function": { + switch (calc.value.type) { + case "calc": + return evaluateCalc(calc.value.value, builder); + case "min": + case "max": { + const fn = calc.value.type; + const values: StyleDescriptor[] = calc.value.value.map((c) => + evaluateCalc(c, builder), + ); + if (values.every((v): v is number => typeof v === "number")) { + return round(Math[fn](...values)); + } + return [{}, fn, values]; + } + case "clamp": { + const min = evaluateCalc(calc.value.value[0], builder); + const val = evaluateCalc(calc.value.value[1], builder); + const max = evaluateCalc(calc.value.value[2], builder); + if ( + typeof min === "number" && + typeof val === "number" && + typeof max === "number" + ) { + return round(Math.max(min, Math.min(val, max))); + } + return [{}, "clamp", [min, val, max]]; + } + default: + return undefined; + } + } + } +} + export function parseAngle(angle: Angle | number, builder: StylesheetBuilder) { if (typeof angle === "number") { return `${angle}deg`;