Skip to content

Commit aad4e3a

Browse files
feat: add CSS @Property rule support
1 parent 930095f commit aad4e3a

File tree

7 files changed

+372
-7
lines changed

7 files changed

+372
-7
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { compile } from "react-native-css/compiler";
2+
3+
test("@property with length initial value", () => {
4+
const compiled = compile(`
5+
@property --tw-translate-x {
6+
syntax: "<length-percentage>";
7+
inherits: false;
8+
initial-value: 0px;
9+
}
10+
`);
11+
12+
const result = compiled.stylesheet();
13+
expect(result.vr).toBeDefined();
14+
15+
const vrMap = new Map(result.vr);
16+
expect(vrMap.has("tw-translate-x")).toBe(true);
17+
expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]);
18+
});
19+
20+
test("@property without initial value is skipped", () => {
21+
const compiled = compile(`
22+
@property --tw-ring-color {
23+
syntax: "*";
24+
inherits: false;
25+
}
26+
`);
27+
28+
const result = compiled.stylesheet();
29+
expect(result.vr).toBeUndefined();
30+
});
31+
32+
test("@property with number initial value", () => {
33+
const compiled = compile(`
34+
@property --tw-backdrop-opacity {
35+
syntax: "<number>";
36+
inherits: false;
37+
initial-value: 1;
38+
}
39+
`);
40+
41+
const result = compiled.stylesheet();
42+
expect(result.vr).toBeDefined();
43+
44+
const vrMap = new Map(result.vr);
45+
expect(vrMap.get("tw-backdrop-opacity")).toStrictEqual([[1]]);
46+
});
47+
48+
test("@property with color initial value", () => {
49+
const compiled = compile(`
50+
@property --tw-ring-offset-color {
51+
syntax: "<color>";
52+
inherits: false;
53+
initial-value: #fff;
54+
}
55+
`);
56+
57+
const result = compiled.stylesheet();
58+
expect(result.vr).toBeDefined();
59+
60+
const vrMap = new Map(result.vr);
61+
expect(vrMap.get("tw-ring-offset-color")).toStrictEqual([["#fff"]]);
62+
});
63+
64+
test("@property with token-list initial value (shadow)", () => {
65+
const compiled = compile(`
66+
@property --tw-shadow {
67+
syntax: "*";
68+
inherits: false;
69+
initial-value: 0 0 #0000;
70+
}
71+
`);
72+
73+
const result = compiled.stylesheet();
74+
expect(result.vr).toBeDefined();
75+
76+
const vrMap = new Map(result.vr);
77+
expect(vrMap.get("tw-shadow")).toStrictEqual([[[0, 0, "#0000"]]]);
78+
});
79+
80+
test("@property defaults are root variables, not universal", () => {
81+
const compiled = compile(`
82+
@property --tw-shadow {
83+
syntax: "*";
84+
inherits: false;
85+
initial-value: 0 0 #0000;
86+
}
87+
`);
88+
89+
const result = compiled.stylesheet();
90+
expect(result.vr).toBeDefined();
91+
expect(result.vu).toBeUndefined();
92+
});
93+
94+
test("@supports -moz-orient fallback no longer fires", () => {
95+
const compiled = compile(`
96+
@supports (-moz-orient: inline) {
97+
*, ::before, ::after, ::backdrop {
98+
--tw-shadow: 0 0 #0000;
99+
}
100+
}
101+
`);
102+
103+
const result = compiled.stylesheet();
104+
expect(result.vu).toBeUndefined();
105+
});
106+
107+
test("@property + class override produces valid stylesheet", () => {
108+
const compiled = compile(`
109+
@property --tw-shadow {
110+
syntax: "*";
111+
inherits: false;
112+
initial-value: 0 0 #0000;
113+
}
114+
@property --tw-inset-shadow {
115+
syntax: "*";
116+
inherits: false;
117+
initial-value: 0 0 #0000;
118+
}
119+
@property --tw-ring-shadow {
120+
syntax: "*";
121+
inherits: false;
122+
initial-value: 0 0 #0000;
123+
}
124+
@property --tw-inset-ring-shadow {
125+
syntax: "*";
126+
inherits: false;
127+
initial-value: 0 0 #0000;
128+
}
129+
@property --tw-ring-offset-shadow {
130+
syntax: "*";
131+
inherits: false;
132+
initial-value: 0 0 #0000;
133+
}
134+
135+
.shadow-md {
136+
--tw-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
137+
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
138+
}
139+
`);
140+
141+
const result = compiled.stylesheet();
142+
expect(result.vr).toBeDefined();
143+
expect(result.s).toBeDefined();
144+
145+
const shadowRule = result.s?.find(([name]) => name === "shadow-md");
146+
expect(shadowRule).toBeDefined();
147+
});
148+
149+
test("@property with percentage initial value", () => {
150+
const compiled = compile(`
151+
@property --tw-shadow-alpha {
152+
syntax: "<percentage>";
153+
inherits: false;
154+
initial-value: 100%;
155+
}
156+
`);
157+
158+
const result = compiled.stylesheet();
159+
expect(result.vr).toBeDefined();
160+
161+
const vrMap = new Map(result.vr);
162+
expect(vrMap.get("tw-shadow-alpha")).toStrictEqual([["100%"]]);
163+
});
164+
165+
test("multiple @property declarations with verified values", () => {
166+
const compiled = compile(`
167+
@property --tw-translate-x {
168+
syntax: "<length-percentage>";
169+
inherits: false;
170+
initial-value: 0px;
171+
}
172+
@property --tw-translate-y {
173+
syntax: "<length-percentage>";
174+
inherits: false;
175+
initial-value: 0px;
176+
}
177+
@property --tw-rotate {
178+
syntax: "<angle>";
179+
inherits: false;
180+
initial-value: 0deg;
181+
}
182+
`);
183+
184+
const result = compiled.stylesheet();
185+
expect(result.vr).toBeDefined();
186+
187+
const vrMap = new Map(result.vr);
188+
expect(vrMap.get("tw-translate-x")).toStrictEqual([[0]]);
189+
expect(vrMap.get("tw-translate-y")).toStrictEqual([[0]]);
190+
expect(vrMap.get("tw-rotate")).toStrictEqual([["0deg"]]);
191+
});

