Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e8f6242
feat(UTheme): variants poc
Bobakanoosh Feb 13, 2026
5bb24e4
Merge branch 'v4' into feat/theme-variants
benjamincanac Apr 17, 2026
fcb206e
Merge branch 'v4' into feat/theme-variants
benjamincanac Apr 21, 2026
d7b5565
feat(Theme): add variants prop and unify theme context
benjamincanac Apr 21, 2026
d12961b
feat(Theme): replace `:variants` with `:props` for any-prop overrides
benjamincanac Apr 28, 2026
c342c29
Merge branch 'v4' into feat/theme-variants
benjamincanac Apr 28, 2026
59e7e92
refactor(Theme): rename useComponentDefaults to useComponentProps and…
benjamincanac Apr 28, 2026
c47df55
refactor(Theme): switch ThemeDefaults to flat literal interface
benjamincanac Apr 29, 2026
cf80cda
feat(cli): scaffold components with useComponentProps and auto-regist…
benjamincanac Apr 29, 2026
9f23628
docs(contributing): align component templates with useComponentProps …
benjamincanac Apr 29, 2026
e299d0c
feat(Theme): migrate all components to :props proxy
benjamincanac Apr 29, 2026
09b8dfe
test(Theme): cover closer-context precedence for FieldGroup and Avata…
benjamincanac Apr 29, 2026
52fce9c
docs(contributing): correct useComponentProps chain and fix CLI prose…
benjamincanac Apr 29, 2026
4d138d4
fix(Theme): catch bare prop refs from inherited interfaces
benjamincanac Apr 29, 2026
1753a5a
fix(Theme): route remaining script-block prop reads through the proxy
benjamincanac Apr 29, 2026
02e00c5
fix(Carousel,DashboardSidebar): align proxy reads and JSDoc with runtime
benjamincanac Apr 29, 2026
c47bac7
fix(ContentNavigation): route `level` reads through the proxy
benjamincanac Apr 29, 2026
90dcd77
fix(form): respect <UTheme :props> on bare inputs without UFormField
benjamincanac Apr 30, 2026
f148b79
docs(theme): document _props + ?? props.X fallback in form/group comp…
benjamincanac Apr 30, 2026
ac90ac2
test(theme): cover bare form inputs and cross-component nested :props
benjamincanac Apr 30, 2026
c8c1025
feat(Listbox): expose color prop matching theme variant
benjamincanac Apr 30, 2026
299276a
docs(theme): clarify nested inheritance and form component precedence
benjamincanac Apr 30, 2026
eddf64c
fix: use local proxy-aware `useForwardProps` for the rest of the comp…
sandros94 Apr 30, 2026
aa2c602
fix(Theme): drop ThemeDefaults entries that don't honor :props
sandros94 Apr 30, 2026
a98ea77
test(Theme): pin ThemeDefaults to the #build/ui registry
sandros94 Apr 30, 2026
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
131 changes: 83 additions & 48 deletions .github/contributing/component-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,34 +50,36 @@ export interface ComponentNameSlots {
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { useAppConfig } from '#imports'
import { useComponentUI } from '../composables/useComponentUI'
import { useComponentProps } from '../composables/useComponentProps'
import { tv } from '../utils/tv'

// 7. Props with withDefaults for runtime defaults
const props = withDefaults(defineProps<ComponentNameProps>(), {
as: 'div'
})
// 7. Raw props (use withDefaults only when you actually need a runtime default)
const _props = defineProps<ComponentNameProps>()
const slots = defineSlots<ComponentNameSlots>()

// 8. App config
const appConfig = useAppConfig() as ComponentName['AppConfig']
// 8. Theme-aware proxy: resolves explicit > <UTheme :props> > withDefaults
// > app.config.ui.<name>.defaultVariants. The `ui` prop is deep-merged
// automatically, so reach for `props.ui?.<slot>` in the template.
// `theme.defaultVariants` is NOT in this chain β€” it only feeds `tv()`
// class resolution.
const props = useComponentProps('componentName', _props)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 9. Theme-aware ui prop - merges Theme context with component ui prop
const uiProp = useComponentUI('componentName', props)
// 9. App config
const appConfig = useAppConfig() as ComponentName['AppConfig']

// 10. Computed UI - always computed for reactivity
const ui = computed(() => tv({
extend: tv(theme),
...(appConfig.ui?.componentName || {})
const ui = computed(() => tv({
extend: tv(theme),
...(appConfig.ui?.componentName || {})
})({
color: props.color,
size: props.size
}))
</script>

<template>
<!-- 11. data-slot on all elements, use uiProp instead of props.ui -->
<Primitive :as="as" data-slot="root" :class="ui.root({ class: [uiProp?.root, props.class] })">
<!-- 11. data-slot on every element, always read props as `props.x` -->
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :ui="ui" />
</Primitive>
</template>
Expand Down Expand Up @@ -113,34 +115,40 @@ export interface CollapsibleSlots {

<script setup lang="ts">
import { computed } from 'vue'
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, useForwardPropsEmits } from 'reka-ui'
import { CollapsibleRoot, CollapsibleTrigger, CollapsibleContent } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useComponentUI } from '../composables/useComponentUI'
import { useComponentProps } from '../composables/useComponentProps'
import { useForwardProps } from '../composables/useForwardProps'
import { tv } from '../utils/tv'

const props = withDefaults(defineProps<CollapsibleProps>(), {
const _props = withDefaults(defineProps<CollapsibleProps>(), {
unmountOnHide: true
})
const emits = defineEmits<CollapsibleEmits>()
const slots = defineSlots<CollapsibleSlots>()

// Theme-aware proxy. `props` deep-merges `ui` and resolves <UTheme :props> defaults.
const props = useComponentProps('collapsible', _props)

const appConfig = useAppConfig() as Collapsible['AppConfig']
const uiProp = useComponentUI('collapsible', props)

// Forward only Reka UI props
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled', 'unmountOnHide'), emits)
// Pick from `props` (the proxy) so theme-supplied values flow through.
// Use the local `useForwardProps` β€” reka-ui's `useForwardProps` /
// `useForwardPropsEmits` filter root props by `vm.vnode.props βˆͺ withDefaults`
// and would strip <UTheme :props> values.
const rootProps = useForwardProps(reactivePick(props, 'as', 'defaultOpen', 'open', 'disabled', 'unmountOnHide'), emits)

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.collapsible || {}) })())
</script>

<template>
<CollapsibleRoot v-slot="{ open }" v-bind="rootProps" data-slot="root" :class="ui.root({ class: [uiProp?.root, props.class] })">
<CollapsibleRoot v-slot="{ open }" v-bind="rootProps" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<CollapsibleTrigger v-if="!!slots.default" as-child>
<slot :open="open" />
</CollapsibleTrigger>

<CollapsibleContent data-slot="content" :class="ui.content({ class: uiProp?.content })">
<CollapsibleContent data-slot="content" :class="ui.content({ class: props.ui?.content })">
<slot name="content" />
</CollapsibleContent>
</CollapsibleRoot>
Expand Down Expand Up @@ -187,12 +195,32 @@ import { useFieldGroup } from '../composables/useFieldGroup'

defineOptions({ inheritAttrs: false })

const {
id, name, size, color, highlight, disabled,
ariaAttrs, emitFormBlur, emitFormInput, emitFormChange
} = useFormField<InputProps>(props, { deferInputValidation: true })

const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
// Pass raw `_props` (not the proxy) so the wrapping `<UFormField>` /
// `<UFieldGroup>` keep precedence over `<UTheme :props>` / `withDefaults` /
// `app.config` defaults. Their internal fallback is `props?.x ?? injected.x`,
// so handing them the proxy would leak theme defaults into "explicit prop"
// and silently override the wrapper.
const {
id, name, size: formFieldSize, color, highlight, disabled,
ariaAttrs, emitFormBlur, emitFormInput, emitFormChange
} = useFormField<InputProps>(_props, { deferInputValidation: true })

const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_props)

