fix: dynamic slots autocomplete (#77)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
plushdohn
2024-04-24 19:15:20 +02:00
committed by GitHub
parent 6d377d1f4b
commit c6a93f71f2
13 changed files with 97 additions and 69 deletions

View File

@@ -22,7 +22,7 @@ const items = [{
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}, {
label: 'Utilities',
slot: 'toto',
slot: 'custom' as const,
icon: 'i-heroicons-wrench-screwdriver',
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed neque elit, tristique placerat feugiat ac, facilisis vitae arcu. Proin eget egestas augue. Praesent ut sem nec arcu pellentesque aliquet. Duis dapibus diam vel metus tempus vulputate.'
}]
@@ -30,6 +30,10 @@ const items = [{
<template>
<UCard :ui="{ body: 'p-0 sm:p-0' }">
<UAccordion :items="items" class="w-96" :ui="{ trigger: 'px-3.5', content: 'px-3.5' }" />
<UAccordion :items="items" class="w-96" :ui="{ trigger: 'px-3.5', content: 'px-3.5' }">
<template #custom="{ item }">
<span class="text-gray-500 dark:text-gray-400">Custom: {{ item.content }}</span>
</template>
</UAccordion>
</UCard>
</template>

View File

@@ -3,7 +3,7 @@ const items = [{
label: 'Home',
to: '/'
}, {
slot: 'dropdown',
slot: 'dropdown' as const,
icon: 'i-heroicons-ellipsis-horizontal',
children: [{
label: 'Documentation'

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
const appConfig = useAppConfig()
const items = [
[{
label: 'My account',
@@ -10,6 +12,7 @@ const items = [
[{
label: 'Profile',
icon: 'i-heroicons-user',
slot: 'custom' as const,
select(e: Event) {
e.preventDefault()
console.log('Profile clicked')
@@ -116,6 +119,14 @@ defineShortcuts(extractShortcuts(items))
<div class="flex-1">
<UDropdownMenu :items="items" arrow :content="{ side: 'bottom' }" class="min-w-48">
<UButton label="Open" color="white" />
<template #custom="{ item }">
<UIcon :name="item.icon" class="shrink-0 size-5" />
<span class="truncate">{{ item.label }}</span>
<UIcon :name="appConfig.ui.icons.check" class="shrink-0 size-5 text-primary-500 dark:text-primary-400 ms-auto" />
</template>
</UDropdownMenu>
</div>
</template>

View File

@@ -26,7 +26,8 @@ const items = [
label: 'Examples',
icon: 'i-heroicons-light-bulb',
to: 'https://ui.nuxt.com',
target: '_blank'
target: '_blank',
slot: 'custom' as const
}, {
label: 'Help',
icon: 'i-heroicons-question-mark-circle',
@@ -37,7 +38,13 @@ const items = [
<template>
<div class="flex flex-col gap-12 w-full max-w-4xl">
<UNavigationMenu :items="items" class="border-b border-gray-200 dark:border-gray-800" />
<UNavigationMenu :items="items" class="border-b border-gray-200 dark:border-gray-800">
<template #custom="{ item }">
<UIcon :name="item.icon" class="size-5" />
<span class="truncate text-primary-500 dark:text-primary-400">{{ item.label }}</span>
</template>
</UNavigationMenu>
<UNavigationMenu :items="items" orientation="vertical" class="w-48" />
</div>

View File

@@ -12,13 +12,19 @@ const items = [{
}, {
label: 'Tab3',
icon: 'i-heroicons-bell',
content: 'Finally, this is the content for Tab3'
content: 'Finally, this is the content for Tab3',
slot: 'custom' as const
}]
</script>
<template>
<div class="flex flex-col gap-4">
<UTabs :items="items" class="w-96" />
<UTabs :items="items" class="w-96">
<template #custom="{ item }">
<span class="text-gray-500 dark:text-gray-400">Custom: {{ item.content }}</span>
</template>
</UTabs>
<UTabs :items="items" orientation="vertical" />
</div>
</template>

View File

@@ -4,6 +4,7 @@ import type { AccordionRootProps, AccordionRootEmits, AccordionContentProps } fr
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/accordion'
import type { DynamicSlots } from '#ui/types/utils'
const appConfig = _appConfig as AppConfig & { ui: { accordion: Partial<typeof theme> } }
@@ -31,14 +32,13 @@ export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type AccordionSlots<T> = {
export type AccordionSlots<T extends { slot?: string }> = {
default: SlotProps<T>
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
[key: string]: SlotProps<T>
}
} & DynamicSlots<T, SlotProps<T>>
</script>
<script setup lang="ts" generic="T extends AccordionItem">

View File

@@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/breadcrumb'
import type { AvatarProps, LinkProps } from '#ui/types'
import type { DynamicSlots } from '#ui/types/utils'
const appConfig = _appConfig as AppConfig & { ui: { breadcrumb: Partial<typeof theme> } }
@@ -26,14 +27,13 @@ export interface BreadcrumbProps<T> extends Omit<PrimitiveProps, 'asChild'> {
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any
export interface BreadcrumbSlots<T> {
export type BreadcrumbSlots<T extends { slot?: string }> = {
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
[key: string]: SlotProps<T>
separator(): any
}
} & DynamicSlots<T, SlotProps<T>>
</script>
<script setup lang="ts" generic="T extends BreadcrumbItem">
@@ -56,8 +56,8 @@ const ui = computed(() => tv({ extend: breadcrumb, slots: props.ui })())
<ol :class="ui.list()">
<template v-for="(item, index) in items" :key="index">
<li :class="ui.item()">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<ULink as="span" v-bind="omit(item, ['label', 'icon', 'avatar'])" :aria-current="index === items!.length - 1 ? 'page' : undefined" :class="ui.link({ active: index === items!.length - 1, disabled: !!item.disabled, to: !!item.to })" raw>
<ULink as="span" v-bind="omit(item, ['label', 'icon', 'avatar', 'slot'])" :aria-current="index === items!.length - 1 ? 'page' : undefined" :class="ui.link({ active: index === items!.length - 1, disabled: !!item.disabled, to: !!item.to })" raw>
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="index === items!.length - 1" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active: index === items!.length - 1 })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active: index === items!.length - 1 })" />
@@ -70,8 +70,8 @@ const ui = computed(() => tv({ extend: breadcrumb, slots: props.ui })())
</span>
<slot name="trailing" :item="item" :active="index === items!.length - 1" :index="index" />
</ULink>
</slot>
</slot>
</ULink>
</li>
<li v-if="index < items!.length - 1" role="presentation" :class="ui.separator()">

View File

@@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/dropdown-menu'
import type { AvatarProps, KbdProps, LinkProps } from '#ui/types'
import type { DynamicSlots } from '#ui/types/utils'
const appConfig = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof theme> } }
@@ -43,14 +44,13 @@ export interface DropdownMenuEmits extends DropdownMenuRootEmits {}
type SlotProps<T> = (props: { item: T, active?: boolean, index: number }) => any
export interface DropdownMenuSlots<T> {
export type DropdownMenuSlots<T extends { slot?: string }> = {
default(): any
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
[key: string]: SlotProps<T>
}
} & DynamicSlots<T, SlotProps<T>>
</script>
<script setup lang="ts" generic="T extends DropdownMenuItem">
@@ -71,7 +71,7 @@ const slots = defineSlots<DropdownMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'modal'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8 }) as DropdownMenuContentProps)
const arrowProps = toRef(() => props.arrow as DropdownMenuArrowProps)
const proxySlots = omit(slots, ['default'])
const proxySlots = omit(slots, ['default']) as Record<string, DropdownMenuSlots<T>[string]>
const ui = computed(() => tv({ extend: dropdownMenu, slots: props.ui })())
</script>

View File

@@ -16,7 +16,7 @@ interface DropdownMenuContentProps<T> extends Omit<RadixDropdownMenuContentProps
interface DropdownMenuContentEmits extends RadixDropdownMenuContentEmits {}
interface DropdownMenuContentSlots<T> extends DropdownMenuSlots<T> {}
type DropdownMenuContentSlots<T extends { slot?: string }> = DropdownMenuSlots<T>
</script>
<script setup lang="ts" generic="T extends DropdownMenuItem">
@@ -34,7 +34,7 @@ const slots = defineSlots<DropdownMenuContentSlots<T>>()
const appConfig = useAppConfig()
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'class', 'ui'), emits)
const proxySlots = omit(slots, ['default'])
const proxySlots = omit(slots, ['default']) as Record<string, DropdownMenuContentSlots<T>[string]>
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate()
@@ -43,25 +43,27 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<template>
<DefineItemTemplate v-slot="{ item, active, index }">
<slot name="leading" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active })" />
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active })" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.linkLabel()">
<slot name="label" :item="item" :active="active" :index="index">
{{ item.label }}
</slot>
</span>
<span v-if="$slots.trailing || item.children?.length || item.kbds?.length" :class="ui.linkTrailing()">
<slot name="trailing" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.linkTrailingIcon()" />
<span v-else-if="item.kbds?.length" :class="ui.linkTrailingKbds()">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
</slot>
</span>
</slot>
<span v-if="item.label || $slots.label" :class="ui.linkLabel()">
<slot name="label" :item="item" :active="active" :index="index">
{{ item.label }}
</slot>
</span>
<span v-if="$slots.trailing || item.children?.length || item.kbds?.length" :class="ui.linkTrailing()">
<slot name="trailing" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.linkTrailingIcon()" />
<span v-else-if="item.kbds?.length" :class="ui.linkTrailingKbds()">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
</slot>
</span>
</DefineItemTemplate>
<DropdownMenu.Portal :disabled="!portal">
@@ -69,9 +71,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<DropdownMenu.Group v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group()">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<DropdownMenu.Label v-if="item.type === 'label'" :class="ui.label()">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<ReuseItemTemplate :item="item" :index="index" />
</slot>
<ReuseItemTemplate :item="item" :index="index" />
</DropdownMenu.Label>
<DropdownMenu.Sub v-else-if="item?.children?.length">
<DropdownMenu.SubTrigger
@@ -83,9 +83,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:text-value="item.label"
:class="ui.link()"
>
<slot :name="item.slot || 'item'" :item="item" :index="index">
<ReuseItemTemplate :item="item" :index="index" />
</slot>
<ReuseItemTemplate :item="item" :index="index" />
</DropdownMenu.SubTrigger>
<UDropdownMenuContent
@@ -106,13 +104,11 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</UDropdownMenuContent>
</DropdownMenu.Sub>
<DropdownMenu.Item v-else as-child :disabled="item.disabled" :text-value="item.label" @select="item.select">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as DropdownMenuItem), ['label', 'icon', 'avatar', 'kbds', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
<ULinkBase v-bind="slotProps" :class="ui.link({ active })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</ULink>
</slot>
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as DropdownMenuItem), ['label', 'icon', 'avatar', 'kbds', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
<ULinkBase v-bind="slotProps" :class="ui.link({ active })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</ULink>
</DropdownMenu.Item>
</template>
</DropdownMenu.Group>

View File

@@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/navigation-menu'
import type { AvatarProps, BadgeProps, LinkProps, SeparatorProps } from '#ui/types'
import type { DynamicSlots } from '#ui/types/utils'
const appConfig = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } }
@@ -31,13 +32,12 @@ export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any
export interface NavigationMenuSlots<T> {
export type NavigationMenuSlots<T extends { slot?: string }> = {
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
[key: string]: SlotProps<T>
}
} & DynamicSlots<T, SlotProps<T>>
</script>
<script setup lang="ts" generic="T extends NavigationMenuItem">
@@ -63,10 +63,10 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
<template v-for="(list, listIndex) in lists" :key="`list-${listIndex}`">
<NavigationMenuList :class="ui.list()">
<NavigationMenuItem v-for="(item, index) in list" :key="`list-${listIndex}-${index}`" :value="item.value || String(index)" :class="ui.item()">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(item, ['label', 'icon', 'avatar', 'badge', 'select'])" custom>
<NavigationMenuLink as-child :active="active" @select="item.select">
<ULinkBase v-bind="slotProps" :class="ui.link({ active, disabled: !!item.disabled })">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(item, ['label', 'icon', 'avatar', 'badge', 'slot', 'select'])" custom>
<NavigationMenuLink as-child :active="active" @select="item.select">
<ULinkBase v-bind="slotProps" :class="ui.link({ active, disabled: !!item.disabled })">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active, disabled: !!item.disabled })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active, disabled: !!item.disabled })" />
@@ -89,10 +89,10 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
/>
</slot>
</span>
</ULinkBase>
</NavigationMenuLink>
</ULink>
</slot>
</slot>
</ULinkBase>
</NavigationMenuLink>
</ULink>
</NavigationMenuItem>
</NavigationMenuList>

