Skip to content
Draft
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
8 changes: 6 additions & 2 deletions playgrounds/nuxt/app/pages/components/button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@
</Navbar>

<Matrix v-slot="props" :attrs="attrs">
<UButton label="Button" v-bind="props" />
<UButton label="Link" to="/" v-bind="props" />
<UTheme :variants="{ button: { variant: 'soft', color: 'warning' } }" :ui="{ button: { base: 'font-bold' } }">
<UButton label="Button" v-bind="props" />

Check failure on line 29 in playgrounds/nuxt/app/pages/components/button.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Trailing spaces not allowed
<UButton label="Link" to="/" v-bind="props" />
</UTheme>

<UButton label="Disabled" disabled v-bind="props" />
<UButton label="Disabled link" to="#" disabled v-bind="props" />
<UButton label="Loading" loading v-bind="props" />
Expand Down
11 changes: 7 additions & 4 deletions src/runtime/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { defu } from 'defu'
import { useForwardProps } from 'reka-ui'
import { useAppConfig } from '#imports'
import { useComponentUI } from '../composables/useComponentUI'
import { useComponentVariant } from '../composables/useComponentVariant'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFieldGroup } from '../composables/useFieldGroup'
import { formLoadingInjectionKey } from '../composables/useFormField'
Expand All @@ -64,6 +65,8 @@ const slots = defineSlots<ButtonSlots>()

const appConfig = useAppConfig() as Button['AppConfig']
const uiProp = useComponentUI('button', props)
const variantProp = useComponentVariant('button', props)

const { orientation, size: buttonSize } = useFieldGroup<ButtonProps>(props)

