Skip to content

feat(Theme): new component #4387

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: v3
Choose a base branch
from
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
12 changes: 7 additions & 5 deletions src/runtime/components/Button.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,13 @@ import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
import ULink from './Link.vue'
import ULinkBase from './LinkBase.vue'
import { useComponentUiTheme } from '../composables/useComponentUiTheme'

const props = defineProps<ButtonProps>()
const slots = defineSlots<ButtonSlots>()

const appConfig = useAppConfig() as Button['AppConfig']
const uiTheme = useComponentUiTheme('button', () => ({ slots: props.ui }))
const { orientation, size: buttonSize } = useButtonGroup<ButtonProps>(props)

const linkProps = useForwardProps(pickLinkProps(props))
Expand Down Expand Up @@ -125,26 +127,26 @@ const ui = computed(() => tv({
<ULinkBase
v-bind="slotProps"
:class="ui.base({
class: [props.ui?.base, props.class],
class: [uiTheme?.slots?.base, props.class],
active,
...(active && activeVariant ? { variant: activeVariant } : {}),
...(active && activeColor ? { color: activeColor } : {})
})"
@click="onClickWrapper"
>
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon, active })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar, active })" />
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: uiTheme?.slots?.leadingIcon, active })" />
<UAvatar v-else-if="!!avatar" :size="((uiTheme?.slots?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.leadingAvatar({ class: uiTheme?.slots?.leadingAvatar, active })" />
</slot>

<slot>
<span v-if="label !== undefined && label !== null" :class="ui.label({ class: props.ui?.label, active })">
<span v-if="label !== undefined && label !== null" :class="ui.label({ class: uiTheme?.slots?.label, active })">
{{ label }}
</span>
</slot>

<slot name="trailing">
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon, active })" />
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: uiTheme?.slots?.trailingIcon, active })" />
</slot>
</ULinkBase>
</ULink>
Expand Down
29 changes: 29 additions & 0 deletions src/runtime/components/Theme.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import type * as ui from '#build/ui'
import type { TVConfig } from '../types/tv'
import { computed } from 'vue'
import { provideUTheme } from '../composables/useComponentUiTheme'

type UIConfig = TVConfig<typeof ui>

export interface ThemeProps {
theme: UIConfig
}

export interface ThemeSlots {
default(props?: {}): any
}
</script>

<script setup lang="ts">
const props = defineProps<ThemeProps>()

const rootContext = computed(() => props.theme)
provideUTheme({
theme: rootContext
})
</script>

<template>
<slot />
</template>
23 changes: 23 additions & 0 deletions src/runtime/composables/useComponentUiTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createContext } from 'reka-ui'
import type * as ui from '#build/ui'
import type { TVConfig } from '../types/tv'
import type { ComputedRef } from 'vue'
import { computed, toValue } from 'vue'
import defu from 'defu'
import type { RefOrGetter } from '../types/utils'

type UIConfig = TVConfig<typeof ui>
export type ThemeRootContext = {
theme: ComputedRef<UIConfig>
}

const [inject, provide] = createContext<ThemeRootContext>('UTheme', 'RootContext')

export const provideUTheme = provide

export function useComponentUiTheme<TName extends keyof UIConfig>(name: TName, ui: RefOrGetter<UIConfig[TName]>): ComputedRef<UIConfig[TName]> {
const { theme } = inject({ theme: computed(() => ({})) })
return computed(() => {
return defu(toValue(ui) ?? {}, theme.value[name] || {})
})
}
4 changes: 3 additions & 1 deletion src/runtime/types/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { VNode } from 'vue'
import type { Ref, VNode } from 'vue'
import type { AcceptableValue as _AcceptableValue } from 'reka-ui'

