diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 7a9fbea..d7561ab 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -448,3 +448,121 @@ test("simplifies rem", () => { ], }); }); + +describe("CSS-wide color keywords", () => { + const stylesheetFor = (value: string) => + compile(`.child { color: ${value}; }`).stylesheet(); + + test("compiles to the inherited-color variable instead of being dropped", () => { + // lightningcss emits `color: inherit` as an UnparsedProperty (the keyword is + // not a CssColor), which parseUnparsed used to drop. Per CSS Color, + // `currentcolor` used as the value of `color` is defined as `inherit`, so it + // resolves to the same inherited-color variable. The ABSENCE of a `v` entry + // is the no-self-reference guarantee — publishing this value as its own + // --__rn-css-color would seed a circular var(--__rn-css-color). + expect(stylesheetFor("inherit")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("inherit and currentcolor compile identically (CSS Color spec identity)", () => { + expect(stylesheetFor("inherit")).toStrictEqual( + stylesheetFor("currentcolor"), + ); + }); + + test("currentcolor still resolves to the inherited-color variable (unchanged)", () => { + // The token/ident branch that hunk 1 restructured also carries currentcolor; + // this pins that currentcolor keeps compiling to the same lookup, and — like + // inherit — never self-publishes a `v`. + expect(stylesheetFor("currentcolor")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); + + test("a normal color still publishes --__rn-css-color to descendants", () => { + expect(stylesheetFor("red")).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [{ color: "#f00" }], + v: [["__rn-css-color", "#f00"]], + }, + ], + ], + ], + }); + }); + + test("inherit on a non-color property is still dropped (no inheritance context)", () => { + expect( + compile(`.child { font-size: inherit; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("color: initial is still dropped (different semantics, out of scope)", () => { + expect(stylesheetFor("initial")).toStrictEqual({}); + }); + + test("color: unset resolves like inherit (unset on an inherited property is inherit)", () => { + // Per CSS Cascade, `unset` computes to `inherit` on inherited properties, + // and `color` is inherited — so it maps to the same inherited-color variable. + expect(stylesheetFor("unset")).toStrictEqual(stylesheetFor("inherit")); + }); + + test("keyword matching is case-insensitive (INHERIT)", () => { + // CSS-wide keywords are case-insensitive; lightningcss does not fold case. + expect(stylesheetFor("INHERIT")).toStrictEqual(stylesheetFor("inherit")); + }); + + test("currentColor (camelCase) resolves like currentcolor", () => { + // The spelling React/JS authors reach for; it is valid, case-insensitive CSS. + expect(stylesheetFor("currentColor")).toStrictEqual( + stylesheetFor("currentcolor"), + ); + }); + + test("currentcolor resolves on a non-color property too (border-color)", () => { + expect( + compile(`.child { border-color: currentcolor; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "child", + [ + { + s: [1, 1], + d: [[[{}, "var", "__rn-css-color"], "borderColor", 1]], + dv: 1, + }, + ], + ], + ], + }); + }); +}); diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx index 6a8c725..b67a971 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 { 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,105 @@ 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("text-inherit: a child Text inherits its parent's color", () => { + // The shape that surfaced the bug: a labelled button whose label renders + // React Native's default color (black) on native instead of the button's + // foreground color, while web inherits correctly. + registerCSS(` + .button { color: white; } + .label { color: inherit; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("label").props.style).toStrictEqual({ + color: "#fff", + }); + }); + + test("inherit chains through an inheriting ancestor without breaking the chain", () => { + // The middle node inherits and must NOT republish a circular + // --__rn-css-color, or the grandchild would fail to resolve the color. + 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, same as inherit", () => { + // `unset` computes to `inherit` on inherited properties, and color is one. + registerCSS(` + .parent { color: red; } + .child { color: unset; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("child").props.style).toStrictEqual({ + color: "#f00", + }); + }); +}); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b..c286a3c 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -322,9 +322,17 @@ describe("Typography - Text Color", () => { }); }); test("text-inherit", async () => { + // Per CSS Color, `inherit` on the `color` property is defined as + // `currentcolor`, so text-inherit resolves to the platform label color — + // identical to text-current above — instead of being dropped with a warning. 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 1301364..7591a6f 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -950,10 +950,15 @@ export function parseUnparsedDeclaration( } if (property === "color") { + // Publish the resolved color to descendants as --__rn-css-color — the + // variable `currentcolor` and `color: inherit` both resolve against. Skip + // when the value IS that same lookup (e.g. `color: inherit`) so an + // inheriting rule never seeds a circular `--__rn-css-color: + // var(--__rn-css-color)` that would break resolution for its own subtree. if ( !isStyleFunction(value) || value[1] !== "var" || - value[2] !== "-css-color" + value[2] !== "__rn-css-color" ) { builder.addDescriptor("--__rn-css-color", value); } @@ -1263,11 +1268,30 @@ export function parseUnparsed( return; } - if (value === "inherit" || value === "initial") { + // CSS-wide keywords and `currentcolor` are case-insensitive. + const keyword = value.toLowerCase(); + + // Per CSS Color, `currentcolor` as the value of `color` is defined as + // `inherit`; and per CSS Cascade, `unset` on an inherited property + // (`color` is inherited) computes to `inherit` too. So `currentcolor` + // (valid on any property) and `inherit` / `unset` on `color` all + // resolve to the inherited-color variable every color rule publishes + // to its subtree (see parseUnparsedDeclaration). + if ( + keyword === "currentcolor" || + ((keyword === "inherit" || keyword === "unset") && + property === "color") + ) { + return [{}, "var", "__rn-css-color"] as const; + } + + // `inherit` and `initial` on any other property have no per-property + // resolution context here — drop with a warning. `unset` on a + // non-color property (= `initial` there) and `revert` / + // `revert-layer` keep their existing fall-through handling below. + if (keyword === "inherit" || keyword === "initial") { builder.addWarning("value", value); return; - } else if (value === "currentcolor") { - return [{}, "var", "__rn-css-color"] as const; } if (value === "true") {