diff --git a/src/__tests__/compiler/property.test.ts b/src/__tests__/compiler/property.test.ts new file mode 100644 index 00000000..4b9909cb --- /dev/null +++ b/src/__tests__/compiler/property.test.ts @@ -0,0 +1,191 @@ +import { compile } from "react-native-css/compiler"; + +test("@property with length initial value", () => { + const compiled = compile(` +@property --tw-translate-x { + syntax: ""; + inherits: false; + initial-value: 0px; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + + const vrMap = new Map(result.vr); + expect(vrMap.has("tw-translate-x")).toBe(true); + expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); +}); + +test("@property without initial value is skipped", () => { + const compiled = compile(` +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeUndefined(); +}); + +test("@property with number initial value", () => { + const compiled = compile(` +@property --tw-backdrop-opacity { + syntax: ""; + inherits: false; + initial-value: 1; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + + const vrMap = new Map(result.vr); + expect(vrMap.get("tw-backdrop-opacity")).toStrictEqual([[1]]); +}); + +test("@property with color initial value", () => { + const compiled = compile(` +@property --tw-ring-offset-color { + syntax: ""; + inherits: false; + initial-value: #fff; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + + const vrMap = new Map(result.vr); + expect(vrMap.get("tw-ring-offset-color")).toStrictEqual([["#fff"]]); +}); + +test("@property with token-list initial value (shadow)", () => { + const compiled = compile(` +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + + const vrMap = new Map(result.vr); + expect(vrMap.get("tw-shadow")).toStrictEqual([[[0, 0, "#0000"]]]); +}); + +test("@property defaults are root variables, not universal", () => { + const compiled = compile(` +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + expect(result.vu).toBeUndefined(); +}); + +test("@supports -moz-orient fallback no longer fires", () => { + const compiled = compile(` +@supports (-moz-orient: inline) { + *, ::before, ::after, ::backdrop { + --tw-shadow: 0 0 #0000; + } +} +`); + + const result = compiled.stylesheet(); + expect(result.vu).toBeUndefined(); +}); + +test("@property + class override produces valid stylesheet", () => { + const compiled = compile(` +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + expect(result.s).toBeDefined(); + + const shadowRule = result.s?.find(([name]) => name === "shadow-md"); + expect(shadowRule).toBeDefined(); +}); + +test("@property with percentage initial value", () => { + const compiled = compile(` +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + + const vrMap = new Map(result.vr); + expect(vrMap.get("tw-shadow-alpha")).toStrictEqual([["100%"]]); +}); + +test("multiple @property declarations with verified values", () => { + const compiled = compile(` +@property --tw-translate-x { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-translate-y { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-rotate { + syntax: ""; + inherits: false; + initial-value: 0deg; +} +`); + + const result = compiled.stylesheet(); + expect(result.vr).toBeDefined(); + + const vrMap = new Map(result.vr); + expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]); + expect(vrMap.get("tw-translate-y")).toStrictEqual([[0]]); + expect(vrMap.get("tw-rotate")).toStrictEqual([["0deg"]]); +}); diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx index 48a387a4..65d9c0b4 100644 --- a/src/__tests__/native/box-shadow.test.tsx +++ b/src/__tests__/native/box-shadow.test.tsx @@ -93,3 +93,91 @@ test("shadow values - multiple nested variables", () => { ], }); }); + +test("shadow values from CSS variable are resolved", () => { + registerCSS(` + :root { + --my-shadow: 0 0 0 0 #0000; + } + .test { box-shadow: var(--my-shadow); } + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + boxShadow: [ + { + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 0, + color: "#0000", + }, + ], + }); +}); + +test("@property defaults enable shadow class override", () => { + registerCSS(` + @property --my-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; + } + @property --my-ring { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; + } + + .test { + --my-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + box-shadow: var(--my-ring), var(--my-shadow); + } + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style.boxShadow).toHaveLength(1); + expect(component.props.style.boxShadow[0]).toMatchObject({ + offsetX: 0, + offsetY: 4, + blurRadius: 6, + spreadDistance: -1, + }); +}); + +test("@property defaults with currentcolor (object color)", () => { + registerCSS(` + @property --my-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; + } + @property --my-ring { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; + } + + .test { + --my-ring: 0 0 0 2px currentcolor; + box-shadow: var(--my-shadow), var(--my-ring); + } + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style.boxShadow).toHaveLength(1); + expect(component.props.style.boxShadow[0]).toMatchObject({ + offsetX: 0, + offsetY: 0, + blurRadius: 0, + spreadDistance: 2, + }); + // currentcolor resolves to a platform color object, not a string + expect(typeof component.props.style.boxShadow[0].color).toBe("object"); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index d9172cc2..f97e3fe3 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -7,6 +7,8 @@ import { type MediaQuery as CSSMediaQuery, type CustomAtRules, type MediaRule, + type ParsedComponent, + type PropertyRule, type Rule, type Visitor, } from "lightningcss"; @@ -15,11 +17,19 @@ import { maybeMutateReactNativeOptions, parsePropAtRule } from "./atRules"; import type { CompilerOptions, ContainerQuery, + StyleDescriptor, StyleRuleMapping, UniqueVarInfo, } from "./compiler.types"; import { parseContainerCondition } from "./container-query"; -import { parseDeclaration, round } from "./declarations"; +import { + parseAngle, + parseColor, + parseDeclaration, + parseLength, + reduceParseUnparsed, + round, +} from "./declarations"; import { inlineVariables } from "./inline-variables"; import { extractKeyFrames } from "./keyframes"; import { lightningcssLoader } from "./lightningcss-loader"; @@ -276,13 +286,15 @@ function extractRule( } } break; + case "property": + extractPropertyRule(rule.value, builder); + break; case "custom": case "font-face": case "font-palette-values": case "font-feature-values": case "namespace": case "layer-statement": - case "property": case "view-transition": case "ignored": case "unknown": @@ -377,3 +389,62 @@ function extractContainer( extractRule(rule, builder, mapping); } } + +function extractPropertyRule( + propertyRule: PropertyRule, + builder: StylesheetBuilder, +) { + const { initialValue, name } = propertyRule; + + if (initialValue == null) { + return; + } + + const varName = name.startsWith("--") ? name.slice(2) : name; + const value = parsePropertyInitialValue(initialValue, builder); + + if (value !== undefined) { + builder.addRootVariable(varName, value); + } +} + +function parsePropertyInitialValue( + component: ParsedComponent, + builder: StylesheetBuilder, +): StyleDescriptor { + switch (component.type) { + case "length": + return parseLength(component.value, builder); + case "number": + case "integer": + return round(component.value); + case "percentage": + return `${round(component.value * 100)}%`; + case "color": + return parseColor(component.value, builder); + case "angle": + return parseAngle(component.value, builder); + case "length-percentage": + return parseLength(component.value, builder); + case "token-list": + return reduceParseUnparsed( + component.value, + builder, + "@property", + false, + ); + case "custom-ident": + case "literal": + return component.value; + case "repeated": { + const results = component.value.components + .map((c) => parsePropertyInitialValue(c, builder)) + .filter( + (v): v is NonNullable => v !== undefined, + ); + return results.length === 1 ? results[0] : results; + } + default: + return undefined; + } +} diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 333a154e..a77fdf89 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -577,6 +577,12 @@ export class StylesheetBuilder { this.ruleTemplate.cq.push(query); } + addRootVariable(name: string, value: StyleDescriptor) { + this.shared.rootVariables ??= {}; + this.shared.rootVariables[name] ??= []; + this.shared.rootVariables[name].push([value]); + } + newAnimationFrames(name: string) { this.shared.animations ??= {}; diff --git a/src/compiler/supports.ts b/src/compiler/supports.ts index c3370dcb..bbd74fa7 100644 --- a/src/compiler/supports.ts +++ b/src/compiler/supports.ts @@ -21,8 +21,6 @@ export function supportsConditionValid(condition: SupportsCondition): boolean { } const declarations: Record = { - // We don't actually support this, but its needed for Tailwind CSS - "-moz-orient": ["inline"], // Special text used by TailwindCSS. We should probably change this to all color-mix - "color": ["color-mix(in lab, red, red)"], + color: ["color-mix(in lab, red, red)"], }; diff --git a/src/native/styles/shorthands/_handler.ts b/src/native/styles/shorthands/_handler.ts index 1bc71a14..a5ac1fb1 100644 --- a/src/native/styles/shorthands/_handler.ts +++ b/src/native/styles/shorthands/_handler.ts @@ -1,5 +1,8 @@ /* eslint-disable */ -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + isStyleFunction, +} from "react-native-css/utilities"; import { setDeepPath } from "../../objects"; import { ShortHandSymbol } from "../constants"; @@ -56,6 +59,12 @@ export function shorthandHandler( return type.includes(value) || type.includes(typeof value); } + // Style functions (calc, var, etc.) are unresolved at pattern-match time + // but will resolve to the correct type at runtime, so accept them in any slot. + if (isStyleFunction(value)) { + return true; + } + switch (type) { case "string": case "number": diff --git a/src/native/styles/shorthands/box-shadow.ts b/src/native/styles/shorthands/box-shadow.ts index f65a9370..0018634c 100644 --- a/src/native/styles/shorthands/box-shadow.ts +++ b/src/native/styles/shorthands/box-shadow.ts @@ -4,7 +4,9 @@ import { isStyleDescriptorArray } from "react-native-css/utilities"; import type { StyleFunctionResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; -const color = ["color", "string"] as const; +// "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;