From e8f624273e7a66599de3ef071ddabef570bb1792 Mon Sep 17 00:00:00 2001 From: Bobakanoosh Date: Thu, 12 Feb 2026 21:14:33 -0600 Subject: [PATCH 01/22] feat(UTheme): variants poc --- .../nuxt/app/pages/components/button.vue | 8 +++- src/runtime/components/Button.vue | 11 +++-- src/runtime/components/Theme.vue | 10 ++++- src/runtime/composables/useComponentUI.ts | 4 +- .../composables/useComponentVariant.ts | 40 +++++++++++++++++++ 5 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 src/runtime/composables/useComponentVariant.ts diff --git a/playgrounds/nuxt/app/pages/components/button.vue b/playgrounds/nuxt/app/pages/components/button.vue index a8d25610eb..ab64fdcd68 100644 --- a/playgrounds/nuxt/app/pages/components/button.vue +++ b/playgrounds/nuxt/app/pages/components/button.vue @@ -24,8 +24,12 @@ function onClick() { - - + + + + + + diff --git a/src/runtime/components/Button.vue b/src/runtime/components/Button.vue index b1fd3f2006..822b627932 100644 --- a/src/runtime/components/Button.vue +++ b/src/runtime/components/Button.vue @@ -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' @@ -64,6 +65,8 @@ const slots = defineSlots() const appConfig = useAppConfig() as Button['AppConfig'] const uiProp = useComponentUI('button', props) +const variantProp = useComponentVariant('button', props) + const { orientation, size: buttonSize } = useFieldGroup(props) const linkProps = useForwardProps(pickLinkProps(props)) @@ -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 diff --git a/src/runtime/components/Theme.vue b/src/runtime/components/Theme.vue index ba6fb90ebf..6cf9a03039 100644 --- a/src/runtime/components/Theme.vue +++ b/src/runtime/components/Theme.vue @@ -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 { @@ -16,7 +18,11 @@ export interface ThemeSlots { const props = defineProps() provideThemeContext({ - ui: computed(() => props.ui) + ui: computed(() => props.ui ?? {}), +}) + +provideVariantContext({ + variant: computed(() => props.variants ?? {}) }) diff --git a/src/runtime/composables/useComponentUI.ts b/src/runtime/composables/useComponentUI.ts index 43140438c1..715b60eea0 100644 --- a/src/runtime/composables/useComponentUI.ts +++ b/src/runtime/composables/useComponentUI.ts @@ -22,11 +22,11 @@ export type ThemeUI = { [K in keyof typeof ui]?: ThemeSlotOverrides<(typeof ui)[K]> } -export type ThemeRootContext = { +export type ThemeUIContext = { ui: ComputedRef } -const [injectThemeContext, provideThemeContext] = createContext('UTheme', 'RootContext') +const [injectThemeContext, provideThemeContext] = createContext('UThemeUI', 'RootContext') export { provideThemeContext } diff --git a/src/runtime/composables/useComponentVariant.ts b/src/runtime/composables/useComponentVariant.ts new file mode 100644 index 0000000000..a1c227c037 --- /dev/null +++ b/src/runtime/composables/useComponentVariant.ts @@ -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 +type ComponentVariants = ComponentConfig<(typeof ui)[C], AppConfig, C>['variants'] + +type VariantValue = [V] extends ['true' | 'false'] ? boolean : V +type ThemeVariantOverrides = { + [K in keyof T]?: VariantValue +}; + +export type ThemeVariantContext = { + variant: ComputedRef +} + +export type VariantUI = { + [K in keyof UIConfig]?: ThemeVariantOverrides> +} + +const [injectVariantContext, provideVariantContext] = createContext('UThemeVariant', 'RootContext') + +export { provideVariantContext } + +type ComponentVariantProps = ThemeVariantOverrides> + +export function useComponentVariant(name: C, props: ComponentVariantProps): ComputedRef>> { + const { variant } = injectVariantContext({ variant: computed(() => ({})) }) + + return computed(() => { + const themeOverrides = (get(variant.value, name as string) || {}) + + return defu(props.variants ?? {}, themeOverrides) + }) +} From d7b5565f2649df012eda6508d4be3d6d6ae10c58 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Tue, 21 Apr 2026 17:53:11 +0200 Subject: [PATCH 02/22] feat(Theme): add variants prop and unify theme context --- .../nuxt/app/composables/useNavigation.ts | 1 + .../nuxt/app/pages/components/button.vue | 8 +- .../nuxt/app/pages/components/theme.vue | 81 ++++++++++++++++ src/runtime/components/Button.vue | 17 ++-- src/runtime/components/Theme.vue | 10 +- src/runtime/composables/useComponentUI.ts | 52 +++++++++- .../composables/useComponentVariant.ts | 40 -------- .../composables/useResolvedVariants.ts | 7 +- test/components/Theme.spec.ts | 95 +++++++++++++++++++ 9 files changed, 241 insertions(+), 70 deletions(-) create mode 100644 playgrounds/nuxt/app/pages/components/theme.vue delete mode 100644 src/runtime/composables/useComponentVariant.ts diff --git a/playgrounds/nuxt/app/composables/useNavigation.ts b/playgrounds/nuxt/app/composables/useNavigation.ts index c3bbfcf333..b54d678ee2 100644 --- a/playgrounds/nuxt/app/composables/useNavigation.ts +++ b/playgrounds/nuxt/app/composables/useNavigation.ts @@ -82,6 +82,7 @@ const components = [ 'table', 'tabs', 'textarea', + 'theme', 'timeline', 'toast', 'tooltip', diff --git a/playgrounds/nuxt/app/pages/components/button.vue b/playgrounds/nuxt/app/pages/components/button.vue index ab64fdcd68..a8d25610eb 100644 --- a/playgrounds/nuxt/app/pages/components/button.vue +++ b/playgrounds/nuxt/app/pages/components/button.vue @@ -24,12 +24,8 @@ function onClick() { - - - - - - + + diff --git a/playgrounds/nuxt/app/pages/components/theme.vue b/playgrounds/nuxt/app/pages/components/theme.vue new file mode 100644 index 0000000000..28ba580013 --- /dev/null +++ b/playgrounds/nuxt/app/pages/components/theme.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/runtime/components/Button.vue b/src/runtime/components/Button.vue index cd7687dac3..d8b2c14fee 100644 --- a/src/runtime/components/Button.vue +++ b/src/runtime/components/Button.vue @@ -47,8 +47,7 @@ import { computed, ref, inject } from 'vue' import { defu } from 'defu' import { useForwardProps } from 'reka-ui' import { useAppConfig } from '#imports' -import { useComponentUI } from '../composables/useComponentUI' -import { useComponentVariant } from '../composables/useComponentVariant' +import { useComponentTheme } from '../composables/useComponentUI' import { useComponentIcons } from '../composables/useComponentIcons' import { useFieldGroup } from '../composables/useFieldGroup' import { formLoadingInjectionKey } from '../composables/useFormField' @@ -64,9 +63,7 @@ const props = defineProps() const slots = defineSlots() const appConfig = useAppConfig() as Button['AppConfig'] -const uiProp = useComponentUI('button', props) -const variantProp = useComponentVariant('button', props) - +const { ui: uiProp, variants } = useComponentTheme('button', props) const { orientation, size: buttonSize } = useFieldGroup(props) const linkProps = useForwardProps(pickLinkProps(props)) @@ -107,12 +104,12 @@ const ui = computed(() => tv({ } }, appConfig.ui?.button || {}) })({ - color: variantProp.value.color, - variant: variantProp.value.variant, - size: buttonSize.value, + color: props.color ?? variants.value.color, + variant: props.variant ?? variants.value.variant, + size: buttonSize.value ?? variants.value.size, loading: isLoading.value, - block: variantProp.value.block, - square: variantProp.value.square || (!slots.default && !props.label), + block: props.block, + square: props.square || (!slots.default && !props.label), leading: isLeading.value, trailing: isTrailing.value, fieldGroup: orientation.value diff --git a/src/runtime/components/Theme.vue b/src/runtime/components/Theme.vue index 407731d458..435568c4df 100644 --- a/src/runtime/components/Theme.vue +++ b/src/runtime/components/Theme.vue @@ -1,12 +1,11 @@ diff --git a/src/runtime/composables/useComponentUI.ts b/src/runtime/composables/useComponentUI.ts index 715b60eea0..a69db5ebdb 100644 --- a/src/runtime/composables/useComponentUI.ts +++ b/src/runtime/composables/useComponentUI.ts @@ -3,9 +3,10 @@ import type { ClassValue } from 'tailwind-variants' import { computed } from 'vue' import defu from 'defu' import { createContext } from 'reka-ui' -import type { TVConfig } from '../types/tv' +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 type ExtractUISlots = C extends { slots?: infer S } ? NonNullable : never @@ -22,22 +23,40 @@ export type ThemeUI = { [K in keyof typeof ui]?: ThemeSlotOverrides<(typeof ui)[K]> } -export type ThemeUIContext = { +type ComponentVariants = ComponentConfig<(typeof ui)[C], AppConfig, C>['variants'] + +type VariantValue = [V] extends ['true' | 'false'] ? boolean : V +type ThemeVariantOverrides = { + [K in keyof T]?: VariantValue +} + +type DefaultVariantKeys = T extends { defaultVariants: infer D extends Record } ? keyof D : never + +export type ThemeVariants = { + [K in keyof UIConfig]?: { + [V in keyof ComponentVariants as V extends DefaultVariantKeys ? V : never]?: VariantValue[V]> + } +} + +export type ThemeContext = { ui: ComputedRef + variants: ComputedRef } -const [injectThemeContext, provideThemeContext] = createContext('UThemeUI', 'RootContext') +const [injectThemeContext, provideThemeContext] = createContext('UTheme', 'RootContext') -export { provideThemeContext } +export { injectThemeContext, provideThemeContext } type ComponentUIProps = { ui?: UIConfigSlots } +export const defaultThemeContext: ThemeContext = { ui: computed(() => ({})), variants: computed(() => ({})) } + export function useComponentUI(name: T, props: ComponentUIProps): ComputedRef> export function useComponentUI(name: string, props: { ui?: any }): ComputedRef export function useComponentUI(name: string, props: { ui?: any }): ComputedRef { - const { ui } = injectThemeContext({ ui: computed(() => ({})) }) + const { ui } = injectThemeContext(defaultThemeContext) return computed(() => { const themeOverrides = (get(ui.value, name as string) || {}) @@ -45,3 +64,26 @@ export function useComponentUI(name: string, props: { ui?: any }): ComputedRef(name: C, props: ComponentUIProps): { + ui: ComputedRef> + variants: ComputedRef>> +} +export function useComponentTheme(name: string, props: { ui?: any }): { + ui: ComputedRef + variants: ComputedRef +} +export function useComponentTheme(name: string, props: { ui?: any }): { + ui: ComputedRef + variants: ComputedRef +} { + const { ui, variants } = injectThemeContext(defaultThemeContext) + + return { + ui: computed(() => { + const themeOverrides = (get(ui.value, name as string) || {}) + return defu(props.ui ?? {}, themeOverrides) + }), + variants: computed(() => (get(variants.value, name as string) || {})) + } +} diff --git a/src/runtime/composables/useComponentVariant.ts b/src/runtime/composables/useComponentVariant.ts deleted file mode 100644 index a1c227c037..0000000000 --- a/src/runtime/composables/useComponentVariant.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 -type ComponentVariants = ComponentConfig<(typeof ui)[C], AppConfig, C>['variants'] - -type VariantValue = [V] extends ['true' | 'false'] ? boolean : V -type ThemeVariantOverrides = { - [K in keyof T]?: VariantValue -}; - -export type ThemeVariantContext = { - variant: ComputedRef -} - -export type VariantUI = { - [K in keyof UIConfig]?: ThemeVariantOverrides> -} - -const [injectVariantContext, provideVariantContext] = createContext('UThemeVariant', 'RootContext') - -export { provideVariantContext } - -type ComponentVariantProps = ThemeVariantOverrides> - -export function useComponentVariant(name: C, props: ComponentVariantProps): ComputedRef>> { - const { variant } = injectVariantContext({ variant: computed(() => ({})) }) - - return computed(() => { - const themeOverrides = (get(variant.value, name as string) || {}) - - return defu(props.variants ?? {}, themeOverrides) - }) -} diff --git a/src/runtime/composables/useResolvedVariants.ts b/src/runtime/composables/useResolvedVariants.ts index cc84a51d14..6657c62a0f 100644 --- a/src/runtime/composables/useResolvedVariants.ts +++ b/src/runtime/composables/useResolvedVariants.ts @@ -2,13 +2,14 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue' import { computed, toValue } from 'vue' import { useAppConfig } from '#imports' import { get } from '../utils' +import { injectThemeContext, defaultThemeContext } from './useComponentUI' /** * Resolve variant values that are consumed in template logic (e.g. ``). * * `tv()`'s `defaultVariants` only apply when computing classes — they don't affect * template conditionals that read the prop directly. This mirrors tv's priority: - * `props[key]` > `app.config.ts` `defaultVariants[key]` > `theme.defaultVariants[key]`. + * `props[key]` > `UTheme variants[key]` > `app.config.ts` `defaultVariants[key]` > `theme.defaultVariants[key]`. * * @example * const { variant } = useResolvedVariants('radioGroup', props, theme, ['variant']) @@ -28,12 +29,14 @@ export function useResolvedVariants( overrides?: Partial>> ): { [P in K]: ComputedRef } { const appConfig = useAppConfig() + const { variants: themeVariants } = injectThemeContext(defaultThemeContext) const result = {} as { [P in K]: ComputedRef } for (const key of keys) { result[key] = computed(() => { const value = overrides?.[key] !== undefined ? toValue(overrides[key]!) : get(props, key) - return value ?? ((appConfig as any).ui?.[name] as any)?.defaultVariants?.[key] ?? theme.defaultVariants?.[key] + const themeValue = get(themeVariants.value, `${name}.${key}`) + return value ?? themeValue ?? ((appConfig as any).ui?.[name] as any)?.defaultVariants?.[key] ?? theme.defaultVariants?.[key] }) } diff --git a/test/components/Theme.spec.ts b/test/components/Theme.spec.ts index c78af64708..c5155f38f6 100644 --- a/test/components/Theme.spec.ts +++ b/test/components/Theme.spec.ts @@ -95,6 +95,101 @@ describe('Theme', () => { } ) + test('theme variants apply to child component', async () => { + const wrapper = await mountSuspended({ + components: { Theme, Button }, + template: ` + +