const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)

// In `tv()` calls, fall back to `props.X` (the proxy) so `<UTheme :props>`
// applies when there is no wrapping FormField/FieldGroup. Without `?? props.X`,
// theme size/color/highlight is silently dropped on bare inputs.
//
// Final precedence: explicit > closer-context (form/group) > <UTheme :props>
// > withDefaults > app.config > tv defaults
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.input || {}) })({
color: color.value ?? props.color,
size: inputSize.value ?? props.size,
highlight: highlight.value ?? props.highlight,
variant: props.variant
}))
</script>

<template>
Expand All @@ -208,6 +236,8 @@ const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(props)
</template>
```

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.

## Components with Icons

```vue
Expand Down Expand Up @@ -235,43 +265,35 @@ defineExpose({
</script>
```

## Resolving Variants in Template Logic
## Theme Defaults

`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**.
`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`):

```vue
<script setup lang="ts">
import { useResolvedVariants } from '../composables/useResolvedVariants'

const { variant } = useResolvedVariants('radioGroup', props, theme, ['variant'])

// Use variant.value in template logic and pass it to tv()
</script>

<template>
<component :is="variant === 'list' ? 'div' : Label" />
<component :is="props.variant === 'list' ? 'div' : Label" />
</template>
```

For nested prop paths (e.g. `props.content?.position`), use the `overrides` parameter:

```ts
const { position } = useResolvedVariants('select', props, theme, ['position'], {
position: () => props.content?.position
})
```
Notes:
- The proxy passes through to `_props` for explicitly set props, so `withDefaults` fallbacks stay lower priority than `<UTheme>` overrides.
- The `ui` prop is deep-merged (slot classes layered on top of theme overrides). All other props are explicit-wins.
- **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.
- 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.
- 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.

## Key Patterns

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

## Export Types

Expand All @@ -280,3 +302,16 @@ Add to `src/runtime/types/index.ts`:
```ts
export * from '../components/ComponentName.vue'
```

## Register in `ThemeDefaults`

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:

```ts
export interface ThemeDefaults {
// ... existing entries
componentName?: Partial<ComponentTypes.ComponentNameProps>
}
```

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.
19 changes: 10 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ Load these based on your task. **Do not load all files at once** β€” only load w
| Props defaults | Use `withDefaults()` for runtime, JSDoc `@defaultValue` for docs |
| Template slots | Add `data-slot="name"` attributes on all elements |
| Computed ui | Always use `computed(() => tv(...))` for reactive theming |
| Theme support | Use `useComponentUI(name, props)` to merge Theme context with component `ui` prop |
| 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. |
| 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. |
| Semantic colors | Use `text-default`, `bg-elevated`, etc. - never Tailwind palette |
| Reka UI props | Use `reactivePick` + `useForwardPropsEmits` to forward props |
| 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) |
| Form components | Use `useFormField` and `useFieldGroup` composables |
| 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 |

