diff --git a/README.md b/README.md index bafb2302..36c21a21 100644 --- a/README.md +++ b/README.md @@ -214,26 +214,28 @@ It is preferable that all CSS variables are set via CSS. If you need values to c } ``` -As a last resort, you can use `VariableContext` to dynamically set CSS variables in JavaScript +As a last resort, you can use `VariableContextProvider` to dynamically set CSS variables in JavaScript ```ts -import { VariableContext } from 'react-native-css'; +import { VariableContextProvider } from 'react-native-css'; export default function App() { return ( - + Hello, world! - + ) } ``` -This API only allows for setting CSS variables as primitive values. For more complex styles, you will need to use a helper CSS class. +A custom property holds a token stream on web and a structured value on native, so this API accepts the values both platforms honour: a string, a number, a boolean, or an array of those (the `CustomPropertyValue` type). + +An array is a comma-separated CSS list — a `font-family` stack, a `transition-property` list. A value whose parts are separated by spaces, such as a `box-shadow`, is a single string. `undefined` leaves the property unset, so an ancestor's value inherits. Anything beyond that needs a helper CSS class. > [!IMPORTANT] -> By using `VariableContext` you may need to disable the `inlineVariable` optimization +> By using `VariableContextProvider` you may need to disable the `inlineVariable` optimization ## Optimizations diff --git a/src/__tests__/_custom-property-value.types.ts b/src/__tests__/_custom-property-value.types.ts new file mode 100644 index 00000000..2c75a5fa --- /dev/null +++ b/src/__tests__/_custom-property-value.types.ts @@ -0,0 +1,60 @@ +// Type-level assertions, run by yarn typecheck; the leading underscore keeps jest off this file +import type { CustomPropertyValue } from "react-native-css"; + +type RootApi = typeof import("react-native-css"); +type NativeApi = typeof import("react-native-css/native"); +type WebApi = typeof import("react-native-css/web"); + +type Equal = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false; +type Expect = T; +type Accepts = [TValue] extends [TTarget] ? true : false; + +type VarsParameter unknown }> = + Parameters[0]; +type ProviderValue< + TApi extends { VariableContextProvider: (props: never) => unknown }, +> = Parameters[0]["value"]; + +// The shared entry's declarations reach both platforms, so all three entries must agree +export type Parity = [ + Expect, Record>>, + Expect, VarsParameter>>, + Expect, VarsParameter>>, + Expect< + Equal, Record<`--${string}`, CustomPropertyValue>> + >, + Expect, ProviderValue>>, + Expect, ProviderValue>>, +]; + +export type Accepted = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +export type Rejected = [ + // `null` is not a CSS value — `undefined` is how a property is left unset. + Expect, false>>, + // An object has no custom-property serialisation. + Expect, false>>, + Expect< + Equal< + Accepts<[Record, "var", ["other"]], CustomPropertyValue>, + false + > + >, + Expect< + Equal< + Accepts< + import("react-native-css/compiler").StyleDescriptor, + CustomPropertyValue + >, + false + > + >, +]; diff --git a/src/__tests__/native/variables.test.tsx b/src/__tests__/native/variables.test.tsx index e61340b5..25ac30f5 100644 --- a/src/__tests__/native/variables.test.tsx +++ b/src/__tests__/native/variables.test.tsx @@ -2,9 +2,12 @@ import { memo, useEffect } from "react"; import type { ViewProps } from "react-native"; import { render, screen } from "@testing-library/react-native"; -import { styled, VariableContextProvider } from "react-native-css"; +import { styled } from "react-native-css"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +// `react-native-css` is the web surface to TypeScript, so the native +// `VariableContextProvider` signature is reached through `/native`. +import { VariableContextProvider } from "react-native-css/native"; test("inline variable", () => { registerCSS(`.my-class { width: var(--my-var); --my-var: 10px; }`); @@ -242,6 +245,83 @@ test("VariableContextProvider", () => { expect(component.props.style).toStrictEqual({ color: "red" }); }); +test("VariableContextProvider ignores an undefined value", () => { + // Two definitions, so the compiler cannot inline `--my-var` and the value is + // read at runtime. A single definition is folded into the declaration and + // never reaches the variable record this test is about. + registerCSS(` + .other { --my-var: blue; } + .other-2 { --my-var: purple; } + .test { color: var(--my-var); } + `); + + render( + + + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "red" }); +}); + +test("VariableContextProvider with an undefined value falls through to :root", () => { + registerCSS(` + :root { --my-var: green; } + .other { --my-var: blue; } + .test { color: var(--my-var); } + `); + + render( + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "green" }); +}); + +test("VariableContextProvider with an undefined value leaves the var() fallback reachable", () => { + // Nothing sets `--my-var` on the ancestor chain, so the fallback is the only + // value `.test` can reach. + registerCSS(` + .other { --my-var: blue; } + .other-2 { --my-var: purple; } + .test { color: var(--my-var, green); } + `); + + render( + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: "green" }); +}); + +test("VariableContextProvider clears a variable with unset", () => { + registerCSS(` + :root { --my-var: green; } + .other { --my-var: blue; } + .test { color: var(--my-var); } + `); + + render( + + + + + , + ); + + const component = screen.getByTestId(testID); + expect(component.props.style).toStrictEqual({ color: undefined }); +}); + test("variable overriding with classes", () => { registerCSS(` :root { diff --git a/src/__tests__/native/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..3eb3b0c9 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -4,6 +4,11 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { vars } from "react-native-css/runtime"; +// `vars()` is platform-split and `react-native-css/runtime` is its web half to +// TypeScript. The case below is about what the native implementation stores, so +// it reaches that implementation directly, as units.test.tsx does. +import { vars as nativeVars } from "../../native/api"; + test("vars", () => { registerCSS( `.my-class { @@ -36,3 +41,51 @@ test("vars", () => { color: "blue", }); }); + +test("vars: an array is a list the declaration consumes", () => { + registerCSS(`.my-class { font-variant-caps: var(--font-variant); }`); + + render( + , + ); + + // fontVariant takes an array, so the list must survive the variable pipeline as a list + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + fontVariant: ["small-caps"], + }); +}); + +test("vars: undefined leaves the property unset", () => { + // Kept, so the runtime resolves the variable the inline entry leaves unset + registerCSS( + ` + .my-class { color: var(--color); } + .green { --color: #123456; } + `, + { inlineVariables: false }, + ); + + render( + , + ); + + // An unset inline value lets the class's --color through + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#123456", + }); +}); + +test("vars does not create a key for an undefined value", () => { + const inline = nativeVars({ "--defined": "red", "--absent": undefined }); + + expect(Object.keys(inline)).toStrictEqual(["defined"]); + expect("absent" in inline).toBe(false); +}); diff --git a/src/__tests__/web/variables.test.tsx b/src/__tests__/web/variables.test.tsx new file mode 100644 index 00000000..be4e2876 --- /dev/null +++ b/src/__tests__/web/variables.test.tsx @@ -0,0 +1,56 @@ +import { View } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; + +import { VariableContextProvider } from "../../web/api"; + +const testID = "react-native-css"; + +/** The custom properties the provider wrote onto its host element's style. */ +const customProperties = ( + value: Parameters[0]["value"], +): unknown => { + render( + + + , + ); + + return screen.root.props.style; +}; + +test("an array is a comma-separated CSS list", () => { + expect(customProperties({ "--font-stack": ["Inter", "Helvetica"] })).toEqual({ + "display": "contents", + "--font-stack": "Inter,Helvetica", + }); +}); + +test("a nested array flattens into the same list", () => { + expect(customProperties({ "--list": [1, ["a", true]] })).toEqual({ + "display": "contents", + "--list": "1,a,true", + }); +}); + +test("undefined leaves the property unset", () => { + expect(customProperties({ "--set": "red", "--unset": undefined })).toEqual({ + "display": "contents", + "--set": "red", + }); +}); + +test("an undefined member drops out of the list", () => { + expect(customProperties({ "--list": ["a", undefined, "b"] })).toEqual({ + "display": "contents", + "--list": "a,b", + }); +}); + +test("scalars serialise to their token form", () => { + expect(customProperties({ "--number": 1, "--boolean": true })).toEqual({ + "display": "contents", + "--number": "1", + "--boolean": "true", + }); +}); diff --git a/src/native-internal/variables.tsx b/src/native-internal/variables.tsx index 8a0f81e0..362dea20 100644 --- a/src/native-internal/variables.tsx +++ b/src/native-internal/variables.tsx @@ -5,9 +5,12 @@ import { type PropsWithChildren, } from "react"; -import type { StyleDescriptor } from "react-native-css/compiler"; - -import { VAR_SYMBOL, type VariableContextValue } from "../native/reactivity"; +import { + toVariableRecord, + VAR_SYMBOL, + type VariableContextValue, +} from "../native/reactivity"; +import type { CustomPropertyValue } from "../runtime.types"; globalThis.__react_native_css_variable_context ??= createContext({ @@ -17,16 +20,16 @@ globalThis.__react_native_css_variable_context ??= export const VariableContext = globalThis.__react_native_css_variable_context; export function VariableContextProvider( - props: PropsWithChildren<{ value: Record<`--${string}`, StyleDescriptor> }>, + props: PropsWithChildren<{ + value: Record<`--${string}`, CustomPropertyValue>; + }>, ) { const inheritedVariables = useContext(VariableContext); const value: VariableContextValue = useMemo( () => ({ ...inheritedVariables, - ...Object.fromEntries( - Object.entries(props.value).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), + ...toVariableRecord(props.value), [VAR_SYMBOL]: true, }), [inheritedVariables, props.value], diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..dc7d5ab5 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -2,11 +2,11 @@ import { useContext, useEffect, useState, type ComponentType } from "react"; import { Appearance, type ViewStyle } from "react-native"; -import type { StyleDescriptor } from "react-native-css/compiler"; import { VariableContext } from "react-native-css/native-internal"; import type { ColorScheme, + CustomPropertyValue, Props, ReactComponent, Styled, @@ -18,6 +18,7 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + toVariableRecord, VAR_SYMBOL, type Effect, type Getter, @@ -120,11 +121,9 @@ export function useNativeVariable(name: string) { /** * @deprecated Use `` instead. */ -export function vars(variables: Record): ViewStyle { +export function vars(variables: Record): ViewStyle { return Object.assign( { [VAR_SYMBOL]: "inline" }, - Object.fromEntries( - Object.entries(variables).map(([k, v]) => [k.replace(/^--/, ""), v]), - ), + toVariableRecord(variables), ) as ViewStyle; } diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..70326490 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -198,6 +198,21 @@ export type VariableContextValue = Record & { [VAR_SYMBOL]: true; }; +// Variable lookup is presence-keyed, so an entry with no value produces no key rather than one that stops the cascade +export function toVariableRecord( + variables: Record, +): Record { + const record: Record = {}; + + for (const [name, value] of Object.entries(variables)) { + if (value !== undefined) { + record[name.replace(/^--/, "")] = value; + } + } + + return record; +} + /** Pseudo Classes ************************************************************/ export const hoverFamily = weakFamily(() => observable(false)); diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..093ef8c9 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -138,6 +138,16 @@ export type InlineStyle = | (Record | undefined | null)[] | (() => unknown); +/******************************* Variables ******************************/ + +// The values both platforms honour: web serialises them to a token stream, an array as a comma-separated list +export type CustomPropertyValue = + | string + | number + | boolean + | undefined + | CustomPropertyValue[]; + /********************************* Misc *********************************/ export type Props = Record | undefined | null; diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..81271877 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -13,7 +13,11 @@ import type { StyledOptions, } from "react-native-css"; -import type { ReactComponent, Styled } from "../runtime.types"; +import type { + CustomPropertyValue, + ReactComponent, + Styled, +} from "../runtime.types"; import { assignStyle } from "./assign-style"; const defaultMapping: StyledConfiguration> = { @@ -72,34 +76,54 @@ export const colorScheme: ColorScheme = { }, }; -/** - * @deprecated Use `` instead. - */ -export function vars(variables: Record) { - const $variables: Record = {}; +// undefined leaves the property unset, and drops out of a list +function serializeCustomProperty( + value: CustomPropertyValue, +): string | undefined { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value)) { + return value + .map((member) => serializeCustomProperty(member)) + .filter((member) => member !== undefined) + .join(","); + } + + return String(value); +} + +function toCustomProperties(variables: Record) { + const properties: Record = {}; for (const [key, value] of Object.entries(variables)) { - if (key.startsWith("--")) { - $variables[key] = value.toString(); - } else { - $variables[`--${key}`] = value.toString(); + const serialized = serializeCustomProperty(value); + + if (serialized !== undefined) { + properties[key.startsWith("--") ? key : `--${key}`] = serialized; } } - return $variables; + + return properties; +} + +/** + * @deprecated Use `` instead. + */ +export function vars(variables: Record) { + return toCustomProperties(variables); } export function VariableContextProvider( - props: PropsWithChildren<{ value: Record<`--${string}`, string | number> }>, + props: PropsWithChildren<{ + value: Record<`--${string}`, CustomPropertyValue>; + }>, ) { const style = useMemo(() => { return { display: "contents", - ...Object.fromEntries( - Object.entries(props.value).map(([key, value]) => [ - key.startsWith("--") ? key : `--${key}`, - value, - ]), - ), + ...toCustomProperties(props.value), }; }, [props.value]);