@@ -14,6 +14,7 @@ import { resolveDynamicNode } from "../builtin-handlers.js";
1414import {
1515 cssDeclarationToStylexDeclarations ,
1616 cssPropertyToStylexProp ,
17+ isCssShorthandProperty ,
1718 parseBorderShorthandParts ,
1819 resolveBackgroundStylexProp ,
1920} from "../css-prop-mapping.js" ;
@@ -63,6 +64,7 @@ import { extractUnionLiteralValues } from "./variants.js";
6364import { toStyleKey , styleKeyWithSuffix } from "../transform/helpers.js" ;
6465import { cssPropertyToIdentifier , makeCssProperty , makeCssPropKey } from "./shared.js" ;
6566import { isMemberExpression } from "./utils.js" ;
67+ import { extractIndexedThemeLookupInfo } from "../builtin-handlers/resolver-utils.js" ;
6668type CommentSource = { leading ?: string ; trailingLine ?: string } | null ;
6769
6870type InterpolatedDeclarationContext = {
@@ -2283,13 +2285,105 @@ function resolveDerivedLocalVariable(
22832285}
22842286
22852287function isPseudoElementSelector ( pseudoElement : string | null ) : boolean {
2286- return pseudoElement === "::before" || pseudoElement === "::after" ;
2288+ return (
2289+ pseudoElement === "::before" || pseudoElement === "::after" || pseudoElement === "::placeholder"
2290+ ) ;
2291+ }
2292+
2293+ /**
2294+ * Attempts to resolve an indexed theme lookup from an arrow function expression.
2295+ * Pattern: `(props) => props.theme.color[props.$placeholderColor]`
2296+ * Returns the resolved value expression and metadata, or null if not applicable.
2297+ */
2298+ function tryResolveIndexedThemeForPseudoElement (
2299+ expr : { type ?: string } ,
2300+ state : DeclProcessingState [ "state" ] ,
2301+ ) : {
2302+ valueExpr : ExpressionKind ;
2303+ indexPropName : string ;
2304+ paramName : string ;
2305+ } | null {
2306+ const { resolveValue, resolverImports, parseExpr, api } = state ;
2307+ const arrowExpr = expr as {
2308+ type ?: string ;
2309+ params ?: Array < { type ?: string ; name ?: string } > ;
2310+ body ?: unknown ;
2311+ } ;
2312+ if ( arrowExpr . type !== "ArrowFunctionExpression" ) {
2313+ return null ;
2314+ }
2315+ const paramName = arrowExpr . params ?. [ 0 ] ?. type === "Identifier" ? arrowExpr . params [ 0 ] . name : null ;
2316+ if ( ! paramName ) {
2317+ return null ;
2318+ }
2319+
2320+ const body = arrowExpr . body as { type ?: string } | undefined ;
2321+ if ( ! body || body . type !== "MemberExpression" ) {
2322+ return null ;
2323+ }
2324+
2325+ const info = extractIndexedThemeLookupInfo ( body , paramName ) ;
2326+ if ( ! info ) {
2327+ return null ;
2328+ }
2329+
2330+ const resolved = resolveValue ( {
2331+ kind : "theme" ,
2332+ path : info . themeObjectPath ,
2333+ filePath : state . filePath ,
2334+ loc : getNodeLocStart ( body as any ) ?? undefined ,
2335+ } ) ;
2336+ if ( ! resolved ) {
2337+ return null ;
2338+ }
2339+
2340+ // Register theme imports
2341+ if ( resolved . imports ) {
2342+ for ( const imp of resolved . imports ) {
2343+ resolverImports . set (
2344+ JSON . stringify ( imp ) ,
2345+ imp as typeof resolverImports extends Map < string , infer V > ? V : never ,
2346+ ) ;
2347+ }
2348+ }
2349+
2350+ // Build the indexed expression: resolvedExpr[paramName]
2351+ const resolvedExprAst = parseExpr ( resolved . expr ) ;
2352+ const safeParamName = buildSafeIndexedParamName ( info . indexPropName , resolvedExprAst ) ;
2353+ const exprSource = `(${ resolved . expr } )[${ safeParamName } ]` ;
2354+ try {
2355+ const jParse = api . jscodeshift . withParser ( "tsx" ) ;
2356+ const program = jParse ( `(${ exprSource } );` ) ;
2357+ const stmt = program . find ( jParse . ExpressionStatement ) . nodes ( ) [ 0 ] ;
2358+ let parsedExpr = stmt ?. expression ?? null ;
2359+ while ( parsedExpr ?. type === "ParenthesizedExpression" ) {
2360+ parsedExpr = ( parsedExpr as { expression : ExpressionKind } ) . expression ;
2361+ }
2362+ // Remove extra.parenthesized flag that causes recast to add parentheses
2363+ const exprWithExtra = parsedExpr as ExpressionKind & {
2364+ extra ?: { parenthesized ?: boolean ; parenStart ?: number } ;
2365+ } ;
2366+ if ( exprWithExtra ?. extra ?. parenthesized ) {
2367+ delete exprWithExtra . extra . parenthesized ;
2368+ delete exprWithExtra . extra . parenStart ;
2369+ }
2370+ if ( ! parsedExpr ) {
2371+ return null ;
2372+ }
2373+ return {
2374+ valueExpr : parsedExpr as ExpressionKind ,
2375+ indexPropName : info . indexPropName ,
2376+ paramName : safeParamName ,
2377+ } ;
2378+ } catch {
2379+ return null ;
2380+ }
22872381}
22882382
22892383/**
2290- * Handles dynamic interpolations inside ::before/ ::after pseudo-elements by emitting
2291- * a StyleX dynamic style function whose body wraps the value in the pseudo-element
2292- * selector.
2384+ * Handles dynamic interpolations inside pseudo-elements ( ::before / ::after / ::placeholder)
2385+ * by emitting a StyleX dynamic style function whose body wraps the value in the pseudo-element
2386+ * selector. Also handles indexed theme lookups (e.g., props.theme.color[props.$bg]).
22932387 *
22942388 * Example transform:
22952389 * Input: `&::after { background-color: ${(props) => props.$badgeColor}; }`
@@ -2325,60 +2419,104 @@ function tryHandleDynamicPseudoElementStyleFunction(args: InterpolatedDeclaratio
23252419 return false ;
23262420 }
23272421
2328- if ( hasThemeAccessInArrowFn ( expr ) ) {
2422+ // For indexed theme lookups (e.g., props.theme.color[props.$bg]), resolve the theme
2423+ // reference and build the indexed expression so the function uses the resolved token.
2424+ const indexedTheme = hasThemeAccessInArrowFn ( expr )
2425+ ? tryResolveIndexedThemeForPseudoElement ( expr , state )
2426+ : null ;
2427+
2428+ // Bail on non-indexed theme access (e.g., props.theme.color.primary) — handled elsewhere.
2429+ if ( hasThemeAccessInArrowFn ( expr ) && ! indexedTheme ) {
23292430 return false ;
23302431 }
23312432
2332- const unwrapped = unwrapArrowFunctionToPropsExpr ( j , expr ) ;
2333- if ( ! unwrapped ) {
2433+ // Bail on CSS shorthand properties for indexed theme lookups.
2434+ // The indexed expression produces a single value that can't be expanded to longhands.
2435+ if ( indexedTheme && isCssShorthandProperty ( d . property ) ) {
23342436 return false ;
23352437 }
23362438
2337- const { expr : inlineExpr , propsUsed } = unwrapped ;
2338-
2439+ // Bail when the interpolation has surrounding static text and it's an indexed theme lookup.
2440+ // The indexed expression ($colors[param]) cannot be concatenated with a prefix.
23392441 const { prefix, suffix } = extractStaticPartsForDecl ( d ) ;
2442+ if ( indexedTheme && ( prefix || suffix ) ) {
2443+ return false ;
2444+ }
2445+
2446+ let inlineExpr : ExpressionKind ;
2447+ let propsUsed : Set < string > ;
2448+ let jsxProp : string ;
2449+ let isSimpleIdentity : boolean ;
2450+
2451+ if ( indexedTheme ) {
2452+ // Indexed theme: the value expression is the resolved indexed access (e.g., $colors[param]).
2453+ inlineExpr = indexedTheme . valueExpr ;
2454+ propsUsed = new Set ( [ indexedTheme . indexPropName ] ) ;
2455+ jsxProp = indexedTheme . indexPropName ;
2456+ isSimpleIdentity = true ;
2457+ } else {
2458+ const unwrapped = unwrapArrowFunctionToPropsExpr ( j , expr ) ;
2459+ if ( ! unwrapped ) {
2460+ return false ;
2461+ }
2462+ inlineExpr = unwrapped . expr ;
2463+ propsUsed = unwrapped . propsUsed ;
2464+ // Determine if the expression is a simple identity prop reference (e.g., just `badgeColor`)
2465+ // vs a computed expression (e.g., `tipColor || "black"`, `size * 2`).
2466+ isSimpleIdentity =
2467+ propsUsed . size === 1 &&
2468+ ! prefix &&
2469+ ! suffix &&
2470+ inlineExpr . type === "Identifier" &&
2471+ propsUsed . has ( ( inlineExpr as { name : string } ) . name ) ;
2472+ jsxProp = isSimpleIdentity ? [ ...propsUsed ] [ 0 ] ! : "__props" ;
2473+ }
2474+
23402475 const valueExpr : ExpressionKind =
23412476 prefix || suffix ? buildTemplateWithStaticParts ( j , inlineExpr , prefix , suffix ) : inlineExpr ;
23422477
2343- // Determine if the expression is a simple identity prop reference (e.g., just `badgeColor`)
2344- // vs a computed expression (e.g., `tipColor || "black"`, `size * 2`).
2345- // Simple identity: pass the prop directly as jsxProp.
2346- // Computed: pass the full expression as callArg to preserve the computation.
2347- const isSimpleIdentity =
2348- propsUsed . size === 1 &&
2349- ! prefix &&
2350- ! suffix &&
2351- inlineExpr . type === "Identifier" &&
2352- propsUsed . has ( ( inlineExpr as { name : string } ) . name ) ;
2353-
23542478 const stylexDecls = cssDeclarationToStylexDeclarations ( d ) ;
23552479 const pseudoLabel = pseudoElement . replace ( / ^ : + / , "" ) ;
2356- const jsxProp = isSimpleIdentity ? [ ...propsUsed ] [ 0 ] ! : "__props" ;
23572480
23582481 for ( const out of stylexDecls ) {
23592482 if ( ! out . prop ) {
23602483 continue ;
23612484 }
23622485 const fnKey = styleKeyWithSuffix ( styleKeyWithSuffix ( decl . styleKey , pseudoLabel ) , out . prop ) ;
23632486 if ( ! styleFnDecls . has ( fnKey ) ) {
2364- // Use the prop name (without $) as parameter for cleaner call sites
2365- // when possible: styles.badge({ badgeColor }) instead of
2366- // styles.badge({ backgroundColor: badgeColor })
2367- const outParamName =
2368- isSimpleIdentity && jsxProp . startsWith ( "$" )
2487+ // Build parameter name — for indexed theme use the resolved param name,
2488+ // for simple identity use the prop name (without $) for cleaner call sites.
2489+ const outParamName = indexedTheme
2490+ ? indexedTheme . paramName
2491+ : isSimpleIdentity && jsxProp . startsWith ( "$" )
23692492 ? jsxProp . slice ( 1 )
23702493 : cssPropertyToIdentifier ( out . prop , avoidNames ) ;
23712494 const param = j . identifier ( outParamName ) ;
2372- if ( isSimpleIdentity && jsxProp !== "__props" ) {
2495+
2496+ if ( indexedTheme ) {
2497+ // Use the JSX prop's own type annotation (e.g., Color) when available.
2498+ const propTsType = ctx . findJsxPropTsType ( jsxProp ) ;
2499+ ( param as { typeAnnotation ?: unknown } ) . typeAnnotation = j . tsTypeAnnotation (
2500+ propTsType && typeof propTsType === "object" && ( propTsType as { type ?: string } ) . type
2501+ ? ( propTsType as ReturnType < typeof j . tsStringKeyword > )
2502+ : j . tsStringKeyword ( ) ,
2503+ ) ;
2504+ } else if ( isSimpleIdentity && jsxProp !== "__props" ) {
23732505 ctx . annotateParamFromJsxProp ( param , jsxProp ) ;
23742506 } else if ( / \. ( t s | t s x ) $ / . test ( filePath ) ) {
23752507 ( param as { typeAnnotation ?: unknown } ) . typeAnnotation = j . tsTypeAnnotation (
23762508 j . tsStringKeyword ( ) ,
23772509 ) ;
23782510 }
2511+
2512+ // For indexed theme, use the resolved indexed expression directly.
2513+ // For other cases, use the parameter name (potentially wrapped with pseudo/media).
2514+ const innerValueExpr = indexedTheme
2515+ ? ( cloneAstNode ( indexedTheme . valueExpr ) as ExpressionKind )
2516+ : j . identifier ( outParamName ) ;
23792517 const innerValue = buildPseudoMediaPropValue ( {
23802518 j,
2381- valueExpr : j . identifier ( outParamName ) ,
2519+ valueExpr : innerValueExpr ,
23822520 pseudos,
23832521 media,
23842522 } ) ;
@@ -2400,7 +2538,7 @@ function tryHandleDynamicPseudoElementStyleFunction(args: InterpolatedDeclaratio
24002538 }
24012539
24022540 if ( isSimpleIdentity ) {
2403- const isOptional = ctx . isJsxPropOptional ( jsxProp ) ;
2541+ const isOptional = indexedTheme ? false : ctx . isJsxPropOptional ( jsxProp ) ;
24042542 styleFnFromProps . push ( {
24052543 fnKey,
24062544 jsxProp,
0 commit comments