## Component Creation Workflow

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

### PR Review Checklist
Expand Down
8 changes: 6 additions & 2 deletions cli/commands/make/component.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolve } from 'pathe'
import { defineCommand } from 'citty'
import { consola } from 'consola'
import { splitByCase, upperFirst, camelCase, kebabCase } from 'scule'
import { appendFile, sortFile } from '../../utils.mjs'
import { appendFile, appendThemeDefault, sortFile } from '../../utils.mjs'
import templates from '../../templates.mjs'

export default defineCommand({
Expand Down Expand Up @@ -75,8 +75,12 @@ export default defineCommand({

if (!args.prose) {
const typesPath = resolve(path, 'src/runtime/types/index.ts')
await appendFile(typesPath, `export * from '../components/${args.content ? 'content/' : ''}${splitByCase(name).map(p => upperFirst(p)).join('')}.vue'`)
const pascal = splitByCase(name).map(p => upperFirst(p)).join('')
await appendFile(typesPath, `export * from '../components/${args.content ? 'content/' : ''}${pascal}.vue'`)
await sortFile(typesPath)

const useComponentPropsPath = resolve(path, 'src/runtime/composables/useComponentProps.ts')
await appendThemeDefault(useComponentPropsPath, camelCase(name), `${pascal}Props`)
}
}
})
Loading
Loading