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
1 change: 1 addition & 0 deletions playgrounds/nuxt/app/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const components = [
'table',
'tabs',
'textarea',
'theme',
'timeline',
'toast',
'tooltip',
Expand Down
81 changes: 81 additions & 0 deletions playgrounds/nuxt/app/pages/components/theme.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script setup lang="ts">
import theme from '#build/ui/button'

const colors = Object.keys(theme.variants.color)
const variants = Object.keys(theme.variants.variant)
const sizes = Object.keys(theme.variants.size)

const color = ref<keyof typeof theme.variants.color>('warning')
const variant = ref<keyof typeof theme.variants.variant>('soft')
const size = ref<keyof typeof theme.variants.size>('lg')
</script>

<template>
<Navbar>
<USelect v-model="color" :items="colors" />
<USelect v-model="variant" :items="variants" />
<USelect v-model="size" :items="sizes" />
</Navbar>

<div class="flex flex-col gap-8">
<!-- Variants only -->
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-muted">
UTheme variants={{ `{ button: { color: '${color}', variant: '${variant}', size: '${size}' } }` }}
</p>

<UTheme :variants="{ button: { color, variant, size } }">
<div class="flex items-center gap-2">
<UButton label="Themed" />
<UButton label="Themed with icon" icon="i-lucide-rocket" />
<UButton label="Themed square" icon="i-lucide-star" square />
</div>
</UTheme>
</div>

<!-- Explicit prop overrides theme -->
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-muted">
Explicit props override theme variants
</p>

<UTheme :variants="{ button: { color, variant, size } }">
<div class="flex items-center gap-2">
<UButton label="Theme only" />
<UButton label="color=primary" color="primary" />
<UButton label="variant=solid" variant="solid" />
<UButton label="size=xs" size="xs" />
</div>
</UTheme>
</div>

<!-- UI + Variants combined -->
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-muted">
UI slot classes + variant defaults together
</p>

<UTheme
:variants="{ button: { color, variant } }"
:ui="{ button: { base: 'font-bold rounded-full' } }"
>
<div class="flex items-center gap-2">
<UButton label="Styled + themed" />
<UButton label="With icon" icon="i-lucide-zap" />
</div>
</UTheme>
</div>

<!-- Without UTheme (baseline) -->
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-muted">
Without UTheme (baseline)
</p>

<div class="flex items-center gap-2">
<UButton label="Default" />
<UButton label="Default with icon" icon="i-lucide-rocket" />
</div>
</div>
</div>
</template>
10 changes: 5 additions & 5 deletions src/runtime/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +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 { useComponentTheme } from '../composables/useComponentUI'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFieldGroup } from '../composables/useFieldGroup'
import { formLoadingInjectionKey } from '../composables/useFormField'
Expand All @@ -63,7 +63,7 @@ const props = defineProps<ButtonProps>()
const slots = defineSlots<ButtonSlots>()

const appConfig = useAppConfig() as Button['AppConfig']
const uiProp = useComponentUI('button', props)
const { ui: uiProp, variants } = useComponentTheme('button', props)
const { orientation, size: buttonSize } = useFieldGroup<ButtonProps>(props)

const linkProps = useForwardProps(pickLinkProps(props))
Expand Down Expand Up @@ -104,9 +104,9 @@ const ui = computed(() => tv({
}
}, appConfig.ui?.button || {})
})({
color: props.color,
variant: props.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: props.block,
square: props.square || (!slots.default && !props.label),
Expand Down
8 changes: 5 additions & 3 deletions src/runtime/components/Theme.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script lang="ts">
import { type VNode, computed } from 'vue'
import { provideThemeContext } from '../composables/useComponentUI'
import type { ThemeUI } from '../composables/useComponentUI'
import type { ThemeUI, ThemeVariants } from '../composables/useComponentUI'

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

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

provideThemeContext({
ui: computed(() => props.ui)
ui: computed(() => props.ui ?? {}),
variants: computed(() => props.variants ?? {})
})
</script>

Expand Down
52 changes: 47 additions & 5 deletions src/runtime/composables/useComponentUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ui>
type ExtractUISlots<C> = C extends { slots?: infer S } ? NonNullable<S> : never
Expand All @@ -22,26 +23,67 @@ export type ThemeUI = {
[K in keyof typeof ui]?: ThemeSlotOverrides<(typeof ui)[K]>
}

export type ThemeRootContext = {
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]>
}

type DefaultVariantKeys<T> = T extends { defaultVariants: infer D extends Record<string, any> } ? keyof D : never

export type ThemeVariants = {
[K in keyof UIConfig]?: {
[V in keyof ComponentVariants<K> as V extends DefaultVariantKeys<K extends keyof typeof ui ? (typeof ui)[K] : never> ? V : never]?: VariantValue<ComponentVariants<K>[V]>
}
}

export type ThemeContext = {
ui: ComputedRef<ThemeUI>
variants: ComputedRef<ThemeVariants>
}

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

export { provideThemeContext }
export { injectThemeContext, provideThemeContext }

type ComponentUIProps<T extends keyof UIConfig> = {
ui?: UIConfigSlots<T>
}

export const defaultThemeContext: ThemeContext = { ui: computed(() => ({})), variants: computed(() => ({})) }

export function useComponentUI<T extends keyof UIConfig>(name: T, props: ComponentUIProps<T>): ComputedRef<UIConfigSlots<T>>
export function useComponentUI(name: string, props: { ui?: any }): ComputedRef<any>
export function useComponentUI(name: string, props: { ui?: any }): ComputedRef<any> {
const { ui } = injectThemeContext({ ui: computed(() => ({})) })
const { ui } = injectThemeContext(defaultThemeContext)

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

return defu(props.ui ?? {}, themeOverrides)
})
}

