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";