src/__tests__/native/box-shadow.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,91 @@ test("shadow values - multiple nested variables", () => {
9393
],
9494
});
9595
});
96+
97+
test("shadow values from CSS variable are resolved", () => {
98+
registerCSS(`
99+
:root {
100+
--my-shadow: 0 0 0 0 #0000;
101+
}
102+
.test { box-shadow: var(--my-shadow); }
103+
`);
104+
105+
render(<View testID={testID} className="test" />);
106+
const component = screen.getByTestId(testID);
107+
108+
expect(component.props.style).toStrictEqual({
109+
boxShadow: [
110+
{
111+
offsetX: 0,
112+
offsetY: 0,
113+
blurRadius: 0,
114+
spreadDistance: 0,
115+
color: "#0000",
116+
},
117+
],
118+
});
119+
});
120+
121+
test("@property defaults enable shadow class override", () => {
122+
registerCSS(`
123+
@property --my-shadow {
124+
syntax: "*";
125+
inherits: false;
126+
initial-value: 0 0 #0000;
127+
}
128+
@property --my-ring {
129+
syntax: "*";
130+
inherits: false;
131+
initial-value: 0 0 #0000;
132+
}
133+
134+
.test {
135+
--my-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
136+
box-shadow: var(--my-ring), var(--my-shadow);
137+
}
138+
`);
139+
140+
render(<View testID={testID} className="test" />);
141+
const component = screen.getByTestId(testID);
142+
143+
expect(component.props.style.boxShadow).toHaveLength(1);
144+
expect(component.props.style.boxShadow[0]).toMatchObject({
145+
offsetX: 0,
146+
offsetY: 4,
147+
blurRadius: 6,
148+
spreadDistance: -1,
149+
});
150+
});
151+
152+
test("@property defaults with currentcolor (object color)", () => {
153+
registerCSS(`
154+
@property --my-shadow {
155+
syntax: "*";
156+
inherits: false;
157+
initial-value: 0 0 #0000;
158+
}
159+
@property --my-ring {
160+
syntax: "*";
161+
inherits: false;
162+
initial-value: 0 0 #0000;
163+
}
164+
165+
.test {
166+
--my-ring: 0 0 0 2px currentcolor;
167+
box-shadow: var(--my-shadow), var(--my-ring);
168+
}
169+
`);
170+
171+
render(<View testID={testID} className="test" />);
172+
const component = screen.getByTestId(testID);
173+
174+
expect(component.props.style.boxShadow).toHaveLength(1);
175+
expect(component.props.style.boxShadow[0]).toMatchObject({
176+
offsetX: 0,
177+
offsetY: 0,
178+
blurRadius: 0,
179+
spreadDistance: 2,
180+
});
181+
// currentcolor resolves to a platform color object, not a string
182+
expect(typeof component.props.style.boxShadow[0].color).toBe("object");
183+
});

