diff --git a/src/__tests__/native/border.test.tsx b/src/__tests__/native/border.test.tsx new file mode 100644 index 00000000..0b0b967c --- /dev/null +++ b/src/__tests__/native/border.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +describe("border values from runtime variables", () => { + test("width, style and color", () => { + registerCSS( + `.test { + --border: 2px solid #123456; + border: var(--border); + }`, + { inlineVariables: false }, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + borderWidth: 2, + borderStyle: "solid", + borderColor: "#123456", + }); + }); + + test("color", () => { + registerCSS( + `.test { + --color: #123456; + border: 2px solid var(--color); + }`, + { inlineVariables: false }, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + borderWidth: 2, + borderStyle: "solid", + borderColor: "#123456", + }); + }); +}); 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__/vendor/tailwind/_tailwind.tsx b/src/__tests__/vendor/tailwind/_tailwind.tsx index 3ba5db5e..4ae8b6c5 100644 --- a/src/__tests__/vendor/tailwind/_tailwind.tsx +++ b/src/__tests__/vendor/tailwind/_tailwind.tsx @@ -28,6 +28,8 @@ export type NativewindRenderOptions = RenderOptions & { preflight?: boolean; /** Whether to include the plugin in the generated CSS. @default true */ plugin?: boolean; + /** `false` compiles with every variable resolved at runtime @default variables declared once are inlined */ + inlineVariables?: false; /** Enable debug logging. @default false - Set process.env.NATIVEWIND_TEST_AUTO_DEBUG and run tests with the node inspector */ debug?: boolean | "verbose"; }; @@ -45,6 +47,7 @@ export async function render( theme = true, preflight = false, extraCss, + inlineVariables, ...options }: NativewindRenderOptions = {}, ): Promise & ReturnType> { @@ -86,7 +89,10 @@ export async function render( console.log(`Output CSS:\n---\n${output}\n---\n`); } - const compiled = registerCSS(output, { debug: Boolean(debug) }); + const compiled = registerCSS(output, { + debug: Boolean(debug), + inlineVariables, + }); return Object.assign( {}, diff --git a/src/__tests__/vendor/tailwind/effects.test.tsx b/src/__tests__/vendor/tailwind/effects.test.tsx index 2ebb54d8..b8407950 100644 --- a/src/__tests__/vendor/tailwind/effects.test.tsx +++ b/src/__tests__/vendor/tailwind/effects.test.tsx @@ -4,66 +4,130 @@ import { renderCurrentTest } from "./_tailwind"; * TODO: Add tests for full coverage of https://tailwindcss.com/docs/box-shadow */ -describe("Effects - Box Shadow", () => { - test("shadow-sm", async () => { - expect(await renderCurrentTest()).toStrictEqual({ - props: { - style: { - boxShadow: [ - { - blurRadius: 3, - color: "#0000001a", - offsetX: 0, - offsetY: 1, - spreadDistance: 0, - }, - { - blurRadius: 2, - color: "#0000001a", - offsetX: 0, - offsetY: 1, - spreadDistance: -1, - }, - ], +// The runtime's color-mix() writes rgba() where the compiler writes hex +const routes = [ + { variables: "inlined", options: {}, red500: "#fb2c36" }, + { + variables: "resolved at runtime", + options: { inlineVariables: false }, + red500: "rgba(251, 44, 54, 1)", + }, +] as const; + +describe.each(routes)( + "Effects - Box Shadow, variables $variables", + ({ options }) => { + test("shadow-sm", async () => { + expect(await renderCurrentTest(options)).toStrictEqual({ + props: { + style: { + boxShadow: [ + { + blurRadius: 3, + color: "#0000001a", + offsetX: 0, + offsetY: 1, + spreadDistance: 0, + }, + { + blurRadius: 2, + color: "#0000001a", + offsetX: 0, + offsetY: 1, + spreadDistance: -1, + }, + ], + }, }, - }, + }); }); - }); - test("shadow", async () => { - expect(await renderCurrentTest()).toStrictEqual({ - props: { - style: { - boxShadow: [ - { - blurRadius: 3, - color: "#0000001a", - offsetX: 0, - offsetY: 1, - spreadDistance: 0, - }, - { - blurRadius: 2, - color: "#0000001a", - offsetX: 0, - offsetY: 1, - spreadDistance: -1, - }, - ], + test("shadow", async () => { + expect(await renderCurrentTest(options)).toStrictEqual({ + props: { + style: { + boxShadow: [ + { + blurRadius: 3, + color: "#0000001a", + offsetX: 0, + offsetY: 1, + spreadDistance: 0, + }, + { + blurRadius: 2, + color: "#0000001a", + offsetX: 0, + offsetY: 1, + spreadDistance: -1, + }, + ], + }, + }, + }); + }); + test("shadow-none", async () => { + expect(await renderCurrentTest(options)).toStrictEqual({ + props: { + style: { + boxShadow: [], + }, }, - }, + }); + }); + test("inset-shadow-sm", async () => { + expect(await renderCurrentTest(options)).toStrictEqual({ + props: { + style: { + boxShadow: [ + { + blurRadius: 4, + color: "#0000000d", + inset: true, + offsetX: 0, + offsetY: 2, + spreadDistance: 0, + }, + ], + }, + }, + }); }); - }); - test("shadow-none", async () => { - expect(await renderCurrentTest()).toStrictEqual({ - props: { - style: { - boxShadow: [], + }, +); + +describe.each(routes)( + "Effects - Text Shadow, variables $variables", + ({ options, red500 }) => { + test("text-shadow-md", async () => { + expect(await renderCurrentTest(options)).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({ + ...options, + sourceInline: ["text-shadow-md", "text-shadow-red-500"], + }), + ).toStrictEqual({ + props: { + style: { + textShadowColor: red500, + textShadowOffset: { height: 1, width: 0 }, + textShadowRadius: 1, + }, }, - }, + }); }); - }); -}); + }, +); describe.skip("Effects - Box Shadow Color", () => { // TODO 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..1c022f5c 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -1,8 +1,5 @@ /* eslint-disable */ -import { - isStyleDescriptorArray, - isStyleFunction, -} from "react-native-css/utilities"; +import { isStyleDescriptorArray } from "react-native-css/utilities"; import { setDeepPath } from "../../objects"; import { ShortHandSymbol } from "../constants"; @@ -29,7 +26,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) @@ -59,16 +56,6 @@ export function shorthandHandler( return type.includes(value) || type.includes(typeof value); } - // Style functions (var, calc, env, etc.) are unresolved at pattern-match - // time — their actual values won't be known until runtime variable - // resolution. Accepting them in any type slot makes pattern matching - // less strict when variables are involved, but rejecting them would - // break variable-based shadows entirely (e.g. box-shadow: var(--shadow) - // where --shadow resolves to "0 4px 6px -1px #000"). - if (isStyleFunction(value)) { - return true; - } - switch (type) { case "string": case "number": @@ -115,9 +102,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..cd00d6aa --- /dev/null +++ b/src/native/styles/shorthands/_shadow.ts @@ -0,0 +1,117 @@ +import type { StyleDescriptor } from "react-native-css/compiler"; + +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)) { + 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)); +} + +function spliceTokens(tokens: readonly unknown[]): readonly unknown[] { + return tokens.flatMap((token) => + Array.isArray(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..80f62c45 100644 --- a/src/native/styles/shorthands/text-shadow.ts +++ b/src/native/styles/shorthands/text-shadow.ts @@ -1,17 +1,22 @@ -import { shorthandHandler } from "./_handler"; +import { ShortHandSymbol } from "../constants"; +import type { StyleFunctionResolver } from "../resolve"; +import { readShadows, type ShadowGrammar } from "./_shadow"; -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; +const textShadowGrammar: ShadowGrammar = { maxLengths: 4, acceptsInset: false }; -export const textShadow = shorthandHandler( - [ - [width, height, blur, color], - [color, width, height, blur], - [width, height, color], - [color, width, height], - [width, height], - ], - [blur, color], -); +export const textShadow: StyleFunctionResolver = (resolveValue, func) => { + // React Native draws one text shadow, so the first is the one the compiler keeps. + const [shadow] = + readShadows(resolveValue(func[2]), textShadowGrammar, resolveValue) ?? []; + + if (shadow === undefined) { + return; + } + + return { + [ShortHandSymbol]: true, + textShadowOffset: { width: shadow.offsetX, height: shadow.offsetY }, + textShadowRadius: shadow.blurRadius, + textShadowColor: shadow.color, + }; +};