diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..807a3f26 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -1,4 +1,8 @@ -import { compile } from "react-native-css/compiler"; +import { + compile, + type StyleDeclaration, + type StyleDescriptor, +} from "react-native-css/compiler"; test("hello world", () => { const compiled = compile(` @@ -356,3 +360,700 @@ test("simplifies rem", () => { ], }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute each keyword below as css-cascade-5 §7.3 defines it +describe("CSS-wide keywords", () => { + const stylesheetFor = (value: string) => + compile(`.child { color: ${value}; }`).stylesheet(); + + test("color: inherit compiles to the inherited-color lookup and publishes it", () => { + expect(stylesheetFor("inherit")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + }, + ], + ], + ], + }); + }); + + test("color: inherit and color: currentcolor compile alike", () => { + // currentColor on `color` is `inherit` (css-color-3 §4.4) + expect(stylesheetFor("inherit")).toStrictEqual( + stylesheetFor("currentcolor"), + ); + }); + + test("color: currentcolor compiles to the inherited-color lookup and publishes it", () => { + expect(stylesheetFor("currentcolor")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + }, + ], + ], + ], + }); + }); + + test("a colour of its own publishes --__rn-css-color to descendants", () => { + expect(stylesheetFor("red")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [{ color: "#f00" }], + v: [["__rn-css-color", "#f00"]], + }, + ], + ], + ], + }); + }); + + test.each(["unset", "revert", "revert-layer"])( + "color: %s compiles like color: inherit", + (keyword) => { + // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3), and with no user-agent or user origin `revert` is `unset` (§7.3.4) + expect(stylesheetFor(keyword)).toStrictEqual(stylesheetFor("inherit")); + }, + ); + + test("color: initial compiles to the literal the runtime clears with", () => { + expect(stylesheetFor("initial")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [["unset", "color"]], + v: [["__rn-css-color", "unset"]], + }, + ], + ], + ], + }); + }); + + const otherProperties = [ + ["background-color", "backgroundColor"], + ["border-color", "borderColor"], + ["font-size", "fontSize"], + ["opacity", "opacity"], + ] as const; + + test.each(otherProperties)("%s: inherit is dropped", (property) => { + // Only `color` and custom properties carry a value to descendants here + expect( + compile(`.child { ${property}: inherit; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test.each( + otherProperties.flatMap(([property, nativeProperty]) => + (["initial", "unset", "revert", "revert-layer"] as const).map( + (keyword) => [property, keyword, nativeProperty] as const, + ), + ), + )( + "%s: %s compiles to the literal the runtime clears with", + (property, keyword, nativeProperty) => { + expect( + declarationsFor(`.child { ${property}: ${keyword}; }`), + ).toStrictEqual([["unset", nativeProperty]]); + }, + ); + + test.each([ + ["box-shadow", "boxShadow"], + ["text-shadow", "textShadow"], + ] as const)( + "%s: initial hands the literal to the shorthand's runtime parser", + (property, nativeProperty) => { + expect(declarationsFor(`.child { ${property}: initial; }`)).toStrictEqual( + [[[{}, nativeProperty, "unset", 1], nativeProperty, 1]], + ); + }, + ); + + test("box-shadow: inherit is dropped rather than handed to the shorthand", () => { + expect( + compile(`.child { box-shadow: inherit; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test.each([ + ["object-fit", "initial", ["contentFit"]], + ["object-position", "revert", ["contentPosition"]], + ["corner-shape", "unset", "borderCurve"], + ["font-variant", "revert-layer", "fontVariant"], + ["-webkit-line-clamp", "initial", ["numberOfLines"]], + ["outline-offset", "initial", "outlineOffset"], + ] as const)( + "%s: %s compiles to the literal the runtime clears with", + (property, keyword, nativeProperty) => { + expect( + declarationsFor(`.child { ${property}: ${keyword}; }`), + ).toStrictEqual([["unset", nativeProperty]]); + }, + ); + + test.each(["object-fit", "corner-shape"])( + "%s: inherit is dropped with a warning naming it", + (property) => { + const compiled = compile(`.child { ${property}: inherit; }`); + + expect(compiled.stylesheet()).toStrictEqual({}); + expect(compiled.warnings()).toStrictEqual({ + values: { [property]: ["inherit"] }, + }); + }, + ); + + test.each(["INHERIT", "1px 1px INHERIT"])( + "box-shadow: %s is reported as written", + (value) => { + expect( + compile(`.child { box-shadow: ${value}; }`).warnings(), + ).toStrictEqual({ values: { "box-shadow": ["INHERIT"] } }); + }, + ); + + test.each([ + ["color", "color-mix(in srgb, inherit, blue)"], + ["color", "light-dark(inherit, blue)"], + ["color", "light-dark(blue, inherit)"], + ["color", "rgb(0 0 0 / inherit)"], + ["filter", "blur(inherit)"], + ["transform", "translateX(inherit)"], + ["object-position", "left inherit"], + ["corner-shape", "round inherit"], + ["font-variant", "small-caps inherit"], + ["box-shadow", "1px 1px light-dark(blue, inherit)"], + ["box-shadow", "1px 1px INHERIT"], + ])( + "%s: %s is dropped, as a CSS-wide keyword is only ever a whole value", + (property, value) => { + const compiled = compile(`.child { ${property}: ${value}; }`); + + expect(compiled.stylesheet()).toStrictEqual({}); + expect(compiled.warnings()).toStrictEqual({ + values: { + [property]: [value.includes("INHERIT") ? "INHERIT" : "inherit"], + }, + }); + }, + ); + + test.each([ + ["inherit", [{}, "var", "__rn-css-color"]], + ["INITIAL", "unset"], + ] as const)( + "color: var(--missing, %s) falls back to what the keyword resolves to", + (keyword, fallback) => { + // Substituted, a fallback is the declaration's whole value (css-variables-1 §3) + expect( + declarationsFor(`.child { color: var(--missing, ${keyword}); }`), + ).toStrictEqual([[[{}, "var", ["missing", fallback], 1], "color", 1]]); + }, + ); + + test("a whole-value var() fallback that is a var() passes its keyword fallback through", () => { + expect( + declarationsFor(`.child { color: var(--a, var(--b, inherit)); }`), + ).toStrictEqual([ + [ + [ + {}, + "var", + ["a", [{}, "var", ["b", [{}, "var", "__rn-css-color"]], 1]], + 1, + ], + "color", + 1, + ], + ]); + }); + + test.each([ + ["padding-top", "initial", "paddingTop", "unset"], + ["padding-top", "4px", "paddingTop", 4], + ["color", "inherit", "color", [{}, "var", "__rn-css-color"]], + ] as const)( + "%s: env(safe-area-inset-top, %s) falls back to what the value resolves to there", + (property, value, nativeProperty, fallback) => { + expect( + declarationsFor( + `.child { ${property}: env(safe-area-inset-top, ${value}); }`, + ), + ).toStrictEqual([ + [ + [{}, "var", ["react-native-css-safe-area-inset-top", fallback], 1], + nativeProperty, + 1, + ], + ]); + }, + ); + + test.each([ + [ + "width", + "calc(var(--missing, initial) + 1px)", + "calc(var(--missing) + 1px)", + "initial", + ], + [ + "color", + "color-mix(in srgb, var(--missing, inherit), blue)", + "color-mix(in srgb, var(--missing), blue)", + "inherit", + ], + [ + "transform", + "translateX(var(--missing, initial))", + "translateX(var(--missing))", + "initial", + ], + [ + "box-shadow", + "1px 1px var(--missing, INHERIT)", + "1px 1px var(--missing)", + "INHERIT", + ], + ["width", "var(--missing, 10px inherit)", "var(--missing)", "inherit"], + [ + "padding-top", + "calc(env(safe-area-inset-top, initial) + 1px)", + "calc(env(safe-area-inset-top) + 1px)", + "initial", + ], + [ + "transform", + "translateY(env(safe-area-inset-top, initial))", + "translateY(env(safe-area-inset-top))", + "initial", + ], + ])( + "%s: %s drops the fallback, as a CSS-wide keyword leaves it invalid there", + (property, value, withoutFallback, keyword) => { + // Substituted inside a longer value, a CSS-wide keyword is invalid (css-values-4 §2.1) + const compiled = compile(`.child { ${property}: ${value}; }`); + + expect(compiled.stylesheet()).toStrictEqual( + compile(`.child { ${property}: ${withoutFallback}; }`).stylesheet(), + ); + expect(compiled.warnings()).toStrictEqual({ + values: { [property]: [keyword] }, + }); + }, + ); + + // A second declaration keeps `--brand` from being inlined, so the keyword reaches the compiler on the custom property + const uninlinedCustomProperty = (value: string) => + `.child { --brand: ${value}; color: var(--brand); } + .other { --brand: ${value}; }`; + + // css-variables-1 §2: the keyword is the custom property's own value, which a read then substitutes + test.each(["inherit", "unset", "revert", "revert-layer", "INHERIT"])( + "--brand: %s compiles to the parent's value", + (keyword) => { + const css = uninlinedCustomProperty(keyword); + + expect(publishedVariable(css, "brand")).toEqual([ + [{}, "inherit"], + [{}, "inherit"], + ]); + expect(compile(css).warnings()).toStrictEqual({}); + }, + ); + + test("whitespace around the keyword leaves it the whole value", () => { + expect( + publishedVariable(uninlinedCustomProperty(" inherit "), "brand"), + ).toEqual([ + [{}, "inherit"], + [{}, "inherit"], + ]); + }); + + test("--brand: initial compiles to the guaranteed-invalid value", () => { + const css = uninlinedCustomProperty("initial"); + + expect(publishedVariable(css, "brand")).toEqual([ + [{}, "initial"], + [{}, "initial"], + ]); + expect(compile(css).warnings()).toStrictEqual({}); + }); + + test("--brand: 1px INHERIT stays declared, and its keyword is dropped with a warning", () => { + const css = uninlinedCustomProperty("1px INHERIT"); + + expect(publishedVariable(css, "brand")).toEqual([1, 1]); + expect(compile(css).warnings()).toStrictEqual({ + values: { "--brand": ["INHERIT", "INHERIT"] }, + }); + }); + + test("a comment beside a keyword leaves the keyword the whole value", () => { + expect(stylesheetFor("/* reset */ inherit")).toStrictEqual( + stylesheetFor("inherit"), + ); + expect( + declarationsFor( + `.child { color: var(--missing, /* reset */ inherit /* here */); }`, + ), + ).toStrictEqual( + declarationsFor(`.child { color: var(--missing, inherit); }`), + ); + expect( + publishedVariable( + uninlinedCustomProperty("/* reset */ inherit"), + "brand", + ), + ).toEqual([ + [{}, "inherit"], + [{}, "inherit"], + ]); + }); + + test.each(["currentcolor", "currentColor"])( + "--brand: %s compiles to the inherited-color lookup", + (spelling) => { + // lightningcss hands a custom property's tokens through unfolded, so `currentColor` relies on the compiler's case fold + expect( + publishedVariable(uninlinedCustomProperty(spelling), "brand"), + ).toEqual([ + [{}, "var", "__rn-css-color"], + [{}, "var", "__rn-css-color"], + ]); + }, + ); + + test("a custom property set to inherit is never inlined, because its value is its parent's", () => { + expect( + publishedVariable( + `.child { --brand: inherit; color: var(--brand); }`, + "brand", + ), + ).toEqual([[{}, "inherit"]]); + }); + + test("a variable that leads into a cycle is inlined, and only the cycle's members are left to the runtime", () => { + const rule = compile( + `.child { --lead: var(--cycle-x); --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); color: var(--lead); }`, + ).stylesheet().s?.[0]?.[1]?.[0]; + + expect((rule?.v ?? []).map(([name]) => name)).toEqual( + expect.arrayContaining(["cycle-x", "cycle-y"]), + ); + expect(JSON.stringify(rule?.d)).toContain('"cycle-x"'); + expect(JSON.stringify(rule?.d)).not.toContain('"lead"'); + }); + + test("a variable two branches read is inlined into both, as no cycle", () => { + expect( + declarationsFor( + `.child { --diamond-reader: calc(var(--diamond-left) + var(--diamond-right)); --diamond-left: var(--diamond-base); --diamond-right: var(--diamond-base); --diamond-base: 3px; width: var(--diamond-reader, 1px); }`, + ), + ).toStrictEqual(declarationsFor(`.child { width: 6px; }`)); + }); + + test("a custom property set to initial is inlined as the guaranteed-invalid value, so a read takes its fallback", () => { + expect( + declarationsFor(`.child { --brand: initial; color: var(--brand, red); }`), + ).toStrictEqual(declarationsFor(`.child { color: red; }`)); + }); + + test("a read of an inlined initial with no fallback is invalid at computed-value time, so it compiles as unset", () => { + expect( + declarationsFor(`.child { --brand: initial; width: var(--brand); }`), + ).toStrictEqual(declarationsFor(`.child { width: unset; }`)); + expect( + declarationsFor(`.child { --brand: initial; color: var(--brand); }`), + ).toStrictEqual(declarationsFor(`.child { color: unset; }`)); + }); + + test.each(["INHERIT", "Inherit", "UNSET", "INITIAL"])( + "color: %s compiles like its lowercase spelling", + (spelling) => { + // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one through unfolded + expect(stylesheetFor(spelling)).toStrictEqual( + stylesheetFor(spelling.toLowerCase()), + ); + }, + ); + + test("color: currentColor compiles to the inherited-color lookup", () => { + expect(stylesheetFor("currentColor")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + }, + ], + ], + ], + }); + }); + + test("border-color: currentcolor compiles to the same lookup", () => { + expect( + compile(`.child { border-color: currentcolor; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "borderColor", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("color: inherit !important keeps the important specificity", () => { + expect(stylesheetFor("inherit !important")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + }, + ], + ], + ], + }); + }); + + test("color: inherit inside a media query keeps the condition", () => { + expect( + compile( + `@media (min-width: 100px) { .child { color: inherit; } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [2, 1], + m: [[">=", "width", 100]], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + }, + ], + ], + ], + }); + }); + + test("color: inherit inside :hover keeps the pseudo-class condition", () => { + expect( + compile(`.child:hover { color: inherit; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 2], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + p: { h: 1 }, + }, + ], + ], + ], + }); + }); + + test("color: inherit on ::placeholder targets placeholderTextColor", () => { + expect( + declarationsFor(`.child::placeholder { color: inherit; }`), + ).toStrictEqual([ + [[{}, "var", "__rn-css-color"], ["placeholderTextColor"], 1], + ]); + }); + + test.each(["placeholder", "selection"])( + "a ::%s rule publishes no colour, and keeps its other custom properties", + (pseudoElement) => { + const css = `.child::${pseudoElement} { --brand: red; color: var(--brand); } .other { --brand: blue; }`; + + expect(publishedInheritedColors(css)).toEqual([]); + expect(publishedVariable(css, "brand")).toEqual(["red", "blue"]); + }, + ); +}); + +function declarationsFor(css: string): StyleDeclaration[] { + return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) => + ruleSet.flatMap((rule) => rule.d ?? []), + ); +} + +function publishedVariable(css: string, name: string): StyleDescriptor[] { + return (compile(css).stylesheet().s ?? []).flatMap(([, ruleSet]) => + ruleSet.flatMap((rule) => + (rule.v ?? []) + .filter(([varName]) => varName === name) + .map(([, value]) => value), + ), + ); +} + +function publishedInheritedColors(css: string): StyleDescriptor[] { + return publishedVariable(css, "__rn-css-color"); +} + +describe("the inherited-color variable is never a lookup into itself", () => { + // Each reads the inherited colour somewhere in its descriptor, so it publishes the bare lookup, which the runtime answers with what the element inherited + const colorsThatReadTheInheritedColor = [ + "inherit", + "unset", + "currentcolor", + "var(--missing, inherit)", + "var(--missing, unset)", + "var(--missing, currentcolor)", + "color-mix(in srgb, currentcolor, blue)", + "rgb(from currentcolor r g b)", + "light-dark(currentcolor, blue)", + ]; + + test.each(colorsThatReadTheInheritedColor)( + "color: %s publishes the bare lookup", + (value) => { + const published = publishedInheritedColors(`.child { color: ${value}; }`); + + expect(new Set(published.map((entry) => JSON.stringify(entry)))).toEqual( + new Set([JSON.stringify([{}, "var", "__rn-css-color"])]), + ); + }, + ); + + test.each([ + ["red", "#f00"], + ["#00f", "#00f"], + ["rgb(1 2 3)", "#010203"], + ["color-mix(in srgb, red, blue)", "#800080"], + ["oklch(0.7 0.1 200)", "#40b1b7"], + ])("color: %s still publishes its own resolved value", (value, expected) => { + expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual([ + expected, + ]); + }); + + test.each<[value: string, published: StyleDescriptor[]]>([ + ["var(--brand)", [[{}, "var", "brand", 1]]], + ["var(--brand, red)", [[{}, "var", ["brand", "red"], 1]]], + [ + "color-mix(in srgb, var(--brand), blue)", + [ + [ + {}, + "colorMix", + ["srgb", [{}, "var", "brand", 1], undefined, "blue", undefined], + ], + ], + ], + ])( + "color: %s names a variable that is not the inherited one, so it publishes", + (value, published) => { + expect(publishedInheritedColors(`.child { color: ${value}; }`)).toEqual( + published, + ); + }, + ); + + test("color: light-dark() emits no rule twice", () => { + // Parsing light-dark() adds its dark rule as a side effect, so the colour is parsed once for the declaration and its publish + const rules = ( + compile(`.child { color: light-dark(red, blue); }`).stylesheet().s ?? [] + ).flatMap(([, ruleSet]) => ruleSet.map((rule) => JSON.stringify(rule))); + + expect(new Set(rules).size).toBe(rules.length); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 ignore a written filter with an argument its function cannot take +describe("a written filter the property cannot take", () => { + test.each(["brightness(-1)", "blur(-1px)", "blur(2px) brightness(-0.5)"])( + "filter: %s emits no declaration", + (value) => { + expect( + compile(`.child { filter: ${value}; }`).stylesheet(), + ).toStrictEqual({}); + }, + ); + + test("CONTROL: a url() entry is left out and the filters React Native can draw still apply", () => { + expect( + compile(`.child { filter: blur(2px) url(#shadow); }`).stylesheet() + .s?.[0]?.[1]?.[0]?.d, + ).toStrictEqual([{ filter: [{ blur: 2 }] }]); + }); + + test("CONTROL: a percentage and a non-negative number are kept as written", () => { + expect( + compile(`.child { filter: brightness(150%) contrast(0); }`).stylesheet() + .s?.[0]?.[1]?.[0]?.d, + ).toStrictEqual([{ filter: [{ brightness: "150%" }, { contrast: 0 }] }]); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute a gradian or a turn as the angle it is; React Native takes degrees or radians alone +describe("an angle in gradians or turns", () => { + test.each([ + ["hue-rotate(1turn)", "360deg"], + ["hue-rotate(100grad)", "90deg"], + ])("filter: %s compiles to %s", (filter, degrees) => { + const compiled = compile(`.child { filter: ${filter}; }`); + + expect(compiled.warnings()).toStrictEqual({}); + expect(compiled.stylesheet().s?.[0]?.[1]?.[0]?.d).toStrictEqual([ + { filter: [{ hueRotate: degrees }] }, + ]); + }); + + test.each(["transform: rotate(100grad)", "rotate: 0.25turn"])( + "%s compiles without a warning", + (declaration) => { + expect(compile(`.child { ${declaration}; }`).warnings()).toStrictEqual( + {}, + ); + }, + ); +}); diff --git a/src/__tests__/native/animations.test.tsx b/src/__tests__/native/animations.test.tsx index c9565d16..811fe3ab 100644 --- a/src/__tests__/native/animations.test.tsx +++ b/src/__tests__/native/animations.test.tsx @@ -133,6 +133,80 @@ test("an invalid numeric animation name is omitted without throwing", () => { expect(style()?.animationName).toBeUndefined(); }); +const variableAnimation = `.other { --motion: none; } .earlier { --motion: slide-in 1s; animation: var(--motion); }`; +const clearedAnimation = { + animationName: undefined, + animationDelay: undefined, + animationDirection: undefined, + animationDuration: undefined, + animationFillMode: undefined, + animationIterationCount: undefined, + animationPlayState: undefined, + animationTimingFunction: undefined, +}; + +// Chromium 153, Firefox 155 and WebKit 26.6 reset every animation longhand for `initial`, `unset` and a var() that resolves to nothing +describe.each([undefined, false] as const)( + "an animation reset with inlineVariables=%s", + (inlineVariables) => { + test.each(["initial", "unset", "var(--missing)"])( + "animation: %s after a written animation leaves no animation", + (reset) => { + registerCSS( + `${keyframes} .earlier { animation: slide-in 1s; } .reset { animation: ${reset}; }`, + { inlineVariables }, + ); + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual( + clearedAnimation, + ); + }, + ); + + test.each(["initial", "unset", "var(--missing)"])( + "animation: %s after a variable animation leaves no animation", + (reset) => { + registerCSS( + `${keyframes} ${variableAnimation} .reset { animation: ${reset}; }`, + { inlineVariables }, + ); + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual( + clearedAnimation, + ); + }, + ); + + test.each(["initial", "var(--missing)"])( + "animation-name: %s after a written animation name leaves no animation name", + (reset) => { + registerCSS( + `${keyframes} .earlier { animation-name: slide-in; animation-duration: 1s; } .reset { animation-name: ${reset}; }`, + { inlineVariables }, + ); + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + animationName: undefined, + animationDuration: [1000], + }); + }, + ); + + // Chromium 153, Firefox 155 and WebKit 26.6 apply a later longhand over the variable-held shorthand before it + test("a written animation-duration after a variable animation applies", () => { + registerCSS( + `${keyframes} ${variableAnimation} .reset { animation-duration: 2s; }`, + { inlineVariables }, + ); + render(); + expect(style()).toMatchObject({ + animationName: horizontal, + animationDuration: [2000], + }); + }); + }, +); + test("multiple animation names preserve keyframe order and timing metadata", () => { registerCSS(`${keyframes} .motion { animation: slide-in 1s linear 250ms 2 alternate both, diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 6a8c7255..0ea33475 100644 --- a/src/__tests__/native/colors.test.tsx +++ b/src/__tests__/native/colors.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from "@testing-library/react-native"; +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; @@ -165,3 +166,680 @@ describe("currentcolor", () => { }); }); }); + +describe("inherit", () => { + test("color: inherit resolves to the parent's color", () => { + registerCSS(` + .parent { color: red; } + .child { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("color: inherit on a Text reads its parent's color", () => { + registerCSS(` + .button { color: white; } + .label { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("label").props.style).toStrictEqual({ + color: "#fff", + }); + }); + + test("inherit chains through an inheriting ancestor", () => { + registerCSS(` + .parent { color: red; } + .mid { color: inherit; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("mid").props.style).toStrictEqual({ + color: "#f00", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("inherit follows the nearest colored ancestor", () => { + registerCSS(` + .outer { color: red; } + .inner { color: blue; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#00f", + }); + }); + + test("color: unset inherits the parent's color", () => { + // `unset` on an inherited property is `inherit` (css-cascade-5 §7.3.3) + registerCSS(` + .parent { color: red; } + .child { color: unset; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test.each(["UNSET", "INHERIT", "Inherit"])( + "color: %s is case-folded and inherits", + (spelling) => { + registerCSS(` + .parent { color: red; } + .child { color: ${spelling}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }, + ); + + test("color: INITIAL clears an earlier rule's color", () => { + // React Native's default text color stands in for the initial value + registerCSS(` + .parent { color: red; } + .earlier { color: green; } + .child { color: INITIAL; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: undefined, + }); + }); + + test("a descendant override restarts the chain", () => { + registerCSS(` + .red { color: red; } + .blue { color: blue; } + .inherit { color: inherit; } + `); + + render( + + + + + + + , + ); + + expect(screen.getByTestId("first").props.style).toStrictEqual({ + color: "#f00", + }); + expect(screen.getByTestId("second").props.style).toStrictEqual({ + color: "#00f", + }); + }); + + test("color: inherit under a media query", () => { + registerCSS(` + .parent { color: red; } + @media (min-width: 1px) { .child { color: inherit; } } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("color: inherit under :hover", () => { + registerCSS(` + .parent { color: red; } + .child { color: blue; } + .child:hover { color: inherit; } + `); + + render( + + + , + ); + + const child = screen.getByTestId("child"); + expect(child.props.style).toStrictEqual({ color: "#00f" }); + + fireEvent(child, "hoverIn", {}); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("color: inherit !important beats a normal color on the same element", () => { + registerCSS(` + .parent { color: red; } + .child { color: inherit !important; } + .override { color: blue; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("color: inherit on ::placeholder reads the parent's color", () => { + // React Native has no default input colour to inherit, so this matches Tailwind's preflight `input { color: inherit }` + registerCSS(` + .parent { color: red; } + .child::placeholder { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props).toStrictEqual({ + children: undefined, + placeholderTextColor: "#f00", + style: {}, + testID: "child", + }); + }); + + test.each(["inherit", "currentcolor"])( + "color: %s on ::placeholder reads the element's own color", + (keyword) => { + registerCSS(` + .parent { color: red; } + .child { color: green; } + .child::placeholder { color: ${keyword}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props).toStrictEqual({ + children: undefined, + placeholderTextColor: "#008000", + style: { color: "#008000" }, + testID: "child", + }); + }, + ); + + test("color: inherit with no colored ancestor reads the root's label color", () => { + registerCSS(`.child { color: inherit; }`); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: { semantic: ["label", "labelColor"] }, + }); + }); + + test("inherit resolves an ancestor color from an inlined variable", () => { + registerCSS(` + .parent { --brand: #ff0000; color: var(--brand); } + .child { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#f00", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("inherit resolves an ancestor color from a variable that is not inlined", () => { + // A second declaration keeps `--brand` from being inlined + registerCSS(` + .parent { --brand: #ff0000; color: var(--brand); } + .child { --brand: #0000ff; color: inherit; } + `); + + render( + + + , + ); + + const parentColor = screen.getByTestId("parent").props.style.color; + + expect(parentColor).toBeDefined(); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: parentColor, + }); + }); + + test("color: var(--brand) with --brand: inherit resolves when the variable is inlined", () => { + registerCSS(` + .parent { color: red; } + .child { --brand: inherit; color: var(--brand); } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test.each([ + ["currentcolor", "inlined"], + ["currentcolor", "uninlined"], + ["currentColor", "inlined"], + ["currentColor", "uninlined"], + ] as const)( + "color: var(--brand) with --brand: %s resolves on the %s route", + (spelling, route) => { + // lightningcss hands a custom property's tokens through unfolded, so `currentColor` relies on the compiler's case fold + const secondDefinition = + route === "uninlined" ? `.other { --brand: ${spelling}; }` : ""; + + registerCSS(` + .parent { color: red; } + .child { --brand: ${spelling}; color: var(--brand); } + ${secondDefinition} + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }, + ); + + test("color: inherit alongside a box-shadow leaves no placeholder in the style", () => { + registerCSS(` + .parent { color: red; } + .child { color: inherit; box-shadow: 1px 1px blue; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#00f", + }, + ], + }); + }); + + test("color: currentcolor alongside a box-shadow leaves no placeholder either", () => { + registerCSS(` + .parent { color: red; } + .child { color: currentcolor; box-shadow: 1px 1px blue; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#00f", + }, + ], + }); + }); + + test("color: inherit alongside a text-shadow leaves no placeholder either", () => { + registerCSS(` + .parent { color: red; } + .child { color: inherit; text-shadow: 1px 1px 2px blue; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + textShadowColor: "#00f", + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 2, + }); + }); + + test.each(["inherit", "unset", "currentcolor", "revert", "revert-layer"])( + "color: %s over an earlier rule hands its subtree the parent's color", + (keyword) => { + // With no user-agent or user origin, `revert` is `unset` (css-cascade-5 §7.3.4) + registerCSS(` + .parent { color: red; } + .earlier { color: green; } + .mid { color: ${keyword}; border-color: currentcolor; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("mid").props.style).toStrictEqual({ + color: "#f00", + borderColor: "#f00", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }, + ); + + test("a ::placeholder color leaves the element's own color", () => { + registerCSS(` + .parent { color: blue; } + .child { border-color: currentcolor; } + .child::placeholder { color: red; } + .grandchild { color: inherit; } + `); + + render( + + + + + , + ); + + const child = screen.getByTestId("child"); + + expect(child.props.placeholderTextColor).toBe("#f00"); + expect(child.props.style).toStrictEqual({ borderColor: "#00f" }); + expect(screen.getByTestId("grandchild").props.style).toStrictEqual({ + color: "#00f", + }); + }); + + test.each(["color-mix(in srgb, inherit, blue)", "light-dark(inherit, blue)"])( + "color: %s leaves an earlier rule's color", + (value) => { + // A CSS-wide keyword is only ever a whole value, so this declaration is invalid (css-values-4 §2.1) + registerCSS(` + .parent { color: red; } + .earlier { color: green; } + .child { color: ${value}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#008000", + }); + }, + ); + + test.each([ + ["var(--missing, inherit)", "#f00"], + ["var(--missing, initial)", undefined], + ["var(--missing, var(--also-missing, inherit))", "#f00"], + ] as const)( + "color: %s applies the keyword over an earlier rule", + (value, expectedColor) => { + registerCSS(` + .parent { color: red; } + .earlier { color: green; } + .child { color: ${value}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: expectedColor, + }); + }, + ); + + test("a light-dark() ancestor's color is inherited by a descendant", () => { + registerCSS(` + .parent { color: light-dark(red, blue); } + .child { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("parent").props.style).toStrictEqual({ + color: "#f00", + }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); +}); + +const colorsThatReadTheInheritedColor: [css: string, midColor: string][] = [ + ["inherit", "#f00"], + ["unset", "#f00"], + ["currentcolor", "#f00"], + ["var(--missing, inherit)", "#f00"], + ["var(--missing, unset)", "#f00"], + ["var(--missing, currentcolor)", "#f00"], + ["light-dark(currentcolor, blue)", "#f00"], + ["color-mix(in srgb, currentcolor, blue)", "rgba(127.5, 0, 127.5, 1)"], +]; + +describe("a color that reads the inherited color", () => { + test.each(colorsThatReadTheInheritedColor)( + "mid { color: %s } resolves against its parent", + (midColorValue, expectedMidColor) => { + registerCSS(` + .parent { color: red; } + .mid { color: ${midColorValue}; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("mid").props.style).toStrictEqual({ + color: expectedMidColor, + }); + }, + ); + + test.each( + colorsThatReadTheInheritedColor.filter( + ([, midColor]) => midColor === "#f00", + ), + )("a child of mid { color: %s } inherits the same color", (midColorValue) => { + registerCSS(` + .parent { color: red; } + .mid { color: ${midColorValue}; } + .child { color: inherit; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 agree on each case: currentcolor is the inherited colour only inside `color` itself +describe("currentcolor inside color reads the inherited colour, and nowhere else", () => { + test.each([ + [ + "box-shadow", + "box-shadow: 0 0 1px currentcolor;", + { + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 1, + spreadDistance: 0, + color: "#00f", + }, + ], + }, + ], + [ + "text-shadow", + "text-shadow: 1px 1px 1px currentcolor;", + { + textShadowColor: "#00f", + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 1, + }, + ], + ] as const)( + "%s reads the element's own colour", + (_property, declaration, shadowStyle) => { + registerCSS(` + .parent { color: #ff0000; } + .child { color: #0000ff; ${declaration} } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#00f", + ...shadowStyle, + }); + }, + ); + + test("color-mix() of currentcolor on color mixes the inherited colour", () => { + registerCSS(` + .parent { color: #ff0000; } + .child { color: color-mix(in srgb, currentcolor, #0000ff); } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "rgba(127.5, 0, 127.5, 1)", + }); + }); +}); diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx new file mode 100644 index 00000000..17c6e5cb --- /dev/null +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -0,0 +1,258 @@ +import { render } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { registerCSS, testID } from "react-native-css/jest"; + +// A second definition keeps each variable from being inlined, so it resolves at runtime +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; --s: 9px 9px green; --t: none; }`; + +function renderText(css: string, className = "my-class") { + registerCSS(`${decoy} ${css}`); + + return render().getByTestId( + testID, + ).props; +} + +// A declaration that reads a variable resolves against the target it was declared on +describe("a deferred declaration beside a nested one", () => { + test("color: var() beside a box-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); + + test("color: var() beside a text-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + textShadowColor: "#00f", + textShadowRadius: 2, + textShadowOffset: { width: 1, height: 1 }, + }); + }); + + test("color: var() beside a prop", () => { + const props = renderText( + `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); + + test("a deferred prop beside a style", () => { + const props = renderText( + `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); + + test("a text-shadow with no colour takes the element's colour", () => { + expect( + renderText(`.my-class { color: #0000ff; text-shadow: 2px 2px; }`).style, + ).toStrictEqual({ + color: "#00f", + textShadowColor: "#00f", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }); + }); + + test("a box-shadow's first shadow with no colour takes the inherited colour", () => { + registerCSS( + `${decoy} .parent { color: #123456; } .my-class { box-shadow: inset 1px 1px, 2px 2px #654321; }`, + ); + + const child = render( + + + , + ).getByTestId(testID); + + expect(child.props.style).toStrictEqual({ + boxShadow: [ + { + color: "#123456", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + inset: true, + }, + { + color: "#654321", + offsetX: 2, + offsetY: 2, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves +describe("a deferred shorthand keeps no key a later declaration set", () => { + test.each([ + [ + "border: var() then border-color, in one rule", + ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), in one rule", + ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border: var() then border-color, across rules", + ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), across rules", + ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border-inline-width: var() then border-inline-start-width", + ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }", + "my-class", + { borderStartWidth: 5, borderEndWidth: 3 }, + ], + [ + "border-inline-start-width then border-inline-width: var()", + ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }", + "my-class", + { borderStartWidth: 2, borderEndWidth: 3 }, + ], + [ + "text-shadow: var() then text-shadow, across rules", + ".earlier { --s: 2px 2px #00f; text-shadow: var(--s); } .my-class { text-shadow: 1px 1px red; }", + "earlier my-class", + { + textShadowColor: "#f00", + textShadowRadius: 0, + textShadowOffset: { width: 1, height: 1 }, + }, + ], + [ + "text-shadow then text-shadow: var(), across rules", + ".earlier { text-shadow: 1px 1px red; } .my-class { --s: 2px 2px #00f; text-shadow: var(--s); }", + "earlier my-class", + { + textShadowColor: "#00f", + textShadowRadius: 0, + textShadowOffset: { width: 2, height: 2 }, + }, + ], + ] as const)("%s", (_name, css, className, style) => { + expect(renderText(css, className).style).toStrictEqual(style); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply a transform and an individual transform property together, however the transform is written +test("a deferred transform composes with an individual transform property as a written one does", () => { + registerCSS( + `${decoy} .held { --t: translateX(10px); transform: var(--t); } .written { transform: translateX(10px); } .turn { rotate: 45deg; }`, + ); + + const screen = render( + <> + + + , + ); + + const written = screen.getByTestId("written").props.style as { + transform: unknown[]; + }; + expect(written.transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + expect(screen.getByTestId("held").props.style).toStrictEqual(written); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 reset a property whose var() has no value, so an earlier rule's value is gone +test("a var() with no value resets an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: var(--c-missing); }", + "earlier my-class", + ).style, + ).toStrictEqual({ width: undefined }); +}); + +// CSS 2.1 §4.2: a declaration with a value the property cannot take is ignored, so the earlier rule's value stands +test("a written value the runtime cannot read leaves an earlier rule's value", () => { + expect( + renderText( + ".earlier { width: 10px; } .my-class { width: roundToNearestPixel(foo); }", + "earlier my-class", + ).style, + ).toStrictEqual({ width: 10 }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply an individual transform property whatever the cascade order of the transform beside it +test.each([ + [ + "before a transform", + ".earlier { rotate: 45deg; } .my-class { transform: translateX(10px); }", + ], + [ + "after a transform", + ".earlier { transform: translateX(10px); } .my-class { rotate: 45deg; }", + ], +])( + "an individual transform property declared %s composes with it", + (_order, css) => { + const { transform } = renderText(css, "earlier my-class").style as { + transform: unknown[]; + }; + + expect(transform).toHaveLength(2); + expect(transform).toEqual( + expect.arrayContaining([{ translateX: 10 }, { rotateZ: "45deg" }]), + ); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply both, each to its own property +test("an individual transform property beside a box-shadow", () => { + expect( + renderText(".my-class { rotate: 45deg; box-shadow: 1px 1px blue; }").style, + ).toStrictEqual({ + transform: [{ rotateZ: "45deg" }], + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); +}); diff --git a/src/__tests__/native/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx index 0e7a2840..8d8f4918 100644 --- a/src/__tests__/native/filter-list-runtime.test.tsx +++ b/src/__tests__/native/filter-list-runtime.test.tsx @@ -3,6 +3,7 @@ import { StyleSheet } from "react-native"; import { render, screen } from "@testing-library/react-native"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; +import { VariableContextProvider } from "react-native-css/native"; const firstShadow = { dropShadow: { offsetX: 0, offsetY: 1, standardDeviation: 2, color: "#000" }, @@ -58,6 +59,145 @@ describe.each([undefined, false] as const)( screen.rerender(); expect(filter()).toEqual([firstShadow, secondShadow]); }); + + // Chromium 153, Firefox 155 and WebKit 26.6 leave no filter for each + test.each([ + ["filter: var(--effect)", "none", [{ blur: 3 }]], + [ + "filter: brightness(0.5) var(--effect)", + "none", + [{ brightness: 0.5 }, { blur: 3 }], + ], + ])( + "%s with --effect: %s removes the filter", + (declaration, keyword, before) => { + registerCSS( + `.subject { --effect: blur(3px); ${declaration}; } .keyword { --effect: ${keyword}; }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual(before); + screen.rerender(); + expect(filter()).toBeUndefined(); + }, + ); + + // Chromium 153, Firefox 155 and WebKit 26.6: an empty fallback substitutes nothing, while an undefined variable with none invalidates the list + test("an empty fallback contributes no filter", () => { + registerCSS(`.subject { filter: var(--missing,) blur(2px); }`, { + inlineVariables, + }); + render(); + expect(filter()).toEqual([{ blur: 2 }]); + }); + + test("an undefined variable with no fallback leaves no filter", () => { + registerCSS( + `.earlier { filter: blur(4px); } .subject { filter: var(--missing) blur(2px); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toBeUndefined(); + }); + + test("an undefined variable that is the whole value leaves no filter", () => { + registerCSS( + `.earlier { filter: blur(4px); } .subject { filter: var(--missing); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toBeUndefined(); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 ignore a written filter with an entry the property cannot take, so the earlier filter stands + test.each([ + "blur(2px) drop-shadow(2px)", + "drop-shadow(2px)", + "blur(foo) brightness(2)", + "brightness(-1)", + "blur(-1px)", + "hue-rotate(90)", + "blur(2px 3px)", + "none blur(2px)", + "blur(2px) none", + "drop-shadow(1px 2px 3px 4px red)", + ])("filter: %s as written is ignored", (written) => { + registerCSS( + `.earlier { filter: blur(4px); } .written { filter: ${written}; }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ blur: 4 }]); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 leave no filter where a variable holds one, as the declaration is invalid at computed-value time + test.each([ + "blur(foo)", + "blur(2px) drop-shadow(2px)", + "brightness(-1)", + "blur(2px 3px)", + "drop-shadow(1px 2px 3px 4px red)", + ])("filter: var(--effect) with --effect: %s leaves no filter", (held) => { + registerCSS( + `.other { --effect: blur(1px); } .earlier { filter: blur(4px); } .subject { --effect: ${held}; filter: var(--effect); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toBeUndefined(); + }); + + test("a rejected written filter hands back to the variable filter it followed", () => { + registerCSS( + `.other { --effect: blur(1px); } .earlier { filter: blur(4px); } .middle { --effect: brightness(2); filter: var(--effect); } .written { filter: blur(foo); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ brightness: 2 }]); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 compute hue-rotate(360deg) and hue-rotate(90deg), and React Native takes degrees or radians alone + test.each([ + ["1turn", "360deg"], + ["100grad", "90deg"], + ["-90DEG", "-90deg"], + ])( + "hue-rotate(%s) supplied at render reaches React Native as %s", + (angle, degrees) => { + registerCSS(`.subject { filter: hue-rotate(var(--angle)); }`, { + inlineVariables, + }); + render( + + + , + ); + expect(filter()).toEqual([{ hueRotate: degrees }]); + }, + ); + + test.each([ + ["1turn", "360deg"], + ["100grad", "90deg"], + ])( + "hue-rotate(%s) a stylesheet variable holds reaches React Native as %s", + (angle, degrees) => { + registerCSS( + `.other { --effect: blur(1px); } .subject { --effect: hue-rotate(${angle}); filter: var(--effect); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ hueRotate: degrees }]); + }, + ); + + test("CONTROL: a variable holding filters the property takes keeps them all", () => { + registerCSS( + `.other { --effect: blur(1px); } .subject { --effect: blur(2px) brightness(150%); filter: var(--effect); }`, + { inlineVariables }, + ); + render(); + expect(filter()).toEqual([{ blur: 2 }, { brightness: "150%" }]); + }); }, ); diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index f1b3defc..40ea97f5 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -1,17 +1,658 @@ -import { View } from "react-native"; - -import { renderHook } from "@testing-library/react-native"; +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; -import { useNativeCss } from "react-native-css/native"; -test("unset", () => { - registerCSS(`.my-class { background-color: unset; }`); +const resettingKeywords = ["initial", "unset", "revert", "revert-layer"]; + +const clearedProperties: readonly (readonly [ + property: string, + earlier: string, + cleared: Record, +])[] = [ + ["background-color", "red", { backgroundColor: undefined }], + ["opacity", "0.5", { opacity: undefined }], + ["box-shadow", "1px 1px red", { boxShadow: undefined }], + ["filter", "blur(2px)", { filter: undefined }], + ["transform", "translateX(10px)", { transform: undefined }], + ["line-height", "30px", { lineHeight: undefined }], + [ + "text-shadow", + "1px 1px red", + { + textShadowColor: undefined, + textShadowOffset: undefined, + textShadowRadius: undefined, + }, + ], + [ + "border", + "2px solid red", + { borderWidth: undefined, borderStyle: undefined, borderColor: undefined }, + ], +]; + +function renderStyle(css: string): unknown { + registerCSS(css); + + render(); + + return screen.getByTestId("child").props.style; +} + +// Chromium 153, Firefox 155 and WebKit 26.6 reset each of these to its initial value, which React Native's default matches +test.each( + clearedProperties.flatMap(([property, earlier, cleared]) => + resettingKeywords.map( + (keyword) => [property, keyword, earlier, cleared] as const, + ), + ), +)( + "%s: %s clears an earlier rule's value", + (property, keyword, earlier, cleared) => { + expect( + renderStyle( + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${keyword}; }`, + ), + ).toStrictEqual(cleared); + }, +); + +test.each(resettingKeywords)( + "border-block-width: %s resets to React Native's default width", + (keyword) => { + expect( + renderStyle( + `.earlier { border-block-width: 3px; } .child { border-block-width: ${keyword}; }`, + ), + ).toStrictEqual({ + borderTopWidth: undefined, + borderBottomWidth: undefined, + }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 apply a keyword fallback of an undefined env() as the whole value +test("padding-top: env(safe-area-inset-top, initial) clears an earlier rule's value when no inset is defined", () => { + expect( + renderStyle( + `.earlier { padding-top: 10px; } .child { padding-top: env(safe-area-inset-top, initial); }`, + ), + ).toStrictEqual({ paddingTop: undefined }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 read the ancestor's value for each, as a custom property inherits (css-variables-1 §2) +test.each(["inherit", "unset", "revert", "revert-layer"])( + "--brand: %s reads the ancestor's --brand", + (keyword) => { + registerCSS(` + .parent { --brand: green; } + .child { --brand: ${keyword}; color: var(--brand); } + .other { --brand: ${keyword}; } + `); + + render( + + + , + ); - const { result } = renderHook(() => { - return useNativeCss(View, { className: "my-class" }); + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }, +); + +const routes = [ + ["inlined", {}], + ["kept", { inlineVariables: false }], +] as const; + +function renderNested( + css: string, + options: (typeof routes)[number][1], + childClassName = "child", +): unknown { + registerCSS(css, options); + + render( + + + , + ); + + return screen.getByTestId("child").props.style; +} + +// Chromium 153, Firefox 155 and WebKit 26.6 resolve the keyword on the custom property itself, so it wins over an earlier rule's value (css-variables-1 §2) +test.each(["inherit", "unset", "revert", "revert-layer"])( + "--brand: %s over an earlier rule reads the ancestor's --brand", + (keyword) => { + expect( + renderNested( + ` + .parent { --brand: green; } + .earlier { --brand: blue; } + .child { --brand: ${keyword}; color: var(--brand); } + `, + {}, + "earlier child", + ), + ).toStrictEqual({ color: "green" }); + }, +); + +test("--brand: inherit over an earlier rule hands the subtree the ancestor's --brand", () => { + registerCSS(` + .parent { --brand: green; } + .earlier { --brand: blue; } + .middle { --brand: inherit; } + .child { color: var(--brand); } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", }); +}); + +test("--brand: inherit with no ancestor --brand hands the subtree the guaranteed-invalid value", () => { + registerCSS(` + .earlier { --brand: blue; } + .middle { --brand: inherit; } + .child { color: var(--brand, green); } + `); + + render( + + + + + , + ); - expect(result.current.props).toStrictEqual({ - style: { backgroundColor: undefined }, + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6: * declares on the parent too, so it is the value the parent hands down +test("--parent-own: inherit under no declaring ancestor reads what * declares", () => { + expect( + renderNested( + ` + * { --parent-own: red; } + .child { --parent-own: inherit; color: var(--parent-own, green); } + `, + {}, + ), + ).toStrictEqual({ color: "red" }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 give a custom property set to initial the guaranteed-invalid value (css-variables-1 §2.2), so a var() reading it takes its fallback +describe.each(routes)("--brand: initial, %s", (_route, options) => { + test("takes the var() fallback over an ancestor's --brand", () => { + expect( + renderNested( + ` + .parent { --brand: blue; } + .child { --brand: initial; color: var(--brand, green); } + `, + options, + ), + ).toStrictEqual({ color: "green" }); + }); + + test("makes a custom property reading it the guaranteed-invalid value too", () => { + expect( + renderNested( + ` + .child { --source: initial; --reader: var(--source); color: var(--reader, green); } + .other { --reader: 1px; } + `, + options, + ), + ).toStrictEqual({ color: "green" }); + }); + + test("hides the ancestor's --brand from a descendant", () => { + registerCSS( + ` + .parent { --brand: blue; } + .middle { --brand: initial; } + .child { color: var(--brand, green); } + `, + options, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }); + + test("hides a :root declaration from a descendant", () => { + registerCSS( + ` + :root { --hidden-root: red; } + .middle { --hidden-root: initial; } + .child { color: var(--hidden-root, green); } + .other { --hidden-root: blue; } + `, + options, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }); + + test("over an earlier rule makes var(--brand) invalid, so the colour inherits", () => { + expect( + renderNested( + ` + .parent { color: green; --brand: red; } + .earlier { --brand: blue; } + .child { --brand: initial; color: var(--brand); } + `, + options, + "earlier child", + ), + ).toStrictEqual({ color: "#008000" }); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 give a custom property its var() leaves without a value the guaranteed-invalid value (css-variables-1 §3.1), so a var() reading it takes its fallback +describe.each(routes)( + "a custom property a var() leaves without a value, %s", + (_route, options) => { + test("takes the var() fallback over an ancestor's declaration", () => { + expect( + renderNested( + ` + .parent { --unresolved: blue; } + .child { --unresolved: var(--missing); color: var(--unresolved, green); } + `, + options, + ), + ).toStrictEqual({ color: "green" }); + }); + + test("hides the ancestor's declaration from a descendant", () => { + registerCSS( + ` + .parent { --unresolved-down: blue; } + .middle { --unresolved-down: var(--missing); } + .child { color: var(--unresolved-down, green); } + `, + options, + ); + + render( + + + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }); + + test("reads the guaranteed-invalid value inside a longer value", () => { + registerCSS( + `.child { --source: initial; --reader: var(--source) 1px; width: var(--reader, 5px); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 5, + }); + }); + + test("reads it inside a function declared before the variable it reads", () => { + registerCSS( + `.child { --reader: calc(var(--source) * 2); --source: initial; width: var(--reader, 5px); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 5, + }); + }); + + test.each([ + ["declared once", ""], + ["declared twice", ".other { --reader: 1px; }"], + ])( + "an empty value substituted whole lets the colour inherit, the variable %s", + (_declarations, other) => { + expect( + renderNested( + ` + .parent { color: green; } + .child { --source: initial; --reader: var(--source,); color: var(--reader, red); } + ${other} + `, + options, + ), + ).toStrictEqual({ color: "#008000" }); + }, + ); + + test("a value whose every var() substitutes nothing clears an earlier rule's value", () => { + registerCSS( + ` + .earlier { margin: 5px; } + .child { --empty-start: initial; --empty-end: initial; margin: var(--empty-start,) var(--empty-end,); } + .other { --empty-end: 1px; } + `, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + margin: undefined, + }); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 substitute an empty custom property's value as nothing, over an ancestor's + test("an authored empty value shadows the ancestor's, and lets the colour inherit", () => { + expect( + renderNested( + ` + .parent { color: green; --authored-empty: red; } + .child { --authored-empty: ; color: var(--authored-empty, blue); } + `, + options, + ), + ).toStrictEqual({ color: "#008000" }); + }); + + test("an authored empty value substitutes nothing inside a longer value", () => { + registerCSS( + `.child { --authored-empty-list: ; width: calc(4px var(--authored-empty-list) + 2px); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: 6, + }); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 unset both, reading neither the fallback nor the parent's declaration + test("a value that resolves to nothing reads the same each time it is read", () => { + registerCSS( + ` + .parent { --memo: 9px; } + .child { --memo-empty: initial; --memo: calc(var(--memo-empty,) * 2); width: var(--memo, 5px); height: var(--memo, 5px); } + .other { --memo: 1px; --memo-empty: 1px; } + `, + options, + ); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: undefined, + height: undefined, + }); + }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6: the root has no parent, so inherit gives the guaranteed-invalid value there too +test.each(["inherit", "initial"])( + ":root { --brand: %s } leaves var(--brand) its fallback", + (keyword) => { + expect( + renderNested( + ` + :root { --root-brand: ${keyword}; } + .other { --root-brand: blue; } + .child { color: var(--root-brand, green); } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6: * declares on the element itself, below its classes +test("* { --brand: inherit } reads the parent's --brand", () => { + expect( + renderNested( + ` + .parent { --universal-inherit: blue; } + * { --universal-inherit: inherit; } + .child { color: var(--universal-inherit, green); } + `, + {}, + ), + ).toStrictEqual({ color: "blue" }); +}); + +test("* { --brand: initial } leaves var(--brand) its fallback", () => { + expect( + renderNested( + ` + * { --universal-initial: initial; } + .other { --universal-initial: blue; } + .child { color: var(--universal-initial, green); } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); +}); + +test("--brand: inherit under *'s initial leaves var(--brand) its fallback", () => { + expect( + renderNested( + ` + * { --universal-gone: initial; } + .child { --universal-gone: inherit; color: var(--universal-gone, green); } + .other { --universal-gone: blue; } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); +}); + +test("* { --universal-over-ancestor: initial } hides an ancestor's declaration", () => { + expect( + renderNested( + ` + .parent { --universal-over-ancestor: blue; } + * { --universal-over-ancestor: initial; } + .child { color: var(--universal-over-ancestor, green); } + `, + {}, + ), + ).toStrictEqual({ color: "green" }); +}); + +test("a class's --brand: inherit wins over *'s, and reads the parent's", () => { + expect( + renderNested( + ` + .parent { --class-over-universal: blue; } + * { --class-over-universal: red; } + .child { --class-over-universal: inherit; color: var(--class-over-universal, green); } + `, + {}, + ), + ).toStrictEqual({ color: "blue" }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 make a declaration whose var() resolves to nothing unset (css-variables-1 §3.1): color inherits, the others reset to their initial value, which React Native's default matches +describe.each(routes)( + "a var() that resolves to nothing, %s", + (_route, options) => { + test("--brand: inherit with no ancestor --brand: the colour inherits", () => { + expect( + renderNested( + ` + .parent { color: red; } + .child { --brand: inherit; color: var(--brand); } + `, + options, + ), + ).toStrictEqual({ color: "#f00" }); + }); + + test("color over an earlier rule inherits", () => { + expect( + renderNested( + ` + .parent { color: green; } + .earlier { color: red; } + .child { color: var(--missing); } + `, + options, + "earlier child", + ), + ).toStrictEqual({ color: "#008000" }); + }); + + test.each(clearedProperties)( + "%s clears an earlier rule's value", + (property, earlier, cleared) => { + registerCSS( + `.earlier { ${property}: ${earlier}; } .child { ${property}: var(--missing); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual(cleared); + }, + ); + + test("an empty fallback as the whole value inherits the colour", () => { + expect( + renderNested( + ` + .parent { color: green; } + .earlier { color: red; } + .child { color: var(--missing,); } + `, + options, + "earlier child", + ), + ).toStrictEqual({ color: "#008000" }); + }); + + test("width: calc() of it clears an earlier rule's width", () => { + registerCSS( + `.earlier { width: 50px; } .child { width: calc(var(--missing) * 2); }`, + options, + ); + + render(); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + width: undefined, + }); + }); + }, +); + +const earlierShadow = { + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#f00", + }, + ], +}; + +// Chromium 153, Firefox 155 and WebKit 26.6 ignore each invalid declaration and keep the earlier value +test.each([ + ["filter", "foo(1)", "blur(2px)", { filter: [{ blur: 2 }] }], + ["box-shadow", "foo(1)", "1px 1px red", earlierShadow], + ["box-shadow", "1px 1px inherit", "1px 1px red", earlierShadow], + [ + "transform", + "foo(1)", + "translateX(10px)", + { transform: [{ translateX: 10 }] }, + ], +] as const)( + "%s: %s leaves an earlier rule's value", + (property, invalid, earlier, kept) => { + expect( + renderStyle( + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${invalid}; }`, + ), + ).toStrictEqual(kept); + }, +); + +test.each([ + [ + "object-position", + "left inherit", + "right top", + { contentPosition: "right top" }, + ], + [ + "font-variant", + "all-small-caps inherit", + "small-caps", + { style: { fontVariant: "small-caps" } }, + ], + [ + "corner-shape", + "round inherit", + "squircle", + { style: { borderCurve: "continuous" } }, + ], +] as const)( + "%s: %s leaves an earlier rule's value", + (property, invalid, earlier, kept) => { + registerCSS( + `.earlier { ${property}: ${earlier}; } .child { ${property}: ${invalid}; }`, + ); + + render(); + + expect(screen.getByTestId("child").props).toMatchObject(kept); + }, +); diff --git a/src/__tests__/native/transform.test.tsx b/src/__tests__/native/transform.test.tsx index 7a59b816..25db8c9e 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -113,11 +113,12 @@ describe("transform", () => { }); }); + // Chromium 153, Firefox 155 and WebKit 26.6: Tailwind's empty fallbacks let an undefined variable substitute nothing test("rotate-x-45", () => { registerCSS(` .rotate-45 { --tw-rotate-x: rotateX(45deg); - transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y); + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); }`); const component = render( @@ -154,3 +155,112 @@ describe("transform", () => { }); }); }); + +describe("a transform's target survives a later nested declaration", () => { + test("translate before a box-shadow lands in transform", () => { + registerCSS( + `.my-class { translate: 10px 20px; box-shadow: 1px 1px blue; }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + transform: [{ translateX: 10 }, { translateY: 20 }], + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#00f", + }, + ], + }); + }); + + test("declaration order does not matter", () => { + registerCSS( + `.my-class { box-shadow: 1px 1px blue; translate: 10px 20px; }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + transform: [{ translateX: 10 }, { translateY: 20 }], + boxShadow: [ + { + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + color: "#00f", + }, + ], + }); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 leave no transform for each +test.each([ + ["transform: var(--t)", "none"], + ["transform: translateX(10px) var(--t)", "none"], +])("%s with --t: %s leaves no transform", (declaration, keyword) => { + registerCSS( + `.my-class { --t: rotate(1deg); ${declaration}; } .keyword { --t: ${keyword}; }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ transform: undefined }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 ignore a written transform list with a keyword among its functions, so the earlier transform stands +test("transform: none rotate(10deg) as written is ignored", () => { + registerCSS( + `.earlier { transform: translateX(10px); } .written { transform: none rotate(10deg); }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + transform: [{ translateX: 10 }], + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 substitute each transform a variable holds, in place +test("a var() holding several transforms substitutes each of them", () => { + registerCSS( + `.my-class { --several: rotate(45deg) scale(2); transform: translateX(1px) var(--several); } .other { --several: none; }`, + ); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + transform: [{ translateX: 1 }, { rotate: "45deg" }, { scale: 2 }], + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute a gradian or a turn as the angle it is; React Native takes degrees or radians alone +test.each([ + ["transform: rotate(100grad)", { transform: [{ rotate: "90deg" }] }], + ["rotate: 0.25turn", { transform: [{ rotateZ: "90deg" }] }], + ["transform: skewX(50grad)", { transform: [{ skewX: "45deg" }] }], +])("%s reaches React Native in degrees", (declaration, style) => { + registerCSS(`.my-class { ${declaration}; }`); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual(style); +}); diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..3ebad172 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -271,3 +271,111 @@ test("variable overriding with classes", () => { const component = screen.getByTestId(testID); expect(component.props.style).toStrictEqual({ color: "#f00" }); }); + +// Chromium 153, Firefox 155 and WebKit 26.6 read a custom property's name as its own, never as a property every object inherits +test.each(["constructor", "toString", "hasOwnProperty"])( + "var(--%s) with no declaration takes its fallback", + (name) => { + registerCSS(` + .parent { --unrelated: 1px; } + .other { --unrelated: 2px; } + .test { color: var(--${name}, green); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "green", + }); + }, +); + +test("var(--constructor) reads the ancestor's declaration", () => { + registerCSS(` + .parent { --constructor: blue; } + .other { --constructor: red; } + .test { color: var(--constructor); } + `); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "blue", + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 make each member of a cycle the guaranteed-invalid value (css-variables-1 §2.3), wherever it is declared +test.each([ + ["*", "universal-cycle"], + [":root", "root-cycle"], +])("a cycle declared on %s leaves var() its fallback", (selector, name) => { + registerCSS(` + ${selector} { --${name}-a: var(--${name}-b); --${name}-b: var(--${name}-a); } + .other { --${name}-a: 1px; --${name}-b: 1px; } + .test { color: var(--${name}-a, green); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "green", + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6 compute a variable read inside a function from the variable's own computed value, wherever either is declared +test.each([ + [ + "after", + ".child { --order-source: 3px; --order-middle: var(--order-source); --order-reader: calc(var(--order-middle) * 2); width: var(--order-reader, 5px); }", + ], + [ + "before", + ".child { --order-reader: calc(var(--order-middle) * 2); --order-middle: var(--order-source); --order-source: 3px; width: var(--order-reader, 5px); }", + ], + [ + "inside an undeclared variable's fallback, before", + ".child { --order-reader: var(--order-absent, calc(var(--order-middle) * 2)); --order-middle: var(--order-source); --order-source: 3px; width: var(--order-reader); }", + ], +])( + "a variable read inside a function declared %s the chain it reads resolves through it", + (_order, css) => { + registerCSS(css); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 6 }); + }, +); + +// Chromium 153, Firefox 155 and WebKit 26.6 give every member of a cycle the guaranteed-invalid value however few rules declare it, and resolve a variable two branches read in both +test.each([ + [ + "a cycle declared once leaves var() its fallback", + ".child { --cycle-once-a: var(--cycle-once-b); --cycle-once-b: var(--cycle-once-a); color: var(--cycle-once-b, green); }", + { color: "green" }, + ], + [ + "a cycle read twice in one declaration takes each fallback", + ".child { --cycle-x: var(--cycle-y); --cycle-y: var(--cycle-x); width: calc(var(--cycle-x, 2px) + var(--cycle-x, 3px)); } .other { --cycle-x: 1px; --cycle-y: 1px; }", + { width: 5 }, + ], + [ + "a variable two branches read is no cycle", + ".child { --diamond-reader: calc(var(--diamond-left) + var(--diamond-right)); --diamond-left: var(--diamond-base); --diamond-right: var(--diamond-base); --diamond-base: 3px; width: var(--diamond-reader, 1px); }", + { width: 6 }, + ], +])("%s", (_name, css, style) => { + registerCSS(css); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(style); +}); diff --git a/src/__tests__/utilities/angle.test.ts b/src/__tests__/utilities/angle.test.ts new file mode 100644 index 00000000..4aa512fa --- /dev/null +++ b/src/__tests__/utilities/angle.test.ts @@ -0,0 +1,29 @@ +import { readAngle } from "react-native-css/utilities"; + +describe("readAngle", () => { + test.each([ + [0, 0], + ["90deg", "90deg"], + ["-90DEG", "-90deg"], + ["1rad", "1rad"], + ["1turn", "360deg"], + ["-0.5turn", "-180deg"], + ["100grad", "90deg"], + ["1e-1turn", "36deg"], + [".5rad", ".5rad"], + ])("%s reaches React Native as %s", (angle, expected) => { + expect(readAngle(angle)).toBe(expected); + }); + + test.each([90, "90", "90px", "deg", "foo", null, undefined])( + "%s is no angle", + (argument) => { + expect(readAngle(argument)).toBeUndefined(); + }, + ); + + test("the reading is idempotent", () => { + const once = readAngle("1turn"); + expect(readAngle(once)).toBe(once); + }); +}); diff --git a/src/__tests__/utilities/filter-arguments.test.ts b/src/__tests__/utilities/filter-arguments.test.ts new file mode 100644 index 00000000..d5397ef8 --- /dev/null +++ b/src/__tests__/utilities/filter-arguments.test.ts @@ -0,0 +1,33 @@ +import { + readNonNegativeAmount, + readNonNegativeLength, +} from "react-native-css/utilities"; + +describe("readNonNegativeLength", () => { + test.each([0, 2, 0.5])("%s is a length blur takes", (length) => { + expect(readNonNegativeLength(length)).toBe(length); + }); + + test.each([-1, "2px", "50%", "foo", null, undefined])( + "%s is no length blur takes", + (argument) => { + expect(readNonNegativeLength(argument)).toBeUndefined(); + }, + ); +}); + +describe("readNonNegativeAmount", () => { + test.each([0, 1.5, "0%", "150%", ".5%", "+50%", "1e2%"])( + "%s is an amount a filter takes", + (amount) => { + expect(readNonNegativeAmount(amount)).toBe(amount); + }, + ); + + test.each([-0.5, "-10%", "-1e2%", "2px", "foo", "%", "5.%", null, undefined])( + "%s is no amount a filter takes", + (argument) => { + expect(readNonNegativeAmount(argument)).toBeUndefined(); + }, + ); +}); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..3ff4e94a 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -322,9 +322,15 @@ describe("Typography - Text Color", () => { }); }); test("text-inherit", async () => { + // currentColor on `color` is `inherit` (css-color-3 §4.4), so text-inherit renders as text-current does expect(await renderCurrentTest()).toStrictEqual({ - props: {}, - warnings: { values: { color: "inherit" } }, + props: { + style: { + color: { + semantic: ["label", "labelColor"], + }, + }, + }, }); }); }); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..d063df44 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -159,11 +159,14 @@ export type InlineVariable = { [key: string]: unknown | undefined; }; -export type UniqueVarInfo = { - count: number; - value: TokenOrValue[] | undefined; - flat?: true; -}; +/** A custom property's value as declared, until flattening settles what it substitutes: tokens, or undefined for the guaranteed-invalid value */ +export type UniqueVarInfo = + | { readonly flat?: undefined; count: number; value: TokenOrValue[] } + | { + readonly flat: true; + count: number; + value: TokenOrValue[] | undefined; + }; /****************************** Animations ******************************/ diff --git a/src/compiler/css-wide-keywords.ts b/src/compiler/css-wide-keywords.ts new file mode 100644 index 00000000..26d49978 --- /dev/null +++ b/src/compiler/css-wide-keywords.ts @@ -0,0 +1,43 @@ +import type { TokenOrValue } from "lightningcss"; + +const CSS_WIDE_KEYWORDS = [ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", +] as const; + +export type CSSWideKeyword = (typeof CSS_WIDE_KEYWORDS)[number]; + +export function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { + return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword); +} + +export interface SoleCSSWideKeyword { + readonly keyword: CSSWideKeyword; + readonly written: string; +} + +// A CSS-wide keyword is a value only on its own (css-values-4 §2.1), and a custom property's value keeps the whitespace around it +export function soleCSSWideKeyword( + tokens: readonly TokenOrValue[] | null | undefined, +): SoleCSSWideKeyword | undefined { + const [token, ...rest] = (tokens ?? []).filter( + (part) => part.type !== "token" || part.value.type !== "white-space", + ); + + if ( + token === undefined || + rest.length > 0 || + token.type !== "token" || + token.value.type !== "ident" + ) { + return; + } + + const written = token.value.value; + const keyword = written.toLowerCase(); + + return isCSSWideKeyword(keyword) ? { keyword, written } : undefined; +} diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..05782b7c 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -11,6 +11,7 @@ import type { Declaration, DimensionPercentageFor_LengthValue, EnvironmentVariable, + Filter, FontSize, FontStyle, FontVariantCaps, @@ -34,15 +35,31 @@ import type { TokenOrValue, Translate, UnresolvedColor, + Variable, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { + guaranteedInvalidValue, + INHERITED_COLOR_VARIABLE, + inheritedColorLookup, + inheritedValue, + isInheritedColorLookup, + isStyleFunction, + readAngle, + readNonNegativeAmount, + readNonNegativeLength, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, StyleFunction, StyleRule, } from "./compiler.types"; +import { + isCSSWideKeyword, + soleCSSWideKeyword, + type SoleCSSWideKeyword, +} from "./css-wide-keywords"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; import { toRNProperty } from "./selectors"; import type { StylesheetBuilder } from "./stylesheet"; @@ -983,17 +1000,24 @@ export function parseUnparsedDeclaration( */ builder.descriptorProperty = property; - if (unparsedRuntimeParsing.has(property)) { - const args = parseUnparsed(declaration.value.value, builder, property); + const value = parseDeclarationValue( + declaration.value.value, + builder, + property, + ); + // A value the compiler cannot parse leaves the declaration invalid, so an earlier rule's value stands + if (value === undefined) { + return; + } + + if (unparsedRuntimeParsing.has(property)) { if (property === "animation") { - builder.addDescriptor("animation", [{}, property, args]); + builder.addDescriptor("animation", [{}, property, value]); } else { - builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); + builder.addDescriptor(property, [{}, toRNProperty(property), value, 1]); } } else { - const value = parseUnparsed(declaration.value.value, builder, property); - builder.addDescriptor(property, value); if (property === "font-size") { @@ -1001,30 +1025,288 @@ export function parseUnparsedDeclaration( } if (property === "color") { - if ( - !isStyleFunction(value) || - value[1] !== "var" || - value[2] !== "-css-color" - ) { - builder.addDescriptor("--__rn-css-color", value); + publishInheritedColor(value, builder); + } + } +} + +function parseCSSWideKeyword( + { keyword, written }: SoleCSSWideKeyword, + builder: StylesheetBuilder, + property: string, +): StyleDescriptor { + const isCustomProperty = property.startsWith("--"); + // Only `color` and custom properties reach descendants here, so only they inherit where `unset` would (css-cascade-5 §7.3.3) + const inherits = isCustomProperty || property === "color"; + // React Native has no user-agent or user origin, so `revert` and `revert-layer` roll back to `unset` (css-cascade-5 §7.3.4, §7.3.5) + const resolved = + keyword === "inherit" || keyword === "initial" + ? keyword + : inherits + ? "inherit" + : "initial"; + + if (isCustomProperty) { + return resolved === "inherit" ? inheritedValue() : guaranteedInvalidValue(); + } else if (resolved === "inherit" && property === "color") { + return inheritedColorLookup(); + } else if (resolved === "initial") { + // The literal the runtime clears with, which leaves React Native's default to stand in for the initial value + return "unset"; + } + + builder.addWarning("value", written); + return; +} + +// The value a declaration's CSS-wide keyword gives it, or undefined when it holds none +function parseCSSWideKeywordValue( + tokens: TokenOrValue[] | null | undefined, + builder: StylesheetBuilder, + property: string, +): { readonly value: StyleDescriptor } | undefined { + const sole = soleCSSWideKeyword(tokens); + + if (sole !== undefined) { + return { value: parseCSSWideKeyword(sole, builder, property) }; + } + + // A CSS-wide keyword inside a property's longer value makes the declaration invalid (css-values-4 §2.1); a custom property's value is any tokens + const nested = property.startsWith("--") + ? undefined + : nestedCSSWideKeyword(tokens ?? []); + + if (nested !== undefined) { + builder.addWarning("value", nested); + return { value: undefined }; + } + + return; +} + +function parseDeclarationValue( + tokens: TokenOrValue[] | null | undefined, + builder: StylesheetBuilder, + property: string, + allowAuto?: boolean, +): StyleDescriptor { + const keywordValue = parseCSSWideKeywordValue(tokens, builder, property); + + if (keywordValue !== undefined) { + return keywordValue.value; + } + + const [token, ...rest] = tokens ?? []; + + if (token === undefined || rest.length > 0) { + return parseUnparsed(tokens, builder, property, allowAuto); + } + + switch (token.type) { + case "var": + return listSoleFunction( + parseVariable(token.value, builder, property, "whole-value", allowAuto), + property, + ); + case "env": + return listSoleFunction( + parseEnv(token.value, builder, property, "whole-value"), + property, + ); + default: + return parseUnparsed(tokens, builder, property, allowAuto); + } +} + +// A filter or transform value is a list of functions, so one function alone is a list of one +function listSoleFunction( + value: StyleDescriptor, + property: string, +): StyleDescriptor { + return (property === "filter" || property === "transform") && + isStyleFunction(value) + ? [value] + : value; +} + +// Substituted, a fallback replaces its var() or env(), so it is the declaration's whole value only when the function is (css-variables-1 §3) +type SubstitutionPosition = "whole-value" | "inside-value"; + +function parseSubstitutionFallback( + tokens: TokenOrValue[] | null | undefined, + builder: StylesheetBuilder, + property: string, + position: SubstitutionPosition, + allowAuto?: boolean, +): StyleDescriptor { + switch (position) { + case "whole-value": + return parseDeclarationValue(tokens, builder, property, allowAuto); + case "inside-value": { + // Inside a longer value a CSS-wide keyword is invalid (css-values-4 §2.1) + const keyword = nestedCSSWideKeyword(tokens ?? []); + + if (keyword !== undefined) { + builder.addWarning("value", keyword); + return; } + + return parseUnparsed(tokens, builder, property, allowAuto); + } + default: { + position satisfies never; + return; + } + } +} + +function parseVariable( + variable: Variable, + builder: StylesheetBuilder, + property: string, + position: SubstitutionPosition, + allowAuto?: boolean, +): StyleFunction { + const name = variable.name.ident.slice(2); + // An empty fallback substitutes nothing, which a longer value can hold (css-variables-1 §3) + const fallback = isEmptyTokenList(variable.fallback) + ? [] + : parseSubstitutionFallback( + variable.fallback, + builder, + property, + position, + allowAuto, + ); + + return [{}, "var", fallback === undefined ? name : [name, fallback], 1]; +} + +function isEmptyTokenList(tokens: TokenOrValue[] | null | undefined): boolean { + return ( + Array.isArray(tokens) && + tokens.every( + (token) => token.type === "token" && token.value.type === "white-space", + ) + ); +} + +// A var() or env() defers its value to computed-value time, so a keyword behind one is not looked for +function nestedCSSWideKeyword( + tokens: readonly TokenOrValue[], +): string | undefined { + for (const tokenOrValue of tokens) { + const keyword = cssWideKeywordIn(tokenOrValue); + + if (keyword !== undefined) { + return keyword; + } + } + + return; +} + +function cssWideKeywordIn(tokenOrValue: TokenOrValue): string | undefined { + switch (tokenOrValue.type) { + case "token": + return tokenOrValue.value.type === "ident" && + isCSSWideKeyword(tokenOrValue.value.value.toLowerCase()) + ? tokenOrValue.value.value + : undefined; + case "function": + return nestedCSSWideKeyword(tokenOrValue.value.arguments); + case "unresolved-color": + return cssWideKeywordInColor(tokenOrValue.value); + case "var": + case "env": + case "color": + case "url": + case "length": + case "angle": + case "time": + case "resolution": + case "dashed-ident": + case "animation-name": + return; + default: { + tokenOrValue satisfies never; + return; } } } +function cssWideKeywordInColor(color: UnresolvedColor): string | undefined { + switch (color.type) { + case "rgb": + case "hsl": + return nestedCSSWideKeyword(color.alpha); + case "light-dark": + return ( + nestedCSSWideKeyword(color.light) ?? nestedCSSWideKeyword(color.dark) + ); + default: { + color satisfies never; + return; + } + } +} + +function addCSSWideKeyword( + tokens: TokenOrValue[], + builder: StylesheetBuilder, + property: string, + nativeProperty = property, +): boolean { + const keywordValue = parseCSSWideKeywordValue(tokens, builder, property); + + if (keywordValue === undefined) { + return false; + } + + builder.addDescriptor(nativeProperty, keywordValue.value); + + return true; +} + +// A descendant resolves a published colour lazily, so one that reads the inherited colour publishes the bare lookup, which the runtime answers with what the element inherited +function publishInheritedColor( + value: StyleDescriptor, + builder: StylesheetBuilder, +) { + builder.addDescriptor( + `--${INHERITED_COLOR_VARIABLE}`, + readsInheritedColor(value) ? inheritedColorLookup() : value, + ); +} + +// A fallback, a function argument or a light-dark() arm can hold the read, so the whole tree is walked +function readsInheritedColor(value: StyleDescriptor): boolean { + if (!Array.isArray(value)) { + return false; + } + + if (isStyleFunction(value)) { + return isInheritedColorLookup(value) || readsInheritedColor(value[2]); + } + + return value.some((entry) => readsInheritedColor(entry)); +} + export function parseCustomDeclaration( declaration: Extract, builder: StylesheetBuilder, ) { const property = declaration.value.name; + builder.setWarningProperty(property); + if (isMappingProperty(property)) { // Consumed before declarations so property order cannot affect mapping. return; } else if (property === "-webkit-line-clamp") { builder.addDescriptor( property, - parseUnparsed(declaration.value.value, builder, property), + parseDeclarationValue(declaration.value.value, builder, property), ); } else if (property === "-rn-ripple-style") { if ( @@ -1048,11 +1330,15 @@ export function parseCustomDeclaration( // https://github.com/parcel-bundler/lightningcss/issues/1048 builder.addDescriptor( property, - parseUnparsed(declaration.value.value, builder, property), + parseDeclarationValue(declaration.value.value, builder, property), ); } else if (property === "corner-shape") { parseCornerShape(declaration.value, builder); } else if (property === "font-variant") { + if (addCSSWideKeyword(declaration.value.value, builder, property)) { + return; + } + const parsedFontVariant = parseFontVariantCaps( parseUnparsed( declaration.value.value, @@ -1064,6 +1350,12 @@ export function parseCustomDeclaration( if (parsedFontVariant !== undefined) { builder.addDescriptor(property, parsedFontVariant); } + } else if ( + property.startsWith("--") && + isEmptyTokenList(declaration.value.value) + ) { + // css-variables-1 §2: a custom property's value may be empty, and substitutes nothing + builder.addDescriptor(property, []); } else if ( validProperties.has(property) || property.startsWith("--") || @@ -1071,7 +1363,7 @@ export function parseCustomDeclaration( ) { builder.addDescriptor( property, - parseUnparsed(declaration.value.value, builder, property), + parseDeclarationValue(declaration.value.value, builder, property), ); } else { builder.addWarning("property", declaration.value.name); @@ -1160,13 +1452,7 @@ export function unparsedFunction( * This export function best efforts parsing it into a export function that we can evaluate at runtime */ export function parseUnparsed( - tokenOrValue: - | TokenOrValue - | TokenOrValue[] - | string - | number - | undefined - | null, + tokenOrValue: TokenOrValue | TokenOrValue[] | undefined | null, builder: StylesheetBuilder, property: string, allowAuto = allowAutoProperties.has(property), @@ -1175,22 +1461,6 @@ export function parseUnparsed( return; } - if (typeof tokenOrValue === "string") { - if (tokenOrValue === "true") { - return true; - } else if (tokenOrValue === "false") { - return false; - } else if (tokenOrValue === "currentcolor") { - return [{}, "var", "__rn-css-color"] as const; - } else { - return tokenOrValue; - } - } - - if (typeof tokenOrValue === "number") { - return round(tokenOrValue); - } - if (Array.isArray(tokenOrValue)) { const args = reduceParseUnparsed( tokenOrValue, @@ -1201,14 +1471,9 @@ export function parseUnparsed( if (!args) return; if (Array.isArray(args) && args.length === 1) { return args[0]; - } else if ( - (property === "filter" || property === "transform") && - isStyleFunction(args) - ) { - return [args]; - } else { - return args; } + + return listSoleFunction(args, property); } switch (tokenOrValue.type) { @@ -1220,20 +1485,14 @@ export function parseUnparsed( allowAuto, ); } - case "var": { - let args: StyleDescriptor = tokenOrValue.value.name.ident.slice(2); - const fallback = parseUnparsed( - tokenOrValue.value.fallback, + case "var": + return parseVariable( + tokenOrValue.value, builder, property, + "inside-value", allowAuto, ); - if (fallback !== undefined) { - args = [args, fallback]; - } - - return [{}, "var", args, 1]; - } case "function": { switch (tokenOrValue.value.name) { case "blur": @@ -1317,11 +1576,17 @@ export function parseUnparsed( return; } - if (value === "inherit" || value === "initial") { + // Keywords are ASCII case-insensitive (css-values-4 §4.1), and lightningcss hands an unparsed one through unfolded + const keyword = value.toLowerCase(); + + if (keyword === "currentcolor") { + return inheritedColorLookup(); + } + + // Inside a longer value a CSS-wide keyword is invalid (css-values-4 §2.1) + if (isCSSWideKeyword(keyword)) { builder.addWarning("value", value); return; - } else if (value === "currentcolor") { - return [{}, "var", "__rn-css-color"] as const; } if (value === "true") { @@ -1384,7 +1649,7 @@ export function parseUnparsed( case "color": return parseColor(tokenOrValue.value, builder); case "env": - return parseEnv(tokenOrValue.value, builder); + return parseEnv(tokenOrValue.value, builder, property, "inside-value"); case "time": return parseTime(tokenOrValue.value); case "url": @@ -1532,14 +1797,13 @@ export function parseAngle(angle: Angle | number, builder: StylesheetBuilder) { return `${angle}deg`; } - switch (angle.type) { - case "deg": - case "rad": - return `${angle.value}${angle.type}`; - default: - builder.addWarning("value", `${angle.value} ${angle.type}`); - return undefined; + const reactNativeAngle = readAngle(`${angle.value}${angle.type}`); + + if (reactNativeAngle === undefined) { + builder.addWarning("value", `${angle.value} ${angle.type}`); } + + return reactNativeAngle; } export function parseSizeDeclaration( @@ -1643,17 +1907,11 @@ export function parseFontColorDeclaration( declaration: Extract, builder: StylesheetBuilder, ) { - parseColorDeclaration(declaration, builder); + // Parsed once: light-dark() adds its dark rule as a side effect, so a second parse adds it twice + const value = parseColor(declaration.value, builder); - if ( - typeof declaration.value !== "object" || - declaration.value.type !== "currentcolor" - ) { - builder.addDescriptor( - "--__rn-css-color", - parseColor(declaration.value, builder), - ); - } + builder.addDescriptor(declaration.property, value); + publishInheritedColor(value, builder); } export function parseColorDeclaration( @@ -1692,7 +1950,7 @@ export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) { switch (cssColor.type) { case "currentcolor": - return [{}, "var", "__rn-css-color"] as const; + return inheritedColorLookup(); case "light-dark": { const extraRule: StyleRule = { s: [], @@ -2673,6 +2931,8 @@ const allowAutoProperties = new Set(["pointer-events"]); export function parseEnv( value: EnvironmentVariable, builder: StylesheetBuilder, + property: string, + position: SubstitutionPosition, ): StyleFunction | undefined { switch (value.name.type) { case "ua": @@ -2681,15 +2941,20 @@ export function parseEnv( case "safe-area-inset-right": case "safe-area-inset-bottom": case "safe-area-inset-left": { - const fallback = parseUnparsed( + const name = `react-native-css-${value.name.value}`; + const fallback = parseSubstitutionFallback( value.fallback, builder, - value.name.value, + property, + position, ); - return fallback - ? [{}, "var", [`react-native-css-${value.name.value}`, fallback], 1] - : [{}, "var", [`react-native-css-${value.name.value}`], 1]; + return [ + {}, + "var", + fallback === undefined ? [name] : [name, fallback], + 1, + ]; } case "viewport-segment-width": case "viewport-segment-height": @@ -2824,7 +3089,7 @@ export function parseCalcArguments( for (const arg of args) { switch (arg.type) { case "env": { - parsed.push(parseEnv(arg.value, builder)); + parsed.push(parseEnv(arg.value, builder, property, "inside-value")); break; } case "var": @@ -3277,7 +3542,7 @@ function parseObjectFit( }); builder.addDescriptor( "object-fit", - parseUnparsed(declaration.value, builder, "object-fit"), + parseDeclarationValue(declaration.value, builder, "object-fit"), ); } @@ -3288,6 +3553,11 @@ function parseObjectPosition( builder.addMapping({ "object-position": ["contentPosition"], }); + + if (addCSSWideKeyword(declaration.value, builder, "object-position")) { + return; + } + builder.addDescriptor("object-position", [ {}, "join", @@ -3299,6 +3569,12 @@ function parseCornerShape( declaration: CustomProperty, builder: StylesheetBuilder, ) { + if ( + addCSSWideKeyword(declaration.value, builder, "corner-shape", "borderCurve") + ) { + return; + } + const shape = parseUnparsed(declaration.value, builder, "corner-shape"); if (shape === "round") { @@ -3348,40 +3624,80 @@ function parseFilter( return "unset"; } - return declaration.value.value - .map((value) => { - switch (value.type) { - case "opacity": - case "blur": - case "brightness": - case "contrast": - case "grayscale": - case "invert": - case "saturate": - case "sepia": - return { - [value.type]: parseLength(value.value, builder), - } as unknown as StyleDescriptor; - case "hue-rotate": - return { - [toRNProperty(value.type)]: parseAngle(value.value, builder), - } as unknown as StyleDescriptor; - case "drop-shadow": - return [ - {}, - toRNProperty(value.type), - [ - parseLength(value.value.xOffset, builder), - parseLength(value.value.yOffset, builder), - parseLength(value.value.blur, builder), - parseColor(value.value.color, builder), - ], - ] as unknown as StyleDescriptor; - case "url": - return; - } - }) - .filter((value) => value !== undefined); + const filters: StyleDescriptor[] = []; + + for (const value of declaration.value.value) { + // React Native has no SVG filter reference, so a url() entry is left out and the rest still apply + if (value.type === "url") { + continue; + } + + const filter = parseFilterFunction(value, builder); + + // A filter function the property cannot take leaves the declaration ignored, as the runtime does + if (filter === undefined) { + return; + } + + filters.push(filter); + } + + return filters; +} + +function parseFilterFunction( + value: Exclude, + builder: StylesheetBuilder, +): StyleDescriptor | undefined { + switch (value.type) { + case "blur": + return parseFilterArgument( + value.type, + parseLength(value.value, builder), + readNonNegativeLength, + ); + case "opacity": + case "brightness": + case "contrast": + case "grayscale": + case "invert": + case "saturate": + case "sepia": + return parseFilterArgument( + value.type, + parseLength(value.value, builder), + readNonNegativeAmount, + ); + case "hue-rotate": + return parseFilterArgument( + toRNProperty(value.type), + parseAngle(value.value, builder), + readAngle, + ); + case "drop-shadow": + return [ + {}, + toRNProperty(value.type), + [ + parseLength(value.value.xOffset, builder), + parseLength(value.value.yOffset, builder), + parseLength(value.value.blur, builder), + parseColor(value.value.color, builder), + ], + ] as unknown as StyleDescriptor; + } +} + +// A unit resolved at runtime is read there, by the same reader +function parseFilterArgument( + name: string, + argument: StyleDescriptor | undefined, + read: (argument: unknown) => unknown, +): StyleDescriptor | undefined { + const value = isStyleFunction(argument) ? argument : read(argument); + return value === undefined + ? undefined + : ({ [name]: value } as unknown as StyleDescriptor); } const namedColors = new Set([ diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..62811155 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -6,6 +6,7 @@ import type { } from "lightningcss"; import type { UniqueVarInfo } from "./compiler.types"; +import { soleCSSWideKeyword } from "./css-wide-keywords"; export function inlineVariables( stylesheet: StyleSheet, @@ -118,13 +119,59 @@ function replaceDeclaration( return; } - declaration.value.value = declaration.value.value.flatMap((part) => { - return flattenPart(part, vars); - }); + const value = readsGuaranteedInvalid(declaration.value.value, vars) + ? undefined + : declaration.value.value.flatMap((part) => flattenPart(part, vars)); + + // css-variables-1 §3.1: a declaration a var() leaves without a value is invalid at computed-value time, as if its value were `unset`, and a custom property's is the guaranteed-invalid value + if ( + value === undefined || + (declaration.property === "unparsed" && substitutesNothing(value)) + ) { + declaration.value.value = [ + keywordToken(declaration.property === "custom" ? "initial" : "unset"), + ]; + return declaration; + } + + declaration.value.value = value; return declaration; } +function substitutesNothing(parts: readonly TokenOrValue[]): boolean { + return parts.every( + (part) => part.type === "token" && part.value.type === "white-space", + ); +} + +function readsGuaranteedInvalid( + parts: readonly TokenOrValue[], + vars: Map, +): boolean { + return parts.some((part) => { + if (part.type === "var") { + const varInfo = vars.get(part.value.name.ident); + + if (varInfo === undefined || varInfo.value !== undefined) { + return false; + } + + return part.value.fallback + ? readsGuaranteedInvalid(part.value.fallback, vars) + : true; + } else if (part.type === "function") { + return readsGuaranteedInvalid(part.value.arguments, vars); + } + + return false; + }); +} + +function keywordToken(keyword: "initial" | "unset"): TokenOrValue { + return { type: "token", value: { type: "ident", value: keyword } }; +} + function flattenPart( part: TokenOrValue, vars: Map, @@ -159,51 +206,79 @@ function flattenPart( function flattenVar( name: string, vars: Map, - seen = new Set(), + path = new Set(), ) { - if (seen.has(name)) { - vars.delete(name); + if (path.has(name)) { + // css-variables-1 §2.3: every variable in a cycle is the guaranteed-invalid value, which the runtime's cycle guard answers + leaveCycleToRuntime(name, path, vars); + return; } - seen.add(name); - - let varInfo = vars.get(name); + const varInfo = vars.get(name); if (!varInfo || varInfo.flat) { return; } - let varInfoValue = varInfo.value?.flatMap((part) => { - if (part.type === "var") { - const name = part.value.name.ident; + const parts = varInfo.value; - flattenVar(name, vars, seen); + // Each variable the value reads is flattened before the value substitutes it, whichever was declared first + path.add(name); + for (const readName of readVariableNames(parts)) { + flattenVar(readName, vars, path); + } + path.delete(name); - const nestedVarInfo = vars.get(part.value.name.ident); - if (nestedVarInfo?.value) { - return nestedVarInfo.value; - } - } - return flattenPart(part, vars); - }); + if (!vars.has(name)) { + return; + } - // If the variable is shorthand for "initial", substitute it for undefined - if ( - varInfoValue?.length === 2 && - varInfoValue[0]?.type === "token" && - varInfoValue[0].value.type === "ident" && - varInfoValue[0].value.value === "initial" && - varInfoValue[1]?.type === "token" && - varInfoValue[1].value.type === "white-space" - ) { - varInfoValue = undefined; + const keyword = soleCSSWideKeyword(parts)?.keyword; + + if (keyword !== undefined && keyword !== "initial") { + // The parent's value, which only the runtime knows + vars.delete(name); + return; } - varInfo = { - count: 1, - flat: true, - value: varInfoValue, - }; + // css-variables-1 §2.2, §3.1: `initial`, and a value that reads the guaranteed-invalid value, are the guaranteed-invalid value, which no var() substitutes + const value = + keyword === "initial" || readsGuaranteedInvalid(parts, vars) + ? undefined + : parts.flatMap((part) => flattenPart(part, vars)); + + vars.set(name, { count: 1, flat: true, value }); +} + +function leaveCycleToRuntime( + reentered: string, + path: ReadonlySet, + vars: Map, +) { + let inCycle = false; + + for (const name of path) { + inCycle ||= name === reentered; + + if (inCycle) { + vars.delete(name); + } + } +} + +// The names `flattenPart` substitutes: each var(), its fallback's, and those inside a function's arguments +function readVariableNames( + parts: readonly TokenOrValue[], + names = new Set(), +): ReadonlySet { + for (const part of parts) { + if (part.type === "var") { + names.add(part.value.name.ident); + readVariableNames(part.value.fallback ?? [], names); + } else if (part.type === "function") { + readVariableNames(part.value.arguments, names); + } + } - vars.set(name, varInfo); + return names; } diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index d379da8a..e754be44 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,4 +1,4 @@ -import { isStyleFunction } from "../utilities"; +import { INHERITED_COLOR_VARIABLE, isStyleFunction } from "../utilities"; import type { StyleDeclaration, StyleRule } from "./compiler.types"; export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { @@ -10,7 +10,7 @@ export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { return modifyStyleDeclaration(declaration, "color", "selectionColor"); }); - return rule; + return withoutInheritedColor(rule); } export function modifyRuleForPlaceholder( @@ -24,6 +24,21 @@ export function modifyRuleForPlaceholder( return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); }); + return withoutInheritedColor(rule); +} + +// A pseudo-element's colour is its own, so the element and its subtree keep theirs +function withoutInheritedColor(rule: StyleRule): StyleRule { + const variables = rule.v?.filter( + ([name]) => name !== INHERITED_COLOR_VARIABLE, + ); + + if (variables?.length) { + rule.v = variables; + } else { + delete rule.v; + } + return rule; } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..32ea4ae2 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -1,6 +1,14 @@ /* eslint-disable */ -import type { InlineVariable, StyleRule } from "react-native-css/compiler"; +import type { + InlineVariable, + StyleDescriptor, + StyleRule, +} from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native-internal"; +import { + assignPublishedColor, + assignSubtreeVariables, +} from "react-native-css/utilities"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; @@ -41,6 +49,7 @@ export function updateRules( let usesVariables = false; let variables: VariableContextValue | undefined; + let publishedColor: StyleDescriptor | undefined; let containers: ContainerContextValue | undefined; const inlineVariables = new Set(); @@ -131,13 +140,13 @@ export function updateRules( } if (rule.v) { - if (variables === inheritedVariables) { + if (variables === undefined || variables === inheritedVariables) { variables = { ...inheritedVariables }; } - for (const v of rule.v) { - variables![v[0]] = v[1]; - } + publishedColor = + assignSubtreeVariables(variables, rule.v, inheritedVariables) ?? + publishedColor; } if (rule.c) { @@ -206,6 +215,10 @@ export function updateRules( } } + if (variables !== undefined && variables !== inheritedVariables) { + assignPublishedColor(variables, publishedColor); + } + pressable = activeFamily.has(state.ruleEffectGetter); if (!rules.size && !state.stylesObs && !inlineVariables.size) { diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..2a133a7f 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -2,19 +2,32 @@ import type { InlineVariable, StyleDeclaration, + StyleDescriptor, + StyleFunction, StyleRule, } from "react-native-css/compiler"; -import { Specificity as S } from "react-native-css/utilities"; +import { + assignPublishedColor, + assignRuleVariables, + inheritedColorLookup, + isStyleFunction, + Specificity as S, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; -import { applyValue, getDeepPath } from "../objects"; +import { applyValue } from "../objects"; import { VAR_SYMBOL, type Getter, type VariableContextValue, } from "../reactivity"; import { transformKeys } from "./defaults"; -import { resolveValue } from "./resolve"; +import { + isEmptySubstitution, + resolveValue, + type ResolveValueOptions, + type Substitution, +} from "./resolve"; export function calculateProps( get: Getter, @@ -30,9 +43,10 @@ export function calculateProps( let normal: Record | undefined; let important: Record | undefined; - const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + let publishedColor: StyleDescriptor | undefined; + // css-variables-1 §3: a var() substitutes the element's computed custom property, so every rule's variables are assigned before any declaration resolves for (const rule of rules) { if (VAR_SYMBOL in rule) { if (typeof rule[VAR_SYMBOL] === "string") { @@ -40,47 +54,44 @@ export function calculateProps( } else { Object.assign(inheritedVariables, rule); } - continue; + } else if (rule.v) { + publishedColor = + assignRuleVariables(inlineVariables, rule.v) ?? publishedColor; } + } - if (rule.v) { - for (const variable of rule.v) { - inlineVariables[variable[0]] = variable[1]; - } + assignPublishedColor(inlineVariables, publishedColor); + + for (const rule of rules) { + if (VAR_SYMBOL in rule || !rule.d) { + continue; } - if (rule.d) { - let topLevelTarget = rule.s?.[S.Important] - ? (important ??= {}) - : (normal ??= {}); - let target = topLevelTarget; + let topLevelTarget = rule.s?.[S.Important] + ? (important ??= {}) + : (normal ??= {}); + let target = topLevelTarget; - const ruleTarget = rule.target || "style"; + const ruleTarget = rule.target || "style"; - if (typeof ruleTarget === "string") { - target = target[ruleTarget] ??= {}; - } else if (ruleTarget) { - for (const path of ruleTarget) { - target = target[path] ??= {}; - } + if (typeof ruleTarget === "string") { + target = target[ruleTarget] ??= {}; + } else if (ruleTarget) { + for (const path of ruleTarget) { + target = target[path] ??= {}; } - - applyDeclarations( - get, - rule.d, - inlineVariables, - inheritedVariables, - delayedStyles, - transformStyles, - guards, - target, - topLevelTarget, - ); } - } - for (const delayedStyle of delayedStyles) { - delayedStyle(); + applyDeclarations( + get, + rule.d, + inlineVariables, + inheritedVariables, + transformStyles, + guards, + target, + topLevelTarget, + ); } for (const transformStyle of transformStyles) { @@ -99,25 +110,26 @@ export function applyDeclarations( declarations: StyleDeclaration[], inlineVariables: InlineVariable, inheritedVariables: VariableContextValue, - delayedStyles: (() => void)[] = [], transformStyles: (() => void)[] = [], guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, ) { - const originalTarget = target; - for (const declaration of declarations) { - target = originalTarget; + // The transform closures below run after the walk, so each must capture its own declaration's target + let declarationTarget = target; if (!Array.isArray(declaration)) { // Static styles - Object.assign(target, declaration); + Object.assign(declarationTarget, declaration); } else { // Dynamic styles let value: any = declaration[0]; let propPath = declaration[1]; let prop: string | number; + // Only the element's own `color`: a shadow's colour is a nested `color` key that keeps the cascading read + const computingColor = + (Array.isArray(propPath) ? propPath.join(".") : propPath) === "color"; if (Array.isArray(propPath)) { const [first, ...rest] = propPath; @@ -131,7 +143,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,75 +155,121 @@ export function applyDeclarations( if (!Array.isArray(previousTarget[previousProp])) { previousTarget[previousProp] = []; - target = previousTarget[previousProp]; + declarationTarget = previousTarget[previousProp]; } } - previousTarget = target; + previousTarget = declarationTarget; previousProp = prop; - target[prop] ??= {}; - target = target[prop]; + declarationTarget[prop] ??= {}; + declarationTarget = declarationTarget[prop]; } prop = final; } else { - target = topLevelTarget; + declarationTarget = topLevelTarget; prop = first; } } else { prop = propPath; } - const shouldDelay = declaration[2]; - - if (shouldDelay || transformKeys.has(prop)) { - /** - * We need to delay the resolution of this value until after all - * styles have been calculated. But another style might override - * this value. So we set a placeholder value and only override - * if the placeholder is preserved - * - * This also ensures the props exist, so setValue will properly - * mutate the props object and not create a new one - */ + // An individual transform property composes into the `transform` list once every rule is walked, so it holds its place until then + if (transformKeys.has(prop)) { 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(() => { - value = resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - 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); - } + transformStyles.push(() => { + value = resolveDeclaration(originalValue, prop, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + computingColor, }); - } + applyValue(declarationTarget, prop, value); + }); } else { - value = resolveValue(value, get, { + value = resolveDeclaration(value, prop, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, + computingColor, }); } - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } } } + +// css-variables-1 §3.1: a declaration a var() leaves without a value is invalid at computed-value time, and behaves as if its value were `unset` +function resolveDeclaration( + descriptor: StyleDescriptor, + prop: string | number, + get: Getter, + options: ResolveValueOptions, +): unknown { + const substitution: Substitution = { failed: false, emptied: false }; + const value = resolveValue(descriptor, get, { ...options, substitution }); + + if (!isInvalidAtComputedValueTime(descriptor, value, substitution)) { + return value === undefined && isUnsetDescriptor(descriptor) ? null : value; + } + + // css-cascade-5 §7.3.3: `unset` resets the property even where its parser has no reset of its own + return ( + resolveValue( + unsetDescriptor(descriptor, prop, options.computingColor), + get, + options, + ) ?? null + ); +} + +// The compiler writes a CSS-wide keyword on a property the runtime parses as that parser's `unset` +function isUnsetDescriptor(descriptor: StyleDescriptor): boolean { + return isStyleFunction(descriptor) && descriptor[2] === "unset"; +} + +// The `unset` the compiler writes for the same property: the inherited colour for `color`, the runtime parser's own reset for a property it parses, and the clearing literal otherwise +function unsetDescriptor( + descriptor: StyleDescriptor, + prop: string | number, + computingColor: boolean | undefined, +): StyleDescriptor { + if (computingColor) { + return inheritedColorLookup(); + } else if (isStyleFunction(descriptor) && descriptor[1] === prop) { + const [marker, name, , ...processing] = descriptor; + return [marker, name, "unset", ...processing] as StyleFunction; + } + + return "unset"; +} + +// A var() with no value and no fallback anywhere in the value, or a value its var()s leave empty +function isInvalidAtComputedValueTime( + descriptor: StyleDescriptor, + value: unknown, + substitution: Readonly, +): boolean { + if (substitution.failed || substitution.emptied) { + return true; + } else if (!readsVariable(descriptor)) { + return false; + } + + return value === undefined || isEmptySubstitution(descriptor, value); +} + +function readsVariable(descriptor: StyleDescriptor): boolean { + if (!Array.isArray(descriptor)) { + return false; + } else if (isStyleFunction(descriptor)) { + return descriptor[1] === "var" || readsVariable(descriptor[2]); + } + + return descriptor.some(readsVariable); +} diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 4a88a98a..b3634118 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,70 +1,44 @@ -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + readAngle, + readNonNegativeAmount, + readNonNegativeLength, +} from "react-native-css/utilities"; +import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; -export const blur: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - blur: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const brightness: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - brightness: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const contrast: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - contrast: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const grayscale: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - grayscale: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; +type FilterArgumentReader = (argument: unknown) => unknown; -export const hueRotate: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - hueRotate: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; +// Filter Effects 1: a filter function takes one argument, so one given several is invalid +function readFilterArgument(args: unknown): unknown { + if (!Array.isArray(args)) { + return args; + } -export const invert: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - invert: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; + return args.length === 1 ? args[0] : undefined; +} -export const sepia: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - sepia: (Array.isArray(args) ? args[0] : args) as unknown, +function filterFunction( + name: string, + readArgument: FilterArgumentReader, +): StyleFunctionResolver { + return (resolveValue, value) => { + const argument = readArgument(readFilterArgument(resolveValue(value[2]))); + return argument === undefined ? undefined : { [name]: argument }; }; -}; +} -export const saturate: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - saturate: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; - -export const opacity: StyleFunctionResolver = (resolveValue, value) => { - const args = resolveValue(value[2]); - return { - opacity: (Array.isArray(args) ? args[0] : args) as unknown, - }; -}; +export const blur = filterFunction("blur", readNonNegativeLength); +export const brightness = filterFunction("brightness", readNonNegativeAmount); +export const contrast = filterFunction("contrast", readNonNegativeAmount); +export const grayscale = filterFunction("grayscale", readNonNegativeAmount); +export const hueRotate = filterFunction("hueRotate", readAngle); +export const invert = filterFunction("invert", readNonNegativeAmount); +export const sepia = filterFunction("sepia", readNonNegativeAmount); +export const saturate = filterFunction("saturate", readNonNegativeAmount); +export const opacity = filterFunction("opacity", readNonNegativeAmount); const color = ["color", "color"] as const; const offsetX = ["offsetX", "number"] as const; @@ -90,12 +64,12 @@ export const dropShadow: StyleFunctionResolver = ( options, ) => { const args = resolveValue(func[2]); + if (!isStyleDescriptorArray(args)) { + return undefined; + } - return isStyleDescriptorArray(args) - ? { - dropShadow: handler(resolveValue, args, get, options), - } - : undefined; + const shadow: unknown = handler(resolveValue, args, get, options); + return shadow === undefined ? undefined : { dropShadow: shadow }; }; /** CSS variables may expand to multiple filters; native filter arrays must be flat. */ @@ -103,11 +77,10 @@ export const filter: StyleFunctionResolver = (resolveValue, descriptor) => { const value: unknown = resolveValue(descriptor[2]); if (Array.isArray(value)) { const filters: unknown[] = value.flat(Infinity); - return filters.filter( - (entry) => entry !== undefined && entry !== "initial", - ); + // A keyword among other filters leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset` + return filters.some(resetsToNone) ? undefined : filters; } - if (value === undefined || value === "initial") return; - if (value === "none") return null; + if (value === undefined) return; + if (resetsToNone(value)) return null; return typeof value === "string" ? value : [value]; }; diff --git a/src/native/styles/resets-to-none.ts b/src/native/styles/resets-to-none.ts new file mode 100644 index 00000000..6f79991b --- /dev/null +++ b/src/native/styles/resets-to-none.ts @@ -0,0 +1,4 @@ +// A keyword a var() leaves in a list-valued property is `none` alone or invalid at computed-value time, and either way the property is `none` (css-variables-1 §3.1) +export function resetsToNone(value: unknown): boolean { + return value === null || value === "none" || value === "initial"; +} diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..fc5db5e9 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -5,7 +5,11 @@ import type { StyleDescriptor, StyleFunction, } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isInheritedColorLookup, + isStyleDescriptorArray, + isStyleFunction, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; @@ -54,10 +58,22 @@ export type ResolveValueOptions = { renderGuards?: RenderGuard[]; variableHistory?: Set; variableCycles?: Set; + /** Resolving the element's own `color`, where css-color-3 §4.4 makes `currentcolor` the inherited colour */ + computingColor?: boolean; + /** Where a var() that substitutes nothing is recorded, since a list drops the entry it leaves */ + substitution?: Substitution; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; }; +/** What the var()s in the value being resolved left it (css-variables-1 §3.1) */ +export interface Substitution { + /** A var() had no value and no fallback, which leaves any value invalid at computed-value time */ + failed: boolean; + /** Every entry of a list substituted nothing, which leaves a property's value invalid and a custom property's empty */ + emptied: boolean; +} + export function resolveValue( value: StyleDescriptor, get: Getter, @@ -91,15 +107,31 @@ export function resolveValue( } if (isStyleDescriptorArray(value)) { - value = value - .map((d) => resolveValue(d, get, options)) - .filter((d) => d !== undefined); + let substitutedNothing = value.length > 0; + const entries: unknown[] = []; - if (castToArray && !Array.isArray(value)) { - return [value]; - } else { - return value; + for (const entry of value) { + const resolved: unknown = resolveValue(entry, get, options); + + if (isEmptySubstitution(entry, resolved)) { + continue; + } + + substitutedNothing = false; + + if (resolved !== undefined) { + entries.push(resolved); + } else if (entry !== undefined) { + // A component the property cannot take leaves the whole value invalid, never a shorter one + return; + } + } + + if (substitutedNothing && options.substitution) { + options.substitution.emptied = true; } + + return entries; } const name = value[1]; @@ -109,7 +141,7 @@ export function resolveValue( }; if (name === "var") { - return varResolver(simpleResolve, value, get, options); + return resolveVariable(value, get, options); } else if (name in functionResolvers) { const fn = functionResolvers[name as keyof typeof functionResolvers]; @@ -166,3 +198,44 @@ export function resolveValue( } } } + +// css-variables-1 §3: a var() whose value or fallback is empty substitutes nothing +export function isEmptySubstitution( + descriptor: StyleDescriptor, + value: unknown, +): boolean { + return ( + isStyleFunction(descriptor) && + descriptor[1] === "var" && + Array.isArray(value) && + value.length === 0 + ); +} + +function resolveVariable( + fn: StyleFunction, + get: Getter, + options: ResolveValueOptions, +): unknown { + if (options.computingColor && isInheritedColorLookup(fn)) { + const inheritedScope: ResolveValueOptions = { + ...options, + inlineVariables: undefined, + computingColor: false, + }; + + return varResolver( + (inner) => resolveValue(inner, get, inheritedScope), + fn, + get, + inheritedScope, + ); + } + + return varResolver( + (inner) => resolveValue(inner, get, options), + fn, + get, + options, + ); +} diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index b8147d1a..47ae8495 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -38,6 +38,10 @@ export function shorthandHandler( ? resolve(value[2]) : value; + if (args === null && returnType === "shorthandObject") { + return clearedLonghands([...mappings.flat(), ...defaults]); + } + if (!Array.isArray(args)) { return; } @@ -133,3 +137,19 @@ export function shorthandHandler( } }; } + +// The runtime resolves `unset` to null, and a shorthand reset that way resets every longhand it sets (css-cascade-5 §7.3.3) +export function clearedLonghands( + longhandEntries: readonly ShorthandRequiredValue[], +) { + const target: Record = { [ShortHandSymbol]: true }; + const longhands = longhandEntries.flatMap(([prop]) => + typeof prop === "string" ? [prop] : prop.slice(0, 1), + ); + + for (const longhand of longhands) { + target[longhand] = undefined; + } + + return target; +} diff --git a/src/native/styles/shorthands/animation.ts b/src/native/styles/shorthands/animation.ts index f28aacd9..76fccf59 100644 --- a/src/native/styles/shorthands/animation.ts +++ b/src/native/styles/shorthands/animation.ts @@ -3,7 +3,7 @@ import { StyleCollection } from "react-native-css/native-internal"; import { applyShorthand } from "../../objects"; import type { StyleFunctionResolver } from "../resolve"; -import { shorthandHandler } from "./_handler"; +import { clearedLonghands, shorthandHandler } from "./_handler"; const name = ["animationName", "string", "none"] as const; const delay = ["animationDelay", "number", 0] as const; @@ -33,6 +33,16 @@ const timingFunction = [ ["linear", "ease", "ease-in", "ease-out", "ease-in-out", "object"], "ease", ] as const; +const longhands = [ + name, + delay, + direction, + duration, + fill, + iteration, + playState, + timingFunction, +]; export const animationShorthand = shorthandHandler( [ @@ -48,16 +58,7 @@ export const animationShorthand = shorthandHandler( [duration, delay, iteration, timingFunction, name], [name, duration, timingFunction, delay, iteration, fill], ], - [ - name, - delay, - direction, - duration, - fill, - iteration, - playState, - timingFunction, - ], + longhands, "tuples", ); @@ -75,7 +76,10 @@ export const animation: StyleFunctionResolver = ( ); if (!Array.isArray(animationShortHandTuples)) { - return; + // css-cascade-5 §7.3.3: a shorthand reset to `unset` resets every longhand it sets + return resolveValue(value[2]) === null + ? clearedLonghands(longhands) + : undefined; } const nameTuple = animationShortHandTuples.find( diff --git a/src/native/styles/shorthands/logical-border-width.ts b/src/native/styles/shorthands/logical-border-width.ts index 147a3517..3913c12f 100644 --- a/src/native/styles/shorthands/logical-border-width.ts +++ b/src/native/styles/shorthands/logical-border-width.ts @@ -4,6 +4,11 @@ import type { StyleFunctionResolver } from "../resolve"; function logicalWidth(start: string, end: string): StyleFunctionResolver { return (resolve, descriptor) => { const resolved = resolve(descriptor[2]); + + if (resolved === null) { + return { [ShortHandSymbol]: true, [start]: undefined, [end]: undefined }; + } + const values: unknown[] = Array.isArray(resolved) ? resolved : [resolved]; if ( values.length < 1 || diff --git a/src/native/styles/shorthands/transform.ts b/src/native/styles/shorthands/transform.ts index ef603dd8..ac276f77 100644 --- a/src/native/styles/shorthands/transform.ts +++ b/src/native/styles/shorthands/transform.ts @@ -1,3 +1,4 @@ +import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; /** @@ -11,9 +12,11 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - return transforms.filter( - (transform) => transform !== undefined && transform !== "initial", - ) as unknown; + // A var() holding several transforms substitutes each of them into the list + const entries: unknown[] = transforms.flat(); + + // A keyword among other transforms leaves the list invalid; one a var() substitutes makes it invalid at computed-value time, which resolves to `unset` + return entries.some(resetsToNone) ? undefined : entries; } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; diff --git a/src/native/styles/variables.ts b/src/native/styles/variables.ts index 0b785645..5e730ad3 100644 --- a/src/native/styles/variables.ts +++ b/src/native/styles/variables.ts @@ -3,10 +3,36 @@ import { rootVariables, universalVariables, } from "react-native-css/native-internal"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isGuaranteedInvalidValue, + isInheritedValue, + isStyleDescriptorArray, +} from "react-native-css/utilities"; import { VAR_SYMBOL, type Getter } from "../reactivity"; -import type { ResolveValueOptions, SimpleResolveValue } from "./resolve"; +import type { + ResolveValueOptions, + SimpleResolveValue, + Substitution, +} from "./resolve"; + +// A variable rncss publishes itself carries a property's computed value, which always exists, so one nothing publishes leaves the property its default +const LIBRARY_VARIABLE_PREFIX = "__rn-css-"; + +function isLibraryVariable(name: string): boolean { + return name.startsWith(LIBRARY_VARIABLE_PREFIX); +} + +// What one source declares for the variable: nothing, its parent's value, the guaranteed-invalid value, or a value +type Declaration = + | { readonly kind: "absent" } + | { readonly kind: "inherit" } + | { readonly kind: "guaranteed-invalid" } + | { readonly kind: "value"; readonly value: unknown }; + +const ABSENT: Declaration = { kind: "absent" }; +const INHERIT: Declaration = { kind: "inherit" }; +const GUARANTEED_INVALID: Declaration = { kind: "guaranteed-invalid" }; export function varResolver( resolve: SimpleResolveValue, @@ -41,7 +67,20 @@ export function varResolver( return; } - if (variableCycles.has(name)) return resolve(fallback); + // css-variables-1 §3.1: a var() with no fallback for a variable that has no value leaves its declaration invalid at computed-value time + const substituteFallback = (): unknown => { + if ( + fallback === undefined && + options.substitution && + !isLibraryVariable(name) + ) { + options.substitution.failed = true; + } + + return resolve(fallback); + }; + + if (variableCycles.has(name)) return substituteFallback(); // Share the active resolution path with recursive calls. Mark only the // actual cycle, so a dependent variable can still use its own fallback. @@ -56,43 +95,117 @@ export function varResolver( variableHistory.add(name); - try { - let value = resolve(inlineVariables?.[name] as StyleDescriptor); - if (variableCycles.has(name)) return resolve(fallback); + const memoise = (value: unknown) => { if (value !== undefined) { options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; options.inlineVariables[name] = value; + } - return value; + return value; + }; + + // css-variables-1 §3.1: a value a var() leaves without one, like a member of a cycle, is the guaranteed-invalid value + const readDeclaration = ( + descriptor: StyleDescriptor | undefined, + ): Declaration => { + if (descriptor === undefined) { + return ABSENT; + } else if (isGuaranteedInvalidValue(descriptor)) { + return GUARANTEED_INVALID; + } else if (isInheritedValue(descriptor)) { + return INHERIT; } - // A universal selector declares the variable on this element. It has lower - // specificity than the element's class declarations, but beats inheritance. - value = resolve(get(universalVariables(name))); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - return value; + const outer = options.substitution; + const substitution: Substitution = { failed: false, emptied: false }; + options.substitution = substitution; + + try { + const value = resolve(descriptor); + + return substitution.failed || variableCycles.has(name) + ? GUARANTEED_INVALID + : { kind: "value", value }; + } finally { + options.substitution = outer; } + }; - // Guards retain the ancestor's raw descriptor, matching the context value. - if (name in variables) { - renderGuards?.push(["v", name, variables[name]]); - value = resolve(variables[name]); - return variableCycles.has(name) ? resolve(fallback) : value; + // The parent's value: what it hands down, or else what `*` declares on it, then what :root does + const readParentValue = (readsUniversal: boolean): unknown => { + const handedDown = readOwnEntry(variables, name); + + // Guards retain the ancestor's raw descriptor, matching the context value + if (handedDown !== undefined) { + renderGuards?.push(["v", name, handedDown]); + const inherited = readDeclaration(handedDown); + + if (inherited.kind === "value") { + return inherited.value; + } else if (inherited.kind === "guaranteed-invalid") { + return substituteFallback(); + } } - value = resolve(get(rootVariables(name))); - if (variableCycles.has(name)) return resolve(fallback); - if (value !== undefined) { - options.inlineVariables ??= { [VAR_SYMBOL]: "inline" }; - options.inlineVariables[name] = value; - return value; + if (readsUniversal) { + const universal = readDeclaration(get(universalVariables(name))); + + if (universal.kind === "value") { + return memoise(universal.value); + } else if (universal.kind === "guaranteed-invalid") { + return substituteFallback(); + } } - return resolve(fallback); + // At the root a keyword resolves to nothing, so the fallback applies + const root = readDeclaration(get(rootVariables(name))); + + return root.kind === "value" ? memoise(root.value) : substituteFallback(); + }; + + try { + const own = readDeclaration(readOwnEntry(inlineVariables, name)); + + switch (own.kind) { + case "value": + return memoise(own.value); + case "guaranteed-invalid": + return substituteFallback(); + case "inherit": + // A custom property set to `inherit` takes its parent's value, over every declaration on the element + return readParentValue(true); + case "absent": + break; + default: + own satisfies never; + } + + // A universal selector declares the variable on every element: below a class, above inheritance + const universal = readDeclaration(get(universalVariables(name))); + + switch (universal.kind) { + case "value": + return memoise(universal.value); + case "guaranteed-invalid": + return substituteFallback(); + case "inherit": + case "absent": + return readParentValue(false); + default: + universal satisfies never; + return; + } } finally { variableHistory.delete(name); } } + +// A variable's name is its own, never a property every object inherits, and an entry with no value is no declaration +function readOwnEntry( + record: Readonly> | undefined, + name: string, +): StyleDescriptor | undefined { + return record !== undefined && Object.hasOwn(record, name) + ? (record[name] as StyleDescriptor | undefined) + : undefined; +} diff --git a/src/utilities/angle.ts b/src/utilities/angle.ts new file mode 100644 index 00000000..24a53ae8 --- /dev/null +++ b/src/utilities/angle.ts @@ -0,0 +1,24 @@ +const ANGLE = /^([+-]?(?:\d*\.)?\d+(?:e[+-]?\d+)?)(deg|grad|rad|turn)$/i; +const DEGREES_PER_ANGLE_UNIT: Readonly> = { + deg: 1, + grad: 0.9, + turn: 360, +}; + +// React Native takes an angle in degrees or radians alone, so a gradian or a turn arrives in degrees +export function readAngle(argument: unknown): number | string | undefined { + if (argument === 0) { + return 0; + } + + const match = typeof argument === "string" ? ANGLE.exec(argument) : null; + if (!match) { + return undefined; + } + + const [, amount = "", unit = ""] = match; + const degreesPerUnit = DEGREES_PER_ANGLE_UNIT[unit.toLowerCase()]; + return degreesPerUnit === undefined + ? `${amount}rad` + : `${Number(amount) * degreesPerUnit}deg`; +} diff --git a/src/utilities/custom-property-keywords.ts b/src/utilities/custom-property-keywords.ts new file mode 100644 index 00000000..da6cdb0f --- /dev/null +++ b/src/utilities/custom-property-keywords.ts @@ -0,0 +1,31 @@ +import type { StyleFunction } from "react-native-css/compiler"; + +import { isStyleFunction } from "./style-descriptor"; + +// css-variables-1 §2: a custom property set to a CSS-wide keyword takes the keyword's value itself, which `var()` then substitutes +const INHERITED_VALUE = "inherit"; +const GUARANTEED_INVALID_VALUE = "initial"; + +/** The value of a custom property set to `inherit`, `unset`, `revert` or `revert-layer`: its parent's */ +export function inheritedValue(): StyleFunction { + return [{}, INHERITED_VALUE]; +} + +/** The value of a custom property set to `initial`: the guaranteed-invalid value, which no `var()` substitutes */ +export function guaranteedInvalidValue(): StyleFunction { + return [{}, GUARANTEED_INVALID_VALUE]; +} + +export function isInheritedValue(value: unknown): boolean { + return ( + isStyleFunction(value) && value.length === 2 && value[1] === INHERITED_VALUE + ); +} + +export function isGuaranteedInvalidValue(value: unknown): boolean { + return ( + isStyleFunction(value) && + value.length === 2 && + value[1] === GUARANTEED_INVALID_VALUE + ); +} diff --git a/src/utilities/filter-arguments.ts b/src/utilities/filter-arguments.ts new file mode 100644 index 00000000..f9aee3f6 --- /dev/null +++ b/src/utilities/filter-arguments.ts @@ -0,0 +1,18 @@ +const NON_NEGATIVE_PERCENTAGE = /^\+?(?:\d*\.)?\d+(?:e[+-]?\d+)?%$/i; + +// Filter Effects 1: blur takes a non-negative length, hue-rotate an angle or unitless zero, and every other function a non-negative number or percentage +export function readNonNegativeLength(argument: unknown): number | undefined { + return typeof argument === "number" && argument >= 0 ? argument : undefined; +} + +export function readNonNegativeAmount( + argument: unknown, +): number | string | undefined { + if (typeof argument === "number") { + return argument >= 0 ? argument : undefined; + } + + return typeof argument === "string" && NON_NEGATIVE_PERCENTAGE.test(argument) + ? argument + : undefined; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..cbabf574 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,7 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./inherited-color"; +export * from "./custom-property-keywords"; +export * from "./angle"; +export * from "./filter-arguments"; diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts new file mode 100644 index 00000000..6aac8efe --- /dev/null +++ b/src/utilities/inherited-color.ts @@ -0,0 +1,84 @@ +import type { + StyleDescriptor, + StyleFunction, + StyleRule, +} from "react-native-css/compiler"; + +import { isInheritedValue } from "./custom-property-keywords"; +import { isStyleFunction } from "./style-descriptor"; + +/** The custom property every `color` rule publishes to its subtree and `currentcolor` reads back */ +export const INHERITED_COLOR_VARIABLE = "__rn-css-color"; + +// A fresh tuple per call, because a descriptor is owned by the rule it lands in +export function inheritedColorLookup() { + return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction; +} + +export function isInheritedColorLookup(fn: StyleFunction): boolean { + if (fn[1] !== "var") { + return false; + } + + const args = fn[2]; + const name = Array.isArray(args) ? args[0] : args; + + return name === INHERITED_COLOR_VARIABLE; +} + +/** Assigns a rule's custom properties to the element's own, and returns the inherited colour it publishes for `assignPublishedColor` */ +export function assignRuleVariables( + variables: Record, + entries: NonNullable, +): StyleDescriptor | undefined { + return assignEntries(entries, (name, value) => { + variables[name] = value; + }); +} + +/** Assigns a rule's custom properties to what its subtree inherits, where one set to `inherit` hands down the parent's value */ +export function assignSubtreeVariables( + variables: Record, + entries: NonNullable, + parentVariables: Readonly>, +): StyleDescriptor | undefined { + return assignEntries(entries, (name, value) => { + if (!isInheritedValue(value)) { + variables[name] = value; + } else { + variables[name] = Object.hasOwn(parentVariables, name) + ? parentVariables[name] + : undefined; + } + }); +} + +function assignEntries( + entries: NonNullable, + assign: (name: string, value: StyleDescriptor) => void, +): StyleDescriptor | undefined { + let color: StyleDescriptor | undefined; + + for (const [name, value] of entries) { + if (name === INHERITED_COLOR_VARIABLE) { + color = value; + } else { + assign(name, value); + } + } + + return color; +} + +// The last rule's colour is the element's, and one that reads the inherited colour leaves the inherited one in place, as CSS inherits a computed value +export function assignPublishedColor( + variables: Record, + color: StyleDescriptor | undefined, +): void { + if ( + color !== undefined && + !(isStyleFunction(color) && isInheritedColorLookup(color)) + ) { + variables[INHERITED_COLOR_VARIABLE] = color; + } +}