const linkProps = useForwardProps(pickLinkProps(props))
Expand Down Expand Up @@ -104,12 +107,12 @@ const ui = computed(() => tv({
}
}, appConfig.ui?.button || {})
})({
color: props.color,
variant: props.variant,
color: variantProp.value.color,
variant: variantProp.value.variant,
size: buttonSize.value,
loading: isLoading.value,
block: props.block,
square: props.square || (!slots.default && !props.label),
block: variantProp.value.block,
square: variantProp.value.square || (!slots.default && !props.label),
leading: isLeading.value,
trailing: isTrailing.value,
fieldGroup: orientation.value
Expand Down
10 changes: 8 additions & 2 deletions src/runtime/components/Theme.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import { computed } from 'vue'
import { provideThemeContext } from '../composables/useComponentUI'
import type { ThemeUI } from '../composables/useComponentUI'
import { provideVariantContext, type VariantUI } from '../composables/useComponentVariant'

export interface ThemeProps {
ui: ThemeUI
ui?: ThemeUI
variants?: VariantUI
}

export interface ThemeSlots {
Expand All @@ -16,7 +18,11 @@
const props = defineProps<ThemeProps>()

provideThemeContext({
ui: computed(() => props.ui)
ui: computed(() => props.ui ?? {}),

Check failure on line 21 in src/runtime/components/Theme.vue

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Unexpected trailing comma
})

provideVariantContext({
variant: computed(() => props.variants ?? {})
})
</script>

Expand Down
4 changes: 2 additions & 2 deletions src/runtime/composables/useComponentUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ export type ThemeUI = {
[K in keyof typeof ui]?: ThemeSlotOverrides<(typeof ui)[K]>
}

export type ThemeRootContext = {
export type ThemeUIContext = {
ui: ComputedRef<ThemeUI>
}

const [injectThemeContext, provideThemeContext] = createContext<ThemeRootContext>('UTheme', 'RootContext')
const [injectThemeContext, provideThemeContext] = createContext<ThemeUIContext>('UThemeUI', 'RootContext')

export { provideThemeContext }

Expand Down
40 changes: 40 additions & 0 deletions src/runtime/composables/useComponentVariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ComputedRef } from 'vue'
import { computed } from 'vue'
import defu from 'defu'
import { createContext } from 'reka-ui'
import type { ComponentConfig, TVConfig } from '../types/tv'
import type * as ui from '#build/ui'
import { get } from '../utils'
import type { AppConfig } from '@nuxt/schema'

type UIConfig = TVConfig<typeof ui>
type ComponentVariants<C extends keyof UIConfig> = ComponentConfig<(typeof ui)[C], AppConfig, C>['variants']

type VariantValue<V> = [V] extends ['true' | 'false'] ? boolean : V
type ThemeVariantOverrides<T> = {
[K in keyof T]?: VariantValue<T[K]>

Check failure on line 15 in src/runtime/composables/useComponentVariant.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected indentation of 2 spaces but found 4
};

Check failure on line 16 in src/runtime/composables/useComponentVariant.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Extra semicolon

export type ThemeVariantContext = {
variant: ComputedRef<VariantUI>
}

export type VariantUI = {
[K in keyof UIConfig]?: ThemeVariantOverrides<ComponentVariants<K>>

Check failure on line 23 in src/runtime/composables/useComponentVariant.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

Expected indentation of 2 spaces but found 4
}

const [injectVariantContext, provideVariantContext] = createContext<ThemeVariantContext>('UThemeVariant', 'RootContext')

export { provideVariantContext }

type ComponentVariantProps<C extends keyof UIConfig> = ThemeVariantOverrides<ComponentVariants<C>>

export function useComponentVariant<C extends keyof UIConfig>(name: C, props: ComponentVariantProps<C>): ComputedRef<ThemeVariantOverrides<ComponentVariants<C>>> {
const { variant } = injectVariantContext({ variant: computed(() => ({})) })

return computed(() => {
const themeOverrides = (get(variant.value, name as string) || {})

return defu(props.variants ?? {}, themeOverrides)
})
Comment on lines +32 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Bug: props.variants is always undefined β€” explicit component props are ignored.

ComponentVariantProps<C> maps variant keys (color, variant, block, square, …) as top-level properties. There is no .variants key on this type or on the actual ButtonProps object passed by the caller. As a result, props.variants ?? {} always evaluates to {}, and defu({}, themeOverrides) returns only the theme overrides β€” making it impossible for explicit component props (e.g., <UButton color="red">) to override theme-provided values.

Additionally, since no variant-related prop is actually read inside this computed, Vue won't track changes to props like color or variant, so the returned value won't reactively update when those props change.

Proposed fix β€” merge `props` directly with theme overrides
 export function useComponentVariant<C extends keyof UIConfig>(name: C, props: ComponentVariantProps<C>): ComputedRef<ThemeVariantOverrides<ComponentVariants<C>>> {
   const { variant } = injectVariantContext({ variant: computed(() => ({})) })
 
   return computed(() => {
     const themeOverrides = (get(variant.value, name as string) || {})
 
-    return defu(props.variants ?? {}, themeOverrides)
+    return defu(props, themeOverrides)
   })
 }

With defu(props, themeOverrides):

  • Explicit prop values (e.g., color="red") win over theme overrides.
  • undefined props (not passed by parent) fall through to theme overrides, thanks to defu skipping undefined.
  • Vue's reactivity tracking works because defu enumerates the reactive props proxy.

If you want to limit over-tracking (any prop change triggers recompute), consider picking only variant-relevant keys before merging.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function useComponentVariant<C extends keyof UIConfig>(name: C, props: ComponentVariantProps<C>): ComputedRef<ThemeVariantOverrides<ComponentVariants<C>>> {
const { variant } = injectVariantContext({ variant: computed(() => ({})) })
return computed(() => {
const themeOverrides = (get(variant.value, name as string) || {})
return defu(props.variants ?? {}, themeOverrides)
})
export function useComponentVariant<C extends keyof UIConfig>(name: C, props: ComponentVariantProps<C>): ComputedRef<ThemeVariantOverrides<ComponentVariants<C>>> {
const { variant } = injectVariantContext({ variant: computed(() => ({})) })
return computed(() => {
const themeOverrides = (get(variant.value, name as string) || {})
return defu(props, themeOverrides)
})
}
πŸ€– Prompt for AI Agents
In `@src/runtime/composables/useComponentVariant.ts` around lines 32 - 39, The
computed in useComponentVariant currently merges theme overrides with
props.variants which doesn't exist, so component props (e.g., color, variant)
are ignored and not tracked; update the merge to use the actual props object
(e.g., defu(props, themeOverrides)) so explicit props take precedence and Vue
reactivity tracks prop changes inside the computed returned by
useComponentVariant; optionally, to reduce over-tracking, pick only the
variant-related keys from props before calling defu.

}
Loading