View File

@@ -5,6 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/tabs'
import type { AvatarProps } from '#ui/types'
import type { DynamicSlots } from '#ui/types/utils'
const appConfig = _appConfig as AppConfig & { ui: { tabs: Partial<typeof theme> } }
@@ -31,14 +32,13 @@ export interface TabsEmits extends TabsRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type TabsSlots<T> = {
export type TabsSlots<T extends { slot?: string }> = {
default: SlotProps<T>
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
[key: string]: SlotProps<T>
}
} & DynamicSlots<T, SlotProps<T>>
</script>
<script setup lang="ts" generic="T extends TabsItem">

View File

@@ -1,3 +1,6 @@
export type DeepPartial<T> = Partial<{
[P in keyof T]: DeepPartial<T[P]> | { [key: string]: string | object }
}>
export type DynamicSlots<T extends { slot?: string }, SlotProps, Slot = T['slot']> =
Record<string, SlotProps> & (Slot extends string ? Record<Slot, SlotProps> : Record<string, never>)

View File

@@ -14,7 +14,8 @@ describe('Tabs', () => {
}, {
label: 'Tab3',
icon: 'i-heroicons-bell',
content: 'Finally, this is the content for Tab3'
content: 'Finally, this is the content for Tab3',
slot: 'custom'
}]
const props = { items }