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
9 changes: 9 additions & 0 deletions .changeset/modern-glasses-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@pandacss/preset-base': minor
'@pandacss/generator': minor
'@pandacss/config': minor
'@pandacss/types': minor
'@pandacss/core': minor
---

Add support for dynamic conditions
23 changes: 20 additions & 3 deletions packages/config/src/validation/validate-condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,32 @@ import type { Conditions } from '@pandacss/types'
import type { AddError } from '../types'
import { isString } from '@pandacss/shared'

const validateSelector = (value: string, addError: AddError) => {
if (!value.startsWith('@') && !value.includes('&')) {
addError('conditions', `Selectors should contain the \`&\` character: \`${value}\``)
}
}

export const validateConditions = (conditions: Conditions | undefined, addError: AddError) => {
if (!conditions) return

Object.values(conditions).forEach((condition) => {
if (isString(condition)) {
if (!condition.startsWith('@') && !condition.includes('&')) {
addError('conditions', `Selectors should contain the \`&\` character: \`${condition}\``)
}
validateSelector(condition, addError)
return
}

if (typeof condition === 'function') {
try {
const withArg = condition('item')
if (typeof withArg !== 'string') {
addError('conditions', `Dynamic condition function must return a string, got ${typeof withArg}`)
return
}
validateSelector(withArg, addError)
} catch (err) {
addError('conditions', `Dynamic condition function threw: ${err instanceof Error ? err.message : String(err)}`)
}
return
}

Expand Down
18 changes: 18 additions & 0 deletions packages/core/__tests__/atomic-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,24 @@ describe('atomic / with grouped conditions styles', () => {
`)
})

test('dynamic condition _groupHover/item produces .group\\/item selector', () => {
const result = css({
_groupHover: { bg: 'red.500' },
})
expect(result).toContain('.group:is(:hover, [data-hover])')
const resultWithModifier = css({
'_groupHover/item': { bg: 'red.500' },
})
expect(resultWithModifier).toContain('.group\\/item')
})

test('dynamic condition _nth/3 produces :nth-child selector', () => {
const result = css({
'_nth/3': { color: 'red.500' },
})
expect(result).toContain('nth-child(3)')
})

test('multiple scopes', () => {
expect(
css({
Expand Down
60 changes: 60 additions & 0 deletions packages/core/__tests__/conditions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,64 @@ describe('Conditions', () => {
}
`)
})

describe('dynamic conditions', () => {
test('has() and getRaw() for dynamic condition with and without modifier', () => {
const conditions = new Conditions({
conditions: {
groupHover: (name?: string) =>
name ? `.group\\/${name}:is(:hover, [data-hover]) &` : '.group:is(:hover, [data-hover]) &',
nth: (value?: string) => `&:nth-child(${value ?? 'n'})`,
},
})

expect(conditions.has('_groupHover')).toBe(true)
expect(conditions.has('_groupHover/item')).toBe(true)
expect(conditions.has('_nth')).toBe(true)
expect(conditions.has('_nth/3')).toBe(true)
expect(conditions.has('_unknown')).toBe(false)

const groupHoverRaw = conditions.getRaw('_groupHover')
expect(groupHoverRaw?.raw).toBe('.group:is(:hover, [data-hover]) &')

const groupHoverItemRaw = conditions.getRaw('_groupHover/item')
expect(groupHoverItemRaw?.raw).toBe('.group\\/item:is(:hover, [data-hover]) &')

const nth3Raw = conditions.getRaw('_nth/3')
expect(nth3Raw?.raw).toBe('&:nth-child(3)')
})

test('getDynamicConditionNames() returns dynamic condition base names', () => {
const conditions = new Conditions({
conditions: {
groupHover: (name?: string) => (name ? `.group\\/${name}:hover &` : '.group:hover &'),
nth: (value?: string) => `&:nth-child(${value ?? 'n'})`,
},
})
expect(conditions.getDynamicConditionNames()).toEqual(['groupHover', 'nth'])
})

test('finalize and sort with dynamic keys', () => {
const conditions = new Conditions({
conditions: {
groupHover: (name?: string) =>
name ? `.group\\/${name}:is(:hover, [data-hover]) &` : '.group:is(:hover, [data-hover]) &',
},
})
expect(conditions.finalize(['_groupHover/item'])).toEqual(['groupHover/item'])
const sorted = conditions.sort(['_groupHover/item', '_groupHover'])
expect(sorted.map((c) => c.raw)).toContain('.group\\/item:is(:hover, [data-hover]) &')
expect(sorted.map((c) => c.raw)).toContain('.group:is(:hover, [data-hover]) &')
})

test('get() returns selector for dynamic condition', () => {
const conditions = new Conditions({
conditions: {
groupHover: (name?: string) => (name ? `.group\\/${name}:hover &` : '.group:hover &'),
},
})
expect(conditions.get('_groupHover')).toBe('.group:hover &')
expect(conditions.get('_groupHover/card')).toBe('.group\\/card:hover &')
})
})
})
93 changes: 87 additions & 6 deletions packages/core/src/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ConditionDetails,
ConditionQuery,
Conditions as ConditionsConfig,
DynamicConditionFn,
ThemeVariantsMap,
} from '@pandacss/types'
import { Breakpoints } from './breakpoints'
Expand Down Expand Up @@ -42,8 +43,19 @@ interface Options {
const underscoreRegex = /^_/
const selectorRegex = /&|@/

/** Parse _base or _base/arg into [baseName, arg]. */
function parseDynamicKey(key: string): [string, string | undefined] | null {
if (!key.startsWith('_')) return null
const rest = key.slice(1)
const i = rest.indexOf('/')
if (i === -1) return [rest, undefined]
return [rest.slice(0, i), rest.slice(i + 1)]
}

export class Conditions {
values: Record<string, ConditionDetails>
dynamicConditions: Record<string, DynamicConditionFn>
private dynamicCache = new Map<string, ConditionDetails>()

breakpoints: Breakpoints

Expand All @@ -53,19 +65,35 @@ export class Conditions {
const breakpoints = new Breakpoints(breakpointValues)
this.breakpoints = breakpoints

const entries = Object.entries(conditions).map(([key, value]) => [`_${key}`, parseCondition(value)])
const staticEntries: [string, ConditionDetails][] = []
const dynamicConditions: Record<string, DynamicConditionFn> = {}

for (const [key, value] of Object.entries(conditions)) {
if (typeof value === 'function') {
dynamicConditions[key] = value as DynamicConditionFn
} else {
const parsed = parseCondition(value as string | string[])
if (parsed) staticEntries.push([`_${key}`, parsed])
}
}

this.dynamicConditions = dynamicConditions

const containers = this.setupContainers()
const themes = this.setupThemes()

this.values = {
...Object.fromEntries(entries),
...Object.fromEntries(staticEntries),
...breakpoints.conditions,
...containers,
...themes,
}
}

getDynamicConditionNames = (): string[] => {
return Object.keys(this.dynamicConditions)
}

private setupContainers = () => {
const { containerNames = [], containerSizes = {} } = this.options

Expand Down Expand Up @@ -154,7 +182,11 @@ export class Conditions {
}

has = (key: string) => {
return Object.prototype.hasOwnProperty.call(this.values, key)
if (Object.prototype.hasOwnProperty.call(this.values, key)) return true
const parsed = parseDynamicKey(key)
if (!parsed) return false
const [base] = parsed
return Object.prototype.hasOwnProperty.call(this.dynamicConditions, base)
}

isCondition = (key: string) => {
Expand All @@ -167,16 +199,65 @@ export class Conditions {

get = (key: string): undefined | string | string[] => {
const details = this.values[key]
return details?.raw
if (details) return details.raw

const parsed = parseDynamicKey(key)
if (!parsed) return undefined
const [base, arg] = parsed
const fn = this.dynamicConditions[base]
if (!fn) return undefined
try {
const raw = fn(arg ?? '')
return Array.isArray(raw) ? raw : raw
} catch {
return undefined
}
}

getRaw = (condNameOrQuery: ConditionQuery): ConditionDetails | undefined => {
if (typeof condNameOrQuery === 'string' && this.values[condNameOrQuery]) return this.values[condNameOrQuery]
if (typeof condNameOrQuery !== 'string') {
try {
return parseCondition(condNameOrQuery)
} catch (error) {
logger.error('core:condition', error)
}
return undefined
}

if (this.values[condNameOrQuery]) return this.values[condNameOrQuery]

const parsed = parseDynamicKey(condNameOrQuery)
if (!parsed) {
try {
return parseCondition(condNameOrQuery)
} catch (error) {
logger.error('core:condition', error)
}
return undefined
}

const [base, arg] = parsed
const fn = this.dynamicConditions[base]
if (!fn) {
try {
return parseCondition(condNameOrQuery)
} catch (error) {
logger.error('core:condition', error)
}
return undefined
}

const cached = this.dynamicCache.get(condNameOrQuery)
if (cached) return cached

try {
return parseCondition(condNameOrQuery)
const selector = fn(arg ?? '')
const details = parseCondition(selector)
if (details) this.dynamicCache.set(condNameOrQuery, details)
return details
} catch (error) {
logger.error('core:condition', error)
return undefined
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/parse-condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export function parseCondition(condition: ConditionQuery): ConditionDetails | un
} as MixedCondition
}

if (typeof condition === 'function') {
return undefined
}

if (condition.startsWith('@')) {
return parseAtRule(condition)
}
Expand Down
16 changes: 12 additions & 4 deletions packages/generator/src/artifacts/js/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,33 @@ import type { Context } from '@pandacss/core'
import outdent from 'outdent'

export function generateConditions(ctx: Context) {
const keys = Object.keys(ctx.conditions.values).concat('base')
const staticKeys = Object.keys(ctx.conditions.values)
const dynamicNames = ctx.conditions.getDynamicConditionNames()
const dynamicPrefixes = dynamicNames.map((name) => '_' + name)
const keys = [...staticKeys, ...dynamicPrefixes, 'base']

const dynamicPrefixesStr = JSON.stringify(dynamicPrefixes)

return {
js: outdent`
${ctx.file.import('withoutSpace', '../helpers')}

const conditionsStr = "${keys.join(',')}"
const conditionsStr = "${staticKeys.join(',')}"
const conditions = new Set(conditionsStr.split(','))
const dynamicConditionPrefixes = ${dynamicPrefixesStr}

const conditionRegex = /^@|&|&$/

export function isCondition(value){
return conditions.has(value) || conditionRegex.test(value)
return conditions.has(value) || conditionRegex.test(value) || dynamicConditionPrefixes.some(prefix => value === prefix || value.startsWith(prefix + '/'))
}

const underscoreRegex = /^_/
const conditionsSelectorRegex = /&|@/

export function finalizeConditions(paths){
return paths.map((path) => {
if (conditions.has(path)){
if (conditions.has(path) || dynamicConditionPrefixes.some(prefix => path === prefix || path.startsWith(prefix + '/'))){
return path.replace(underscoreRegex, '')
}

Expand Down Expand Up @@ -58,6 +65,7 @@ export function generateConditions(ctx: Context) {
}\t${JSON.stringify(key)}: string`,
)
.join('\n')}
${dynamicNames.map((name) => `\t\`_${name}/\${string}\`: string`).join('\n')}
}

export type ConditionalValue<V> =
Expand Down
19 changes: 17 additions & 2 deletions packages/generator/src/spec/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const generateConditionsSpec = (ctx: Context): ConditionSpec => {
const jsxStyleProps = ctx.config.jsxStyleProps
const breakpointKeys = new Set(Object.keys(ctx.conditions.breakpoints.conditions))

const conditions = Object.entries(ctx.conditions.values).map(([name, detail]) => {
const staticConditions = Object.entries(ctx.conditions.values).map(([name, detail]) => {
const value = Array.isArray(detail.raw) ? detail.raw.join(', ') : detail.raw

// Check if this is a breakpoint condition
Expand All @@ -44,8 +44,23 @@ export const generateConditionsSpec = (ctx: Context): ConditionSpec => {
}
})

const dynamicNames = ctx.conditions.getDynamicConditionNames()
const dynamicConditions = dynamicNames.map((baseName) => {
const conditionName = '_' + baseName
const sampleValue = ctx.conditions.get(conditionName)
return {
name: conditionName,
value: (sampleValue ?? '(dynamic)') as string,
functionExamples: [
`css({ margin: { base: '2', ${conditionName}: '4' } })`,
`css({ margin: { base: '2', '${conditionName}/item': '4' } })`,
],
jsxExamples: generateConditionJsxExamples(conditionName, jsxStyleProps),
}
})

return {
type: 'conditions',
data: conditions,
data: [...staticConditions, ...dynamicConditions],
}
}
Loading
Loading