diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..b3a5e520 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,609 @@ 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}; }`; + + test.each(["inherit", "unset", "revert", "revert-layer", "INHERIT"])( + "--brand: %s publishes nothing, so a read finds the ancestor's value", + (keyword) => { + const css = uninlinedCustomProperty(keyword); + + expect(publishedVariable(css, "brand")).toEqual([]); + expect(compile(css).warnings()).toStrictEqual({}); + }, + ); + + test("--brand: initial is dropped with a warning", () => { + const css = uninlinedCustomProperty("initial"); + + expect(publishedVariable(css, "brand")).toEqual([]); + expect(compile(css).warnings()).toStrictEqual({ + values: { "--brand": ["initial", "initial"] }, + }); + }); + + 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([]); + }); + + 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 declared once is inlined, so its keyword resolves on color", () => { + expect( + compile(`.child { --brand: inherit; color: var(--brand); }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "__rn-css-color"]]], + }, + ], + ], + ], + }); + }); + + 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); + }); +}); 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/filter-list-runtime.test.tsx b/src/__tests__/native/filter-list-runtime.test.tsx index 0e7a2840..7b9e99c3 100644 --- a/src/__tests__/native/filter-list-runtime.test.tsx +++ b/src/__tests__/native/filter-list-runtime.test.tsx @@ -58,6 +58,28 @@ 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(); + }, + ); }, ); diff --git a/src/__tests__/native/keywords.test.tsx b/src/__tests__/native/keywords.test.tsx index f1b3defc..cc57e1af 100644 --- a/src/__tests__/native/keywords.test.tsx +++ b/src/__tests__/native/keywords.test.tsx @@ -1,17 +1,169 @@ -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)", {}], + ["line-height", "30px", {}], + [ + "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(); - const { result } = renderHook(() => { - return useNativeCss(View, { className: "my-class" }); - }); + return screen.getByTestId("child").props.style; +} - expect(result.current.props).toStrictEqual({ - style: { backgroundColor: undefined }, - }); +// 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( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "green", + }); + }, +); + +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..fed88597 100644 --- a/src/__tests__/native/transform.test.tsx +++ b/src/__tests__/native/transform.test.tsx @@ -154,3 +154,67 @@ 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({}); +}); 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/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..f6fff6ff 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -34,9 +34,14 @@ import type { TokenOrValue, Translate, UnresolvedColor, + Variable, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { + INHERITED_COLOR_VARIABLE, + isInheritedColorLookup, + isStyleFunction, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -983,17 +988,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,15 +1013,305 @@ 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); + } + } +} + +// A fresh tuple per call, because a descriptor is owned by the rule it lands in +function inheritedColorLookup() { + return [{}, "var", INHERITED_COLOR_VARIABLE] as const satisfies StyleFunction; +} + +const CSS_WIDE_KEYWORDS = [ + "inherit", + "initial", + "unset", + "revert", + "revert-layer", +] as const; + +type CSSWideKeyword = (typeof CSS_WIDE_KEYWORDS)[number]; + +function isCSSWideKeyword(keyword: string): keyword is CSSWideKeyword { + return (CSS_WIDE_KEYWORDS as readonly string[]).includes(keyword); +} + +interface SoleCSSWideKeyword { + keyword: CSSWideKeyword; + written: string; +} + +// A CSS-wide keyword is a value only on its own (css-values-4 §2.1) +function soleCSSWideKeyword( + tokens: TokenOrValue[] | null | undefined, +): SoleCSSWideKeyword | undefined { + const [token, ...rest] = tokens ?? []; + + 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; +} + +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 (resolved === "inherit" && property === "color") { + return inheritedColorLookup(); + } else if (resolved === "inherit" && isCustomProperty) { + // An undeclared custom property already reads its ancestor's value + return; + } else if (resolved === "initial" && !isCustomProperty) { + // 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); + const fallback = parseSubstitutionFallback( + variable.fallback, + builder, + property, + position, + allowAuto, + ); + + return [{}, "var", fallback === undefined ? name : [name, fallback], 1]; +} + +// 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( @@ -1018,13 +1320,15 @@ export function parseCustomDeclaration( ) { 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 +1352,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, @@ -1071,7 +1379,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 +1468,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 +1477,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 +1487,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 +1501,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 +1592,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 +1665,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": @@ -1643,17 +1924,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 +1967,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 +2948,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 +2958,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 +3106,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 +3559,7 @@ function parseObjectFit( }); builder.addDescriptor( "object-fit", - parseUnparsed(declaration.value, builder, "object-fit"), + parseDeclarationValue(declaration.value, builder, "object-fit"), ); } @@ -3288,6 +3570,11 @@ function parseObjectPosition( builder.addMapping({ "object-position": ["contentPosition"], }); + + if (addCSSWideKeyword(declaration.value, builder, "object-position")) { + return; + } + builder.addDescriptor("object-position", [ {}, "join", @@ -3299,6 +3586,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") { 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..c621f0c5 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, + assignRuleVariables, +} 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,12 @@ 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 = + assignRuleVariables(variables, rule.v) ?? publishedColor; } if (rule.c) { @@ -206,6 +214,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..c082a08e 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -2,9 +2,14 @@ import type { InlineVariable, StyleDeclaration, + StyleDescriptor, StyleRule, } from "react-native-css/compiler"; -import { Specificity as S } from "react-native-css/utilities"; +import { + assignPublishedColor, + assignRuleVariables, + Specificity as S, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { applyValue, getDeepPath } from "../objects"; @@ -32,6 +37,7 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + let publishedColor: StyleDescriptor | undefined; for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -44,9 +50,8 @@ export function calculateProps( } if (rule.v) { - for (const variable of rule.v) { - inlineVariables[variable[0]] = variable[1]; - } + publishedColor = + assignRuleVariables(inlineVariables, rule.v) ?? publishedColor; } if (rule.d) { @@ -79,6 +84,8 @@ export function calculateProps( } } + assignPublishedColor(inlineVariables, publishedColor); + for (const delayedStyle of delayedStyles) { delayedStyle(); } @@ -105,19 +112,21 @@ export function applyDeclarations( target: Record = {}, topLevelTarget = target, ) { - const originalTarget = target; - for (const declaration of declarations) { - target = originalTarget; + // The delayed and 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 +140,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,19 +152,19 @@ 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 { @@ -185,20 +194,22 @@ export function applyDeclarations( inheritedVariables, renderGuards: guards, calculateProps, + computingColor, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); }); } else { delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; + if (getDeepPath(declarationTarget, prop) === value) { + delete declarationTarget[prop]; value = resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, + computingColor, }); - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } }); } @@ -208,10 +219,11 @@ export function applyDeclarations( inheritedVariables, renderGuards: guards, calculateProps, + computingColor, }); } - applyValue(target, prop, value); + applyValue(declarationTarget, prop, value); } } } diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts index 4a88a98a..92e6bdd0 100644 --- a/src/native/styles/functions/filters.ts +++ b/src/native/styles/functions/filters.ts @@ -1,5 +1,6 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { resetsToNone } from "../resets-to-none"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "../shorthands/_handler"; @@ -103,11 +104,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", - ); + if (filters.some(resetsToNone)) return null; + return filters.filter((entry) => entry !== undefined); } if (value === undefined || value === "initial") return; - if (value === "none") return null; + 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..27c00309 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -5,7 +5,10 @@ import type { StyleDescriptor, StyleFunction, } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isInheritedColorLookup, + isStyleDescriptorArray, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; @@ -54,6 +57,8 @@ 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; /** Pass down to perform recursive calculations and avoid circular dependencies */ calculateProps?: typeof calculateProps; }; @@ -109,6 +114,21 @@ export function resolveValue( }; if (name === "var") { + if (options.computingColor && isInheritedColorLookup(value)) { + const inheritedScope: ResolveValueOptions = { + ...options, + inlineVariables: undefined, + computingColor: false, + }; + + return varResolver( + (inner) => resolveValue(inner, get, inheritedScope), + value, + get, + inheritedScope, + ); + } + return varResolver(simpleResolve, value, get, options); } else if (name in functionResolvers) { const fn = functionResolvers[name as keyof typeof functionResolvers]; diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index b8147d1a..e20f4a53 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, defaults); + } + if (!Array.isArray(args)) { return; } @@ -133,3 +137,20 @@ 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) +function clearedLonghands( + mappings: ShorthandRequiredValue[][], + defaults: ShorthandDefaultValue[], +) { + const target: Record = { [ShortHandSymbol]: true }; + const longhands = [...mappings.flat(), ...defaults].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/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..1f2852a7 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,9 @@ export const transform: StyleFunctionResolver = ( const transforms = resolveValue(transformDescriptor[2]); if (Array.isArray(transforms)) { - return transforms.filter( - (transform) => transform !== undefined && transform !== "initial", - ) as unknown; + return transforms.some(resetsToNone) + ? undefined + : (transforms.filter((transform) => transform !== undefined) as unknown); } else if (transforms) { // If it's a single transform, wrap it in an array return [transforms]; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..dd762410 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./inherited-color"; diff --git a/src/utilities/inherited-color.ts b/src/utilities/inherited-color.ts new file mode 100644 index 00000000..d025a92b --- /dev/null +++ b/src/utilities/inherited-color.ts @@ -0,0 +1,52 @@ +import type { + StyleDescriptor, + StyleFunction, + StyleRule, +} from "react-native-css/compiler"; + +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"; + +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, and returns the inherited colour it publishes for `assignPublishedColor` */ +export function assignRuleVariables( + variables: Record, + entries: NonNullable, +): StyleDescriptor | undefined { + let color: StyleDescriptor | undefined; + + for (const [name, value] of entries) { + if (name === INHERITED_COLOR_VARIABLE) { + color = value; + } else { + variables[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; + } +}