diff --git a/packages/config/src/validation/validate-condition.ts b/packages/config/src/validation/validate-condition.ts index e9e12d9c76..7a20f94b6e 100644 --- a/packages/config/src/validation/validate-condition.ts +++ b/packages/config/src/validation/validate-condition.ts @@ -1,7 +1,19 @@ -import type { Conditions } from '@pandacss/types' +import type { Conditions, ConditionObjectQuery } from '@pandacss/types' import type { AddError } from '../types' import { isString } from '@pandacss/shared' +const validateObjectCondition = (obj: ConditionObjectQuery, addError: AddError) => { + for (const [key, value] of Object.entries(obj)) { + if (!key.startsWith('@') && !key.includes('&')) { + addError('conditions', `Selectors should contain the \`&\` character: \`${key}\``) + } + if (value === '@slot') continue + if (typeof value === 'object' && value !== null) { + validateObjectCondition(value, addError) + } + } +} + export const validateConditions = (conditions: Conditions | undefined, addError: AddError) => { if (!conditions) return @@ -14,10 +26,17 @@ export const validateConditions = (conditions: Conditions | undefined, addError: return } - condition.forEach((c) => { - if (!c.startsWith('@') && !c.includes('&')) { - addError('conditions', `Selectors should contain the \`&\` character: \`${c}\``) - } - }) + if (Array.isArray(condition)) { + condition.forEach((c) => { + if (!c.startsWith('@') && !c.includes('&')) { + addError('conditions', `Selectors should contain the \`&\` character: \`${c}\``) + } + }) + + return + } + + // Object syntax with @slot markers + validateObjectCondition(condition, addError) }) } diff --git a/packages/core/__tests__/rule-processor.test.ts b/packages/core/__tests__/rule-processor.test.ts index 84eb6b907c..4fce2937d3 100644 --- a/packages/core/__tests__/rule-processor.test.ts +++ b/packages/core/__tests__/rule-processor.test.ts @@ -2150,3 +2150,117 @@ describe('js to css', () => { `) }) }) + +describe('multi-block conditions (object syntax with @slot)', () => { + test('basic multi-block condition with two at-rule blocks', () => { + const result = css( + { + _hoverActive: { + background: 'red', + }, + }, + { + conditions: { + hoverActive: { + '@media (hover: hover)': { + '&:is(:hover, [data-hover])': '@slot', + }, + '@media (hover: none)': { + '&:is(:active, [data-active])': '@slot', + }, + }, + }, + }, + ) + + expect(result.css).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .hoverActive\\:bg_red:is(:hover, [data-hover]) { + background: red; + } + } + + @media (hover: none) { + .hoverActive\\:bg_red:is(:active, [data-active]) { + background: red; + } + } + }" + `) + }) + + test('single-block object condition (backward compat)', () => { + const result = css( + { + _anyHover: { + color: 'blue', + }, + }, + { + conditions: { + anyHover: { + '@media (hover: hover)': { + '&:hover': '@slot', + }, + }, + }, + }, + ) + + expect(result.css).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .anyHover\\:c_blue:hover { + color: blue; + } + } + }" + `) + }) + + test('multi-block condition with multiple properties', () => { + const result = css( + { + _hoverActive: { + background: 'red', + color: 'white', + }, + }, + { + conditions: { + hoverActive: { + '@media (hover: hover)': { + '&:is(:hover, [data-hover])': '@slot', + }, + '@media (hover: none)': { + '&:is(:active, [data-active])': '@slot', + }, + }, + }, + }, + ) + + expect(result.css).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .hoverActive\\:bg_red:is(:hover, [data-hover]) { + background: red; + } + .hoverActive\\:c_white:is(:hover, [data-hover]) { + color: var(--colors-white); + } + } + + @media (hover: none) { + .hoverActive\\:bg_red:is(:active, [data-active]) { + background: red; + } + .hoverActive\\:c_white:is(:active, [data-active]) { + color: var(--colors-white); + } + } + }" + `) + }) +}) diff --git a/packages/core/src/conditions.ts b/packages/core/src/conditions.ts index 6db2441146..8cf39ae825 100644 --- a/packages/core/src/conditions.ts +++ b/packages/core/src/conditions.ts @@ -173,7 +173,7 @@ export class Conditions { return Object.keys(this.values).length === 0 } - get = (key: string): undefined | string | string[] => { + get = (key: string): undefined | string | string[] | Record => { const details = this.values[key] return details?.raw } diff --git a/packages/core/src/parse-condition.ts b/packages/core/src/parse-condition.ts index 05da93f2ba..84a55c70b6 100644 --- a/packages/core/src/parse-condition.ts +++ b/packages/core/src/parse-condition.ts @@ -1,8 +1,10 @@ import type { AtRuleCondition, ConditionDetails, + ConditionObjectQuery, ConditionQuery, MixedCondition, + MultiBlockCondition, SelectorCondition, } from '@pandacss/types' import { AtRule } from 'postcss' @@ -21,6 +23,47 @@ function parseAtRule(value: string): AtRuleCondition { } } +/** + * Parses an object condition with `@slot` markers into condition blocks. + * Each path from root to `@slot` becomes an independent condition block. + * + * @example + * ```ts + * parseObjectCondition({ + * "@media (hover: hover)": { "&:is(:hover, [data-hover])": "@slot" }, + * "@media (hover: none)": { "&:is(:active, [data-active])": "@slot" }, + * }) + * ``` + */ +function parseObjectCondition(obj: ConditionObjectQuery): MultiBlockCondition | MixedCondition | undefined { + const blocks: MixedCondition[] = [] + + function traverse(node: ConditionObjectQuery, path: string[]) { + for (const [key, value] of Object.entries(node)) { + if (value === '@slot') { + const parts = [...path, key] + const parsed = parseCondition(parts) + if (parsed && parsed.type === 'mixed') { + blocks.push(parsed) + } + } else if (typeof value === 'object' && value !== null) { + traverse(value, [...path, key]) + } + } + } + + traverse(obj, []) + + if (blocks.length === 0) return undefined + if (blocks.length === 1) return blocks[0] + + return { + type: 'multi-block', + value: blocks, + raw: obj, + } as MultiBlockCondition +} + export function parseCondition(condition: ConditionQuery): ConditionDetails | undefined { if (Array.isArray(condition)) { return { @@ -30,6 +73,11 @@ export function parseCondition(condition: ConditionQuery): ConditionDetails | un } as MixedCondition } + // Handle object syntax with @slot markers + if (typeof condition === 'object' && condition !== null) { + return parseObjectCondition(condition as ConditionObjectQuery) + } + if (condition.startsWith('@')) { return parseAtRule(condition) } diff --git a/packages/core/src/sort-style-rules.ts b/packages/core/src/sort-style-rules.ts index a1c7b9a1a9..88ac80165f 100644 --- a/packages/core/src/sort-style-rules.ts +++ b/packages/core/src/sort-style-rules.ts @@ -3,7 +3,7 @@ import { sortAtRules } from './sort-at-rules' import { getPropertyPriority } from '@pandacss/shared' const hasAtRule = (conditions: ConditionDetails[]) => - conditions.some((details) => details.type === 'at-rule' || details.type === 'mixed') + conditions.some((details) => details.type === 'at-rule' || details.type === 'mixed' || details.type === 'multi-block') const styleOrder = [':link', ':visited', ':focus-within', ':focus', ':focus-visible', ':hover', ':active'] const pseudoSelectorScore = (selector: string) => { @@ -27,7 +27,12 @@ const compareSelectors = (a: WithConditions, b: WithConditions) => { /** * Flatten mixed conditions to Array */ -const flatten = (conds: ConditionDetails[]) => conds.flatMap((cond) => (cond.type === 'mixed' ? cond.value : cond)) +const flatten = (conds: ConditionDetails[]) => + conds.flatMap((cond) => { + if (cond.type === 'mixed') return cond.value + if (cond.type === 'multi-block') return cond.value.flatMap((block) => block.value) + return cond + }) /** * Compare 2 Array diff --git a/packages/core/src/style-decoder.ts b/packages/core/src/style-decoder.ts index 611a2f3418..52ab66b7e7 100644 --- a/packages/core/src/style-decoder.ts +++ b/packages/core/src/style-decoder.ts @@ -13,6 +13,7 @@ import type { Dict, GroupedResult, GroupedStyleResultDetails, + MultiBlockCondition, RecipeBaseResult, StyleEntry, StyleResultObject, @@ -109,6 +110,26 @@ export class StyleDecoder { : this.context.utility.tokens.resolveReference(condition.raw) } + /** + * Expands multi-block conditions into separate sets of conditions. + * Each block becomes an independent condition set that produces its own CSS block. + * Returns null if no multi-block condition is found. + */ + private expandMultiBlock(conditions: ConditionDetails[]): ConditionDetails[][] | null { + const multiBlockIdx = conditions.findIndex((c) => c.type === 'multi-block') + if (multiBlockIdx < 0) return null + + const multiBlock = conditions[multiBlockIdx] as MultiBlockCondition + const otherConditions = conditions.filter((_, i) => i !== multiBlockIdx) + + return multiBlock.value.map((block) => { + const blockParts = block.value.filter(Boolean) + const combined = [...blockParts, ...otherConditions] + // Sort: at-rules first, pseudo-elements last, preserve relative order + return sortConditionDetails(combined) + }) + } + private getAtomic = (hash: string) => { const cached = this.atomic_cache.get(hash) if (cached) return cached @@ -128,8 +149,18 @@ export class StyleDecoder { if (entry.cond) { conditions = this.context.conditions.sort(parts) - const path = basePath.concat(conditions.flatMap((c) => this.resolveCondition(c))) - deepSet(obj, path, styles) + + // Expand multi-block conditions into separate CSS blocks + const expanded = this.expandMultiBlock(conditions) + if (expanded) { + for (const blockConditions of expanded) { + const path = basePath.concat(blockConditions.flatMap((c) => this.resolveCondition(c))) + deepSet(obj, path, styles) + } + } else { + const path = basePath.concat(conditions.flatMap((c) => this.resolveCondition(c))) + deepSet(obj, path, styles) + } } else { deepSet(obj, basePath, styles) } @@ -190,8 +221,17 @@ export class StyleDecoder { const sorted = sortStyleRules(details) sorted.forEach((value) => { if (value.conditions) { - const path = basePath.concat(value.conditions.flatMap((c) => this.resolveCondition(c))) - obj = deepSet(obj, path, value.result) + // Expand multi-block conditions into separate CSS blocks + const expanded = this.expandMultiBlock(value.conditions) + if (expanded) { + for (const blockConditions of expanded) { + const path = basePath.concat(blockConditions.flatMap((c) => this.resolveCondition(c))) + obj = deepSet(obj, path, value.result) + } + } else { + const path = basePath.concat(value.conditions.flatMap((c) => this.resolveCondition(c))) + obj = deepSet(obj, path, value.result) + } } else { obj = deepSet(obj, basePath, value.result) } @@ -386,3 +426,30 @@ const castBoolean = (value: string) => { if (value === 'false') return false return value } + +const pseudoElementRegex = /::[\w-]/ + +/** + * Sort flattened condition details (at-rules and selectors only): + * at-rules first, pseudo-elements last, preserve relative order. + * + * Note: This only operates on individual at-rule and selector conditions + * (not mixed or multi-block), so checking `raw` as string is sufficient + * for pseudo-element detection. + */ +const sortConditionDetails = (conditions: ConditionDetails[]): ConditionDetails[] => { + const indexed = conditions.map((cond, i) => ({ cond, i })) + indexed.sort((a, b) => { + const aIsAtRule = a.cond.type === 'at-rule' + const bIsAtRule = b.cond.type === 'at-rule' + if (aIsAtRule && !bIsAtRule) return -1 + if (!aIsAtRule && bIsAtRule) return 1 + + const aIsPseudo = typeof a.cond.raw === 'string' && pseudoElementRegex.test(a.cond.raw) + const bIsPseudo = typeof b.cond.raw === 'string' && pseudoElementRegex.test(b.cond.raw) + if (aIsPseudo !== bIsPseudo) return aIsPseudo ? 1 : -1 + + return a.i - b.i + }) + return indexed.map((item) => item.cond) +} diff --git a/packages/generator/src/artifacts/js/conditions.ts b/packages/generator/src/artifacts/js/conditions.ts index d50161b337..32a75220d8 100644 --- a/packages/generator/src/artifacts/js/conditions.ts +++ b/packages/generator/src/artifacts/js/conditions.ts @@ -1,6 +1,14 @@ import type { Context } from '@pandacss/core' import outdent from 'outdent' +function formatConditionJsDoc(raw: string | string[] | Record | undefined): string { + if (!raw) return '' + if (typeof raw === 'string') return `/** \`${raw}\` */\n` + if (Array.isArray(raw)) return `/** \`${raw.join(' ')}\` */\n` + // Object condition (multi-block) - display a compact representation + return `/** Multi-block condition */\n` +} + export function generateConditions(ctx: Context) { const keys = Object.keys(ctx.conditions.values).concat('base') return { @@ -52,9 +60,7 @@ export function generateConditions(ctx: Context) { `\t${ key === 'base' ? `/** The base (=no conditions) styles to apply */\n` - : ctx.conditions.get(key) - ? `/** \`${([] as string[]).concat(ctx.conditions.get(key) ?? '').join(' ')}\` */\n` - : '' + : formatConditionJsDoc(ctx.conditions.get(key)) }\t${JSON.stringify(key)}: string`, ) .join('\n')} diff --git a/packages/types/src/conditions.ts b/packages/types/src/conditions.ts index 95e6440e6f..9bb472f12c 100644 --- a/packages/types/src/conditions.ts +++ b/packages/types/src/conditions.ts @@ -1,8 +1,8 @@ import type { AnySelector, Selectors } from './selectors' -export type ConditionType = 'at-rule' | 'parent-nesting' | 'self-nesting' | 'combinator-nesting' | 'mixed' +export type ConditionType = 'at-rule' | 'parent-nesting' | 'self-nesting' | 'combinator-nesting' | 'mixed' | 'multi-block' -export type ConditionDetails = AtRuleCondition | SelectorCondition | MixedCondition +export type ConditionDetails = AtRuleCondition | SelectorCondition | MixedCondition | MultiBlockCondition export interface AtRuleCondition { type: 'at-rule' @@ -24,11 +24,21 @@ export interface MixedCondition { raw: string[] } +export interface MultiBlockCondition { + type: 'multi-block' + value: MixedCondition[] + raw: Record +} + /* ----------------------------------------------------------------------------- * Shadowed export (in CLI): DO NOT REMOVE * -----------------------------------------------------------------------------*/ -export type ConditionQuery = string | string[] +export type ConditionObjectQuery = { + [key: string]: ConditionObjectQuery | '@slot' +} + +export type ConditionQuery = string | string[] | ConditionObjectQuery export interface Conditions { [condition: string]: ConditionQuery