diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx
index b08e24c3..082f9a10 100644
--- a/src/__tests__/compiler/compiler.test.tsx
+++ b/src/__tests__/compiler/compiler.test.tsx
@@ -333,6 +333,113 @@ test("warnings", () => {
});
});
+test("a transform value the compiler cannot convert is ignored, with a warning", () => {
+ const compiled = compile(`
+.my-class {
+ transform: translate(10px, 1cap);
+ translate: calc(1em + 5px) 5px;
+ rotate: 1grad;
+ scale: 2;
+}`);
+
+ expect(compiled.stylesheet()).toStrictEqual({
+ s: [
+ [
+ "my-class",
+ [
+ {
+ d: [
+ [
+ [
+ [{}, "scaleX", 2],
+ [{}, "scaleY", 2],
+ ],
+ "scale",
+ ],
+ ],
+ s: [1, 1],
+ },
+ ],
+ ],
+ ],
+ });
+
+ expect(compiled.warnings()).toStrictEqual({
+ values: {
+ transform: ["1cap"],
+ translate: ["calc()"],
+ rotate: ["1 grad"],
+ },
+ });
+});
+
+test("a scale along z or a rotate about an oblique axis warns", () => {
+ const compiled = compile(`
+.my-class {
+ scale: 2 3 400%;
+ rotate: 1 1 0 45deg;
+}
+.unwarned {
+ scale: 2 3 100%;
+ translate: 10px 20px 30px;
+ rotate: 0 0 0 45deg;
+}`);
+
+ expect(compiled.stylesheet()).toStrictEqual({
+ s: [
+ [
+ "my-class",
+ [
+ {
+ d: [
+ [
+ [
+ [{}, "scaleX", 2],
+ [{}, "scaleY", 3],
+ ],
+ "scale",
+ ],
+ ],
+ s: [1, 1],
+ },
+ ],
+ ],
+ [
+ "unwarned",
+ [
+ {
+ d: [
+ [
+ [
+ [{}, "scaleX", 2],
+ [{}, "scaleY", 3],
+ ],
+ "scale",
+ ],
+ [
+ [
+ [{}, "translateX", 10],
+ [{}, "translateY", 20],
+ ],
+ "translate",
+ ],
+ [[{}, "rotateZ", "0deg"], "rotate"],
+ ],
+ s: [2, 1],
+ },
+ ],
+ ],
+ ],
+ });
+
+ expect(compiled.warnings()).toStrictEqual({
+ values: {
+ scale: ["400%"],
+ rotate: ["1 1 0 45deg"],
+ },
+ });
+});
+
test("simplifies rem", () => {
const compiled = compile(`.test {
border-width: calc(10rem + 2px);
diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx
index c2e18378..4dbd378f 100644
--- a/src/__tests__/compiler/declarations.test.tsx
+++ b/src/__tests__/compiler/declarations.test.tsx
@@ -8,8 +8,8 @@ const tests = [
["-rn-ripple-layer: foreground;", [{ d: [[true, ["android_ripple", "foreground"]]], s: [1, 1] }]],
["caret-color: black", [{ d: [["#000", ["cursorColor"]]], s: [1, 1] }]],
["fill: black;", [{ d: [["#000", ["fill"]]], s: [1, 1] }]],
- ["rotate: 3deg;", [{ d: [[[{}, "rotateZ", "3deg"], "rotateZ"]], s: [1, 1] }]],
- ["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotateX"]], s: [1, 1] }]],
+ ["rotate: 3deg;", [{ d: [[[{}, "rotateZ", "3deg"], "rotate"]], s: [1, 1] }]],
+ ["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotate"]], s: [1, 1] }]],
["stroke-width: 1px;", [{ d: [[1, ["strokeWidth"]]], s: [1, 1] }]],
["stroke: black;", [{ d: [["#000", ["stroke"]]], s: [1, 1] }]],
] as const;
diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx
index 7a59b816..7b7edeaf 100644
--- a/src/__tests__/native/transform.test.tsx
+++ b/src/__tests__/native/transform.test.tsx
@@ -154,3 +154,351 @@ describe("transform", () => {
});
});
});
+
+function renderStyle(css: string): unknown {
+ registerCSS(css);
+
+ return render(
+ ,
+ ).getByTestId(testID).props.style;
+}
+
+// A second definition keeps each variable from being inlined, so it resolves at runtime
+const decoy = `.decoy { --sx: 9; --sy: 9; --t: 9px; --offset: 1px; --s: 1; --x: 9px; --y: 9px; --r: 9deg; }`;
+
+// React Native requires exactly one property per transform object, and its validator throws otherwise
+describe("one property per entry", () => {
+ // Chromium 153, Firefox 155 and WebKit 26.6 scale each axis by its own operand
+ test.each([
+ ["75%", "50%", [{ scaleX: 0.75 }, { scaleY: 0.5 }]],
+ ["2", "3", [{ scaleX: 2 }, { scaleY: 3 }]],
+ ])(
+ "transform: scale(var(--sx), var(--sy)) with %s and %s renders an entry per axis",
+ (sx, sy, transform) => {
+ expect(
+ renderStyle(
+ `${decoy} .my-class { --sx: ${sx}; --sy: ${sy}; transform: scale(var(--sx), var(--sy)); }`,
+ ),
+ ).toStrictEqual({ transform });
+ },
+ );
+
+ test("transform: scale(var(--sx)) scales both axes by its one operand", () => {
+ expect(
+ renderStyle(
+ `${decoy} .my-class { --sx: 75%; transform: scale(var(--sx)); }`,
+ ),
+ ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] });
+ });
+
+ test("scale: var(--sx) scales both axes by its one value", () => {
+ expect(
+ renderStyle(`${decoy} .my-class { --sx: 75%; scale: var(--sx); }`),
+ ).toStrictEqual({ transform: [{ scaleX: 0.75 }, { scaleY: 0.75 }] });
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 translate a one-operand translate() along x only
+ test.each([
+ "transform: translate(10px);",
+ "--t: 10px; transform: translate(var(--t));",
+ "--t: 10px; translate: var(--t);",
+ ])("%s translates along x only", (declarations) => {
+ expect(renderStyle(`${decoy} .my-class { ${declarations} }`)).toStrictEqual(
+ { transform: [{ translateX: 10 }, { translateY: 0 }] },
+ );
+ });
+
+ // Chromium 153, Firefox 155 and WebKit 26.6 read an operand list a var() supplies whole
+ test.each([
+ [
+ "--offset: 10px 20px; translate: var(--offset);",
+ [{ translateX: 10 }, { translateY: 20 }],
+ ],
+ [
+ "--offset: 10px, 20px; transform: translate(var(--offset));",
+ [{ translateX: 10 }, { translateY: 20 }],
+ ],
+ ["--s: 2 3; scale: var(--s);", [{ scaleX: 2 }, { scaleY: 3 }]],
+ ] as const)("%s reads both operands", (declarations, transform) => {
+ expect(renderStyle(`${decoy} .my-class { ${declarations} }`)).toStrictEqual(
+ { transform },
+ );
+ });
+
+ test("a two-operand translate() renders an entry per axis, beside a sibling", () => {
+ expect(
+ renderStyle(
+ `${decoy} .my-class { --t: 10px; transform: translate(var(--t), var(--t)) rotate(45deg); }`,
+ ),
+ ).toStrictEqual({
+ transform: [{ translateX: 10 }, { translateY: 10 }, { rotate: "45deg" }],
+ });
+ });
+
+ // React Native refuses a transform entry without a property, so a function the compiler does not convert contributes none
+ test.each([
+ "transform: scale3d(1, 2, 3);",
+ "transform: scaleZ(2);",
+ "transform: matrix(1, 0, 0, 1, 0, 0);",
+ ])("%s renders no entry", (declaration) => {
+ expect(renderStyle(`.my-class { ${declaration} }`)).toStrictEqual({
+ transform: [],
+ });
+ });
+
+ test("a translate() pair in a var() fallback renders an entry per axis", () => {
+ expect(
+ renderStyle(
+ `.my-class { transform: var(--missing, translate(10px, 20px)); }`,
+ ),
+ ).toStrictEqual({ transform: [{ translateX: 10 }, { translateY: 20 }] });
+ });
+
+ test("a function React Native has no form for leaves its neighbour", () => {
+ expect(
+ renderStyle(
+ `.my-class { transform: translateX(10px) scale3d(1, 2, 3); }`,
+ ),
+ ).toStrictEqual({ transform: [{ translateX: 10 }] });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: a function left without a valid argument makes the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1)
+test.each(
+ (
+ [
+ ["", "scaleX(var(--missing))"],
+ ["", "translate(10px, var(--missing))"],
+ ["", "rotate(var(--missing))"],
+ ["", "scale(2, var(--missing))"],
+ ["--offset: 10px 20px; ", "var(--offset)"],
+ ["--sx: none; ", "scaleX(var(--sx))"],
+ ["--sx: none; ", "scale(var(--sx))"],
+ ["--t: 1px, 2px, 3px; ", "translate(var(--t))"],
+ ["--s: 1, 2, 3; ", "scale(var(--s))"],
+ ["--r: 10deg, 20deg; ", "rotate(var(--r))"],
+ ] as const
+ ).flatMap(([customProperties, transform]) => [
+ `${customProperties}transform: ${transform};`,
+ `${customProperties}transform: translateX(5px) ${transform};`,
+ ]),
+)("%s leaves no transform", (declarations) => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { transform: rotate(10deg); } .my-class { ${declarations} }`,
+ ),
+ ).toStrictEqual({ transform: [] });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: a scale or translate longhand left without a value is none, and the transform property beside it stands
+test.each([
+ "scale: var(--missing);",
+ "scale: var(--missing) var(--missing);",
+ "translate: var(--missing) var(--missing);",
+ "--sx: none; --sy: 2; scale: var(--sx) var(--sy);",
+ "scale: unset;",
+ "scale: initial;",
+ "translate: initial;",
+])("%s leaves the transform property's entries", (declarations) => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { transform: rotate(10deg); } .my-class { ${declarations} }`,
+ ),
+ ).toStrictEqual({ transform: [{ rotate: "10deg" }] });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 cascade each transform property on its own, and compose translate, rotate, scale, then the transform list (css-transforms-2 §6)
+test.each([
+ [
+ ".earlier { translate: 10px 20px; } .my-class { --x: 1px; --y: 2px; translate: var(--x) var(--y); }",
+ [{ translateX: 1 }, { translateY: 2 }],
+ ],
+ [
+ ".earlier { --x: 1px; --y: 2px; translate: var(--x) var(--y); } .my-class { translate: 10px 20px; }",
+ [{ translateX: 10 }, { translateY: 20 }],
+ ],
+ [
+ ".earlier { scale: 2; } .my-class { scale: 3; }",
+ [{ scaleX: 3 }, { scaleY: 3 }],
+ ],
+ [
+ ".earlier { transform: rotate(10deg); } .my-class { rotate: 20deg; }",
+ [{ rotateZ: "20deg" }, { rotate: "10deg" }],
+ ],
+ [
+ ".earlier { translate: 10px 20px; } .my-class { --t: 5px; transform: translateX(var(--t)); }",
+ [{ translateX: 10 }, { translateY: 20 }, { translateX: 5 }],
+ ],
+ [
+ ".earlier { --t: 5px; transform: translateX(var(--t)); } .my-class { translate: 10px 20px; }",
+ [{ translateX: 10 }, { translateY: 20 }, { translateX: 5 }],
+ ],
+ [
+ ".my-class { translate: 10px 20px; rotate: 30deg; scale: 2; transform: translateX(5px); }",
+ [
+ { translateX: 10 },
+ { translateY: 20 },
+ { rotateZ: "30deg" },
+ { scaleX: 2 },
+ { scaleY: 2 },
+ { translateX: 5 },
+ ],
+ ],
+ [
+ ".earlier { translate: 10px 20px !important; } .my-class { translate: 1px 2px; }",
+ [{ translateX: 10 }, { translateY: 20 }],
+ ],
+ [
+ ".earlier { transform: rotate(10deg) !important; } .my-class { translate: 1px 2px; }",
+ [{ translateX: 1 }, { translateY: 2 }, { rotate: "10deg" }],
+ ],
+] as const)("%s composes in CSS order", (css, transform) => {
+ expect(renderStyle(`${decoy} ${css}`)).toStrictEqual({ transform });
+});
+
+// A unit React Native has no equivalent for leaves the declaration invalid there, and an invalid declaration is ignored (CSS 2.2 §4.2)
+test.each([
+ [
+ ".earlier { translate: 5px 6px; } .my-class { translate: 10px 1cap; }",
+ [{ translateX: 5 }, { translateY: 6 }],
+ ],
+ [
+ ".earlier { transform: rotate(5deg); } .my-class { translate: 10px 1cap; }",
+ [{ rotate: "5deg" }],
+ ],
+ [
+ ".earlier { transform: rotate(5deg); } .my-class { transform: translate(10px, 1cap); }",
+ [{ rotate: "5deg" }],
+ ],
+] as const)(
+ "%s ignores the declaration it cannot convert",
+ (css, transform) => {
+ expect(renderStyle(css)).toStrictEqual({ transform });
+ },
+);
+
+// Chromium 153, Firefox 155 and WebKit 26.6 resolve an em length against the element's font size, whichever rule sets it (css-values-4 §6.1.1)
+test.each([
+ [
+ ".earlier { translate: 1em 5px; } .my-class { font-size: 20px; }",
+ [{ translateX: 20 }, { translateY: 5 }],
+ ],
+ [
+ ".earlier { transform: translateX(1em); } .my-class { font-size: 20px; }",
+ [{ translateX: 20 }],
+ ],
+] as const)(
+ "%s resolves em against the element's font size",
+ (css, transform) => {
+ expect(renderStyle(css)).toStrictEqual({ fontSize: 20, transform });
+ },
+);
+
+// Chromium 153 and Firefox 155 rotate about the axis a vector names, reversing the angle about a negative axis, and rotate by none about the zero vector; WebKit 26.6 ignores the direction of a z axis
+test.each([
+ ["rotate: 0 0 -1 45deg;", [{ rotateZ: "-45deg" }]],
+ ["--r: 0 0 -1 45deg; rotate: var(--r);", [{ rotateZ: "-45deg" }]],
+ ["rotate: -1 0 0 45deg;", [{ rotateX: "-45deg" }]],
+ ["--r: -1 0 0 45deg; rotate: var(--r);", [{ rotateX: "-45deg" }]],
+ ["rotate: 0 0 2 45deg;", [{ rotateZ: "45deg" }]],
+ ["--r: 0 0 2 45deg; rotate: var(--r);", [{ rotateZ: "45deg" }]],
+ ["rotate: x 45deg;", [{ rotateX: "45deg" }]],
+ ["--r: x 45deg; rotate: var(--r);", [{ rotateX: "45deg" }]],
+ ["rotate: 45deg y;", [{ rotateY: "45deg" }]],
+ ["--r: 45deg y; rotate: var(--r);", [{ rotateY: "45deg" }]],
+ ["--r: 20deg; rotate: var(--r);", [{ rotateZ: "20deg" }]],
+ ["--r: x; rotate: var(--r) 45deg;", [{ rotateX: "45deg" }]],
+ ["--r: 0 0 -1; rotate: var(--r) 45deg;", [{ rotateZ: "-45deg" }]],
+ ["rotate: 0 0 0 45deg;", [{ rotateZ: "0deg" }]],
+ ["--r: 0 0 0 45deg; rotate: var(--r);", [{ rotateZ: "0deg" }]],
+ ["--r: none; rotate: var(--r);", [{ rotateZ: "0deg" }]],
+] as const)("%s rotates about the axis it names", (declarations, transform) => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { rotate: 10deg; } .my-class { ${declarations} }`,
+ ),
+ ).toStrictEqual({ transform });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 read the third value of translate and scale as its z, which React Native has no axis for
+test.each([
+ ["translate: 10px 20px 30px;", [{ translateX: 10 }, { translateY: 20 }]],
+ [
+ "--t: 10px 20px 30px; translate: var(--t);",
+ [{ translateX: 10 }, { translateY: 20 }],
+ ],
+ [
+ "--t: 20px 30px; translate: 10px var(--t);",
+ [{ translateX: 10 }, { translateY: 20 }],
+ ],
+ ["--t: none; translate: var(--t);", [{ translateX: 0 }, { translateY: 0 }]],
+ ["--t: NONE; translate: var(--t);", [{ translateX: 0 }, { translateY: 0 }]],
+ ["scale: 2 3 4;", [{ scaleX: 2 }, { scaleY: 3 }]],
+ ["--s: 2 3 4; scale: var(--s);", [{ scaleX: 2 }, { scaleY: 3 }]],
+ ["--s: 3 4; scale: 2 var(--s);", [{ scaleX: 2 }, { scaleY: 3 }]],
+ ["--s: none; scale: var(--s);", [{ scaleX: 1 }, { scaleY: 1 }]],
+] as const)("%s renders its x and y", (declarations, transform) => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { translate: 1px 2px; scale: 5; } .my-class { translate: none; scale: none; ${declarations} }`,
+ ),
+ ).toStrictEqual({
+ transform: [
+ ...(declarations.includes("translate")
+ ? transform
+ : [{ translateX: 0 }, { translateY: 0 }]),
+ ...(declarations.includes("scale")
+ ? transform
+ : [{ scaleX: 1 }, { scaleY: 1 }]),
+ ],
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: a value the property's grammar does not take is invalid at computed-value time, so the property is none (css-variables-1 §3.1)
+test.each([
+ "--r: x y 45deg; rotate: var(--r);",
+ "--r: 0 45deg 0 1; rotate: var(--r);",
+ "--t: 10px 20px 30%; translate: var(--t);",
+ "--t: 1px 2px 3px 4px; translate: var(--t);",
+ "--s: 2 3 4 5; scale: var(--s);",
+ "--s: 2 3 red; scale: var(--s);",
+ "--s: none 2; scale: var(--s);",
+])("%s leaves the property none", (declarations) => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { translate: 1px 2px; rotate: 10deg; scale: 5; transform: translateX(5px); } .my-class { ${declarations} }`,
+ ),
+ ).toStrictEqual({
+ transform: [
+ ...(declarations.includes("translate:")
+ ? []
+ : [{ translateX: 1 }, { translateY: 2 }]),
+ ...(declarations.includes("rotate:") ? [] : [{ rotateZ: "10deg" }]),
+ ...(declarations.includes("scale:")
+ ? []
+ : [{ scaleX: 5 }, { scaleY: 5 }]),
+ { translateX: 5 },
+ ],
+ });
+});
+
+// React Native's rotate keys turn only about an axis, so a rotate about a vector off every axis is ignored when written and none when substituted
+test.each([
+ ["rotate: 1 1 0 45deg;", [{ rotateZ: "10deg" }]],
+ ["--r: 1 1 0 45deg; rotate: var(--r);", []],
+] as const)("%s rotates by none", (declarations, transform) => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { rotate: 10deg; } .my-class { ${declarations} }`,
+ ),
+ ).toStrictEqual(transform.length === 0 ? {} : { transform });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6: a later transform without a value replaces an earlier one, and is none (css-variables-1 §3.1)
+test("transform: var(--missing) replaces an earlier rule's transform", () => {
+ expect(
+ renderStyle(
+ `${decoy} .earlier { transform: rotate(10deg); } .my-class { transform: var(--missing); }`,
+ ),
+ ).toStrictEqual({ transform: [] });
+});
diff --git a/src/__tests__/vendor/tailwind/transform.test.ts b/src/__tests__/vendor/tailwind/transform.test.ts
index 2a7cf239..7d859046 100644
--- a/src/__tests__/vendor/tailwind/transform.test.ts
+++ b/src/__tests__/vendor/tailwind/transform.test.ts
@@ -26,7 +26,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
- transform: [{ scale: 0 }],
+ transform: [{ scaleX: 0 }, { scaleY: 0 }],
},
},
});
@@ -53,7 +53,7 @@ describe("Transforms - Scale", () => {
expect(await renderCurrentTest()).toStrictEqual({
props: {
style: {
- transform: [{ scale: 0.5 }],
+ transform: [{ scaleX: 0.5 }, { scaleY: 0.5 }],
},
},
});
@@ -233,11 +233,12 @@ describe("Transforms - Mixed", () => {
).toStrictEqual({
props: {
style: {
+ // css-transforms-2 §6: translate, then rotate, then the transform list
transform: [
- { skewY: "1deg" },
{ translateX: 3.5 },
{ translateY: 0 },
{ rotateZ: "90deg" },
+ { skewY: "1deg" },
],
},
},
diff --git a/src/compiler/calculation.ts b/src/compiler/calculation.ts
new file mode 100644
index 00000000..3f7cc715
--- /dev/null
+++ b/src/compiler/calculation.ts
@@ -0,0 +1,145 @@
+import type {
+ DimensionPercentageFor_LengthValue,
+ Length,
+ RoundingStrategy,
+ TokenOrValue,
+} from "lightningcss";
+
+import {
+ commaSeparated,
+ delimiterToken,
+ functionToken,
+ identifierToken,
+ lengthToken,
+ numberToken,
+ percentageToken,
+ WHITESPACE_TOKEN,
+} from "./tokens";
+
+export type LengthLeaf = Length | DimensionPercentageFor_LengthValue;
+
+/** lightningcss's calculation tree for a length, as it holds one it could not fold. */
+export type LengthCalculation =
+ | { readonly type: "value"; readonly value: LengthLeaf }
+ | { readonly type: "number"; readonly value: number }
+ | {
+ readonly type: "sum";
+ readonly value: readonly [LengthCalculation, LengthCalculation];
+ }
+ | {
+ readonly type: "product";
+ readonly value: readonly [number, LengthCalculation];
+ }
+ | { readonly type: "function"; readonly value: LengthMathFunction };
+
+export type LengthMathFunction =
+ | {
+ readonly type: "calc" | "abs" | "sign";
+ readonly value: LengthCalculation;
+ }
+ | {
+ readonly type: "min" | "max" | "hypot";
+ readonly value: readonly LengthCalculation[];
+ }
+ | {
+ readonly type: "clamp";
+ readonly value: readonly [
+ LengthCalculation,
+ LengthCalculation,
+ LengthCalculation,
+ ];
+ }
+ | {
+ readonly type: "round";
+ readonly value: readonly [
+ RoundingStrategy,
+ LengthCalculation,
+ LengthCalculation,
+ ];
+ }
+ | {
+ readonly type: "rem" | "mod";
+ readonly value: readonly [LengthCalculation, LengthCalculation];
+ };
+
+export function lengthLeafTokens(leaf: LengthLeaf): TokenOrValue[] {
+ switch (leaf.type) {
+ case "value":
+ case "dimension":
+ return [lengthToken(leaf.value)];
+ case "percentage":
+ return [percentageToken(leaf.value)];
+ case "calc":
+ return [calculationToken(leaf.value)];
+ }
+}
+
+/** The one token a calculation is written as: its own math function, or `calc()` around anything else. */
+export function calculationToken(calculation: LengthCalculation): TokenOrValue {
+ return calculation.type === "function"
+ ? mathFunctionToken(calculation.value)
+ : functionToken("calc", calculationTokens(calculation));
+}
+
+function calculationTokens(calculation: LengthCalculation): TokenOrValue[] {
+ switch (calculation.type) {
+ case "value":
+ return lengthLeafTokens(calculation.value);
+ case "number":
+ return [numberToken(calculation.value)];
+ case "sum":
+ return [
+ ...calculationTokens(calculation.value[0]),
+ WHITESPACE_TOKEN,
+ delimiterToken("+"),
+ WHITESPACE_TOKEN,
+ ...calculationTokens(calculation.value[1]),
+ ];
+ case "product": {
+ const [factor, operand] = calculation.value;
+
+ return [
+ numberToken(factor),
+ WHITESPACE_TOKEN,
+ delimiterToken("*"),
+ WHITESPACE_TOKEN,
+ ...(operand.type === "sum"
+ ? [calculationToken(operand)]
+ : calculationTokens(operand)),
+ ];
+ }
+ case "function":
+ return [mathFunctionToken(calculation.value)];
+ }
+}
+
+function mathFunctionToken(math: LengthMathFunction): TokenOrValue {
+ switch (math.type) {
+ case "calc":
+ case "abs":
+ case "sign":
+ return functionToken(math.type, calculationTokens(math.value));
+ case "min":
+ case "max":
+ case "hypot":
+ case "clamp":
+ case "rem":
+ case "mod":
+ return functionToken(
+ math.type,
+ commaSeparated(math.value.map(calculationTokens)),
+ );
+ case "round": {
+ const [strategy, value, interval] = math.value;
+
+ return functionToken(
+ "round",
+ commaSeparated([
+ [identifierToken(strategy)],
+ calculationTokens(value),
+ calculationTokens(interval),
+ ]),
+ );
+ }
+ }
+}
diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts
index 363ded58..6229a2f5 100644
--- a/src/compiler/compiler.ts
+++ b/src/compiler/compiler.ts
@@ -33,6 +33,7 @@ import {
reduceParseUnparsed,
round,
} from "./declarations";
+import { unparseIndividualTransform } from "./individual-transforms";
import { inlineVariables } from "./inline-variables";
import { extractKeyFrames } from "./keyframes";
import { lightningcssLoader } from "./lightningcss-loader";
@@ -145,26 +146,33 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) {
};
}
- if (options.inlineVariables !== false) {
- const exclusionList: string[] = options.inlineVariables?.exclude ?? [];
+ const exclusionList: string[] =
+ options.inlineVariables === false
+ ? []
+ : (options.inlineVariables?.exclude ?? []);
+
+ firstPassVisitor.Declaration = (decl) => {
+ if (
+ options.inlineVariables !== false &&
+ decl.property === "custom" &&
+ decl.value.name.startsWith("--") &&
+ !exclusionList.includes(decl.value.name)
+ ) {
+ const entry = vars.get(decl.value.name) ?? {
+ count: 0,
+ value: [
+ ...decl.value.value,
+ { type: "token", value: { type: "white-space", value: " " } },
+ ],
+ };
+ entry.count++;
+ vars.set(decl.value.name, entry);
+ }
- firstPassVisitor.Declaration = (decl) => {
- if (
- decl.property === "custom" &&
- decl.value.name.startsWith("--") &&
- !exclusionList.includes(decl.value.name)
- ) {
- const entry = vars.get(decl.value.name) ?? {
- count: 0,
- value: [
- ...decl.value.value,
- { type: "token", value: { type: "white-space", value: " " } },
- ],
- };
- entry.count++;
- vars.set(decl.value.name, entry);
- }
- };
+ return unparseIndividualTransform(decl);
+ };
+
+ if (options.inlineVariables !== false) {
firstPassVisitor.StyleSheetExit = (sheet) => {
return inlineVariables(sheet, vars);
};
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 9ca2aab8..1d6c7001 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -36,7 +36,7 @@ import type {
UnresolvedColor,
} from "lightningcss";
-import { isStyleFunction } from "../utilities";
+import { isStyleFunction, negateAngle, rotationAxis } from "../utilities";
import { isMappingProperty } from "./atRules";
import type {
StyleDescriptor,
@@ -102,6 +102,13 @@ const unparsedRuntimeParsing = new Set([
"translate",
]);
+// css-transforms-2 §3: the individual transform properties each have a grammar of their own, which the transform function of the same name does not share
+const INDIVIDUAL_TRANSFORM_RESOLVERS: ReadonlyMap = new Map([
+ ["rotate", "individualRotate"],
+ ["scale", "individualScale"],
+ ["translate", "individualTranslate"],
+]);
+
const parsers: {
[K in Declaration["property"]]?: Parser;
} = {
@@ -736,130 +743,126 @@ function parseFont(
builder.addDescriptor("font-weight", parseFontWeight(value.weight, builder));
}
+type TransformFunction = Extract<
+ StyleFunction,
+ [Record, string, StyleDescriptor]
+>;
+
+// An argument React Native has no equivalent for leaves the declaration invalid there, and an invalid declaration is ignored (CSS 2.2 §4.2)
+function hasUnconvertibleArgument(
+ functions: readonly TransformFunction[],
+): boolean {
+ return functions.some(([, , argument]) => argument === undefined);
+}
+
function parseTransform(
{ value }: DeclarationType<"transform">,
builder: StylesheetBuilder,
) {
- builder.addDescriptor("transform", [
- {},
- "transform",
- value.flatMap((t): StyleDescriptor[] => {
- switch (t.type) {
- case "perspective":
- return [[{}, "perspective", parseLength(t.value, builder)]];
- case "translate":
- return [
- [
- {},
- "translateX",
- parseLengthOrCoercePercentageToRuntime(t.value[0], builder),
- ],
- [
- [
- {},
- "translateY",
- parseLengthOrCoercePercentageToRuntime(t.value[1], builder),
- ],
- ],
- ];
- case "translateX":
- return [
- [
- {},
- "translateX",
- parseLengthOrCoercePercentageToRuntime(t.value, builder),
- ],
- ];
- case "translateY":
- return [
- [
- {},
- "translateY",
- parseLengthOrCoercePercentageToRuntime(t.value, builder),
- ],
- ];
- case "rotate":
- return [[{}, "rotate", parseAngle(t.value, builder)]];
- case "rotateX":
- return [[{}, "rotateX", parseAngle(t.value, builder)]];
- case "rotateY":
- return [[{}, "rotateY", parseAngle(t.value, builder)]];
- case "rotateZ":
- return [[{}, "rotateZ", parseAngle(t.value, builder)]];
- case "scale":
- return [
- [{}, "scaleX", parseLength(t.value[0], builder)],
- [{}, "scaleY", parseLength(t.value[1], builder)],
- ];
- case "scaleX":
- return [[{}, "scaleX", parseLength(t.value, builder)]];
- case "scaleY":
- return [[{}, "scaleY", parseLength(t.value, builder)]];
- case "skew":
- return [
- [{}, "skewX", parseAngle(t.value[0], builder)],
- [{}, "skewY", parseAngle(t.value[1], builder)],
- ];
- case "skewX":
- return [[{}, "skewX", parseAngle(t.value, builder)]];
- case "skewY":
- return [[{}, "skewY", parseAngle(t.value, builder)]];
- case "translateZ":
- case "translate3d":
- case "scaleZ":
- case "scale3d":
- case "rotate3d":
- case "matrix":
- case "matrix3d":
- return [[]];
- }
- }),
- ]);
- return;
+ const functions = value.flatMap((t): TransformFunction[] => {
+ switch (t.type) {
+ case "perspective":
+ return [[{}, "perspective", parseLength(t.value, builder)]];
+ case "translate":
+ return [
+ [{}, "translateX", parseLength(t.value[0], builder)],
+ [{}, "translateY", parseLength(t.value[1], builder)],
+ ];
+ case "translateX":
+ return [[{}, "translateX", parseLength(t.value, builder)]];
+ case "translateY":
+ return [[{}, "translateY", parseLength(t.value, builder)]];
+ case "rotate":
+ return [[{}, "rotate", parseAngle(t.value, builder)]];
+ case "rotateX":
+ return [[{}, "rotateX", parseAngle(t.value, builder)]];
+ case "rotateY":
+ return [[{}, "rotateY", parseAngle(t.value, builder)]];
+ case "rotateZ":
+ return [[{}, "rotateZ", parseAngle(t.value, builder)]];
+ case "scale":
+ return [
+ [{}, "scaleX", parseLength(t.value[0], builder)],
+ [{}, "scaleY", parseLength(t.value[1], builder)],
+ ];
+ case "scaleX":
+ return [[{}, "scaleX", parseLength(t.value, builder)]];
+ case "scaleY":
+ return [[{}, "scaleY", parseLength(t.value, builder)]];
+ case "skew":
+ return [
+ [{}, "skewX", parseAngle(t.value[0], builder)],
+ [{}, "skewY", parseAngle(t.value[1], builder)],
+ ];
+ case "skewX":
+ return [[{}, "skewX", parseAngle(t.value, builder)]];
+ case "skewY":
+ return [[{}, "skewY", parseAngle(t.value, builder)]];
+ case "translateZ":
+ case "translate3d":
+ case "scaleZ":
+ case "scale3d":
+ case "rotate3d":
+ case "matrix":
+ case "matrix3d":
+ return [];
+ }
+ });
+
+ if (hasUnconvertibleArgument(functions)) {
+ return;
+ }
+
+ builder.addDescriptor("transform", [{}, "transform", functions]);
}
function parseTranslate(
{ value }: DeclarationType<"translate">,
builder: StylesheetBuilder,
) {
- builder.addDescriptor("translateX", [
- {},
- "translateX",
- parseTranslateProp(value, "x", builder),
- ]);
- builder.addDescriptor("translateY", [
- {},
- "translateY",
- parseTranslateProp(value, "y", builder),
- ]);
+ const translations: TransformFunction[] = [
+ [{}, "translateX", parseTranslateProp(value, "x", builder)],
+ [{}, "translateY", parseTranslateProp(value, "y", builder)],
+ ];
+
+ if (hasUnconvertibleArgument(translations)) {
+ return;
+ }
+
+ builder.addDescriptor("translate", translations);
}
function parseRotate(
{ value }: DeclarationType<"rotate">,
builder: StylesheetBuilder,
) {
- if (value.x) {
- builder.addDescriptor("rotateX", [
- {},
- "rotateX",
- parseAngle(value.angle, builder),
- ]);
- }
-
- if (value.y) {
- builder.addDescriptor("rotateY", [
- {},
- "rotateY",
- parseAngle(value.angle, builder),
- ]);
- }
+ const rotation = rotationAxis(value.x, value.y, value.z);
- if (value.z) {
- builder.addDescriptor("rotateZ", [
- {},
- "rotateZ",
- parseAngle(value.angle, builder),
- ]);
+ switch (rotation.type) {
+ case "none":
+ builder.addDescriptor("rotate", [{}, "rotateZ", "0deg"]);
+ return;
+ case "axis": {
+ const angle = parseAngle(value.angle, builder);
+
+ if (angle !== undefined) {
+ builder.addDescriptor("rotate", [
+ {},
+ rotation.rotation,
+ rotation.reversed ? negateAngle(angle) : angle,
+ ]);
+ }
+ return;
+ }
+ // React Native's rotate keys turn only about an axis, so a vector off every axis has none to take it
+ case "oblique":
+ builder.addWarning(
+ "value",
+ `${value.x} ${value.y} ${value.z} ${value.angle.value}${value.angle.type}`,
+ );
+ return;
+ default:
+ rotation satisfies never;
}
}
@@ -867,28 +870,32 @@ function parseScale(
{ value }: DeclarationType<"scale">,
builder: StylesheetBuilder,
) {
- builder.addDescriptor("scaleX", [
- {},
- "scaleX",
- parseScaleValue(value, "x", builder),
- ]);
- builder.addDescriptor("scaleY", [
- {},
- "scaleY",
- parseScaleValue(value, "y", builder),
+ // React Native has no z axis to scale along, and lightningcss holds a percentage as its fraction
+ if (value !== "none" && value.z.value !== 1) {
+ builder.addWarning(
+ "value",
+ value.z.type === "percentage"
+ ? `${round(value.z.value * 100)}%`
+ : `${round(value.z.value)}`,
+ );
+ }
+
+ builder.addDescriptor("scale", [
+ [{}, "scaleX", parseScaleValue(value, "x", builder)],
+ [{}, "scaleY", parseScaleValue(value, "y", builder)],
]);
}
export function parseScaleValue(
- translate: Scale,
+ scale: Scale,
prop: keyof Extract,
builder: StylesheetBuilder,
): StyleDescriptor {
- if (translate === "none") {
+ if (scale === "none") {
return 1;
}
- return parseLength(translate[prop], builder);
+ return parseLength(scale[prop], builder);
}
function parseLetterSpacing(
@@ -989,7 +996,12 @@ export function parseUnparsedDeclaration(
if (property === "animation") {
builder.addDescriptor("animation", [{}, property, args]);
} else {
- builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]);
+ builder.addDescriptor(property, [
+ {},
+ INDIVIDUAL_TRANSFORM_RESOLVERS.get(property) ?? toRNProperty(property),
+ args,
+ 1,
+ ]);
}
} else {
const value = parseUnparsed(declaration.value.value, builder, property);
@@ -1509,6 +1521,7 @@ export function parseLength(
switch (length.type) {
case "calc": {
// TODO: Add the calc polyfill
+ builder.addWarning("value", "calc()");
return undefined;
}
case "number": {
@@ -2401,13 +2414,6 @@ export function parseFontVariantCaps(
return undefined;
}
-export function parseLengthOrCoercePercentageToRuntime(
- value: Length | DimensionPercentageFor_LengthValue | NumberOrPercentage,
- builder: StylesheetBuilder,
-): StyleDescriptor {
- return parseLength(value, builder);
-}
-
export function parseGap(
declaration: DeclarationType<"gap" | "column-gap" | "row-gap">,
builder: StylesheetBuilder,
diff --git a/src/compiler/individual-transforms.ts b/src/compiler/individual-transforms.ts
new file mode 100644
index 00000000..0086b5bf
--- /dev/null
+++ b/src/compiler/individual-transforms.ts
@@ -0,0 +1,94 @@
+import type {
+ Declaration,
+ NumberOrPercentage,
+ Rotate,
+ Scale,
+ TokenOrValue,
+ Translate,
+} from "lightningcss";
+
+import { lengthLeafTokens } from "./calculation";
+import {
+ identifierToken,
+ numberToken,
+ percentageToken,
+ spaceSeparated,
+} from "./tokens";
+
+/**
+ * Carries `translate`, `rotate` and `scale` across the first pass as the tokens
+ * they were written as. lightningcss before
+ * https://github.com/parcel-bundler/lightningcss/pull/1340 folds a parsed one
+ * into `transform` when it serialises the rule, and passes an unparsed one
+ * through in place.
+ */
+export function unparseIndividualTransform(
+ declaration: Declaration,
+): Declaration | undefined {
+ switch (declaration.property) {
+ case "translate":
+ return {
+ property: "unparsed",
+ value: {
+ propertyId: { property: "translate" },
+ value: translateTokens(declaration.value),
+ },
+ };
+ case "rotate":
+ return {
+ property: "unparsed",
+ value: {
+ propertyId: { property: "rotate" },
+ value: rotateTokens(declaration.value),
+ },
+ };
+ case "scale":
+ return {
+ property: "unparsed",
+ value: {
+ propertyId: { property: "scale" },
+ value: scaleTokens(declaration.value),
+ },
+ };
+ default:
+ return undefined;
+ }
+}
+
+function numberOrPercentageTokens(value: NumberOrPercentage): TokenOrValue[] {
+ switch (value.type) {
+ case "number":
+ return [numberToken(value.value)];
+ case "percentage":
+ return [percentageToken(value.value)];
+ }
+}
+
+function translateTokens(value: Translate): TokenOrValue[] {
+ return value === "none"
+ ? [identifierToken("none")]
+ : spaceSeparated([
+ lengthLeafTokens(value.x),
+ lengthLeafTokens(value.y),
+ lengthLeafTokens(value.z),
+ ]);
+}
+
+function rotateTokens(value: Rotate): TokenOrValue[] {
+ return spaceSeparated([
+ [numberToken(value.x)],
+ [numberToken(value.y)],
+ [numberToken(value.z)],
+ [{ type: "angle", value: value.angle }],
+ ]);
+}
+
+function scaleTokens(value: Scale): TokenOrValue[] {
+ return value === "none"
+ ? [identifierToken("none")]
+ : spaceSeparated([
+ numberOrPercentageTokens(value.x),
+ numberOrPercentageTokens(value.y),
+ numberOrPercentageTokens(value.z),
+ ]);
+}
diff --git a/src/compiler/tokens.ts b/src/compiler/tokens.ts
new file mode 100644
index 00000000..1ca251b9
--- /dev/null
+++ b/src/compiler/tokens.ts
@@ -0,0 +1,60 @@
+import type { LengthValue, TokenOrValue } from "lightningcss";
+
+export const WHITESPACE_TOKEN: TokenOrValue = {
+ type: "token",
+ value: { type: "white-space", value: " " },
+};
+
+export const COMMA_TOKEN: TokenOrValue = {
+ type: "token",
+ value: { type: "comma" },
+};
+
+export function delimiterToken(value: string): TokenOrValue {
+ return { type: "token", value: { type: "delim", value } };
+}
+
+export function identifierToken(value: string): TokenOrValue {
+ return { type: "token", value: { type: "ident", value } };
+}
+
+export function numberToken(value: number): TokenOrValue {
+ return { type: "token", value: { type: "number", value } };
+}
+
+/** lightningcss holds a percentage as its fraction, in a token as in a value: 50% is 0.5. */
+export function percentageToken(fraction: number): TokenOrValue {
+ return { type: "token", value: { type: "percentage", value: fraction } };
+}
+
+export function lengthToken(value: LengthValue): TokenOrValue {
+ return { type: "length", value };
+}
+
+export function functionToken(
+ name: string,
+ argumentTokens: TokenOrValue[],
+): TokenOrValue {
+ return { type: "function", value: { name, arguments: argumentTokens } };
+}
+
+function joinTokens(
+ groups: readonly TokenOrValue[][],
+ separator: readonly TokenOrValue[],
+): TokenOrValue[] {
+ return groups.flatMap((group, index) =>
+ index === 0 ? group : [...separator, ...group],
+ );
+}
+
+export function spaceSeparated(
+ groups: readonly TokenOrValue[][],
+): TokenOrValue[] {
+ return joinTokens(groups, [WHITESPACE_TOKEN]);
+}
+
+export function commaSeparated(
+ groups: readonly TokenOrValue[][],
+): TokenOrValue[] {
+ return joinTokens(groups, [COMMA_TOKEN, WHITESPACE_TOKEN]);
+}
diff --git a/src/native/objects.ts b/src/native/objects.ts
index 12e69ccd..c6bb7f9a 100644
--- a/src/native/objects.ts
+++ b/src/native/objects.ts
@@ -1,6 +1,5 @@
/* eslint-disable */
import { ShortHandSymbol } from "../native/styles/constants";
-import { transformKeys } from "../native/styles/defaults";
export function getDeepPath(source: any, paths: string | string[] | false) {
if (!source) {
@@ -23,10 +22,6 @@ export function getDeepPath(source: any, paths: string | string[] | false) {
}
return target;
- } else if (transformKeys.has(paths)) {
- return Array.isArray(source?.transform)
- ? source.transform.find((t: any) => t[paths] !== undefined)
- : source.transform;
} else {
return source?.[paths];
}
@@ -62,23 +57,7 @@ export function applyValue(
value = undefined;
}
- if (transformKeys.has(prop)) {
- if (!Array.isArray(target.transform)) {
- target.transform = [];
- }
-
- const transformArray: Record[] = target.transform;
-
- // Remove any existing values
- target.transform = transformArray.filter((t) => !(prop in t));
-
- if (Array.isArray(value)) {
- target.transform.push(...value);
- } else {
- target.transform.push(value);
- }
- return;
- } else if (typeof value === "object" && value && ShortHandSymbol in value) {
+ if (typeof value === "object" && value && ShortHandSymbol in value) {
delete value[ShortHandSymbol];
Object.assign(target, value);
return;
diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts
index 28b9ed36..c8afe058 100644
--- a/src/native/styles/calculate-props.ts
+++ b/src/native/styles/calculate-props.ts
@@ -2,6 +2,7 @@
import type {
InlineVariable,
StyleDeclaration,
+ StyleDescriptor,
StyleRule,
} from "react-native-css/compiler";
import { Specificity as S } from "react-native-css/utilities";
@@ -13,7 +14,6 @@ import {
type Getter,
type VariableContextValue,
} from "../reactivity";
-import { transformKeys } from "./defaults";
import { resolveValue } from "./resolve";
export function calculateProps(
@@ -31,7 +31,7 @@ export function calculateProps(
let important: Record | undefined;
const delayedStyles: (() => void)[] = [];
- const transformStyles: (() => void)[] = [];
+ const transforms = createTransformComposition();
for (const rule of rules) {
if (VAR_SYMBOL in rule) {
@@ -50,9 +50,8 @@ export function calculateProps(
}
if (rule.d) {
- let topLevelTarget = rule.s?.[S.Important]
- ? (important ??= {})
- : (normal ??= {});
+ const isImportant = Boolean(rule.s?.[S.Important]);
+ let topLevelTarget = isImportant ? (important ??= {}) : (normal ??= {});
let target = topLevelTarget;
const ruleTarget = rule.target || "style";
@@ -71,7 +70,7 @@ export function calculateProps(
inlineVariables,
inheritedVariables,
delayedStyles,
- transformStyles,
+ transforms.recorderFor(target, ruleTarget, isImportant),
guards,
target,
topLevelTarget,
@@ -83,9 +82,14 @@ export function calculateProps(
delayedStyle();
}
- for (const transformStyle of transformStyles) {
- transformStyle();
- }
+ transforms.compose((value) =>
+ resolveValue(value, get, {
+ inlineVariables,
+ inheritedVariables,
+ renderGuards: guards,
+ calculateProps,
+ }),
+ );
return {
normal,
@@ -94,13 +98,108 @@ export function calculateProps(
};
}
+// css-transforms-2 §6: an element's transform is its `translate`, then its `rotate`, then its `scale`, then its `transform` list
+const TRANSFORM_PROPERTIES = [
+ "translate",
+ "rotate",
+ "scale",
+ "transform",
+] as const;
+
+type TransformProperty = (typeof TRANSFORM_PROPERTIES)[number];
+
+function isTransformProperty(prop: string | number): prop is TransformProperty {
+ return (TRANSFORM_PROPERTIES as readonly (string | number)[]).includes(prop);
+}
+
+type TransformSlots = Partial>;
+
+interface TransformTarget {
+ readonly target: Record;
+ readonly slots: TransformSlots;
+}
+
+type RecordTransform = (
+ property: TransformProperty,
+ value: StyleDescriptor,
+) => void;
+
+/** Each transform property of each style target is a cascade slot of its own, and the slots compose into one `transform` once every declaration has been walked. */
+function createTransformComposition() {
+ const byPath = new Map<
+ string,
+ { normal?: TransformTarget; important?: TransformTarget }
+ >();
+
+ return {
+ recorderFor(
+ target: Record,
+ path: string | readonly string[],
+ important: boolean,
+ ): RecordTransform {
+ return (property, value) => {
+ const pathKey = typeof path === "string" ? path : path.join("\0");
+ const pair = byPath.get(pathKey) ?? {};
+ const side = important ? "important" : "normal";
+ const recorded = pair[side] ?? { target, slots: {} };
+
+ recorded.slots[property] = value;
+ pair[side] = recorded;
+ byPath.set(pathKey, pair);
+ };
+ },
+
+ compose(resolve: (value: StyleDescriptor) => unknown) {
+ for (const { normal, important } of byPath.values()) {
+ const destination = important ?? normal;
+ const entries = composeTransform(
+ { ...normal?.slots, ...important?.slots },
+ resolve,
+ );
+
+ if (destination !== undefined && entries !== undefined) {
+ destination.target.transform = entries;
+ }
+ }
+ },
+ };
+}
+
+function composeTransform(
+ slots: TransformSlots,
+ resolve: (value: StyleDescriptor) => unknown,
+): unknown[] | undefined {
+ let composed: unknown[] | undefined;
+
+ for (const property of TRANSFORM_PROPERTIES) {
+ if (!Object.hasOwn(slots, property)) {
+ continue;
+ }
+
+ const entries = transformEntries(resolve(slots[property]));
+
+ if (entries !== undefined) {
+ composed = [...(composed ?? []), ...entries];
+ }
+ }
+
+ return composed;
+}
+
+// A property that resolves to no value is `none` (css-variables-1 §3.1)
+function transformEntries(value: unknown): unknown[] | undefined {
+ return value === undefined || value === null
+ ? undefined
+ : [value].flat(Number.POSITIVE_INFINITY);
+}
+
export function applyDeclarations(
get: Getter,
declarations: StyleDeclaration[],
inlineVariables: InlineVariable,
inheritedVariables: VariableContextValue,
delayedStyles: (() => void)[] = [],
- transformStyles: (() => void)[] = [],
+ recordTransform: RecordTransform = () => undefined,
guards: RenderGuard[] = [],
target: Record = {},
topLevelTarget = target,
@@ -162,9 +261,14 @@ export function applyDeclarations(
prop = propPath;
}
+ if (target === originalTarget && isTransformProperty(prop)) {
+ recordTransform(prop, value);
+ continue;
+ }
+
const shouldDelay = declaration[2];
- if (shouldDelay || transformKeys.has(prop)) {
+ if (shouldDelay) {
/**
* We need to delay the resolution of this value until after all
* styles have been calculated. But another style might override
@@ -175,11 +279,11 @@ export function applyDeclarations(
* mutate the props object and not create a new one
*/
const originalValue = value;
- // This needs to be a object with the [prop] so we can discover in transform arrays
value = { [prop]: true };
- if (transformKeys.has(prop)) {
- transformStyles.push(() => {
+ delayedStyles.push(() => {
+ if (getDeepPath(target, prop) === value) {
+ delete target[prop];
value = resolveValue(originalValue, get, {
inlineVariables,
inheritedVariables,
@@ -187,21 +291,8 @@ export function applyDeclarations(
calculateProps,
});
applyValue(target, prop, value);
- });
- } else {
- delayedStyles.push(() => {
- if (getDeepPath(target, prop) === value) {
- delete target[prop];
- value = resolveValue(originalValue, get, {
- inlineVariables,
- inheritedVariables,
- renderGuards: guards,
- calculateProps,
- });
- applyValue(target, prop, value);
- }
- });
- }
+ }
+ });
} else {
value = resolveValue(value, get, {
inlineVariables,
diff --git a/src/native/styles/functions/transform-functions.ts b/src/native/styles/functions/transform-functions.ts
index 9a38d15e..7efe81ab 100644
--- a/src/native/styles/functions/transform-functions.ts
+++ b/src/native/styles/functions/transform-functions.ts
@@ -1,86 +1,191 @@
-import { isStyleDescriptorArray } from "react-native-css/utilities";
-
-import type { StyleFunctionResolver } from "../resolve";
+import type { StyleDescriptor } from "react-native-css/compiler";
+import {
+ isStyleDescriptorArray,
+ negateAngle,
+ rotationAxis,
+} from "react-native-css/utilities";
+
+import type { SimpleResolveValue, StyleFunctionResolver } from "../resolve";
import { scaleFactor } from "../scale-factor";
+// A component left without a valid value makes the declaration invalid at computed-value time (css-variables-1 §3.1), which null carries
export const scale: StyleFunctionResolver = (resolveValue, descriptor) => {
- const args = descriptor[2];
+ return scalePair(resolveOperands(resolveValue, descriptor[2]));
+};
+
+export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => {
+ const operands = resolveOperands(resolveValue, descriptor[2]);
+ const [angle] = operands;
+
+ return operands.length === 1 && isTransformComponent(angle)
+ ? { rotate: angle }
+ : null;
+};
+
+export const translate: StyleFunctionResolver = (resolveValue, descriptor) => {
+ return translatePair(resolveOperands(resolveValue, descriptor[2]));
+};
- if (!isStyleDescriptorArray(args)) {
- return { scale: scaleFactor(resolveValue(args)) };
+// css-transforms-2 §3: `translate: none | [ ?]?`, and React Native has no z axis to move along
+export const individualTranslate: StyleFunctionResolver = (
+ resolveValue,
+ descriptor,
+) => {
+ const operands = resolveOperands(resolveValue, descriptor[2]);
+
+ if (operands.length === 1 && isNoneKeyword(operands[0])) {
+ return [{ translateX: 0 }, { translateY: 0 }];
}
- const x = scaleFactor(resolveValue(args[0]));
- const y = scaleFactor(resolveValue(args[1]));
+ const z = operands.length > 2 ? operands[2] : 0;
- const isXValid = typeof x === "string" || typeof x === "number";
- const isYValid = typeof y === "string" || typeof y === "number";
+ return operands.length <= 3 && typeof z === "number"
+ ? translatePair(operands.slice(0, 2))
+ : null;
+};
- if (isXValid && isYValid) {
- return x === y ? { scale: x } : [{ scaleX: x }, { scaleY: y }];
- } else if (isXValid) {
- return { scale: x };
- } else if (isYValid) {
- return { scaleY: y };
+// css-transforms-2 §3: `scale: none | [ | ]{1,3}`, and React Native has no z axis to scale along
+export const individualScale: StyleFunctionResolver = (
+ resolveValue,
+ descriptor,
+) => {
+ const operands = resolveOperands(resolveValue, descriptor[2]);
+
+ if (operands.length === 1 && isNoneKeyword(operands[0])) {
+ return [{ scaleX: 1 }, { scaleY: 1 }];
}
- return;
+ const z = operands.length > 2 ? operands[2] : 1;
+
+ return operands.length <= 3 && scaleFactor(z) !== null
+ ? scalePair(operands.slice(0, 2))
+ : null;
};
-export const rotate: StyleFunctionResolver = (resolveValue, descriptor) => {
- const args = descriptor[2];
+// css-transforms-2 §3: `rotate: none | | [x | y | z | {3}] && `
+export const individualRotate: StyleFunctionResolver = (
+ resolveValue,
+ descriptor,
+) => {
+ const operands = resolveOperands(resolveValue, descriptor[2]);
- if (!isStyleDescriptorArray(args)) {
- return { rotate: resolveValue(args) };
+ if (operands.length === 1) {
+ const [angle] = operands;
+
+ if (isNoneKeyword(angle)) {
+ return { rotateZ: "0deg" };
+ }
+
+ return isTransformComponent(angle) ? { rotateZ: angle } : null;
}
- const x = resolveValue(args[0]);
- const y = resolveValue(args[1]);
- const z = resolveValue(args[2]);
-
- const isXValid = typeof x === "string" || typeof x === "number";
- const isYValid = typeof y === "string" || typeof y === "number";
- const isZValid = typeof z === "string" || typeof z === "number";
-
- if (isXValid && isYValid && isZValid) {
- return [{ rotateX: x }, { rotateY: y }, { rotateZ: z }];
- } else if (isXValid && isYValid) {
- return [{ rotateX: x }, { rotateY: y }];
- } else if (isXValid && isZValid) {
- return [{ rotateX: x }, { rotateZ: z }];
- } else if (isYValid && isZValid) {
- return [{ rotateY: y }, { rotateZ: z }];
- } else if (isXValid) {
- return { rotateX: x };
- } else if (isYValid) {
- return { rotateY: y };
- } else if (isZValid) {
- return { rotateZ: z };
+ const axisIndex = operands.findIndex(isAxisKeyword);
+
+ if (axisIndex !== -1) {
+ const axis = operands[axisIndex];
+ const angle = operands[axisIndex === 0 ? 1 : 0];
+
+ return operands.length === 2 &&
+ isAxisKeyword(axis) &&
+ isTransformComponent(angle)
+ ? { [AXIS_ROTATIONS[axis]]: angle }
+ : null;
}
- return;
+ const angleIndex = operands.length === 4 ? angleOperandIndex(operands) : -1;
+ const angle = operands[angleIndex];
+ const [x, y, z] = operands.filter((_, index) => index !== angleIndex);
+
+ if (
+ angleIndex === -1 ||
+ !isTransformComponent(angle) ||
+ typeof x !== "number" ||
+ typeof y !== "number" ||
+ typeof z !== "number"
+ ) {
+ return null;
+ }
+
+ const rotation = rotationAxis(x, y, z);
+
+ switch (rotation.type) {
+ case "none":
+ return { rotateZ: "0deg" };
+ case "axis":
+ return {
+ [rotation.rotation]: rotation.reversed ? negateAngle(angle) : angle,
+ };
+ case "oblique":
+ return null;
+ default:
+ rotation satisfies never;
+ return null;
+ }
};
-export const translate: StyleFunctionResolver = (resolveValue, descriptor) => {
- const args = descriptor[2];
+const AXIS_ROTATIONS = {
+ x: "rotateX",
+ y: "rotateY",
+ z: "rotateZ",
+} as const;
+
+function isAxisKeyword(value: unknown): value is keyof typeof AXIS_ROTATIONS {
+ return value === "x" || value === "y" || value === "z";
+}
+
+// The angle of a vector rotation comes first or last, around three numbers
+function angleOperandIndex(operands: readonly unknown[]): number {
+ const index = operands.findIndex((operand) => typeof operand === "string");
+
+ return index === 0 || index === operands.length - 1 ? index : -1;
+}
- if (!isStyleDescriptorArray(args)) {
- return;
+// `none` is the identity transform, and React Native refuses the keyword itself
+function isNoneKeyword(value: unknown): boolean {
+ return typeof value === "string" && value.toLowerCase() === "none";
+}
+
+function scalePair(operands: readonly unknown[]): unknown {
+ const x = scaleFactor(operands[0]);
+ const y = operands.length > 1 ? scaleFactor(operands[1]) : x;
+
+ if (operands.length > 2 || x === null || y === null) {
+ return null;
}
- const x = resolveValue(args[0]);
- const y = resolveValue(args[1]);
+ return [{ scaleX: x }, { scaleY: y }];
+}
- const isXValid = typeof x === "string" || typeof x === "number";
- const isYValid = typeof y === "string" || typeof y === "number";
+// `translate(x)` is `translate(x, 0)` (css-transforms-1 §9)
+function translatePair(operands: readonly unknown[]): unknown {
+ const x = operands[0];
+ const y = operands.length > 1 ? operands[1] : 0;
- if (isXValid && isYValid) {
- return [{ translateX: x }, { translateY: y }];
- } else if (isXValid) {
- return { translateX: x };
- } else if (isYValid) {
- return { translateY: y };
+ if (
+ operands.length > 2 ||
+ !isTransformComponent(x) ||
+ !isTransformComponent(y)
+ ) {
+ return null;
}
- return;
-};
+ return [{ translateX: x }, { translateY: y }];
+}
+
+// A var() substitutes its tokens before the grammar reads them, so it can supply one operand, several, or the whole list (css-variables-1 §3)
+function resolveOperands(
+ resolveValue: SimpleResolveValue,
+ args: StyleDescriptor,
+): unknown[] {
+ const resolved = isStyleDescriptorArray(args)
+ ? args.map((arg) => resolveValue(arg))
+ : [resolveValue(args)];
+
+ return resolved.flatMap((operand): readonly unknown[] =>
+ Array.isArray(operand) ? operand : [operand],
+ );
+}
+
+function isTransformComponent(value: unknown): value is string | number {
+ return typeof value === "string" || typeof value === "number";
+}
diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts
index 0cb40385..d40d1aae 100644
--- a/src/native/styles/resolve.ts
+++ b/src/native/styles/resolve.ts
@@ -125,16 +125,16 @@ export function resolveValue(
) as StyleDescriptor;
} else if (transformKeys.has(name)) {
// translate, rotate, scale, etc.
- let resolved = simpleResolve(value[2], castToArray);
+ const resolved = simpleResolve(value[2], castToArray);
// CSS scale percentages describe a factor, unlike translation percentages.
- if (
- (name === "scaleX" || name === "scaleY" || name === "scale") &&
- typeof resolved === "string" &&
- resolved.endsWith("%")
- ) {
- resolved = scaleFactor(resolved);
- }
- return { [name]: resolved };
+ const component =
+ name === "scaleX" || name === "scaleY" || name === "scale"
+ ? scaleFactor(resolved)
+ : resolved;
+
+ return component === undefined || component === null
+ ? null
+ : { [name]: component };
} else {
let args = simpleResolve(value[2], castToArray);
diff --git a/src/native/styles/scale-factor.ts b/src/native/styles/scale-factor.ts
index 5d30789a..f8fe9d44 100644
--- a/src/native/styles/scale-factor.ts
+++ b/src/native/styles/scale-factor.ts
@@ -1,6 +1,9 @@
-export function scaleFactor(value: unknown) {
- if (typeof value === "string" && value.endsWith("%")) {
- return Number(value.slice(0, -1)) / 100;
- }
- return value;
+// React Native takes a scale only as a number, and its transform validator throws on anything else
+export function scaleFactor(value: unknown): number | null {
+ const factor =
+ typeof value === "string" && value.endsWith("%")
+ ? Number(value.slice(0, -1)) / 100
+ : value;
+
+ return typeof factor === "number" ? factor : null;
}
diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts
index ef603dd8..2622859f 100644
--- a/src/native/styles/shorthands/transform.ts
+++ b/src/native/styles/shorthands/transform.ts
@@ -10,14 +10,16 @@ export const transform: StyleFunctionResolver = (
) => {
const transforms = resolveValue(transformDescriptor[2]);
- if (Array.isArray(transforms)) {
- return transforms.filter(
- (transform) => transform !== undefined && transform !== "initial",
- ) as unknown;
- } else if (transforms) {
- // If it's a single transform, wrap it in an array
- return [transforms];
- } else {
- return;
- }
+ // A group from a resolver and the list a var() fallback holds each nest a level, while React Native takes a flat list of single-key objects
+ const resolved: unknown[] = [transforms].flat(Number.POSITIVE_INFINITY);
+ const entries = resolved.filter(
+ (transform) => transform !== undefined && transform !== "initial",
+ );
+
+ // Anything but a transform entry, null included, leaves the declaration invalid at computed-value time, so the transform is none (css-variables-1 §3.1)
+ return entries.every(isTransformEntry) ? entries : [];
};
+
+function isTransformEntry(value: unknown): boolean {
+ return typeof value === "object" && value !== null;
+}
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
index 0c95da63..40b18563 100644
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -1,3 +1,4 @@
export * from "./specificity";
export * from "./style-descriptor";
export * from "./dot-notation.types";
+export * from "./rotation-axis";
diff --git a/src/utilities/rotation-axis.ts b/src/utilities/rotation-axis.ts
new file mode 100644
index 00000000..9a4fa30a
--- /dev/null
+++ b/src/utilities/rotation-axis.ts
@@ -0,0 +1,37 @@
+export type RotationAxis =
+ | { readonly type: "none" }
+ | {
+ readonly type: "axis";
+ readonly rotation: "rotateX" | "rotateY" | "rotateZ";
+ readonly reversed: boolean;
+ }
+ | { readonly type: "oblique" };
+
+// css-transforms-2 §3: a rotation turns about the direction its vector names, and the zero vector names none
+export function rotationAxis(x: number, y: number, z: number): RotationAxis {
+ if (x === 0 && y === 0 && z === 0) {
+ return { type: "none" };
+ }
+
+ if (y === 0 && z === 0) {
+ return { type: "axis", rotation: "rotateX", reversed: x < 0 };
+ }
+
+ if (x === 0 && z === 0) {
+ return { type: "axis", rotation: "rotateY", reversed: y < 0 };
+ }
+
+ if (x === 0 && y === 0) {
+ return { type: "axis", rotation: "rotateZ", reversed: z < 0 };
+ }
+
+ return { type: "oblique" };
+}
+
+export function negateAngle(angle: string | number): string | number {
+ if (typeof angle === "number") {
+ return -angle;
+ }
+
+ return angle.startsWith("-") ? angle.slice(1) : `-${angle}`;
+}