fix(components): improve generic types (#3331)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Sandro Circi
2025-03-24 21:38:13 +01:00
committed by GitHub
parent 370054b20c
commit b9983549a4
106 changed files with 1203 additions and 535 deletions

View File

@@ -1,5 +1,6 @@
<!-- eslint-disable no-useless-escape --> <!-- eslint-disable no-useless-escape -->
<script setup lang="ts"> <script setup lang="ts">
import type { ChipProps } from '@nuxt/ui'
import json5 from 'json5' import json5 from 'json5'
import { upperFirst, camelCase, kebabCase } from 'scule' import { upperFirst, camelCase, kebabCase } from 'scule'
import { hash } from 'ohash' import { hash } from 'ohash'
@@ -53,6 +54,8 @@ const props = defineProps<{
hide?: string[] hide?: string[]
/** List of props to externalize in script setup */ /** List of props to externalize in script setup */
external?: string[] external?: string[]
/** The types of the externalized props */
externalTypes?: string[]
/** List of props to use with `v-model` */ /** List of props to use with `v-model` */
model?: string[] model?: string[]
/** List of props to cast from code and selection */ /** List of props to cast from code and selection */
@@ -209,11 +212,21 @@ ${props.slots?.default}
code += ` code += `
<script setup lang="ts"> <script setup lang="ts">
` `
for (const key of props.external) { if (props.externalTypes?.length) {
const removeArrayBrackets = (type: string): string => type.endsWith('[]') ? removeArrayBrackets(type.slice(0, -2)) : type
const types = props.externalTypes.map(type => removeArrayBrackets(type))
code += `import type { ${types.join(', ')} } from '@nuxt/ui${props.pro ? '-pro' : ''}'
`
}
for (const [i, key] of props.external.entries()) {
const cast = props.cast?.[key] const cast = props.cast?.[key]
const value = cast ? castMap[cast]!.template(componentProps[key]) : json5.stringify(componentProps[key], null, 2)?.replace(/,([ |\t\n]+[}|\]])/g, '$1') const value = cast ? castMap[cast]!.template(componentProps[key]) : json5.stringify(componentProps[key], null, 2)?.replace(/,([ |\t\n]+[}|\]])/g, '$1')
const type = props.externalTypes?.[i] ? `<${props.externalTypes[i]}>` : ''
code += `const ${key === 'modelValue' ? 'value' : key} = ref(${value}) code += `const ${key === 'modelValue' ? 'value' : key} = ref${type}(${value})
` `
} }
code += `<\/script> code += `<\/script>
@@ -346,7 +359,7 @@ const { data: ast } = await useAsyncData(`component-code-${name}-${hash({ props:
inset inset
standalone standalone
:color="(modelValue as any)" :color="(modelValue as any)"
:size="ui.itemLeadingChipSize()" :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
class="size-2" class="size-2"
/> />
</template> </template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ChipProps } from '@nuxt/ui'
import { camelCase } from 'scule' import { camelCase } from 'scule'
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
import { get, set } from '#ui/utils' import { get, set } from '#ui/utils'
@@ -185,7 +186,7 @@ const urlSearchParams = computed(() => {
inset inset
standalone standalone
:color="(modelValue as any)" :color="(modelValue as any)"
:size="ui.itemLeadingChipSize()" :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
class="size-2" class="size-2"
/> />
</template> </template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { AccordionItem } from '@nuxt/ui'
const items: AccordionItem[] = [
{ {
label: 'Icons', label: 'Icons',
icon: 'i-lucide-smile' icon: 'i-lucide-smile'

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { AccordionItem } from '@nuxt/ui'
const items: AccordionItem[] = [
{ {
label: 'Icons', label: 'Icons',
icon: 'i-lucide-smile' icon: 'i-lucide-smile'

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AccordionItem } from '@nuxt/ui'
const items = [ const items = [
{ {
label: 'Icons', label: 'Icons',
@@ -8,7 +10,7 @@ const items = [
{ {
label: 'Colors', label: 'Colors',
icon: 'i-lucide-swatch-book', icon: 'i-lucide-swatch-book',
slot: 'colors', slot: 'colors' as const,
content: 'Choose a primary and a neutral color from your Tailwind CSS theme.' content: 'Choose a primary and a neutral color from your Tailwind CSS theme.'
}, },
{ {
@@ -16,7 +18,7 @@ const items = [
icon: 'i-lucide-box', icon: 'i-lucide-box',
content: 'You can customize components by using the `class` / `ui` props or in your app.config.ts.' content: 'You can customize components by using the `class` / `ui` props or in your app.config.ts.'
} }
] ] satisfies AccordionItem[]
</script> </script>
<template> <template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { AccordionItem } from '@nuxt/ui'
const items: AccordionItem[] = [
{ {
label: 'Icons', label: 'Icons',
icon: 'i-lucide-smile', icon: 'i-lucide-smile',

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BreadcrumbItem } from '@nuxt/ui'
const items = [{ const items = [{
label: 'Home', label: 'Home',
to: '/' to: '/'
}, { }, {
slot: 'dropdown', slot: 'dropdown' as const,
icon: 'i-lucide-ellipsis', icon: 'i-lucide-ellipsis',
children: [{ children: [{
label: 'Documentation' label: 'Documentation'
@@ -18,7 +20,7 @@ const items = [{
}, { }, {
label: 'Breadcrumb', label: 'Breadcrumb',
to: '/components/breadcrumb' to: '/components/breadcrumb'
}] }] satisfies BreadcrumbItem[]
</script> </script>
<template> <template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [{ import type { BreadcrumbItem } from '@nuxt/ui'
const items: BreadcrumbItem[] = [{
label: 'Home', label: 'Home',
to: '/' to: '/'
}, { }, {

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [{ import type { DropdownMenuItem } from '@nuxt/ui'
const items: DropdownMenuItem[] = [{
label: 'Team', label: 'Team',
icon: 'i-lucide-users' icon: 'i-lucide-users'
}, { }, {

View File

@@ -11,7 +11,7 @@ const groups = [{
label: 'Billing', label: 'Billing',
icon: 'i-lucide-credit-card', icon: 'i-lucide-credit-card',
kbds: ['meta', 'B'], kbds: ['meta', 'B'],
slot: 'billing' slot: 'billing' as const
}, },
{ {
label: 'Notifications', label: 'Notifications',
@@ -25,7 +25,7 @@ const groups = [{
}, { }, {
id: 'users', id: 'users',
label: 'Users', label: 'Users',
slot: 'users', slot: 'users' as const,
items: [ items: [
{ {
label: 'Benjamin Canac', label: 'Benjamin Canac',

View File

@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContextMenuItem } from '@nuxt/ui'
const showSidebar = ref(true) const showSidebar = ref(true)
const showToolbar = ref(false) const showToolbar = ref(false)
const items = computed(() => [{ const items = computed<ContextMenuItem[]>(() => [{
label: 'View', label: 'View',
type: 'label' as const type: 'label' as const
}, { }, {

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { ContextMenuItem } from '@nuxt/ui'
const items: ContextMenuItem[][] = [
[ [
{ {
label: 'View', label: 'View',

View File

@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContextMenuItem } from '@nuxt/ui'
const loading = ref(true) const loading = ref(true)
const items = [{ const items: ContextMenuItem[] = [{
label: 'Refresh the Page', label: 'Refresh the Page',
slot: 'refresh' slot: 'refresh'
}, { }, {

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
const showBookmarks = ref(true) const showBookmarks = ref(true)
const showHistory = ref(false) const showHistory = ref(false)
const showDownloads = ref(false) const showDownloads = ref(false)
@@ -36,7 +38,7 @@ const items = computed(() => [{
onUpdateChecked(checked: boolean) { onUpdateChecked(checked: boolean) {
showDownloads.value = checked showDownloads.value = checked
} }
}]) }] satisfies DropdownMenuItem[])
</script> </script>
<template> <template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { DropdownMenuItem } from '@nuxt/ui'
const items: DropdownMenuItem[][] = [
[ [
{ {
label: 'View', label: 'View',
@@ -17,7 +19,7 @@ const items = [
[ [
{ {
label: 'Delete', label: 'Delete',
color: 'error' as const, color: 'error',
icon: 'i-lucide-trash' icon: 'i-lucide-trash'
} }
] ]
@@ -27,9 +29,5 @@ const items = [
<template> <template>
<UDropdownMenu :items="items" :ui="{ content: 'w-48' }"> <UDropdownMenu :items="items" :ui="{ content: 'w-48' }">
<UButton label="Open" color="neutral" variant="outline" icon="i-lucide-menu" /> <UButton label="Open" color="neutral" variant="outline" icon="i-lucide-menu" />
<template #profile-trailing>
<UIcon name="i-lucide-badge-check" class="shrink-0 size-5 text-(--ui-primary)" />
</template>
</UDropdownMenu> </UDropdownMenu>
</template> </template>

View File

@@ -1,15 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [{ import type { DropdownMenuItem } from '@nuxt/ui'
label: 'Profile',
icon: 'i-lucide-user', const items = [
slot: 'profile' {
}, { label: 'Profile',
label: 'Billing', icon: 'i-lucide-user',
icon: 'i-lucide-credit-card' slot: 'profile' as const
}, { }, {
label: 'Settings', label: 'Billing',
icon: 'i-lucide-cog' icon: 'i-lucide-credit-card'
}] }, {
label: 'Settings',
icon: 'i-lucide-cog'
}
] satisfies DropdownMenuItem[]
</script> </script>
<template> <template>

View File

@@ -1,20 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
const open = ref(false) const open = ref(false)
defineShortcuts({ defineShortcuts({
o: () => open.value = !open.value o: () => open.value = !open.value
}) })
const items = [{ const items: DropdownMenuItem[] = [
label: 'Profile', {
icon: 'i-lucide-user' label: 'Profile',
}, { icon: 'i-lucide-user'
label: 'Billing', }, {
icon: 'i-lucide-credit-card' label: 'Billing',
}, { icon: 'i-lucide-credit-card'
label: 'Settings', }, {
icon: 'i-lucide-cog' label: 'Settings',
}] icon: 'i-lucide-cog'
}
]
</script> </script>
<template> <template>

View File

@@ -16,7 +16,7 @@ function onOpen() {
<template> <template>
<UInputMenu <UInputMenu
:items="countries || []" :items="countries"
:loading="status === 'pending'" :loading="status === 'pending'"
label-key="name" label-key="name"
:search-input="{ icon: 'i-lucide-search' }" :search-input="{ icon: 'i-lucide-search' }"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
key: 'typicode-users', key: 'typicode-users',
transform: (data: { id: number, name: string }[]) => { transform: (data: { id: number, name: string }[]) => {
@@ -6,7 +8,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
label: user.name, label: user.name,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -14,7 +16,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<template> <template>
<UInputMenu <UInputMenu
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
icon="i-lucide-user" icon="i-lucide-user"
placeholder="Select user" placeholder="Select user"
@@ -23,7 +25,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="modelValue.avatar" v-bind="modelValue.avatar"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
key: 'typicode-users-email', key: 'typicode-users-email',
transform: (data: { id: number, name: string, email: string }[]) => { transform: (data: { id: number, name: string, email: string }[]) => {
@@ -7,7 +9,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
email: user.email, email: user.email,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -15,7 +17,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<template> <template>
<UInputMenu <UInputMenu
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
:filter-fields="['label', 'email']" :filter-fields="['label', 'email']"
icon="i-lucide-user" icon="i-lucide-user"
@@ -26,7 +28,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="modelValue.avatar" v-bind="modelValue.avatar"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const searchTerm = ref('') const searchTerm = ref('')
const searchTermDebounced = refDebounced(searchTerm, 200) const searchTermDebounced = refDebounced(searchTerm, 200)
@@ -10,7 +12,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
label: user.name, label: user.name,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -19,7 +21,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<template> <template>
<UInputMenu <UInputMenu
v-model:search-term="searchTerm" v-model:search-term="searchTerm"
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
ignore-filter ignore-filter
icon="i-lucide-user" icon="i-lucide-user"
@@ -29,7 +31,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="modelValue.avatar" v-bind="modelValue.avatar"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InputMenuItem } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'benjamincanac', label: 'benjamincanac',
@@ -23,8 +25,16 @@ const items = ref([
src: 'https://github.com/noook.png', src: 'https://github.com/noook.png',
alt: 'noook' alt: 'noook'
} }
},
{
label: 'sandros94',
value: 'sandros94',
avatar: {
src: 'https://github.com/sandros94.png',
alt: 'sandros94'
}
} }
]) ] satisfies InputMenuItem[])
const value = ref(items.value[0]) const value = ref(items.value[0])
</script> </script>

View File

@@ -1,27 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InputMenuItem, ChipProps } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'bug', label: 'bug',
value: 'bug', value: 'bug',
chip: { chip: {
color: 'error' as const color: 'error'
} }
}, },
{ {
label: 'feature', label: 'feature',
value: 'feature', value: 'feature',
chip: { chip: {
color: 'success' as const color: 'success'
} }
}, },
{ {
label: 'enhancement', label: 'enhancement',
value: 'enhancement', value: 'enhancement',
chip: { chip: {
color: 'info' as const color: 'info'
} }
} }
]) ] satisfies InputMenuItem[])
const value = ref(items.value[0]) const value = ref(items.value[0])
</script> </script>
@@ -33,7 +36,7 @@ const value = ref(items.value[0])
v-bind="modelValue.chip" v-bind="modelValue.chip"
inset inset
standalone standalone
:size="ui.itemLeadingChipSize()" :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
:class="ui.itemLeadingChip()" :class="ui.itemLeadingChip()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InputMenuItem } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'Backlog', label: 'Backlog',
@@ -20,7 +22,8 @@ const items = ref([
value: 'done', value: 'done',
icon: 'i-lucide-circle-check' icon: 'i-lucide-circle-check'
} }
]) ] satisfies InputMenuItem[])
const value = ref(items.value[0]) const value = ref(items.value[0])
</script> </script>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
const items = [ const items = [
{ {
label: 'Docs', label: 'Docs',
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',
slot: 'docs', slot: 'docs' as const,
children: [ children: [
{ {
label: 'Icons', label: 'Icons',
@@ -22,7 +24,7 @@ const items = [
{ {
label: 'Components', label: 'Components',
icon: 'i-lucide-box', icon: 'i-lucide-box',
slot: 'components', slot: 'components' as const,
children: [ children: [
{ {
label: 'Link', label: 'Link',
@@ -54,7 +56,7 @@ const items = [
label: 'GitHub', label: 'GitHub',
icon: 'i-simple-icons-github' icon: 'i-simple-icons-github'
} }
] ] satisfies NavigationMenuItem[]
</script> </script>
<template> <template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { NavigationMenuItem } from '@nuxt/ui'
const items: NavigationMenuItem[] = [
{ {
label: 'Guide', label: 'Guide',
icon: 'i-lucide-book-open' icon: 'i-lucide-book-open'

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { NavigationMenuItem } from '@nuxt/ui'
const items: NavigationMenuItem[] = [
{ {
label: 'Guide', label: 'Guide',
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',

View File

@@ -4,8 +4,7 @@ const { data: countries, status, execute } = await useLazyFetch<{
code: string code: string
emoji: string emoji: string
}[]>('/api/countries.json', { }[]>('/api/countries.json', {
immediate: false, immediate: false
default: () => []
}) })
function onOpen() { function onOpen() {

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
key: 'typicode-users', key: 'typicode-users',
transform: (data: { id: number, name: string }[]) => { transform: (data: { id: number, name: string }[]) => {
@@ -6,7 +8,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
label: user.name, label: user.name,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -14,7 +16,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<template> <template>
<USelectMenu <USelectMenu
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
icon="i-lucide-user" icon="i-lucide-user"
placeholder="Select user" placeholder="Select user"
@@ -24,7 +26,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="modelValue.avatar" v-bind="modelValue.avatar"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
key: 'typicode-users-email', key: 'typicode-users-email',
transform: (data: { id: number, name: string, email: string }[]) => { transform: (data: { id: number, name: string, email: string }[]) => {
@@ -7,7 +9,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
email: user.email, email: user.email,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -15,7 +17,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<template> <template>
<USelectMenu <USelectMenu
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
:filter-fields="['label', 'email']" :filter-fields="['label', 'email']"
icon="i-lucide-user" icon="i-lucide-user"
@@ -26,7 +28,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="modelValue.avatar" v-bind="modelValue.avatar"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const searchTerm = ref('') const searchTerm = ref('')
const searchTermDebounced = refDebounced(searchTerm, 200) const searchTermDebounced = refDebounced(searchTerm, 200)
@@ -10,7 +12,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
label: user.name, label: user.name,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -19,7 +21,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<template> <template>
<USelectMenu <USelectMenu
v-model:search-term="searchTerm" v-model:search-term="searchTerm"
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
ignore-filter ignore-filter
icon="i-lucide-user" icon="i-lucide-user"
@@ -30,7 +32,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="modelValue.avatar" v-bind="modelValue.avatar"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectMenuItem } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'benjamincanac', label: 'benjamincanac',
@@ -23,8 +25,16 @@ const items = ref([
src: 'https://github.com/noook.png', src: 'https://github.com/noook.png',
alt: 'noook' alt: 'noook'
} }
},
{
label: 'sandros94',
value: 'sandros94',
avatar: {
src: 'https://github.com/sandros94.png',
alt: 'sandros94'
}
} }
]) ] satisfies SelectMenuItem[])
const value = ref(items.value[0]) const value = ref(items.value[0])
</script> </script>

View File

@@ -1,27 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectMenuItem, ChipProps } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'bug', label: 'bug',
value: 'bug', value: 'bug',
chip: { chip: {
color: 'error' as const color: 'error'
} }
}, },
{ {
label: 'feature', label: 'feature',
value: 'feature', value: 'feature',
chip: { chip: {
color: 'success' as const color: 'success'
} }
}, },
{ {
label: 'enhancement', label: 'enhancement',
value: 'enhancement', value: 'enhancement',
chip: { chip: {
color: 'info' as const color: 'info'
} }
} }
]) ] satisfies SelectMenuItem[])
const value = ref(items.value[0]) const value = ref(items.value[0])
</script> </script>
@@ -33,7 +35,7 @@ const value = ref(items.value[0])
v-bind="modelValue.chip" v-bind="modelValue.chip"
inset inset
standalone standalone
:size="ui.itemLeadingChipSize()" :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
:class="ui.itemLeadingChip()" :class="ui.itemLeadingChip()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectMenuItem } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'Backlog', label: 'Backlog',
@@ -20,7 +22,7 @@ const items = ref([
value: 'done', value: 'done',
icon: 'i-lucide-circle-check' icon: 'i-lucide-circle-check'
} }
]) ] satisfies SelectMenuItem[])
const value = ref(items.value[0]) const value = ref(items.value[0])
</script> </script>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
key: 'typicode-users', key: 'typicode-users',
transform: (data: { id: number, name: string }[]) => { transform: (data: { id: number, name: string }[]) => {
@@ -6,7 +8,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
label: user.name, label: user.name,
value: String(user.id), value: String(user.id),
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
})) || [] }))
}, },
lazy: true lazy: true
}) })
@@ -18,17 +20,18 @@ function getUserAvatar(value: string) {
<template> <template>
<USelect <USelect
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
icon="i-lucide-user" icon="i-lucide-user"
placeholder="Select user" placeholder="Select user"
class="w-48" class="w-48"
value-key="value"
> >
<template #leading="{ modelValue, ui }"> <template #leading="{ modelValue, ui }">
<UAvatar <UAvatar
v-if="modelValue" v-if="modelValue"
v-bind="getUserAvatar(modelValue as string)" v-bind="getUserAvatar(modelValue)"
:size="ui.leadingAvatarSize()" :size="(ui.leadingAvatarSize() as AvatarProps['size'])"
:class="ui.leadingAvatar()" :class="ui.leadingAvatar()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItem } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'benjamincanac', label: 'benjamincanac',
@@ -23,13 +25,21 @@ const items = ref([
src: 'https://github.com/noook.png', src: 'https://github.com/noook.png',
alt: 'noook' alt: 'noook'
} }
},
{
label: 'sandros94',
value: 'sandros94',
avatar: {
src: 'https://github.com/sandros94.png',
alt: 'sandros94'
}
} }
]) ] satisfies SelectItem[])
const value = ref(items.value[0]?.value) const value = ref(items.value[0]?.value)
const avatar = computed(() => items.value.find(item => item.value === value.value)?.avatar) const avatar = computed(() => items.value.find(item => item.value === value.value)?.avatar)
</script> </script>
<template> <template>
<USelect v-model="value" :avatar="avatar" :items="items" class="w-48" /> <USelect v-model="value" :items="items" value-key="value" :avatar="avatar" class="w-48" />
</template> </template>

View File

@@ -1,27 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItem, ChipProps } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'bug', label: 'bug',
value: 'bug', value: 'bug',
chip: { chip: {
color: 'error' as const color: 'error'
} }
}, },
{ {
label: 'feature', label: 'feature',
value: 'feature', value: 'feature',
chip: { chip: {
color: 'success' as const color: 'success'
} }
}, },
{ {
label: 'enhancement', label: 'enhancement',
value: 'enhancement', value: 'enhancement',
chip: { chip: {
color: 'info' as const color: 'info'
} }
} }
]) ] satisfies SelectItem[])
const value = ref(items.value[0]?.value) const value = ref(items.value[0]?.value)
function getChip(value: string) { function getChip(value: string) {
@@ -30,14 +33,14 @@ function getChip(value: string) {
</script> </script>
<template> <template>
<USelect v-model="value" :items="items" class="w-48"> <USelect v-model="value" :items="items" value-key="value" class="w-48">
<template #leading="{ modelValue, ui }"> <template #leading="{ modelValue, ui }">
<UChip <UChip
v-if="modelValue" v-if="modelValue"
v-bind="getChip(modelValue as string)" v-bind="getChip(modelValue)"
inset inset
standalone standalone
:size="ui.itemLeadingChipSize()" :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
:class="ui.itemLeadingChip()" :class="ui.itemLeadingChip()"
/> />
</template> </template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItem } from '@nuxt/ui'
const items = ref([ const items = ref([
{ {
label: 'Backlog', label: 'Backlog',
@@ -20,12 +22,12 @@ const items = ref([
value: 'done', value: 'done',
icon: 'i-lucide-circle-check' icon: 'i-lucide-circle-check'
} }
]) ] satisfies SelectItem[])
const value = ref(items.value[0]?.value) const value = ref(items.value[0]?.value)
const icon = computed(() => items.value.find(item => item.value === value.value)?.icon) const icon = computed(() => items.value.find(item => item.value === value.value)?.icon)
</script> </script>
<template> <template>
<USelect v-model="value" :icon="icon" :items="items" class="w-48" /> <USelect v-model="value" :items="items" value-key="value" :icon="icon" class="w-48" />
</template> </template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { StepperItem } from '@nuxt/ui'
const items: StepperItem[] = [
{ {
title: 'Address', title: 'Address',
description: 'Add your address here', description: 'Add your address here',

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { StepperItem } from '@nuxt/ui'
const items: StepperItem[] = [
{ {
slot: 'address', slot: 'address',
title: 'Address', title: 'Address',

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { StepperItem } from '@nuxt/ui'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
const items = [ const items: StepperItem[] = [
{ {
title: 'Address', title: 'Address',
description: 'Add your address here', description: 'Add your address here',

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { StepperItem } from '@nuxt/ui'
const items: StepperItem[] = [
{ {
slot: 'address', slot: 'address',
title: 'Address', title: 'Address',

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { TabsItem } from '@nuxt/ui'
const items: TabsItem[] = [
{ {
label: 'Account', label: 'Account',
icon: 'i-lucide-user' icon: 'i-lucide-user'

View File

@@ -1,18 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TabsItem } from '@nuxt/ui'
const items = [ const items = [
{ {
label: 'Account', label: 'Account',
description: 'Make changes to your account here. Click save when you\'re done.', description: 'Make changes to your account here. Click save when you\'re done.',
icon: 'i-lucide-user', icon: 'i-lucide-user',
slot: 'account' slot: 'account' as const
}, },
{ {
label: 'Password', label: 'Password',
description: 'Change your password here. After saving, you\'ll be logged out.', description: 'Change your password here. After saving, you\'ll be logged out.',
icon: 'i-lucide-lock', icon: 'i-lucide-lock',
slot: 'password' slot: 'password' as const
} }
] ] satisfies TabsItem[]
const state = reactive({ const state = reactive({
name: 'Benjamin Canac', name: 'Benjamin Canac',

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const items = [ import type { TabsItem } from '@nuxt/ui'
const items: TabsItem[] = [
{ {
label: 'Account' label: 'Account'
}, },

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TreeItem } from '@nuxt/ui' import type { TreeItem } from '@nuxt/ui'
const items: TreeItem[] = [ const items = [
{ {
label: 'app/', label: 'app/',
slot: 'app', slot: 'app' as const,
defaultExpanded: true, defaultExpanded: true,
children: [{ children: [{
label: 'composables/', label: 'composables/',
@@ -24,7 +24,7 @@ const items: TreeItem[] = [
}, },
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' }, { label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' } { label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' }
] ] satisfies TreeItem[]
</script> </script>
<template> <template>

View File

@@ -25,7 +25,7 @@ const items: TreeItem[] = [
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' } { label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' }
] ]
const value = ref(items[items.length - 1]) const value = ref()
</script> </script>
<template> <template>

View File

@@ -30,6 +30,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- AccordionItem[]
hide: hide:
- class - class
props: props:
@@ -58,6 +60,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- AccordionItem[]
hide: hide:
- class - class
props: props:
@@ -87,6 +91,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- AccordionItem[]
hide: hide:
- class - class
props: props:
@@ -115,6 +121,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- AccordionItem[]
hide: hide:
- class - class
props: props:
@@ -149,6 +157,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- AccordionItem[]
hide: hide:
- class - class
props: props:
@@ -182,6 +192,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- AccordionItem[]
hide: hide:
- class - class
props: props:

View File

@@ -27,6 +27,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- BreadcrumbItem[]
props: props:
items: items:
- label: 'Home' - label: 'Home'
@@ -54,6 +56,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- BreadcrumbItem[]
props: props:
separatorIcon: 'i-lucide-arrow-right' separatorIcon: 'i-lucide-arrow-right'
items: items:

View File

@@ -44,6 +44,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- ContextMenuItem[][]
props: props:
items: items:
- - label: Appearance - - label: Appearance
@@ -124,6 +126,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- ContextMenuItem[]
props: props:
size: xl size: xl
items: items:
@@ -158,6 +162,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- ContextMenuItem[]
props: props:
disabled: true disabled: true
items: items:

View File

@@ -44,6 +44,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- DropdownMenuItem[][]
props: props:
items: items:
- - label: Benjamin - - label: Benjamin
@@ -123,6 +125,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- DropdownMenuItem[]
items: items:
content.align: content.align:
- start - start
@@ -169,6 +173,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- DropdownMenuItem[]
props: props:
arrow: true arrow: true
items: items:
@@ -202,6 +208,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- DropdownMenuItem[]
props: props:
size: xl size: xl
items: items:
@@ -244,6 +252,8 @@ ignore:
- ui.content - ui.content
external: external:
- items - items
externalTypes:
- DropdownMenuItem[]
props: props:
disabled: true disabled: true
items: items:
@@ -334,7 +344,9 @@ Inside the `defineShortcuts` composable, there is an `extractShortcuts` utility
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
const items = [{ import type { DropdownMenuItem } from '@nuxt/ui'
const items: DropdownMenuItem[] = [{
label: 'Invite users', label: 'Invite users',
icon: 'i-lucide-user-plus', icon: 'i-lucide-user-plus',
children: [{ children: [{

View File

@@ -39,6 +39,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[]
props: props:
items: items:
- label: Guide - label: Guide
@@ -148,6 +150,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[][]
props: props:
orientation: 'vertical' orientation: 'vertical'
items: items:
@@ -247,6 +251,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[][]
props: props:
highlight: true highlight: true
highlightColor: 'primary' highlightColor: 'primary'
@@ -346,6 +352,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[][]
props: props:
color: neutral color: neutral
items: items:
@@ -379,6 +387,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[][]
props: props:
color: neutral color: neutral
variant: link variant: link
@@ -423,6 +433,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[]
props: props:
trailingIcon: 'i-lucide-arrow-down' trailingIcon: 'i-lucide-arrow-down'
items: items:
@@ -519,6 +531,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[]
props: props:
arrow: true arrow: true
items: items:
@@ -611,6 +625,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[]
props: props:
arrow: true arrow: true
contentOrientation: 'vertical' contentOrientation: 'vertical'
@@ -682,6 +698,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- NavigationMenuItem[]
props: props:
unmountOnHide: false unmountOnHide: false
items: items:

View File

@@ -28,6 +28,9 @@ ignore:
external: external:
- items - items
- modelValue - modelValue
externalTypes:
- RadioGroupItem[]
- RadioGroupValue
props: props:
modelValue: 'System' modelValue: 'System'
items: items:
@@ -52,6 +55,9 @@ ignore:
external: external:
- items - items
- modelValue - modelValue
externalTypes:
- RadioGroupItem[]
- RadioGroupValue
props: props:
modelValue: 'system' modelValue: 'system'
items: items:
@@ -84,6 +90,9 @@ ignore:
external: external:
- items - items
- modelValue - modelValue
externalTypes:
- RadioGroupItem[]
- RadioGroupValue
props: props:
modelValue: 'light' modelValue: 'light'
valueKey: 'id' valueKey: 'id'
@@ -112,6 +121,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- RadioGroupItem[]
props: props:
legend: 'Theme' legend: 'Theme'
defaultValue: 'System' defaultValue: 'System'
@@ -134,6 +145,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- RadioGroupItem[]
props: props:
orientation: 'horizontal' orientation: 'horizontal'
defaultValue: 'System' defaultValue: 'System'
@@ -156,6 +169,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- RadioGroupItem[]
props: props:
color: neutral color: neutral
defaultValue: 'System' defaultValue: 'System'
@@ -178,6 +193,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- RadioGroupItem[]
props: props:
size: 'xl' size: 'xl'
defaultValue: 'System' defaultValue: 'System'
@@ -200,6 +217,8 @@ ignore:
- items - items
external: external:
- items - items
externalTypes:
- RadioGroupItem[]
props: props:
disabled: true disabled: true
defaultValue: 'System' defaultValue: 'System'

View File

@@ -31,6 +31,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- StepperItem[]
props: props:
items: items:
- title: 'Address' - title: 'Address'
@@ -61,6 +63,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- StepperItem[]
props: props:
color: neutral color: neutral
items: items:
@@ -88,6 +92,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- StepperItem[]
props: props:
size: xl size: xl
items: items:
@@ -115,6 +121,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- StepperItem[]
props: props:
orientation: vertical orientation: vertical
items: items:
@@ -142,6 +150,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- StepperItem[]
props: props:
disabled: true disabled: true
items: items:

View File

@@ -31,6 +31,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
items: items:
- label: Account - label: Account
@@ -55,6 +57,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
content: false content: false
items: items:
@@ -80,6 +84,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
unmountOnHide: false unmountOnHide: false
items: items:
@@ -109,6 +115,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
color: neutral color: neutral
content: false content: false
@@ -131,6 +139,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
color: neutral color: neutral
variant: link variant: link
@@ -154,6 +164,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
size: md size: md
variant: pill variant: pill
@@ -177,6 +189,8 @@ ignore:
- class - class
external: external:
- items - items
externalTypes:
- TabsItem[]
props: props:
orientation: vertical orientation: vertical
variant: pill variant: pill

View File

@@ -10,7 +10,7 @@
"@nuxt/content": "^3.4.0", "@nuxt/content": "^3.4.0",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxt/ui": "latest", "@nuxt/ui": "latest",
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@d96a086", "@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@e524f08",
"@nuxthub/core": "^0.8.18", "@nuxthub/core": "^0.8.18",
"@nuxtjs/plausible": "^1.2.0", "@nuxtjs/plausible": "^1.2.0",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",

View File

@@ -18,6 +18,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.' 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: 'Components', label: 'Components',
slot: 'test' as const,
icon: 'i-lucide-layers-3', icon: 'i-lucide-layers-3',
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.' 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.'
}, { }, {
@@ -37,6 +38,11 @@ const items = [{
</p> </p>
</template> </template>
<template #custom="{ item }">
<p class="text-(--ui-text-muted)">
Custom: {{ item.content }}
</p>
</template>
<template #custom-body="{ item }"> <template #custom-body="{ item }">
<p class="text-(--ui-text-muted)"> <p class="text-(--ui-text-muted)">
Custom: {{ item.content }} Custom: {{ item.content }}

View File

@@ -150,10 +150,6 @@ defineShortcuts(extractShortcuts(items.value))
<UDropdownMenu :items="itemsWithColor" :size="size" arrow :content="{ side: 'bottom', align: 'start' }" :ui="{ content: 'w-48' }"> <UDropdownMenu :items="itemsWithColor" :size="size" arrow :content="{ side: 'bottom', align: 'start' }" :ui="{ content: 'w-48' }">
<UButton label="Color" color="neutral" variant="outline" icon="i-lucide-menu" /> <UButton label="Color" color="neutral" variant="outline" icon="i-lucide-menu" />
<template #custom-trailing>
<UIcon name="i-lucide-badge-check" class="shrink-0 size-5 text-(--ui-primary)" />
</template>
</UDropdownMenu> </UDropdownMenu>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { InputMenuItem, AvatarProps } from '@nuxt/ui'
import { upperFirst } from 'scule' import { upperFirst } from 'scule'
import { refDebounced } from '@vueuse/core' import { refDebounced } from '@vueuse/core'
import type { User } from '~/types' import type { User } from '~/types'
@@ -10,7 +12,7 @@ const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme
const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple'] const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek'] const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']
const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]] const items = [[{ label: 'Fruits', type: 'label' as const }, ...fruits], [{ label: 'Vegetables', type: 'label' as const }, ...vegetables]]
const selectedItems = ref([fruits[0]!, vegetables[0]!]) const selectedItems = ref([fruits[0]!, vegetables[0]!])
const statuses = [{ const statuses = [{
@@ -28,7 +30,7 @@ const statuses = [{
}, { }, {
label: 'Canceled', label: 'Canceled',
icon: 'i-lucide-circle-x' icon: 'i-lucide-circle-x'
}] }] satisfies InputMenuItem[]
const searchTerm = ref('') const searchTerm = ref('')
const searchTermDebounced = refDebounced(searchTerm, 200) const searchTermDebounced = refDebounced(searchTerm, 200)
@@ -126,7 +128,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
class="w-48" class="w-48"
> >
<template #leading="{ modelValue, ui }"> <template #leading="{ modelValue, ui }">
<UAvatar v-if="modelValue?.avatar" :size="ui.itemLeadingAvatarSize()" v-bind="modelValue.avatar" /> <UAvatar v-if="modelValue?.avatar" :size="(ui.itemLeadingAvatarSize() as AvatarProps['size'])" v-bind="modelValue.avatar" />
</template> </template>
</UInputMenu> </UInputMenu>
</div> </div>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectMenuItem, AvatarProps } from '@nuxt/ui'
import { upperFirst } from 'scule' import { upperFirst } from 'scule'
import { refDebounced } from '@vueuse/core' import { refDebounced } from '@vueuse/core'
import theme from '#build/ui/select-menu' import theme from '#build/ui/select-menu'
@@ -10,7 +12,7 @@ const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme
const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple'] const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek'] const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']
const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]] const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]] satisfies SelectMenuItem[][]
const selectedItems = ref([fruits[0]!, vegetables[0]!]) const selectedItems = ref([fruits[0]!, vegetables[0]!])
const statuses = [{ const statuses = [{
@@ -33,7 +35,7 @@ const statuses = [{
label: 'Canceled', label: 'Canceled',
value: 'canceled', value: 'canceled',
icon: 'i-lucide-circle-x' icon: 'i-lucide-circle-x'
}] }] satisfies SelectMenuItem[]
const searchTerm = ref('') const searchTerm = ref('')
const searchTermDebounced = refDebounced(searchTerm, 200) const searchTermDebounced = refDebounced(searchTerm, 200)
@@ -41,7 +43,7 @@ const searchTermDebounced = refDebounced(searchTerm, 200)
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
params: { q: searchTermDebounced }, params: { q: searchTermDebounced },
transform: (data: User[]) => { transform: (data: User[]) => {
return data?.map(user => ({ id: user.id, label: user.name, avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } })) || [] return data?.map(user => ({ id: user.id, label: user.name, avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` } }))
}, },
lazy: true lazy: true
}) })
@@ -122,7 +124,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
v-for="size in sizes" v-for="size in sizes"
:key="size" :key="size"
v-model:search-term="searchTerm" v-model:search-term="searchTerm"
:items="users || []" :items="users"
:loading="status === 'pending'" :loading="status === 'pending'"
ignore-filter ignore-filter
icon="i-lucide-user" icon="i-lucide-user"
@@ -132,7 +134,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
@update:open="searchTerm = ''" @update:open="searchTerm = ''"
> >
<template #leading="{ modelValue, ui }"> <template #leading="{ modelValue, ui }">
<UAvatar v-if="modelValue?.avatar" :size="ui.itemLeadingAvatarSize()" v-bind="modelValue.avatar" /> <UAvatar v-if="modelValue?.avatar" :size="(ui.itemLeadingAvatarSize() as AvatarProps['size'])" v-bind="modelValue.avatar" />
</template> </template>
</USelectMenu> </USelectMenu>
</div> </div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItem, AvatarProps } from '@nuxt/ui'
import { upperFirst } from 'scule' import { upperFirst } from 'scule'
import theme from '#build/ui/select' import theme from '#build/ui/select'
import type { User } from '~/types' import type { User } from '~/types'
@@ -9,7 +10,7 @@ const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme
const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple'] const fruits = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']
const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek'] const vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']
const items = [[{ label: 'Fruits', type: 'label' }, ...fruits], [{ label: 'Vegetables', type: 'label' }, ...vegetables]] const items = [[{ label: 'Fruits', type: 'label' as const }, ...fruits], [{ label: 'Vegetables', type: 'label' as const }, ...vegetables]]
const selectedItems = ref([fruits[0]!, vegetables[0]!]) const selectedItems = ref([fruits[0]!, vegetables[0]!])
const statuses = [{ const statuses = [{
@@ -32,7 +33,7 @@ const statuses = [{
label: 'Canceled', label: 'Canceled',
value: 'canceled', value: 'canceled',
icon: 'i-lucide-circle-x' icon: 'i-lucide-circle-x'
}] }] satisfies SelectItem[]
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', { const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
transform: (data: User[]) => { transform: (data: User[]) => {
@@ -114,9 +115,10 @@ function getUserAvatar(value: string) {
trailing-icon="i-lucide-chevrons-up-down" trailing-icon="i-lucide-chevrons-up-down"
:size="size" :size="size"
class="w-48" class="w-48"
value-key="value"
> >
<template #leading="{ modelValue, ui }"> <template #leading="{ modelValue, ui }">
<UIcon v-if="modelValue" :name="getStatusIcon(modelValue as string)" :class="ui.leadingIcon()" /> <UIcon v-if="modelValue" :name="getStatusIcon(modelValue)" :class="ui.leadingIcon()" />
</template> </template>
</USelect> </USelect>
</div> </div>
@@ -130,9 +132,10 @@ function getUserAvatar(value: string) {
placeholder="Search users..." placeholder="Search users..."
:size="size" :size="size"
class="w-48" class="w-48"
value-key="value"
> >
<template #leading="{ modelValue, ui }"> <template #leading="{ modelValue, ui }">
<UAvatar v-if="modelValue" :size="ui.itemLeadingAvatarSize()" v-bind="getUserAvatar(modelValue as string)" /> <UAvatar v-if="modelValue" :size="(ui.itemLeadingAvatarSize() as AvatarProps['size'])" v-bind="getUserAvatar(modelValue)" />
</template> </template>
</USelect> </USelect>
</div> </div>

View File

@@ -11,22 +11,22 @@ const size = ref('md' as const)
const items = [ const items = [
{ {
slot: 'address', slot: 'address' as const,
title: 'Address', title: 'Address',
description: 'Add your address here', description: 'Add your address here',
icon: 'i-lucide-house' icon: 'i-lucide-house'
}, { }, {
slot: 'shipping', slot: 'shipping' as const,
title: 'Shipping', title: 'Shipping',
description: 'Set your preferred shipping method', description: 'Set your preferred shipping method',
icon: 'i-lucide-truck' icon: 'i-lucide-truck'
}, { }, {
slot: 'payment', slot: 'payment' as const,
title: 'Payment', title: 'Payment',
description: 'Select your payment method', description: 'Select your payment method',
icon: 'i-lucide-credit-card' icon: 'i-lucide-credit-card'
}, { }, {
slot: 'checkout', slot: 'checkout' as const,
title: 'Checkout', title: 'Checkout',
description: 'Confirm your order' description: 'Confirm your order'
} }
@@ -50,27 +50,27 @@ const stepper = useTemplateRef('stepper')
:orientation="orientation" :orientation="orientation"
:size="size" :size="size"
> >
<template #address> <template #address="{ item }">
<Placeholder class="size-full min-h-60 min-w-60"> <Placeholder class="size-full min-h-60 min-w-60">
Address {{ item.title }}
</Placeholder> </Placeholder>
</template> </template>
<template #shipping> <template #shipping="{ item }">
<Placeholder class="size-full min-h-60 min-w-60"> <Placeholder class="size-full min-h-60 min-w-60">
Shipping {{ item.title }}
</Placeholder> </Placeholder>
</template> </template>
<template #payment> <template #payment="{ item }">
<Placeholder class="size-full min-h-60 min-w-60"> <Placeholder class="size-full min-h-60 min-w-60">
Payment {{ item.title }}
</Placeholder> </Placeholder>
</template> </template>
<template #checkout> <template #checkout="{ item }">
<Placeholder class="size-full min-h-60 min-w-60"> <Placeholder class="size-full min-h-60 min-w-60">
Checkout {{ item.title }}
</Placeholder> </Placeholder>
</template> </template>
</UStepper> </UStepper>

View File

@@ -41,8 +41,8 @@ const itemsWithMappedId = [
{ id: 'id3', title: 'obiwan kenobi' } { id: 'id3', title: 'obiwan kenobi' }
] ]
const modelValue = ref<TreeItem>() const modelValue = ref<string>()
const modelValues = ref<TreeItem[]>([]) const modelValues = ref<string[]>([])
</script> </script>
<template> <template>
@@ -64,22 +64,14 @@ const modelValues = ref<TreeItem[]>([])
<!-- Typescript tests --> <!-- Typescript tests -->
<template v-if="false"> <template v-if="false">
<!-- @vue-expect-error - multiple props should type modelValue to array. --> <UTree :model-value="modelValues" :items="items" multiple />
<UTree :model-value="modelValue" :items="items" multiple /> <UTree :default-value="modelValues" :items="items" multiple />
<!-- @vue-expect-error - multiple props should type defaultValue to array. --> <UTree :items="items" multiple @update:model-value="(payload) => payload" />
<UTree :default-value="modelValue" :items="items" multiple /> <UTree :model-value="modelValue" :items="items" />
<!-- @vue-expect-error - multiple props should type @update:modelValue to array. --> <UTree :default-value="modelValue" :items="items" />
<UTree :items="items" multiple @update:model-value="(payload: TreeItem) => payload" /> <UTree :items="items" @update:model-value="(payload) => payload" />
<!-- @vue-expect-error - default should type modelValue to single item. -->
<UTree :model-value="modelValues" :items="items" />
<!-- @vue-expect-error - default should type defaultValue to single item. -->
<UTree :default-value="modelValues" :items="items" />
<!-- @vue-expect-error - default should type @update:modelValue to single item. -->
<UTree :items="items" @update:model-value="(payload: TreeItem[]) => payload" />
<!-- @vue-expect-error - value key should type v-model. -->
<UTree v-model="modelValue" :items="itemsWithMappedId" value-key="id" /> <UTree v-model="modelValue" :items="itemsWithMappedId" value-key="id" />
<!-- @vue-expect-error - label key should type v-model. -->
<UTree v-model="modelValue" :items="itemsWithMappedId" label-key="title" /> <UTree v-model="modelValue" :items="itemsWithMappedId" label-key="title" />
</template> </template>
</div> </div>

30
pnpm-lock.yaml generated
View File

@@ -240,8 +240,8 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:.. version: link:..
'@nuxt/ui-pro': '@nuxt/ui-pro':
specifier: https://pkg.pr.new/@nuxt/ui-pro@d96a086 specifier: https://pkg.pr.new/@nuxt/ui-pro@e524f08
version: https://pkg.pr.new/@nuxt/ui-pro@d96a086(@babel/parser@7.26.10)(magicast@0.3.5)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)) version: https://pkg.pr.new/@nuxt/ui-pro@e524f08(@babel/parser@7.26.10)(magicast@0.3.5)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
'@nuxthub/core': '@nuxthub/core':
specifier: ^0.8.18 specifier: ^0.8.18
version: 0.8.18(db0@0.3.1(better-sqlite3@11.9.1))(ioredis@5.6.0)(magicast@0.3.5)(vite@6.2.3(@types/node@22.13.12)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0)) version: 0.8.18(db0@0.3.1(better-sqlite3@11.9.1))(ioredis@5.6.0)(magicast@0.3.5)(vite@6.2.3(@types/node@22.13.12)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.39.0)(yaml@2.7.0))
@@ -1555,9 +1555,9 @@ packages:
vitest: vitest:
optional: true optional: true
'@nuxt/ui-pro@https://pkg.pr.new/@nuxt/ui-pro@d96a086': '@nuxt/ui-pro@https://pkg.pr.new/@nuxt/ui-pro@e524f08':
resolution: {tarball: https://pkg.pr.new/@nuxt/ui-pro@d96a086} resolution: {tarball: https://pkg.pr.new/@nuxt/ui-pro@e524f08}
version: 3.0.0 version: 3.0.1
peerDependencies: peerDependencies:
typescript: ^5.6.3 typescript: ^5.6.3
@@ -4046,10 +4046,6 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true hasBin: true
git-config-path@2.0.0:
resolution: {integrity: sha512-qc8h1KIQbJpp+241id3GuAtkdyJ+IK+LIVtkiFTRKRrmddDzs3SI9CvP1QYmWBFvm1I/PWRwj//of8bgAc0ltA==}
engines: {node: '>=4'}
git-raw-commits@5.0.0: git-raw-commits@5.0.0:
resolution: {integrity: sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==} resolution: {integrity: sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5330,10 +5326,6 @@ packages:
parse-entities@4.0.2: parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-git-config@3.0.0:
resolution: {integrity: sha512-wXoQGL1D+2COYWCD35/xbiKma1Z15xvZL8cI25wvxzled58V51SJM04Urt/uznS900iQor7QO04SgdfT/XlbuA==}
engines: {node: '>=8'}
parse-imports@2.2.1: parse-imports@2.2.1:
resolution: {integrity: sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==} resolution: {integrity: sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -8415,7 +8407,7 @@ snapshots:
- typescript - typescript
- yaml - yaml
'@nuxt/ui-pro@https://pkg.pr.new/@nuxt/ui-pro@d96a086(@babel/parser@7.26.10)(magicast@0.3.5)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))': '@nuxt/ui-pro@https://pkg.pr.new/@nuxt/ui-pro@e524f08(@babel/parser@7.26.10)(magicast@0.3.5)(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))':
dependencies: dependencies:
'@nuxt/kit': 3.16.1(magicast@0.3.5) '@nuxt/kit': 3.16.1(magicast@0.3.5)
'@nuxt/schema': 3.16.1 '@nuxt/schema': 3.16.1
@@ -8427,9 +8419,8 @@ snapshots:
git-url-parse: 16.0.1 git-url-parse: 16.0.1
ofetch: 1.4.1 ofetch: 1.4.1
ohash: 2.0.11 ohash: 2.0.11
parse-git-config: 3.0.0
pathe: 2.0.3 pathe: 2.0.3
pkg-types: 1.3.1 pkg-types: 2.1.0
scule: 1.3.0 scule: 1.3.0
tinyglobby: 0.2.12 tinyglobby: 0.2.12
typescript: 5.8.2 typescript: 5.8.2
@@ -11147,8 +11138,6 @@ snapshots:
nypm: 0.6.0 nypm: 0.6.0
pathe: 2.0.3 pathe: 2.0.3
git-config-path@2.0.0: {}
git-raw-commits@5.0.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.1.0): git-raw-commits@5.0.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.1.0):
dependencies: dependencies:
'@conventional-changelog/git-client': 1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.1.0) '@conventional-changelog/git-client': 1.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.1.0)
@@ -12988,11 +12977,6 @@ snapshots:
is-decimal: 2.0.1 is-decimal: 2.0.1
is-hexadecimal: 2.0.1 is-hexadecimal: 2.0.1
parse-git-config@3.0.0:
dependencies:
git-config-path: 2.0.0
ini: 1.3.8
parse-imports@2.2.1: parse-imports@2.2.1:
dependencies: dependencies:
es-module-lexer: 1.6.0 es-module-lexer: 1.6.0

View File

@@ -26,9 +26,10 @@ export interface AccordionItem {
/** A unique value for the accordion item. Defaults to the index. */ /** A unique value for the accordion item. Defaults to the index. */
value?: string value?: string
disabled?: boolean disabled?: boolean
[key: string]: any
} }
export interface AccordionProps<T> extends Pick<AccordionRootProps, 'collapsible' | 'defaultValue' | 'modelValue' | 'type' | 'disabled' | 'unmountOnHide'> { export interface AccordionProps<T extends AccordionItem = AccordionItem> extends Pick<AccordionRootProps, 'collapsible' | 'defaultValue' | 'modelValue' | 'type' | 'disabled' | 'unmountOnHide'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -52,15 +53,15 @@ export interface AccordionProps<T> extends Pick<AccordionRootProps, 'collapsible
export interface AccordionEmits extends AccordionRootEmits {} export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T> = (props: { item: T, index: number, open: boolean }) => any type SlotProps<T extends AccordionItem> = (props: { item: T, index: number, open: boolean }) => any
export type AccordionSlots<T extends { slot?: string }> = { export type AccordionSlots<T extends AccordionItem = AccordionItem> = {
leading: SlotProps<T> leading: SlotProps<T>
default: SlotProps<T> default: SlotProps<T>
trailing: SlotProps<T> trailing: SlotProps<T>
content: SlotProps<T> content: SlotProps<T>
body: SlotProps<T> body: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<T, 'body', { index: number, open: boolean }>
</script> </script>
@@ -92,7 +93,7 @@ const ui = computed(() => accordion({
<template> <template>
<AccordionRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })"> <AccordionRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
<AccordionItem <AccordionItem
v-for="(item, index) in items" v-for="(item, index) in props.items"
v-slot="{ open }" v-slot="{ open }"
:key="index" :key="index"
:value="item.value || String(index)" :value="item.value || String(index)"
@@ -115,10 +116,10 @@ const ui = computed(() => accordion({
</AccordionTrigger> </AccordionTrigger>
</AccordionHeader> </AccordionHeader>
<AccordionContent v-if="item.content || !!slots.content || (item.slot && !!slots[item.slot]) || !!slots.body || (item.slot && !!slots[`${item.slot}-body`])" :class="ui.content({ class: props.ui?.content })"> <AccordionContent v-if="item.content || !!slots.content || (item.slot && !!slots[item.slot as keyof AccordionSlots<T>]) || !!slots.body || (item.slot && !!slots[`${item.slot}-body` as keyof AccordionSlots<T>])" :class="ui.content({ class: props.ui?.content })">
<slot :name="item.slot || 'content'" :item="item" :index="index" :open="open"> <slot :name="((item.slot || 'content') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
<div :class="ui.body({ class: props.ui?.body })"> <div :class="ui.body({ class: props.ui?.body })">
<slot :name="item.slot ? `${item.slot}-body`: 'body'" :item="item" :index="index" :open="open"> <slot :name="((item.slot ? `${item.slot}-body`: 'body') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
{{ item.content }} {{ item.content }}
</slot> </slot>
</div> </div>

View File

@@ -71,7 +71,7 @@ export interface AlertSlots {
title(props?: {}): any title(props?: {}): any
description(props?: {}): any description(props?: {}): any
actions(props?: {}): any actions(props?: {}): any
close(props: { ui: any }): any close(props: { ui: ReturnType<typeof alert> }): any
} }
</script> </script>

View File

@@ -17,7 +17,7 @@ export default {
} }
</script> </script>
<script setup lang="ts" generic="T extends Messages = Messages"> <script setup lang="ts" generic="T extends Messages">
import { toRef, useId, provide } from 'vue' import { toRef, useId, provide } from 'vue'
import { ConfigProvider, TooltipProvider, useForwardProps } from 'reka-ui' import { ConfigProvider, TooltipProvider, useForwardProps } from 'reka-ui'
import { reactivePick } from '@vueuse/core' import { reactivePick } from '@vueuse/core'

View File

@@ -19,9 +19,10 @@ export interface BreadcrumbItem extends Omit<LinkProps, 'raw' | 'custom'> {
icon?: string icon?: string
avatar?: AvatarProps avatar?: AvatarProps
slot?: string slot?: string
[key: string]: any
} }
export interface BreadcrumbProps<T> { export interface BreadcrumbProps<T extends BreadcrumbItem = BreadcrumbItem> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'nav' * @defaultValue 'nav'
@@ -43,15 +44,15 @@ export interface BreadcrumbProps<T> {
ui?: PartialString<typeof breadcrumb.slots> ui?: PartialString<typeof breadcrumb.slots>
} }
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any type SlotProps<T extends BreadcrumbItem> = (props: { item: T, index: number, active?: boolean }) => any
export type BreadcrumbSlots<T extends { slot?: string }> = { export type BreadcrumbSlots<T extends BreadcrumbItem = BreadcrumbItem> = {
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
'item-trailing': SlotProps<T> 'item-trailing': SlotProps<T>
'separator'(props?: {}): any 'separator': any
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<T, 'leading' | 'label' | 'trailing', { index: number, active?: boolean }>
</script> </script>
@@ -88,19 +89,19 @@ const ui = breadcrumb()
<li :class="ui.item({ class: props.ui?.item })"> <li :class="ui.item({ class: props.ui?.item })">
<ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom> <ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom>
<ULinkBase v-bind="slotProps" as="span" :aria-current="active && (index === items!.length - 1) ? 'page' : undefined" :class="ui.link({ class: [props.ui?.link, item.class], active: index === items!.length - 1, disabled: !!item.disabled, to: !!item.to })"> <ULinkBase v-bind="slotProps" as="span" :aria-current="active && (index === items!.length - 1) ? 'page' : undefined" :class="ui.link({ class: [props.ui?.link, item.class], active: index === items!.length - 1, disabled: !!item.disabled, to: !!item.to })">
<slot :name="item.slot || 'item'" :item="item" :index="index"> <slot :name="((item.slot || 'item') as keyof BreadcrumbSlots<T>)" :item="item" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="item" :active="index === items!.length - 1" :index="index"> <slot :name="((item.slot ? `${item.slot}-leading`: 'item-leading') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active: index === items!.length - 1 })" /> <UIcon v-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active: index === items!.length - 1 })" />
<UAvatar v-else-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active: index === items!.length - 1 })" /> <UAvatar v-else-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active: index === items!.length - 1 })" />
</slot> </slot>
<span v-if="get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.linkLabel({ class: props.ui?.linkLabel })"> <span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label`: 'item-label') as keyof BreadcrumbSlots<T>]" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="item" :active="index === items!.length - 1" :index="index"> <slot :name="((item.slot ? `${item.slot}-label`: 'item-label') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</slot> </slot>
</span> </span>
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="item" :active="index === items!.length - 1" :index="index" /> <slot :name="((item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index" />
</slot> </slot>
</ULinkBase> </ULinkBase>
</ULink> </ULink>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { VariantProps } from 'tailwind-variants' import type { VariantProps } from 'tailwind-variants'
import type { CalendarRootProps, CalendarRootEmits, RangeCalendarRootEmits, DateRange, CalendarCellTriggerProps } from 'reka-ui' import type { CalendarRootProps, CalendarRootEmits, RangeCalendarRootProps, RangeCalendarRootEmits, DateRange, CalendarCellTriggerProps } from 'reka-ui'
import type { DateValue } from '@internationalized/date' import type { DateValue } from '@internationalized/date'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import type { ButtonProps } from '../types' import type { ButtonProps } from '../types'
@@ -15,13 +15,21 @@ const calendar = tv({ extend: tv(theme), ...(appConfigCalendar.ui?.calendar || {
type CalendarVariants = VariantProps<typeof calendar> type CalendarVariants = VariantProps<typeof calendar>
type CalendarModelValue<R extends boolean = false, M extends boolean = false> = R extends true type CalendarDefaultValue<R extends boolean = false, M extends boolean = false> = R extends true
? DateRange ? DateRange
: M extends true : M extends true
? DateValue[] ? DateValue[]
: DateValue : DateValue
type CalendarModelValue<R extends boolean = false, M extends boolean = false> = R extends true
? (DateRange | null)
: M extends true
? (DateValue[] | undefined)
: (DateValue | undefined)
export interface CalendarProps<R extends boolean, M extends boolean> extends Omit<CalendarRootProps, 'as' | 'asChild' | 'modelValue' | 'defaultValue' | 'dir' | 'locale' | 'calendarLabel' | 'multiple'> { type _CalendarRootProps = Omit<CalendarRootProps, 'as' | 'asChild' | 'modelValue' | 'defaultValue' | 'dir' | 'locale' | 'calendarLabel' | 'multiple'>
type _RangeCalendarRootProps = Omit<RangeCalendarRootProps, 'as' | 'asChild' | 'modelValue' | 'defaultValue' | 'dir' | 'locale' | 'calendarLabel' | 'multiple'>
export interface CalendarProps<R extends boolean = false, M extends boolean = false> extends _RangeCalendarRootProps, _CalendarRootProps {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -87,7 +95,7 @@ export interface CalendarProps<R extends boolean, M extends boolean> extends Omi
monthControls?: boolean monthControls?: boolean
/** Show year controls */ /** Show year controls */
yearControls?: boolean yearControls?: boolean
defaultValue?: CalendarModelValue<R, M> defaultValue?: CalendarDefaultValue<R, M>
modelValue?: CalendarModelValue<R, M> modelValue?: CalendarModelValue<R, M>
class?: any class?: any
ui?: PartialString<typeof calendar.slots> ui?: PartialString<typeof calendar.slots>
@@ -104,7 +112,7 @@ export interface CalendarSlots {
} }
</script> </script>
<script setup lang="ts" generic="R extends boolean = false, M extends boolean = false"> <script setup lang="ts" generic="R extends boolean, M extends boolean">
import { computed } from 'vue' import { computed } from 'vue'
import { useForwardPropsEmits } from 'reka-ui' import { useForwardPropsEmits } from 'reka-ui'
import { Calendar as SingleCalendar, RangeCalendar } from 'reka-ui/namespaced' import { Calendar as SingleCalendar, RangeCalendar } from 'reka-ui/namespaced'
@@ -151,8 +159,8 @@ const Calendar = computed(() => props.range ? RangeCalendar : SingleCalendar)
<Calendar.Root <Calendar.Root
v-slot="{ weekDays, grid }" v-slot="{ weekDays, grid }"
v-bind="rootProps" v-bind="rootProps"
:model-value="(modelValue as CalendarModelValue<true & false>)" :model-value="modelValue"
:default-value="(defaultValue as CalendarModelValue<true & false>)" :default-value="defaultValue"
:locale="locale" :locale="locale"
:dir="dir" :dir="dir"
:class="ui.root({ class: [props.class, props.ui?.root] })" :class="ui.root({ class: [props.class, props.ui?.root] })"

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts"> <script lang="ts">
import type { VariantProps } from 'tailwind-variants' import type { VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
@@ -21,7 +22,9 @@ const carousel = tv({ extend: tv(theme), ...(appConfigCarousel.ui?.carousel || {
type CarouselVariants = VariantProps<typeof carousel> type CarouselVariants = VariantProps<typeof carousel>
export interface CarouselProps<T> extends Omit<EmblaOptionsType, 'axis' | 'container' | 'slides' | 'direction'> { export type CarouselItem = AcceptableValue
export interface CarouselProps<T extends CarouselItem = CarouselItem> extends Omit<EmblaOptionsType, 'axis' | 'container' | 'slides' | 'direction'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -99,12 +102,13 @@ export interface CarouselProps<T> extends Omit<EmblaOptionsType, 'axis' | 'conta
ui?: PartialString<typeof carousel.slots> ui?: PartialString<typeof carousel.slots>
} }
export type CarouselSlots<T> = { export type CarouselSlots<T extends CarouselItem = CarouselItem> = {
default(props: { item: T, index: number }): any default(props: { item: T, index: number }): any
} }
</script> </script>
<script setup lang="ts" generic="T extends AcceptableValue"> <script setup lang="ts" generic="T extends CarouselItem">
import { computed, ref, watch, onMounted } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import useEmblaCarousel from 'embla-carousel-vue' import useEmblaCarousel from 'embla-carousel-vue'
import { Primitive, useForwardProps } from 'reka-ui' import { Primitive, useForwardProps } from 'reka-ui'

View File

@@ -9,7 +9,7 @@ import theme from '#build/ui/command-palette'
import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, ButtonProps, ChipProps, KbdProps, InputProps, LinkProps } from '../types' import type { AvatarProps, ButtonProps, ChipProps, KbdProps, InputProps, LinkProps } from '../types'
import type { DynamicSlots, PartialString } from '../types/utils' import type { PartialString } from '../types/utils'
const appConfigCommandPalette = _appConfig as AppConfig & { ui: { commandPalette: Partial<typeof theme> } } const appConfigCommandPalette = _appConfig as AppConfig & { ui: { commandPalette: Partial<typeof theme> } }
@@ -31,6 +31,7 @@ export interface CommandPaletteItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
disabled?: boolean disabled?: boolean
slot?: string slot?: string
onSelect?(e?: Event): void onSelect?(e?: Event): void
[key: string]: any
} }
export interface CommandPaletteGroup<T> { export interface CommandPaletteGroup<T> {
@@ -125,12 +126,12 @@ type SlotProps<T> = (props: { item: T, index: number }) => any
export type CommandPaletteSlots<G extends { slot?: string }, T extends { slot?: string }> = { export type CommandPaletteSlots<G extends { slot?: string }, T extends { slot?: string }> = {
'empty'(props: { searchTerm?: string }): any 'empty'(props: { searchTerm?: string }): any
'close'(props: { ui: any }): any 'close'(props: { ui: ReturnType<typeof commandPalette> }): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
'item-trailing': SlotProps<T> 'item-trailing': SlotProps<T>
} & DynamicSlots<G, SlotProps<T>> & DynamicSlots<T, SlotProps<T>> } & Record<string, SlotProps<G>> & Record<string, SlotProps<T>>
</script> </script>
@@ -297,8 +298,8 @@ const groups = computed(() => {
> >
<ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom> <ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom>
<ULinkBase v-bind="slotProps" :class="ui.item({ class: props.ui?.item, active: active || item.active })"> <ULinkBase v-bind="slotProps" :class="ui.item({ class: props.ui?.item, active: active || item.active })">
<slot :name="item.slot || group.slot || 'item'" :item="item" :index="index"> <slot :name="((item.slot || group.slot || 'item') as keyof CommandPaletteSlots<G, T>)" :item="(item as any)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading` : group.slot ? `${group.slot}-leading` : `item-leading`" :item="item" :index="index"> <slot :name="((item.slot ? `${item.slot}-leading` : group.slot ? `${group.slot}-leading` : `item-leading`) as keyof CommandPaletteSlots<G, T>)" :item="(item as any)" :index="index">
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon, loading: true })" /> <UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon, active: active || item.active })" /> <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon, active: active || item.active })" />
<UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar, active: active || item.active })" /> <UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar, active: active || item.active })" />
@@ -312,8 +313,8 @@ const groups = computed(() => {
/> />
</slot> </slot>
<span v-if="item.labelHtml || get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`]" :class="ui.itemLabel({ class: props.ui?.itemLabel, active: active || item.active })"> <span v-if="item.labelHtml || get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`) as keyof CommandPaletteSlots<G, T>]" :class="ui.itemLabel({ class: props.ui?.itemLabel, active: active || item.active })">
<slot :name="item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`" :item="item" :index="index"> <slot :name="((item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`) as keyof CommandPaletteSlots<G, T>)" :item="(item as any)" :index="index">
<span v-if="item.prefix" :class="ui.itemLabelPrefix({ class: props.ui?.itemLabelPrefix })">{{ item.prefix }}</span> <span v-if="item.prefix" :class="ui.itemLabelPrefix({ class: props.ui?.itemLabelPrefix })">{{ item.prefix }}</span>
<span :class="ui.itemLabelBase({ class: props.ui?.itemLabelBase, active: active || item.active })" v-html="item.labelHtml || get(item, props.labelKey as string)" /> <span :class="ui.itemLabelBase({ class: props.ui?.itemLabelBase, active: active || item.active })" v-html="item.labelHtml || get(item, props.labelKey as string)" />
@@ -323,7 +324,7 @@ const groups = computed(() => {
</span> </span>
<span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })"> <span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing` : group.slot ? `${group.slot}-trailing` : `item-trailing`" :item="item" :index="index"> <slot :name="((item.slot ? `${item.slot}-trailing` : group.slot ? `${group.slot}-trailing` : `item-trailing`) as keyof CommandPaletteSlots<G, T>)" :item="(item as any)" :index="index">
<span v-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: props.ui?.itemTrailingKbds })"> <span v-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: props.ui?.itemTrailingKbds })">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.ui?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" /> <UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.ui?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span> </span>

