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/vars.test.tsx b/src/__tests__/native/vars.test.tsx index d87eb7c4..b893a77c 100644 --- a/src/__tests__/native/vars.test.tsx +++ b/src/__tests__/native/vars.test.tsx @@ -36,3 +36,40 @@ 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", () => { + registerCSS(` + .my-class { color: var(--color); } + .green { --color: green; } + `); + + render( + , + ); + + // An unset inline value lets the class's --color through + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#008000", + }); +}); 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..a098135a 100644 --- a/src/native-internal/variables.tsx +++ b/src/native-internal/variables.tsx @@ -5,9 +5,8 @@ import { type PropsWithChildren, } from "react"; -import type { StyleDescriptor } from "react-native-css/compiler"; - import { VAR_SYMBOL, type VariableContextValue } from "../native/reactivity"; +import type { CustomPropertyValue } from "../runtime.types"; globalThis.__react_native_css_variable_context ??= createContext({ @@ -17,7 +16,9 @@ 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); diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..481ba3d8 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, @@ -120,7 +120,7 @@ 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( 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]);