export function useComponentTheme<C extends keyof UIConfig>(name: C, props: ComponentUIProps<C>): {
ui: ComputedRef<UIConfigSlots<C>>
variants: ComputedRef<ThemeVariantOverrides<ComponentVariants<C>>>
}
export function useComponentTheme(name: string, props: { ui?: any }): {
ui: ComputedRef<any>
variants: ComputedRef<any>
}
export function useComponentTheme(name: string, props: { ui?: any }): {
ui: ComputedRef<any>
variants: ComputedRef<any>
} {
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) || {}))
}
}
7 changes: 5 additions & 2 deletions src/runtime/composables/useResolvedVariants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<component :is="...">`).
*
* `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'])
Expand All @@ -28,12 +29,14 @@ export function useResolvedVariants<K extends string>(
overrides?: Partial<Record<K, MaybeRefOrGetter<any>>>
): { [P in K]: ComputedRef<any> } {
const appConfig = useAppConfig()
const { variants: themeVariants } = injectThemeContext(defaultThemeContext)
const result = {} as { [P in K]: ComputedRef<any> }

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]
})
}

Expand Down
95 changes: 95 additions & 0 deletions test/components/Theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,101 @@ describe('Theme', () => {
}
)

test('theme variants apply to child component', async () => {
const wrapper = await mountSuspended({
components: { Theme, Button },
template: `
<Theme :variants="{ button: { color: 'error', variant: 'soft' } }">
<Button label="Themed" />
</Theme>
`
})

expect(wrapper.find('button').classes()).toContain('bg-error/10')
expect(wrapper.find('button').classes()).not.toContain('bg-primary')
})

test('explicit prop overrides theme variant', async () => {
const wrapper = await mountSuspended({
components: { Theme, Button },
template: `
<Theme :variants="{ button: { color: 'error' } }">
<Button label="Override" color="primary" />
</Theme>
`
})

expect(wrapper.find('button').classes()).toContain('bg-primary')
expect(wrapper.find('button').classes()).not.toContain('bg-error')
})

test('theme variant applies size', async () => {
const wrapper = await mountSuspended({
components: { Theme, Button },
template: `
<Theme :variants="{ button: { size: 'xl' } }">
<Button label="Large" />
</Theme>
`
})

expect(wrapper.find('button').classes()).toContain('text-base')
})

test('theme variants do not leak outside scope', async () => {
const wrapper = await mountSuspended({
components: { Theme, Button },
template: `
<div>
<Theme :variants="{ button: { color: 'error', variant: 'soft' } }">
<Button label="Inside" class="inside-btn" />
</Theme>
<Button label="Outside" class="outside-btn" />
</div>
`
})

expect(wrapper.find('.inside-btn').classes()).toContain('bg-error/10')
expect(wrapper.find('.outside-btn').classes()).toContain('bg-primary')
expect(wrapper.find('.outside-btn').classes()).not.toContain('bg-error/10')
})

test('theme variants react to prop changes', async () => {
const variants = ref<any>({ button: { color: 'error', variant: 'soft' } })

const wrapper = await mountSuspended({
components: { Theme, Button },
setup: () => ({ variants }),
template: `
<Theme :variants="variants">
<Button label="Themed" />
</Theme>
`
})

expect(wrapper.find('button').classes()).toContain('bg-error/10')

variants.value = { button: { color: 'success', variant: 'soft' } }
await nextTick()

expect(wrapper.find('button').classes()).toContain('bg-success/10')
expect(wrapper.find('button').classes()).not.toContain('bg-error/10')
})

test('ui and variants work together', async () => {
const wrapper = await mountSuspended({
components: { Theme, Button },
template: `
<Theme :variants="{ button: { color: 'error', variant: 'soft' } }" :ui="{ button: { base: 'test-ui-class' } }">
<Button label="Both" />
</Theme>
`
})

expect(wrapper.find('button').classes()).toContain('bg-error/10')
expect(wrapper.find('button').classes()).toContain('test-ui-class')
})

test('applies theme classes to child component', async () => {
const wrapper = await mountSuspended({
components: { Theme, Button },
Expand Down
Loading