View File

@@ -7,7 +7,14 @@ import _appConfig from '#build/app.config'
import theme from '#build/ui/context-menu' import theme from '#build/ui/context-menu'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, KbdProps, LinkProps } from '../types' import type { AvatarProps, KbdProps, LinkProps } from '../types'
import type { DynamicSlots, PartialString, EmitsToProps } from '../types/utils' import type {
ArrayOrNested,
DynamicSlots,
MergeTypes,
NestedItem,
PartialString,
EmitsToProps
} from '../types/utils'
const appConfigContextMenu = _appConfig as AppConfig & { ui: { contextMenu: Partial<typeof theme> } } const appConfigContextMenu = _appConfig as AppConfig & { ui: { contextMenu: Partial<typeof theme> } }
@@ -36,17 +43,18 @@ export interface ContextMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custo
checked?: boolean checked?: boolean
open?: boolean open?: boolean
defaultOpen?: boolean defaultOpen?: boolean
children?: ContextMenuItem[] | ContextMenuItem[][] children?: ArrayOrNested<ContextMenuItem>
onSelect?(e: Event): void onSelect?(e: Event): void
onUpdateChecked?(checked: boolean): void onUpdateChecked?(checked: boolean): void
[key: string]: any
} }
export interface ContextMenuProps<T> extends Omit<ContextMenuRootProps, 'dir'> { export interface ContextMenuProps<T extends ArrayOrNested<ContextMenuItem> = ArrayOrNested<ContextMenuItem>> extends Omit<ContextMenuRootProps, 'dir'> {
/** /**
* @defaultValue 'md' * @defaultValue 'md'
*/ */
size?: ContextMenuVariants['size'] size?: ContextMenuVariants['size']
items?: T[] | T[][] items?: T
/** /**
* The icon displayed when an item is checked. * The icon displayed when an item is checked.
* @defaultValue appConfig.ui.icons.check * @defaultValue appConfig.ui.icons.check
@@ -77,7 +85,7 @@ export interface ContextMenuProps<T> extends Omit<ContextMenuRootProps, 'dir'> {
* The key used to get the label from the item. * The key used to get the label from the item.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: string labelKey?: keyof NestedItem<T>
disabled?: boolean disabled?: boolean
class?: any class?: any
ui?: PartialString<typeof contextMenu.slots> ui?: PartialString<typeof contextMenu.slots>
@@ -85,19 +93,22 @@ export interface ContextMenuProps<T> extends Omit<ContextMenuRootProps, 'dir'> {
export interface ContextMenuEmits extends ContextMenuRootEmits {} export interface ContextMenuEmits extends ContextMenuRootEmits {}
type SlotProps<T> = (props: { item: T, active?: boolean, index: number }) => any type SlotProps<T extends ContextMenuItem> = (props: { item: T, active?: boolean, index: number }) => any
export type ContextMenuSlots<T extends { slot?: string }> = { export type ContextMenuSlots<
A extends ArrayOrNested<ContextMenuItem> = ArrayOrNested<ContextMenuItem>,
T extends NestedItem<A> = NestedItem<A>
> = {
'default'(props?: {}): any 'default'(props?: {}): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
'item-trailing': SlotProps<T> 'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script> </script>
<script setup lang="ts" generic="T extends ContextMenuItem"> <script setup lang="ts" generic="T extends ArrayOrNested<ContextMenuItem>">
import { computed, toRef } from 'vue' import { computed, toRef } from 'vue'
import { ContextMenuRoot, ContextMenuTrigger, useForwardPropsEmits } from 'reka-ui' import { ContextMenuRoot, ContextMenuTrigger, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core' import { reactivePick } from '@vueuse/core'
@@ -114,8 +125,9 @@ const emits = defineEmits<ContextMenuEmits>()
const slots = defineSlots<ContextMenuSlots<T>>() const slots = defineSlots<ContextMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'modal'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'modal'), emits)
const contentProps = toRef(() => props.content) const contentProps = toRef(() => props.content)
const proxySlots = omit(slots, ['default']) as Record<string, ContextMenuSlots<T>[string]> const proxySlots = omit(slots, ['default'])
const ui = computed(() => contextMenu({ const ui = computed(() => contextMenu({
size: props.size size: props.size
@@ -135,13 +147,13 @@ const ui = computed(() => contextMenu({
v-bind="contentProps" v-bind="contentProps"
:items="items" :items="items"
:portal="portal" :portal="portal"
:label-key="labelKey" :label-key="(labelKey as keyof NestedItem<T>)"
:checked-icon="checkedIcon" :checked-icon="checkedIcon"
:loading-icon="loadingIcon" :loading-icon="loadingIcon"
:external-icon="externalIcon" :external-icon="externalIcon"
> >
<template v-for="(_, name) in proxySlots" #[name]="slotData"> <template v-for="(_, name) in proxySlots" #[name]="slotData">
<slot :name="name" v-bind="slotData" /> <slot :name="(name as keyof ContextMenuSlots<T>)" v-bind="slotData" />
</template> </template>
</UContextMenuContent> </UContextMenuContent>
</ContextMenuRoot> </ContextMenuRoot>

View File

@@ -2,15 +2,16 @@
import type { ContextMenuContentProps as RekaContextMenuContentProps, ContextMenuContentEmits as RekaContextMenuContentEmits } from 'reka-ui' import type { ContextMenuContentProps as RekaContextMenuContentProps, ContextMenuContentEmits as RekaContextMenuContentEmits } from 'reka-ui'
import theme from '#build/ui/context-menu' import theme from '#build/ui/context-menu'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { KbdProps, AvatarProps, ContextMenuItem, ContextMenuSlots } from '../types' import type { AvatarProps, ContextMenuItem, ContextMenuSlots, KbdProps } from '../types'
import type { ArrayOrNested, NestedItem } from '../types/utils'
const _contextMenu = tv(theme)() const _contextMenu = tv(theme)()
interface ContextMenuContentProps<T> extends Omit<RekaContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> { interface ContextMenuContentProps<T extends ArrayOrNested<ContextMenuItem>> extends Omit<RekaContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T[] | T[][] items?: T
portal?: boolean portal?: boolean
sub?: boolean sub?: boolean
labelKey: string labelKey: keyof NestedItem<T>
/** /**
* @IconifyIcon * @IconifyIcon
*/ */
@@ -31,13 +32,13 @@ interface ContextMenuContentProps<T> extends Omit<RekaContextMenuContentProps, '
interface ContextMenuContentEmits extends RekaContextMenuContentEmits {} interface ContextMenuContentEmits extends RekaContextMenuContentEmits {}
</script> </script>
<script setup lang="ts" generic="T extends ContextMenuItem"> <script setup lang="ts" generic="T extends ArrayOrNested<ContextMenuItem>">
import { computed } from 'vue' import { computed } from 'vue'
import { ContextMenu } from 'reka-ui/namespaced' import { ContextMenu } from 'reka-ui/namespaced'
import { useForwardPropsEmits } from 'reka-ui' import { useForwardPropsEmits } from 'reka-ui'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core' import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports' import { useAppConfig } from '#imports'
import { omit, get } from '../utils' import { omit, get, isArrayOfArray } from '../utils'
import { pickLinkProps } from '../utils/link' import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue' import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue' import ULink from './Link.vue'
@@ -53,24 +54,30 @@ const slots = defineSlots<ContextMenuSlots<T>>()
const appConfig = useAppConfig() const appConfig = useAppConfig()
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui', 'uiOverride'), emits) const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui', 'uiOverride'), emits)
const proxySlots = omit(slots, ['default']) as Record<string, ContextMenuSlots<T>[string]> const proxySlots = omit(slots, ['default'])
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: ContextMenuItem, active?: boolean, index: number }>() const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: ContextMenuItem, active?: boolean, index: number }>()
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as T[][] : []) const groups = computed<ContextMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
</script> </script>
<template> <template>
<DefineItemTemplate v-slot="{ item, active, index }"> <DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index"> <slot :name="((item.slot || 'item') as keyof ContextMenuSlots<T>)" :item="item" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-leading`: 'item-leading') as keyof ContextMenuSlots<T>)" :item="item" :active="active" :index="index">
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, loading: true })" /> <UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, active })" /> <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, active })" />
<UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" /> <UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" />
</slot> </slot>
<span v-if="get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel({ class: uiOverride?.itemLabel, active })"> <span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label`: 'item-label') as keyof ContextMenuSlots<T>]" :class="ui.itemLabel({ class: uiOverride?.itemLabel, active })">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-label`: 'item-label') as keyof ContextMenuSlots<T>)" :item="item" :active="active" :index="index">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</slot> </slot>
@@ -78,7 +85,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</span> </span>
<span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })"> <span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof ContextMenuSlots<T>)" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color, active })" /> <UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color, active })" />
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })"> <span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" /> <UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
@@ -117,7 +124,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:ui="ui" :ui="ui"
:ui-override="uiOverride" :ui-override="uiOverride"
:portal="portal" :portal="portal"
:items="item.children" :items="(item.children as T)"
:align-offset="-4" :align-offset="-4"
:label-key="labelKey" :label-key="labelKey"
:checked-icon="checkedIcon" :checked-icon="checkedIcon"
@@ -126,7 +133,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
v-bind="item.content" v-bind="item.content"
> >
<template v-for="(_, name) in proxySlots" #[name]="slotData: any"> <template v-for="(_, name) in proxySlots" #[name]="slotData: any">
<slot :name="name" v-bind="slotData" /> <slot :name="(name as keyof ContextMenuSlots<T>)" v-bind="slotData" />
</template> </template>
</UContextMenuContent> </UContextMenuContent>
</ContextMenu.Sub> </ContextMenu.Sub>

