Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/contributing/component-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,35 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.input || {})

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.

## `data-slot` on the root

Parents label a child by passing `data-slot` (e.g. `<UIcon data-slot="leadingIcon" />`, `<UAvatar data-slot="leadingAvatar" />`). The rule is: **a caller-supplied `data-slot` always wins on the component's root element**, with the component's own value (`root`/`base`) as the fallback. Inner elements keep their own `data-slot`.

How you achieve it depends on how the root receives attributes:

- **Single root, default `inheritAttrs`** (Badge, Card, …): nothing to do. Vue's attribute fallthrough already lets the caller's `data-slot` override the static one on the root. This only holds when the template root renders an element: `Button`'s root is a renderless `ULink custom` that hands `$attrs` back as slot props, so its `ULinkBase` needs the default placed before the spread (`<ULinkBase data-slot="base" v-bind="slotProps">`), same as the `$attrs` case below.
- **`inheritAttrs: false`, `$attrs` spread on the root**: fallthrough is off, so a static `data-slot="root"` placed *after* `v-bind` would win over the caller. Put the attribute *before* the `v-bind` instead, so a caller value in `$attrs` overrides it:

```vue
<Primitive :as="props.as" data-slot="root" v-bind="$attrs" :class="ui.root({ class: [props.ui?.root, props.class] })" />

<Separator data-slot="root" v-bind="{ ...rootProps, ...$attrs }" :class="ui.root({ class: [props.ui?.root, props.class] })" />
```

Keep `:id`, `ref` and `v-slot` **before** `data-slot`: `vue/attributes-order` ranks them first, and its autofix moves `data-slot` past the `v-bind` (reverting the override) instead of moving them up. `test/components/DataSlot.spec.ts` catches this, but better not to trip it.

- **`inheritAttrs: false`, `$attrs` forwarded to an inner element** (Avatar, Input, Checkbox, Switch, …): the root never receives `$attrs`, so read the caller's value on the root explicitly, and keep each inner element's own `data-slot` *after* its `$attrs` spread so the caller's value does not leak onto it:

```vue
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<input v-bind="{ ...$attrs, ...ariaAttrs }" data-slot="base" :class="ui.base({ class: props.ui?.base })">
</Primitive>
```

For `<Slot>` forwards and inner elements that have no `data-slot` of their own, strip it from what you forward so it cannot leak: `v-bind="{ ...$attrs, 'data-slot': undefined }"`. The same applies when attributes are forwarded from the script, like `Editor` spreading `useAttrs()` into tiptap's `editorProps.attributes`: use `omit(attrs, ['data-slot'])`.

The rule is enforced by `test/components/DataSlot.spec.ts`, which mounts every component with a caller `data-slot` and asserts it lands exactly once, on the outermost rendered element.

## Components with Icons

