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}`; +}