View File

@@ -7,7 +7,14 @@ import _appConfig from '#build/app.config'
import theme from '#build/ui/dropdown-menu' import theme from '#build/ui/dropdown-menu'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, KbdProps, LinkProps } from '../types' import type { AvatarProps, KbdProps, LinkProps } from '../types'
import type { DynamicSlots, PartialString, EmitsToProps } from '../types/utils' import type {
ArrayOrNested,
DynamicSlots,
MergeTypes,
NestedItem,
PartialString,
EmitsToProps
} from '../types/utils'
const appConfigDropdownMenu = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof theme> } } const appConfigDropdownMenu = _appConfig as AppConfig & { ui: { dropdownMenu: Partial<typeof theme> } }
@@ -36,17 +43,18 @@ export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cust
checked?: boolean checked?: boolean
open?: boolean open?: boolean
defaultOpen?: boolean defaultOpen?: boolean
children?: DropdownMenuItem[] | DropdownMenuItem[][] children?: ArrayOrNested<DropdownMenuItem>
onSelect?(e: Event): void onSelect?(e: Event): void
onUpdateChecked?(checked: boolean): void onUpdateChecked?(checked: boolean): void
[key: string]: any
} }
export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'> { export interface DropdownMenuProps<T extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>> extends Omit<DropdownMenuRootProps, 'dir'> {
/** /**
* @defaultValue 'md' * @defaultValue 'md'
*/ */
size?: DropdownMenuVariants['size'] size?: DropdownMenuVariants['size']
items?: T[] | T[][] items?: T
/** /**
* The icon displayed when an item is checked. * The icon displayed when an item is checked.
* @defaultValue appConfig.ui.icons.check * @defaultValue appConfig.ui.icons.check
@@ -85,7 +93,7 @@ export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'>
* The key used to get the label from the item. * The key used to get the label from the item.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: string labelKey?: keyof NestedItem<T>
disabled?: boolean disabled?: boolean
class?: any class?: any
ui?: PartialString<typeof dropdownMenu.slots> ui?: PartialString<typeof dropdownMenu.slots>
@@ -93,19 +101,22 @@ export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'>
export interface DropdownMenuEmits extends DropdownMenuRootEmits {} export interface DropdownMenuEmits extends DropdownMenuRootEmits {}
type SlotProps<T> = (props: { item: T, active?: boolean, index: number }) => any type SlotProps<T extends DropdownMenuItem> = (props: { item: T, active?: boolean, index: number }) => any
export type DropdownMenuSlots<T extends { slot?: string }> = { export type DropdownMenuSlots<
A extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>,
T extends NestedItem<A> = NestedItem<A>
> = {
'default'(props: { open: boolean }): any 'default'(props: { open: boolean }): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
'item-trailing': SlotProps<T> 'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script> </script>
<script setup lang="ts" generic="T extends DropdownMenuItem"> <script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
import { computed, toRef } from 'vue' import { computed, toRef } from 'vue'
import { defu } from 'defu' import { defu } from 'defu'
import { DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuArrow, useForwardPropsEmits } from 'reka-ui' import { DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuArrow, useForwardPropsEmits } from 'reka-ui'
@@ -125,7 +136,7 @@ const slots = defineSlots<DropdownMenuSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'modal'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'modal'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as DropdownMenuContentProps) const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as DropdownMenuContentProps)
const arrowProps = toRef(() => props.arrow as DropdownMenuArrowProps) const arrowProps = toRef(() => props.arrow as DropdownMenuArrowProps)
const proxySlots = omit(slots, ['default']) as Record<string, DropdownMenuSlots<T>[string]> const proxySlots = omit(slots, ['default'])
const ui = computed(() => dropdownMenu({ const ui = computed(() => dropdownMenu({
size: props.size size: props.size
@@ -145,13 +156,13 @@ const ui = computed(() => dropdownMenu({
v-bind="contentProps" v-bind="contentProps"
:items="items" :items="items"
:portal="portal" :portal="portal"
:label-key="labelKey" :label-key="(labelKey as keyof NestedItem<T>)"
:checked-icon="checkedIcon" :checked-icon="checkedIcon"
:loading-icon="loadingIcon" :loading-icon="loadingIcon"
:external-icon="externalIcon" :external-icon="externalIcon"
> >
<template v-for="(_, name) in proxySlots" #[name]="slotData"> <template v-for="(_, name) in proxySlots" #[name]="slotData">
<slot :name="name" v-bind="slotData" /> <slot :name="(name as keyof DropdownMenuSlots<T>)" v-bind="slotData" />
</template> </template>
<DropdownMenuArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" /> <DropdownMenuArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />

View File

@@ -4,14 +4,15 @@ import type { DropdownMenuContentProps as RekaDropdownMenuContentProps, Dropdown
import theme from '#build/ui/dropdown-menu' import theme from '#build/ui/dropdown-menu'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { KbdProps, AvatarProps, DropdownMenuItem, DropdownMenuSlots } from '../types' import type { KbdProps, AvatarProps, DropdownMenuItem, DropdownMenuSlots } from '../types'
import type { ArrayOrNested, NestedItem } from '../types/utils'
const _dropdownMenu = tv(theme)() const _dropdownMenu = tv(theme)()
interface DropdownMenuContentProps<T> extends Omit<RekaDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> { interface DropdownMenuContentProps<T extends ArrayOrNested<DropdownMenuItem>> extends Omit<RekaDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T[] | T[][] items?: T
portal?: boolean portal?: boolean
sub?: boolean sub?: boolean
labelKey: string labelKey: keyof NestedItem<T>
/** /**
* @IconifyIcon * @IconifyIcon
*/ */
@@ -31,19 +32,19 @@ interface DropdownMenuContentProps<T> extends Omit<RekaDropdownMenuContentProps,
interface DropdownMenuContentEmits extends RekaDropdownMenuContentEmits {} interface DropdownMenuContentEmits extends RekaDropdownMenuContentEmits {}
type DropdownMenuContentSlots<T extends { slot?: string }> = Omit<DropdownMenuSlots<T>, 'default'> & { type DropdownMenuContentSlots<T extends ArrayOrNested<DropdownMenuItem>> = Omit<DropdownMenuSlots<T>, 'default'> & {
default(props?: {}): any default(props?: {}): any
} }
</script> </script>
<script setup lang="ts" generic="T extends DropdownMenuItem"> <script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
import { computed } from 'vue' import { computed } from 'vue'
import { DropdownMenu } from 'reka-ui/namespaced' import { DropdownMenu } from 'reka-ui/namespaced'
import { useForwardPropsEmits } from 'reka-ui' import { useForwardPropsEmits } from 'reka-ui'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core' import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports' import { useAppConfig } from '#imports'
import { omit, get } from '../utils' import { omit, get, isArrayOfArray } from '../utils'
import { pickLinkProps } from '../utils/link' import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue' import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue' import ULink from './Link.vue'
@@ -59,24 +60,30 @@ const slots = defineSlots<DropdownMenuContentSlots<T>>()
const appConfig = useAppConfig() const appConfig = useAppConfig()
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui', 'uiOverride'), emits) const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui', 'uiOverride'), emits)
const proxySlots = omit(slots, ['default']) as Record<string, DropdownMenuContentSlots<T>[string]> const proxySlots = omit(slots, ['default'])
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: DropdownMenuItem, active?: boolean, index: number }>() const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: DropdownMenuItem, active?: boolean, index: number }>()
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as T[][] : []) const groups = computed<DropdownMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
</script> </script>
<template> <template>
<DefineItemTemplate v-slot="{ item, active, index }"> <DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index"> <slot :name="((item.slot || 'item') as keyof DropdownMenuContentSlots<T>)" :item="(item as Extract<NestedItem<T>, { slot: string; }>)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-leading`: 'item-leading') as keyof DropdownMenuContentSlots<T>)" :item="(item as Extract<NestedItem<T>, { slot: string; }>)" :active="active" :index="index">
<UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, loading: true })" /> <UIcon v-if="item.loading" :name="loadingIcon || appConfig.ui.icons.loading" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, loading: true })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, active })" /> <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: uiOverride?.itemLeadingIcon, color: item?.color, active })" />
<UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" /> <UAvatar v-else-if="item.avatar" :size="((props.uiOverride?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: uiOverride?.itemLeadingAvatar, active })" />
</slot> </slot>
<span v-if="get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel({ class: uiOverride?.itemLabel, active })"> <span v-if="get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label`: 'item-label') as keyof DropdownMenuContentSlots<T>]" :class="ui.itemLabel({ class: uiOverride?.itemLabel, active })">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-label`: 'item-label') as keyof DropdownMenuContentSlots<T>)" :item="(item as Extract<NestedItem<T>, { slot: string; }>)" :active="active" :index="index">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</slot> </slot>
@@ -84,7 +91,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</span> </span>
<span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })"> <span :class="ui.itemTrailing({ class: uiOverride?.itemTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof DropdownMenuContentSlots<T>)" :item="(item as Extract<NestedItem<T>, { slot: string; }>)" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color, active })" /> <UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon({ class: uiOverride?.itemTrailingIcon, color: item?.color, active })" />
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })"> <span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: uiOverride?.itemTrailingKbds })">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" /> <UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((props.uiOverride?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
@@ -123,7 +130,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:ui="ui" :ui="ui"
:ui-override="uiOverride" :ui-override="uiOverride"
:portal="portal" :portal="portal"
:items="item.children" :items="(item.children as T)"
side="right" side="right"
align="start" align="start"
:align-offset="-4" :align-offset="-4"
@@ -135,7 +142,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
v-bind="item.content" v-bind="item.content"
> >
<template v-for="(_, name) in proxySlots" #[name]="slotData: any"> <template v-for="(_, name) in proxySlots" #[name]="slotData: any">
<slot :name="name" v-bind="slotData" /> <slot :name="(name as keyof DropdownMenuContentSlots<T>)" v-bind="slotData" />
</template> </template>
</UDropdownMenuContent> </UDropdownMenuContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>

