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
60 changes: 60 additions & 0 deletions src/__tests__/_custom-property-value.types.ts
Original file line number Diff line number Diff line change
@@ -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, 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 shared entry's declarations reach both platforms, so all three entries must agree
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>>>,
];

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>>,
];

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>>,
Expect<
Equal<
Accepts<[Record<never, never>, "var", ["other"]], CustomPropertyValue>,
false
>
>,
Expect<
Equal<
Accepts<
import("react-native-css/compiler").StyleDescriptor,
CustomPropertyValue
>,
false
>
>,
];
37 changes: 37 additions & 0 deletions src/__tests__/native/vars.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<View
testID={testID}
className="my-class"
style={vars({ "--font-variant": ["small-caps"] })}
/>,
);

// 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(
<View
testID={testID}
className="my-class green"
style={vars({ color: undefined })}
/>,
);

// An unset inline value lets the class's --color through
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, 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,
Expand Down Expand Up @@ -120,7 +120,7 @@ export function useNativeVariable(name: string) {
/**
* @deprecated Use `<VariableContextProvider />` instead.
*/
export function vars(variables: Record<string, StyleDescriptor>): ViewStyle {
export function vars(variables: Record<string, CustomPropertyValue>): ViewStyle {
return Object.assign(
{ [VAR_SYMBOL]: "inline" },
Object.fromEntries(
Expand Down
10 changes: 10 additions & 0 deletions src/runtime.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ export type InlineStyle =
| (Record<string, unknown> | 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<string, any> | undefined | null;
Expand Down
60 changes: 42 additions & 18 deletions src/web/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentType<{ style: unknown }>> = {
Expand Down Expand Up @@ -72,34 +76,54 @@ export const colorScheme: ColorScheme = {
},
};

/**
* @deprecated Use `<VariableContextProvider />` instead.
*/
export function vars(variables: Record<string, string | number>) {
const $variables: Record<string, string> = {};
// 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<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