Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions packages/config/src/validation/validate-condition.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
})
}
114 changes: 114 additions & 0 deletions packages/core/__tests__/rule-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}"
`)
})
})
2 changes: 1 addition & 1 deletion packages/core/src/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> => {
const details = this.values[key]
return details?.raw
}
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/parse-condition.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {
AtRuleCondition,
ConditionDetails,
ConditionObjectQuery,
ConditionQuery,
MixedCondition,
MultiBlockCondition,
SelectorCondition,
} from '@pandacss/types'
import { AtRule } from 'postcss'
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/sort-style-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -27,7 +27,12 @@ const compareSelectors = (a: WithConditions, b: WithConditions) => {
/**
* Flatten mixed conditions to Array<AtRuleCondition | SelectorCondition>
*/
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<AtRuleCondition | SelectorCondition>
Expand Down
75 changes: 71 additions & 4 deletions packages/core/src/style-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
Dict,
GroupedResult,
GroupedStyleResultDetails,
MultiBlockCondition,
RecipeBaseResult,
StyleEntry,
StyleResultObject,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
12 changes: 9 additions & 3 deletions packages/generator/src/artifacts/js/conditions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { Context } from '@pandacss/core'
import outdent from 'outdent'

function formatConditionJsDoc(raw: string | string[] | Record<string, any> | 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 {
Expand Down Expand Up @@ -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')}
Expand Down
Loading