diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx
index 85a809a5..556f3252 100644
--- a/src/__tests__/native/box-shadow.test.tsx
+++ b/src/__tests__/native/box-shadow.test.tsx
@@ -498,6 +498,7 @@ describe("shadow values from runtime variables", () => {
offsetX: 0,
offsetY: 4,
blurRadius: 6,
+ spreadDistance: 0,
color: "#000",
},
]);
@@ -515,13 +516,13 @@ describe("shadow values from runtime variables", () => {
render();
const component = screen.getByTestId(testID);
- // Runtime path has no default color injection (compile-time adds currentcolor)
expect(component.props.style.boxShadow).toStrictEqual([
{
offsetX: 0,
offsetY: 4,
blurRadius: 6,
spreadDistance: -1,
+ color: { semantic: ["label", "labelColor"] },
},
]);
});
@@ -595,7 +596,7 @@ describe("shadow values from runtime variables", () => {
]);
});
- test("inset shadow - without color has broken color parsing (known limitation)", () => {
+ test("inset shadow - without color", () => {
registerCSS(
`.test {
--my-shadow: inset 0 0 10px 5px;
@@ -607,18 +608,14 @@ describe("shadow values from runtime variables", () => {
render();
const component = screen.getByTestId(testID);
- // TODO: Known bug — runtime shorthand handler pattern-matches the flat
- // array and places "inset" string in the color slot. The compile-time
- // path (via lightningcss CSS parser) correctly identifies the inset
- // keyword and inherits platform default color. This only affects inset
- // shadows without an explicit color, which is uncommon in practice.
expect(component.props.style.boxShadow).toStrictEqual([
{
- color: "inset",
+ inset: true,
offsetX: 0,
offsetY: 0,
blurRadius: 10,
spreadDistance: 5,
+ color: { semantic: ["label", "labelColor"] },
},
]);
});
@@ -641,6 +638,7 @@ describe("shadow values from runtime variables", () => {
offsetX: 0,
offsetY: 2,
blurRadius: 4,
+ spreadDistance: 0,
color: "#000",
},
]);
diff --git a/src/__tests__/native/shadow-grammar.test.tsx b/src/__tests__/native/shadow-grammar.test.tsx
new file mode 100644
index 00000000..bb75ff04
--- /dev/null
+++ b/src/__tests__/native/shadow-grammar.test.tsx
@@ -0,0 +1,437 @@
+import { StyleSheet, View } from "react-native";
+
+import { render, screen } from "@testing-library/react-native";
+import { VariableContextProvider } from "react-native-css";
+import { compile } from "react-native-css/compiler";
+import { Text } from "react-native-css/components/Text";
+import { registerCSS, testID } from "react-native-css/jest";
+import { StyleCollection } from "react-native-css/native";
+
+// Every expected value is what Chromium, Firefox and WebKit compute for the same CSS.
+const currentColor = { semantic: ["label", "labelColor"] };
+const navy = "#123456";
+
+interface ShadowCase {
+ readonly spelling: string;
+ readonly variables?: string;
+ readonly expected: unknown;
+}
+
+const offsetVariable = "--offset: 2px 2px;";
+const layersVariable = "--layers: 1px 1px #123456, 2px 2px #654321;";
+
+const expectedBoxShadow = (
+ [offsetX, offsetY, blurRadius, spreadDistance]: readonly [
+ number,
+ number,
+ number,
+ number,
+ ],
+ color: unknown,
+ inset = false,
+) => ({
+ offsetX,
+ offsetY,
+ blurRadius,
+ spreadDistance,
+ color,
+ ...(inset ? { inset } : {}),
+});
+
+const expectedTextShadow = (
+ [width, height, textShadowRadius]: readonly [number, number, number],
+ textShadowColor: unknown,
+) => ({
+ textShadowOffset: { width, height },
+ textShadowRadius,
+ textShadowColor,
+});
+
+const expectedDropShadow = (
+ [offsetX, offsetY, standardDeviation]: readonly [number, number, number],
+ color: unknown,
+) => [{ dropShadow: { offsetX, offsetY, standardDeviation, color } }];
+
+const boxShadowCases: readonly ShadowCase[] = [
+ {
+ spelling: "2px 2px",
+ expected: [expectedBoxShadow([2, 2, 0, 0], currentColor)],
+ },
+ {
+ spelling: "2px 2px 4px",
+ expected: [expectedBoxShadow([2, 2, 4, 0], currentColor)],
+ },
+ {
+ spelling: "2px 2px 4px 1px",
+ expected: [expectedBoxShadow([2, 2, 4, 1], currentColor)],
+ },
+ {
+ spelling: "2px 2px #123456",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy)],
+ },
+ {
+ spelling: "#123456 2px 2px",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy)],
+ },
+ {
+ spelling: "#123456 2px 2px 4px",
+ expected: [expectedBoxShadow([2, 2, 4, 0], navy)],
+ },
+ {
+ spelling: "2px 2px 4px #123456",
+ expected: [expectedBoxShadow([2, 2, 4, 0], navy)],
+ },
+ {
+ spelling: "#123456 2px 2px 4px 1px",
+ expected: [expectedBoxShadow([2, 2, 4, 1], navy)],
+ },
+ {
+ spelling: "2px 2px 4px 1px #123456",
+ expected: [expectedBoxShadow([2, 2, 4, 1], navy)],
+ },
+ {
+ spelling: "inset 2px 2px",
+ expected: [expectedBoxShadow([2, 2, 0, 0], currentColor, true)],
+ },
+ {
+ spelling: "2px 2px inset",
+ expected: [expectedBoxShadow([2, 2, 0, 0], currentColor, true)],
+ },
+ {
+ spelling: "inset 2px 2px #123456",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "2px 2px #123456 inset",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "#123456 inset 2px 2px",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "inset #123456 2px 2px",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "2px 2px inset #123456",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "#123456 2px 2px inset",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "2px 2px 4px 1px inset",
+ expected: [expectedBoxShadow([2, 2, 4, 1], currentColor, true)],
+ },
+ {
+ spelling: "INSET 2px 2px #123456",
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy, true)],
+ },
+ {
+ spelling: "-2px -2px 4px -1px #123456",
+ expected: [expectedBoxShadow([-2, -2, 4, -1], navy)],
+ },
+ {
+ spelling: "0 0",
+ expected: [expectedBoxShadow([0, 0, 0, 0], currentColor)],
+ },
+ {
+ spelling: "2px 2px currentcolor",
+ expected: [expectedBoxShadow([2, 2, 0, 0], currentColor)],
+ },
+ {
+ spelling: "2px 2px #123456, 4px 4px",
+ expected: [
+ expectedBoxShadow([2, 2, 0, 0], navy),
+ expectedBoxShadow([4, 4, 0, 0], currentColor),
+ ],
+ },
+ {
+ spelling: "2px 2px #123456, inset 1px 1px",
+ expected: [
+ expectedBoxShadow([2, 2, 0, 0], navy),
+ expectedBoxShadow([1, 1, 0, 0], currentColor, true),
+ ],
+ },
+ {
+ spelling: "var(--offset) #123456",
+ variables: offsetVariable,
+ expected: [expectedBoxShadow([2, 2, 0, 0], navy)],
+ },
+ {
+ spelling: "var(--layers), 4px 4px",
+ variables: layersVariable,
+ expected: [
+ expectedBoxShadow([1, 1, 0, 0], navy),
+ expectedBoxShadow([2, 2, 0, 0], "#654321"),
+ expectedBoxShadow([4, 4, 0, 0], currentColor),
+ ],
+ },
+ // A variable holding what is not a box shadow leaves none, as browsers make the declaration unset (css-variables-1 §3.1)
+ ...[
+ "2px",
+ "2px 2px 4px 1px 3px",
+ "2px #123456 2px",
+ "2px 2px #123456 #654321",
+ "inset inset 2px 2px",
+ "2px inset 2px",
+ "2px 2px #123456, 3px",
+ ].map((spelling) => ({ spelling, expected: undefined })),
+];
+
+const textShadowCases: readonly ShadowCase[] = [
+ {
+ spelling: "2px 2px",
+ expected: expectedTextShadow([2, 2, 0], currentColor),
+ },
+ {
+ spelling: "2px 2px 4px",
+ expected: expectedTextShadow([2, 2, 4], currentColor),
+ },
+ {
+ spelling: "2px 2px #123456",
+ expected: expectedTextShadow([2, 2, 0], navy),
+ },
+ {
+ spelling: "#123456 2px 2px",
+ expected: expectedTextShadow([2, 2, 0], navy),
+ },
+ {
+ spelling: "#123456 2px 2px 4px",
+ expected: expectedTextShadow([2, 2, 4], navy),
+ },
+ {
+ spelling: "2px 2px 4px #123456",
+ expected: expectedTextShadow([2, 2, 4], navy),
+ },
+ {
+ spelling: "-2px -2px 4px #123456",
+ expected: expectedTextShadow([-2, -2, 4], navy),
+ },
+ {
+ spelling: "2px 2px currentcolor",
+ expected: expectedTextShadow([2, 2, 0], currentColor),
+ },
+ // React Native draws one text shadow, so the compiler keeps the first.
+ {
+ spelling: "2px 2px #123456, 4px 4px",
+ expected: expectedTextShadow([2, 2, 0], navy),
+ },
+ {
+ spelling: "var(--layers), 4px 4px",
+ variables: layersVariable,
+ expected: expectedTextShadow([1, 1, 0], navy),
+ },
+ {
+ spelling: "var(--offset) #123456",
+ variables: offsetVariable,
+ expected: expectedTextShadow([2, 2, 0], navy),
+ },
+];
+
+const dropShadowCases: readonly ShadowCase[] = [
+ {
+ spelling: "2px 2px",
+ expected: expectedDropShadow([2, 2, 0], currentColor),
+ },
+ {
+ spelling: "2px 2px 4px",
+ expected: expectedDropShadow([2, 2, 4], currentColor),
+ },
+ {
+ spelling: "2px 2px #123456",
+ expected: expectedDropShadow([2, 2, 0], navy),
+ },
+ {
+ spelling: "#123456 2px 2px",
+ expected: expectedDropShadow([2, 2, 0], navy),
+ },
+ {
+ spelling: "#123456 2px 2px 4px",
+ expected: expectedDropShadow([2, 2, 4], navy),
+ },
+ {
+ spelling: "2px 2px 4px #123456",
+ expected: expectedDropShadow([2, 2, 4], navy),
+ },
+ {
+ spelling: "-2px -2px 4px #123456",
+ expected: expectedDropShadow([-2, -2, 4], navy),
+ },
+ {
+ spelling: "2px 2px currentcolor",
+ expected: expectedDropShadow([2, 2, 0], currentColor),
+ },
+ {
+ spelling: "var(--offset) #123456",
+ variables: offsetVariable,
+ expected: expectedDropShadow([2, 2, 0], navy),
+ },
+ { spelling: "2px", expected: [] },
+ { spelling: "2px 2px 4px 1px", expected: [] },
+ { spelling: "2px #123456 2px", expected: [] },
+ { spelling: "inset 2px 2px", expected: [] },
+ { spelling: "2px 2px #123456, 4px 4px", expected: [] },
+ {
+ spelling: "var(--layers), 4px 4px",
+ variables: layersVariable,
+ expected: [],
+ },
+];
+
+const readTextShadow = ({
+ textShadowOffset,
+ textShadowRadius,
+ textShadowColor,
+}: Record): unknown => ({
+ textShadowOffset,
+ textShadowRadius,
+ textShadowColor,
+});
+
+const properties = [
+ {
+ property: "box-shadow",
+ earlier: "box-shadow: 9px 9px 0 0 #010203",
+ declare: "box-shadow: var(--shadow)",
+ read: (style: Record): unknown => style.boxShadow,
+ cases: boxShadowCases,
+ },
+ {
+ property: "text-shadow",
+ earlier: "text-shadow: 9px 9px 0 #010203",
+ declare: "text-shadow: var(--shadow)",
+ read: readTextShadow,
+ cases: textShadowCases,
+ },
+ {
+ property: "drop-shadow()",
+ earlier: "filter: drop-shadow(9px 9px 0 #010203)",
+ declare: "filter: drop-shadow(var(--shadow))",
+ read: (style: Record): unknown => style.filter,
+ cases: dropShadowCases,
+ },
+] as const;
+
+// A written value that is not a text shadow is invalid, so browsers ignore the declaration and the earlier rule's shadow stays
+const writtenInvalidTextShadows = [
+ "2px",
+ "#123456",
+ "2px #123456 2px",
+ "inset 2px 2px",
+ "2px 2px #123456 #654321",
+ "2px 2px #123456, 3px",
+] as const;
+
+const routes = [
+ { variables: "inlined", options: {}, sheet: "as compiled" },
+ {
+ variables: "resolved at runtime",
+ options: { inlineVariables: false } as const,
+ sheet: "as compiled",
+ },
+ {
+ variables: "inlined",
+ options: {},
+ sheet: "as the JSON a device receives",
+ },
+ {
+ variables: "resolved at runtime",
+ options: { inlineVariables: false } as const,
+ sheet: "as the JSON a device receives",
+ },
+] as const;
+
+function renderSubject(
+ css: string,
+ { options, sheet }: (typeof routes)[number],
+): Record {
+ const stylesheet = compile(css, options).stylesheet();
+ StyleCollection.inject(
+ sheet === "as compiled"
+ ? stylesheet
+ : (JSON.parse(JSON.stringify(stylesheet)) as typeof stylesheet),
+ );
+
+ render(
+
+
+
+
+ ,
+ );
+
+ return StyleSheet.flatten(screen.getByTestId(testID).props.style) as Record<
+ string,
+ unknown
+ >;
+}
+
+describe.each(routes)(
+ "variables $variables, the sheet injected $sheet",
+ (route) => {
+ describe.each(properties)(
+ "$property",
+ ({ earlier, declare, read, cases }) => {
+ test.each(cases)("$spelling", ({ spelling, variables, expected }) => {
+ const style = renderSubject(
+ `.first { ${earlier}; } .second { ${variables ?? ""} --shadow: ${spelling}; ${declare}; }`,
+ route,
+ );
+
+ expect(read(style)).toStrictEqual(expected);
+ });
+ },
+ );
+
+ test.each(writtenInvalidTextShadows)(
+ "text-shadow written as %s is ignored",
+ (spelling) => {
+ const style = renderSubject(
+ `.first { text-shadow: 9px 9px 0 #010203; } .second { text-shadow: ${spelling}; }`,
+ route,
+ );
+
+ expect(readTextShadow(style)).toStrictEqual(
+ expectedTextShadow([9, 9, 0], "#010203"),
+ );
+ },
+ );
+
+ test("an omitted box shadow colour is the inherited colour", () => {
+ const style = renderSubject(
+ `.parent { color: #0000ff; } .second { --shadow: 2px 2px 4px; box-shadow: var(--shadow); }`,
+ route,
+ );
+
+ expect(style.boxShadow).toStrictEqual([
+ expectedBoxShadow([2, 2, 4, 0], "#00f"),
+ ]);
+ });
+
+ test("an omitted text shadow colour is the inherited colour", () => {
+ const style = renderSubject(
+ `.parent { color: #0000ff; } .second { --shadow: 2px 2px 4px; text-shadow: var(--shadow); }`,
+ route,
+ );
+
+ expect(style).toMatchObject(expectedTextShadow([2, 2, 4], "#00f"));
+ });
+ },
+);
+
+test("a box shadow string set from JavaScript reaches React Native, which reads it", () => {
+ registerCSS(`.subject { box-shadow: var(--shadow); }`);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(testID).props.style.boxShadow).toBe(
+ "2px 2px #123456",
+ );
+});
diff --git a/src/__tests__/native/text-shadow-none.test.tsx b/src/__tests__/native/text-shadow-none.test.tsx
new file mode 100644
index 00000000..7f87463d
--- /dev/null
+++ b/src/__tests__/native/text-shadow-none.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
+import { registerCSS, testID } from "react-native-css/jest";
+
+// Chromium 153, Firefox 155 and WebKit 26.6 compute none wherever this is expected
+const NO_SHADOW = {
+ textShadowColor: "transparent",
+ textShadowOffset: { width: 0, height: 0 },
+ textShadowRadius: 0,
+};
+
+// A second definition keeps --shadow from being inlined, so it resolves at runtime
+const decoy = `.decoy { --shadow: 9px 9px green; }`;
+
+test("a later rule's none removes an earlier rule's shadow", () => {
+ registerCSS(
+ `.shadowed { text-shadow: 1px 1px red; } .plain { text-shadow: none; }`,
+ );
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW);
+});
+
+test("a later rule's shadow outranks an earlier rule's none", () => {
+ registerCSS(
+ `.plain { text-shadow: none; } .shadowed { text-shadow: 1px 1px red; }`,
+ );
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ textShadowColor: "#f00",
+ textShadowOffset: { width: 1, height: 1 },
+ textShadowRadius: 0,
+ });
+});
+
+test("a shadow written over none leaves the next none drawing nothing", () => {
+ registerCSS(
+ `.plain { text-shadow: none; } .shadowed { text-shadow: 1px 1px red; }`,
+ );
+ render();
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW);
+});
+
+test("none on a child outranks its parent's shadow", () => {
+ registerCSS(
+ `.parent { text-shadow: 1px 1px red; } .child { text-shadow: none; }`,
+ );
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW);
+});
+
+test("a variable holding none removes the shadow at runtime", () => {
+ registerCSS(
+ `${decoy} .shadowed { text-shadow: 1px 1px red; } .plain { --shadow: none; text-shadow: var(--shadow); }`,
+ );
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(NO_SHADOW);
+});
diff --git a/src/__tests__/vendor/tailwind/effects.test.tsx b/src/__tests__/vendor/tailwind/effects.test.tsx
index 2ebb54d8..6724f28d 100644
--- a/src/__tests__/vendor/tailwind/effects.test.tsx
+++ b/src/__tests__/vendor/tailwind/effects.test.tsx
@@ -63,6 +63,53 @@ describe("Effects - Box Shadow", () => {
},
});
});
+ test("inset-shadow-sm", async () => {
+ expect(await renderCurrentTest()).toStrictEqual({
+ props: {
+ style: {
+ boxShadow: [
+ {
+ blurRadius: 4,
+ color: "#0000000d",
+ inset: true,
+ offsetX: 0,
+ offsetY: 2,
+ spreadDistance: 0,
+ },
+ ],
+ },
+ },
+ });
+ });
+});
+
+describe("Effects - Text Shadow", () => {
+ test("text-shadow-md", async () => {
+ expect(await renderCurrentTest()).toStrictEqual({
+ props: {
+ style: {
+ textShadowColor: "#0000001a",
+ textShadowOffset: { height: 1, width: 0 },
+ textShadowRadius: 1,
+ },
+ },
+ });
+ });
+ test("text-shadow-md with a text-shadow color", async () => {
+ expect(
+ await renderCurrentTest({
+ sourceInline: ["text-shadow-md", "text-shadow-red-500"],
+ }),
+ ).toStrictEqual({
+ props: {
+ style: {
+ textShadowColor: "#fb2c36",
+ textShadowOffset: { height: 1, width: 0 },
+ textShadowRadius: 1,
+ },
+ },
+ });
+ });
});
describe.skip("Effects - Box Shadow Color", () => {
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 9ca2aab8..2e36517c 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -36,7 +36,7 @@ import type {
UnresolvedColor,
} from "lightningcss";
-import { isStyleFunction } from "../utilities";
+import { isStyleFunction, NO_TEXT_SHADOW } from "../utilities";
import { isMappingProperty } from "./atRules";
import type {
StyleDescriptor,
@@ -986,7 +986,16 @@ export function parseUnparsedDeclaration(
if (unparsedRuntimeParsing.has(property)) {
const args = parseUnparsed(declaration.value.value, builder, property);
- if (property === "animation") {
+ if (property === "text-shadow" && args === "none") {
+ // lightningcss leaves `none` unparsed, and resolved at runtime it would land after a later rule's text shadow
+ builder.addDescriptor("textShadowColor", NO_TEXT_SHADOW.color);
+ builder.addDescriptor("&.textShadowOffset.width", NO_TEXT_SHADOW.offsetX);
+ builder.addDescriptor(
+ "&.textShadowOffset.height",
+ NO_TEXT_SHADOW.offsetY,
+ );
+ builder.addDescriptor("textShadowRadius", NO_TEXT_SHADOW.blurRadius);
+ } else if (property === "animation") {
builder.addDescriptor("animation", [{}, property, args]);
} else {
builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]);
diff --git a/src/native/styles/functions/filters.ts b/src/native/styles/functions/filters.ts
index 4a88a98a..53147c5f 100644
--- a/src/native/styles/functions/filters.ts
+++ b/src/native/styles/functions/filters.ts
@@ -1,7 +1,5 @@
-import { isStyleDescriptorArray } from "react-native-css/utilities";
-
import type { StyleFunctionResolver } from "../resolve";
-import { shorthandHandler } from "../shorthands/_handler";
+import { readShadows, type ShadowGrammar } from "../shorthands/_shadow";
export const blur: StyleFunctionResolver = (resolveValue, value) => {
const args = resolveValue(value[2]);
@@ -66,36 +64,24 @@ export const opacity: StyleFunctionResolver = (resolveValue, value) => {
};
};
-const color = ["color", "color"] as const;
-const offsetX = ["offsetX", "number"] as const;
-const offsetY = ["offsetY", "number"] as const;
-const standardDeviation = ["standardDeviation", "number"] as const;
+const dropShadowGrammar: ShadowGrammar = { maxLengths: 3, acceptsInset: false };
-const handler = shorthandHandler(
- [
- [offsetX, offsetY, standardDeviation, color],
- [color, offsetX, offsetY],
- [color, offsetX, offsetY, standardDeviation],
- [offsetX, offsetY, color],
- [offsetX, offsetY, standardDeviation, color],
- ],
- [],
- "object",
-);
+export const dropShadow: StyleFunctionResolver = (resolveValue, func) => {
+ const [shadow, ...others] =
+ readShadows(resolveValue(func[2]), dropShadowGrammar, resolveValue) ?? [];
-export const dropShadow: StyleFunctionResolver = (
- resolveValue,
- func,
- get,
- options,
-) => {
- const args = resolveValue(func[2]);
+ if (shadow === undefined || others.length > 0) {
+ return;
+ }
- return isStyleDescriptorArray(args)
- ? {
- dropShadow: handler(resolveValue, args, get, options),
- }
- : undefined;
+ return {
+ dropShadow: {
+ offsetX: shadow.offsetX,
+ offsetY: shadow.offsetY,
+ standardDeviation: shadow.blurRadius,
+ color: shadow.color,
+ },
+ };
};
/** CSS variables may expand to multiple filters; native filter arrays must be flat. */
diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts
index b8147d1a..019fa6bc 100644
--- a/src/native/styles/shorthands/_handler.ts
+++ b/src/native/styles/shorthands/_handler.ts
@@ -29,7 +29,7 @@ type ShorthandDefaultValue = readonly [
export function shorthandHandler(
mappings: ShorthandRequiredValue[][],
defaults: ShorthandDefaultValue[],
- returnType: "shorthandObject" | "tuples" | "object" = "shorthandObject",
+ returnType: "shorthandObject" | "tuples" = "shorthandObject",
): StyleResolver {
return (resolve, value, __, { castToArray }) => {
let args = isStyleDescriptorArray(value)
@@ -115,9 +115,8 @@ export function shorthandHandler(
),
];
- if (returnType === "shorthandObject" || returnType === "object") {
- const target: Record =
- returnType === "shorthandObject" ? { [ShortHandSymbol]: true } : {};
+ if (returnType === "shorthandObject") {
+ const target: Record = { [ShortHandSymbol]: true };
for (const [value, prop] of tuples) {
if (typeof prop === "string") {
diff --git a/src/native/styles/shorthands/_shadow.ts b/src/native/styles/shorthands/_shadow.ts
new file mode 100644
index 00000000..6cc0bc4e
--- /dev/null
+++ b/src/native/styles/shorthands/_shadow.ts
@@ -0,0 +1,123 @@
+import type { StyleDescriptor } from "react-native-css/compiler";
+import { isStyleFunction } from "react-native-css/utilities";
+
+import type { SimpleResolveValue } from "../resolve";
+
+const currentColor: StyleDescriptor = [{}, "var", "__rn-css-color"];
+
+/** What one shadow property accepts, as lightningcss parses it for the compiler. */
+export interface ShadowGrammar {
+ readonly maxLengths: 3 | 4;
+ readonly acceptsInset: boolean;
+}
+
+export interface Shadow {
+ readonly offsetX: number;
+ readonly offsetY: number;
+ readonly blurRadius: number;
+ readonly spreadDistance: number;
+ readonly color: unknown;
+ readonly inset: boolean;
+}
+
+/** Every shadow in a resolved list, or `undefined` when one is not a shadow, with the compiler's defaults for what it omits. */
+export function readShadows(
+ value: unknown,
+ grammar: ShadowGrammar,
+ resolveValue: SimpleResolveValue,
+): readonly Shadow[] | undefined {
+ const shadows: Shadow[] = [];
+
+ for (const tokens of readCommaGroups(value)) {
+ const shadow = readShadow(tokens, grammar, resolveValue);
+
+ if (shadow === undefined) {
+ return undefined;
+ }
+
+ shadows.push(shadow);
+ }
+
+ return shadows;
+}
+
+/** A `var()` that resolved to a list stands for its groups, and one that resolved to part of a shadow is spliced into it. */
+function readCommaGroups(value: unknown): readonly (readonly unknown[])[] {
+ if (!Array.isArray(value) || isStyleFunction(value)) {
+ return [[value]];
+ }
+
+ return isGroupList(value)
+ ? value.flatMap(readCommaGroups)
+ : [spliceTokens(value)];
+}
+
+function isGroupList(value: readonly unknown[]): boolean {
+ return (
+ value.length > 0 &&
+ value.every((entry) => Array.isArray(entry) && !isStyleFunction(entry))
+ );
+}
+
+function spliceTokens(tokens: readonly unknown[]): readonly unknown[] {
+ return tokens.flatMap((token) =>
+ Array.isArray(token) && !isStyleFunction(token)
+ ? spliceTokens(token)
+ : [token],
+ );
+}
+
+function readShadow(
+ tokens: readonly unknown[],
+ grammar: ShadowGrammar,
+ resolveValue: SimpleResolveValue,
+): Shadow | undefined {
+ const lengths: number[] = [];
+ let lengthsEnded = false;
+ let color: unknown;
+ let inset = false;
+
+ for (const token of tokens) {
+ if (typeof token === "number") {
+ if (lengthsEnded || lengths.length === grammar.maxLengths) {
+ return undefined;
+ }
+
+ lengths.push(token);
+ continue;
+ }
+
+ lengthsEnded = lengths.length > 0;
+
+ if (isInsetKeyword(token)) {
+ if (!grammar.acceptsInset || inset) {
+ return undefined;
+ }
+
+ inset = true;
+ } else if (token === undefined || token === null || color !== undefined) {
+ return undefined;
+ } else {
+ color = token;
+ }
+ }
+
+ const [offsetX, offsetY, blurRadius = 0, spreadDistance = 0] = lengths;
+
+ if (offsetX === undefined || offsetY === undefined) {
+ return undefined;
+ }
+
+ return {
+ offsetX,
+ offsetY,
+ blurRadius,
+ spreadDistance,
+ color: color ?? resolveValue(currentColor),
+ inset,
+ };
+}
+
+function isInsetKeyword(token: unknown): boolean {
+ return typeof token === "string" && token.toLowerCase() === "inset";
+}
diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts
index 20936e49..733b19b8 100644
--- a/src/native/styles/shorthands/box-shadow.ts
+++ b/src/native/styles/shorthands/box-shadow.ts
@@ -1,106 +1,30 @@
-import type { StyleDescriptor } from "react-native-css/compiler";
-import { isStyleDescriptorArray } from "react-native-css/utilities";
-
import type { StyleFunctionResolver } from "../resolve";
-import { shorthandHandler } from "./_handler";
-
-// "color" type accepts both strings ("#fff") and objects (PlatformColor from currentcolor).
-// Using "string" here would reject platform color objects and silently drop the shadow.
-const color = ["color", "color"] as const;
-const offsetX = ["offsetX", "number"] as const;
-const offsetY = ["offsetY", "number"] as const;
-const blurRadius = ["blurRadius", "number"] as const;
-const spreadDistance = ["spreadDistance", "number"] 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
- [inset, offsetX, offsetY, blurRadius, spreadDistance],
- [inset, offsetX, offsetY, blurRadius, spreadDistance, color],
- [inset, offsetX, offsetY, blurRadius, color],
- [inset, color, offsetX, offsetY, blurRadius, spreadDistance],
- ],
- [],
- "object",
-);
+import { readShadows, type Shadow, type ShadowGrammar } from "./_shadow";
-export const boxShadow: StyleFunctionResolver = (
- resolveValue,
- func,
- get,
- options,
-) => {
- const args = resolveValue(func[2]);
+const boxShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: true };
- if (!isStyleDescriptorArray(args)) {
- return args;
- }
+export const boxShadow: StyleFunctionResolver = (resolveValue, func) => {
+ const value: unknown = resolveValue(func[2]);
- // A flat array of primitives (e.g. [0, 4, 6, -1, "#000"]) is a single shadow
- // resolved from a runtime variable. Pass it directly to the pattern handler.
- // A nested array (e.g. [[0, 4, 6, -1, "#000"], [...]]) is multiple shadows.
- if (args.length > 0 && !Array.isArray(args[0])) {
- const result = handler(resolveValue, args, get, options);
- if (result === undefined) {
- return [];
- }
- const filtered = omitTransparentShadows(result);
- return filtered !== undefined ? [normalizeInsetValue(filtered)] : [];
+ // React Native reads a string itself, `none` included.
+ if (value === undefined || value === null || typeof value === "string") {
+ return value;
}
- return args
- .flatMap(flattenShadowDescriptor)
- .map((shadows) => {
- if (shadows === undefined) {
- return;
- } else {
- return normalizeInsetValue(
- omitTransparentShadows(handler(resolveValue, shadows, get, options)),
- );
- }
- })
- .filter((v) => v !== undefined);
-};
+ const shadows = readShadows(value, boxShadowGrammar, resolveValue);
-function flattenShadowDescriptor(arg: StyleDescriptor): StyleDescriptor[] {
- if (isStyleDescriptorArray(arg) && isStyleDescriptorArray(arg[0])) {
- return arg.map((arg) => {
- return flattenShadowDescriptor(arg);
- });
+ if (shadows === undefined) {
+ return;
}
- return [arg];
-}
-
-function omitTransparentShadows(style: unknown) {
- if (typeof style === "object" && style && "color" in style) {
- if (style.color === "#0000" || style.color === "transparent") {
- return;
- }
- }
+ return shadows.filter((shadow) => !isTransparent(shadow)).map(toBoxShadow);
+};
- return style;
+/** Tailwind's unset shadow layers hold `0 0 #0000`, which React Native would still draw as a native shadow. */
+function isTransparent({ color }: Shadow): boolean {
+ return color === "#0000" || color === "transparent";
}
-/**
- * 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;
+function toBoxShadow({ inset, ...shadow }: Shadow) {
+ return inset ? { ...shadow, inset } : shadow;
}
diff --git a/src/native/styles/shorthands/text-shadow.ts b/src/native/styles/shorthands/text-shadow.ts
index a3eb61cd..c3cc9e45 100644
--- a/src/native/styles/shorthands/text-shadow.ts
+++ b/src/native/styles/shorthands/text-shadow.ts
@@ -1,17 +1,28 @@
-import { shorthandHandler } from "./_handler";
+import { NO_TEXT_SHADOW } from "react-native-css/utilities";
-const width = [["textShadowOffset", "width"], "number"] as const;
-const height = [["textShadowOffset", "height"], "number"] as const;
-const blur = ["textShadowRadius", "number", "textShadowRadius"] as const;
-const color = ["textShadowColor", "color", "color"] as const;
+import { ShortHandSymbol } from "../constants";
+import type { StyleFunctionResolver } from "../resolve";
+import { readShadows, type ShadowGrammar } from "./_shadow";
-export const textShadow = shorthandHandler(
- [
- [width, height, blur, color],
- [color, width, height, blur],
- [width, height, color],
- [color, width, height],
- [width, height],
- ],
- [blur, color],
-);
+const textShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: false };
+
+export const textShadow: StyleFunctionResolver = (resolveValue, func) => {
+ const value = resolveValue(func[2]);
+
+ // React Native draws one text shadow, so the first is the one the compiler keeps.
+ const shadow =
+ value === "none"
+ ? NO_TEXT_SHADOW
+ : readShadows(value, textShadowGrammar, resolveValue)?.[0];
+
+ if (shadow === undefined) {
+ return;
+ }
+
+ return {
+ [ShortHandSymbol]: true,
+ textShadowOffset: { width: shadow.offsetX, height: shadow.offsetY },
+ textShadowRadius: shadow.blurRadius,
+ textShadowColor: shadow.color,
+ };
+};
diff --git a/src/utilities/index.ts b/src/utilities/index.ts
index 0c95da63..24a7645b 100644
--- a/src/utilities/index.ts
+++ b/src/utilities/index.ts
@@ -1,3 +1,4 @@
export * from "./specificity";
export * from "./style-descriptor";
export * from "./dot-notation.types";
+export * from "./text-shadow";
diff --git a/src/utilities/text-shadow.ts b/src/utilities/text-shadow.ts
new file mode 100644
index 00000000..c384eb38
--- /dev/null
+++ b/src/utilities/text-shadow.ts
@@ -0,0 +1,7 @@
+/** React Native has no `text-shadow: none`; a transparent shadow with no offset or blur draws nothing, and outranks one a parent Text passes down. */
+export const NO_TEXT_SHADOW = {
+ color: "transparent",
+ offsetX: 0,
+ offsetY: 0,
+ blurRadius: 0,
+} as const;