```vue
Expand Down Expand Up @@ -290,6 +319,7 @@ Notes:
| `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 |
| Caller `data-slot` wins on root | Place the default `data-slot` before the root `v-bind`, or read `$attrs['data-slot']` on the root β€” see [`data-slot` on the root](#data-slot-on-the-root) |
| `reactivePick` | Pick keys off `props` (the proxy) before forwarding |
| `createReusableTemplate` | Complex template reuse (Table, Modal) |
| `useTemplateRef` | Template refs (Vue 3.5+) |
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Load these based on your task. **Do not load all files at once** β€” only load w
| Type imports | Always separate: `import type { X }` on its own line |
| Props defaults | Use `withDefaults()` for runtime, JSDoc `@defaultValue` for docs |
| Template slots | Add `data-slot="name"` attributes on all elements |
| `data-slot` on root | A caller-supplied `data-slot` must win on the component's **root** (component's own value as fallback); inner elements keep theirs. Single-root components with default `inheritAttrs` get this free via Vue fallthrough. For `inheritAttrs: false`, place the default before the root's `v-bind` (`data-slot="root" v-bind="$attrs"`), or read `($attrs['data-slot'] as string \| undefined) ?? 'root'` on the root when `$attrs` is forwarded to an inner element. See [component-structure.md](.github/contributing/component-structure.md#data-slot-on-the-root). |
| Computed ui | Always use `computed(() => tv(...))` for reactive theming |
| 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. |
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/AuthForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ defineExpose({
</script>

<template>
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<div v-if="(props.icon || !!slots.leading) || (props.title || !!slots.title) || (props.description || !!slots.description) || !!slots.header" data-slot="header" :class="ui.header({ class: props.ui?.header })">
<slot name="header">
<div v-if="props.icon || !!slots.leading" data-slot="leading" :class="ui.leading({ class: props.ui?.leading })">
Expand Down Expand Up @@ -262,9 +262,9 @@ defineExpose({
:validate-on="props.validateOn"
:disabled="props.disabled"
:loading-auto="props.loadingAuto"
data-slot="form"
:class="ui.form({ class: props.ui?.form })"
v-bind="$attrs"
data-slot="form"
@submit="props.onSubmit"
>
<UFormField
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Avatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function onError() {
:is="props.chip ? UChip : Primitive"
:as="as.root"
v-bind="props.chip ? (typeof props.chip === 'object' ? { inset: true, ...props.chip } : { inset: true }) : {}"
data-slot="root"
:data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'"
:class="rootClass"
:style="props.style"
>
Expand All @@ -127,7 +127,7 @@ function onError() {
@error="onError"
/>

<Slot v-else v-bind="$attrs">
<Slot v-else v-bind="{ ...$attrs, 'data-slot': undefined }">
<slot>
<UIcon v-if="props.icon" :name="props.icon" data-slot="icon" :class="ui.icon({ class: props.ui?.icon })" />
<span v-else data-slot="fallback" :class="ui.fallback({ class: props.ui?.fallback })">{{ fallback || '&nbsp;' }}</span>
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Banner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,13 @@ function onClose() {
v-bind="!props.to ? $attrs : {}"
class="banner"
:data-banner-id="id"
data-slot="root"
:data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'"
:class="ui.root({ class: [props.ui?.root, props.class] })"
>
<ULink
v-if="props.to"
:aria-label="props.title"
v-bind="{ to: props.to, target: props.target, ...$attrs }"
v-bind="{ 'to': props.to, 'target': props.target, ...$attrs, 'data-slot': undefined }"
:class="prefix('focus:outline-none')"
raw
>
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/BlogPost.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,14 @@ const ariaLabel = computed(() => {
:as="props.as"
v-bind="!props.to ? $attrs : {}"
:data-orientation="props.orientation"
data-slot="root"
:data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'"
:class="ui.root({ class: [props.ui?.root, props.class] })"
@click="props.onClick"
>
<ULink
v-if="props.to"
:aria-label="ariaLabel"
v-bind="{ to: props.to, target: props.target, ...$attrs }"
v-bind="{ 'to': props.to, 'target': props.target, ...$attrs, 'data-slot': undefined }"
:class="prefix('focus:outline-none absolute inset-0')"
raw
/>
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ const ui = computed(() => tv({
custom
>
<ULinkBase
v-bind="slotProps"
data-slot="base"
v-bind="slotProps"
:class="ui.base({
class: [props.ui?.base, props.class],
active,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/ChangelogVersion.vue
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const ariaLabel = computed(() => {
<ULink
v-if="props.to"
:aria-label="ariaLabel"
v-bind="{ to: props.to, target: props.target, ...$attrs }"
v-bind="{ 'to': props.to, 'target': props.target, ...$attrs, 'data-slot': undefined }"
:class="prefix('focus:outline-none peer')"
raw
>
Expand All @@ -152,7 +152,7 @@ const ariaLabel = computed(() => {
</time>
</DefineDateTemplate>

<Primitive :as="props.as" v-bind="!props.to ? $attrs : {}" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })" @click="props.onClick">
<Primitive :as="props.as" v-bind="!props.to ? $attrs : {}" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })" @click="props.onClick">
<div v-if="!!props.indicator || !!slots.indicator" data-slot="indicator" :class="ui.indicator({ class: props.ui?.indicator })">
<slot name="indicator" :ui="ui">
<ReuseDateTemplate />
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/ChatPrompt.vue
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ defineExpose({
</script>

<template>
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })" @submit.prevent="submit">
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })" @submit.prevent="submit">
<div v-if="!!slots.header" data-slot="header" :class="ui.header({ class: props.ui?.header })">
<slot name="header" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Checkbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ function onUpdate(value: any) {

<!-- eslint-disable vue/no-template-shadow -->
<template>
<Primitive :as="(!props.variant || props.variant === 'list') ? props.as : Label" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="(!props.variant || props.variant === 'list') ? props.as : Label" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<div data-slot="container" :class="ui.container({ class: props.ui?.container })">
<CheckboxRoot
:id="id"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Chip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.chip || {}) })({
</script>

<template>
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Slot v-bind="$attrs">
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Slot v-bind="{ ...$attrs, 'data-slot': undefined }">
<slot />
</Slot>

Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/CommandPalette.vue
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ function onSelect(e: Event, item: T) {
</ULink>
</DefineItemTemplate>

<ListboxRoot v-bind="{ ...rootProps, ...$attrs }" ref="rootRef" :selection-behavior="props.selectionBehavior" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<ListboxRoot ref="rootRef" data-slot="root" v-bind="{ ...rootProps, ...$attrs }" :selection-behavior="props.selectionBehavior" :class="ui.root({ class: [props.ui?.root, props.class] })">
<ListboxFilter v-if="props.input" v-model="searchTerm" as-child>
<UInput
variant="none"
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/DashboardNavbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.dashboardNavbar
</slot>
</DefineToggleTemplate>

<Primitive :as="props.as" v-bind="$attrs" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="props.as" data-slot="root" v-bind="$attrs" :class="ui.root({ class: [props.ui?.root, props.class] })">
<div data-slot="left" :class="ui.left({ class: props.ui?.left })">
<ReuseToggleTemplate v-if="props.toggleSide === 'left'" />

Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/DashboardPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.dashboardPanel |
<div
:id="id"
ref="el"
data-slot="root"
v-bind="$attrs"
:data-dragging="isDragging"
data-slot="root"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:style="[size ? { '--width': `${size}${dashboardContext.unit}` } : undefined]"
>
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/DashboardSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,10 @@ function toggleOpen() {
<div
:id="id"
ref="el"
data-slot="root"
v-bind="$attrs"
:data-collapsed="isCollapsed"
:data-dragging="isDragging"
data-slot="root"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:style="{ '--width': `${size || 0}${dashboardContext.unit}` }"
>
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import { reactiveOmit } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useComponentProps } from '../composables/useComponentProps'
import { useForwardProps } from '../composables/useForwardProps'
import { omit } from '../utils'
import { createHandlers } from '../utils/editor'
import { tv } from '../utils/tv'

Expand Down Expand Up @@ -132,7 +133,7 @@ const editorProps = computed(() => defu(props.editorProps, {
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
...attrs,
...omit(attrs, ['data-slot']),
class: ui.value.base({ class: props.ui?.base })
}
} as EditorOptions['editorProps']))
Expand Down Expand Up @@ -283,7 +284,7 @@ defineExpose({
</script>

<template>
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<template v-if="editor">
<slot :editor="editor" :handlers="handlers" />

Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/EditorDragHandle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function onClick() {
:compute-position-config="computePositionConfig"
:editor="props.editor"
:on-node-change="onNodeChange"
data-slot="root"
:data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'"
:class="ui.root({ class: [props.ui?.root, props.class] })"
@click="onClick"
>
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/FileUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ defineExpose({
</template>
</DefineFilesTemplate>

<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot :open="open" :remove-file="removeFile" :ui="ui">
<component
:is="variant === 'button' ? 'button' : 'div'"
Expand Down Expand Up @@ -416,7 +416,7 @@ defineExpose({
:multiple="(multiple as boolean)"
:required="props.required"
:disabled="disabled"
v-bind="{ ...$attrs, ...ariaAttrs }"
v-bind="{ ...$attrs, ...ariaAttrs, 'data-slot': undefined }"
/>
</Primitive>
</template>
2 changes: 1 addition & 1 deletion src/runtime/components/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ function toggleOpen() {
</div>
</DefineRightTemplate>

<Primitive :as="props.as" v-bind="$attrs" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="props.as" data-slot="root" v-bind="$attrs" :class="ui.root({ class: [props.ui?.root, props.class] })">
<slot name="top" />

<UContainer data-slot="container" :class="ui.container({ class: props.ui?.container })">
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -185,20 +185,20 @@ defineExpose({
</script>

<template>
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<Primitive :as="props.as" :data-slot="($attrs['data-slot'] as string | undefined) ?? 'root'" :class="ui.root({ class: [props.ui?.root, props.class] })">
<input
:id="id"
ref="inputRef"
:type="props.type"
:value="modelValue"
:name="name"
:placeholder="props.placeholder"
data-slot="base"
:class="ui.base({ class: props.ui?.base })"
:disabled="disabled"
:required="props.required"
:autocomplete="props.autocomplete"
v-bind="{ ...$attrs, ...ariaAttrs }"
data-slot="base"
@input="onInput"
@blur="onBlur"
@change="onChange"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/InputDate.vue
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,14 @@ defineExpose({
</DefineSegmentsTemplate>

<DateField.Root
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
:id="id"
v-slot="{ segments }"
data-slot="base"
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
:model-value="(props.modelValue as DateValue)"
:default-value="(props.defaultValue as DateValue)"
:name="name"
:disabled="disabled"
data-slot="base"
:class="ui.base({ class: [props.ui?.base, props.class] })"
@update:model-value="onUpdate"
@blur="onBlur"
Expand Down
Loading
Loading