Skip to content
Open
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
56 changes: 56 additions & 0 deletions .changeset/support-multi-block-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@pandacss/types': minor
'@pandacss/core': minor
'@pandacss/config': minor
'@pandacss/generator': minor
---

### Added: Multi-block conditions with object syntax

Allow a single condition to generate multiple independent CSS blocks using a declarative object syntax with `@slot` markers.

This is useful for defining conditions like hover-for-desktop + active-for-touch in one condition, where each block needs its own at-rule.

**Config:**

```ts
import { defineConfig } from '@pandacss/dev'

export default defineConfig({
conditions: {
extend: {
hoverActive: {
'@media (hover: hover)': {
'&:is(:hover, [data-hover])': '@slot',
},
'@media (hover: none)': {
'&:is(:active, [data-active])': '@slot',
},
},
},
},
})
```

**Usage:**

```ts
css({ _hoverActive: { bg: 'red' } })
```

**Generated CSS:**

```css
@media (hover: hover) {
.hoverActive\:bg_red:is(:hover, [data-hover]) {
background: red;
}
}
@media (hover: none) {
.hoverActive\:bg_red:is(:active, [data-active]) {
background: red;
}
}
```

This is backward compatible — existing `string` and `string[]` conditions continue to work as before.
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
Loading
Loading