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 -->
<script setup lang="ts">
import type { ChipProps } from '@nuxt/ui'
import json5 from 'json5'
import { upperFirst, camelCase, kebabCase } from 'scule'
import { hash } from 'ohash'
@@ -53,6 +54,8 @@ const props = defineProps<{
hide?: string[]
/** List of props to externalize in script setup */
external?: string[]
/** The types of the externalized props */
externalTypes?: string[]
/** List of props to use with `v-model` */
model?: string[]
/** List of props to cast from code and selection */
@@ -209,11 +212,21 @@ ${props.slots?.default}
code += `
<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 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>
@@ -346,7 +359,7 @@ const { data: ast } = await useAsyncData(`component-code-${name}-${hash({ props:
inset
standalone
:color="(modelValue as any)"
:size="ui.itemLeadingChipSize()"
:size="(ui.itemLeadingChipSize() as ChipProps['size'])"
class="size-2"
/>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
const items = [
import type { DropdownMenuItem } from '@nuxt/ui'
const items: DropdownMenuItem[][] = [
[
{
label: 'View',
@@ -17,7 +19,7 @@ const items = [
[
{
label: 'Delete',
color: 'error' as const,
color: 'error',
icon: 'i-lucide-trash'
}
]
@@ -27,9 +29,5 @@ const items = [
<template>
<UDropdownMenu :items="items" :ui="{ content: 'w-48' }">
<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>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { SelectItem } from '@nuxt/ui'
const items = ref([
{
label: 'benjamincanac',
@@ -23,13 +25,21 @@ const items = ref([
src: 'https://github.com/noook.png',
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 avatar = computed(() => items.value.find(item => item.value === value.value)?.avatar)
</script>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
"@nuxt/content": "^3.4.0",
"@nuxt/image": "^1.10.0",
"@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",
"@nuxtjs/plausible": "^1.2.0",
"@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.'
}, {
label: 'Components',
slot: 'test' as const,
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.'
}, {
@@ -37,6 +38,11 @@ const items = [{
</p>
</template>
<template #custom="{ item }">
<p class="text-(--ui-text-muted)">
Custom: {{ item.content }}
</p>
</template>
<template #custom-body="{ item }">
<p class="text-(--ui-text-muted)">
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' }">
<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>
</div>
</div>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { InputMenuItem, AvatarProps } from '@nuxt/ui'
import { upperFirst } from 'scule'
import { refDebounced } from '@vueuse/core'
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 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 statuses = [{
@@ -28,7 +30,7 @@ const statuses = [{
}, {
label: 'Canceled',
icon: 'i-lucide-circle-x'
}]
}] satisfies InputMenuItem[]
const searchTerm = ref('')
const searchTermDebounced = refDebounced(searchTerm, 200)
@@ -126,7 +128,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
class="w-48"
>
<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>
</UInputMenu>
</div>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { SelectMenuItem, AvatarProps } from '@nuxt/ui'
import { upperFirst } from 'scule'
import { refDebounced } from '@vueuse/core'
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 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 statuses = [{
@@ -33,7 +35,7 @@ const statuses = [{
label: 'Canceled',
value: 'canceled',
icon: 'i-lucide-circle-x'
}]
}] satisfies SelectMenuItem[]
const searchTerm = ref('')
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', {
params: { q: searchTermDebounced },
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
})
@@ -122,7 +124,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
v-for="size in sizes"
:key="size"
v-model:search-term="searchTerm"
:items="users || []"
:items="users"
:loading="status === 'pending'"
ignore-filter
icon="i-lucide-user"
@@ -132,7 +134,7 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
@update:open="searchTerm = ''"
>
<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>
</USelectMenu>
</div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { SelectItem, AvatarProps } from '@nuxt/ui'
import { upperFirst } from 'scule'
import theme from '#build/ui/select'
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 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 statuses = [{
@@ -32,7 +33,7 @@ const statuses = [{
label: 'Canceled',
value: 'canceled',
icon: 'i-lucide-circle-x'
}]
}] satisfies SelectItem[]
const { data: users, status } = await useFetch('https://jsonplaceholder.typicode.com/users', {
transform: (data: User[]) => {
@@ -114,9 +115,10 @@ function getUserAvatar(value: string) {
trailing-icon="i-lucide-chevrons-up-down"
:size="size"
class="w-48"
value-key="value"
>
<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>
</USelect>
</div>
@@ -130,9 +132,10 @@ function getUserAvatar(value: string) {
placeholder="Search users..."
:size="size"
class="w-48"
value-key="value"
>
<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>
</USelect>
</div>

View File

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

View File

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

30
pnpm-lock.yaml generated
View File

@@ -240,8 +240,8 @@ importers:
specifier: workspace:*
version: link:..
'@nuxt/ui-pro':
specifier: https://pkg.pr.new/@nuxt/ui-pro@d96a086
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))
specifier: https://pkg.pr.new/@nuxt/ui-pro@e524f08
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':
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))
@@ -1555,9 +1555,9 @@ packages:
vitest:
optional: true
'@nuxt/ui-pro@https://pkg.pr.new/@nuxt/ui-pro@d96a086':
resolution: {tarball: https://pkg.pr.new/@nuxt/ui-pro@d96a086}
version: 3.0.0
'@nuxt/ui-pro@https://pkg.pr.new/@nuxt/ui-pro@e524f08':
resolution: {tarball: https://pkg.pr.new/@nuxt/ui-pro@e524f08}
version: 3.0.1
peerDependencies:
typescript: ^5.6.3
@@ -4046,10 +4046,6 @@ packages:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
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:
resolution: {integrity: sha512-I2ZXrXeOc0KrCvC7swqtIFXFN+rbjnC7b2T943tvemIOVNl+XP8YnA9UVwqFhzzLClnSA60KR/qEjLpXzs73Qg==}
engines: {node: '>=18'}
@@ -5330,10 +5326,6 @@ packages:
parse-entities@4.0.2:
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:
resolution: {integrity: sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==}
engines: {node: '>= 18'}
@@ -8415,7 +8407,7 @@ snapshots:
- typescript
- 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:
'@nuxt/kit': 3.16.1(magicast@0.3.5)
'@nuxt/schema': 3.16.1
@@ -8427,9 +8419,8 @@ snapshots:
git-url-parse: 16.0.1
ofetch: 1.4.1
ohash: 2.0.11
parse-git-config: 3.0.0
pathe: 2.0.3
pkg-types: 1.3.1
pkg-types: 2.1.0
scule: 1.3.0
tinyglobby: 0.2.12
typescript: 5.8.2
@@ -11147,8 +11138,6 @@ snapshots:
nypm: 0.6.0
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):
dependencies:
'@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-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:
dependencies:
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. */
value?: string
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.
* @defaultValue 'div'
@@ -52,15 +53,15 @@ export interface AccordionProps<T> extends Pick<AccordionRootProps, 'collapsible
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>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
body: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
} & DynamicSlots<T, 'body', { index: number, open: boolean }>
</script>
@@ -92,7 +93,7 @@ const ui = computed(() => accordion({
<template>
<AccordionRoot v-bind="rootProps" :class="ui.root({ class: [props.class, props.ui?.root] })">
<AccordionItem
v-for="(item, index) in items"
v-for="(item, index) in props.items"
v-slot="{ open }"
:key="index"
:value="item.value || String(index)"
@@ -115,10 +116,10 @@ const ui = computed(() => accordion({
</AccordionTrigger>
</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 })">
<slot :name="item.slot || 'content'" :item="item" :index="index" :open="open">
<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') as keyof AccordionSlots<T>)" :item="item" :index="index" :open="open">
<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 }}
</slot>
</div>

View File

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

View File

@@ -17,7 +17,7 @@ export default {
}
</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 { ConfigProvider, TooltipProvider, useForwardProps } from 'reka-ui'
import { reactivePick } from '@vueuse/core'

View File

@@ -19,9 +19,10 @@ export interface BreadcrumbItem extends Omit<LinkProps, 'raw' | 'custom'> {
icon?: string
avatar?: AvatarProps
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.
* @defaultValue 'nav'
@@ -43,15 +44,15 @@ export interface BreadcrumbProps<T> {
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-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'separator'(props?: {}): any
} & DynamicSlots<T, SlotProps<T>>
'separator': any
} & DynamicSlots<T, 'leading' | 'label' | 'trailing', { index: number, active?: boolean }>
</script>
@@ -88,19 +89,19 @@ const ui = breadcrumb()
<li :class="ui.item({ class: props.ui?.item })">
<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 })">
<slot :name="item.slot || 'item'" :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') as keyof BreadcrumbSlots<T>)" :item="item" :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 })" />
<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>
<span v-if="get(item, props.labelKey as string) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :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">
<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') as keyof BreadcrumbSlots<T>)" :item="item" :active="index === items!.length - 1" :index="index">
{{ get(item, props.labelKey as string) }}
</slot>
</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>
</ULinkBase>
</ULink>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
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 { AppConfig } from '@nuxt/schema'
import type { ButtonProps } from '../types'
@@ -15,13 +15,21 @@ const calendar = tv({ extend: tv(theme), ...(appConfigCalendar.ui?.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
: M extends true
? 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.
* @defaultValue 'div'
@@ -87,7 +95,7 @@ export interface CalendarProps<R extends boolean, M extends boolean> extends Omi
monthControls?: boolean
/** Show year controls */
yearControls?: boolean
defaultValue?: CalendarModelValue<R, M>
defaultValue?: CalendarDefaultValue<R, M>
modelValue?: CalendarModelValue<R, M>
class?: any
ui?: PartialString<typeof calendar.slots>
@@ -104,7 +112,7 @@ export interface CalendarSlots {
}
</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 { useForwardPropsEmits } from 'reka-ui'
import { Calendar as SingleCalendar, RangeCalendar } from 'reka-ui/namespaced'
@@ -151,8 +159,8 @@ const Calendar = computed(() => props.range ? RangeCalendar : SingleCalendar)
<Calendar.Root
v-slot="{ weekDays, grid }"
v-bind="rootProps"
:model-value="(modelValue as CalendarModelValue<true & false>)"
:default-value="(defaultValue as CalendarModelValue<true & false>)"
:model-value="modelValue"
:default-value="defaultValue"
:locale="locale"
:dir="dir"
:class="ui.root({ class: [props.class, props.ui?.root] })"

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
@@ -21,7 +22,9 @@ const carousel = tv({ extend: tv(theme), ...(appConfigCarousel.ui?.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.
* @defaultValue 'div'
@@ -99,12 +102,13 @@ export interface CarouselProps<T> extends Omit<EmblaOptionsType, 'axis' | 'conta
ui?: PartialString<typeof carousel.slots>
}
export type CarouselSlots<T> = {
export type CarouselSlots<T extends CarouselItem = CarouselItem> = {
default(props: { item: T, index: number }): any
}
</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 useEmblaCarousel from 'embla-carousel-vue'
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 { tv } from '../utils/tv'
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> } }
@@ -31,6 +31,7 @@ export interface CommandPaletteItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
disabled?: boolean
slot?: string
onSelect?(e?: Event): void
[key: string]: any
}
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 }> = {
'empty'(props: { searchTerm?: string }): any
'close'(props: { ui: any }): any
'close'(props: { ui: ReturnType<typeof commandPalette> }): any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<G, SlotProps<T>> & DynamicSlots<T, SlotProps<T>>
} & Record<string, SlotProps<G>> & Record<string, SlotProps<T>>
</script>
@@ -297,8 +298,8 @@ const groups = computed(() => {
>
<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 })">
<slot :name="item.slot || group.slot || 'item'" :item="item" :index="index">
<slot :name="item.slot ? `${item.slot}-leading` : group.slot ? `${group.slot}-leading` : `item-leading`" :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`) 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-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 })" />
@@ -312,8 +313,8 @@ const groups = computed(() => {
/>
</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 })">
<slot :name="item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`" :item="item" :index="index">
<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`) 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 :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 :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 })">
<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>

View File

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

View File

@@ -2,15 +2,16 @@
import type { ContextMenuContentProps as RekaContextMenuContentProps, ContextMenuContentEmits as RekaContextMenuContentEmits } from 'reka-ui'
import theme from '#build/ui/context-menu'
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)()
interface ContextMenuContentProps<T> extends Omit<RekaContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T[] | T[][]
interface ContextMenuContentProps<T extends ArrayOrNested<ContextMenuItem>> extends Omit<RekaContextMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T
portal?: boolean
sub?: boolean
labelKey: string
labelKey: keyof NestedItem<T>
/**
* @IconifyIcon
*/
@@ -31,13 +32,13 @@ interface ContextMenuContentProps<T> extends Omit<RekaContextMenuContentProps, '
interface ContextMenuContentEmits extends RekaContextMenuContentEmits {}
</script>
<script setup lang="ts" generic="T extends ContextMenuItem">
<script setup lang="ts" generic="T extends ArrayOrNested<ContextMenuItem>">
import { computed } from 'vue'
import { ContextMenu } from 'reka-ui/namespaced'
import { useForwardPropsEmits } from 'reka-ui'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { omit, get } from '../utils'
import { omit, get, isArrayOfArray } from '../utils'
import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue'
@@ -53,24 +54,30 @@ const slots = defineSlots<ContextMenuSlots<T>>()
const appConfig = useAppConfig()
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 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>
<template>
<DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :index="index">
<slot :name="((item.slot || 'item') as keyof ContextMenuSlots<T>)" :item="item" :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-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 })" />
</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 })">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="(item as T)" :active="active" :index="index">
<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') as keyof ContextMenuSlots<T>)" :item="item" :active="active" :index="index">
{{ get(item, props.labelKey as string) }}
</slot>
@@ -78,7 +85,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</span>
<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 })" />
<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" />
@@ -117,7 +124,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:ui="ui"
:ui-override="uiOverride"
:portal="portal"
:items="item.children"
:items="(item.children as T)"
:align-offset="-4"
:label-key="labelKey"
:checked-icon="checkedIcon"
@@ -126,7 +133,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
v-bind="item.content"
>
<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>
</UContextMenuContent>
</ContextMenu.Sub>

View File

@@ -7,7 +7,14 @@ import _appConfig from '#build/app.config'
import theme from '#build/ui/dropdown-menu'
import { tv } from '../utils/tv'
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> } }
@@ -36,17 +43,18 @@ export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cust
checked?: boolean
open?: boolean
defaultOpen?: boolean
children?: DropdownMenuItem[] | DropdownMenuItem[][]
children?: ArrayOrNested<DropdownMenuItem>
onSelect?(e: Event): 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'
*/
size?: DropdownMenuVariants['size']
items?: T[] | T[][]
items?: T
/**
* The icon displayed when an item is checked.
* @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.
* @defaultValue 'label'
*/
labelKey?: string
labelKey?: keyof NestedItem<T>
disabled?: boolean
class?: any
ui?: PartialString<typeof dropdownMenu.slots>
@@ -93,19 +101,22 @@ export interface DropdownMenuProps<T> extends Omit<DropdownMenuRootProps, 'dir'>
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
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
} & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script>
<script setup lang="ts" generic="T extends DropdownMenuItem">
<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
import { computed, toRef } from 'vue'
import { defu } from 'defu'
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 contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as DropdownMenuContentProps)
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({
size: props.size
@@ -145,13 +156,13 @@ const ui = computed(() => dropdownMenu({
v-bind="contentProps"
:items="items"
:portal="portal"
:label-key="labelKey"
:label-key="(labelKey as keyof NestedItem<T>)"
:checked-icon="checkedIcon"
:loading-icon="loadingIcon"
:external-icon="externalIcon"
>
<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>
<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 { tv } from '../utils/tv'
import type { KbdProps, AvatarProps, DropdownMenuItem, DropdownMenuSlots } from '../types'
import type { ArrayOrNested, NestedItem } from '../types/utils'
const _dropdownMenu = tv(theme)()
interface DropdownMenuContentProps<T> extends Omit<RekaDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T[] | T[][]
interface DropdownMenuContentProps<T extends ArrayOrNested<DropdownMenuItem>> extends Omit<RekaDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
items?: T
portal?: boolean
sub?: boolean
labelKey: string
labelKey: keyof NestedItem<T>
/**
* @IconifyIcon
*/
@@ -31,19 +32,19 @@ interface DropdownMenuContentProps<T> extends Omit<RekaDropdownMenuContentProps,
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
}
</script>
<script setup lang="ts" generic="T extends DropdownMenuItem">
<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
import { computed } from 'vue'
import { DropdownMenu } from 'reka-ui/namespaced'
import { useForwardPropsEmits } from 'reka-ui'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { omit, get } from '../utils'
import { omit, get, isArrayOfArray } from '../utils'
import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue'
@@ -59,24 +60,30 @@ const slots = defineSlots<DropdownMenuContentSlots<T>>()
const appConfig = useAppConfig()
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 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>
<template>
<DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="(item as T)" :active="active" :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') 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-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 })" />
</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 })">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="(item as T)" :active="active" :index="index">
<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') as keyof DropdownMenuContentSlots<T>)" :item="(item as Extract<NestedItem<T>, { slot: string; }>)" :active="active" :index="index">
{{ get(item, props.labelKey as string) }}
</slot>
@@ -84,7 +91,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</span>
<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 })" />
<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" />
@@ -123,7 +130,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:ui="ui"
:ui-override="uiOverride"
:portal="portal"
:items="item.children"
:items="(item.children as T)"
side="right"
align="start"
:align-offset="-4"
@@ -135,7 +142,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
v-bind="item.content"
>
<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>
</UDropdownMenuContent>
</DropdownMenu.Sub>

View File

@@ -1,20 +1,29 @@
<script lang="ts">
import type { InputHTMLAttributes } from 'vue'
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 _appConfig from '#build/app.config'
import theme from '#build/ui/input-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv'
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 inputMenu = tv({ extend: tv(theme), ...(appConfigInputMenu.ui?.inputMenu || {}) })
export interface InputMenuItem {
interface _InputMenuItem {
label?: string
/**
* @IconifyIcon
@@ -27,13 +36,16 @@ export interface InputMenuItem {
* @defaultValue 'item'
*/
type?: 'label' | 'separator' | 'item'
value?: string | number
disabled?: boolean
onSelect?(e?: Event): void
[key: string]: any
}
export type InputMenuItem = _InputMenuItem | AcceptableValue | boolean
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.
* @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.
* @defaultValue undefined
*/
valueKey?: V
valueKey?: VK
/**
* When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label'
*/
labelKey?: V
items?: I
labelKey?: keyof NestedItem<T>
items?: T
/** 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`. */
modelValue?: SelectModelValue<T, V, M>
modelValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */
multiple?: M & boolean
/** 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>
}
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]
blur: [payload: FocusEvent]
focus: [payload: FocusEvent]
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> {
'leading'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any
'trailing'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any
export interface InputMenuSlots<
A extends ArrayOrNested<InputMenuItem> = ArrayOrNested<InputMenuItem>,
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
'item': SlotProps<T>
'item-leading': SlotProps<T>
@@ -153,7 +175,7 @@ export interface InputMenuSlots<T, M extends boolean> {
}
</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 { 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'
@@ -164,21 +186,21 @@ import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { useLocale } from '../composables/useLocale'
import { get, compare } from '../utils'
import { compare, get, isArrayOfArray } from '../utils'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
import UChip from './Chip.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputMenuProps<T, I, V, M>>(), {
const props = withDefaults(defineProps<InputMenuProps<T, VK, M>>(), {
type: 'text',
autofocusDelay: 0,
portal: true,
labelKey: 'label' as never
})
const emits = defineEmits<InputMenuEmits<T, V, M>>()
const slots = defineSlots<InputMenuSlots<T, M>>()
const emits = defineEmits<InputMenuEmits<T, VK, M>>()
const slots = defineSlots<InputMenuSlots<T, VK, M>>()
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)
}
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
const items = computed(() => groups.value.flatMap(group => group) as T[])
const items = computed(() => groups.value.flatMap(group => group))
const filteredGroups = computed(() => {
if (props.ignoreFilter || !searchTerm.value) {
@@ -230,9 +258,9 @@ const filteredGroups = computed(() => {
const fields = Array.isArray(props.filterFields) ? props.filterFields : [props.labelKey] as string[]
return groups.value.map(items => items.filter((item) => {
if (typeof item !== 'object') {
return contains(item, searchTerm.value)
return groups.value.map(group => group.filter((item) => {
if (typeof item !== 'object' || item === null) {
return contains(String(item), searchTerm.value)
}
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))
})).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(() => {
if (!props.createItem || !searchTerm.value) {
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') {
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
@@ -307,12 +337,16 @@ function onUpdateOpen(value: boolean) {
function onRemoveTag(event: any) {
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))
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({
inputRef
})
@@ -362,15 +396,15 @@ defineExpose({
@focus="onFocus"
@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 })">
<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) }}
</slot>
</TagsInputItemText>
<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 })" />
</slot>
</TagsInputItemDelete>
@@ -401,14 +435,14 @@ defineExpose({
/>
<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 })" />
<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>
</span>
<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 })" />
</slot>
</ComboboxTrigger>
@@ -427,25 +461,25 @@ defineExpose({
<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}`">
<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) }}
</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
v-else
:class="ui.item({ class: props.ui?.item })"
:disabled="item.disabled"
:value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item"
@select="item.onSelect"
:disabled="isInputItem(item) && item.disabled"
:value="props.valueKey && isInputItem(item) ? get(item, String(props.valueKey)) : item"
@select="isInputItem(item) && item.onSelect"
>
<slot name="item" :item="(item as T)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index">
<UIcon v-if="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 })" />
<slot name="item" :item="(item as NestedItem<T>)" :index="index">
<slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
<UIcon v-if="isInputItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
<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
v-else-if="item.chip"
v-else-if="isInputItem(item) && item.chip"
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
inset
standalone
@@ -455,13 +489,13 @@ defineExpose({
</slot>
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="item-label" :item="(item as T)" :index="index">
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
<slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
{{ isInputItem(item) ? get(item, props.labelKey as string) : item }}
</slot>
</span>
<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>
<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
title(props?: {}): any
description(props?: {}): any
close(props: { ui: any }): any
close(props: { ui: ReturnType<typeof modal> }): any
body(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 { tv } from '../utils/tv'
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 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?: string
[key: string]: any
}
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
children?: NavigationMenuChildItem[]
onSelect?(e: Event): void
[key: string]: any
}
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.
* @defaultValue 'div'
@@ -110,31 +119,34 @@ export interface NavigationMenuProps<T> extends Pick<NavigationMenuRootProps, 'm
* The key used to get the label from the item.
* @defaultValue 'label'
*/
labelKey?: string
labelKey?: keyof NestedItem<T>
class?: any
ui?: PartialString<typeof navigationMenu.slots>
}
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-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'item-content': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
} & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing' | 'content', { index: number, active?: boolean }>
</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 { NavigationMenuRoot, NavigationMenuList, NavigationMenuItem, NavigationMenuTrigger, NavigationMenuContent, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, useForwardPropsEmits } from 'reka-ui'
import { createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { get } from '../utils'
import { get, isArrayOfArray } from '../utils'
import { pickLinkProps } from '../utils/link'
import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue'
@@ -143,7 +155,7 @@ import UIcon from './Icon.vue'
import UBadge from './Badge.vue'
import UCollapsible from './Collapsible.vue'
const props = withDefaults(defineProps<NavigationMenuProps<I>>(), {
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
orientation: 'horizontal',
contentOrientation: 'horizontal',
externalIcon: true,
@@ -170,8 +182,14 @@ const rootProps = useForwardPropsEmits(computed(() => ({
const contentProps = toRef(() => props.content)
const appConfig = useAppConfig()
const [DefineLinkTemplate, ReuseLinkTemplate] = createReusableTemplate<{ item: NavigationMenuItem, index: number, active?: boolean }>()
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: NavigationMenuItem, index: number, level?: number }>({
const [DefineLinkTemplate, ReuseLinkTemplate] = createReusableTemplate<
{ item: NavigationMenuItem, index: number, active?: boolean },
NavigationMenuSlots<T>
>()
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<
{ item: NavigationMenuItem, index: number, level?: number },
NavigationMenuSlots<T>
>({
props: {
item: Object,
index: Number,
@@ -189,30 +207,36 @@ const ui = computed(() => navigationMenu({
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>
<template>
<DefineLinkTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="(item as T)" :index="index">
<slot :name="item.slot ? `${item.slot}-leading` : 'item-leading'" :item="(item as T)" :active="active" :index="index">
<slot :name="((item.slot || 'item') as keyof NavigationMenuSlots<T>)" :item="item" :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 })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ class: props.ui?.linkLeadingIcon, active, disabled: !!item.disabled })" />
</slot>
<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 })"
>
<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) }}
</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 })" />
</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 })">
<slot :name="item.slot ? `${item.slot}-trailing` : 'item-trailing'" :item="(item as T)" :active="active" :index="index">
<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') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
<UBadge
v-if="item.badge"
color="neutral"
@@ -222,7 +246,7 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
: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 })" />
</slot>
</span>
@@ -239,23 +263,23 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
:open="item.open"
>
<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>
<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
: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
:active="active || item.active"
:disabled="item.disabled"
@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 })">
<ReuseLinkTemplate :item="(item as T)" :active="active || item.active" :index="index" />
<ReuseLinkTemplate :item="item" :active="active || item.active" :index="index" />
</ULinkBase>
</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 })">
<slot :name="item.slot ? `${item.slot}-content` : 'item-content'" :item="(item as T)" :active="active" :index="index">
<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') as keyof NavigationMenuSlots<T>)" :item="item" :active="active" :index="index">
<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 })">
<ULink v-slot="{ active: childActive, ...childSlotProps }" v-bind="pickLinkProps(childItem)" custom>

View File

@@ -1,10 +1,11 @@
<script lang="ts">
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 _appConfig from '#build/app.config'
import theme from '#build/ui/radio-group'
import { tv } from '../utils/tv'
import type { AcceptableValue } from '../types/utils'
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>
export interface RadioGroupItem {
export type RadioGroupValue = AcceptableValue
export type RadioGroupItem = {
label?: string
description?: string
disabled?: boolean
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.
* @defaultValue 'div'
@@ -63,16 +66,16 @@ export type RadioGroupEmits = RadioGroupRootEmits & {
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
label: SlotProps<T>
description: SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends RadioGroupItem | AcceptableValue">
<script setup lang="ts" generic="T extends RadioGroupItem">
import { computed, useId } from 'vue'
import { RadioGroupRoot, RadioGroupItem, RadioGroupIndicator, Label, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
@@ -102,11 +105,19 @@ const ui = computed(() => radioGroup({
}))
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 {
id: `${id}:${item}`,
value: item,
label: item
value: String(item),
label: String(item)
}
}
@@ -170,10 +181,10 @@ function onUpdate(value: any) {
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<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>
<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 }}
</slot>
</p>

View File

@@ -1,19 +1,29 @@
<script lang="ts">
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 _appConfig from '#build/app.config'
import theme from '#build/ui/select'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv'
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 select = tv({ extend: tv(theme), ...(appConfigSelect.ui?.select || {}) })
export interface SelectItem {
interface SelectItemBase {
label?: string
/**
* @IconifyIcon
@@ -26,13 +36,15 @@ export interface SelectItem {
* @defaultValue 'item'
*/
type?: 'label' | 'separator' | 'item'
value?: string
value?: string | number
disabled?: boolean
[key: string]: any
}
export type SelectItem = SelectItemBase | AcceptableValue | boolean
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
/** The placeholder text when the select is empty. */
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.
* @defaultValue 'value'
*/
valueKey?: V
valueKey?: VK
/**
* When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label'
*/
labelKey?: V
items?: I
labelKey?: keyof NestedItem<T>
items?: T
/** 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`. */
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. */
multiple?: M & boolean
/** 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>
}
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]
blur: [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> {
'leading'(props: { modelValue?: M extends true ? AcceptableValue[] : AcceptableValue, open: boolean, ui: any }): any
'default'(props: { modelValue?: M extends true ? AcceptableValue[] : AcceptableValue, open: boolean }): any
'trailing'(props: { modelValue?: M extends true ? AcceptableValue[] : AcceptableValue, open: boolean, ui: any }): any
export interface SelectSlots<
A extends ArrayOrNested<SelectItem> = ArrayOrNested<SelectItem>,
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 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-leading': SlotProps<T>
'item-label': SlotProps<T>
@@ -117,7 +134,7 @@ export interface SelectSlots<T, M extends boolean> {
}
</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 { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectViewport, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'reka-ui'
import { defu } from 'defu'
@@ -126,20 +143,20 @@ import { useAppConfig } from '#imports'
import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { get, compare } from '../utils'
import { compare, get, isArrayOfArray } from '../utils'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
import UChip from './Chip.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<SelectProps<T, I, V, M>>(), {
const props = withDefaults(defineProps<SelectProps<T, VK, M>>(), {
valueKey: 'value' as never,
labelKey: 'label' as never,
portal: true
})
const emits = defineEmits<SelectEmits<T, V, M>>()
const slots = defineSlots<SelectSlots<T, M>>()
const emits = defineEmits<SelectEmits<T, VK, M>>()
const slots = defineSlots<SelectSlots<T, VK, M>>()
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'disabled', 'autocomplete', 'required', 'multiple'), emits)
@@ -163,11 +180,17 @@ const ui = computed(() => select({
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
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)) {
return value.map(v => displayValue(v)).filter(Boolean).join(', ')
}
@@ -195,6 +218,10 @@ function onUpdateOpen(value: boolean) {
emitFormFocus()
}
}
function isSelectItem(item: SelectItem): item is SelectItemBase {
return typeof item === 'object' && item !== null
}
</script>
<!-- eslint-disable vue/no-template-shadow -->
@@ -205,21 +232,21 @@ function onUpdateOpen(value: boolean) {
v-bind="rootProps"
:autocomplete="autocomplete"
:disabled="disabled"
:default-value="(defaultValue as (AcceptableValue | AcceptableValue[] | undefined))"
:model-value="(modelValue as (AcceptableValue | AcceptableValue[] | undefined))"
:default-value="(defaultValue as (AcceptableValue | AcceptableValue[]))"
:model-value="(modelValue as (AcceptableValue | AcceptableValue[]))"
@update:model-value="onUpdate"
@update:open="onUpdateOpen"
>
<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 })">
<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 })" />
<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>
</span>
<slot :model-value="(modelValue as M extends true ? AcceptableValue[] : AcceptableValue)" :open="open">
<template v-for="displayedModelValue in [displayValue(modelValue)]" :key="displayedModelValue">
<slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
<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 })">
{{ displayedModelValue }}
</span>
@@ -230,7 +257,7 @@ function onUpdateOpen(value: boolean) {
</slot>
<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 })" />
</slot>
</span>
@@ -241,24 +268,24 @@ function onUpdateOpen(value: boolean) {
<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 })">
<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) }}
</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
v-else
:class="ui.item({ class: props.ui?.item })"
:disabled="item.disabled"
:value="typeof item === 'object' ? get(item, props.valueKey as string) : item"
:disabled="isSelectItem(item) && item.disabled"
:value="isSelectItem(item) ? get(item, props.valueKey as string) : item"
>
<slot name="item" :item="(item as T)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index">
<UIcon v-if="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 })" />
<slot name="item" :item="(item as NestedItem<T>)" :index="index">
<slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
<UIcon v-if="isSelectItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
<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
v-else-if="item.chip"
v-else-if="isSelectItem(item) && item.chip"
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
inset
standalone
@@ -268,13 +295,13 @@ function onUpdateOpen(value: boolean) {
</slot>
<SelectItemText :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="item-label" :item="(item as T)" :index="index">
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
<slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
{{ isSelectItem(item) ? get(item, props.labelKey as string) : item }}
</slot>
</SelectItemText>
<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>
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: props.ui?.itemTrailingIcon })" />

View File

@@ -1,19 +1,29 @@
<script lang="ts">
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 _appConfig from '#build/app.config'
import theme from '#build/ui/select-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { tv } from '../utils/tv'
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 selectMenu = tv({ extend: tv(theme), ...(appConfigSelectMenu.ui?.selectMenu || {}) })
export interface SelectMenuItem {
interface _SelectMenuItem {
label?: string
/**
* @IconifyIcon
@@ -26,13 +36,16 @@ export interface SelectMenuItem {
* @defaultValue 'item'
*/
type?: 'label' | 'separator' | 'item'
value?: string | number
disabled?: boolean
onSelect?(e?: Event): void
[key: string]: any
}
export type SelectMenuItem = _SelectMenuItem | AcceptableValue | boolean
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
/** The placeholder text when the select is empty. */
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.
* @defaultValue undefined
*/
valueKey?: V
valueKey?: VK
/**
* When `items` is an array of objects, select the field to use as the label.
* @defaultValue 'label'
*/
labelKey?: V
items?: I
labelKey?: keyof NestedItem<T>
items?: T
/** 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`. */
modelValue?: SelectModelValue<T, V, M>
modelValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */
multiple?: M & boolean
/** 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>
}
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]
blur: [payload: FocusEvent]
focus: [payload: FocusEvent]
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> {
'leading'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any
'default'(props: { modelValue?: M extends true ? T[] : T, open: boolean }): any
'trailing'(props: { modelValue?: M extends true ? T[] : T, open: boolean, ui: any }): any
export interface SelectMenuSlots<
A extends ArrayOrNested<SelectMenuItem> = ArrayOrNested<SelectMenuItem>,
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 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
'item': SlotProps<T>
'item-leading': SlotProps<T>
@@ -144,7 +167,7 @@ export interface SelectMenuSlots<T, M extends boolean> {
}
</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 { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui'
import { defu } from 'defu'
@@ -154,7 +177,7 @@ import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { useLocale } from '../composables/useLocale'
import { get, compare } from '../utils'
import { compare, get, isArrayOfArray } from '../utils'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
import UChip from './Chip.vue'
@@ -162,14 +185,14 @@ import UInput from './Input.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<SelectMenuProps<T, I, V, M>>(), {
const props = withDefaults(defineProps<SelectMenuProps<T, VK, M>>(), {
portal: true,
searchInput: true,
labelKey: 'label' as never,
resetSearchTermOnBlur: true
})
const emits = defineEmits<SelectMenuEmits<T, V, M>>()
const slots = defineSlots<SelectMenuSlots<T, M>>()
const emits = defineEmits<SelectMenuEmits<T, VK, M>>()
const slots = defineSlots<SelectMenuSlots<T, VK, M>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' })
@@ -201,7 +224,7 @@ const ui = computed(() => selectMenu({
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)) {
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)
}
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
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[]
return groups.value.map(items => items.filter((item) => {
if (typeof item !== 'object') {
return contains(item, searchTerm.value)
if (typeof item !== 'object' || item === null) {
return contains(String(item), searchTerm.value)
}
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))
})).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(() => {
if (!props.createItem || !searchTerm.value) {
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') {
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
@@ -290,6 +321,10 @@ function onUpdateOpen(value: boolean) {
clearTimeout(timeoutId)
}
}
function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
return typeof item === 'object' && item !== null
}
</script>
<!-- eslint-disable vue/no-template-shadow -->
@@ -324,14 +359,14 @@ function onUpdateOpen(value: boolean) {
<ComboboxAnchor as-child>
<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 })">
<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 })" />
<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>
</span>
<slot :model-value="(modelValue as M extends true ? T[] : T)" :open="open">
<template v-for="displayedModelValue in [displayValue(modelValue as M extends true ? T[] : T)]" :key="displayedModelValue">
<slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
<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 })">
{{ displayedModelValue }}
</span>
@@ -342,7 +377,7 @@ function onUpdateOpen(value: boolean) {
</slot>
<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 })" />
</slot>
</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 })">
<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) }}
</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
v-else
:class="ui.item({ class: props.ui?.item })"
:disabled="item.disabled"
:value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item"
@select="item.onSelect"
:disabled="isSelectItem(item) && item.disabled"
:value="props.valueKey && isSelectItem(item) ? get(item, props.valueKey as string) : item"
@select="isSelectItem(item) && item.onSelect"
>
<slot name="item" :item="(item as T)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index">
<UIcon v-if="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 })" />
<slot name="item" :item="(item as NestedItem<T>)" :index="index">
<slot name="item-leading" :item="(item as NestedItem<T>)" :index="index">
<UIcon v-if="isSelectItem(item) && item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ class: props.ui?.itemLeadingIcon })" />
<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
v-else-if="item.chip"
v-else-if="isSelectItem(item) && item.chip"
:size="((props.ui?.itemLeadingChipSize || ui.itemLeadingChipSize()) as ChipProps['size'])"
inset
standalone
@@ -395,13 +430,13 @@ function onUpdateOpen(value: boolean) {
</slot>
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="item-label" :item="(item as T)" :index="index">
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
<slot name="item-label" :item="(item as NestedItem<T>)" :index="index">
{{ isSelectItem(item) ? get(item, props.labelKey as string) : item }}
</slot>
</span>
<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>
<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
title(props?: {}): any
description(props?: {}): any
close(props: { ui: any }): any
close(props: { ui: ReturnType<typeof slideover> }): any
body(props?: {}): any
footer(props?: {}): any
}

View File

@@ -25,9 +25,10 @@ export interface StepperItem {
icon?: string
content?: string
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.
* @defaultValue 'div'
@@ -56,19 +57,19 @@ export interface StepperProps<T extends StepperItem> extends Pick<StepperRootPro
class?: any
}
export type StepperEmits<T> = Omit<StepperRootEmits, 'update:modelValue'> & {
export type StepperEmits<T extends StepperItem = StepperItem> = Omit<StepperRootEmits, 'update:modelValue'> & {
next: [payload: T]
prev: [payload: T]
}
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>
title: SlotProps<T>
description: SlotProps<T>
content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
} & DynamicSlots<T>
</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 hasPrev = computed(() => currentStepIndex.value > 0)
@@ -116,13 +117,13 @@ defineExpose({
next() {
if (hasNext.value) {
currentStepIndex.value += 1
emits('next', currentStep.value)
emits('next', currentStep.value as T)
}
},
prev() {
if (hasPrev.value) {
currentStepIndex.value -= 1
emits('prev', currentStep.value)
emits('prev', currentStep.value as T)
}
},
hasNext,
@@ -173,10 +174,10 @@ defineExpose({
</StepperItem>
</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
:name="!!slots[currentStep?.slot ?? currentStep.value!] ? currentStep.slot ?? currentStep.value : 'content'"
:item="currentStep"
:name="((currentStep?.slot || 'content') as keyof StepperSlots<T>)"
:item="(currentStep as Extract<T, { slot: string }>)"
>
{{ currentStep?.content }}
</slot>

View File

@@ -25,11 +25,12 @@ export interface TabsItem {
/** A unique value for the tab item. Defaults to the index. */
value?: string | number
disabled?: boolean
[key: string]: any
}
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.
* @defaultValue 'div'
@@ -69,14 +70,14 @@ export interface TabsProps<T> extends Pick<TabsRootProps<string | number>, 'defa
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>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
} & DynamicSlots<T, undefined, { index: number }>
</script>
@@ -129,7 +130,7 @@ const ui = computed(() => tabs({
<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 })">
<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 }}
</slot>
</TabsContent>

View File

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

View File

@@ -6,7 +6,14 @@ import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/tree'
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> } }
@@ -31,9 +38,10 @@ export type TreeItem = {
children?: TreeItem[]
onToggle?(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.
* @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.
* @defaultValue 'value'
*/
valueKey?: K
valueKey?: VK
/**
* The key used to get the label from the item.
* @defaultValue 'label'
*/
labelKey?: K
labelKey?: keyof NestedItem<T>
/**
* The icon displayed on the right side of a parent node.
* @defaultValue appConfig.ui.icons.chevronDown
@@ -75,33 +83,34 @@ export interface TreeProps<T extends TreeItem, M extends boolean = false, K exte
* @IconifyIcon
*/
collapsedIcon?: string
items?: T[]
items?: T
/** 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. */
defaultValue?: MaybeMultipleModelValue<T, M>
defaultValue?: GetModelValue<T, VK, M>
/** Whether multiple options can be selected or not. */
multiple?: M & boolean
class?: any
ui?: PartialString<typeof tree.slots>
}
export type TreeEmits<T, M extends boolean = false> = Omit<TreeRootEmits, 'update:modelValue'> & {
'update:modelValue': [payload: MaybeMultipleModelValue<T, M>]
}
export type TreeEmits<A extends TreeItem[], VK extends GetItemKeys<A> | undefined, M extends boolean> = Omit<TreeRootEmits, 'update:modelValue'> & GetModelValueEmits<A, VK, 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-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
} & DynamicSlots<T, undefined, { index: number, level: number, expanded: boolean, selected: boolean }>
</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 type { PropType } from 'vue'
import { TreeRoot, TreeItem, useForwardPropsEmits } from 'reka-ui'
@@ -109,18 +118,21 @@ import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { get } from '../utils'
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,
valueKey: 'value' as never
})
const emits = defineEmits<TreeEmits<T, M>>()
const emits = defineEmits<TreeEmits<T, VK, M>>()
const slots = defineSlots<TreeSlots<T>>()
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: {
items: Array as PropType<T[]>,
items: Array as PropType<NestedItem<T>[]>,
level: Number
}
})
@@ -130,22 +142,24 @@ const ui = computed(() => tree({
size: props.size
}))
function getItemLabel(item: T) {
function getItemLabel(item: NestedItem<T>): 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)
}
function getDefaultOpenedItems(item: T): string[] {
function getDefaultOpenedItems(item: NestedItem<T>): string[] {
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[]
}
const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.flatMap(getDefaultOpenedItems))
const defaultExpanded = computed(() =>
props.defaultExpanded ?? props.items?.flatMap(item => getDefaultOpenedItems(item as NestedItem<T>))
)
</script>
<!-- eslint-disable vue/no-template-shadow -->
@@ -165,8 +179,8 @@ const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.fla
@select="item.onSelect"
>
<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.slot}-leading`: 'item-leading'" 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') as keyof TreeSlots<T>)" v-bind="{ index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
<UIcon
v-if="item.icon"
:name="item.icon"
@@ -179,14 +193,14 @@ const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.fla
/>
</slot>
<span v-if="getItemLabel(item) || !!slots[item.slot ? `${item.slot}-label`: 'item-label']" :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 }">
<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') as keyof TreeSlots<T>)" v-bind="{ item, index, level, expanded: isExpanded, selected: isSelected }" :item="(item as Extract<NestedItem<T>, { slot: string; }>)">
{{ getItemLabel(item) }}
</slot>
</span>
<span v-if="item.trailingIcon || item.children?.length || !!slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :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 }">
<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') 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-else-if="item.children?.length" :name="trailingIcon ?? appConfig.ui.icons.chevronDown" :class="ui.linkTrailingIcon({ class: props.ui?.linkTrailingIcon })" />
</slot>
@@ -195,19 +209,19 @@ const defaultExpanded = computed(() => props.defaultExpanded ?? props.items?.fla
</button>
<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>
</TreeItem>
</li>
</DefineTreeTemplate>
<TreeRoot
v-bind="rootProps"
v-bind="(rootProps as unknown as TreeRootProps<NestedItem<T>>)"
:class="ui.root({ class: [props.class, props.ui?.root] })"
:get-key="getItemValue"
:default-expanded="defaultExpanded"
:selection-behavior="selectionBehavior"
>
<ReuseTreeTemplate :items="items" :level="0" />
<ReuseTreeTemplate :items="(items as NestedItem<T>[] | undefined)" :level="0" />
</TreeRoot>
</template>

View File

@@ -1,3 +1,4 @@
import type { AcceptableValue as _AcceptableValue } from 'reka-ui'
import type { VNode } from 'vue'
export interface TightMap<O = any> {
@@ -14,8 +15,19 @@ export type DeepPartial<T, O = any> = {
[key: string]: O | TightMap<O>
}
export type DynamicSlots<T extends { slot?: string }, SlotProps, Slot = T['slot']> =
Record<string, SlotProps> & (Slot extends string ? Record<Slot, SlotProps> : Record<string, never>)
export type DynamicSlots<
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>
? MaybeObject[Key]
@@ -25,18 +37,49 @@ export type PartialString<T> = {
[K in keyof T]?: string
}
export type MaybeArrayOfArray<T> = T[] | T[][]
export type MaybeArrayOfArrayItem<I> = I extends Array<infer T> ? T extends Array<infer U> ? U : T : never
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
export type SelectItemKey<T> = T extends Record<string, any> ? keyof T : string
export type SelectModelValueEmits<T, V, M extends boolean = false, DV = T> = {
'update:modelValue': [payload: SelectModelValue<T, V, M, DV>]
export type AcceptableValue = Exclude<_AcceptableValue, Record<string, any>>
export type ArrayOrNested<T> = T[] | T[][]
export type NestedItem<T> = T extends Array<infer I> ? NestedItem<I> : T
type AllKeys<T> = T extends any ? keyof T : never
type NonCommonKeys<T extends object> = Exclude<AllKeys<T>, keyof T>
type PickTypeOf<T, K extends string | number | symbol> = K extends AllKeys<T>
? T extends { [k in K]?: any }
? T[K]
: 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 =
| string

View File

@@ -81,3 +81,7 @@ export function compare<T>(value?: T, currentValue?: T, comparator?: string | ((
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',
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.',
slot: 'custom'
slot: 'custom' as const
}]
const props = { items }
@@ -57,7 +57,7 @@ describe('Accordion', () => {
['with body slot', { props: { ...props, modelValue: '1' }, slots: { body: () => 'Body 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' } }]
])('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)
expect(html).toMatchSnapshot()
})

View File

@@ -37,7 +37,7 @@ describe('Breadcrumb', () => {
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
['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)
expect(html).toMatchSnapshot()
})

View File

@@ -37,7 +37,7 @@ describe('Carousel', () => {
['with as', { props: { ...props, as: 'nav' } }],
['with class', { props: { ...props, class: 'w-full max-w-xs' } }],
['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)
expect(html).toMatchSnapshot()
})

View File

@@ -21,7 +21,7 @@ describe('Chip', () => {
// Slots
['with default slot', { slots: { default: () => 'Default 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)
expect(html).toMatchSnapshot()
})

View File

@@ -1,8 +1,9 @@
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 theme from '#build/ui/context-menu'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { expectSlotProps } from '../utils/types'
const ContextMenuWrapper = defineComponent({
components: {
@@ -95,11 +96,33 @@ describe('ContextMenu', () => {
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing 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)
await wrapper.find('span').trigger('click.right')
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 ComponentRender from '../component-render'
import theme from '#build/ui/dropdown-menu'
import { expectSlotProps } from '../utils/types'
describe('DropdownMenu', () => {
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-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing 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)
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-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label 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' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputMenuProps<typeof items[number]>, slots?: Partial<InputMenuSlots<typeof items[number], false>> }) => {
['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' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: InputMenuProps, slots?: Partial<InputMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, InputMenu)
expect(html).toMatchSnapshot()
})

View File

@@ -108,7 +108,7 @@ describe('NavigationMenu', () => {
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing 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)
expect(html).toMatchSnapshot()
})

View File

@@ -38,7 +38,7 @@ describe('RadioGroup', () => {
['with legend slot', { props, slots: { label: () => 'Legend slot' } }],
['with label slot', { props, slots: { label: () => 'Label 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)
expect(html).toMatchSnapshot()
})

View File

@@ -78,7 +78,7 @@ describe('Select', () => {
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
['with item-label slot', { props, slots: { 'item-label': () => 'Item label 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)
expect(html).toMatchSnapshot()
})
@@ -192,10 +192,10 @@ describe('Select', () => {
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]]
})).toEqualTypeOf<[string | number]>()
// with groups, mixed types and valueKey = undefined
// with groups, multiple, mixed types and valueKey
expectEmitPayloadType('update:modelValue', () => Select({
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
valueKey: undefined
valueKey: 'value' // TODO: value is already the default valueKey
})).toEqualTypeOf<[string | number]>()
})
})

View File

@@ -81,9 +81,9 @@ describe('SelectMenu', () => {
['with item slot', { props, slots: { item: () => 'Item 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-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' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectMenuProps<typeof items[number]>, slots?: Partial<SelectMenuSlots<typeof items[number], false>> }) => {
['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' } }]
])('renders %s correctly', async (nameOrHtml: string, options: { props?: SelectMenuProps, slots?: Partial<SelectMenuSlots> }) => {
const html = await ComponentRender(nameOrHtml, options, SelectMenu)
expect(html).toMatchSnapshot()
})

View File

@@ -43,7 +43,7 @@ describe('Stepper', () => {
['with description slot', { props, slots: { description: () => 'Description slot' } }],
['with content slot', { props, slots: { content: () => 'Content 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)
expect(html).toMatchSnapshot()
})

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