View File

@@ -1,20 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { InputHTMLAttributes } from 'vue' import type { InputHTMLAttributes } from 'vue'
import type { VariantProps } from 'tailwind-variants' import type { VariantProps } from 'tailwind-variants'
import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxContentEmits, ComboboxArrowProps, AcceptableValue } from 'reka-ui' import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxContentEmits, ComboboxArrowProps } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config' import _appConfig from '#build/app.config'
import theme from '#build/ui/input-menu' import theme from '#build/ui/input-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, ChipProps, InputProps } from '../types' import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey, EmitsToProps } from '../types/utils' import type {
AcceptableValue,
ArrayOrNested,
GetItemKeys,
GetModelValue,
GetModelValueEmits,
NestedItem,
PartialString,
EmitsToProps
} from '../types/utils'
const appConfigInputMenu = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof theme> } } const appConfigInputMenu = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof theme> } }
const inputMenu = tv({ extend: tv(theme), ...(appConfigInputMenu.ui?.inputMenu || {}) }) const inputMenu = tv({ extend: tv(theme), ...(appConfigInputMenu.ui?.inputMenu || {}) })
export interface InputMenuItem { interface _InputMenuItem {
label?: string label?: string
/** /**
* @IconifyIcon * @IconifyIcon
@@ -27,13 +36,16 @@ export interface InputMenuItem {
* @defaultValue 'item' * @defaultValue 'item'
*/ */
type?: 'label' | 'separator' | 'item' type?: 'label' | 'separator' | 'item'
value?: string | number
disabled?: boolean disabled?: boolean
onSelect?(e?: Event): void onSelect?(e?: Event): void
[key: string]: any
} }
export type InputMenuItem = _InputMenuItem | AcceptableValue | boolean
type InputMenuVariants = VariantProps<typeof inputMenu> type InputMenuVariants = VariantProps<typeof inputMenu>
export interface InputMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<InputMenuItem | AcceptableValue | boolean> = MaybeArrayOfArray<InputMenuItem | AcceptableValue | boolean>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false> extends Pick<ComboboxRootProps<T>, 'open' | 'defaultOpen' | 'disabled' | 'name' | 'resetSearchTermOnBlur' | 'highlightOnHover'>, UseComponentIconsProps { export interface InputMenuProps<T extends ArrayOrNested<InputMenuItem> = ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false> extends Pick<ComboboxRootProps<T>, 'open' | 'defaultOpen' | 'disabled' | 'name' | 'resetSearchTermOnBlur' | 'highlightOnHover'>, UseComponentIconsProps {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -96,17 +108,17 @@ export interface InputMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends Ma
* When `items` is an array of objects, select the field to use as the value instead of the object itself. * When `items` is an array of objects, select the field to use as the value instead of the object itself.
* @defaultValue undefined * @defaultValue undefined
*/ */
valueKey?: V valueKey?: VK
/** /**
* When `items` is an array of objects, select the field to use as the label. * When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: V labelKey?: keyof NestedItem<T>
items?: I items?: T
/** The value of the InputMenu when initially rendered. Use when you do not need to control the state of the InputMenu. */ /** The value of the InputMenu when initially rendered. Use when you do not need to control the state of the InputMenu. */
defaultValue?: SelectModelValue<T, V, M> defaultValue?: GetModelValue<T, VK, M>
/** The controlled value of the InputMenu. Can be binded-with with `v-model`. */ /** The controlled value of the InputMenu. Can be binded-with with `v-model`. */
modelValue?: SelectModelValue<T, V, M> modelValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */ /** Whether multiple options can be selected or not. */
multiple?: M & boolean multiple?: M & boolean
/** Highlight the ring color like a focus state. */ /** Highlight the ring color like a focus state. */
@@ -130,18 +142,28 @@ export interface InputMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends Ma
ui?: PartialString<typeof inputMenu.slots> ui?: PartialString<typeof inputMenu.slots>
} }
export type InputMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>, 'update:modelValue'> & { export type InputMenuEmits<A extends ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<A> | undefined, M extends boolean> = Pick<ComboboxRootEmits, 'update:open'> & {
change: [payload: Event] change: [payload: Event]
blur: [payload: FocusEvent] blur: [payload: FocusEvent]
focus: [payload: FocusEvent] focus: [payload: FocusEvent]
create: [item: string] create: [item: string]
} & SelectModelValueEmits<T, V, M> /** Event handler when highlighted element changes. */
highlight: [payload: {
ref: HTMLElement
value: GetModelValue<A, VK, M>
} | undefined]
} & GetModelValueEmits<A, VK, M>
type SlotProps<T> = (props: { item: T, index: number }) => any type SlotProps<T extends InputMenuItem> = (props: { item: T, index: number }) => any
export interface InputMenuSlots<T, M extends boolean> { export interface InputMenuSlots<
'leading'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any A extends ArrayOrNested<InputMenuItem> = ArrayOrNested<InputMenuItem>,
'trailing'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any VK extends GetItemKeys<A> | undefined = undefined,
M extends boolean = false,
T extends NestedItem<A> = NestedItem<A>
> {
'leading'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean, ui: ReturnType<typeof inputMenu> }): any
'trailing'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean, ui: ReturnType<typeof inputMenu> }): any
'empty'(props: { searchTerm?: string }): any 'empty'(props: { searchTerm?: string }): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
@@ -153,7 +175,7 @@ export interface InputMenuSlots<T, M extends boolean> {
} }
</script> </script>
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<InputMenuItem | AcceptableValue | boolean> = MaybeArrayOfArray<InputMenuItem | AcceptableValue | boolean>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false"> <script setup lang="ts" generic="T extends ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
import { computed, ref, toRef, onMounted, toRaw } from 'vue' import { computed, ref, toRef, onMounted, toRaw } from 'vue'
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits, useFilter } from 'reka-ui' import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits, useFilter } from 'reka-ui'
import { defu } from 'defu' import { defu } from 'defu'
@@ -164,21 +186,21 @@ import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons' import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField' import { useFormField } from '../composables/useFormField'
import { useLocale } from '../composables/useLocale' import { useLocale } from '../composables/useLocale'
import { get, compare } from '../utils' import { compare, get, isArrayOfArray } from '../utils'
import UIcon from './Icon.vue' import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue' import UAvatar from './Avatar.vue'
import UChip from './Chip.vue' import UChip from './Chip.vue'
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputMenuProps<T, I, V, M>>(), { const props = withDefaults(defineProps<InputMenuProps<T, VK, M>>(), {
type: 'text', type: 'text',
autofocusDelay: 0, autofocusDelay: 0,
portal: true, portal: true,
labelKey: 'label' as never labelKey: 'label' as never
}) })
const emits = defineEmits<InputMenuEmits<T, V, M>>() const emits = defineEmits<InputMenuEmits<T, VK, M>>()
const slots = defineSlots<InputMenuSlots<T, M>>() const slots = defineSlots<InputMenuSlots<T, VK, M>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' }) const searchTerm = defineModel<string>('searchTerm', { default: '' })
@@ -219,9 +241,15 @@ function displayValue(value: T): string {
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item) return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
} }
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as InputMenuItem[][] : []) const groups = computed<InputMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
const items = computed(() => groups.value.flatMap(group => group) as T[]) const items = computed(() => groups.value.flatMap(group => group))
const filteredGroups = computed(() => { const filteredGroups = computed(() => {
if (props.ignoreFilter || !searchTerm.value) { if (props.ignoreFilter || !searchTerm.value) {
@@ -230,9 +258,9 @@ const filteredGroups = computed(() => {
const fields = Array.isArray(props.filterFields) ? props.filterFields : [props.labelKey] as string[] const fields = Array.isArray(props.filterFields) ? props.filterFields : [props.labelKey] as string[]
return groups.value.map(items => items.filter((item) => { return groups.value.map(group => group.filter((item) => {
if (typeof item !== 'object') { if (typeof item !== 'object' || item === null) {
return contains(item, searchTerm.value) return contains(String(item), searchTerm.value)
} }
if (item.type && ['label', 'separator'].includes(item.type)) { if (item.type && ['label', 'separator'].includes(item.type)) {
@@ -240,19 +268,21 @@ const filteredGroups = computed(() => {
} }
return fields.some(field => contains(get(item, field), searchTerm.value)) return fields.some(field => contains(get(item, field), searchTerm.value))
})).filter(group => group.filter(item => !item.type || !['label', 'separator'].includes(item.type)).length > 0) })).filter(group => group.filter(item =>
isInputItem(item) && (!item.type || !['label', 'separator'].includes(item.type))
).length > 0)
}) })
const filteredItems = computed(() => filteredGroups.value.flatMap(group => group) as T[]) const filteredItems = computed(() => filteredGroups.value.flatMap(group => group))
const createItem = computed(() => { const createItem = computed(() => {
if (!props.createItem || !searchTerm.value) { if (!props.createItem || !searchTerm.value) {
return false return false
} }
const newItem = props.valueKey ? { [props.valueKey]: searchTerm.value } as T : searchTerm.value const newItem = props.valueKey ? { [props.valueKey]: searchTerm.value } as NestedItem<T> : searchTerm.value
if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') { if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') {
return !filteredItems.value.find(item => compare(item, newItem, props.valueKey)) return !filteredItems.value.find(item => compare(item, newItem, String(props.valueKey)))
} }
return !filteredItems.value.length return !filteredItems.value.length
@@ -307,12 +337,16 @@ function onUpdateOpen(value: boolean) {
function onRemoveTag(event: any) { function onRemoveTag(event: any) {
if (props.multiple) { if (props.multiple) {
const modelValue = props.modelValue as SelectModelValue<T, V, true> const modelValue = props.modelValue as GetModelValue<T, VK, true>
const filteredValue = modelValue.filter(value => !isEqual(value, event)) const filteredValue = modelValue.filter(value => !isEqual(value, event))
emits('update:modelValue', filteredValue as SelectModelValue<T, V, M>) emits('update:modelValue', filteredValue as GetModelValue<T, VK, M>)
} }
} }
function isInputItem(item: InputMenuItem): item is _InputMenuItem {
return typeof item === 'object' && item !== null
}
defineExpose({ defineExpose({
inputRef inputRef
}) })
@@ -362,15 +396,15 @@ defineExpose({
@focus="onFocus" @focus="onFocus"
@remove-tag="onRemoveTag" @remove-tag="onRemoveTag"
> >
<TagsInputItem v-for="(item, index) in tags" :key="index" :value="(item as string)" :class="ui.tagsItem({ class: props.ui?.tagsItem })"> <TagsInputItem v-for="(item, index) in tags" :key="index" :value="item" :class="ui.tagsItem({ class: props.ui?.tagsItem })">
<TagsInputItemText :class="ui.tagsItemText({ class: props.ui?.tagsItemText })"> <TagsInputItemText :class="ui.tagsItemText({ class: props.ui?.tagsItemText })">
<slot name="tags-item-text" :item="(item as T)" :index="index"> <slot name="tags-item-text" :item="(item as NestedItem<T>)" :index="index">
{{ displayValue(item as T) }} {{ displayValue(item as T) }}
</slot> </slot>
</TagsInputItemText> </TagsInputItemText>
<TagsInputItemDelete :class="ui.tagsItemDelete({ class: props.ui?.tagsItemDelete })" :disabled="disabled"> <TagsInputItemDelete :class="ui.tagsItemDelete({ class: props.ui?.tagsItemDelete })" :disabled="disabled">
<slot name="tags-item-delete" :item="(item as T)" :index="index"> <slot name="tags-item-delete" :item="(item as NestedItem<T>)" :index="index">
<UIcon :name="deleteIcon || appConfig.ui.icons.close" :class="ui.tagsItemDeleteIcon({ class: props.ui?.tagsItemDeleteIcon })" /> <UIcon :name="deleteIcon || appConfig.ui.icons.close" :class="ui.tagsItemDeleteIcon({ class: props.ui?.tagsItemDeleteIcon })" />
</slot> </slot>
</TagsInputItemDelete> </TagsInputItemDelete>
@@ -401,14 +435,14 @@ defineExpose({
/> />
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })"> <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :model-value="(modelValue as M extends true ? T[] : T)" :open="open" :ui="ui"> <slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" /> <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" /> <UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
</slot> </slot>
</span> </span>
<ComboboxTrigger v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })"> <ComboboxTrigger v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing" :model-value="(modelValue as M extends true ? T[] : T)" :open="open" :ui="ui"> <slot name="trailing" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" /> <UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot> </slot>
</ComboboxTrigger> </ComboboxTrigger>
@@ -427,25 +461,25 @@ defineExpose({
<ComboboxGroup v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })"> <ComboboxGroup v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`"> <template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })"> <ComboboxLabel v-if="isInputItem(item) && item.type === 'label'" :class="ui.label({ class: props.ui?.label })">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</ComboboxLabel> </ComboboxLabel>
<ComboboxSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" /> <ComboboxSeparator v-else-if="isInputItem(item) && item.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
<ComboboxItem <ComboboxItem
v-else v-else
:class="ui.item({ class: props.ui?.item })" :class="ui.item({ class: props.ui?.item })"
:disabled="item.disabled" :disabled="isInputItem(item) && item.disabled"
:value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item" :value="props.valueKey && isInputItem(item) ? get(item, String(props.valueKey)) : item"
@select="item.onSelect" @select="isInputItem(item) && item.onSelect"
> >
<slot name="item" :item="(item as T)" :index="index"> <slot name="item" :item="(item as NestedItem<T>)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index"> <slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" /> <UIcon v-if="isInputItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
<UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" /> <UAvatar v-else-if="isInputItem(item) && item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
<UChip <UChip
v-else-if="item.chip" v-else-if="isInputItem(item) && item.chip"
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])" :size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
inset inset
standalone standalone
@@ -455,13 +489,13 @@ defineExpose({
</slot> </slot>
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })"> <span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="item-label" :item="(item as T)" :index="index"> <slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }} {{ isInputItem(item) ? get(item, props.labelKey as string) : item }}
</slot> </slot>
</span> </span>
<span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })"> <span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })">
<slot name="item-trailing" :item="(item as T)" :index="index" /> <slot name="item-trailing" :item="(item as NestedItem<T>)" :index="index" />
<ComboboxItemIndicator as-child> <ComboboxItemIndicator as-child>
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" /> <UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />

View File

@@ -67,7 +67,7 @@ export interface ModalSlots {
header(props?: {}): any header(props?: {}): any
title(props?: {}): any title(props?: {}): any
description(props?: {}): any description(props?: {}): any
close(props: { ui: any }): any close(props: { ui: ReturnType<typeof modal> }): any
body(props?: {}): any body(props?: {}): any
footer(props?: {}): any footer(props?: {}): any
} }

View File

@@ -7,15 +7,23 @@ import _appConfig from '#build/app.config'
import theme from '#build/ui/navigation-menu' import theme from '#build/ui/navigation-menu'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, BadgeProps, LinkProps } from '../types' import type { AvatarProps, BadgeProps, LinkProps } from '../types'
import type { DynamicSlots, MaybeArrayOfArray, MaybeArrayOfArrayItem, PartialString, EmitsToProps } from '../types/utils' import type {
ArrayOrNested,
DynamicSlots,
MergeTypes,
NestedItem,
PartialString,
EmitsToProps
} from '../types/utils'
const appConfigNavigationMenu = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } } const appConfigNavigationMenu = _appConfig as AppConfig & { ui: { navigationMenu: Partial<typeof theme> } }
const navigationMenu = tv({ extend: tv(theme), ...(appConfigNavigationMenu.ui?.navigationMenu || {}) }) const navigationMenu = tv({ extend: tv(theme), ...(appConfigNavigationMenu.ui?.navigationMenu || {}) })
export interface NavigationMenuChildItem extends Omit<NavigationMenuItem, 'children' | 'type'> { export interface NavigationMenuChildItem extends Omit<NavigationMenuItem, 'type'> {
/** Description is only used when `orientation` is `horizontal`. */ /** Description is only used when `orientation` is `horizontal`. */
description?: string description?: string
[key: string]: any
} }
export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<CollapsibleRootProps, 'defaultOpen' | 'open'> { export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'>, Pick<CollapsibleRootProps, 'defaultOpen' | 'open'> {
@@ -44,11 +52,12 @@ export interface NavigationMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
value?: string value?: string
children?: NavigationMenuChildItem[] children?: NavigationMenuChildItem[]
onSelect?(e: Event): void onSelect?(e: Event): void
[key: string]: any
} }
type NavigationMenuVariants = VariantProps<typeof navigationMenu> type NavigationMenuVariants = VariantProps<typeof navigationMenu>
export interface NavigationMenuProps<T> extends Pick<NavigationMenuRootProps, 'modelValue' | 'defaultValue' | 'delayDuration' | 'disableClickTrigger' | 'disableHoverTrigger' | 'skipDelayDuration' | 'disablePointerLeaveClose' | 'unmountOnHide'> { export interface NavigationMenuProps<T extends ArrayOrNested<NavigationMenuItem> = ArrayOrNested<NavigationMenuItem>> extends Pick<NavigationMenuRootProps, 'modelValue' | 'defaultValue' | 'delayDuration' | 'disableClickTrigger' | 'disableHoverTrigger' | 'skipDelayDuration' | 'disablePointerLeaveClose' | 'unmountOnHide'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -110,31 +119,34 @@ export interface NavigationMenuProps<T> extends Pick<NavigationMenuRootProps, 'm
* The key used to get the label from the item. * The key used to get the label from the item.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: string labelKey?: keyof NestedItem<T>
class?: any class?: any
ui?: PartialString<typeof navigationMenu.slots> ui?: PartialString<typeof navigationMenu.slots>
} }
export interface NavigationMenuEmits extends NavigationMenuRootEmits {} export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any type SlotProps<T extends NavigationMenuItem> = (props: { item: T, index: number, active?: boolean }) => any
export type NavigationMenuSlots<T extends { slot?: string }> = { export type NavigationMenuSlots<
A extends ArrayOrNested<NavigationMenuItem> = ArrayOrNested<NavigationMenuItem>,
T extends NestedItem<A> = NestedItem<A>
> = {
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
'item-trailing': SlotProps<T> 'item-trailing': SlotProps<T>
'item-content': SlotProps<T> 'item-content': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing' | 'content', { index: number, active?: boolean }>
</script> </script>
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<NavigationMenuItem>"> <script setup lang="ts" generic="T extends ArrayOrNested<NavigationMenuItem>">
import { computed, toRef } from 'vue' import { computed, toRef } from 'vue'
import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'reka-ui' import { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'reka-ui'
import { createReusableTemplate } from '@vueuse/core' import { createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports' import { useAppConfig } from '#imports'
import { get } from '../utils' import { get, isArrayOfArray } from '../utils'
import { pickLinkProps } from '../utils/link' import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue' import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue' import ULink from './Link.vue'
@@ -143,7 +155,7 @@ import UIcon from './Icon.vue'
import UBadge from './Badge.vue' import UBadge from './Badge.vue'
import UCollapsible from './Collapsible.vue' import UCollapsible from './Collapsible.vue'
const props = withDefaults(defineProps<NavigationMenuProps<I>>(), { const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
orientation: 'horizontal', orientation: 'horizontal',
contentOrientation: 'horizontal', contentOrientation: 'horizontal',
externalIcon: true, externalIcon: true,
@@ -170,8 +182,14 @@ const rootProps = useForwardPropsEmits(computed(() => ({
const contentProps = toRef(() => props.content) const contentProps = toRef(() => props.content)
const appConfig = useAppConfig() const appConfig = useAppConfig()
const [DefineLinkTemplate, ReuseLinkTemplate] = createReusableTemplate<{ item: NavigationMenuItem, index: number, active?: boolean }>() const [DefineLinkTemplate, ReuseLinkTemplate] = createReusableTemplate<
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: NavigationMenuItem, index: number, level?: number }>({ { item: NavigationMenuItem, index: number, active?: boolean },
NavigationMenuSlots<T>
>()
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<
{ item: NavigationMenuItem, index: number, level?: number },
NavigationMenuSlots<T>
>({
props: { props: {
item: Object, item: Object,
index: Number, index: Number,
@@ -189,30 +207,36 @@ const ui = computed(() => navigationMenu({
highlightColor: props.highlightColor || props.color highlightColor: props.highlightColor || props.color
})) }))
const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as T[][] : []) const lists = computed<NavigationMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
</script> </script>
<template> <template>
<DefineLinkTemplate v-slot="{ item, active, index }"> <DefineLinkTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index"> <slot :name="((item.slot || 'item') as keyof NavigationMenuSlots<T>)" :item="item" :index="index">
<slot :name="item.slot ? `${item.slot}-leading` : 'item-leading'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-leading` : 'item-leading') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active, disabled: !!item.disabled })" /> <UAvatar v-if="item.avatar" :size="((props.ui?.linkLeadingAvatarSize || ui.linkLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ class: props.ui?.linkLeadingAvatar, active, disabled: !!item.disabled })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active, disabled: !!item.disabled })" /> <UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active, disabled: !!item.disabled })" />
</slot> </slot>
<span <span
v-if="(!collapsed || orientation !== 'vertical') && (get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label` : 'item-label'])" v-if="(!collapsed || orientation !== 'vertical') && (get(item, props.labelKey as string) || !!slots[(item.slot ? `${item.slot}-label` : 'item-label') as keyof NavigationMenuSlots<T>])"
:class="ui.linkLabel({ class: props.ui?.linkLabel })" :class="ui.linkLabel({ class: props.ui?.linkLabel })"
> >
<slot :name="item.slot ? `${item.slot}-label` : 'item-label'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-label` : 'item-label') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</slot> </slot>
<UIcon v-if="item.target === '_blank' && externalIcon !== false" :name="typeof externalIcon === 'string' ? externalIcon : appConfig.ui.icons.external" :class="ui.linkLabelExternalIcon({ class: props.ui?.linkLabelExternalIcon, active })" /> <UIcon v-if="item.target === '_blank' && externalIcon !== false" :name="typeof externalIcon === 'string' ? externalIcon : appConfig.ui.icons.external" :class="ui.linkLabelExternalIcon({ class: props.ui?.linkLabelExternalIcon, active })" />
</span> </span>
<span v-if="(!collapsed || orientation !== 'vertical') && (item.badge || (orientation === 'horizontal' && (item.children?.length || !!slots[item.slot ? `${item.slot}-content` : 'item-content'])) || (orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[item.slot ? `${item.slot}-trailing` : 'item-trailing'])" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })"> <span v-if="(!collapsed || orientation !== 'vertical') && (item.badge || (orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (orientation === 'vertical' && item.children?.length) || item.trailingIcon || !!slots[(item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>])" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing` : 'item-trailing'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-trailing` : 'item-trailing') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
<UBadge <UBadge
v-if="item.badge" v-if="item.badge"
color="neutral" color="neutral"
@@ -222,7 +246,7 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
:class="ui.linkTrailingBadge({ class: props.ui?.linkTrailingBadge })" :class="ui.linkTrailingBadge({ class: props.ui?.linkTrailingBadge })"
/> />
<UIcon v-if="(orientation === 'horizontal' && (item.children?.length || !!slots[item.slot ? `${item.slot}-content` : 'item-content'])) || (orientation === 'vertical' && item.children?.length)" :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon, active })" /> <UIcon v-if="(orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) || (orientation === 'vertical' && item.children?.length)" :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon, active })" />
<UIcon v-else-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon, active })" /> <UIcon v-else-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon, active })" />
</slot> </slot>
</span> </span>
@@ -239,23 +263,23 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
:open="item.open" :open="item.open"
> >
<div v-if="orientation === 'vertical' && item.type === 'label'" :class="ui.label({ class: props.ui?.label })"> <div v-if="orientation === 'vertical' && item.type === 'label'" :class="ui.label({ class: props.ui?.label })">
<ReuseLinkTemplate :item="(item as T)" :index="index" /> <ReuseLinkTemplate :item="item" :index="index" />
</div> </div>
<ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="(orientation === 'vertical' && item.children?.length && !collapsed) ? {} : pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom> <ULink v-else-if="item.type !== 'label'" v-slot="{ active, ...slotProps }" v-bind="(orientation === 'vertical' && item.children?.length && !collapsed) ? {} : pickLinkProps(item as Omit<NavigationMenuItem, 'type'>)" custom>
<component <component
:is="(orientation === 'horizontal' && (item.children?.length || !!slots[item.slot ? `${item.slot}-content` : 'item-content'])) ? NavigationMenuTrigger : NavigationMenuLink" :is="(orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])) ? NavigationMenuTrigger : NavigationMenuLink"
as-child as-child
:active="active || item.active" :active="active || item.active"
:disabled="item.disabled" :disabled="item.disabled"
@select="item.onSelect" @select="item.onSelect"
> >
<ULinkBase v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.class], active: active || item.active, disabled: !!item.disabled, level: orientation === 'horizontal' || level > 0 })"> <ULinkBase v-bind="slotProps" :class="ui.link({ class: [props.ui?.link, item.class], active: active || item.active, disabled: !!item.disabled, level: orientation === 'horizontal' || level > 0 })">
<ReuseLinkTemplate :item="(item as T)" :active="active || item.active" :index="index" /> <ReuseLinkTemplate :item="item" :active="active || item.active" :index="index" />
</ULinkBase> </ULinkBase>
</component> </component>
<NavigationMenuContent v-if="orientation === 'horizontal' && (item.children?.length || !!slots[item.slot ? `${item.slot}-content` : 'item-content'])" v-bind="contentProps" :class="ui.content({ class: props.ui?.content })"> <NavigationMenuContent v-if="orientation === 'horizontal' && (item.children?.length || !!slots[(item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>])" v-bind="contentProps" :class="ui.content({ class: props.ui?.content })">
<slot :name="item.slot ? `${item.slot}-content` : 'item-content'" :item="(item as T)" :active="active" :index="index"> <slot :name="((item.slot ? `${item.slot}-content` : 'item-content') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
<ul :class="ui.childList({ class: props.ui?.childList })"> <ul :class="ui.childList({ class: props.ui?.childList })">
<li v-for="(childItem, childIndex) in item.children" :key="childIndex" :class="ui.childItem({ class: props.ui?.childItem })"> <li v-for="(childItem, childIndex) in item.children" :key="childIndex" :class="ui.childItem({ class: props.ui?.childItem })">
<ULink v-slot="{ active: childActive, ...childSlotProps }" v-bind="pickLinkProps(childItem)" custom> <ULink v-slot="{ active: childActive, ...childSlotProps }" v-bind="pickLinkProps(childItem)" custom>

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { VariantProps } from 'tailwind-variants' import type { VariantProps } from 'tailwind-variants'
import type { RadioGroupRootProps, RadioGroupRootEmits, AcceptableValue } from 'reka-ui' import type { RadioGroupRootProps, RadioGroupRootEmits } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config' import _appConfig from '#build/app.config'
import theme from '#build/ui/radio-group' import theme from '#build/ui/radio-group'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AcceptableValue } from '../types/utils'
const appConfigRadioGroup = _appConfig as AppConfig & { ui: { radioGroup: Partial<typeof theme> } } const appConfigRadioGroup = _appConfig as AppConfig & { ui: { radioGroup: Partial<typeof theme> } }
@@ -12,14 +13,16 @@ const radioGroup = tv({ extend: tv(theme), ...(appConfigRadioGroup.ui?.radioGrou
type RadioGroupVariants = VariantProps<typeof radioGroup> type RadioGroupVariants = VariantProps<typeof radioGroup>
export interface RadioGroupItem { export type RadioGroupValue = AcceptableValue
export type RadioGroupItem = {
label?: string label?: string
description?: string description?: string
disabled?: boolean disabled?: boolean
value?: string value?: string
} [key: string]: any
} | RadioGroupValue
export interface RadioGroupProps<T> extends Pick<RadioGroupRootProps, 'defaultValue' | 'disabled' | 'loop' | 'modelValue' | 'name' | 'required'> { export interface RadioGroupProps<T extends RadioGroupItem = RadioGroupItem> extends Pick<RadioGroupRootProps, 'defaultValue' | 'disabled' | 'loop' | 'modelValue' | 'name' | 'required'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -63,16 +66,16 @@ export type RadioGroupEmits = RadioGroupRootEmits & {
change: [payload: Event] change: [payload: Event]
} }
type SlotProps<T> = (props: { item: T, modelValue?: AcceptableValue }) => any type SlotProps<T extends RadioGroupItem> = (props: { item: T & { id: string }, modelValue?: RadioGroupValue }) => any
export interface RadioGroupSlots<T> { export interface RadioGroupSlots<T extends RadioGroupItem = RadioGroupItem> {
legend(props?: {}): any legend(props?: {}): any
label: SlotProps<T> label: SlotProps<T>
description: SlotProps<T> description: SlotProps<T>
} }
</script> </script>
<script setup lang="ts" generic="T extends RadioGroupItem | AcceptableValue"> <script setup lang="ts" generic="T extends RadioGroupItem">
import { computed, useId } from 'vue' import { computed, useId } from 'vue'
import { RadioGroupRoot, RadioGroupItem, RadioGroupIndicator, Label, useForwardPropsEmits } from 'reka-ui' import { RadioGroupRoot, RadioGroupItem, RadioGroupIndicator, Label, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core' import { reactivePick } from '@vueuse/core'
@@ -102,11 +105,19 @@ const ui = computed(() => radioGroup({
})) }))
function normalizeItem(item: any) { function normalizeItem(item: any) {
if (['string', 'number', 'boolean'].includes(typeof item)) { if (item === null) {
return {
id: `${id}:null`,
value: undefined,
label: undefined
}
}
if (typeof item === 'string' || typeof item === 'number') {
return { return {
id: `${id}:${item}`, id: `${id}:${item}`,
value: item, value: String(item),
label: item label: String(item)
} }
} }
@@ -170,10 +181,10 @@ function onUpdate(value: any) {
<div :class="ui.wrapper({ class: props.ui?.wrapper })"> <div :class="ui.wrapper({ class: props.ui?.wrapper })">
<Label :class="ui.label({ class: props.ui?.label })" :for="item.id"> <Label :class="ui.label({ class: props.ui?.label })" :for="item.id">
<slot name="label" :item="item" :model-value="modelValue">{{ item.label }}</slot> <slot name="label" :item="item" :model-value="(modelValue as RadioGroupValue)">{{ item.label }}</slot>
</Label> </Label>
<p v-if="item.description || !!slots.description" :class="ui.description({ class: props.ui?.description })"> <p v-if="item.description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
<slot name="description" :item="item" :model-value="modelValue"> <slot name="description" :item="item" :model-value="(modelValue as RadioGroupValue)">
{{ item.description }} {{ item.description }}
</slot> </slot>
</p> </p>

View File

@@ -1,19 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { VariantProps } from 'tailwind-variants' import type { VariantProps } from 'tailwind-variants'
import type { SelectRootProps, SelectRootEmits, SelectContentProps, SelectContentEmits, SelectArrowProps, AcceptableValue } from 'reka-ui' import type { SelectRootProps, SelectRootEmits, SelectContentProps, SelectContentEmits, SelectArrowProps } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config' import _appConfig from '#build/app.config'
import theme from '#build/ui/select' import theme from '#build/ui/select'
import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, ChipProps, InputProps } from '../types' import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey, EmitsToProps } from '../types/utils' import type {
AcceptableValue,
ArrayOrNested,
GetItemKeys,
GetItemValue,
GetModelValue,
GetModelValueEmits,
NestedItem,
PartialString,
EmitsToProps
} from '../types/utils'
const appConfigSelect = _appConfig as AppConfig & { ui: { select: Partial<typeof theme> } } const appConfigSelect = _appConfig as AppConfig & { ui: { select: Partial<typeof theme> } }
const select = tv({ extend: tv(theme), ...(appConfigSelect.ui?.select || {}) }) const select = tv({ extend: tv(theme), ...(appConfigSelect.ui?.select || {}) })
export interface SelectItem { interface SelectItemBase {
label?: string label?: string
/** /**
* @IconifyIcon * @IconifyIcon
@@ -26,13 +36,15 @@ export interface SelectItem {
* @defaultValue 'item' * @defaultValue 'item'
*/ */
type?: 'label' | 'separator' | 'item' type?: 'label' | 'separator' | 'item'
value?: string value?: string | number
disabled?: boolean disabled?: boolean
[key: string]: any
} }
export type SelectItem = SelectItemBase | AcceptableValue | boolean
type SelectVariants = VariantProps<typeof select> type SelectVariants = VariantProps<typeof select>
export interface SelectProps<T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectItem | AcceptableValue | boolean> = MaybeArrayOfArray<SelectItem | AcceptableValue | boolean>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false> extends Omit<SelectRootProps<T>, 'dir' | 'multiple' | 'modelValue' | 'defaultValue' | 'by'>, UseComponentIconsProps { export interface SelectProps<T extends ArrayOrNested<SelectItem> = ArrayOrNested<SelectItem>, VK extends GetItemKeys<T> = 'value', M extends boolean = false> extends Omit<SelectRootProps<T>, 'dir' | 'multiple' | 'modelValue' | 'defaultValue' | 'by'>, UseComponentIconsProps {
id?: string id?: string
/** The placeholder text when the select is empty. */ /** The placeholder text when the select is empty. */
placeholder?: string placeholder?: string
@@ -79,17 +91,17 @@ export interface SelectProps<T extends MaybeArrayOfArrayItem<I>, I extends Maybe
* When `items` is an array of objects, select the field to use as the value. * When `items` is an array of objects, select the field to use as the value.
* @defaultValue 'value' * @defaultValue 'value'
*/ */
valueKey?: V valueKey?: VK
/** /**
* When `items` is an array of objects, select the field to use as the label. * When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: V labelKey?: keyof NestedItem<T>
items?: I items?: T
/** The value of the Select when initially rendered. Use when you do not need to control the state of the Select. */ /** The value of the Select when initially rendered. Use when you do not need to control the state of the Select. */
defaultValue?: SelectModelValue<T, V, M, T extends { value: infer U } ? U : never> defaultValue?: GetModelValue<T, VK, M>
/** The controlled value of the Select. Can be bind as `v-model`. */ /** The controlled value of the Select. Can be bind as `v-model`. */
modelValue?: SelectModelValue<T, V, M, T extends { value: infer U } ? U : never> modelValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */ /** Whether multiple options can be selected or not. */
multiple?: M & boolean multiple?: M & boolean
/** Highlight the ring color like a focus state. */ /** Highlight the ring color like a focus state. */
@@ -98,18 +110,23 @@ export interface SelectProps<T extends MaybeArrayOfArrayItem<I>, I extends Maybe
ui?: PartialString<typeof select.slots> ui?: PartialString<typeof select.slots>
} }
export type SelectEmits<T, V, M extends boolean> = Omit<SelectRootEmits<T>, 'update:modelValue'> & { export type SelectEmits<A extends ArrayOrNested<SelectItem>, VK extends GetItemKeys<A> | undefined, M extends boolean> = Omit<SelectRootEmits, 'update:modelValue'> & {
change: [payload: Event] change: [payload: Event]
blur: [payload: FocusEvent] blur: [payload: FocusEvent]
focus: [payload: FocusEvent] focus: [payload: FocusEvent]
} & SelectModelValueEmits<T, V, M, T extends { value: infer U } ? U : never> } & GetModelValueEmits<A, VK, M>
type SlotProps<T> = (props: { item: T, index: number }) => any type SlotProps<T extends SelectItem> = (props: { item: T, index: number }) => any
export interface SelectSlots<T, M extends boolean> { export interface SelectSlots<
'leading'(props: { modelValue?: M extends true ? AcceptableValue[] : AcceptableValue, open: boolean, ui: any }): any A extends ArrayOrNested<SelectItem> = ArrayOrNested<SelectItem>,
'default'(props: { modelValue?: M extends true ? AcceptableValue[] : AcceptableValue, open: boolean }): any VK extends GetItemKeys<A> | undefined = undefined,
'trailing'(props: { modelValue?: M extends true ? AcceptableValue[] : AcceptableValue, open: boolean, ui: any }): any M extends boolean = false,
T extends NestedItem<A> = NestedItem<A>
> {
'leading'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean, ui: ReturnType<typeof select> }): any
'default'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean }): any
'trailing'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean, ui: ReturnType<typeof select> }): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
@@ -117,7 +134,7 @@ export interface SelectSlots<T, M extends boolean> {
} }
</script> </script>
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectItem | AcceptableValue | boolean> = MaybeArrayOfArray<SelectItem | AcceptableValue | boolean>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false"> <script setup lang="ts" generic="T extends ArrayOrNested<SelectItem>, VK extends GetItemKeys<T> = 'value', M extends boolean = false">
import { computed, toRef } from 'vue' import { computed, toRef } from 'vue'
import { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectViewport, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'reka-ui' import { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectViewport, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'reka-ui'
import { defu } from 'defu' import { defu } from 'defu'
@@ -126,20 +143,20 @@ import { useAppConfig } from '#imports'
import { useButtonGroup } from '../composables/useButtonGroup' import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons' import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField' import { useFormField } from '../composables/useFormField'
import { get, compare } from '../utils' import { compare, get, isArrayOfArray } from '../utils'
import UIcon from './Icon.vue' import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue' import UAvatar from './Avatar.vue'
import UChip from './Chip.vue' import UChip from './Chip.vue'
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<SelectProps<T, I, V, M>>(), { const props = withDefaults(defineProps<SelectProps<T, VK, M>>(), {
valueKey: 'value' as never, valueKey: 'value' as never,
labelKey: 'label' as never, labelKey: 'label' as never,
portal: true portal: true
}) })
const emits = defineEmits<SelectEmits<T, V, M>>() const emits = defineEmits<SelectEmits<T, VK, M>>()
const slots = defineSlots<SelectSlots<T, M>>() const slots = defineSlots<SelectSlots<T, VK, M>>()
const appConfig = useAppConfig() const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'disabled', 'autocomplete', 'required', 'multiple'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'disabled', 'autocomplete', 'required', 'multiple'), emits)
@@ -163,11 +180,17 @@ const ui = computed(() => select({
buttonGroup: orientation.value buttonGroup: orientation.value
})) }))
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as SelectItem[][] : []) const groups = computed<SelectItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
const items = computed(() => groups.value.flatMap(group => group) as T[]) const items = computed(() => groups.value.flatMap(group => group) as T[])
function displayValue(value?: AcceptableValue | AcceptableValue[]): string | undefined { function displayValue(value?: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string {
if (props.multiple && Array.isArray(value)) { if (props.multiple && Array.isArray(value)) {
return value.map(v => displayValue(v)).filter(Boolean).join(', ') return value.map(v => displayValue(v)).filter(Boolean).join(', ')
} }
@@ -195,6 +218,10 @@ function onUpdateOpen(value: boolean) {
emitFormFocus() emitFormFocus()
} }
} }
function isSelectItem(item: SelectItem): item is SelectItemBase {
return typeof item === 'object' && item !== null
}
</script> </script>
<!-- eslint-disable vue/no-template-shadow --> <!-- eslint-disable vue/no-template-shadow -->
@@ -205,21 +232,21 @@ function onUpdateOpen(value: boolean) {
v-bind="rootProps" v-bind="rootProps"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:disabled="disabled" :disabled="disabled"
:default-value="(defaultValue as (AcceptableValue | AcceptableValue[] | undefined))" :default-value="(defaultValue as (AcceptableValue | AcceptableValue[]))"
:model-value="(modelValue as (AcceptableValue | AcceptableValue[] | undefined))" :model-value="(modelValue as (AcceptableValue | AcceptableValue[]))"
@update:model-value="onUpdate" @update:model-value="onUpdate"
@update:open="onUpdateOpen" @update:open="onUpdateOpen"
> >
<SelectTrigger :id="id" :class="ui.base({ class: [props.class, props.ui?.base] })" v-bind="{ ...$attrs, ...ariaAttrs }"> <SelectTrigger :id="id" :class="ui.base({ class: [props.class, props.ui?.base] })" v-bind="{ ...$attrs, ...ariaAttrs }">
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })"> <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :model-value="(modelValue as M extends true ? AcceptableValue[] : AcceptableValue)" :open="open" :ui="ui"> <slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" /> <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" /> <UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
</slot> </slot>
</span> </span>
<slot :model-value="(modelValue as M extends true ? AcceptableValue[] : AcceptableValue)" :open="open"> <slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
<template v-for="displayedModelValue in [displayValue(modelValue)]" :key="displayedModelValue"> <template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue">
<span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })"> <span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })">
{{ displayedModelValue }} {{ displayedModelValue }}
</span> </span>
@@ -230,7 +257,7 @@ function onUpdateOpen(value: boolean) {
</slot> </slot>
<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })"> <span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing" :model-value="(modelValue as M extends true ? AcceptableValue[] : AcceptableValue)" :open="open" :ui="ui"> <slot name="trailing" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" /> <UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot> </slot>
</span> </span>
@@ -241,24 +268,24 @@ function onUpdateOpen(value: boolean) {
<SelectViewport :class="ui.viewport({ class: props.ui?.viewport })"> <SelectViewport :class="ui.viewport({ class: props.ui?.viewport })">
<SelectGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })"> <SelectGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`"> <template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<SelectLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })"> <SelectLabel v-if="isSelectItem(item) && item.type === 'label'" :class="ui.label({ class: props.ui?.label })">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</SelectLabel> </SelectLabel>
<SelectSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" /> <SelectSeparator v-else-if="isSelectItem(item) && item.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
<SelectItem <SelectItem
v-else v-else
:class="ui.item({ class: props.ui?.item })" :class="ui.item({ class: props.ui?.item })"
:disabled="item.disabled" :disabled="isSelectItem(item) && item.disabled"
:value="typeof item === 'object' ? get(item, props.valueKey as string) : item" :value="isSelectItem(item) ? get(item, props.valueKey as string) : item"
> >
<slot name="item" :item="(item as T)" :index="index"> <slot name="item" :item="(item as NestedItem<T>)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index"> <slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" /> <UIcon v-if="isSelectItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
<UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" /> <UAvatar v-else-if="isSelectItem(item) && item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
<UChip <UChip
v-else-if="item.chip" v-else-if="isSelectItem(item) && item.chip"
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])" :size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
inset inset
standalone standalone
@@ -268,13 +295,13 @@ function onUpdateOpen(value: boolean) {
</slot> </slot>
<SelectItemText :class="ui.itemLabel({ class: props.ui?.itemLabel })"> <SelectItemText :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="item-label" :item="(item as T)" :index="index"> <slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }} {{ isSelectItem(item) ? get(item, props.labelKey as string) : item }}
</slot> </slot>
</SelectItemText> </SelectItemText>
<span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })"> <span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })">
<slot name="item-trailing" :item="(item as T)" :index="index" /> <slot name="item-trailing" :item="(item as NestedItem<T>)" :index="index" />
<SelectItemIndicator as-child> <SelectItemIndicator as-child>
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" /> <UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />

