Skip to content

Commit f884d5c

Browse files
committed
Fix CSS variable bridge: preserve static parts and improve type safety
- Compose var() references with surrounding static parts (e.g., `box-shadow: 0 4px 8px ${color}` → `"0 4px 8px var(--name)"`) - Remove unnecessary type assertion, use proper TypeScript narrowing after discriminated union check - Extend test case with box-shadow (static prefix) and border (shorthand expansion) edge cases https://claude.ai/code/session_01SkMXxV5ASTu66vhriVXRRq
1 parent 48e3353 commit f884d5c

3 files changed

Lines changed: 156 additions & 17 deletions

File tree

src/internal/lower-rules/process-rules.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,16 +1278,15 @@ function tryForwardCssVarBridge(
12781278
if (d.value.kind !== "interpolated" || !d.property) {
12791279
return "bail";
12801280
}
1281-
const parts = (d.value as { parts?: Array<{ kind: string; slotId?: number }> }).parts;
1282-
if (!parts) {
1283-
return "bail";
1284-
}
1285-
const slotParts = parts.filter((p) => p.kind === "slot" && p.slotId !== undefined);
1281+
const slotParts = d.value.parts.filter(
1282+
(p): p is CssValuePart & { kind: "slot" } => p.kind === "slot",
1283+
);
12861284
// Only handle single-slot interpolations for now
1287-
if (slotParts.length !== 1 || slotParts[0]!.slotId === undefined) {
1285+
const singleSlot = slotParts.length === 1 ? slotParts[0] : undefined;
1286+
if (!singleSlot) {
12881287
return "bail";
12891288
}
1290-
const slotId = slotParts[0]!.slotId;
1289+
const slotId = singleSlot.slotId;
12911290
const expr = decl.templateExpressions[slotId];
12921291
if (
12931292
!expr ||
@@ -1317,12 +1316,14 @@ function tryForwardCssVarBridge(
13171316
// Generate a CSS variable name from the override style key and CSS property
13181317
const varName = `--${overrideStyleKey}-${kebabToCamelCase(d.property)}`;
13191318

1320-
// Set bucket value(s) to var(--name) — shorthand expansion produces the right outputs
1319+
// Set bucket value(s) — shorthand expansion may produce multiple outputs.
1320+
// Compose static parts with the var() reference to preserve prefixes/suffixes
1321+
// (e.g., `box-shadow: 0 4px 8px ${color}` → `"0 4px 8px var(--name)"`).
13211322
for (const out of cssDeclarationToStylexDeclarations(d)) {
13221323
if (out.value.kind === "static") {
13231324
bucket[out.prop] = cssValueToJs(out.value, d.important, out.prop);
13241325
} else {
1325-
bucket[out.prop] = `var(${varName})`;
1326+
bucket[out.prop] = composeVarReference(out.value.parts, varName);
13261327
}
13271328
writtenProps.add(out.prop);
13281329
}
@@ -1335,6 +1336,14 @@ function tryForwardCssVarBridge(
13351336
return writtenProps;
13361337
}
13371338

1339+
/**
1340+
* Builds a CSS value string from parts, replacing slot references with `var(--name)`.
1341+
* Preserves static prefixes/suffixes around the interpolation slot.
1342+
*/
1343+
function composeVarReference(parts: CssValuePart[], varName: string): string {
1344+
return parts.map((p) => (p.kind === "static" ? p.value : `var(${varName})`)).join("");
1345+
}
1346+
13381347
/**
13391348
* Processes rule declarations into a relation override bucket, handling both static
13401349
* and interpolated (theme-resolved) values. Returns "bail" if any interpolated

test-cases/selector-descendantComponentDynamicProp.input.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,47 @@ const Button = styled.button<{ $color?: string }>`
1616
}
1717
`;
1818

19+
// Static parts around the interpolation must be preserved in the var() reference
20+
// (e.g., `box-shadow: 0 4px 8px ${color}` → `"0 4px 8px var(--name)"`).
21+
const Badge = styled.span`
22+
font-size: 12px;
23+
`;
24+
25+
const Card = styled.div<{ $shadow?: string }>`
26+
padding: 16px;
27+
background: white;
28+
29+
&:hover ${Badge} {
30+
box-shadow: 0 4px 8px ${(props) => props.$shadow ?? "rgba(0,0,0,0.2)"};
31+
}
32+
`;
33+
34+
// Shorthand border with interpolation: static longhands stay static,
35+
// dynamic color is bridged via CSS variable.
36+
const Tag = styled.span`
37+
display: inline-block;
38+
`;
39+
40+
const Toolbar = styled.div<{ $accent?: string }>`
41+
display: flex;
42+
gap: 8px;
43+
44+
&:hover ${Tag} {
45+
border: 2px solid ${(props) => props.$accent ?? "gray"};
46+
}
47+
`;
48+
1949
export const App = () => (
20-
<Button>
21-
<Icon />
22-
Click
23-
</Button>
50+
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
51+
<Button $color="blue">
52+
<Icon />
53+
Button hover → Icon color
54+
</Button>
55+
<Card $shadow="rgba(0,0,255,0.3)">
56+
<Badge>Card hover → Badge shadow</Badge>
57+
</Card>
58+
<Toolbar $accent="red">
59+
<Tag>Toolbar hover → Tag border</Tag>
60+
</Toolbar>
61+
</div>
2462
);

test-cases/selector-descendantComponentDynamicProp.output.tsx

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,65 @@ function Button(props: ButtonProps) {
2626
);
2727
}
2828

29+
type CardProps = React.PropsWithChildren<{
30+
shadow?: string;
31+
}>;
32+
33+
function Card(props: CardProps) {
34+
const { children, shadow } = props;
35+
const sx = stylex.props(styles.card, stylex.defaultMarker());
36+
37+
return (
38+
<div
39+
{...sx}
40+
style={
41+
{
42+
...sx.style,
43+
"--badgeInCard-boxShadow": props.shadow ?? "rgba(0,0,0,0.2)",
44+
} as React.CSSProperties
45+
}
46+
>
47+
{children}
48+
</div>
49+
);
50+
}
51+
52+
type ToolbarProps = React.PropsWithChildren<{
53+
accent?: string;
54+
}>;
55+
56+
function Toolbar(props: ToolbarProps) {
57+
const { children, accent } = props;
58+
const sx = stylex.props(styles.toolbar, stylex.defaultMarker());
59+
60+
return (
61+
<div
62+
{...sx}
63+
style={
64+
{
65+
...sx.style,
66+
"--tagInToolbar-border": props.accent ?? "gray",
67+
} as React.CSSProperties
68+
}
69+
>
70+
{children}
71+
</div>
72+
);
73+
}
74+
2975
export const App = () => (
30-
<Button>
31-
<span sx={[styles.icon, styles.iconInButton]} />
32-
Click
33-
</Button>
76+
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
77+
<Button color="blue">
78+
<span sx={[styles.icon, styles.iconInButton]} />
79+
Button hover → Icon color
80+
</Button>
81+
<Card shadow="rgba(0,0,255,0.3)">
82+
<span sx={[styles.badge, styles.badgeInCard]}>Card hover → Badge shadow</span>
83+
</Card>
84+
<Toolbar accent="red">
85+
<span sx={[styles.tag, styles.tagInToolbar]}>Toolbar hover → Tag border</span>
86+
</Toolbar>
87+
</div>
3488
);
3589

3690
const styles = stylex.create({
@@ -41,10 +95,48 @@ const styles = stylex.create({
4195
button: {
4296
padding: 8,
4397
},
98+
// Static parts around the interpolation must be preserved in the var() reference
99+
// (e.g., `box-shadow: 0 4px 8px ${color}` → `"0 4px 8px var(--name)"`).
100+
badge: {
101+
fontSize: 12,
102+
},
103+
card: {
104+
padding: 16,
105+
backgroundColor: "white",
106+
},
107+
// Shorthand border with interpolation: static longhands stay static,
108+
// dynamic color is bridged via CSS variable.
109+
tag: {
110+
display: "inline-block",
111+
},
112+
toolbar: {
113+
display: "flex",
114+
gap: 8,
115+
},
44116
iconInButton: {
45117
color: {
46118
default: null,
47119
[stylex.when.ancestor(":hover")]: "var(--iconInButton-color)",
48120
},
49121
},
122+
badgeInCard: {
123+
boxShadow: {
124+
default: null,
125+
[stylex.when.ancestor(":hover")]: "0 4px 8px var(--badgeInCard-boxShadow)",
126+
},
127+
},
128+
tagInToolbar: {
129+
borderWidth: {
130+
default: null,
131+
[stylex.when.ancestor(":hover")]: 2,
132+
},
133+
borderStyle: {
134+
default: null,
135+
[stylex.when.ancestor(":hover")]: "solid",
136+
},
137+
borderColor: {
138+
default: null,
139+
[stylex.when.ancestor(":hover")]: "var(--tagInToolbar-border)",
140+
},
141+
},
50142
});

0 commit comments

Comments
 (0)