src/compiler/compiler.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
type MediaQuery as CSSMediaQuery,
88
type CustomAtRules,
99
type MediaRule,
10+
type ParsedComponent,
11+
type PropertyRule,
1012
type Rule,
1113
type Visitor,
1214
} from "lightningcss";
@@ -15,11 +17,19 @@ import { maybeMutateReactNativeOptions, parsePropAtRule } from "./atRules";
1517
import type {
1618
CompilerOptions,
1719
ContainerQuery,
20+
StyleDescriptor,
1821
StyleRuleMapping,
1922
UniqueVarInfo,
2023
} from "./compiler.types";
2124
import { parseContainerCondition } from "./container-query";
22-
import { parseDeclaration, round } from "./declarations";
25+
import {
26+
parseAngle,
27+
parseColor,
28+
parseDeclaration,
29+
parseLength,
30+
reduceParseUnparsed,
31+
round,
32+
} from "./declarations";
2333
import { inlineVariables } from "./inline-variables";
2434
import { extractKeyFrames } from "./keyframes";
2535
import { lightningcssLoader } from "./lightningcss-loader";
@@ -276,13 +286,15 @@ function extractRule(
276286
}
277287
}
278288
break;
289+
case "property":
290+
extractPropertyRule(rule.value, builder);
291+
break;
279292
case "custom":
280293
case "font-face":
281294
case "font-palette-values":
282295
case "font-feature-values":
283296
case "namespace":
284297
case "layer-statement":
285-
case "property":
286298
case "view-transition":
287299
case "ignored":
288300
case "unknown":
@@ -377,3 +389,62 @@ function extractContainer(
377389
extractRule(rule, builder, mapping);
378390
}
379391
}
392+
393+
function extractPropertyRule(
394+
propertyRule: PropertyRule,
395+
builder: StylesheetBuilder,
396+
) {
397+
const { initialValue, name } = propertyRule;
398+
399+
if (initialValue == null) {
400+
return;
401+
}
402+
403+
const varName = name.startsWith("--") ? name.slice(2) : name;
404+
const value = parsePropertyInitialValue(initialValue, builder);
405+
406+
if (value !== undefined) {
407+
builder.addRootVariable(varName, value);
408+
}
409+
}
410+
411+
function parsePropertyInitialValue(
412+
component: ParsedComponent,
413+
builder: StylesheetBuilder,
414+
): StyleDescriptor {
415+
switch (component.type) {
416+
case "length":
417+
return parseLength(component.value, builder);
418+
case "number":
419+
case "integer":
420+
return round(component.value);
421+
case "percentage":
422+
return `${round(component.value * 100)}%`;
423+
case "color":
424+
return parseColor(component.value, builder);
425+
case "angle":
426+
return parseAngle(component.value, builder);
427+
case "length-percentage":
428+
return parseLength(component.value, builder);
429+
case "token-list":
430+
return reduceParseUnparsed(
431+
component.value,
432+
builder,
433+
"@property",
434+
false,
435+
);
436+
case "custom-ident":
437+
case "literal":
438+
return component.value;
439+
case "repeated": {
440+
const results = component.value.components
441+
.map((c) => parsePropertyInitialValue(c, builder))
442+
.filter(
443+
(v): v is NonNullable<StyleDescriptor> => v !== undefined,
444+
);
445+
return results.length === 1 ? results[0] : results;
446+
}
447+
default:
448+
return undefined;
449+
}
450+
}

0 commit comments

Comments
 (0)