Skip to content

Commit 71c008e

Browse files
Bobakanooshbenjamincanacsandros94
authored
feat(Theme): override component prop defaults (#6031)
Co-authored-by: Benjamin Canac <canacb1@gmail.com> Co-authored-by: Sandro Circi <sandro.circi@digitoolmedia.com>
1 parent 6e96375 commit 71c008e

196 files changed

Lines changed: 4081 additions & 2780 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/contributing/component-structure.md

Lines changed: 83 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -50,34 +50,36 @@ export interface ComponentNameSlots {
5050
import { computed } from 'vue'
5151
import { Primitive } from 'reka-ui'
5252
import { useAppConfig } from '#imports'
53-
import { useComponentUI } from '../composables/useComponentUI'
53+
import { useComponentProps } from '../composables/useComponentProps'
5454
import { tv } from '../utils/tv'
5555
56-
// 7. Props with withDefaults for runtime defaults
57-
const props = withDefaults(defineProps<ComponentNameProps>(), {
58-
as: 'div'
59-
})
56+
// 7. Raw props (use withDefaults only when you actually need a runtime default)
57+
const _props = defineProps<ComponentNameProps>()
6058
const slots = defineSlots<ComponentNameSlots>()
6159
62-
// 8. App config
63-
const appConfig = useAppConfig() as ComponentName['AppConfig']
60+
// 8. Theme-aware proxy: resolves explicit > <UTheme :props> > withDefaults
61+
// > app.config.ui.<name>.defaultVariants. The `ui` prop is deep-merged
62+
// automatically, so reach for `props.ui?.<slot>` in the template.
63+
// `theme.defaultVariants` is NOT in this chain — it only feeds `tv()`
64+
// class resolution.
65+
const props = useComponentProps('componentName', _props)
6466
65-
// 9. Theme-aware ui prop - merges Theme context with component ui prop
66-
const uiProp = useComponentUI('componentName', props)
67+
// 9. App config
68+
const appConfig = useAppConfig() as ComponentName['AppConfig']
6769
6870
// 10. Computed UI - always computed for reactivity
69-
const ui = computed(() => tv({
70-
extend: tv(theme),
71-
...(appConfig.ui?.componentName || {})
71+
const ui = computed(() => tv({
72+
extend: tv(theme),
73+
...(appConfig.ui?.componentName || {})
7274
})({
7375
color: props.color,
7476
size: props.size
7577
}))
7678
</script>
7779
7880
<template>
79-
<!-- 11. data-slot on all elements, use uiProp instead of props.ui -->
80-
<Primitive :as="as" data-slot="root" :class="ui.root({ class: [uiProp?.root, props.class] })">
81+
<!-- 11. data-slot on every element, always read props as `props.x` -->
82+
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
8183
<slot :ui="ui" />
8284
</Primitive>
8385
</template>
@@ -113,34 +115,40 @@ export interface CollapsibleSlots {
113115
114116
<script setup lang="ts">
115117
import { computed } from 'vue'
116-
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, useForwardPropsEmits } from 'reka-ui'
118+
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent } from 'reka-ui'
117119
import { reactivePick } from '@vueuse/core'
118120
import { useAppConfig } from '#imports'
119-
import { useComponentUI } from '../composables/useComponentUI'
121+
import { useComponentProps } from '../composables/useComponentProps'
122+
import { useForwardProps } from '../composables/useForwardProps'
120123
import { tv } from '../utils/tv'
121124
122-
const props = withDefaults(defineProps<CollapsibleProps>(), {
125+
const _props = withDefaults(defineProps<CollapsibleProps>(), {
123126
unmountOnHide: true
124127
})
125128
const emits = defineEmits<CollapsibleEmits>()
126129
const slots = defineSlots<CollapsibleSlots>()
127130
131+
// Theme-aware proxy. `props` deep-merges `ui` and resolves <UTheme :props> defaults.
132+
const props = useComponentProps('collapsible', _props)
133+
128134
const appConfig = useAppConfig() as Collapsible['AppConfig']
129-
const uiProp = useComponentUI('collapsible', props)
130135
131-
// Forward only Reka UI props
132-
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled', 'unmountOnHide'), emits)
136+
// Pick from `props` (the proxy) so theme-supplied values flow through.
137+
// Use the local `useForwardProps` — reka-ui's `useForwardProps` /
138+
// `useForwardPropsEmits` filter root props by `vm.vnode.props ∪ withDefaults`
139+
// and would strip <UTheme :props> values.
140+
const rootProps = useForwardProps(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled', 'unmountOnHide'), emits)
133141
134142
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.collapsible || {}) })())
135143
</script>
136144
137145
<template>
138-
<CollapsibleRoot v-slot="{ open }" v-bind="rootProps" data-slot="root" :class="ui.root({ class: [uiProp?.root, props.class] })">
146+
<CollapsibleRoot v-slot="{ open }" v-bind="rootProps" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
139147
<CollapsibleTrigger v-if="!!slots.default" as-child>
140148
<slot :open="open" />
141149
</CollapsibleTrigger>
142150
143-
<CollapsibleContent data-slot="content" :class="ui.content({ class: uiProp?.content })">
151+
<CollapsibleContent data-slot="content" :class="ui.content({ class: props.ui?.content })">
144152
<slot name="content" />
145153
</CollapsibleContent>
146154
</CollapsibleRoot>
@@ -187,12 +195,32 @@ import { useFieldGroup } from '../composables/useFieldGroup'
187195
188196
defineOptions({ inheritAttrs: false })
189197
190-
const {
191-
id, name, size, color, highlight, disabled,
192-
ariaAttrs, emitFormBlur, emitFormInput, emitFormChange
193-
} = useFormField<InputProps>(props, { deferInputValidation: true })
194-
195-
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
198+
// Pass raw `_props` (not the proxy) so the wrapping `<UFormField>` /
199+
// `<UFieldGroup>` keep precedence over `<UTheme :props>` / `withDefaults` /
200+
// `app.config` defaults. Their internal fallback is `props?.x ?? injected.x`,
201+
// so handing them the proxy would leak theme defaults into "explicit prop"
202+
// and silently override the wrapper.
203+
const {
204+
id, name, size: formFieldSize, color, highlight, disabled,
205+
ariaAttrs, emitFormBlur, emitFormInput, emitFormChange
206+
} = useFormField<InputProps>(_props, { deferInputValidation: true })
207+
208+
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_props)
209+
210+
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
211+
212+
// In `tv()` calls, fall back to `props.X` (the proxy) so `<UTheme :props>`
213+
// applies when there is no wrapping FormField/FieldGroup. Without `?? props.X`,
214+
// theme size/color/highlight is silently dropped on bare inputs.
215+
//
216+
// Final precedence: explicit > closer-context (form/group) > <UTheme :props>
217+
// > withDefaults > app.config > tv defaults
218+
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.input || {}) })({
219+
color: color.value ?? props.color,
220+
size: inputSize.value ?? props.size,
221+
highlight: highlight.value ?? props.highlight,
222+
variant: props.variant
223+
}))
196224
</script>
197225
198226
<template>
@@ -208,6 +236,8 @@ const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
208236
</template>
209237
```
210238

239+
The same `?? props.X` pattern applies to `useAvatarGroup` (`size`) and any other context composable whose contract is `props?.x ?? injected.x`. The composable itself stays untouched — the fallback lives at the `tv()` call site so the wrapper-vs-theme precedence is explicit and reviewable.
240+
211241
## Components with Icons
212242

213243
```vue
@@ -235,43 +265,35 @@ defineExpose({
235265
</script>
236266
```
237267

238-
## Resolving Variants in Template Logic
268+
## Theme Defaults
239269

240-
`tv()`'s `defaultVariants` only apply when computing CSS classes — they do **not** affect runtime checks (e.g. `<component :is>`, `v-if`, computed conditionals). When a variant drives template logic, use `useResolvedVariants` to mirror `tv()`'s resolution: **prop > `app.config.ts` `defaultVariants` > fallback**.
270+
`useComponentProps` is the primary integration with `<UTheme>`. The proxy resolves the priority chain **explicit prop > nearest `<UTheme :props>` > `withDefaults` > `app.config.ui.<name>.defaultVariants`** for every prop — including ones driving template logic that `tv().defaultVariants` can't reach (`<component :is>`, `v-if`, computed conditionals). `theme.defaultVariants` is intentionally NOT in the proxy chain — it only feeds `tv()` class resolution. If a prop value is consumed in template logic, it must come from one of the proxy-resolved sources (typically `withDefaults`):
241271

242272
```vue
243-
<script setup lang="ts">
244-
import { useResolvedVariants } from '../composables/useResolvedVariants'
245-
246-
const { variant } = useResolvedVariants('radioGroup', props, theme, ['variant'])
247-
248-
// Use variant.value in template logic and pass it to tv()
249-
</script>
250-
251273
<template>
252-
<component :is="variant === 'list' ? 'div' : Label" />
274+
<component :is="props.variant === 'list' ? 'div' : Label" />
253275
</template>
254276
```
255277

256-
For nested prop paths (e.g. `props.content?.position`), use the `overrides` parameter:
257-
258-
```ts
259-
const { position } = useResolvedVariants('select', props, theme, ['position'], {
260-
position: () => props.content?.position
261-
})
262-
```
278+
Notes:
279+
- The proxy passes through to `_props` for explicitly set props, so `withDefaults` fallbacks stay lower priority than `<UTheme>` overrides.
280+
- The `ui` prop is deep-merged (slot classes layered on top of theme overrides). All other props are explicit-wins.
281+
- **Always read props as `props.x` in templates and `<script setup>`.** Bare prop names (`{{ label }}`, `v-if="arrow"`) resolve to `_props` and bypass the proxy, so `<UTheme :props>` defaults won't apply. The `nuxt-ui/no-bare-prop-refs` ESLint rule autofixes this.
282+
- Pass the **raw** `_props` (not the proxy) to context composables — `useFormField`, `useFieldGroup`, `useAvatarGroup`. Their internal fallback is `props?.x ?? injected.x`, so the wrapping `<UFormField>` / `<UFieldGroup>` / `<UAvatarGroup>` should beat `<UTheme :props>` / `withDefaults` / `app.config` defaults (closer context wins). **Then always fall back to the proxy in `tv()` calls**`size: formSize.value ?? props.size`, `color: color.value ?? props.color`, `highlight: highlight.value ?? props.highlight`. Without `?? props.X`, `<UTheme :props>` is silently dropped when no closer context wraps the component. Final chain: `explicit > closer-context > UTheme > withDefaults > app.config > tv defaults`. `useComponentIcons` has no injection chain, so pass the proxy `props` directly.
283+
- Reka primitives' `useForwardProps` / `useForwardPropsEmits` filter root props by `vm.vnode.props ∪ withDefaults` and would strip theme-supplied values. Import `useForwardProps` from `composables/useForwardProps.ts` instead — same `(source, emits?)` signature, proxy-aware.
263284

264285
## Key Patterns
265286

266287
| Pattern | Usage |
267288
|---------|-------|
289+
| `useComponentProps(name, _props)` | Theme-aware proxy — default for new components |
290+
| `useForwardProps(source, emits?)` (local) | Forward Reka UI props/emits without filtering theme defaults |
268291
| `withDefaults` | Runtime default values |
269292
| `defineOptions({ inheritAttrs: false })` | When spreading `$attrs` to inner element |
270-
| `reactivePick` + `useForwardPropsEmits` | Forward Reka UI props/emits |
293+
| `reactivePick` | Pick keys off `props` (the proxy) before forwarding |
271294
| `createReusableTemplate` | Complex template reuse (Table, Modal) |
272295
| `useTemplateRef` | Template refs (Vue 3.5+) |
273296
| `toRef(() => props.x)` | Reactive prop access |
274-
| `useResolvedVariants` | Resolve variants for template logic (when variant drives `<component :is>`, `v-if`, etc.) |
275297

276298
## Export Types
277299

@@ -280,3 +302,16 @@ Add to `src/runtime/types/index.ts`:
280302
```ts
281303
export * from '../components/ComponentName.vue'
282304
```
305+
306+
## Register in `ThemeDefaults`
307+
308+
The `ThemeDefaults` interface in `src/runtime/composables/useComponentProps.ts` powers autocomplete inside `<UTheme :props="{ componentName: { … } }">`. The CLI scaffolder (`nuxt-ui make component`) auto-inserts the entry; only do this manually if you skipped the CLI:
309+
310+
```ts
311+
export interface ThemeDefaults {
312+
// ... existing entries
313+
componentName?: Partial<ComponentTypes.ComponentNameProps>
314+
}
315+
```
316+
317+
The key is the component name in camelCase (matches the `#build/ui` registry). The value is `Partial<XProps>`. This is a flat literal interface (not a mapped type) because Volar only surfaces inner-prop autocomplete for interface members, not mapped-type members, in template inline objects.

AGENTS.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,11 @@ Load these based on your task. **Do not load all files at once** — only load w
9090
| Props defaults | Use `withDefaults()` for runtime, JSDoc `@defaultValue` for docs |
9191
| Template slots | Add `data-slot="name"` attributes on all elements |
9292
| Computed ui | Always use `computed(() => tv(...))` for reactive theming |
93-
| Theme support | Use `useComponentUI(name, props)` to merge Theme context with component `ui` prop |
93+
| Theme defaults | Wrap raw props with `useComponentProps(name, _props)` to resolve the priority chain (explicit prop > `<UTheme :props>` > `withDefaults` > `app.config.ui.<name>.defaultVariants`). The proxy deep-merges `ui` automatically — read `props.ui?.<slot>` in templates. `theme.defaultVariants` is **not** read by the proxy — it only feeds `tv()` class resolution. Pass the **raw** `_props` (not the proxy) to `useFormField` / `useFieldGroup` / `useAvatarGroup` so their injection precedence (closer context wins) stays correct. |
94+
| Form/group fallback | When consuming `size` / `color` / `highlight` from `useFormField`, `useFieldGroup`, or `useAvatarGroup`, always fall back to the proxy in `tv()` calls: `size: size.value ?? props.size`, `color: color.value ?? props.color`, `highlight: highlight.value ?? props.highlight`. This gives the full precedence `explicit > group/formField > <UTheme :props> > undefined`. Without the `?? props.X` fallback, `<UTheme :props>` is silently dropped when the closer context (FormField/FieldGroup/AvatarGroup) is absent. |
9495
| Semantic colors | Use `text-default`, `bg-elevated`, etc. - never Tailwind palette |
95-
| Reka UI props | Use `reactivePick` + `useForwardPropsEmits` to forward props |
96+
| Reka UI props | Use `reactivePick` + `useForwardProps(source, emits?)` from `composables/useForwardProps` to forward props (proxy-aware; reka-ui's `useForwardProps` / `useForwardPropsEmits` filter out `<UTheme :props>` defaults) |
9697
| Form components | Use `useFormField` and `useFieldGroup` composables |
97-
| Variant in template logic | Use `useResolvedVariants(name, props, theme, ['variant'])` when variant values are consumed in template logic (`<component :is>`, `v-if`, computed) — `tv()` `defaultVariants` only affect classes, not runtime checks |
9898

9999
## Component Creation Workflow
100100

@@ -107,12 +107,13 @@ Progress:
107107
- [ ] 2. Implement component in src/runtime/components/
108108
- [ ] 3. Create theme in src/theme/
109109
- [ ] 4. Export types from src/runtime/types/index.ts
110-
- [ ] 5. Write tests in test/components/
111-
- [ ] 6. Create docs in docs/content/docs/2.components/
112-
- [ ] 7. Add playground page
113-
- [ ] 8. Run pnpm run lint
114-
- [ ] 9. Run pnpm run typecheck
115-
- [ ] 10. Run pnpm run test
110+
- [ ] 5. Register in ThemeDefaults interface (src/runtime/composables/useComponentProps.ts)
111+
- [ ] 6. Write tests in test/components/
112+
- [ ] 7. Create docs in docs/content/docs/2.components/
113+
- [ ] 8. Add playground page
114+
- [ ] 9. Run pnpm run lint
115+
- [ ] 10. Run pnpm run typecheck
116+
- [ ] 11. Run pnpm run test
116117
```
117118

118119
### PR Review Checklist

cli/commands/make/component.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { resolve } from 'pathe'
33
import { defineCommand } from 'citty'
44
import { consola } from 'consola'
55
import { splitByCase, upperFirst, camelCase, kebabCase } from 'scule'
6-
import { appendFile, sortFile } from '../../utils.mjs'
6+
import { appendFile, appendThemeDefault, sortFile } from '../../utils.mjs'
77
import templates from '../../templates.mjs'
88

99
export default defineCommand({
@@ -75,8 +75,12 @@ export default defineCommand({
7575

7676
if (!args.prose) {
7777
const typesPath = resolve(path, 'src/runtime/types/index.ts')
78-
await appendFile(typesPath, `export * from '../components/${args.content ? 'content/' : ''}${splitByCase(name).map(p => upperFirst(p)).join('')}.vue'`)
78+
const pascal = splitByCase(name).map(p => upperFirst(p)).join('')
79+
await appendFile(typesPath, `export * from '../components/${args.content ? 'content/' : ''}${pascal}.vue'`)
7980
await sortFile(typesPath)
81+
82+
const useComponentPropsPath = resolve(path, 'src/runtime/composables/useComponentProps.ts')
83+
await appendThemeDefault(useComponentPropsPath, camelCase(name), `${pascal}Props`)
8084
}
8185
}
8286
})

0 commit comments

Comments
 (0)