View File

@@ -1,19 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { VariantProps } from 'tailwind-variants' import type { VariantProps } from 'tailwind-variants'
import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxContentEmits, ComboboxArrowProps, AcceptableValue } from 'reka-ui' import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxContentEmits, ComboboxArrowProps } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config' import _appConfig from '#build/app.config'
import theme from '#build/ui/select-menu' import theme from '#build/ui/select-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons' import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { AvatarProps, ChipProps, InputProps } from '../types' import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey, EmitsToProps } from '../types/utils' import type {
AcceptableValue,
ArrayOrNested,
GetItemKeys,
GetItemValue,
GetModelValue,
GetModelValueEmits,
NestedItem,
PartialString,
EmitsToProps
} from '../types/utils'
const appConfigSelectMenu = _appConfig as AppConfig & { ui: { selectMenu: Partial<typeof theme> } } const appConfigSelectMenu = _appConfig as AppConfig & { ui: { selectMenu: Partial<typeof theme> } }
const selectMenu = tv({ extend: tv(theme), ...(appConfigSelectMenu.ui?.selectMenu || {}) }) const selectMenu = tv({ extend: tv(theme), ...(appConfigSelectMenu.ui?.selectMenu || {}) })
export interface SelectMenuItem { interface _SelectMenuItem {
label?: string label?: string
/** /**
* @IconifyIcon * @IconifyIcon
@@ -26,13 +36,16 @@ export interface SelectMenuItem {
* @defaultValue 'item' * @defaultValue 'item'
*/ */
type?: 'label' | 'separator' | 'item' type?: 'label' | 'separator' | 'item'
value?: string | number
disabled?: boolean disabled?: boolean
onSelect?(e?: Event): void onSelect?(e?: Event): void
[key: string]: any
} }
export type SelectMenuItem = _SelectMenuItem | AcceptableValue | boolean
type SelectMenuVariants = VariantProps<typeof selectMenu> type SelectMenuVariants = VariantProps<typeof selectMenu>
export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectMenuItem | AcceptableValue | boolean> = MaybeArrayOfArray<SelectMenuItem | AcceptableValue | boolean>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false> extends Pick<ComboboxRootProps<T>, 'open' | 'defaultOpen' | 'disabled' | 'name' | 'resetSearchTermOnBlur' | 'highlightOnHover'>, UseComponentIconsProps { export interface SelectMenuProps<T extends ArrayOrNested<SelectMenuItem> = ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false> extends Pick<ComboboxRootProps<T>, 'open' | 'defaultOpen' | 'disabled' | 'name' | 'resetSearchTermOnBlur' | 'highlightOnHover'>, UseComponentIconsProps {
id?: string id?: string
/** The placeholder text when the select is empty. */ /** The placeholder text when the select is empty. */
placeholder?: string placeholder?: string
@@ -88,17 +101,17 @@ export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends M
* When `items` is an array of objects, select the field to use as the value instead of the object itself. * When `items` is an array of objects, select the field to use as the value instead of the object itself.
* @defaultValue undefined * @defaultValue undefined
*/ */
valueKey?: V valueKey?: VK
/** /**
* When `items` is an array of objects, select the field to use as the label. * When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: V labelKey?: keyof NestedItem<T>
items?: I items?: T
/** The value of the SelectMenu when initially rendered. Use when you do not need to control the state of the SelectMenu. */ /** The value of the SelectMenu when initially rendered. Use when you do not need to control the state of the SelectMenu. */
defaultValue?: SelectModelValue<T, V, M> defaultValue?: GetModelValue<T, VK, M>
/** The controlled value of the SelectMenu. Can be binded-with with `v-model`. */ /** The controlled value of the SelectMenu. Can be binded-with with `v-model`. */
modelValue?: SelectModelValue<T, V, M> modelValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */ /** Whether multiple options can be selected or not. */
multiple?: M & boolean multiple?: M & boolean
/** Highlight the ring color like a focus state. */ /** Highlight the ring color like a focus state. */
@@ -122,19 +135,29 @@ export interface SelectMenuProps<T extends MaybeArrayOfArrayItem<I>, I extends M
ui?: PartialString<typeof selectMenu.slots> ui?: PartialString<typeof selectMenu.slots>
} }
export type SelectMenuEmits<T, V, M extends boolean> = Omit<ComboboxRootEmits<T>, 'update:modelValue'> & { export type SelectMenuEmits<A extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<A> | undefined, M extends boolean> = Pick<ComboboxRootEmits, 'update:open'> & {
change: [payload: Event] change: [payload: Event]
blur: [payload: FocusEvent] blur: [payload: FocusEvent]
focus: [payload: FocusEvent] focus: [payload: FocusEvent]
create: [item: string] create: [item: string]
} & SelectModelValueEmits<T, V, M> /** Event handler when highlighted element changes. */
highlight: [payload: {
ref: HTMLElement
value: GetModelValue<A, VK, M>
} | undefined]
} & GetModelValueEmits<A, VK, M>
type SlotProps<T> = (props: { item: T, index: number }) => any type SlotProps<T extends SelectMenuItem> = (props: { item: T, index: number }) => any
export interface SelectMenuSlots<T, M extends boolean> { export interface SelectMenuSlots<
'leading'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any A extends ArrayOrNested<SelectMenuItem> = ArrayOrNested<SelectMenuItem>,
'default'(props: { modelValue?: M extends true ? T[] : T, open: boolean }): any VK extends GetItemKeys<A> | undefined = undefined,
'trailing'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any M extends boolean = false,
T extends NestedItem<A> = NestedItem<A>
> {
'leading'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean, ui: ReturnType<typeof selectMenu> }): any
'default'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean }): any
'trailing'(props: { modelValue?: GetModelValue<A, VK, M>, open: boolean, ui: ReturnType<typeof selectMenu> }): any
'empty'(props: { searchTerm?: string }): any 'empty'(props: { searchTerm?: string }): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
@@ -144,7 +167,7 @@ export interface SelectMenuSlots<T, M extends boolean> {
} }
</script> </script>
<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<SelectMenuItem | AcceptableValue | boolean> = MaybeArrayOfArray<SelectMenuItem | AcceptableValue | boolean>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false"> <script setup lang="ts" generic="T extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
import { computed, toRef, toRaw } from 'vue' import { computed, toRef, toRaw } from 'vue'
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui' import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui'
import { defu } from 'defu' import { defu } from 'defu'
@@ -154,7 +177,7 @@ import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons' import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField' import { useFormField } from '../composables/useFormField'
import { useLocale } from '../composables/useLocale' import { useLocale } from '../composables/useLocale'
import { get, compare } from '../utils' import { compare, get, isArrayOfArray } from '../utils'
import UIcon from './Icon.vue' import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue' import UAvatar from './Avatar.vue'
import UChip from './Chip.vue' import UChip from './Chip.vue'
@@ -162,14 +185,14 @@ import UInput from './Input.vue'
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<SelectMenuProps<T, I, V, M>>(), { const props = withDefaults(defineProps<SelectMenuProps<T, VK, M>>(), {
portal: true, portal: true,
searchInput: true, searchInput: true,
labelKey: 'label' as never, labelKey: 'label' as never,
resetSearchTermOnBlur: true resetSearchTermOnBlur: true
}) })
const emits = defineEmits<SelectMenuEmits<T, V, M>>() const emits = defineEmits<SelectMenuEmits<T, VK, M>>()
const slots = defineSlots<SelectMenuSlots<T, M>>() const slots = defineSlots<SelectMenuSlots<T, VK, M>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' }) const searchTerm = defineModel<string>('searchTerm', { default: '' })
@@ -201,7 +224,7 @@ const ui = computed(() => selectMenu({
buttonGroup: orientation.value buttonGroup: orientation.value
})) }))
function displayValue(value: T | T[]): string { function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string {
if (props.multiple && Array.isArray(value)) { if (props.multiple && Array.isArray(value)) {
return value.map(v => displayValue(v)).filter(Boolean).join(', ') return value.map(v => displayValue(v)).filter(Boolean).join(', ')
} }
@@ -214,7 +237,13 @@ function displayValue(value: T | T[]): string {
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item) return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
} }
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as SelectMenuItem[][] : []) const groups = computed<SelectMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
const items = computed(() => groups.value.flatMap(group => group) as T[]) const items = computed(() => groups.value.flatMap(group => group) as T[])
@@ -226,8 +255,8 @@ const filteredGroups = computed(() => {
const fields = Array.isArray(props.filterFields) ? props.filterFields : [props.labelKey] as string[] const fields = Array.isArray(props.filterFields) ? props.filterFields : [props.labelKey] as string[]
return groups.value.map(items => items.filter((item) => { return groups.value.map(items => items.filter((item) => {
if (typeof item !== 'object') { if (typeof item !== 'object' || item === null) {
return contains(item, searchTerm.value) return contains(String(item), searchTerm.value)
} }
if (item.type && ['label', 'separator'].includes(item.type)) { if (item.type && ['label', 'separator'].includes(item.type)) {
@@ -235,19 +264,21 @@ const filteredGroups = computed(() => {
} }
return fields.some(field => contains(get(item, field), searchTerm.value)) return fields.some(field => contains(get(item, field), searchTerm.value))
})).filter(group => group.filter(item => !item.type || !['label', 'separator'].includes(item.type)).length > 0) })).filter(group => group.filter(item =>
isSelectItem(item) && (!item.type || !['label', 'separator'].includes(item.type))
).length > 0)
}) })
const filteredItems = computed(() => filteredGroups.value.flatMap(group => group) as T[]) const filteredItems = computed(() => filteredGroups.value.flatMap(group => group))
const createItem = computed(() => { const createItem = computed(() => {
if (!props.createItem || !searchTerm.value) { if (!props.createItem || !searchTerm.value) {
return false return false
} }
const newItem = props.valueKey ? { [props.valueKey]: searchTerm.value } as T : searchTerm.value const newItem = props.valueKey ? { [props.valueKey]: searchTerm.value } as NestedItem<T> : searchTerm.value
if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') { if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') {
return !filteredItems.value.find(item => compare(item, newItem, props.valueKey)) return !filteredItems.value.find(item => compare(item, newItem, String(props.valueKey)))
} }
return !filteredItems.value.length return !filteredItems.value.length
@@ -290,6 +321,10 @@ function onUpdateOpen(value: boolean) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
} }
} }
function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
return typeof item === 'object' && item !== null
}
</script> </script>
<!-- eslint-disable vue/no-template-shadow --> <!-- eslint-disable vue/no-template-shadow -->
@@ -324,14 +359,14 @@ function onUpdateOpen(value: boolean) {
<ComboboxAnchor as-child> <ComboboxAnchor as-child>
<ComboboxTrigger :class="ui.base({ class: [props.class, props.ui?.base] })" tabindex="0"> <ComboboxTrigger :class="ui.base({ class: [props.class, props.ui?.base] })" tabindex="0">
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })"> <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :model-value="(modelValue as M extends true ? T[] : T)" :open="open" :ui="ui"> <slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" /> <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" /> <UAvatar v-else-if="!!avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
</slot> </slot>
</span> </span>
<slot :model-value="(modelValue as M extends true ? T[] : T)" :open="open"> <slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
<template v-for="displayedModelValue in [displayValue(modelValue as M extends true ? T[] : T)]" :key="displayedModelValue"> <template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue">
<span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })"> <span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })">
{{ displayedModelValue }} {{ displayedModelValue }}
</span> </span>
@@ -342,7 +377,7 @@ function onUpdateOpen(value: boolean) {
</slot> </slot>
<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })"> <span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing" :model-value="(modelValue as M extends true ? T[] : T)" :open="open" :ui="ui"> <slot name="trailing" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" /> <UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot> </slot>
</span> </span>
@@ -367,25 +402,25 @@ function onUpdateOpen(value: boolean) {
<ComboboxGroup v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })"> <ComboboxGroup v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`"> <template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })"> <ComboboxLabel v-if="isSelectItem(item) && item.type === 'label'" :class="ui.label({ class: props.ui?.label })">
{{ get(item, props.labelKey as string) }} {{ get(item, props.labelKey as string) }}
</ComboboxLabel> </ComboboxLabel>
<ComboboxSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" /> <ComboboxSeparator v-else-if="isSelectItem(item) && item.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
<ComboboxItem <ComboboxItem
v-else v-else
:class="ui.item({ class: props.ui?.item })" :class="ui.item({ class: props.ui?.item })"
:disabled="item.disabled" :disabled="isSelectItem(item) && item.disabled"
:value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item" :value="props.valueKey && isSelectItem(item) ? get(item, props.valueKey as string) : item"
@select="item.onSelect" @select="isSelectItem(item) && item.onSelect"
> >
<slot name="item" :item="(item as T)" :index="index"> <slot name="item" :item="(item as NestedItem<T>)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index"> <slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" /> <UIcon v-if="isSelectItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
<UAvatar v-else-if="item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" /> <UAvatar v-else-if="isSelectItem(item) && item.avatar" :size="((props.ui?.itemLeadingAvatarSize || ui.itemLeadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar })" />
<UChip <UChip
v-else-if="item.chip" v-else-if="isSelectItem(item) && item.chip"
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])" :size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
inset inset
standalone standalone
@@ -395,13 +430,13 @@ function onUpdateOpen(value: boolean) {
</slot> </slot>
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })"> <span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="item-label" :item="(item as T)" :index="index"> <slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }} {{ isSelectItem(item) ? get(item, props.labelKey as string) : item }}
</slot> </slot>
</span> </span>
<span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })"> <span :class="ui.itemTrailing({ class: props.ui?.itemTrailing })">
<slot name="item-trailing" :item="(item as T)" :index="index" /> <slot name="item-trailing" :item="(item as NestedItem<T>)" :index="index" />
<ComboboxItemIndicator as-child> <ComboboxItemIndicator as-child>
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" /> <UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />

