Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<VariableContext values={{ "--my-color": "red" }}>
<VariableContextProvider value={{ "--my-color": "red" }}>
<Text className="my-color-text">
Hello, world!
</Text>
</VariableContext>
</VariableContextProvider>
)
}
```

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

Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/_custom-property-value.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Compile-time assertions for the value a CSS custom property can be given from
* JavaScript. `yarn typecheck` is the runner — there is nothing here to execute,
* which is why the filename is `_`-prefixed and jest skips it.
*
* The planes are reached as module types rather than imported, so the file stays
* type-only under `verbatimModuleSyntax`.
*/
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, Y> = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false;
type Expect<T extends true> = T;
type Accepts<TValue, TTarget> = [TValue] extends [TTarget] ? true : false;

type VarsParameter<TApi extends { vars: (variables: never) => unknown }> =
Parameters<TApi["vars"]>[0];
type ProviderValue<
TApi extends { VariableContextProvider: (props: never) => unknown },
> = Parameters<TApi["VariableContextProvider"]>[0]["value"];

/**
* The parity invariant. `react-native-css` ships one set of declarations to both
* platforms — there is no `moduleSuffixes` or export condition that hands a
* consumer the native ones — so a value the shared entry accepts has to be a
* value both implementations honour. A future divergence fails here.
*/
export type Parity = [
Expect<Equal<VarsParameter<RootApi>, Record<string, CustomPropertyValue>>>,
Expect<Equal<VarsParameter<NativeApi>, VarsParameter<RootApi>>>,
Expect<Equal<VarsParameter<WebApi>, VarsParameter<RootApi>>>,
Expect<
Equal<ProviderValue<RootApi>, Record<`--${string}`, CustomPropertyValue>>
>,
Expect<Equal<ProviderValue<NativeApi>, ProviderValue<RootApi>>>,
Expect<Equal<ProviderValue<WebApi>, ProviderValue<RootApi>>>,
];

/**
* An array is a comma-separated CSS list, alongside the scalars a custom
* property can hold. `undefined` leaves the property unset so an ancestor's
* value inherits.
*/
export type Accepted = [
Expect<Accepts<string, CustomPropertyValue>>,
Expect<Accepts<number, CustomPropertyValue>>,
Expect<Accepts<boolean, CustomPropertyValue>>,
Expect<Accepts<undefined, CustomPropertyValue>>,
Expect<Accepts<string[], CustomPropertyValue>>,
Expect<Accepts<(string | number)[], CustomPropertyValue>>,
Expect<Accepts<(string | string[])[], CustomPropertyValue>>,
];

/**
* A `StyleDescriptor` is wider than a custom property's public value type. Each
* row fails if the type is widened past what both implementations serialise.
*/
export type Rejected = [
// `null` is not a CSS value — `undefined` is how a property is left unset.
Expect<Equal<Accepts<null, CustomPropertyValue>, false>>,
// An object has no custom-property serialisation.
Expect<Equal<Accepts<{ red: 1 }, CustomPropertyValue>, false>>,
// A StyleFunction is the compiler's own encoding of `var()`, `rgba()` and the
// rest. The web implementation has no way to serialise one.
Expect<
Equal<
Accepts<[Record<never, never>, "var", ["other"]], CustomPropertyValue>,
false
>
>,
// The compiler's whole descriptor union — what the native runtime resolves
// internally — is wider than what a caller may hand in.
Expect<
Equal<
Accepts<
import("react-native-css/compiler").StyleDescriptor,
CustomPropertyValue
>,
false
>
>,
];
39 changes: 39 additions & 0 deletions src/__tests__/native/vars.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,42 @@ test("vars", () => {
color: "blue",
});
});

test("vars: an array is a list the declaration consumes", () => {
registerCSS(`.my-class { font-variant-caps: var(--font-variant); }`);

render(
<View
testID={testID}
className="my-class"
style={vars({ "--font-variant": ["small-caps"] })}
/>,
);

// `fontVariant` is one of the React Native style properties that takes an
// array, so the list has to 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(
<View
testID={testID}
className="my-class green"
style={vars({ color: undefined })}
/>,
);

// The class still sets `--color`, so leaving the inline value unset lets it
// through rather than blanking the property.
expect(screen.getByTestId(testID).props.style).toStrictEqual({
color: "#008000",
});
});
56 changes: 56 additions & 0 deletions src/__tests__/web/variables.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof VariableContextProvider>[0]["value"],
): unknown => {
render(
<VariableContextProvider value={value}>
<View testID={testID} />
</VariableContextProvider>,
);

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",
});
});
7 changes: 4 additions & 3 deletions src/native-internal/variables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<VariableContextValue>({
Expand All @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions src/native/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
import { useContext, useState, type ComponentType } from "react";
import { Appearance } 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,
StyledConfiguration,
Expand Down Expand Up @@ -114,7 +114,7 @@ export function useNativeVariable(name: string) {
/**
* @deprecated Use `<VariableContextProvider />` instead.
*/
export function vars(variables: Record<string, StyleDescriptor>) {
export function vars(variables: Record<string, CustomPropertyValue>) {
return Object.assign(
{ [VAR_SYMBOL]: "inline" },
Object.fromEntries(
Expand Down
27 changes: 27 additions & 0 deletions src/runtime.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,33 @@ export type InlineStyle =
| (Record<string, unknown> | undefined | null)[]
| (() => unknown);

/******************************* Variables ******************************/

/**
* A value a CSS custom property can be given from JavaScript, via `vars()` or
* `<VariableContextProvider />`.
*
* Both platforms are written against this one type. A custom property holds a
* token stream on web and a structured value on native, so the type is the set
* of values both can honour:
*
* - An array is a comma-separated CSS list — a `font-family` stack, a
* `transition-property` list. A value whose parts are separated by spaces (a
* `box-shadow`, a `transform`) is a single string.
* - `undefined` leaves the property unset, so an ancestor's value inherits.
*
* A `StyleDescriptor` is wider than this: it also covers the `StyleFunction`
* tuples the compiler emits for `var()`, `rgba()` and friends. Those are an
* internal encoding of the native runtime and have no web serialisation, so
* they are not part of the public API.
*/
export type CustomPropertyValue =
| string
| number
| boolean
| undefined
| CustomPropertyValue[];

/********************************* Misc *********************************/

export type Props = Record<string, any> | undefined | null;
Expand Down
56 changes: 40 additions & 16 deletions src/web/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type {
StyledProps,
} from "react-native-css";

import type { ReactComponent } from "../runtime.types";
import type { CustomPropertyValue, ReactComponent } from "../runtime.types";
import { assignStyle } from "./assign-style";

const defaultMapping: StyledConfiguration<ComponentType<{ style: unknown }>> = {
Expand Down Expand Up @@ -78,33 +78,57 @@ export const colorScheme: ColorScheme = {
};

/**
* @deprecated Use `<VariableContextProvider />` instead.
* Serialises a custom property into the token stream CSS stores it as. An array
* is a comma-separated list; an `undefined` member drops out of that list, and
* an `undefined` value leaves the property unset altogether.
*/
export function vars(variables: Record<string, string | number>) {
const $variables: Record<string, string> = {};
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<string, CustomPropertyValue>) {
const properties: Record<string, string> = {};

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 `<VariableContextProvider />` instead.
*/
export function vars(variables: Record<string, CustomPropertyValue>) {
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]);

Expand Down