diff --git a/.changeset/modern-glasses-wait.md b/.changeset/modern-glasses-wait.md new file mode 100644 index 0000000000..5827730d9a --- /dev/null +++ b/.changeset/modern-glasses-wait.md @@ -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 diff --git a/packages/config/src/validation/validate-condition.ts b/packages/config/src/validation/validate-condition.ts index e9e12d9c76..4324f2c189 100644 --- a/packages/config/src/validation/validate-condition.ts +++ b/packages/config/src/validation/validate-condition.ts @@ -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 } diff --git a/packages/core/__tests__/atomic-rule.test.ts b/packages/core/__tests__/atomic-rule.test.ts index fa9ccb59af..f905fc8589 100644 --- a/packages/core/__tests__/atomic-rule.test.ts +++ b/packages/core/__tests__/atomic-rule.test.ts @@ -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({ diff --git a/packages/core/__tests__/conditions.test.ts b/packages/core/__tests__/conditions.test.ts index e2d4fd5c9c..0a683976cf 100644 --- a/packages/core/__tests__/conditions.test.ts +++ b/packages/core/__tests__/conditions.test.ts @@ -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 &') + }) + }) }) diff --git a/packages/core/src/conditions.ts b/packages/core/src/conditions.ts index 5b9a5f189e..495b0093ee 100644 --- a/packages/core/src/conditions.ts +++ b/packages/core/src/conditions.ts @@ -4,6 +4,7 @@ import type { ConditionDetails, ConditionQuery, Conditions as ConditionsConfig, + DynamicConditionFn, ThemeVariantsMap, } from '@pandacss/types' import { Breakpoints } from './breakpoints' @@ -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 + dynamicConditions: Record + private dynamicCache = new Map() breakpoints: Breakpoints @@ -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 = {} + + 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 @@ -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) => { @@ -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 } } diff --git a/packages/core/src/parse-condition.ts b/packages/core/src/parse-condition.ts index 05da93f2ba..747095dac7 100644 --- a/packages/core/src/parse-condition.ts +++ b/packages/core/src/parse-condition.ts @@ -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) } diff --git a/packages/generator/src/artifacts/js/conditions.ts b/packages/generator/src/artifacts/js/conditions.ts index d50161b337..d8a9f44f37 100644 --- a/packages/generator/src/artifacts/js/conditions.ts +++ b/packages/generator/src/artifacts/js/conditions.ts @@ -2,18 +2,25 @@ 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 = /^_/ @@ -21,7 +28,7 @@ export function generateConditions(ctx: Context) { 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, '') } @@ -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 = diff --git a/packages/generator/src/spec/conditions.ts b/packages/generator/src/spec/conditions.ts index 8ba4f79bbf..f860674c68 100644 --- a/packages/generator/src/spec/conditions.ts +++ b/packages/generator/src/spec/conditions.ts @@ -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 @@ -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], } } diff --git a/packages/preset-base/src/conditions.ts b/packages/preset-base/src/conditions.ts index 3a6c3796d3..efd8c06e1e 100644 --- a/packages/preset-base/src/conditions.ts +++ b/packages/preset-base/src/conditions.ts @@ -1,3 +1,5 @@ +const group = (state: string) => (name?: string) => (name ? `.group\\/${name}${state} &` : `.group${state} &`) + export const conditions = { hover: '&:is(:hover, [data-hover])', focus: '&:is(:focus, [data-focus])', @@ -32,6 +34,7 @@ export const conditions = { only: '&:only-child', even: '&:nth-child(even)', odd: '&:nth-child(odd)', + nth: (value?: string) => `&:nth-child(${value ?? 'n'})`, firstOfType: '&:first-of-type', lastOfType: '&:last-of-type', @@ -48,15 +51,15 @@ export const conditions = { peerExpanded: '.peer:is([aria-expanded=true], [data-expanded], [data-state="expanded"]) ~ &', peerPlaceholderShown: '.peer:placeholder-shown ~ &', - groupFocus: '.group:is(:focus, [data-focus]) &', - groupHover: '.group:is(:hover, [data-hover]) &', - groupActive: '.group:is(:active, [data-active]) &', - groupFocusWithin: '.group:focus-within &', - groupFocusVisible: '.group:is(:focus-visible, [data-focus-visible]) &', - groupDisabled: '.group:is(:disabled, [disabled], [data-disabled], [aria-disabled=true]) &', - groupChecked: '.group:is(:checked, [data-checked], [aria-checked=true], [data-state="checked"]) &', - groupExpanded: '.group:is([aria-expanded=true], [data-expanded], [data-state="expanded"]) &', - groupInvalid: '.group:is(:invalid, [data-invalid], [aria-invalid=true]) &', + groupFocus: group(':is(:focus, [data-focus])'), + groupHover: group(':is(:hover, [data-hover])'), + groupActive: group(':is(:active, [data-active])'), + groupFocusWithin: group(':focus-within'), + groupFocusVisible: group(':is(:focus-visible, [data-focus-visible])'), + groupDisabled: group(':is(:disabled, [disabled], [data-disabled], [aria-disabled=true])'), + groupChecked: group(':is(:checked, [data-checked], [aria-checked=true], [data-state="checked"])'), + groupExpanded: group(':is([aria-expanded=true], [data-expanded], [data-state="expanded"])'), + groupInvalid: group(':is(:invalid, [data-invalid], [aria-invalid=true])'), indeterminate: '&:is(:indeterminate, [data-indeterminate], [aria-checked=mixed], [data-state="indeterminate"])', required: '&:is(:required, [data-required], [aria-required=true])', diff --git a/packages/types/src/conditions.ts b/packages/types/src/conditions.ts index 95e6440e6f..7170f87843 100644 --- a/packages/types/src/conditions.ts +++ b/packages/types/src/conditions.ts @@ -28,7 +28,9 @@ export interface MixedCondition { * Shadowed export (in CLI): DO NOT REMOVE * -----------------------------------------------------------------------------*/ -export type ConditionQuery = string | string[] +export type DynamicConditionFn = (arg?: string) => string + +export type ConditionQuery = string | string[] | DynamicConditionFn export interface Conditions { [condition: string]: ConditionQuery diff --git a/website/content/docs/concepts/conditional-styles.mdx b/website/content/docs/concepts/conditional-styles.mdx index 7ccc3500ea..835845ab14 100644 --- a/website/content/docs/concepts/conditional-styles.mdx +++ b/website/content/docs/concepts/conditional-styles.mdx @@ -166,6 +166,23 @@ You can also style even and odd elements using the `_even` and `_odd` modifier: ``` +### Nth child (dynamic) + +For a specific `:nth-child()` value, use the `_nth/` condition: + +```jsx +
    + {items.map((item, i) => ( +
  • + {item} +
  • + ))} +
+``` + +Only the 3rd child will get the red color. You can use any valid `:nth-child()` value, e.g. `_nth/2n`, `_nth/2n+1`, +`_nth/5`. + ## Pseudo Elements ### Before and After @@ -329,8 +346,31 @@ parent element, and use any of the `_group*` modifiers on the child element. ``` -This modifer for every pseudo class modifiers like `_groupHover`, `_groupActive`, `_groupFocus`, and `_groupDisabled`, -etc. +This works for every pseudo class modifier like `_groupHover`, `_groupActive`, `_groupFocus`, and `_groupDisabled`, etc. + +### Group with modifier (named groups) + +When you have multiple groups in the same tree, you can give each group a **modifier** so only the matching child is +styled. Use the class `group/` on the parent and the condition `_groupHover/` (and other `_group*` variants) +on the child. + +```jsx +
+

Hover the card

+
+ +
+

Hover the item

+
+``` + +The modifier name after the slash (e.g. `item`) becomes part of the selector (`.group\/item:is(:hover, ...) &`), so only +the element with `className="group/item"` triggers the style. Use the same name in the condition key: `_groupHover/item` +for `group/item`, `_groupHover/card` for `group/card`. + +> **Note:** In object keys you must use a string when the key contains a slash: `'_groupHover/item'`. In the `css` prop, +> use the same syntax. JSX does not allow `/` in prop names, so use the `css` prop or a quoted key in a style object for +> dynamic conditions with modifiers. ## Sibling Selectors @@ -535,7 +575,7 @@ function Demo() { Here's a list of all the condition shortcuts you can use in Panda: | Condition name | Selector | -| ---------------------- | -------------------------------------------------------------------------------------------------| +| ---------------------- | ------------------------------------------------------------------------------------------------ | | \_hover | `&:is(:hover, [data-hover])` | | \_focus | `&:is(:focus, [data-focus])` | | \_focusWithin | `&:focus-within` | @@ -567,6 +607,7 @@ Here's a list of all the condition shortcuts you can use in Panda: | \_only | `&:only-child` | | \_even | `&:nth-child(even)` | | \_odd | `&:nth-child(odd)` | +| \_nth/\ | `&:nth-child()` | | \_firstOfType | `&:first-of-type` | | \_lastOfType | `&:last-of-type` | | \_onlyOfType | `&:only-of-type` | @@ -646,5 +687,7 @@ Here's a list of all the condition shortcuts you can use in Panda: ## Custom conditions -Panda lets you create your own conditions, so you're not limited to the ones in the default preset. Learn more about -customizing conditions [here](/docs/customization/conditions). +Panda lets you create your own conditions, so you're not limited to the ones in the default preset. You can define +static selectors or **dynamic conditions** (functions that take an argument). Learn more about +[customizing conditions](/docs/customization/conditions), including +[dynamic conditions](/docs/customization/conditions#dynamic-conditions). diff --git a/website/content/docs/customization/conditions.mdx b/website/content/docs/customization/conditions.mdx index d08090979c..86f1d2d63e 100644 --- a/website/content/docs/customization/conditions.mdx +++ b/website/content/docs/customization/conditions.mdx @@ -133,6 +133,35 @@ export default defineConfig({ }) ``` +## Dynamic conditions + +Conditions can be **functions** that take an optional argument and return a selector string. This lets one condition name support both a base form (`_name`) and a parameterized form (`_name/arg`). The argument is passed when you use the slash syntax in your styles; the selector is resolved at build time. + +```ts filename="panda.config.ts" +import { defineConfig } from '@pandacss/dev' + +export default defineConfig({ + conditions: { + extend: { + // (arg?) => string: no arg = base selector, with arg = modifier in selector + groupHover: (name?: string) => + name + ? `.group\\/${name}:is(:hover, [data-hover]) &` + : '.group:is(:hover, [data-hover]) &', + nth: (value?: string) => `&:nth-child(${value ?? 'n'})` + } + } +}) +``` + +Then you can use: + +- `_groupHover` — matches when a parent with class `group` is hovered +- `_groupHover/item` — matches when a parent with class `group/item` is hovered (use `className="group/item"` on the parent) +- `_nth/3` — matches the 3rd child (`&:nth-child(3)`) + +The function receives the part after the slash (e.g. `item` for `_groupHover/item`). In CSS, the class name `group/item` is written as `.group\/item` (escaped slash), so the function returns that selector. Dynamic conditions are resolved at **build time** and cached; your styles are fully resolved in the generated CSS. + ## Mixed conditions You can also use mixed conditions (nested at-rules/selectors) under a single condition name: