diff --git a/README.md b/README.md index bafb2302..61190bae 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,18 @@ 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 +## Color scheme + +`colorScheme.get()` returns the scheme in effect, and `colorScheme.set()` forces `"dark"` or `"light"`; `colorScheme.set(null)` follows the system again. + +```ts +import { colorScheme } from "react-native-css"; + +colorScheme.set("dark"); +``` + +On native, the forced scheme answers every `prefers-color-scheme` condition. On web no page can override `prefers-color-scheme`, so `set()` puts a `dark` or `light` class on `` and sets its `color-scheme`. Rules keyed on that class, such as a dark variant declared as `@custom-variant dark (&:where(.dark, .dark *))`, and every `light-dark()` value follow it. A rule inside `@media (prefers-color-scheme: dark)` keeps the system's answer. + ## 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/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 76994001..31816364 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -43,3 +43,11 @@ test("@media (hover: hover)", () => { ], }); }); + +test.each(["dark", "light"])("@media (prefers-color-scheme: %s)", (scheme) => { + const rule = compile( + `@media (prefers-color-scheme: ${scheme}) { .my-class { color: red; } }`, + ).stylesheet().s?.[0]?.[1][0]; + + expect(rule?.m).toStrictEqual([["=", "prefers-color-scheme", scheme]]); +}); diff --git a/src/__tests__/metro/global-class-name-polyfill.test.ts b/src/__tests__/metro/global-class-name-polyfill.test.ts new file mode 100644 index 00000000..ddb93ab6 --- /dev/null +++ b/src/__tests__/metro/global-class-name-polyfill.test.ts @@ -0,0 +1,190 @@ +import { resolve, sep } from "node:path"; + +import type { MetroConfig } from "metro-config"; +import type { + CustomResolutionContext, + CustomResolver, + Resolution, +} from "metro-resolver"; + +import { withReactNativeCSS } from "../../metro"; + +// globalClassNamePolyfill decides whether the resolvers run at all; this covers the dispatch into them + +// The resolver reads two fields off the context, so the fixture bridges the rest of ResolutionContext once +function makeContext( + originModulePath: string, + resolveRequest: CustomResolver, +): CustomResolutionContext { + return { + originModulePath, + resolveRequest, + } as unknown as CustomResolutionContext; +} + +function makeRecorder(): { calls: string[]; resolver: CustomResolver } { + const calls: string[] = []; + + const resolver: CustomResolver = (_context, moduleName): Resolution => { + calls.push(moduleName); + return { + type: "sourceFile", + filePath: resolve("/app/node_modules", moduleName, "index.js"), + }; + }; + + return { calls, resolver }; +} + +function makeConfig( + options?: Parameters[1], +): MetroConfig { + return withReactNativeCSS( + {}, + { disableTypeScriptGeneration: true, ...options }, + ); +} + +function resolveThrough( + config: MetroConfig, + moduleName: string, + platform: string | null, + resolver: CustomResolver, +): Resolution { + const resolveRequest = config.resolver?.resolveRequest; + + if (!resolveRequest) { + throw new Error("withReactNativeCSS did not install a resolveRequest"); + } + + return resolveRequest( + makeContext(resolve("/app/index.js"), resolver), + moduleName, + platform, + ); +} + +describe("globalClassNamePolyfill", () => { + test("is off by default, so react-native resolves untouched", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig(), + "react-native", + "ios", + resolver, + ); + + expect(calls).toStrictEqual(["react-native"]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve("/app/node_modules/react-native/index.js"), + }); + }); + + test("routes react-native to the components barrel when on", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig({ globalClassNamePolyfill: true }), + "react-native", + "ios", + resolver, + ); + + expect(calls).toStrictEqual([ + "react-native", + "react-native-css/components", + ]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve( + "/app/node_modules/react-native-css/components/index.js", + ), + }); + }); + + test("dispatches to the web resolver on the web platform when on", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig({ globalClassNamePolyfill: true }), + "react-native-web/dist/exports/View", + "web", + resolver, + ); + + // Only the web resolver rewrites a resolved react-native-web path; the native one keys on the module name + expect(calls).toStrictEqual([ + "react-native-web/dist/exports/View", + "react-native-css/components/View", + ]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve( + "/app/node_modules/react-native-css/components/View/index.js", + ), + }); + }); + + test("leaves the same web module alone when off", () => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig(), + "react-native-web/dist/exports/View", + "web", + resolver, + ); + + expect(calls).toStrictEqual(["react-native-web/dist/exports/View"]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: resolve( + "/app/node_modules/react-native-web/dist/exports/View/index.js", + ), + }); + }); + + test.each([true, false])( + "short-circuits the metro override without consulting the parent (polyfill: %s)", + (globalClassNamePolyfill) => { + const { calls, resolver } = makeRecorder(); + + const resolution = resolveThrough( + makeConfig({ globalClassNamePolyfill }), + "react-native-css-metro-override", + "ios", + resolver, + ); + + expect(calls).toStrictEqual([]); + expect(resolution).toStrictEqual({ + type: "sourceFile", + filePath: expect.stringContaining(`${sep}override.`), + }); + }, + ); + + test("prefers an existing resolveRequest over the one on the context", () => { + const existing = makeRecorder(); + const fromContext = makeRecorder(); + + const config = withReactNativeCSS( + { resolver: { resolveRequest: existing.resolver } }, + { disableTypeScriptGeneration: true, globalClassNamePolyfill: true }, + ); + + resolveThrough(config, "react-native", "ios", fromContext.resolver); + + expect(existing.calls).toStrictEqual([ + "react-native", + "react-native-css/components", + ]); + expect(fromContext.calls).toStrictEqual([]); + }); + + test("appends the css source extension regardless of the gate", () => { + expect(makeConfig().resolver?.sourceExts).toStrictEqual(["css"]); + }); +}); diff --git a/src/__tests__/web/color-scheme-prerender.test.ts b/src/__tests__/web/color-scheme-prerender.test.ts new file mode 100644 index 00000000..7cd190b0 --- /dev/null +++ b/src/__tests__/web/color-scheme-prerender.test.ts @@ -0,0 +1,12 @@ +import { Appearance } from "react-native"; + +import { colorScheme } from "../../web/api"; + +test("with no document, as while a route prerenders, set() is a no-op and get() reads the system scheme", () => { + expect(typeof document).toBe("undefined"); + + expect(() => { + colorScheme.set("dark"); + }).not.toThrow(); + expect(colorScheme.get()).toBe(Appearance.getColorScheme() ?? "light"); +}); diff --git a/src/__tests__/web/color-scheme.test.ts b/src/__tests__/web/color-scheme.test.ts new file mode 100644 index 00000000..2abf45b0 --- /dev/null +++ b/src/__tests__/web/color-scheme.test.ts @@ -0,0 +1,69 @@ +/** + * @jest-environment jsdom + */ +import { Appearance } from "react-native"; + +import { colorScheme } from "../../web/api"; + +const root = () => document.documentElement; + +afterEach(() => { + colorScheme.set(null); + root().removeAttribute("class"); + root().removeAttribute("style"); +}); + +test("react-native-web's Appearance has no setter to forward to", () => { + const { Appearance: WebAppearance } = + jest.requireActual>("react-native-web"); + + expect(WebAppearance).not.toHaveProperty("setColorScheme"); +}); + +test.each(["dark", "light"] as const)( + "set(%s) marks the root with the scheme and its color-scheme", + (scheme) => { + colorScheme.set(scheme); + + expect([...root().classList]).toStrictEqual([scheme]); + expect(root().style.getPropertyValue("color-scheme")).toBe(scheme); + expect(colorScheme.get()).toBe(scheme); + }, +); + +test("a scheme replaces the one set before it", () => { + colorScheme.set("dark"); + colorScheme.set("light"); + + expect([...root().classList]).toStrictEqual(["light"]); + expect(root().style.getPropertyValue("color-scheme")).toBe("light"); +}); + +test.each([null, undefined, "unspecified"] as const)( + "set(%p) returns the root to the system scheme", + (value) => { + colorScheme.set("dark"); + colorScheme.set(value); + + expect([...root().classList]).toStrictEqual([]); + expect(root().style.getPropertyValue("color-scheme")).toBe(""); + expect(colorScheme.get()).toBe(Appearance.getColorScheme() ?? "light"); + }, +); + +test("get() reads a scheme the app put on the root itself", () => { + root().classList.add("dark"); + + expect(colorScheme.get()).toBe("dark"); +}); + +test("the root keeps its other classes and inline styles", () => { + root().classList.add("app"); + root().style.setProperty("font-size", "16px"); + + colorScheme.set("dark"); + colorScheme.set(null); + + expect([...root().classList]).toStrictEqual(["app"]); + expect(root().style.getPropertyValue("font-size")).toBe("16px"); +}); diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..75b5e414 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -63,12 +63,49 @@ export const useCssElement = ( return createElement(component, props); }; +interface RootElement { + readonly classList: { + add(...tokens: string[]): void; + contains(token: string): boolean; + remove(...tokens: string[]): void; + }; + readonly style: { + removeProperty(property: string): string; + setProperty(property: string, value: string): void; + }; +} + +// The package compiles without the DOM lib, and the document is absent while a route prerenders +declare const document: { readonly documentElement: RootElement } | undefined; + +const EXPLICIT_SCHEMES = ["dark", "light"] as const; + +const resolveRootElement = (): RootElement | undefined => + typeof document === "undefined" ? undefined : document.documentElement; + +// No page can override prefers-color-scheme, so an explicit scheme is a class on the root, which Tailwind's class strategy reads, and color-scheme, which light-dark() follows export const colorScheme: ColorScheme = { get() { - return Appearance.getColorScheme(); + const root = resolveRootElement(); + return ( + EXPLICIT_SCHEMES.find((scheme) => root?.classList.contains(scheme)) ?? + Appearance.getColorScheme() ?? + "light" + ); }, set(name) { - Appearance.setColorScheme(name ?? "unspecified"); + const root = resolveRootElement(); + if (!root) { + return; + } + + root.classList.remove(...EXPLICIT_SCHEMES); + if (name === "dark" || name === "light") { + root.classList.add(name); + root.style.setProperty("color-scheme", name); + } else { + root.style.removeProperty("color-scheme"); + } }, };