Skip to content

Commit 9760baa

Browse files
skovhusclaude
andauthored
feat(css-helper): support theme ternary inside conditional css blocks (#373)
* feat(css-helper): support theme.isDark ternary inside conditional css blocks Handle ConditionalExpression nodes with theme test (e.g., props.theme.isDark) inside css`` helper blocks within prop-conditional interpolations. Previously the codemod bailed with "failed to parse expression" for patterns like: ${props => props.$isDisabled && css`color: ${props.theme.isDark ? "a" : "b"};`} Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: drop outer condition props in && css helper with theme ternary variants When all styles in a `props.$x && css` block end up as conditional variants (e.g., theme ternary), the outer condition's props weren't being dropped from DOM forwarding. Adds dropAllTestInfoProps(testInfo) call and extends the test case with a non-transient prop to verify the fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 43cfdde commit 9760baa

File tree

4 files changed

+276
-17
lines changed

4 files changed

+276
-17
lines changed

src/internal/lower-rules/css-helper-conditional.ts

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,35 @@ export function createCssHelperConditionalHandler(ctx: CssHelperConditionalConte
357357
return replace(cloned, undefined) as ExpressionKind;
358358
};
359359

360+
/** Apply conditional variants, composing with an outer condition, and inject useTheme() for theme refs. */
361+
const applyConditionalVariantsInline = (
362+
conditionalVariants: ConditionalVariant[],
363+
outerCondition: string,
364+
): void => {
365+
for (const cv of conditionalVariants) {
366+
const composedWhen = `${outerCondition} && ${cv.when}`;
367+
applyVariant({ when: composedWhen, propName: cv.propName }, cv.style);
368+
if (cv.propName) {
369+
ensureShouldForwardPropDrop(decl, cv.propName);
370+
}
371+
// Theme-based conditional variants need useTheme() to be injected
372+
if (cv.when.startsWith("theme.") || cv.when.startsWith("!theme.")) {
373+
if (!decl.needsUseThemeHook) {
374+
decl.needsUseThemeHook = [];
375+
}
376+
if (
377+
!decl.needsUseThemeHook.some((e) => e.trueStyleKey === null && e.falseStyleKey === null)
378+
) {
379+
decl.needsUseThemeHook.push({
380+
themeProp: "__variantCondition",
381+
trueStyleKey: null,
382+
falseStyleKey: null,
383+
});
384+
}
385+
}
386+
}
387+
};
388+
360389
const resolveCssBranchToInlineMap = (
361390
node: ExpressionKind,
362391
): Map<string, ExpressionKind> | null => {
@@ -631,12 +660,12 @@ export function createCssHelperConditionalHandler(ctx: CssHelperConditionalConte
631660
}
632661

633662
// Apply conditional variants from nested ternaries within the css block
634-
for (const cv of conditionalVariants) {
635-
// Compose the outer condition with the inner condition
636-
const composedWhen = `${testInfo.when} && ${cv.when}`;
637-
applyVariant({ when: composedWhen, propName: cv.propName }, cv.style);
638-
ensureShouldForwardPropDrop(decl, cv.propName);
639-
}
663+
applyConditionalVariantsInline(conditionalVariants, testInfo.when);
664+
665+
// Ensure the outer condition's props are dropped from DOM forwarding.
666+
// This covers the case where all styles ended up as conditional variants
667+
// (e.g., theme ternary) and consStyle is empty, so applyVariant was never called.
668+
dropAllTestInfoProps(testInfo);
640669

641670
return true;
642671
}
@@ -1543,17 +1572,8 @@ export function createCssHelperConditionalHandler(ctx: CssHelperConditionalConte
15431572
return resolveCssHelperTemplate(tplNode.quasi, paramName, decl.loc);
15441573
};
15451574

1546-
// Helper to apply conditional variants from a resolved branch
1547-
const applyConditionalVariants = (
1548-
conditionalVariants: ConditionalVariant[],
1549-
outerCondition: string,
1550-
): void => {
1551-
for (const cv of conditionalVariants) {
1552-
const composedWhen = `${outerCondition} && ${cv.when}`;
1553-
applyVariant({ when: composedWhen, propName: cv.propName }, cv.style);
1554-
ensureShouldForwardPropDrop(decl, cv.propName);
1555-
}
1556-
};
1575+
// Reuse the shared helper for the ternary handler path
1576+
const applyConditionalVariants = applyConditionalVariantsInline;
15571577

15581578
if (consIsCss && altIsCss) {
15591579
const consResolved = resolveCssBranch(cons);

src/internal/lower-rules/css-helper.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,18 @@ export function createCssHelperResolver(args: {
283283
return null;
284284
};
285285

286+
/**
287+
* Extracts the theme path from a ternary test that accesses `props.theme.*`.
288+
* e.g., `props.theme.isDark` → "isDark", `props.theme.mode` → "mode"
289+
*/
290+
const extractThemePathFromCondTest = (test: any, paramName: string | null): string | null => {
291+
if (!test || !paramName) {
292+
return null;
293+
}
294+
const path = getMemberPathFromIdentifier(test, paramName);
295+
return path && path[0] === "theme" && path.length > 1 ? path.slice(1).join(".") : null;
296+
};
297+
286298
const resolveCssHelperTemplate = (
287299
template: any,
288300
paramName: string | null,
@@ -573,6 +585,61 @@ export function createCssHelperResolver(args: {
573585
);
574586
}
575587
const resolved = resolveHelperExprToAst(expr as any, paramName);
588+
// Handle ConditionalExpression with theme test: ${props.theme.isDark ? "a" : "b"}
589+
if (!resolved && (expr as any).type === "ConditionalExpression") {
590+
const ternaryExpr = expr as {
591+
test: any;
592+
consequent: any;
593+
alternate: any;
594+
};
595+
const themePath = extractThemePathFromCondTest(ternaryExpr.test, paramName);
596+
if (themePath) {
597+
const consResolved = resolveTernaryBranchToAst(ternaryExpr.consequent);
598+
const altResolved = resolveTernaryBranchToAst(ternaryExpr.alternate);
599+
if (consResolved && altResolved) {
600+
const buildVariantStyle = (branchResolved: {
601+
ast: any;
602+
exprString: string;
603+
}): Record<string, unknown> => {
604+
const variantStyle: Record<string, unknown> = {};
605+
for (const mapped of cssDeclarationToStylexDeclarations(d)) {
606+
if (hasStaticParts) {
607+
const { prefix, suffix } = extractPrefixSuffix(parts);
608+
const wrappedExpr = wrapExprWithStaticParts(
609+
branchResolved.exprString,
610+
prefix,
611+
suffix,
612+
);
613+
const ast = parseExpr(wrappedExpr);
614+
if (ast) {
615+
variantStyle[mapped.prop] = mergeIntoContext(ast, mapped.prop, target as any);
616+
}
617+
} else {
618+
variantStyle[mapped.prop] = mergeIntoContext(
619+
branchResolved.ast,
620+
mapped.prop,
621+
target as any,
622+
);
623+
}
624+
}
625+
return variantStyle;
626+
};
627+
const consStyle = buildVariantStyle(consResolved);
628+
const altStyle = buildVariantStyle(altResolved);
629+
conditionalVariants.push({
630+
when: `theme.${themePath}`,
631+
propName: "",
632+
style: consStyle,
633+
});
634+
conditionalVariants.push({
635+
when: `!theme.${themePath}`,
636+
propName: "",
637+
style: altStyle,
638+
});
639+
continue;
640+
}
641+
}
642+
}
576643
if (!resolved && hasThemeAccessInExpr(expr, paramName)) {
577644
return bail(
578645
"Conditional `css` block: failed to parse expression",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Conditional css blocks with theme.isDark ternary inside the template expression
2+
import * as React from "react";
3+
import styled, { css } from "styled-components";
4+
5+
interface InitialProps {
6+
$fontSize: number;
7+
/** Should the avatar render as inactive. */
8+
$isInactive?: boolean;
9+
/** Should the avatar render as for an invite. */
10+
$isInvite?: boolean;
11+
/** Whether the avatar should be rendered as disabled. */
12+
$isDisabled?: boolean;
13+
}
14+
15+
const Thing = styled.div<InitialProps>`
16+
display: flex;
17+
${(props) =>
18+
props.$isDisabled &&
19+
css`
20+
color: ${props.theme.isDark ? "#ffffff55" : "#FFFFFF"};
21+
`}
22+
${(props) =>
23+
props.$isInactive
24+
? css`
25+
background-color: ${props.theme.color.bgBorderSolid};
26+
`
27+
: ""};
28+
${(props) =>
29+
props.$isInvite
30+
? css`
31+
background-color: ${props.theme.color.bgBase};
32+
`
33+
: ""};
34+
`;
35+
36+
// Non-transient prop (no $ prefix) — verifies prop is dropped from DOM forwarding
37+
interface HighlightProps {
38+
highlighted?: boolean;
39+
}
40+
41+
const Highlight = styled.div<HighlightProps>`
42+
padding: 8px;
43+
${(props) =>
44+
props.highlighted &&
45+
css`
46+
border-width: ${props.theme.isDark ? 2 : 1}px;
47+
border-style: solid;
48+
border-color: ${props.theme.color.bgBorderSolid};
49+
`}
50+
`;
51+
52+
export const App = () => (
53+
<div style={{ display: "flex", gap: 16, padding: 16 }}>
54+
<Thing $fontSize={14} $isDisabled>
55+
Disabled
56+
</Thing>
57+
<Thing $fontSize={14} $isInactive>
58+
Inactive
59+
</Thing>
60+
<Thing $fontSize={14} $isInvite>
61+
Invite
62+
</Thing>
63+
<Thing $fontSize={14}>Default</Thing>
64+
<Highlight highlighted>Highlighted</Highlight>
65+
<Highlight>Normal</Highlight>
66+
</div>
67+
);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Conditional css blocks with theme.isDark ternary inside the template expression
2+
import * as React from "react";
3+
import { useTheme } from "styled-components";
4+
import * as stylex from "@stylexjs/stylex";
5+
import { $colors } from "./tokens.stylex";
6+
7+
interface InitialProps {
8+
$fontSize: number;
9+
/** Should the avatar render as inactive. */
10+
isInactive?: boolean;
11+
/** Should the avatar render as for an invite. */
12+
isInvite?: boolean;
13+
/** Whether the avatar should be rendered as disabled. */
14+
isDisabled?: boolean;
15+
}
16+
17+
function Thing(props: React.PropsWithChildren<InitialProps>) {
18+
const { children, isInactive, isInvite, isDisabled } = props;
19+
const theme = useTheme();
20+
21+
return (
22+
<div
23+
sx={[
24+
styles.thing,
25+
isDisabled && theme.isDark ? styles.thingIsDisabledThemeIsDark : undefined,
26+
isDisabled && !theme.isDark ? styles.thingIsDisabledNotThemeIsDark : undefined,
27+
isInactive ? styles.thingInactive : undefined,
28+
isInvite ? styles.thingInvite : undefined,
29+
]}
30+
>
31+
{children}
32+
</div>
33+
);
34+
}
35+
36+
// Non-transient prop (no $ prefix) — verifies prop is dropped from DOM forwarding
37+
interface HighlightProps {
38+
highlighted?: boolean;
39+
}
40+
41+
function Highlight(props: React.PropsWithChildren<HighlightProps>) {
42+
const { children, highlighted } = props;
43+
const theme = useTheme();
44+
45+
return (
46+
<div
47+
sx={[
48+
styles.highlight,
49+
highlighted ? styles.highlightHighlighted : undefined,
50+
highlighted && theme.isDark ? styles.highlightHighlightedThemeIsDark : undefined,
51+
highlighted && !theme.isDark ? styles.highlightHighlightedNotThemeIsDark : undefined,
52+
]}
53+
>
54+
{children}
55+
</div>
56+
);
57+
}
58+
59+
export const App = () => (
60+
<div style={{ display: "flex", gap: 16, padding: 16 }}>
61+
<Thing $fontSize={14} isDisabled>
62+
Disabled
63+
</Thing>
64+
<Thing $fontSize={14} isInactive>
65+
Inactive
66+
</Thing>
67+
<Thing $fontSize={14} isInvite>
68+
Invite
69+
</Thing>
70+
<Thing $fontSize={14}>Default</Thing>
71+
<Highlight highlighted>Highlighted</Highlight>
72+
<Highlight>Normal</Highlight>
73+
</div>
74+
);
75+
76+
const styles = stylex.create({
77+
thing: {
78+
display: "flex",
79+
},
80+
thingIsDisabledThemeIsDark: {
81+
color: "#ffffff55",
82+
},
83+
thingIsDisabledNotThemeIsDark: {
84+
color: "#FFFFFF",
85+
},
86+
thingInactive: {
87+
backgroundColor: $colors.bgBorderSolid,
88+
},
89+
thingInvite: {
90+
backgroundColor: $colors.bgBase,
91+
},
92+
highlight: {
93+
padding: 8,
94+
},
95+
highlightHighlighted: {
96+
borderStyle: "solid",
97+
borderColor: $colors.bgBorderSolid,
98+
},
99+
highlightHighlightedThemeIsDark: {
100+
borderWidth: "2px",
101+
},
102+
highlightHighlightedNotThemeIsDark: {
103+
borderWidth: "1px",
104+
},
105+
});

0 commit comments

Comments
 (0)