diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..1637849f 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -99,6 +99,26 @@ test("preserves excluded CSS variables using the public compiler option", () => expect(rule?.v?.some(([name]) => name === "red")).toBe(false); }); +test.each([ + [ + "an hsl() alpha", + `:root { --alpha: 0.5; } .test { color: hsl(0 100% 50% / var(--alpha)); }`, + `.test { color: hsl(0 100% 50% / 0.5); }`, + ], + [ + "an rgb() alpha", + `:root { --alpha: 0.5; } .test { color: rgb(255 0 0 / var(--alpha)); }`, + `.test { color: rgb(255 0 0 / 0.5); }`, + ], + [ + "a light-dark() arm", + `:root { --dark: #654321; } .test { color: light-dark(#123456, var(--dark)); }`, + `.test { color: light-dark(#123456, #654321); }`, + ], +])("folds a variable read inside %s", (_name, css, folded) => { + expect(compile(css).stylesheet()).toStrictEqual(compile(folded).stylesheet()); +}); + test("multiple rules with same selector", () => { const compiled = compile(` .redOrGreen:hover { @@ -211,19 +231,9 @@ test("light-dark()", () => { [ { d: [ - { - backgroundColor: "#333b3c", - }, - ], - s: [1, 1], - }, - { - d: [ - { - backgroundColor: "#efefec", - }, + [[{}, "lightDark", ["#333b3c", "#efefec"]], "backgroundColor", 1], ], - m: [["=", "prefers-color-scheme", "dark"]], + dv: 1, s: [1, 1], }, ], diff --git a/src/__tests__/compiler/light-dark.test.ts b/src/__tests__/compiler/light-dark.test.ts new file mode 100644 index 00000000..1b1c1148 --- /dev/null +++ b/src/__tests__/compiler/light-dark.test.ts @@ -0,0 +1,157 @@ +import { compile, type CompilerOptions } from "react-native-css/compiler"; +import { COLOR_SCHEME_VARIABLE } from "react-native-css/utilities"; + +import type { StyleRule } from "../../compiler/compiler.types"; + +const inlineVariablesOff = { inlineVariables: false } as const; + +const rulesFor = ( + css: string, + className = "a", + options: CompilerOptions = {}, +): StyleRule[] => + compile(css, options) + .stylesheet() + .s?.find(([name]) => name === className)?.[1] ?? []; + +const readLightDark = (value: unknown): unknown => + Array.isArray(value) + ? value[1] === "lightDark" + ? value[2] + : value.map(readLightDark).find((found) => found !== undefined) + : undefined; + +// CSS Color 5 §7: light-dark() is one value with two arms, resolved against the used colour scheme +describe("light-dark() compiles to one value", () => { + test.each([ + ["color", "color: light-dark(#123456, #654321)"], + ["background-color", "background-color: light-dark(#123456, #654321)"], + ["a border colour", "border: 1px solid light-dark(#123456, #654321)"], + ["a shadow colour", "box-shadow: 0 0 1px light-dark(#123456, #654321)"], + [ + "a text shadow colour", + "text-shadow: 1px 1px 1px light-dark(#123456, #654321)", + ], + ["a custom property", "--x: light-dark(#123456, #654321)"], + ])("%s gets one rule holding both colours", (_name, declaration) => { + const rules = rulesFor(`.a { ${declaration}; }`, "a", inlineVariablesOff); + + expect(rules).toHaveLength(1); + expect(rules[0]?.m).toBeUndefined(); + expect(readLightDark([rules[0]?.d, rules[0]?.v])).toStrictEqual([ + "#123456", + "#654321", + ]); + }); + + test("a colour publishes the same value to its subtree", () => { + const [rule] = rulesFor(`.a { color: light-dark(#123456, #654321); }`); + + expect(rule?.v).toStrictEqual([ + ["__rn-css-color", [{}, "lightDark", ["#123456", "#654321"]]], + ]); + }); + + test("a :root custom property keeps both colours", () => { + expect( + compile( + `:root { --x: light-dark(#123456, #654321); }`, + inlineVariablesOff, + ).stylesheet().vr, + ).toStrictEqual([["x", [[[{}, "lightDark", ["#123456", "#654321"]]]]]]); + }); + + test("a branch that reads a variable delays the declaration", () => { + const [rule] = rulesFor( + `.a { color: light-dark(#123456, var(--d)); }`, + "a", + inlineVariablesOff, + ); + + expect(rule?.dv).toBe(1); + expect(readLightDark(rule?.d)).toStrictEqual([ + "#123456", + [{}, "var", "d", 1], + ]); + }); + + test("a media query keeps only its own condition", () => { + expect( + rulesFor( + `@media (min-width: 100px) { .a { color: light-dark(#123456, #654321); } }`, + ).map((rule) => rule.m), + ).toStrictEqual([[[">=", "width", 100]]]); + }); + + test("a pseudo-element maps it to its prop", () => { + expect( + rulesFor(`.a::placeholder { color: light-dark(#123456, #654321); }`).map( + (rule) => rule.d, + ), + ).toStrictEqual([ + [ + [ + [{}, "lightDark", ["#123456", "#654321"]], + ["placeholderTextColor"], + 1, + ], + ], + ]); + }); + + test.each([ + "light-dark(canvastext, #654321)", + "light-dark(#123456, canvastext)", + ])("%s drops the declaration as the colour it cannot paint does", (value) => { + expect( + rulesFor(`.a { color: light-dark(#123456, #654321); }`), + ).not.toStrictEqual([]); + expect(rulesFor(`.a { color: ${value}; }`)).toStrictEqual( + rulesFor(`.a { color: canvastext; }`), + ); + }); +}); + +test("a light-dark() declaration reads the element's colour scheme", () => { + const [rule] = rulesFor(`.a { color: light-dark(#123456, #654321); }`); + + expect(rule?.dv).toBe(1); +}); + +// CSS Color Adjust 1 §2.2: color-scheme publishes the schemes it supports to its subtree and renders nothing of its own +describe("color-scheme", () => { + test.each([ + ["normal", "normal"], + ["light", "light"], + ["dark", "dark"], + ["light dark", "light dark"], + ["dark light", "light dark"], + ["only light", "light"], + ["only dark", "dark"], + ["only light dark", "light dark"], + ["brand", "normal"], + ["brand dark", "dark"], + ])("color-scheme: %s publishes %s", (value, support) => { + const compiled = compile(`.a { color-scheme: ${value}; }`); + + expect(compiled.stylesheet()).toStrictEqual({ + s: [["a", [{ s: [1, 1], v: [[COLOR_SCHEME_VARIABLE, support]] }]]], + }); + expect(compiled.warnings()).toStrictEqual({}); + }); + + test.each([ + ["var(--scheme)", [{}, "var", "scheme", 1]], + ["var(--scheme, dark)", [{}, "var", ["scheme", "dark"], 1]], + ])("color-scheme: %s publishes the reference", (value, reference) => { + expect( + rulesFor(`.a { color-scheme: ${value}; }`, "a", inlineVariablesOff), + ).toStrictEqual([{ s: [1, 1], v: [[COLOR_SCHEME_VARIABLE, reference]] }]); + }); + + test("a :root color-scheme reaches every element", () => { + expect( + compile(`:root { color-scheme: light dark; }`).stylesheet().vr, + ).toStrictEqual([[COLOR_SCHEME_VARIABLE, [["light dark"]]]]); + }); +}); diff --git a/src/__tests__/native/light-dark.test.tsx b/src/__tests__/native/light-dark.test.tsx new file mode 100644 index 00000000..f44a4961 --- /dev/null +++ b/src/__tests__/native/light-dark.test.tsx @@ -0,0 +1,402 @@ +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 { + rootVariables, + StyleCollection, + universalVariables, +} from "react-native-css/native-internal"; +import { colorScheme } from "react-native-css/runtime"; +import { COLOR_SCHEME_VARIABLE } from "react-native-css/utilities"; + +type Arm = "light" | "dark"; + +const grandparentTestID = "grandparent"; +const parentTestID = "parent"; + +// An arm is a colour, which may nest one function inside another: hsl(0 100% 50% / var(--a)) +const ARGUMENT = String.raw`(?:[^(),]|\((?:[^()]|\([^()]*\))*\))+`; +const ARM = new RegExp( + String.raw`light-dark\((${ARGUMENT}),\s*(${ARGUMENT})\)`, + "g", +); + +/** The same stylesheet with every `light-dark()` replaced by one of its arms, a nested one included */ +const withArm = (css: string, arm: Arm): string => { + const replaced = css.replaceAll(ARM, (_match, light: string, dark: string) => + arm === "light" ? light : dark, + ); + + return replaced === css ? css : withArm(replaced, arm); +}; + +// registerCSS keeps every :root and * variable between tests, and this channel has one fixed name +beforeEach(() => { + rootVariables(COLOR_SCHEME_VARIABLE).set([]); + universalVariables(COLOR_SCHEME_VARIABLE).set([]); +}); + +const inlineVariableModes = [ + ["on", {}], + ["off", { inlineVariables: false }], +] as const satisfies readonly (readonly [string, CompilerOptions])[]; + +const renderTree = (css: string, options: CompilerOptions) => { + registerCSS(css, options); + + const { unmount } = render( + + + + + , + ); + + const readStyles = () => ({ + grandparent: screen.getByTestId(grandparentTestID).props.style, + parent: screen.getByTestId(parentTestID).props.style, + element: screen.getByTestId(testID).props.style, + }); + + return { readStyles, unmount }; +}; + +const renderedStyles = (css: string, options: CompilerOptions) => { + const tree = renderTree(css, options); + const styles = tree.readStyles(); + tree.unmount(); + return styles; +}; + +// CSS Color Adjust 1 §2.3: an element that supports both schemes uses the preferred one +const SUPPORTS_BOTH_SCHEMES = ":root { color-scheme: light dark; }"; + +const scenes = [ + ["color", `.a { color: light-dark(#123456, #654321); }`], + [ + "background-color", + `.a { background-color: light-dark(#123456, #654321); }`, + ], + ["a border colour", `.a { border: 1px solid light-dark(#123456, #654321); }`], + [ + "four border colours", + `.a { border-width: 1px; border-color: light-dark(#111111, #222222) light-dark(#333333, #444444) light-dark(#555555, #666666) light-dark(#777777, #888888); }`, + ], + [ + "the inline border colours", + `.a { border-width: 1px; border-inline-color: light-dark(#123456, #654321) light-dark(#111111, #222222); }`, + ], + [ + "one border side", + `.a { border-top: 1px solid light-dark(#123456, #654321); }`, + ], + [ + "the block border", + `.a { border-block: 1px solid light-dark(#123456, #654321); }`, + ], + [ + "the inline-start border", + `.a { border-inline-start: 1px solid light-dark(#123456, #654321); }`, + ], + ["outline-color", `.a { outline-color: light-dark(#123456, #654321); }`], + [ + "text-decoration-color", + `.a { text-decoration-line: underline; text-decoration-color: light-dark(#123456, #654321); }`, + ], + [ + "the text-decoration shorthand", + `.a { text-decoration: underline light-dark(#123456, #654321); }`, + ], + [ + "a shadow colour", + `.a { box-shadow: 0 0 1px light-dark(#123456, #654321); }`, + ], + [ + "a text shadow colour", + `.a { text-shadow: 1px 1px 1px light-dark(#123456, #654321); }`, + ], + [ + "a nested light-dark()", + `.a { color: light-dark(light-dark(#123456, #654321), #778899); }`, + ], + [ + "a var() fallback", + `.a { color: var(--ld-missing, light-dark(#123456, #654321)); }`, + ], + [ + "a :root custom property", + `:root { --ld-root: light-dark(#123456, #654321); } .a { color: var(--ld-root); }`, + ], + [ + "a universal custom property", + `* { --ld-universal: light-dark(#123456, #654321); } .a { color: var(--ld-universal); }`, + ], + [ + "an ancestor's custom property", + `.p { --x: light-dark(#123456, #654321); } .a { color: var(--x); }`, + ], + [ + "the element's own custom property", + `.a { --x: light-dark(#123456, #654321); color: var(--x); }`, + ], + [ + "currentcolor from an ancestor", + `.p { color: light-dark(#123456, #654321); } .a { border-width: 1px; border-color: currentcolor; }`, + ], + [ + "an ancestor's colour beside its other variables", + `.p { --other: 5px; color: light-dark(#123456, #654321); } .a { width: var(--other); background-color: currentcolor; }`, + ], + [ + "two declarations on one rule", + `.p { color: light-dark(#123456, #654321); background-color: light-dark(#111111, #222222); } .a { background-color: currentcolor; }`, + ], + [ + "a branch that reads a variable", + `.p { --d: #654321; } .a { background-color: light-dark(#123456, var(--d)); }`, + ], + [ + "two branches that read a variable", + `:root { --ld-alpha: 1; } .a { color: light-dark(hsl(0 100% 50% / var(--ld-alpha)), hsl(240 100% 50% / var(--ld-alpha))); background-color: light-dark(hsl(120 100% 25% / var(--ld-alpha)), hsl(60 100% 50% / var(--ld-alpha))); }`, + ], + ["!important", `.a { color: light-dark(#123456, #654321) !important; }`], + [ + "a media query", + `@media (min-width: 0px) { .a { color: light-dark(#123456, #654321); } }`, + ], +] as const; + +// CSS Color 5 §7: light-dark() is its first colour unless the element's used colour scheme is dark +describe.each(inlineVariableModes)( + "with inline variables %s", + (_mode, options) => { + test.each(scenes)( + "%s renders its light colour, then its dark colour", + (_name, scene) => { + const css = `${SUPPORTS_BOTH_SCHEMES} ${scene}`; + + act(() => { + colorScheme.set("light"); + }); + + const lightStyles = renderedStyles(withArm(css, "light"), options); + const darkStyles = renderedStyles(withArm(css, "dark"), options); + + expect(darkStyles).not.toStrictEqual(lightStyles); + + const tree = renderTree(css, options); + + expect(tree.readStyles()).toStrictEqual(lightStyles); + + act(() => { + colorScheme.set("dark"); + }); + + expect(tree.readStyles()).toStrictEqual(darkStyles); + + act(() => { + colorScheme.set("light"); + }); + + expect(tree.readStyles()).toStrictEqual(lightStyles); + }, + ); + }, +); + +const followsPreference = { light: "light", dark: "dark" } as const; +const alwaysLight = { light: "light", dark: "light" } as const; +const alwaysDark = { light: "dark", dark: "dark" } as const; + +// The arm each scene takes under each preference is the one Chromium 153, Firefox 155 and WebKit 26.6 render +const colorSchemeScenes = [ + [ + "no color-scheme", + `.a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: normal", + `:root { color-scheme: normal; } .a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: light dark", + `:root { color-scheme: light dark; } .a { color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "color-scheme: dark light", + `:root { color-scheme: dark light; } .a { color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "color-scheme: only light dark", + `:root { color-scheme: only light dark; } .a { color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "color-scheme: dark on the element", + `.a { color-scheme: dark; color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme: light on the element, under a root that supports both", + `:root { color-scheme: light dark; } .a { color-scheme: light; color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: only light", + `:root { color-scheme: only light; } .a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: only dark", + `:root { color-scheme: only dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme inherited from an ancestor", + `.p { color-scheme: dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme: normal under a dark ancestor", + `.p { color-scheme: dark; } .a { color-scheme: normal; color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "a custom ident alone", + `:root { color-scheme: brand; } .a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "a custom ident beside dark", + `:root { color-scheme: brand dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "the later color-scheme", + `.a { color-scheme: dark; color: light-dark(#123456, #654321); } .a { color-scheme: light; }`, + alwaysLight, + ], + [ + "an !important color-scheme", + `.a { color-scheme: dark !important; color: light-dark(#123456, #654321); } .a { color-scheme: light; }`, + alwaysDark, + ], + [ + "a color-scheme in a media query", + `.a { color: light-dark(#123456, #654321); } @media (min-width: 0px) { .a { color-scheme: dark; } }`, + alwaysDark, + ], + [ + "a custom property resolved where it is used", + `.p { color-scheme: dark; --x: light-dark(#123456, #654321); } .a { color-scheme: light; color: var(--x); }`, + alwaysLight, + ], + [ + "a universal color-scheme", + `* { color-scheme: dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme from var()", + `:root { --scheme: dark; } .a { color-scheme: var(--scheme); color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "both schemes from var()", + `:root { --scheme: light dark; } .a { color-scheme: var(--scheme); color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "a var() fallback color-scheme", + `.a { color-scheme: var(--scheme-missing, dark); color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme: initial", + `.p { color-scheme: dark; } .a { color-scheme: initial; color: light-dark(#123456, #654321); }`, + alwaysLight, + ], +] as const satisfies readonly (readonly [ + string, + string, + Readonly>, +])[]; + +// CSS Color Adjust 1 §2.3: light-dark() reads the used colour scheme, which color-scheme decides and the preference only picks within +describe.each(inlineVariableModes)( + "the used colour scheme, with inline variables %s", + (_mode, options) => { + test.each(colorSchemeScenes)("%s", (_name, css, arms) => { + expect(renderedStyles(withArm(css, "light"), options)).not.toStrictEqual( + renderedStyles(withArm(css, "dark"), options), + ); + + for (const preference of ["light", "dark"] as const) { + act(() => { + colorScheme.set(preference); + }); + + expect(renderedStyles(css, options)).toStrictEqual( + renderedStyles(withArm(css, arms[preference]), options), + ); + } + }); + }, +); + +test("a pseudo-element's light-dark() reaches its prop, never the element", () => { + registerCSS( + `${SUPPORTS_BOTH_SCHEMES} .a::placeholder { color: light-dark(#123456, #654321); }`, + ); + render(); + const component = screen.getByTestId(testID); + + expect(component.props.placeholderTextColor).toBe("#123456"); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props.placeholderTextColor).toBe("#654321"); + expect(component.props.style).toStrictEqual({}); +}); + +test("a stylesheet whose light-dark() lacks an arm renders no colour", () => { + StyleCollection.inject({ + s: [ + [ + "a", + [ + { + s: [1, 1], + d: [[[{}, "lightDark", ["#123456"]], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +// Chromium, Firefox and WebKit render the light arm when the platform reports no preference +test.each([ + [":root { color-scheme: light dark; }", "#123456"], + [":root { color-scheme: dark; }", "#654321"], +])("with no preference, %s renders %s", (root, color) => { + act(() => { + colorScheme.set(null); + }); + registerCSS(`${root} .a { color: light-dark(#123456, #654321); }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ color }); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..a704d71e 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,13 +36,14 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { + COLOR_SCHEME_VARIABLE, + describeColorSchemeSupport, + isStyleFunction, + LIGHT_DARK_FUNCTION, +} from "../utilities"; import { isMappingProperty } from "./atRules"; -import type { - StyleDescriptor, - StyleFunction, - StyleRule, -} from "./compiler.types"; +import type { StyleDescriptor, StyleFunction } from "./compiler.types"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; import { toRNProperty } from "./selectors"; import type { StylesheetBuilder } from "./stylesheet"; @@ -180,6 +181,7 @@ const parsers: { "box-sizing": parseBoxSizing, "caret-color": parseColorOrAutoDeclaration, "color": parseFontColorDeclaration, + "color-scheme": parseColorScheme, "column-gap": parseGap, "container": parseContainer, "container-name": parseContainerName, @@ -316,8 +318,6 @@ function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) { if (declaration.property in parsers) { const parser = parsers[declaration.property] as Parser; - builder.descriptorProperty = declaration.property; - builder.setWarningProperty(declaration.property); const value = parser(declaration, builder, declaration.property); @@ -981,8 +981,6 @@ export function parseUnparsedDeclaration( /** * Unparsed shorthand properties need to be parsed at runtime */ - builder.descriptorProperty = property; - if (unparsedRuntimeParsing.has(property)) { const args = parseUnparsed(declaration.value.value, builder, property); @@ -991,6 +989,11 @@ export function parseUnparsedDeclaration( } else { builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); } + } else if (property === "color-scheme") { + builder.addDescriptor( + `--${COLOR_SCHEME_VARIABLE}`, + parseUnparsed(declaration.value.value, builder, property), + ); } else { const value = parseUnparsed(declaration.value.value, builder, property); @@ -1678,7 +1681,10 @@ function nanToZero(value: number): number { return Number.isNaN(value) ? 0 : value; } -export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) { +export function parseColor( + cssColor: CssColor, + builder: StylesheetBuilder, +): StyleDescriptor { if (typeof cssColor === "string") { if (namedColors.has(cssColor)) { return cssColor; @@ -1694,18 +1700,12 @@ export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) { case "currentcolor": return [{}, "var", "__rn-css-color"] as const; case "light-dark": { - const extraRule: StyleRule = { - s: [], - m: [["=", "prefers-color-scheme", "dark"]], - }; - - builder.addUnnamedDescriptor( - parseColor(cssColor.dark, builder), - false, - extraRule, - ); - builder.addExtraRule(extraRule); - return parseColor(cssColor.light, builder); + const light = parseColor(cssColor.light, builder); + const dark = parseColor(cssColor.dark, builder); + + return light === undefined || dark === undefined + ? undefined + : ([{}, LIGHT_DARK_FUNCTION, [light, dark]] as const); } case "rgb": { color = new Color({ @@ -2556,6 +2556,16 @@ export function parseDisplay( } } +function parseColorScheme( + declaration: DeclarationType<"color-scheme">, + builder: StylesheetBuilder, +) { + builder.addDescriptor( + `--${COLOR_SCHEME_VARIABLE}`, + describeColorSchemeSupport(declaration.value), + ); +} + export function parseDirection( declaration: DeclarationType<"direction">, builder: StylesheetBuilder, @@ -2954,16 +2964,22 @@ export function parseUnresolvedColor( ], ]; case "light-dark": { - const extraRule = builder.extendRule({ - m: [["=", "prefers-color-scheme", "dark"]], - }); - builder.addUnnamedDescriptor( - reduceParseUnparsed(color.dark, builder, property, allowAuto), - false, - extraRule, + const light = reduceParseUnparsed( + color.light, + builder, + property, + allowAuto, + ); + const dark = reduceParseUnparsed( + color.dark, + builder, + property, + allowAuto, ); - builder.addExtraRule(extraRule); - return reduceParseUnparsed(color.light, builder, property, allowAuto); + + return light === undefined || dark === undefined + ? undefined + : [{}, LIGHT_DARK_FUNCTION, [light, dark]]; } default: color satisfies never; diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..1d94c001 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -151,6 +151,21 @@ function flattenPart( part.value.arguments = part.value.arguments.flatMap((arg) => { return flattenPart(arg, vars); }); + } else if (part.type === "unresolved-color") { + const color = part.value; + + switch (color.type) { + case "rgb": + case "hsl": + color.alpha = color.alpha.flatMap((arg) => flattenPart(arg, vars)); + break; + case "light-dark": + color.light = color.light.flatMap((arg) => flattenPart(arg, vars)); + color.dark = color.dark.flatMap((arg) => flattenPart(arg, vars)); + break; + default: + color satisfies never; + } } return part; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..99526462 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -2,6 +2,7 @@ import type { SelectorList } from "lightningcss"; import { isStyleDescriptorArray, + LIGHT_DARK_FUNCTION, Specificity, specificityCompareFn, } from "../utilities"; @@ -34,8 +35,6 @@ const staticDeclarations = new WeakMap< Record >(); -const extraRules = new WeakMap[]>(); - const keywords = new Set(["unset"]); export class StylesheetBuilder { @@ -58,7 +57,6 @@ export class StylesheetBuilder { }, // Any default mapping should be included in the @nativeMapping parsing private mapping: StyleRuleMapping = {}, - public descriptorProperty?: string, private shared: { ruleSets: Record; rootVariables?: VariableRecord; @@ -102,7 +100,6 @@ export class StylesheetBuilder { mode, this.cloneRule(), { ...this.mapping }, - this.descriptorProperty, this.shared, selectors, ); @@ -121,25 +118,6 @@ export class StylesheetBuilder { return rule; } - private createRuleFromPartial(rule: StyleRule, partial: Partial) { - rule = this.cloneRule(rule); - - if (partial.m) { - rule.m ??= []; - rule.m.push(...partial.m); - } - - if (partial.d) { - rule.d = partial.d; - } - - return rule; - } - - extendRule(rule: Partial) { - return this.cloneRule({ ...this.rule, ...rule }); - } - getOptions(): CompilerOptions { return this.options; } @@ -267,16 +245,6 @@ export class StylesheetBuilder { this.newRule(mapping, { important }); } - /** Hack for light-dark, which requires adding a new rule without changing the current rule */ - addExtraRule(rule: Partial) { - let extraRuleArray = extraRules.get(this.rule); - if (!extraRuleArray) { - extraRuleArray = []; - extraRules.set(this.rule, extraRuleArray); - } - extraRuleArray.push(rule); - } - private addRuleToRuleSet(name: string, rule = this.rule) { if (this.shared.ruleSets[name]) { this.shared.ruleSets[name].push(rule); @@ -300,18 +268,6 @@ export class StylesheetBuilder { } } - addUnnamedDescriptor( - value: StyleDescriptor, - forceTuple?: boolean, - rule = this.rule, - ) { - if (this.descriptorProperty === undefined) { - return; - } - - this.addDescriptor(this.descriptorProperty, value, forceTuple, rule); - } - addDescriptor( property: string, value: StyleDescriptor, @@ -536,16 +492,6 @@ export class StylesheetBuilder { } this.addRuleToRuleSet(className, rule); - - const extraRulesArray = extraRules.get(this.rule); - if (extraRulesArray) { - for (const extraRule of extraRulesArray) { - this.addRuleToRuleSet( - className, - this.createRuleFromPartial(rule, extraRule), - ); - } - } } else { // These can only have variable declarations if (!this.rule.v) { @@ -617,6 +563,11 @@ function isStyleFunction( ); } +/** Style functions that read a custom property without naming it: `light-dark()` reads the element's colour scheme */ +const IMPLICIT_VARIABLE_READERS: ReadonlySet = new Set([ + LIGHT_DARK_FUNCTION, +]); + function postProcessStyleFunction(value: StyleDescriptor): [ // Should it be delayed boolean, @@ -641,7 +592,8 @@ function postProcessStyleFunction(value: StyleDescriptor): [ let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); - usesVariables ||= value[1] === "var"; + usesVariables ||= + value[1] === "var" || IMPLICIT_VARIABLE_READERS.has(value[1]); shouldDelay ||= value[3] === 1 || usesVariables; if (shouldDelay) { diff --git a/src/native/styles/functions/light-dark.ts b/src/native/styles/functions/light-dark.ts new file mode 100644 index 00000000..e66fadfb --- /dev/null +++ b/src/native/styles/functions/light-dark.ts @@ -0,0 +1,24 @@ +import { + COLOR_SCHEME_VARIABLE, + isStyleDescriptorArray, + resolveUsedColorScheme, +} from "react-native-css/utilities"; + +import { colorScheme } from "../../reactivity"; +import type { StyleFunctionResolver } from "../resolve"; + +/** CSS Color 5 §7: the first colour unless the element's used colour scheme is dark; only the chosen arm is resolved */ +export const lightDark: StyleFunctionResolver = (resolveValue, value, get) => { + const branches = value[2]; + + if (!isStyleDescriptorArray(branches) || branches.length !== 2) { + return; + } + + const usedColorScheme = resolveUsedColorScheme( + resolveValue([{}, "var", COLOR_SCHEME_VARIABLE]), + get(colorScheme), + ); + + return resolveValue(usedColorScheme === "dark" ? branches[1] : branches[0]); +}; diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..ce914ead 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -5,13 +5,17 @@ import type { StyleDescriptor, StyleFunction, } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + LIGHT_DARK_FUNCTION, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; import type { calculateProps } from "./calculate-props"; import { transformKeys } from "./defaults"; import * as functions from "./functions"; +import { lightDark } from "./functions/light-dark"; import { lineHeight } from "./line-height"; import { scaleFactor } from "./scale-factor"; import * as shorthands from "./shorthands"; @@ -40,6 +44,7 @@ export type StyleResolver = ( const functionResolvers = { ...shorthands, ...functions, + [LIGHT_DARK_FUNCTION]: lightDark, lineHeight, em, rem, diff --git a/src/utilities/color-scheme.ts b/src/utilities/color-scheme.ts new file mode 100644 index 00000000..435ee2ee --- /dev/null +++ b/src/utilities/color-scheme.ts @@ -0,0 +1,47 @@ +export const LIGHT_DARK_FUNCTION = "lightDark"; + +/** The custom property an element's `color-scheme` publishes to its subtree */ +export const COLOR_SCHEME_VARIABLE = "__rn-css-color-scheme"; + +export type UsedColorScheme = "light" | "dark"; + +/** CSS Color Adjust 1 §2.2: the schemes a `color-scheme` value supports; `normal` and a custom ident support none */ +export function describeColorSchemeSupport(value: { + readonly light: boolean; + readonly dark: boolean; +}): string { + const supported: UsedColorScheme[] = []; + + if (value.light) { + supported.push("light"); + } + + if (value.dark) { + supported.push("dark"); + } + + return supported.length > 0 ? supported.join(" ") : "normal"; +} + +/** CSS Color Adjust 1 §2.3: the preferred scheme when the element supports it, else the one it supports, else light */ +export function resolveUsedColorScheme( + support: unknown, + preference: unknown, +): UsedColorScheme { + const supported = readSupportedSchemes(support); + const preferred: UsedColorScheme = preference === "dark" ? "dark" : "light"; + + if (supported.includes(preferred)) { + return preferred; + } + + return supported.includes("dark") ? "dark" : "light"; +} + +function readSupportedSchemes(support: unknown): readonly unknown[] { + if (Array.isArray(support)) { + return support; + } + + return typeof support === "string" ? support.split(" ") : []; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..12d13132 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 "./color-scheme";