View File

@@ -70,7 +70,7 @@ export interface SlideoverSlots {
header(props?: {}): any header(props?: {}): any
title(props?: {}): any title(props?: {}): any
description(props?: {}): any description(props?: {}): any
close(props: { ui: any }): any close(props: { ui: ReturnType<typeof slideover> }): any
body(props?: {}): any body(props?: {}): any
footer(props?: {}): any footer(props?: {}): any
} }

View File

@@ -25,9 +25,10 @@ export interface StepperItem {
icon?: string icon?: string
content?: string content?: string
disabled?: boolean disabled?: boolean
[key: string]: any
} }
export interface StepperProps<T extends StepperItem> extends Pick<StepperRootProps, 'linear'> { export interface StepperProps<T extends StepperItem = StepperItem> extends Pick<StepperRootProps, 'linear'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -56,19 +57,19 @@ export interface StepperProps<T extends StepperItem> extends Pick<StepperRootPro
class?: any class?: any
} }
export type StepperEmits<T> = Omit<StepperRootEmits, 'update:modelValue'> & { export type StepperEmits<T extends StepperItem = StepperItem> = Omit<StepperRootEmits, 'update:modelValue'> & {
next: [payload: T] next: [payload: T]
prev: [payload: T] prev: [payload: T]
} }
type SlotProps<T extends StepperItem> = (props: { item: T }) => any type SlotProps<T extends StepperItem> = (props: { item: T }) => any
export type StepperSlots<T extends StepperItem> = { export type StepperSlots<T extends StepperItem = StepperItem> = {
indicator: SlotProps<T> indicator: SlotProps<T>
title: SlotProps<T> title: SlotProps<T>
description: SlotProps<T> description: SlotProps<T>
content: SlotProps<T> content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<T>
</script> </script>
@@ -108,7 +109,7 @@ const currentStepIndex = computed({
} }
}) })
const currentStep = computed(() => props.items?.[currentStepIndex.value] as T) const currentStep = computed(() => props.items?.[currentStepIndex.value])
const hasNext = computed(() => currentStepIndex.value < props.items?.length - 1) const hasNext = computed(() => currentStepIndex.value < props.items?.length - 1)
const hasPrev = computed(() => currentStepIndex.value > 0) const hasPrev = computed(() => currentStepIndex.value > 0)
@@ -116,13 +117,13 @@ defineExpose({
next() { next() {
if (hasNext.value) { if (hasNext.value) {
currentStepIndex.value += 1 currentStepIndex.value += 1
emits('next', currentStep.value) emits('next', currentStep.value as T)
} }
}, },
prev() { prev() {
if (hasPrev.value) { if (hasPrev.value) {
currentStepIndex.value -= 1 currentStepIndex.value -= 1
emits('prev', currentStep.value) emits('prev', currentStep.value as T)
} }
}, },
hasNext, hasNext,
@@ -173,10 +174,10 @@ defineExpose({
</StepperItem> </StepperItem>
</div> </div>
<div v-if="currentStep?.content || !!slots.content || (currentStep?.slot && !!slots[currentStep.slot]) || (currentStep?.value && !!slots[currentStep.value])" :class="ui.content({ class: props.ui?.description })"> <div v-if="currentStep?.content || !!slots.content || currentStep?.slot" :class="ui.content({ class: props.ui?.description })">
<slot <slot
:name="!!slots[currentStep?.slot ?? currentStep.value!] ? currentStep.slot ?? currentStep.value : 'content'" :name="((currentStep?.slot || 'content') as keyof StepperSlots<T>)"
:item="currentStep" :item="(currentStep as Extract<T, { slot: string }>)"
> >
{{ currentStep?.content }} {{ currentStep?.content }}
</slot> </slot>

View File

@@ -25,11 +25,12 @@ export interface TabsItem {
/** A unique value for the tab item. Defaults to the index. */ /** A unique value for the tab item. Defaults to the index. */
value?: string | number value?: string | number
disabled?: boolean disabled?: boolean
[key: string]: any
} }
type TabsVariants = VariantProps<typeof tabs> type TabsVariants = VariantProps<typeof tabs>
export interface TabsProps<T> extends Pick<TabsRootProps<string | number>, 'defaultValue' | 'modelValue' | 'activationMode' | 'unmountOnHide'> { export interface TabsProps<T extends TabsItem = TabsItem> extends Pick<TabsRootProps<string | number>, 'defaultValue' | 'modelValue' | 'activationMode' | 'unmountOnHide'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -69,14 +70,14 @@ export interface TabsProps<T> extends Pick<TabsRootProps<string | number>, 'defa
export interface TabsEmits extends TabsRootEmits<string | number> {} export interface TabsEmits extends TabsRootEmits<string | number> {}
type SlotProps<T> = (props: { item: T, index: number }) => any type SlotProps<T extends TabsItem> = (props: { item: T, index: number }) => any
export type TabsSlots<T extends { slot?: string }> = { export type TabsSlots<T extends TabsItem = TabsItem> = {
leading: SlotProps<T> leading: SlotProps<T>
default: SlotProps<T> default: SlotProps<T>
trailing: SlotProps<T> trailing: SlotProps<T>
content: SlotProps<T> content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<T, undefined, { index: number }>
</script> </script>
@@ -129,7 +130,7 @@ const ui = computed(() => tabs({
<template v-if="!!content"> <template v-if="!!content">
<TabsContent v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :class="ui.content({ class: props.ui?.content })"> <TabsContent v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :class="ui.content({ class: props.ui?.content })">
<slot :name="item.slot || 'content'" :item="item" :index="index"> <slot :name="((item.slot || 'content') as keyof TabsSlots<T>)" :item="(item as Extract<T, { slot: string; }>)" :index="index">
{{ item.content }} {{ item.content }}
</slot> </slot>
</TabsContent> </TabsContent>

View File

@@ -66,7 +66,7 @@ export interface ToastSlots {
title(props?: {}): any title(props?: {}): any
description(props?: {}): any description(props?: {}): any
actions(props?: {}): any actions(props?: {}): any
close(props: { ui: any }): any close(props: { ui: ReturnType<typeof toast> }): any
} }
</script> </script>

View File

@@ -6,7 +6,14 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config' import _appConfig from '#build/app.config'
import theme from '#build/ui/tree' import theme from '#build/ui/tree'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import type { PartialString, DynamicSlots, MaybeMultipleModelValue, SelectItemKey } from '../types/utils' import type {
DynamicSlots,
GetItemKeys,
GetModelValue,
GetModelValueEmits,
NestedItem,
PartialString
} from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { tree: Partial<typeof theme> } } const appConfig = _appConfig as AppConfig & { ui: { tree: Partial<typeof theme> } }
@@ -31,9 +38,10 @@ export type TreeItem = {
children?: TreeItem[] children?: TreeItem[]
onToggle?(e: Event): void onToggle?(e: Event): void
onSelect?(e?: Event): void onSelect?(e?: Event): void
[key: string]: any
} }
export interface TreeProps<T extends TreeItem, M extends boolean = false, K extends SelectItemKey<T> | undefined = undefined> extends Pick<TreeRootProps<T>, 'expanded' | 'defaultExpanded' | 'selectionBehavior' | 'propagateSelect' | 'disabled'> { export interface TreeProps<T extends TreeItem[] = TreeItem[], VK extends GetItemKeys<T> = 'value', M extends boolean = false> extends Pick<TreeRootProps<T>, 'expanded' | 'defaultExpanded' | 'selectionBehavior' | 'propagateSelect' | 'disabled'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'ul' * @defaultValue 'ul'
@@ -51,12 +59,12 @@ export interface TreeProps<T extends TreeItem, M extends boolean = false, K exte
* The key used to get the value from the item. * The key used to get the value from the item.
* @defaultValue 'value' * @defaultValue 'value'
*/ */
valueKey?: K valueKey?: VK
/** /**
* The key used to get the label from the item. * The key used to get the label from the item.
* @defaultValue 'label' * @defaultValue 'label'
*/ */
labelKey?: K labelKey?: keyof NestedItem<T>
/** /**
* The icon displayed on the right side of a parent node. * The icon displayed on the right side of a parent node.
* @defaultValue appConfig.ui.icons.chevronDown * @defaultValue appConfig.ui.icons.chevronDown
@@ -75,33 +83,34 @@ export interface TreeProps<T extends TreeItem, M extends boolean = false, K exte
* @IconifyIcon * @IconifyIcon
*/ */
collapsedIcon?: string collapsedIcon?: string
items?: T[] items?: T
/** The controlled value of the Tree. Can be bind as `v-model`. */ /** The controlled value of the Tree. Can be bind as `v-model`. */
modelValue?: MaybeMultipleModelValue<T, M> modelValue?: GetModelValue<T, VK, M>
/** The value of the Tree when initially rendered. Use when you do not need to control the state of the Tree. */ /** The value of the Tree when initially rendered. Use when you do not need to control the state of the Tree. */
defaultValue?: MaybeMultipleModelValue<T, M> defaultValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */ /** Whether multiple options can be selected or not. */
multiple?: M & boolean multiple?: M & boolean
class?: any class?: any
ui?: PartialString<typeof tree.slots> ui?: PartialString<typeof tree.slots>
} }
export type TreeEmits<T, M extends boolean = false> = Omit<TreeRootEmits, 'update:modelValue'> & { export type TreeEmits<A extends TreeItem[], VK extends GetItemKeys<A> | undefined, M extends boolean> = Omit<TreeRootEmits, 'update:modelValue'> & GetModelValueEmits<A, VK, M>
'update:modelValue': [payload: MaybeMultipleModelValue<T, M>]
}
type SlotProps<T> = (props: { item: T, index: number, level: number, expanded: boolean, selected: boolean }) => any type SlotProps<T extends TreeItem> = (props: { item: T, index: number, level: number, expanded: boolean, selected: boolean }) => any
export type TreeSlots<T extends { slot?: string }> = { export type TreeSlots<
A extends TreeItem[] = TreeItem[],
T extends NestedItem<A> = NestedItem<A>
> = {
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
'item-label': SlotProps<T> 'item-label': SlotProps<T>
'item-trailing': SlotProps<T> 'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>> } & DynamicSlots<T, undefined, { index: number, level: number, expanded: boolean, selected: boolean }>
</script> </script>
<script setup lang="ts" generic="T extends TreeItem, M extends boolean = false, K extends SelectItemKey<T> | undefined = undefined"> <script setup lang="ts" generic="T extends TreeItem[], VK extends GetItemKeys<T> = 'value', M extends boolean = false">
import { computed } from 'vue' import { computed } from 'vue'
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { TreeRoot, TreeItem, useForwardPropsEmits } from 'reka-ui' import { TreeRoot, TreeItem, useForwardPropsEmits } from 'reka-ui'
@@ -109,18 +118,21 @@ import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { get } from '../utils' import { get } from '../utils'
import UIcon from './Icon.vue' import UIcon from './Icon.vue'
const props = withDefaults(defineProps<TreeProps<T, M, K>>(), { const props = withDefaults(defineProps<TreeProps<T, VK, M>>(), {
labelKey: 'label' as never, labelKey: 'label' as never,
valueKey: 'value' as never valueKey: 'value' as never
}) })
const emits = defineEmits<TreeEmits<T, M>>() const emits = defineEmits<TreeEmits<T, VK, M>>()
const slots = defineSlots<TreeSlots<T>>() const slots = defineSlots<TreeSlots<T>>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'items', 'multiple', 'expanded', 'disabled', 'propagateSelect'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'items', 'multiple', 'expanded', 'disabled', 'propagateSelect'), emits)
const [DefineTreeTemplate, ReuseTreeTemplate] = createReusableTemplate<{ items?: T[], level: number }>({ const [DefineTreeTemplate, ReuseTreeTemplate] = createReusableTemplate<
{ items?: NestedItem<T>[], level: number },
TreeSlots<T>
>({
props: { props: {
items: Array as PropType<T[]>, items: Array as PropType<NestedItem<T>[]>,
level: Number level: Number
} }
}) })
@@ -130,22 +142,24 @@ const ui = computed(() => tree({
size: props.size size: props.size
})) }))
function getItemLabel(item: T) { function getItemLabel(item: NestedItem<T>): string {
return get(item, props.labelKey as string) return get(item, props.labelKey as string)
} }
function getItemValue(item?: T) { function getItemValue(item: NestedItem<T>): string {
return get(item, props.valueKey as string) ?? get(item, props.labelKey as string) return get(item, props.valueKey as string) ?? get(item, props.labelKey as string)
} }
function getDefaultOpenedItems(item: T): string[] { function getDefaultOpenedItems(item: NestedItem<T>): string[] {
const currentItem = item.defaultExpanded ? getItemValue(item) : null const currentItem = item.defaultExpanded ? getItemValue(item) : null
const childItems = item.children?.flatMap(child => getDefaultOpenedItems(child as T)) ?? [] const childItems = item.children?.flatMap((child: TreeItem) => getDefaultOpenedItems(child as NestedItem<T>)) ?? []
return [currentItem, ...childItems].filter(Boolean) as string[] return [currentItem, ...childItems].filter(Boolean) as string[]
} }
const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.flatMap(getDefaultOpenedItems)) const defaultExpanded = computed(() =>
props.defaultExpanded ?? props.items?.flatMap(item => getDefaultOpenedItems(item as NestedItem<T>))
)
</script> </script>
<!-- eslint-disable vue/no-template-shadow --> <!-- eslint-disable vue/no-template-shadow -->
@@ -165,8 +179,8 @@ const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.fla
@select="item.onSelect" @select="item.onSelect"
> >
<button :disabled="item.disabled || disabled" :class="ui.link({ class: props.ui?.link, selected: isSelected, disabled: item.disabled || disabled })"> <button :disabled="item.disabled || disabled" :class="ui.link({ class: props.ui?.link, selected: isSelected, disabled: item.disabled || disabled })">
<slot :name="item.slot || 'item'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }"> <slot :name="((item.slot || 'item') as keyof TreeSlots<T>)" v-bind="{ index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }"> <slot :name="((item.slot ? `${item.slot}-leading`: 'item-leading') as keyof TreeSlots<T>)" v-bind="{ index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<UIcon <UIcon
v-if="item.icon" v-if="item.icon"
:name="item.icon" :name="item.icon"
@@ -179,14 +193,14 @@ const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.fla
/> />
</slot> </slot>
<span v-if="getItemLabel(item) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.linkLabel({ class: props.ui?.linkLabel })"> <span v-if="getItemLabel(item) || !!slots[(item.slot ? `${item.slot}-label`: 'item-label') as keyof TreeSlots<T>]" :class="ui.linkLabel({ class: props.ui?.linkLabel })">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }"> <slot :name="((item.slot ? `${item.slot}-label`: 'item-label') as keyof TreeSlots<T>)" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
{{ getItemLabel(item) }} {{ getItemLabel(item) }}
</slot> </slot>
</span> </span>
<span v-if="item.trailingIcon || item.children?.length || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })"> <span v-if="item.trailingIcon || item.children?.length || !!slots[(item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof TreeSlots<T>]" :class="ui.linkTrailing({ class: props.ui?.linkTrailing })">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }"> <slot :name="((item.slot ? `${item.slot}-trailing`: 'item-trailing') as keyof TreeSlots<T>)" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<UIcon v-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" /> <UIcon v-if="item.trailingIcon" :name="item.trailingIcon" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" />
<UIcon v-else-if="item.children?.length" :name="trailingIcon ?? appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" /> <UIcon v-else-if="item.children?.length" :name="trailingIcon ?? appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" />
</slot> </slot>
@@ -195,19 +209,19 @@ const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.fla
</button> </button>
<ul v-if="item.children?.length && isExpanded" :class="ui.listWithChildren({ class: props.ui?.listWithChildren })"> <ul v-if="item.children?.length && isExpanded" :class="ui.listWithChildren({ class: props.ui?.listWithChildren })">
<ReuseTreeTemplate :items="(item.children as T[])" :level="level + 1" /> <ReuseTreeTemplate :items="(item.children as NestedItem<T>[])" :level="level + 1" />
</ul> </ul>
</TreeItem> </TreeItem>
</li> </li>
</DefineTreeTemplate> </DefineTreeTemplate>
<TreeRoot <TreeRoot
v-bind="rootProps" v-bind="(rootProps as unknown as TreeRootProps<NestedItem<T>>)"
:class="ui.root({ class: [props.class, props.ui?.root] })" :class="ui.root({ class: [props.class, props.ui?.root] })"
:get-key="getItemValue" :get-key="getItemValue"
:default-expanded="defaultExpanded" :default-expanded="defaultExpanded"
:selection-behavior="selectionBehavior" :selection-behavior="selectionBehavior"
> >
<ReuseTreeTemplate :items="items" :level="0" /> <ReuseTreeTemplate :items="(items as NestedItem<T>[] | undefined)" :level="0" />
</TreeRoot> </TreeRoot>
</template> </template>

