diff --git a/README.md b/README.md index bafb2302..0b6f501c 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,28 @@ This API only allows for setting CSS variables as primitive values. For more com > [!IMPORTANT] > By using `VariableContext` you may need to disable the `inlineVariable` optimization +## Pseudo-elements + +React Native has no pseudo-elements. A `TextInput` has one prop for each of `::selection` and `::placeholder`: + +| CSS | React Native prop | +| ---------------------------------- | ---------------------- | +| `::selection { background-color }` | `selectionColor` | +| `::placeholder { color }` | `placeholderTextColor` | + +`selectionColor` is the band behind the selected text, which CSS styles with `background-color`; `::selection { color }` is the selected text itself, which React Native cannot style. With Tailwind, use `selection:bg-*` on the input. + +The custom properties a pseudo-element declares are its own, and `currentcolor` inside it is its own `color`, as CSS specifies. Every other declaration is dropped rather than applied to the element, and `compile()` lists what it dropped. A Metro build does not print this list. + +```js +compile( + `.input::selection { background-color: red; color: white; width: 10px; }`, +).warnings(); +// { values: { "::selection": ["color", "width"] } } +``` + +On web the stylesheet reaches the browser unchanged, so both pseudo-elements behave as CSS specifies. + ## Optimizations CSS is a dynamic styling language that use highly optimized engines that are not available in React Native. Instead, we optimize the styles to improve performance diff --git a/src/__tests__/compiler/pseudo-element-field-policy.test.ts b/src/__tests__/compiler/pseudo-element-field-policy.test.ts new file mode 100644 index 00000000..2d77030c --- /dev/null +++ b/src/__tests__/compiler/pseudo-element-field-policy.test.ts @@ -0,0 +1,53 @@ +import type { StyleRule } from "../../compiler/compiler.types"; +import { + pseudoElementFieldPolicy, + scopeRuleToPseudoElement, +} from "../../compiler/pseudo-elements"; + +const policyFields = Object.keys(pseudoElementFieldPolicy).filter( + (key): key is keyof typeof pseudoElementFieldPolicy => + key in pseudoElementFieldPolicy, +); + +test("the policy classifies at least one field of each kind", () => { + for (const kind of ["carried", "rebuilt", "dropped"] as const) { + expect( + policyFields.filter((field) => pseudoElementFieldPolicy[field] === kind), + ).not.toStrictEqual([]); + } +}); + +test("a carried field is kept, a rebuilt one is the pseudo-element's and a dropped one is gone", () => { + const populated: StyleRule = { + s: [1, 1], + d: [["#f00", "backgroundColor"]], + v: [["brand", "#f00"]], + c: ["c:foo"], + pe: "placeholder", + dv: 1, + a: true, + target: "style", + m: [[">=", "width", 100]], + p: { h: 1 }, + cq: [{ m: [">=", "width", 100] }], + aq: [["d", "x"]], + }; + + const { rule: scoped } = scopeRuleToPseudoElement(populated, "selection"); + + expect(scoped).toBeDefined(); + + for (const field of policyFields) { + switch (pseudoElementFieldPolicy[field]) { + case "carried": + expect(scoped).toHaveProperty(field, populated[field]); + break; + case "dropped": + expect(scoped).not.toHaveProperty(field); + break; + case "rebuilt": + expect(scoped?.[field]).not.toStrictEqual(populated[field]); + break; + } + } +}); diff --git a/src/__tests__/compiler/pseudo-elements.test.ts b/src/__tests__/compiler/pseudo-elements.test.ts new file mode 100644 index 00000000..4cf62d1f --- /dev/null +++ b/src/__tests__/compiler/pseudo-elements.test.ts @@ -0,0 +1,469 @@ +import { compile, type CompilerOptions } from "react-native-css/compiler"; + +import type { StyleRule } from "../../compiler/compiler.types"; + +interface CompiledClass { + /** Every field of every rule except `s`, which the selector owns rather than the declarations */ + rules: Partial[]; + warnings: ReturnType["warnings"]>; +} + +const compileFor = ( + css: string, + className = "a", + options: CompilerOptions = {}, +): CompiledClass => { + const compiled = compile(css, options); + const rules = (compiled + .stylesheet() + .s?.find(([name]) => name === className)?.[1] ?? []) as StyleRule[]; + + return { + rules: rules.map((rule) => { + const fields: Partial = { ...rule }; + delete fields.s; + return fields; + }), + warnings: compiled.warnings(), + }; +}; + +const inlineVariablesOff = { inlineVariables: false } as const; + +/** The value a rule writes to `prop`, whether it compiled to a static object or a dynamic entry */ +const readWrittenValue = ( + rule: Partial | undefined, + prop: string, +): unknown => { + for (const declaration of rule?.d ?? []) { + if (!Array.isArray(declaration)) { + if (Object.hasOwn(declaration, prop)) { + return declaration[prop]; + } + continue; + } + + const path = declaration[1]; + + if (path === prop || (Array.isArray(path) && path.join() === prop)) { + return declaration[0]; + } + } + + return undefined; +}; + +const readsCustomProperty = (value: unknown, name: string): boolean => + Array.isArray(value) && + ((value[1] === "var" && + (value[2] === name || (Array.isArray(value[2]) && value[2][0] === name))) || + value.some((entry) => readsCustomProperty(entry, name))); + +const pseudoElements = [ + { + selector: "::selection", + property: "background-color", + elementProp: "backgroundColor", + prop: "selectionColor", + pseudoElement: "selection", + }, + { + selector: "::placeholder", + property: "color", + elementProp: "color", + prop: "placeholderTextColor", + pseudoElement: "placeholder", + }, +] as const; + +// The pseudo-element's rule is the element's rule for the same CSS, re-targeted at its prop +describe.each(pseudoElements)( + "$selector compiles what an element would", + ({ selector, property, elementProp, prop, pseudoElement }) => { + test.each([ + "#ff0000", + "var(--x)", + "hsl(calc(1em) 50% 50%)", + "color-mix(in srgb, #ff0000 50%, transparent)", + "currentcolor", + ])(`${property}: %s writes the element's value to ${prop}`, (value) => { + const [scoped] = compileFor( + `.a${selector} { ${property}: ${value}; }`, + "a", + inlineVariablesOff, + ).rules; + const [element] = compileFor( + `.b { ${property}: ${value}; }`, + "b", + inlineVariablesOff, + ).rules; + + expect(scoped?.pe).toBe(pseudoElement); + expect(readWrittenValue(element, elementProp)).toBeDefined(); + expect(readWrittenValue(scoped, prop)).toStrictEqual( + readWrittenValue(element, elementProp), + ); + expect(scoped?.dv).toBe(element?.dv); + }); + + test("declares the variables an element would", () => { + const declarations = `color: #ff0000; font-size: 40px; --brand: #123456;`; + const [scoped] = compileFor( + `.a${selector} { ${declarations} }`, + "a", + inlineVariablesOff, + ).rules; + const [element] = compileFor( + `.b { ${declarations} }`, + "b", + inlineVariablesOff, + ).rules; + + expect(element?.v?.length).toBeGreaterThan(0); + expect(scoped?.v).toStrictEqual(element?.v); + }); + }, +); + +describe("::selection", () => { + test("background-color maps to selectionColor and sets nothing else", () => { + expect( + compileFor(`.a::selection { background-color: #ff0000; }`), + ).toStrictEqual({ + rules: [{ pe: "selection", d: [["#f00", ["selectionColor"]]] }], + warnings: {}, + }); + }); + + test.each([ + ["color: #ff0000", "color"], + ["font-size: 40px", "fontSize"], + ])("`%s` writes no prop and is reported", (declaration, reported) => { + const { rules, warnings } = compileFor(`.a::selection { ${declaration}; }`); + + expect(rules.map((rule) => rule.d)).toStrictEqual([undefined]); + expect(warnings).toStrictEqual({ values: { "::selection": [reported] } }); + }); + + test("an unmapped static declaration is dropped", () => { + expect( + compileFor(`.a::selection { width: 10px; height: 5px; }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["width", "height"] } }, + }); + }); + + test("an unmapped var() declaration is dropped", () => { + expect(compileFor(`.a::selection { width: var(--x); }`)).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["width"] } }, + }); + }); + + test("an unmapped style-function declaration is dropped", () => { + expect( + compileFor(`.a::selection { transform: translateX(10px); }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::selection": ["transform"] } }, + }); + }); + + test("an unmapped declaration beside a mapped one is still dropped", () => { + expect( + compileFor(`.a::selection { background-color: #ff0000; width: 10px; }`), + ).toStrictEqual({ + rules: [{ pe: "selection", d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["width"] } }, + }); + }); + + test("an animation leaves no animated rule behind", () => { + const { rules, warnings } = compileFor( + `.a::selection { animation: spin 1s; }`, + ); + + expect(rules).toStrictEqual([]); + expect(warnings.values?.["::selection"]).toContain("animationName"); + }); + + test("a transition beside a mapped declaration does not animate the element", () => { + const { rules, warnings } = compileFor( + `.a::selection { background-color: #ff0000; transition: background-color 1s; }`, + ); + + expect(rules).toStrictEqual([ + { pe: "selection", d: [["#f00", ["selectionColor"]]] }, + ]); + expect(warnings.values?.["::selection"]).toContain("transitionProperty"); + }); + + test.each([ + "container-name: foo", + "container-type: inline-size", + "container: foo / inline-size", + ])("`%s` does not make the element a container", (declaration) => { + expect( + compileFor( + `.a::selection { background-color: #ff0000; ${declaration}; }`, + ), + ).toStrictEqual({ + rules: [{ pe: "selection", d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["container"] } }, + }); + }); + + test("container-name: none registers no container and reports no drop", () => { + // `none` empties `c` rather than leaving it absent + expect( + compileFor( + `.a::selection { background-color: #ff0000; container-name: none; }`, + ), + ).toStrictEqual({ + rules: [{ pe: "selection", d: [["#f00", ["selectionColor"]]] }], + warnings: {}, + }); + }); + + test("every declaration is scoped, not only the first", () => { + expect( + compileFor( + `.a::selection { background-color: #ff0000; transform: translateX(1px); }`, + ), + ).toStrictEqual({ + rules: [{ pe: "selection", d: [["#f00", ["selectionColor"]]] }], + warnings: { values: { "::selection": ["transform"] } }, + }); + }); + + test("a nested property path is reported the way the runtime reads it", () => { + expect( + compileFor(`.a::selection { text-shadow: 1px 2px 3px red; }`).warnings, + ).toStrictEqual({ + values: { + "::selection": [ + "textShadowColor", + "textShadowRadius", + "textShadowOffset.width", + "textShadowOffset.height", + ], + }, + }); + + expect( + compileFor(`.a::selection { box-shadow: 1px 2px 3px red; }`).warnings, + ).toStrictEqual({ + values: { + "::selection": [ + "boxShadow[0].color", + "boxShadow[0].offsetX", + "boxShadow[0].offsetY", + "boxShadow[0].blurRadius", + "boxShadow[0].spreadDistance", + ], + }, + }); + }); + + test("light-dark() scopes the dark value as well", () => { + expect( + compileFor( + `.a::selection { background-color: light-dark(#123456, #654321); }`, + ), + ).toStrictEqual({ + rules: [ + { pe: "selection", d: [["#123456", ["selectionColor"]]] }, + { + pe: "selection", + d: [["#654321", ["selectionColor"]]], + m: [["=", "prefers-color-scheme", "dark"]], + }, + ], + warnings: {}, + }); + }); + + test("the selector's own conditions survive the scoping", () => { + expect( + compileFor( + `@media (min-width: 100px) { .a:hover[data-x]::selection { background-color: #ff0000; } }`, + ), + ).toStrictEqual({ + rules: [ + { + pe: "selection", + d: [["#f00", ["selectionColor"]]], + m: [[">=", "width", 100]], + p: { h: 1 }, + aq: [["d", "x"]], + }, + ], + warnings: {}, + }); + }); + + test("a container query survives the scoping", () => { + expect( + compileFor( + `@container (min-width: 100px) { .a::selection { background-color: #ff0000; } }`, + ), + ).toStrictEqual({ + rules: [ + { + pe: "selection", + d: [["#f00", ["selectionColor"]]], + cq: [{ m: [">=", "width", 100] }], + }, + ], + warnings: {}, + }); + }); + + test("one authored rule warns once however many selectors it expands to", () => { + const { warnings } = compileFor( + `.a::selection, .b::selection { width: 10px; }`, + ); + + expect(warnings).toStrictEqual({ values: { "::selection": ["width"] } }); + }); + + test("each pseudo-element in one authored rule reports its own drops", () => { + const { warnings } = compileFor( + `.a::selection, .b::placeholder { width: 10px; }`, + ); + + expect(warnings).toStrictEqual({ + values: { "::selection": ["width"], "::placeholder": ["width"] }, + }); + }); + + test("the README example compiles to what the README says", () => { + expect( + compileFor( + `.input::selection { background-color: red; color: white; width: 10px; }`, + "input", + ).warnings, + ).toStrictEqual({ values: { "::selection": ["color", "width"] } }); + }); +}); + +describe("::placeholder", () => { + test("an unmapped declaration is dropped", () => { + expect( + compileFor(`.a::placeholder { background-color: #ff0000; }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::placeholder": ["backgroundColor"] } }, + }); + }); + + test("an unmapped var() declaration is dropped", () => { + expect( + compileFor(`.a::placeholder { background-color: var(--x); }`), + ).toStrictEqual({ + rules: [], + warnings: { values: { "::placeholder": ["backgroundColor"] } }, + }); + }); +}); + +// CSS Pseudo-Elements 4 §3.5: the custom properties a pseudo-element sets are its own +describe("a pseudo-element's custom properties", () => { + test("are not reported", () => { + expect( + compileFor( + `.a::selection { color: #ff0000; --brand: #123456; }`, + "a", + inlineVariablesOff, + ).warnings, + ).toStrictEqual({ values: { "::selection": ["color"] } }); + }); + + test("keep a rule that declares nothing else", () => { + expect( + compileFor(`.a::placeholder { --x: #123456; }`, "a", inlineVariablesOff), + ).toStrictEqual({ + rules: [{ pe: "placeholder", v: [["x", "#123456"]] }], + warnings: {}, + }); + }); + + test("are not inlined into the pseudo-element's own declaration", () => { + const css = `.a::selection { --x: #123456; background-color: var(--x); }`; + const { rules } = compileFor(css); + + expect(rules).toStrictEqual(compileFor(css, "a", inlineVariablesOff).rules); + expect(readsCustomProperty(rules[0]?.d, "x")).toBe(true); + }); + + test.each([ + ["declared", `.a::selection { --x: #123456; } .b { color: var(--x); }`], + [ + "declared !important", + `.a::selection { --x: #123456 !important; } .b { color: var(--x); }`, + ], + [ + "declared through nesting", + `.a { &::selection { --x: #123456; } } .b { color: var(--x); }`, + ], + ])("are not inlined into an element when %s", (_name, css) => { + const { rules } = compileFor(css, "b"); + + expect(rules).toStrictEqual(compileFor(css, "b", inlineVariablesOff).rules); + expect(readsCustomProperty(rules[0]?.d, "x")).toBe(true); + }); + + test.each([ + [ + ":root's", + `:root { --x: #123456; } .a::selection { background-color: var(--x); }`, + ], + [ + "the one elements and pseudo-elements share", + `*, ::before, ::after { --x: #123456; } .a::selection { background-color: var(--x); }`, + ], + ])("leave %s custom property to the optimization", (_name, css) => { + expect(readWrittenValue(compileFor(css).rules[0], "selectionColor")).toBe( + "#123456", + ); + }); +}); + +describe("rules without a pseudo-element", () => { + test("a plain rule on the same class compiles as it does alone", () => { + expect( + compileFor( + `.a { color: #ff0000; } .a::selection { background-color: #0000ff; }`, + ).rules[0], + ).toStrictEqual(compileFor(`.a { color: #ff0000; }`).rules[0]); + }); + + test("a plain rule's light-dark() keeps its dark rule as it is", () => { + expect( + compileFor(`.a { background-color: light-dark(#123456, #654321); }`) + .rules, + ).toStrictEqual([ + { d: [{ backgroundColor: "#123456" }] }, + { + d: [{ backgroundColor: "#654321" }], + m: [["=", "prefers-color-scheme", "dark"]], + }, + ]); + }); + + test("the unscoped half of a grouped selector is untouched", () => { + const css = `.a::selection, .b { background-color: #ff0000; --x: #123456; }`; + + expect(compileFor(css, "a", inlineVariablesOff).rules).toStrictEqual([ + { + pe: "selection", + d: [["#f00", ["selectionColor"]]], + v: [["x", "#123456"]], + }, + ]); + expect(compileFor(css, "b", inlineVariablesOff).rules).toStrictEqual([ + { d: [{ backgroundColor: "#f00" }], v: [["x", "#123456"]] }, + ]); + }); +}); diff --git a/src/__tests__/native/pseudo-elements.test.tsx b/src/__tests__/native/pseudo-elements.test.tsx new file mode 100644 index 00000000..7b7c5d0e --- /dev/null +++ b/src/__tests__/native/pseudo-elements.test.tsx @@ -0,0 +1,335 @@ +import { act, render, screen } from "@testing-library/react-native"; +import type { CompilerOptions } from "react-native-css/compiler"; +import { TextInput } from "react-native-css/components/TextInput"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +const controlTestID = "control"; + +const propsWithoutTestID = (id: string): Record => { + const { testID: _testID, ...props } = screen.getByTestId(id).props; + return props; +}; + +// Prop names first: a leaked container wraps the children in a provider whose full diff exhausts the heap +const expectIndistinguishableFromControl = () => { + const host = propsWithoutTestID(testID); + const control = propsWithoutTestID(controlTestID); + + expect(Object.keys(host).sort()).toStrictEqual(Object.keys(control).sort()); + expect(host).toStrictEqual(control); +}; + +describe("declarations a pseudo-element cannot express never reach the host", () => { + test("::selection { color } does not publish currentcolor to the subtree", () => { + registerCSS(` + .sel::selection { color: #ff0000; } + .child { color: currentColor; } + `); + + render( + + + + + + , + ); + + expectIndistinguishableFromControl(); + }); + + test("::selection { font-size } does not become the element's em base", () => { + registerCSS(` + .a::selection { font-size: 40px; } + .a { width: 2em; } + .control { width: 2em; } + `); + + render( + + + + , + ); + + expectIndistinguishableFromControl(); + }); + + test("::selection { animation } does not render the host as animated", () => { + registerCSS(`.a::selection { animation: spin 1s; }`); + + render( + + + + , + ); + + expectIndistinguishableFromControl(); + }); + + test("::selection { container-name } does not turn the host into a container", () => { + registerCSS(` + .a::selection { background-color: #ff0000; container-name: foo; } + .control::selection { background-color: #ff0000; } + `); + + render( + + + + , + ); + + expectIndistinguishableFromControl(); + }); + + test("::placeholder { color } does not publish currentcolor to the subtree", () => { + registerCSS(` + .a::placeholder { color: #ff0000; } + .child { color: currentColor; } + `); + + render( + + + + + + , + ); + + expectIndistinguishableFromControl(); + }); +}); + +describe("the declaration a pseudo-element can express reaches its prop", () => { + test("::selection { background-color } reaches selectionColor", () => { + registerCSS(`.a::selection { background-color: #ff0000; }`); + + render(); + + expect(screen.getByTestId(testID).props).toStrictEqual({ + children: undefined, + selectionColor: "#f00", + style: {}, + testID, + }); + }); + + test("::placeholder { color } reaches placeholderTextColor", () => { + registerCSS(`.a::placeholder { color: #ff0000; }`); + + render(); + + expect(screen.getByTestId(testID).props).toStrictEqual({ + children: undefined, + placeholderTextColor: "#f00", + style: {}, + testID, + }); + }); + + test("light-dark() reaches selectionColor in either scheme, never the host", () => { + registerCSS( + `:root { color-scheme: light dark; } .a::selection { background-color: light-dark(#123456, #654321); }`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props).toStrictEqual({ + children: undefined, + selectionColor: "#123456", + style: {}, + testID, + }); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props).toStrictEqual({ + children: undefined, + selectionColor: "#654321", + style: {}, + testID, + }); + }); +}); + +const pseudoElements = [ + { + selector: "::selection", + property: "background-color", + prop: "selectionColor", + }, + { + selector: "::placeholder", + property: "color", + prop: "placeholderTextColor", + }, +] as const; + +const inlineVariablesModes = [ + ["on", {}], + ["off", { inlineVariables: false }], +] as const satisfies readonly (readonly [string, CompilerOptions])[]; + +// CSS Pseudo-Elements 4 §3.5: a pseudo-element's own custom properties win, the rest come from its originating element +describe.each(pseudoElements)( + "$selector custom properties", + ({ selector, property, prop }) => { + const other = pseudoElements.find((entry) => entry.selector !== selector); + + describe.each(inlineVariablesModes)( + "with inline variables %s", + (_mode, options) => { + const propOf = (css: string): unknown => { + registerCSS(css, options); + + render( + + + , + ); + + return screen.getByTestId(testID).props[prop]; + }; + + test.each([ + [ + "apply to its own declaration", + `.a${selector} { --x: #123456; ${property}: var(--x); }`, + ], + [ + "apply across its rules", + `.a${selector} { --x: #123456; } .a${selector} { ${property}: var(--x); }`, + ], + [ + "win over the originating element's", + `.a { --x: #654321; } .a${selector} { --x: #123456; ${property}: var(--x); }`, + ], + [ + "win over an ancestor's", + `.p { --x: #654321; } .a${selector} { --x: #123456; ${property}: var(--x); }`, + ], + [ + "cascade by importance", + `.a${selector} { --x: #123456 !important; } .a${selector} { --x: #654321; ${property}: var(--x); }`, + ], + [ + "fall back to the originating element's", + `.a { --x: #123456; } .a${selector} { ${property}: var(--x); }`, + ], + [ + "fall back to an ancestor's", + `.p { --x: #123456; } .a${selector} { ${property}: var(--x); }`, + ], + [ + "fall back to the root's", + `:root { --root: #123456; } .a${selector} { ${property}: var(--root); }`, + ], + ])("%s", (_name, css) => { + expect(propOf(css)).toBe("#123456"); + }); + + test("are not inherited from a parent's pseudo-element", () => { + expect( + propOf( + `.p${selector} { --x: #123456; } .a${selector} { ${property}: var(--x, #654321); }`, + ), + ).toBe("#654321"); + }); + + test("follow their media query", () => { + expect( + propOf( + `.a${selector} { --x: #123456; ${property}: var(--x); } @media (prefers-color-scheme: dark) { .a${selector} { --x: #654321; } }`, + ), + ).toBe("#123456"); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId(testID).props[prop]).toBe("#654321"); + }); + + test("never reach the originating element", () => { + registerCSS( + `.a${selector} { --x: #123456; } .a { background-color: var(--x, #654321); }`, + options, + ); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + backgroundColor: "#654321", + }); + }); + + test("never reach a descendant", () => { + registerCSS( + `.a${selector} { --x: #123456; } .child { background-color: var(--x, #654321); }`, + options, + ); + + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + backgroundColor: "#654321", + }); + }); + + test("never reach the element's other pseudo-element", () => { + expect(other).toBeDefined(); + + registerCSS( + `.a${selector} { --x: #123456; } .a${other?.selector} { ${other?.property}: var(--x, #654321); }`, + options, + ); + + render(); + + expect(screen.getByTestId(testID).props[other?.prop ?? prop]).toBe( + "#654321", + ); + }); + }, + ); + }, +); + +// CSS Color 4: currentcolor is the pseudo-element's own color, or its originating element's when it sets none +describe("currentcolor in a pseudo-element", () => { + test.each([ + [ + "is ::selection's own color", + `.a { color: #654321; } .a::selection { color: #123456; background-color: currentcolor; }`, + { selectionColor: "#123456" }, + ], + [ + "is the originating element's color when ::selection sets none", + `.a { color: #123456; } .a::selection { background-color: currentcolor; }`, + { selectionColor: "#123456" }, + ], + [ + "as ::placeholder's color is the originating element's color", + `.a { color: #123456; } .a::placeholder { color: currentcolor; }`, + { placeholderTextColor: "#123456" }, + ], + ])("%s", (_name, css, expected) => { + registerCSS(css); + + render(); + + expect(screen.getByTestId(testID).props).toMatchObject(expected); + }); +}); diff --git a/src/__tests__/vendor/tailwind/states.test.tsx b/src/__tests__/vendor/tailwind/states.test.tsx index c4abd1e0..ff04dbd8 100644 --- a/src/__tests__/vendor/tailwind/states.test.tsx +++ b/src/__tests__/vendor/tailwind/states.test.tsx @@ -65,8 +65,9 @@ test("mixed", async () => { expect(component).toHaveStyle({ color: "#fff" }); }); +// selectionColor is the band behind the selected text, which CSS styles with background-color test("selection", async () => { - await render(); + await render(); const component = screen.getByTestId(testID); expect(component.props).toEqual({ @@ -77,6 +78,41 @@ test("selection", async () => { }); }); +test("selection: the selected text color does not reach the element", async () => { + const { warnings } = await render( + , + ); + + expect(screen.getByTestId(testID).props).toEqual({ + testID, + children: undefined, + }); + + expect(warnings()).toStrictEqual({ + values: { "::selection": ["color"] }, + }); +}); + +test.each([ + [ + "its own custom property", + "selection:[--brand:#123456] selection:bg-(--brand)", + ], + ["the element's custom property", "[--brand:#123456] selection:bg-(--brand)"], + [ + "its own color as currentcolor", + "selection:text-[#123456] selection:bg-current", + ], + [ + "the element's color as currentcolor", + "text-[#123456] selection:bg-current", + ], +])("selection: reads %s", async (_name, className) => { + await render(); + + expect(screen.getByTestId(testID).props.selectionColor).toBe("#123456"); +}); + test("ltr:", async () => { await render(); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 363ded58..f0030b6b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -98,8 +98,27 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { options.inlineRem = effectiveRem; } + const pseudoElementVariables = new Set(); + const firstPassVisitor: Visitor = { Rule(rule) { + // Custom properties only pseudo-elements declare are theirs alone, so no other use may inline them + if ( + rule.type === "style" && + rule.value.selectors.every((selector) => + selector.some((part) => part.type === "pseudo-element"), + ) + ) { + const { declarations = [], importantDeclarations = [] } = + rule.value.declarations ?? {}; + + for (const declaration of [...declarations, ...importantDeclarations]) { + if (declaration.property === "custom") { + pseudoElementVariables.add(declaration.value.name); + } + } + } + if ( rule.type === "unknown" && ["cssInterop", "react-native"].includes(rule.value.name) @@ -166,6 +185,10 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { } }; firstPassVisitor.StyleSheetExit = (sheet) => { + for (const name of pseudoElementVariables) { + vars.delete(name); + } + return inlineVariables(sheet, vars); }; } diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..4d522ce1 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -59,6 +59,8 @@ export interface ReactNativeCssStyleSheet { */ export type StyleRuleSet = StyleRule[]; +export type PseudoElement = "selection" | "placeholder"; + export interface StyleRule { /** Specificity */ s: SpecificityArray; @@ -68,6 +70,8 @@ export interface StyleRule { v?: VariableDescriptor[]; /** Named Containers */ c?: string[]; + /** The pseudo-element the rule styles, which owns its declarations and variables */ + pe?: PseudoElement; /** Declarations use variables */ dv?: number; diff --git a/src/compiler/pseudo-elements.ts b/src/compiler/pseudo-elements.ts index d379da8a..a3e3cd0c 100644 --- a/src/compiler/pseudo-elements.ts +++ b/src/compiler/pseudo-elements.ts @@ -1,54 +1,147 @@ -import { isStyleFunction } from "../utilities"; -import type { StyleDeclaration, StyleRule } from "./compiler.types"; +import { postProcessStyleFunction } from "../utilities"; +import type { + PseudoElement, + StyleDeclaration, + StyleRule, +} from "./compiler.types"; -export function modifyRuleForSelection(rule: StyleRule): StyleRule | undefined { - if (!rule.d) { - return; - } +/** + * The one declaration each pseudo-element can express on the host component, and the React + * Native prop it becomes. `::selection { color }` is the selected text, while selectionColor + * is the band behind it, so ::selection maps background-color + */ +const pseudoElementProp = { + selection: ["backgroundColor", "selectionColor"], + placeholder: ["color", "placeholderTextColor"], +} as const satisfies Record; - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "selectionColor"); - }); +/** + * What scoping does with each StyleRule field: a `carried` field is kept as it is, a `rebuilt` + * field is recomputed for the pseudo-element, and a `dropped` field never reaches the host. + * Typed over every StyleRule field, so a new one fails to compile until it is classified + */ +export const pseudoElementFieldPolicy = { + s: "carried", + m: "carried", + p: "carried", + cq: "carried", + aq: "carried", + v: "carried", + d: "rebuilt", + dv: "rebuilt", + pe: "rebuilt", + c: "dropped", + a: "dropped", + target: "dropped", +} as const satisfies Record; - return rule; +export interface ScopedRule { + /** The rule to register, or undefined when the pseudo-element is left with nothing */ + rule: StyleRule | undefined; + /** What the pseudo-element cannot express, in declaration order */ + dropped: string[]; } -export function modifyRuleForPlaceholder( +/** + * Rebuild a rule as the pseudo-element's own: the one declaration it can express and the + * variables it declares, under the selector's own conditions. Anything else would style the + * host element + */ +export function scopeRuleToPseudoElement( rule: StyleRule, -): StyleRule | undefined { - if (!rule.d) { - return; + pseudoElement: PseudoElement, +): ScopedRule { + const [from, to] = pseudoElementProp[pseudoElement]; + + const declarations: StyleDeclaration[] = []; + const dropped: string[] = []; + + for (const declaration of rule.d ?? []) { + scopeDeclaration(declaration, from, to, declarations, dropped); } - rule.d = rule.d.flatMap((declaration): StyleDeclaration[] => { - return modifyStyleDeclaration(declaration, "color", "placeholderTextColor"); - }); + // container-name, container-type and the container shorthand all record only the name + if (rule.c?.length) { + dropped.push("container"); + } - return rule; + if (!declarations.length && !rule.v?.length) { + return { rule: undefined, dropped }; + } + + const scoped: StyleRule = { s: rule.s, pe: pseudoElement }; + + if (declarations.length) scoped.d = declarations; + if (rule.v?.length) scoped.v = rule.v; + if (rule.m) scoped.m = rule.m; + if (rule.p) scoped.p = rule.p; + if (rule.cq) scoped.cq = rule.cq; + if (rule.aq) scoped.aq = rule.aq; + + if (declarations.some(usesVariables)) { + scoped.dv = 1; + } + + return { rule: scoped, dropped }; } -function modifyStyleDeclaration( +function scopeDeclaration( declaration: StyleDeclaration, from: string, to: string, -): StyleDeclaration[] { + declarations: StyleDeclaration[], + dropped: string[], +): void { if (Array.isArray(declaration)) { - if (isStyleFunction(declaration) && declaration[2] === from) { - declaration = [...declaration] as StyleDeclaration; - declaration[2] = [to]; - return [declaration]; - } else if (declaration[1] === from) { - declaration = [...declaration] as StyleDeclaration; - declaration[1] = [to]; - return [declaration]; + const property = toPropertyName(declaration[1]); + + if (property !== from) { + dropped.push(property); + return; } - } else if (typeof declaration === "object") { - const { color: selectionColor, ...rest } = declaration; - if (selectionColor) { - return [rest, [selectionColor, [to]]] as StyleDeclaration[]; + declarations.push( + declaration.length === 3 + ? [declaration[0], [to], declaration[2]] + : [declaration[0], [to]], + ); + + return; + } + + for (const [property, value] of Object.entries(declaration)) { + if (property === from) { + declarations.push([value, [to]]); + } else { + dropped.push(property); } } +} + +/** + * The React Native property a declaration writes, spelled the way the runtime reads it: a + * leading `&` routes the path to the top level and `[n]` is an index + */ +function toPropertyName(path: string | string[]): string { + if (!Array.isArray(path)) { + return path; + } + + return path.reduce((name, segment, index) => { + if (index === 0 && segment === "&") { + return name; + } + + if (segment.startsWith("[")) { + return `${name}${segment}`; + } + + return name ? `${name}.${segment}` : segment; + }, ""); +} - return [declaration]; +function usesVariables(declaration: StyleDeclaration): boolean { + return ( + Array.isArray(declaration) && postProcessStyleFunction(declaration[0])[1] + ); } diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..1a0fd461 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -8,6 +8,7 @@ import type { ContainerQuery, MediaCondition, PseudoClassesQuery, + PseudoElement, SpecificityArray, } from "./compiler.types"; @@ -19,7 +20,7 @@ interface ReactNativeClassNameSelector { containerQuery?: ContainerQuery[]; pseudoClassesQuery?: PseudoClassesQuery; attributeQuery?: AttributeQuery[]; - pseudoElementQuery?: string[]; + pseudoElement?: PseudoElement; } interface ReactNativeGlobalSelector { @@ -133,8 +134,7 @@ function parseComponents( case "placeholder": { specificity[Specificity.PseudoElements] = (specificity[Specificity.PseudoElements] ?? 0) + 1; - root.pseudoElementQuery ??= []; - root.pseudoElementQuery.push(component.kind); + root.pseudoElement = component.kind; return parseComponents(rest, options, root, ref, specificity); } default: { diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..88d6488e 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -1,7 +1,7 @@ import type { SelectorList } from "lightningcss"; import { - isStyleDescriptorArray, + postProcessStyleFunction, Specificity, specificityCompareFn, } from "../utilities"; @@ -11,6 +11,7 @@ import type { CompilerOptions, ContainerQuery, MediaCondition, + PseudoElement, ReactNativeCssStyleSheet, StyleDeclaration, StyleDescriptor, @@ -21,10 +22,7 @@ import type { VariableRecord, VariableValue, } from "./compiler.types"; -import { - modifyRuleForPlaceholder, - modifyRuleForSelection, -} from "./pseudo-elements"; +import { scopeRuleToPseudoElement } from "./pseudo-elements"; import { getClassNameSelectors, toRNProperty } from "./selector-builder"; type BuilderMode = "style" | "media" | "container" | "keyframes"; @@ -447,16 +445,27 @@ export class StylesheetBuilder { this.options, ); + const warnedPseudoElements = new Set(); + for (const selector of normalizedSelectors) { // We are going to be apply the current rule to n selectors, so we clone the rule let rule: StyleRule | undefined = this.cloneRule(this.rule); + const pseudoElement = + selector.type === "className" ? selector.pseudoElement : undefined; + + if (pseudoElement) { + const scoped = scopeRuleToPseudoElement(rule, pseudoElement); - if (selector.type === "className" && selector.pseudoElementQuery) { - if (selector.pseudoElementQuery.includes("selection")) { - rule = modifyRuleForSelection(rule); - } else if (selector.pseudoElementQuery.includes("placeholder")) { - rule = modifyRuleForPlaceholder(rule); + // One authored rule reports once, however many selectors it expands to + if (!warnedPseudoElements.has(pseudoElement)) { + warnedPseudoElements.add(pseudoElement); + + for (const property of scoped.dropped) { + this.addWarning("style", `::${pseudoElement}`, property); + } } + + rule = scoped.rule; } if (!rule) { @@ -540,10 +549,15 @@ export class StylesheetBuilder { const extraRulesArray = extraRules.get(this.rule); if (extraRulesArray) { for (const extraRule of extraRulesArray) { - this.addRuleToRuleSet( - className, - this.createRuleFromPartial(rule, extraRule), - ); + const merged = this.createRuleFromPartial(rule, extraRule); + // An extra rule brings its own declarations, so they are scoped as well + const scopedExtraRule = pseudoElement + ? scopeRuleToPseudoElement(merged, pseudoElement).rule + : merged; + + if (scopedExtraRule) { + this.addRuleToRuleSet(className, scopedExtraRule); + } } } } else { @@ -617,40 +631,6 @@ function isStyleFunction( ); } -function postProcessStyleFunction(value: StyleDescriptor): [ - // Should it be delayed - boolean, - // Does it use variables - boolean, -] { - if (!Array.isArray(value)) { - return [false, false]; - } - - if (isStyleDescriptorArray(value)) { - let shouldDelay = false; - let usesVariables = false; - for (const v of value) { - const [delayed, variables] = postProcessStyleFunction(v); - shouldDelay ||= delayed; - usesVariables ||= variables; - } - - return [shouldDelay, usesVariables]; - } - - let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); - - usesVariables ||= value[1] === "var"; - shouldDelay ||= value[3] === 1 || usesVariables; - - if (shouldDelay) { - return [true, usesVariables]; - } - - return [false, false]; -} - function allEqual(...params: unknown[]) { return params.every((param, index, array) => { return index === 0 ? true : equal(array[0], param); diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..564e5573 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -109,7 +109,10 @@ export function updateRules( // We we need to inject React context if (rule.a) animated = true; - if (rule.v) { + // A pseudo-element's variables are its own and never reach the element's children + const publishesVariables = Boolean(rule.v) && !rule.pe; + + if (publishesVariables) { variables ??= inheritedVariables; } @@ -130,7 +133,7 @@ export function updateRules( continue; } - if (rule.v) { + if (publishesVariables && rule.v) { if (variables === inheritedVariables) { variables = { ...inheritedVariables }; } diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..1bf9667c 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import type { InlineVariable, + PseudoElement, StyleDeclaration, StyleRule, } from "react-native-css/compiler"; @@ -32,6 +33,7 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + const pseudoElementScopes = new Map(); for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -43,9 +45,15 @@ export function calculateProps( continue; } + const pseudoElementScope = rule.pe + ? readPseudoElementScope(pseudoElementScopes, rule.pe) + : undefined; + if (rule.v) { + const declared = pseudoElementScope?.declared ?? inlineVariables; + for (const variable of rule.v) { - inlineVariables[variable[0]] = variable[1]; + declared[variable[0]] = variable[1]; } } @@ -68,7 +76,7 @@ export function calculateProps( applyDeclarations( get, rule.d, - inlineVariables, + pseudoElementScope?.resolved ?? inlineVariables, inheritedVariables, delayedStyles, transformStyles, @@ -79,6 +87,12 @@ export function calculateProps( } } + // A pseudo-element's own variables shadow the element's, which it otherwise inherits. + // Every declaration that reads a variable is delayed, so it resolves against the full scope. + for (const { declared, resolved } of pseudoElementScopes.values()) { + Object.assign(resolved, inlineVariables, declared); + } + for (const delayedStyle of delayedStyles) { delayedStyle(); } @@ -94,6 +108,30 @@ export function calculateProps( }; } +interface PseudoElementScope { + /** The variables the pseudo-element's own rules declare */ + declared: InlineVariable; + /** What its declarations resolve against: the element's variables, then its own */ + resolved: InlineVariable; +} + +function readPseudoElementScope( + scopes: Map, + pseudoElement: PseudoElement, +): PseudoElementScope { + let scope = scopes.get(pseudoElement); + + if (!scope) { + scope = { + declared: { [VAR_SYMBOL]: "inline" }, + resolved: { [VAR_SYMBOL]: "inline" }, + }; + scopes.set(pseudoElement, scope); + } + + return scope; +} + export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], diff --git a/src/utilities/style-descriptor.ts b/src/utilities/style-descriptor.ts index 7a166fa1..e26bd9d1 100644 --- a/src/utilities/style-descriptor.ts +++ b/src/utilities/style-descriptor.ts @@ -17,3 +17,37 @@ export function isStyleFunction(value: unknown): value is StyleFunction { typeof value[1] === "string" ); } + +export function postProcessStyleFunction(value: StyleDescriptor): [ + // Should it be delayed + boolean, + // Does it use variables + boolean, +] { + if (!Array.isArray(value)) { + return [false, false]; + } + + if (isStyleDescriptorArray(value)) { + let shouldDelay = false; + let usesVariables = false; + for (const v of value) { + const [delayed, variables] = postProcessStyleFunction(v); + shouldDelay ||= delayed; + usesVariables ||= variables; + } + + return [shouldDelay, usesVariables]; + } + + let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); + + usesVariables ||= value[1] === "var"; + shouldDelay ||= value[3] === 1 || usesVariables; + + if (shouldDelay) { + return [true, usesVariables]; + } + + return [false, false]; +}