export type DeepPartial<T> = {
Expand Down Expand Up @@ -81,4 +81,6 @@ export type EmitsToProps<T> = {
: never
}

export type RefOrGetter<T> = Ref<T> | (() => T)

export * from './tv'
50 changes: 50 additions & 0 deletions test/components/Theme.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import type { ThemeProps, ThemeSlots } from '../../src/runtime/components/Theme.vue'
import Theme from '../../src/runtime/components/Theme.vue'
import ComponentRender from '../component-render'
import { h } from 'vue'
import Button from '../../src/runtime/components/Button.vue'

type CaseOptions = { props?: ThemeProps, slots?: ThemeSlots }

describe('Theme', () => {
it.each([
// Props
[
'with theme but not for this component',
{
props: { theme: { } },
slots: { default: () => h(Button, { label: 'Button' }) }
} satisfies CaseOptions,
[]
],
[
'with theme',
{
props: { theme: { button: { slots: { label: 'text-[#ff0]', base: 'px-[1.234rem]' } } } },
slots: { default: () => h(Button, { label: 'Button' }) }
} satisfies CaseOptions,
['px-[1.234rem]', 'text-[#ff0]']
],
[
'with ui prop taking priority',
{
props: { theme: { button: { slots: { label: 'text-[#ff0]', base: 'px-[1.234rem]' } } } },
slots: { default: () => h(Button, { label: 'Button', ui: { base: 'px-[2.234rem]' } }) }
} satisfies CaseOptions,
['px-[2.234rem]']
],
[
'with nested theme (most recent theme wins)',
{
props: { theme: { button: { slots: { label: 'text-[#ff0]', base: 'px-[1.234rem]' } } } },
slots: { default: () => h(Theme, { theme: { button: { slots: { label: 'text-[#000]', base: 'px-[2.234rem]' } } } }, () => h(Button, { label: 'Button' })) }
} satisfies CaseOptions,
['px-[2.234rem]', 'text-[#000]']
]
])('renders %s correctly', async (nameOrHtml: string, options: CaseOptions, contains: string[] = []) => {
const html = await ComponentRender(nameOrHtml, options, Theme)
expect(html).toMatchSnapshot()

Check failure on line 47 in test/components/Theme.spec.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

../components/Theme.spec.ts > Theme > renders with nested theme (most recent theme wins) correctly

Error: Snapshot `Theme > renders with nested theme (most recent theme wins) correctly 1` mismatched - Expected + Received - "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[2.234rem]"> + "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[2.234rem]"> <!--v-if--><span class="truncate text-[#000]">Button</span> <!--v-if--> </button>" ❯ ../components/Theme.spec.ts:47:18

Check failure on line 47 in test/components/Theme.spec.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

../components/Theme.spec.ts > Theme > renders with ui prop taking priority correctly

Error: Snapshot `Theme > renders with ui prop taking priority correctly 1` mismatched - Expected + Received - "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[2.234rem]"> + "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[2.234rem]"> <!--v-if--><span class="truncate text-[#ff0]">Button</span> <!--v-if--> </button>" ❯ ../components/Theme.spec.ts:47:18

Check failure on line 47 in test/components/Theme.spec.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

../components/Theme.spec.ts > Theme > renders with theme correctly

Error: Snapshot `Theme > renders with theme correctly 1` mismatched - Expected + Received - "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[1.234rem]"> + "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[1.234rem]"> <!--v-if--><span class="truncate text-[#ff0]">Button</span> <!--v-if--> </button>" ❯ ../components/Theme.spec.ts:47:18

Check failure on line 47 in test/components/Theme.spec.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 22)

../components/Theme.spec.ts > Theme > renders with theme but not for this component correctly

Error: Snapshot `Theme > renders with theme but not for this component correctly 1` mismatched - Expected + Received - "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"> + "<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 active:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"> <!--v-if--><span class="truncate">Button</span> <!--v-if--> </button>" ❯ ../components/Theme.spec.ts:47:18
contains.forEach(c => expect(html).toContain(c))
})
})
29 changes: 29 additions & 0 deletions test/components/__snapshots__/Theme.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Theme > renders with nested theme (most recent theme wins) correctly 1`] = `
"<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[2.234rem]">
<!--v-if--><span class="truncate text-[#000]">Button</span>
<!--v-if-->
</button>"
`;

exports[`Theme > renders with theme but not for this component correctly 1`] = `
"<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
<!--v-if--><span class="truncate">Button</span>
<!--v-if-->
</button>"
`;

exports[`Theme > renders with theme correctly 1`] = `
"<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[1.234rem]">
<!--v-if--><span class="truncate text-[#ff0]">Button</span>
<!--v-if-->
</button>"
`;

exports[`Theme > renders with ui prop taking priority correctly 1`] = `
"<button type="button" class="rounded-md font-medium inline-flex items-center disabled:cursor-not-allowed aria-disabled:cursor-not-allowed disabled:opacity-75 aria-disabled:opacity-75 transition-colors py-1.5 text-sm gap-1.5 text-inverted bg-primary hover:bg-primary/75 disabled:bg-primary aria-disabled:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary px-[2.234rem]">
<!--v-if--><span class="truncate text-[#ff0]">Button</span>
<!--v-if-->
</button>"
`;
Loading