View File

@@ -1,3 +1,4 @@
import type { AcceptableValue as _AcceptableValue } from 'reka-ui'
import type { VNode } from 'vue' import type { VNode } from 'vue'
export interface TightMap<O = any> { export interface TightMap<O = any> {
@@ -14,8 +15,19 @@ export type DeepPartial<T, O = any> = {
[key: string]: O | TightMap<O> [key: string]: O | TightMap<O>
} }
export type DynamicSlots<T extends { slot?: string }, SlotProps, Slot = T['slot']> = export type DynamicSlots<
Record<string, SlotProps> & (Slot extends string ? Record<Slot, SlotProps> : Record<string, never>) T extends { slot?: string },
S extends string | undefined = undefined,
D extends object = {}
> = {
[
K in T['slot'] as K extends string
? S extends string
? (K | `${K}-${S}`)
: K
: never
]?: (props: { item: Extract<T, { slot: K extends `${infer Base}-${S}` ? Base : K }> } & D) => any
}
export type GetObjectField<MaybeObject, Key extends string> = MaybeObject extends Record<string, any> export type GetObjectField<MaybeObject, Key extends string> = MaybeObject extends Record<string, any>
? MaybeObject[Key] ? MaybeObject[Key]
@@ -25,18 +37,49 @@ export type PartialString<T> = {
[K in keyof T]?: string [K in keyof T]?: string
} }
export type MaybeArrayOfArray<T> = T[] | T[][] export type AcceptableValue = Exclude<_AcceptableValue, Record<string, any>>
export type MaybeArrayOfArrayItem<I> = I extends Array<infer T> ? T extends Array<infer U> ? U : T : never export type ArrayOrNested<T> = T[] | T[][]
export type NestedItem<T> = T extends Array<infer I> ? NestedItem<I> : T
export type SelectModelValue<T, V, M extends boolean = false, DV = T> = (T extends Record<string, any> ? V extends keyof T ? T[V] : DV : T) extends infer U ? M extends true ? U[] : U : never type AllKeys<T> = T extends any ? keyof T : never
type NonCommonKeys<T extends object> = Exclude<AllKeys<T>, keyof T>
export type SelectItemKey<T> = T extends Record<string, any> ? keyof T : string type PickTypeOf<T, K extends string | number | symbol> = K extends AllKeys<T>
? T extends { [k in K]?: any }
export type SelectModelValueEmits<T, V, M extends boolean = false, DV = T> = { ? T[K]
'update:modelValue': [payload: SelectModelValue<T, V, M, DV>] : undefined
: never
export type MergeTypes<T extends object> = {
[k in keyof T]: PickTypeOf<T, k>;
} & {
[k in NonCommonKeys<T>]?: PickTypeOf<T, k>;
} }
export type MaybeMultipleModelValue<T, M extends boolean = false> = (T extends infer U ? M extends true ? U[] : U : never) export type GetItemKeys<I> = keyof Extract<NestedItem<I>, object>
export type GetItemValue<I, VK extends GetItemKeys<I> | undefined, T extends NestedItem<I> = NestedItem<I>> =
T extends object
? VK extends undefined
? T
: VK extends keyof T
? T[VK]
: never
: T
export type GetModelValue<
T,
VK extends GetItemKeys<T> | undefined,
M extends boolean
> = M extends true
? GetItemValue<T, VK>[]
: GetItemValue<T, VK>
export type GetModelValueEmits<
T,
VK extends GetItemKeys<T> | undefined,
M extends boolean
> = {
/** Event handler called when the value changes. */
'update:modelValue': [payload: GetModelValue<T, VK, M>]
}
export type StringOrVNode = export type StringOrVNode =
| string | string

