From aee4d5dea66e40933386ffe8cb3a542ad48dff71 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 11 Jan 2026 19:19:08 +0200 Subject: [PATCH] feat: add Tailwind CSS v4 shadow support and inset shadows This PR adds two features for better Tailwind CSS v4 compatibility: 1. **Tailwind CSS v4 shadow variable defaults** - Tailwind v4 uses @property to define initial-value for shadow CSS variables, but react-native-css doesn't support @property - Added default values for tw-shadow, tw-inset-shadow, tw-ring-shadow, etc. to prevent "undefined variable" errors 2. **Inset shadow parsing support** - Added pattern matching for "inset" keyword in box-shadow values - Converts inset: "inset" to inset: true for React Native's boxShadow - Supports patterns: inset [color] --- src/__tests__/native/box-shadow.test.tsx | 112 +++++++++++++++++++++ src/native-internal/root.ts | 23 +++++ src/native/styles/shorthands/box-shadow.ts | 31 +++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx index 48a387a..c656e19 100644 --- a/src/__tests__/native/box-shadow.test.tsx +++ b/src/__tests__/native/box-shadow.test.tsx @@ -93,3 +93,115 @@ test("shadow values - multiple nested variables", () => { ], }); }); + +test("inset shadow - basic", () => { + registerCSS(` + .test { box-shadow: inset 0 2px 4px 0 #000; } + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + boxShadow: [ + { + inset: true, + offsetX: 0, + offsetY: 2, + blurRadius: 4, + spreadDistance: 0, + color: "#000", + }, + ], + }); +}); + +test("inset shadow - with color first", () => { + registerCSS(` + .test { box-shadow: inset #fb2c36 0 0 24px 0; } + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + boxShadow: [ + { + inset: true, + color: "#fb2c36", + offsetX: 0, + offsetY: 0, + blurRadius: 24, + spreadDistance: 0, + }, + ], + }); +}); + +test("inset shadow - without color inherits default", () => { + registerCSS(` + .test { box-shadow: inset 0 0 10px 5px; } + `); + + render(); + const component = screen.getByTestId(testID); + + // Shadows without explicit color inherit the default text color (__rn-css-color) + expect(component.props.style.boxShadow).toHaveLength(1); + expect(component.props.style.boxShadow[0]).toMatchObject({ + inset: true, + offsetX: 0, + offsetY: 0, + blurRadius: 10, + spreadDistance: 5, + }); + // Color is inherited from platform default (PlatformColor) + expect(component.props.style.boxShadow[0].color).toBeDefined(); +}); + +test("mixed inset and regular shadows", () => { + registerCSS(` + .test { box-shadow: 0 4px 6px -1px #000, inset 0 2px 4px 0 #fff; } + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 4, + blurRadius: 6, + spreadDistance: -1, + color: "#000", + }, + { + inset: true, + offsetX: 0, + offsetY: 2, + blurRadius: 4, + spreadDistance: 0, + color: "#fff", + }, + ], + }); +}); + +test("Tailwind v4 shadow variables - transparent color #0000", () => { + // Tailwind v4 uses --tw-shadow etc with @property initial-value of transparent + registerCSS(` + :root { + --tw-shadow: 0 0 0 0 #0000; + } + .test { box-shadow: var(--tw-shadow); } + `); + + render(); + const component = screen.getByTestId(testID); + + // The shadow is parsed correctly with #0000 color + // Note: filtering of transparent shadows happens in omitTransparentShadows + // which checks for exact "#0000" or "transparent" strings + expect(component.props.style.boxShadow).toBeDefined(); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d1..792e329 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -42,3 +42,26 @@ rootVariables("__rn-css-color").set([ ], // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any); + +/** + * Tailwind CSS v4 shadow variable defaults. + * + * Tailwind v4 uses @property to define initial-value for shadow CSS variables, + * but react-native-css doesn't support @property declarations. + * + * These provide fallback values that match Tailwind's defaults: + * - Transparent shadows (0 0 0 0 #0000) are filtered out by omitTransparentShadows + * - This prevents "undefined variable" errors when shadow utilities are used + * + * @see https://github.com/tailwindlabs/tailwindcss/discussions/16772 + */ +// VariableValue[] where each VariableValue is [StyleDescriptor] tuple +// The inner [0, 0, 0, 0, "#0000"] is a StyleDescriptor[] (shadow values) +const transparentShadow: VariableValue[] = [[[0, 0, 0, 0, "#0000"]]]; +rootVariables("tw-shadow").set(transparentShadow); +rootVariables("tw-shadow-color").set([["initial"]]); +rootVariables("tw-inset-shadow").set(transparentShadow); +rootVariables("tw-inset-shadow-color").set([["initial"]]); +rootVariables("tw-ring-shadow").set(transparentShadow); +rootVariables("tw-inset-ring-shadow").set(transparentShadow); +rootVariables("tw-ring-offset-shadow").set(transparentShadow); diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index f65a937..9971e05 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -9,16 +9,25 @@ const offsetX = ["offsetX", "number"] as const; const offsetY = ["offsetY", "number"] as const; const blurRadius = ["blurRadius", "number"] as const; const spreadDistance = ["spreadDistance", "number"] as const; -// const inset = ["inset", "string"] as const; +// Match the literal string "inset" - the array type checks if value is in array +const inset = ["inset", ["inset"]] as const; const handler = shorthandHandler( [ + // Standard patterns (without inset) [offsetX, offsetY, blurRadius, spreadDistance], [offsetX, offsetY, blurRadius, spreadDistance, color], [color, offsetX, offsetY], [color, offsetX, offsetY, blurRadius, spreadDistance], [offsetX, offsetY, color], [offsetX, offsetY, blurRadius, color], + // Inset patterns - "inset" keyword at the beginning + // Matches: inset + [inset, offsetX, offsetY, blurRadius, spreadDistance], + // Matches: inset + [inset, offsetX, offsetY, blurRadius, spreadDistance, color], + // Matches: inset + [inset, color, offsetX, offsetY, blurRadius, spreadDistance], ], [], "object", @@ -41,8 +50,10 @@ export const boxShadow: StyleFunctionResolver = ( if (shadows === undefined) { return; } else { - return omitTransparentShadows( - handler(resolveValue, shadows, get, options), + return normalizeInsetValue( + omitTransparentShadows( + handler(resolveValue, shadows, get, options), + ), ); } }) @@ -69,3 +80,17 @@ function omitTransparentShadows(style: unknown) { return style; } + +/** + * Convert inset: "inset" to inset: true for React Native boxShadow. + * + * The shorthand handler matches the literal "inset" string and assigns it as the value. + * React Native's boxShadow expects inset to be a boolean. + */ +function normalizeInsetValue(style: unknown) { + if (typeof style === "object" && style && "inset" in style) { + return { ...style, inset: true }; + } + + return style; +}