View File

@@ -81,3 +81,7 @@ export function compare<T>(value?: T, currentValue?: T, comparator?: string | ((
return isEqual(value, currentValue) return isEqual(value, currentValue)
} }
export function isArrayOfArray<A>(item: A[] | A[][]): item is A[][] {
return Array.isArray(item[0])
}

View File

@@ -29,7 +29,7 @@ describe('Accordion', () => {
icon: 'i-lucide-wrench', icon: 'i-lucide-wrench',
trailingIcon: 'i-lucide-sun', trailingIcon: 'i-lucide-sun',
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.', 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.',
slot: 'custom' slot: 'custom' as const
}] }]
const props = { items } const props = { items }
@@ -57,7 +57,7 @@ describe('Accordion', () => {
['with body slot', { props: { ...props, modelValue: '1' }, slots: { body: () => 'Body slot' } }], ['with body slot', { props: { ...props, modelValue: '1' }, slots: { body: () => 'Body slot' } }],
['with custom slot', { props: { ...props, modelValue: '5' }, slots: { custom: () => 'Custom slot' } }], ['with custom slot', { props: { ...props, modelValue: '5' }, slots: { custom: () => 'Custom slot' } }],
['with custom body slot', { props: { ...props, modelValue: '5' }, slots: { 'custom-body': () => 'Custom body slot' } }] ['with custom body slot', { props: { ...props, modelValue: '5' }, slots: { 'custom-body': () => 'Custom body slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: AccordionProps<typeof items[number]>, slots?: Partial<AccordionSlots<typeof items[number]>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: AccordionProps, slots?: Partial<AccordionSlots & { custom: () => 'Custom slot' } & { 'custom-body': () => 'Custom body slot' }> }) => {
const html = await ComponentRender(nameOrHtml, options, Accordion) const html = await ComponentRender(nameOrHtml, options, Accordion)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -37,7 +37,7 @@ describe('Breadcrumb', () => {
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }], ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }], ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
['with separator slot', { props, slots: { separator: () => '/' } }] ['with separator slot', { props, slots: { separator: () => '/' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: BreadcrumbProps<typeof items[number]>, slots?: Partial<BreadcrumbSlots<typeof items[number]>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: BreadcrumbProps, slots?: Partial<BreadcrumbSlots & { custom: () => 'Custom slot' }> }) => {
const html = await ComponentRender(nameOrHtml, options, Breadcrumb) const html = await ComponentRender(nameOrHtml, options, Breadcrumb)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -37,7 +37,7 @@ describe('Carousel', () => {
['with as', { props: { ...props, as: 'nav' } }], ['with as', { props: { ...props, as: 'nav' } }],
['with class', { props: { ...props, class: 'w-full max-w-xs' } }], ['with class', { props: { ...props, class: 'w-full max-w-xs' } }],
['with ui', { props: { ...props, ui: { viewport: 'h-[320px]' } } }] ['with ui', { props: { ...props, ui: { viewport: 'h-[320px]' } } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: CarouselProps<typeof items[number]>, slots?: Partial<CarouselSlots<typeof items[number]>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: CarouselProps, slots?: Partial<CarouselSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, CarouselWrapper) const html = await ComponentRender(nameOrHtml, options, CarouselWrapper)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -21,7 +21,7 @@ describe('Chip', () => {
// Slots // Slots
['with default slot', { slots: { default: () => 'Default slot' } }], ['with default slot', { slots: { default: () => 'Default slot' } }],
['with content slot', { slots: { content: () => 'Content slot' } }] ['with content slot', { slots: { content: () => 'Content slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: ChipProps & { show?: boolean }, slots?: Partial<ChipSlots> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: ChipProps, slots?: Partial<ChipSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, Chip) const html = await ComponentRender(nameOrHtml, options, Chip)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -1,8 +1,9 @@
import { h, defineComponent } from 'vue' import { h, defineComponent } from 'vue'
import { describe, it, expect } from 'vitest' import { describe, it, expect, test } from 'vitest'
import ContextMenu, { type ContextMenuProps, type ContextMenuSlots } from '../../src/runtime/components/ContextMenu.vue' import ContextMenu, { type ContextMenuProps, type ContextMenuSlots } from '../../src/runtime/components/ContextMenu.vue'
import theme from '#build/ui/context-menu' import theme from '#build/ui/context-menu'
import { mountSuspended } from '@nuxt/test-utils/runtime' import { mountSuspended } from '@nuxt/test-utils/runtime'
import { expectSlotProps } from '../utils/types'
const ContextMenuWrapper = defineComponent({ const ContextMenuWrapper = defineComponent({
components: { components: {
@@ -95,11 +96,33 @@ describe('ContextMenu', () => {
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }], ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }], ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }] ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: ContextMenuProps<typeof items[number][number]>, slots?: Partial<ContextMenuSlots<any>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: ContextMenuProps, slots?: Partial<ContextMenuSlots> }) => {
const wrapper = await mountSuspended(ContextMenuWrapper, options as any) const wrapper = await mountSuspended(ContextMenuWrapper, options as any)
await wrapper.find('span').trigger('click.right') await wrapper.find('span').trigger('click.right')
expect(wrapper.html()).toMatchSnapshot() expect(wrapper.html()).toMatchSnapshot()
}) })
test('should have the correct types', () => {
// normal
expectSlotProps('item', () => ContextMenu({
items: [{ label: 'foo', value: 'bar' }]
})).toEqualTypeOf<{ item: { label: string, value: string }, index: number, active?: boolean }>()
// groups
expectSlotProps('item', () => ContextMenu({
items: [[{ label: 'foo', value: 'bar' }]]
})).toEqualTypeOf<{ item: { label: string, value: string }, index: number, active?: boolean }>()
// custom
expectSlotProps('item', () => ContextMenu({
items: [{ label: 'foo', value: 'bar', custom: 'nice' }]
})).toEqualTypeOf<{ item: { label: string, value: string, custom: string }, index: number, active?: boolean }>()
// custom + groups
expectSlotProps('item', () => ContextMenu({
items: [[{ label: 'foo', value: 'bar', custom: 'nice' }]]
})).toEqualTypeOf<{ item: { label: string, value: string, custom: string }, index: number, active?: boolean }>()
})
}) })

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, test } from 'vitest'
import DropdownMenu, { type DropdownMenuProps, type DropdownMenuSlots } from '../../src/runtime/components/DropdownMenu.vue' import DropdownMenu, { type DropdownMenuProps, type DropdownMenuSlots } from '../../src/runtime/components/DropdownMenu.vue'
import ComponentRender from '../component-render' import ComponentRender from '../component-render'
import theme from '#build/ui/dropdown-menu' import theme from '#build/ui/dropdown-menu'
import { expectSlotProps } from '../utils/types'
describe('DropdownMenu', () => { describe('DropdownMenu', () => {
const sizes = Object.keys(theme.variants.size) as any const sizes = Object.keys(theme.variants.size) as any
@@ -105,8 +106,30 @@ describe('DropdownMenu', () => {
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }], ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }], ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }] ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: DropdownMenuProps<typeof items[number][number]>, slots?: Partial<DropdownMenuSlots<any>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: DropdownMenuProps, slots?: Partial<DropdownMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, DropdownMenu) const html = await ComponentRender(nameOrHtml, options, DropdownMenu)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })
test('should have the correct types', () => {
// normal
expectSlotProps('item', () => DropdownMenu({
items: [{ label: 'foo', value: 'bar' }]
})).toEqualTypeOf<{ item: { label: string, value: string }, index: number, active?: boolean }>()
// groups
expectSlotProps('item', () => DropdownMenu({
items: [[{ label: 'foo', value: 'bar' }]]
})).toEqualTypeOf<{ item: { label: string, value: string }, index: number, active?: boolean }>()
// custom
expectSlotProps('item', () => DropdownMenu({
items: [{ label: 'foo', value: 'bar', custom: 'nice' }]
})).toEqualTypeOf<{ item: { label: string, value: string, custom: string }, index: number, active?: boolean }>()
// custom + groups
expectSlotProps('item', () => DropdownMenu({
items: [[{ label: 'foo', value: 'bar', custom: 'nice' }]]
})).toEqualTypeOf<{ item: { label: string, value: string, custom: string }, index: number, active?: boolean }>()
})
}) })

View File

@@ -77,9 +77,9 @@ describe('InputMenu', () => {
['with item slot', { props, slots: { item: () => 'Item slot' } }], ['with item slot', { props, slots: { item: () => 'Item slot' } }],
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }], ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }], ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }] ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
// ['with create-item-label slot', { props: { ...props, searchTerm: 'New value', createItem: true }, slots: { 'create-item-label': () => 'Create item slot' } }] ['with create-item-label slot', { props: { ...props, searchTerm: 'New value', createItem: true }, slots: { 'create-item-label': () => 'Create item slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputMenuProps<typeof items[number]>, slots?: Partial<InputMenuSlots<typeof items[number], false>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputMenuProps, slots?: Partial<InputMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, InputMenu) const html = await ComponentRender(nameOrHtml, options, InputMenu)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -108,7 +108,7 @@ describe('NavigationMenu', () => {
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }], ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }], ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }] ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: NavigationMenuProps<typeof items>, slots?: Partial<NavigationMenuSlots<any>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: NavigationMenuProps, slots?: Partial<NavigationMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, NavigationMenu) const html = await ComponentRender(nameOrHtml, options, NavigationMenu)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -38,7 +38,7 @@ describe('RadioGroup', () => {
['with legend slot', { props, slots: { label: () => 'Legend slot' } }], ['with legend slot', { props, slots: { label: () => 'Legend slot' } }],
['with label slot', { props, slots: { label: () => 'Label slot' } }], ['with label slot', { props, slots: { label: () => 'Label slot' } }],
['with description slot', { props, slots: { label: () => 'Description slot' } }] ['with description slot', { props, slots: { label: () => 'Description slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: RadioGroupProps<any>, slots?: Partial<RadioGroupSlots<any>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: RadioGroupProps, slots?: Partial<RadioGroupSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, RadioGroup) const html = await ComponentRender(nameOrHtml, options, RadioGroup)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -78,7 +78,7 @@ describe('Select', () => {
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }], ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }], ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }] ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectProps<typeof items[number]>, slots?: Partial<SelectSlots<typeof items[number], false>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectProps, slots?: Partial<SelectSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, Select) const html = await ComponentRender(nameOrHtml, options, Select)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })
@@ -192,10 +192,10 @@ describe('Select', () => {
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]] items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]]
})).toEqualTypeOf<[string | number]>() })).toEqualTypeOf<[string | number]>()
// with groups, mixed types and valueKey = undefined // with groups, multiple, mixed types and valueKey
expectEmitPayloadType('update:modelValue', () => Select({ expectEmitPayloadType('update:modelValue', () => Select({
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]], items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
valueKey: undefined valueKey: 'value' // TODO: value is already the default valueKey
})).toEqualTypeOf<[string | number]>() })).toEqualTypeOf<[string | number]>()
}) })
}) })

View File

@@ -81,9 +81,9 @@ describe('SelectMenu', () => {
['with item slot', { props, slots: { item: () => 'Item slot' } }], ['with item slot', { props, slots: { item: () => 'Item slot' } }],
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }], ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }], ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }] ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
// ['with create-item-label slot', { props: { ...props, searchTerm: 'New value', createItem: true }, slots: { 'create-item-label': () => 'Create item slot' } }] ['with create-item-label slot', { props: { ...props, searchTerm: 'New value', createItem: true }, slots: { 'create-item-label': () => 'Create item slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectMenuProps<typeof items[number]>, slots?: Partial<SelectMenuSlots<typeof items[number], false>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectMenuProps, slots?: Partial<SelectMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, SelectMenu) const html = await ComponentRender(nameOrHtml, options, SelectMenu)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

View File

@@ -43,7 +43,7 @@ describe('Stepper', () => {
['with description slot', { props, slots: { description: () => 'Description slot' } }], ['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with content slot', { props, slots: { content: () => 'Content slot' } }], ['with content slot', { props, slots: { content: () => 'Content slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }] ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: StepperProps<any>, slots?: Partial<StepperSlots<any>> }) => { ])('renders %s correctly', async (nameOrHtml: string, options: { props?: StepperProps, slots?: Partial<StepperSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, Stepper) const html = await ComponentRender(nameOrHtml, options, Stepper)
expect(html).toMatchSnapshot() expect(html).toMatchSnapshot()
}) })

Some files were not shown because too many files have changed in this diff Show More