mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-16 04:58:12 +01:00
Compare commits
62 Commits
feat/init-
...
pr/1945
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02015e5803 | ||
|
|
62ab01655c | ||
|
|
42b6c96df2 | ||
|
|
5989229744 | ||
|
|
fe1cf02c51 | ||
|
|
66b08fdf82 | ||
|
|
0da34cd8e5 | ||
|
|
f33660035f | ||
|
|
891d66cdb1 | ||
|
|
657ec228b5 | ||
|
|
90660c97c3 | ||
|
|
e9d515cb85 | ||
|
|
20a1240015 | ||
|
|
f32cfeef9e | ||
|
|
6b6ec8cb2c | ||
|
|
1d692fb524 | ||
|
|
3e836dfe4d | ||
|
|
e2695ee7e4 | ||
|
|
8dca270965 | ||
|
|
bfa6460613 | ||
|
|
91a3f311b1 | ||
|
|
c577df7eb1 | ||
|
|
cad7c45c08 | ||
|
|
a2b84d7f62 | ||
|
|
5db3b0f98c | ||
|
|
6ca7c8b7bf | ||
|
|
bb99345f5b | ||
|
|
c64c4cdea0 | ||
|
|
d12160927e | ||
|
|
8b42365bf4 | ||
|
|
cb160e6971 | ||
|
|
4d4234d2f8 | ||
|
|
6f38d3ea8a | ||
|
|
1b14b5dcd9 | ||
|
|
01c8f3bf5e | ||
|
|
10450c537d | ||
|
|
7ef19333f0 | ||
|
|
d983af93b3 | ||
|
|
1db21d1b00 | ||
|
|
6f2ce5c610 | ||
|
|
488707e148 | ||
|
|
ef473c3848 | ||
|
|
93dff3264f | ||
|
|
5da9084da3 | ||
|
|
c92f908b8d | ||
|
|
45553dc3fe | ||
|
|
55e06e97e7 | ||
|
|
a813ea700e | ||
|
|
a4d0ca7396 | ||
|
|
5ad7dabbdc | ||
|
|
d8160ba6ef | ||
|
|
fc24e03cc4 | ||
|
|
1902492cf2 | ||
|
|
0c525638d7 | ||
|
|
35f90b9920 | ||
|
|
836f74849b | ||
|
|
78f92a24f8 | ||
|
|
52908c19f1 | ||
|
|
513cca25f6 | ||
|
|
c1427a3264 | ||
|
|
6519a74de4 | ||
|
|
da05c37ffe |
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -33,5 +33,5 @@ jobs:
|
||||
Thank you for your understanding and support!
|
||||
|
||||
— Nuxt UI Team
|
||||
exempt-issue-labels: 'feature,announcement'
|
||||
operations-per-run: 300
|
||||
exempt-issue-labels: 'feature,announcement,release,reka-ui,upstream'
|
||||
operations-per-run: 300
|
||||
|
||||
@@ -35,7 +35,7 @@ ${pro ? `import type { ComponentConfig } from '@nuxt/ui'` : ''}
|
||||
import theme from '#build/${path}/${prose ? 'prose/' : ''}${content ? 'content/' : ''}${kebabName}'
|
||||
${!pro ? `import type { ComponentConfig } from '../types/utils'` : ''}
|
||||
|
||||
type ${upperName} = ComponentConfig<typeof theme, AppConfig, ${upperName}${pro ? `, '${key}'` : ''}>
|
||||
type ${upperName} = ComponentConfig<typeof theme, AppConfig, '${camelName}'${pro ? `, '${key}'` : ''}>
|
||||
|
||||
export interface ${upperName}Props {
|
||||
/**
|
||||
@@ -80,7 +80,7 @@ ${pro ? `import type { ComponentConfig } from '@nuxt/ui'` : ''}
|
||||
import theme from '#build/${path}/${prose ? 'prose/' : ''}${content ? 'content/' : ''}${kebabName}'
|
||||
${!pro ? `import type { ComponentConfig } from '../types/utils'` : ''}
|
||||
|
||||
type ${upperName} = ComponentConfig<typeof theme, AppConfig, ${upperName}${pro ? `, '${key}'` : ''}>
|
||||
type ${upperName} = ComponentConfig<typeof theme, AppConfig, '${camelName}'${pro ? `, '${key}'` : ''}>
|
||||
|
||||
export interface ${upperName}Props extends Pick<${upperName}RootProps> {
|
||||
class?: any
|
||||
|
||||
@@ -53,7 +53,7 @@ provide('navigation', mappedNavigation)
|
||||
<NuxtLoadingIndicator color="var(--ui-primary)" :height="2" />
|
||||
|
||||
<template v-if="!route.path.startsWith('/examples')">
|
||||
<!-- <Banner /> -->
|
||||
<Banner />
|
||||
|
||||
<Header :links="links" />
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<UBanner
|
||||
id="ui3-launch"
|
||||
title="Nuxt UI v3 is officially released!"
|
||||
icon="i-lucide-rocket"
|
||||
:actions="[
|
||||
{
|
||||
label: 'Discover Nuxt UI Pro',
|
||||
to: '/pro/pricing',
|
||||
trailingIcon: 'i-lucide-arrow-right'
|
||||
}
|
||||
]"
|
||||
id="nuxtlabs-join-vercel"
|
||||
title="NuxtLabs is joining Vercel"
|
||||
icon="i-simple-icons-vercel"
|
||||
to="https://nuxtlabs.com/?utm_source=nuxt-ui&utm_medium=banner&utm_campaign=nuxtlabs-vercel"
|
||||
target="_blank"
|
||||
close
|
||||
:actions="[{
|
||||
label: 'Read the announcement',
|
||||
color: 'neutral',
|
||||
variant: 'outline',
|
||||
trailingIcon: 'i-lucide-arrow-right',
|
||||
to: 'https://nuxtlabs.com/?utm_source=nuxt-ui&utm_medium=banner&utm_campaign=nuxtlabs-vercel',
|
||||
target: '_blank',
|
||||
class: 'ring-0'
|
||||
}]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -34,7 +34,7 @@ const meta = await fetchComponentMeta(name as any)
|
||||
</ProseCode>
|
||||
</ProseTd>
|
||||
<ProseTd>
|
||||
<HighlightInlineType v-if="slot.type" :type="slot.type" />
|
||||
<HighlightInlineType v-if="slot.type" :type="slot.type.replace(/ui:\s*\{[^}]*\}/g, 'ui: {}')" />
|
||||
|
||||
<MDC v-if="slot.description" :value="slot.description" class="text-toned mt-1" :cache-key="`${kebabCase(route.path)}-${slot.name}-description`" />
|
||||
</ProseTd>
|
||||
|
||||
@@ -14,8 +14,8 @@ const items = [
|
||||
v-slot="{ item }"
|
||||
orientation="vertical"
|
||||
:items="items"
|
||||
class="w-full max-w-xs mx-auto"
|
||||
:ui="{ container: 'h-[336px]' }"
|
||||
class="w-full max-w-xs mx-auto"
|
||||
>
|
||||
<img :src="item" width="320" height="320" class="rounded-lg">
|
||||
</UCarousel>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<UDrawer :ui="{ content: 'h-full', overlay: 'bg-inverted/30' }">
|
||||
<UButton label="Open" color="neutral" variant="subtle" trailing-icon="i-lucide-chevron-up" />
|
||||
|
||||
<template #footer>
|
||||
<UDrawer nested :ui="{ content: 'h-full', overlay: 'bg-inverted/30' }">
|
||||
<UButton color="neutral" variant="outline" label="Open nested" />
|
||||
|
||||
<template #content>
|
||||
<Placeholder class="flex-1 m-4" />
|
||||
</template>
|
||||
</UDrawer>
|
||||
</template>
|
||||
</UDrawer>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const { data: users } = await useFetch('https://jsonplaceholder.typicode.com/users', {
|
||||
key: 'typicode-users-email',
|
||||
transform: (data: { id: number, name: string, email: string }[]) => {
|
||||
return data?.map(user => ({
|
||||
label: user.name,
|
||||
value: String(user.id),
|
||||
email: user.email,
|
||||
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
|
||||
}))
|
||||
},
|
||||
lazy: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UInputMenu
|
||||
:items="users"
|
||||
icon="i-lucide-user"
|
||||
placeholder="Select user"
|
||||
:ui="{ content: 'min-w-fit' }"
|
||||
>
|
||||
<template #item-label="{ item }">
|
||||
{{ item.label }}
|
||||
|
||||
<span class="text-muted">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</template>
|
||||
</UInputMenu>
|
||||
</template>
|
||||
@@ -35,6 +35,7 @@ const items = ref([
|
||||
}
|
||||
}
|
||||
] satisfies InputMenuItem[])
|
||||
|
||||
const value = ref(items.value[0])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ const text = computed(() => {
|
||||
placeholder="Password"
|
||||
:color="color"
|
||||
:type="show ? 'text' : 'password'"
|
||||
:ui="{ trailing: 'pe-1' }"
|
||||
:aria-invalid="score < 4"
|
||||
aria-describedby="password-strength"
|
||||
:ui="{ trailing: 'pe-1' }"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
|
||||
@@ -24,3 +24,10 @@ const password = ref('')
|
||||
</template>
|
||||
</UInput>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Hide the password reveal button in Edge */
|
||||
::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,13 +62,13 @@ const items = [
|
||||
<template>
|
||||
<UNavigationMenu
|
||||
:items="items"
|
||||
class="w-full justify-center"
|
||||
:ui="{
|
||||
viewport: 'sm:w-(--reka-navigation-menu-viewport-width)',
|
||||
content: 'sm:w-auto',
|
||||
childList: 'sm:w-96',
|
||||
childLinkDescription: 'text-balance line-clamp-2'
|
||||
}"
|
||||
class="w-full justify-center"
|
||||
>
|
||||
<template #docs-content="{ item }">
|
||||
<ul class="grid gap-2 p-4 lg:w-[500px] lg:grid-cols-[minmax(0,.75fr)_minmax(0,1fr)]">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const { data: users } = await useFetch('https://jsonplaceholder.typicode.com/users', {
|
||||
key: 'typicode-users-email',
|
||||
transform: (data: { id: number, name: string, email: string }[]) => {
|
||||
return data?.map(user => ({
|
||||
label: user.name,
|
||||
value: String(user.id),
|
||||
email: user.email,
|
||||
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
|
||||
}))
|
||||
},
|
||||
lazy: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
:items="users"
|
||||
icon="i-lucide-user"
|
||||
placeholder="Select user"
|
||||
:ui="{ content: 'min-w-fit' }"
|
||||
class="w-48"
|
||||
>
|
||||
<template #item-label="{ item }">
|
||||
{{ item.label }}
|
||||
|
||||
<span class="text-muted">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -35,6 +35,7 @@ const items = ref([
|
||||
}
|
||||
}
|
||||
] satisfies SelectMenuItem[])
|
||||
|
||||
const value = ref(items.value[0])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const items = ref([
|
||||
}
|
||||
}
|
||||
] satisfies SelectMenuItem[])
|
||||
|
||||
const value = ref(items.value[0])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const items = ref([
|
||||
icon: 'i-lucide-circle-check'
|
||||
}
|
||||
] satisfies SelectMenuItem[])
|
||||
|
||||
const value = ref(items.value[0])
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const value = ref<string>()
|
||||
|
||||
const { data: users } = await useFetch('https://jsonplaceholder.typicode.com/users', {
|
||||
key: 'typicode-users-email',
|
||||
transform: (data: { id: number, name: string, email: string }[]) => {
|
||||
return data?.map(user => ({
|
||||
label: user.name,
|
||||
email: user.email,
|
||||
value: String(user.id),
|
||||
avatar: { src: `https://i.pravatar.cc/120?img=${user.id}` }
|
||||
}))
|
||||
},
|
||||
lazy: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelect
|
||||
v-model="value"
|
||||
:items="users"
|
||||
placeholder="Select user"
|
||||
value-key="value"
|
||||
:ui="{ content: 'min-w-fit' }"
|
||||
class="w-48"
|
||||
>
|
||||
<template #item-label="{ item }">
|
||||
{{ item.label }}
|
||||
|
||||
<span class="text-muted">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</template>
|
||||
</USelect>
|
||||
</template>
|
||||
@@ -24,8 +24,8 @@ function getUserAvatar(value: string) {
|
||||
:loading="status === 'pending'"
|
||||
icon="i-lucide-user"
|
||||
placeholder="Select user"
|
||||
class="w-48"
|
||||
value-key="value"
|
||||
class="w-48"
|
||||
>
|
||||
<template #leading="{ modelValue, ui }">
|
||||
<UAvatar
|
||||
|
||||
@@ -35,6 +35,7 @@ const items = ref([
|
||||
}
|
||||
}
|
||||
] satisfies SelectItem[])
|
||||
|
||||
const value = ref(items.value[0]?.value)
|
||||
|
||||
const avatar = computed(() => items.value.find(item => item.value === value.value)?.avatar)
|
||||
|
||||
@@ -23,6 +23,7 @@ const items = ref([
|
||||
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)
|
||||
|
||||
@@ -26,7 +26,7 @@ const state = reactive({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTabs :items="items" variant="link" class="gap-4 w-full" :ui="{ trigger: 'grow' }">
|
||||
<UTabs :items="items" variant="link" :ui="{ trigger: 'grow' }" class="gap-4 w-full">
|
||||
<template #account="{ item }">
|
||||
<p class="text-muted mb-4">
|
||||
{{ item.description }}
|
||||
|
||||
@@ -27,8 +27,8 @@ const items: TimelineItem[] = [{
|
||||
<template>
|
||||
<UTimeline
|
||||
:items="items"
|
||||
:ui="{ item: 'even:flex-row-reverse even:-translate-x-[calc(100%-2rem)] even:text-right' }"
|
||||
:default-value="2"
|
||||
:ui="{ item: 'even:flex-row-reverse even:-translate-x-[calc(100%-2rem)] even:text-right' }"
|
||||
class="translate-x-[calc(50%-1rem)]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -42,11 +42,11 @@ const items = [{
|
||||
<UTimeline
|
||||
:items="items"
|
||||
size="xs"
|
||||
class="w-96"
|
||||
:ui="{
|
||||
date: 'float-end ms-1',
|
||||
description: 'px-3 py-2 ring ring-default mt-2 rounded-md text-default'
|
||||
}"
|
||||
class="w-96"
|
||||
>
|
||||
<template #title="{ item }">
|
||||
<span>{{ item.username }}</span>
|
||||
|
||||
@@ -7,12 +7,12 @@ const appConfig = useAppConfig()
|
||||
<UFormField
|
||||
label="toaster.duration"
|
||||
size="sm"
|
||||
class="inline-flex ring ring-accented rounded-sm"
|
||||
:ui="{
|
||||
wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
|
||||
label: 'text-muted px-2 py-1.5',
|
||||
container: 'mt-0'
|
||||
}"
|
||||
class="inline-flex ring ring-accented rounded-sm"
|
||||
>
|
||||
<UInput
|
||||
v-model="appConfig.toaster.duration"
|
||||
|
||||
@@ -7,12 +7,12 @@ const appConfig = useAppConfig()
|
||||
<UFormField
|
||||
label="toaster.expand"
|
||||
size="sm"
|
||||
class="inline-flex ring ring-accented rounded-sm"
|
||||
:ui="{
|
||||
wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
|
||||
label: 'text-muted px-2 py-1.5',
|
||||
container: 'mt-0'
|
||||
}"
|
||||
class="inline-flex ring ring-accented rounded-sm"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="appConfig.toaster.expand"
|
||||
|
||||
@@ -10,12 +10,12 @@ const appConfig = useAppConfig()
|
||||
<UFormField
|
||||
label="toaster.position"
|
||||
size="sm"
|
||||
class="inline-flex ring ring-accented rounded-sm"
|
||||
:ui="{
|
||||
wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
|
||||
label: 'text-muted px-2 py-1.5',
|
||||
container: 'mt-0'
|
||||
}"
|
||||
class="inline-flex ring ring-accented rounded-sm"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="appConfig.toaster.position"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { onMounted, watch } from 'vue'
|
||||
import FaviconSvg from 'public/icon.svg?raw'
|
||||
import FaviconSvg from '../../public/icon.svg?raw'
|
||||
|
||||
export function useFaviconFromTheme() {
|
||||
const colorMode = useColorMode()
|
||||
|
||||
@@ -107,10 +107,6 @@ export function useLinks() {
|
||||
to: 'https://github.com/Justineo/tempad-dev-plugin-nuxt-ui',
|
||||
target: '_blank'
|
||||
}]
|
||||
}, {
|
||||
label: 'Blog',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/blog'
|
||||
}, {
|
||||
label: 'Releases',
|
||||
icon: 'i-lucide-rocket',
|
||||
|
||||
@@ -57,10 +57,6 @@ export function useSearchLinks() {
|
||||
description: 'Meet the team behind Nuxt UI.',
|
||||
icon: 'i-lucide-users',
|
||||
to: '/team'
|
||||
}, {
|
||||
label: 'Blog',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/blog'
|
||||
}, {
|
||||
label: 'Releases',
|
||||
icon: 'i-lucide-rocket',
|
||||
|
||||
@@ -59,7 +59,7 @@ provide('navigation', mappedNavigation)
|
||||
<UApp>
|
||||
<NuxtLoadingIndicator color="#FFF" />
|
||||
|
||||
<!-- <Banner /> -->
|
||||
<Banner />
|
||||
|
||||
<Header :links="links" />
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
seo:
|
||||
title: Nuxt UI Blog
|
||||
description: Read the latest news, tutorials, and updates about Nuxt UI.
|
||||
title: Nuxt [UI]{.text-primary} Blog
|
||||
navigation.title: Blog
|
||||
description: Read the latest news, tutorials, and updates about Nuxt UI.
|
||||
navigation.icon: i-lucide-newspaper
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { kebabCase } from 'scule'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const [{ data: page }, { data: surround }] = await Promise.all([
|
||||
useAsyncData(kebabCase(route.path), () => queryCollection('blog').path(route.path).first()),
|
||||
useAsyncData(`${kebabCase(route.path)}-surround`, () => {
|
||||
return queryCollectionItemSurroundings('blog', route.path, {
|
||||
fields: ['description']
|
||||
}).order('date', 'DESC')
|
||||
})
|
||||
])
|
||||
|
||||
if (!page.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const title = page.value.seo?.title || page.value.title
|
||||
const description = page.value.seo?.description || page.value.description
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogDescription: description,
|
||||
ogTitle: title
|
||||
})
|
||||
|
||||
if (page.value.image) {
|
||||
defineOgImage({ url: page.value.image })
|
||||
} else {
|
||||
defineOgImageComponent('Docs', {
|
||||
headline: 'Blog',
|
||||
title,
|
||||
description
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).toUpperCase()
|
||||
}
|
||||
|
||||
const getCategoryVariant = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'solid'
|
||||
case 'tutorial': return 'soft'
|
||||
case 'improvement': return 'soft'
|
||||
default: return 'soft'
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'i-lucide-rocket'
|
||||
case 'tutorial': return 'i-lucide-book-open'
|
||||
case 'improvement': return 'i-lucide-trending-up'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="page" class="min-h-screen">
|
||||
<div class="border-b border-default">
|
||||
<UContainer class="py-4">
|
||||
<ULink to="/blog" class="flex items-center gap-2 text-sm">
|
||||
<UIcon name="i-lucide-chevron-left" class="size-4" />
|
||||
Back to Blog
|
||||
</ULink>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
||||
<div class="py-16 sm:pt-20 pb-10">
|
||||
<UContainer class="max-w-4xl">
|
||||
<div class="text-center space-y-6">
|
||||
<div class="flex items-center justify-center gap-4 text-sm">
|
||||
<UBadge
|
||||
v-if="page.category"
|
||||
:variant="getCategoryVariant(page.category)"
|
||||
size="sm"
|
||||
class="font-mono text-xs gap-2"
|
||||
>
|
||||
<UIcon :name="getCategoryIcon(page.category)" class="size-3" />
|
||||
{{ page.category?.toUpperCase() }}
|
||||
</UBadge>
|
||||
|
||||
<span class="text-muted font-mono text-xs">
|
||||
{{ formatDate(page.date) }}
|
||||
</span>
|
||||
|
||||
<span v-if="page.minRead" class="text-muted font-mono text-xs">
|
||||
{{ page.minRead }} MIN READ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ duration: 0.6 }"
|
||||
>
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-highlighted leading-tight">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
</Motion>
|
||||
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.1, duration: 0.6 }"
|
||||
>
|
||||
<p class="text-lg text-muted max-w-2xl mx-auto leading-relaxed">
|
||||
{{ page.description }}
|
||||
</p>
|
||||
</Motion>
|
||||
|
||||
<Motion
|
||||
v-if="page.authors?.length"
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.2, duration: 0.6 }"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<UAvatarGroup>
|
||||
<ULink
|
||||
v-for="(author, index) in page.authors"
|
||||
:key="index"
|
||||
:to="author.to"
|
||||
raw
|
||||
>
|
||||
<UAvatar v-bind="author.avatar" />
|
||||
</ULink>
|
||||
</UAvatarGroup>
|
||||
</Motion>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
||||
<div v-if="page.image" class="py-4">
|
||||
<UContainer class="max-w-6xl">
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 30 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.3, duration: 0.8 }"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="page.image"
|
||||
:alt="page.title"
|
||||
class="w-full max-h-[400px] object-cover object-center max-w-5xl mx-auto"
|
||||
/>
|
||||
</Motion>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
||||
<div class="py-12 sm:py-16">
|
||||
<UContainer class="max-w-3xl">
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.4, duration: 0.6 }"
|
||||
>
|
||||
<ContentRenderer
|
||||
v-if="page.body"
|
||||
:value="page"
|
||||
/>
|
||||
</Motion>
|
||||
|
||||
<div v-if="surround?.length" class="mt-16 pt-8 border-t border-default">
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.6, duration: 0.6 }"
|
||||
>
|
||||
<UContentSurround :surround="surround" />
|
||||
</Motion>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,255 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-expect-error - yaml import not typed
|
||||
import page from '.blog.yml'
|
||||
|
||||
const { data: posts } = await useAsyncData('blogs', () =>
|
||||
queryCollection('blog').order('date', 'DESC').all()
|
||||
)
|
||||
|
||||
const title = page.seo?.title || page.title
|
||||
const description = page.seo?.description || page.description
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: description
|
||||
})
|
||||
|
||||
const selectedFilter = ref('all')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const availableFilters = computed(() => {
|
||||
if (!posts.value?.length) return [{ key: 'all', label: 'ALL', count: 0 }]
|
||||
|
||||
const postsData = posts.value
|
||||
const categories = new Set(postsData.map(post => post.category?.toLowerCase()).filter(Boolean))
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: 'ALL', count: postsData.length }
|
||||
]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const count = postsData.filter(p => p.category?.toLowerCase() === category).length
|
||||
const label = category.replace(/\b\w/g, l => l.toUpperCase()).replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
|
||||
filters.push({
|
||||
key: category,
|
||||
label: label,
|
||||
count
|
||||
})
|
||||
})
|
||||
|
||||
return filters.sort((a, b) => {
|
||||
if (a.key === 'all') return -1
|
||||
if (b.key === 'all') return 1
|
||||
return b.count - a.count
|
||||
})
|
||||
})
|
||||
|
||||
const filteredPosts = computed(() => {
|
||||
if (!posts.value) return []
|
||||
|
||||
let filtered = posts.value
|
||||
|
||||
if (selectedFilter.value !== 'all') {
|
||||
filtered = filtered.filter(post => post.category?.toLowerCase() === selectedFilter.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(post =>
|
||||
post.title?.toLowerCase().includes(query)
|
||||
|| post.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit'
|
||||
}).toUpperCase()
|
||||
}
|
||||
|
||||
const getCategoryVariant = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'solid'
|
||||
case 'tutorial': return 'soft'
|
||||
case 'improvement': return 'soft'
|
||||
default: return 'soft'
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'i-lucide-rocket'
|
||||
case 'tutorial': return 'i-lucide-book-open'
|
||||
case 'improvement': return 'i-lucide-trending-up'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="page" class="relative grid grid-rows-[auto_auto_1fr] min-h-[calc(100vh-150px)]">
|
||||
<UPageHero :links="page.links" :ui="{ container: 'relative py-10 sm:py-16 lg:py-24' }">
|
||||
<LazyStarsBg />
|
||||
|
||||
<div aria-hidden="true" class="absolute z-[-1] border-x border-default inset-0 mx-4 sm:mx-6 lg:mx-8" />
|
||||
|
||||
<template #title>
|
||||
<MDC :value="page.title" unwrap="p" cache-key="pro-templates-hero-title" />
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<MDC :value="page.description" unwrap="p" cache-key="pro-templates-hero-description" />
|
||||
</template>
|
||||
</UPageHero>
|
||||
|
||||
<UPageBody class="!my-0 !py-0 border-y border-default">
|
||||
<UContainer>
|
||||
<div class="border-x border-default px-4 sm:px-6 py-6 sm:py-8">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6 lg:gap-8 sm:mb-6">
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<Motion
|
||||
v-for="(filter, index) in availableFilters"
|
||||
:key="filter.key"
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: index * 0.1 }"
|
||||
>
|
||||
<UButton
|
||||
:variant="selectedFilter === filter.key ? 'solid' : 'ghost'"
|
||||
:color="selectedFilter === filter.key ? 'primary' : 'neutral'"
|
||||
size="sm"
|
||||
class="font-medium transition-all duration-200 hover:scale-105 focus:scale-100 rounded-none text-xs sm:text-sm"
|
||||
:leading-icon="selectedFilter === filter.key ? 'i-lucide-check' : 'i-lucide-circle'"
|
||||
:label="filter.label"
|
||||
@click="selectedFilter = filter.key"
|
||||
>
|
||||
<template #trailing>
|
||||
<UBadge
|
||||
:variant="selectedFilter === filter.key ? 'solid' : 'soft'"
|
||||
size="xs"
|
||||
>
|
||||
{{ filter.count }}
|
||||
</UBadge>
|
||||
</template>
|
||||
</UButton>
|
||||
</Motion>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
|
||||
<div class="relative">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search posts..."
|
||||
icon="i-lucide-search"
|
||||
class="w-full sm:w-64"
|
||||
:ui="{
|
||||
base: 'rounded-none'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
variant="ghost"
|
||||
class="rounded-none whitespace-nowrap"
|
||||
icon="i-lucide-external-link"
|
||||
label="Follow @nuxt_js on X"
|
||||
to="https://x.com/nuxt_js"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-t border-default !gap-0">
|
||||
<Motion
|
||||
v-for="(post, index) in filteredPosts"
|
||||
:key="post.path"
|
||||
:initial="{ opacity: 0, x: -20 }"
|
||||
:animate="{ opacity: 1, x: 0 }"
|
||||
:transition="{ delay: index * 0.05, type: 'spring', stiffness: 300, damping: 30 }"
|
||||
class="group border-b border-default last:border-b-0"
|
||||
>
|
||||
<ULink
|
||||
:to="post.path"
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between p-4 sm:p-6 hover:bg-muted/30 transition-all duration-200 gap-4 sm:gap-6"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6 flex-1 min-w-0">
|
||||
<div class="text-xs text-muted font-mono shrink-0 sm:min-w-[60px]">
|
||||
{{ formatDate(post.date) }}
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:variant="getCategoryVariant(post.category)"
|
||||
size="sm"
|
||||
class="font-mono text-xs justify-center gap-2 shrink-0 self-start sm:self-center"
|
||||
>
|
||||
<UIcon :name="getCategoryIcon(post.category)" class="size-3" />
|
||||
{{ post.category?.toUpperCase() || 'POST' }}
|
||||
</UBadge>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-highlighted group-hover:text-primary transition-colors duration-200 truncate sm:text-base">
|
||||
{{ post.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted mt-1 line-clamp-2 sm:line-clamp-1">
|
||||
{{ post.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between sm:justify-end gap-3 sm:gap-2 shrink-0">
|
||||
<UAvatarGroup v-if="post.authors?.length" size="sm" class="sm:size-sm">
|
||||
<UAvatar
|
||||
v-for="author in post.authors.slice(0, 3)"
|
||||
:key="author.name"
|
||||
:src="author.avatar?.src"
|
||||
:alt="author.name"
|
||||
size="sm"
|
||||
/>
|
||||
</UAvatarGroup>
|
||||
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 text-muted group-hover:text-highlighted transition-colors duration-200 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</ULink>
|
||||
</Motion>
|
||||
|
||||
<Motion
|
||||
v-if="filteredPosts.length === 0"
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
class="text-center py-12 sm:py-16 px-4 sm:px-6"
|
||||
>
|
||||
<UIcon name="i-lucide-search-x" class="size-10 sm:size-12 text-muted mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium mb-2">
|
||||
No posts found
|
||||
</h3>
|
||||
<p class="text-muted mb-4 text-sm sm:text-base">
|
||||
{{ searchQuery ? `No posts match "${searchQuery}"` : 'No posts in this category yet' }}
|
||||
</p>
|
||||
<UButton
|
||||
v-if="selectedFilter !== 'all' || searchQuery"
|
||||
variant="outline"
|
||||
label="Clear filters"
|
||||
class="rounded-none"
|
||||
@click="selectedFilter = 'all'; searchQuery = ''"
|
||||
/>
|
||||
</Motion>
|
||||
</div>
|
||||
</UContainer>
|
||||
</UPageBody>
|
||||
|
||||
<UContainer class="relative min-h-24">
|
||||
<div aria-hidden="true" class="absolute z-[-1] border-x border-default inset-0 mx-4 sm:mx-6 lg:mx-8" />
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,17 @@ pricing:
|
||||
title: Upgrade to Nuxt UI [Pro]{class="text-primary"}.
|
||||
description: On top of 40+ open source components from Nuxt UI, Pro gives you access to 50+ premium Vue components to create beautiful & responsive Nuxt applications in minutes. It includes all primitives to build landing pages, documentations, blogs, dashboards or entire SaaS products.
|
||||
freePlan:
|
||||
description: "**NuxtLabs is joining Vercel** :tada: As part of this transition, Nuxt UI is becoming even more accessible.<br><br> **In September, we're launching Nuxt UI v4**: a free, open-source library that unifies Nuxt UI and Nuxt UI Pro, offering 100+ components and a complete free Figma Kit for everyone."
|
||||
orientation: horizontal
|
||||
button:
|
||||
label: Read the announcement
|
||||
to: 'https://nuxtlabs.com/?utm_source=nuxt-ui&utm_medium=banner&utm_campaign=nuxtlabs-vercel'
|
||||
target: _blank
|
||||
color: 'neutral'
|
||||
trailingIcon: 'i-lucide-arrow-right'
|
||||
ui:
|
||||
trailingIcon: 'ms-0'
|
||||
devPlan:
|
||||
title: Free in development
|
||||
description: Try Nuxt UI Pro for free in development, no credit card required. Upgrade when ready to deploy.
|
||||
orientation: horizontal
|
||||
@@ -13,6 +24,9 @@ pricing:
|
||||
to: '/getting-started/installation/pro/nuxt'
|
||||
color: 'neutral'
|
||||
variant: 'subtle'
|
||||
trailingIcon: 'i-lucide-arrow-right'
|
||||
ui:
|
||||
trailingIcon: 'ms-0'
|
||||
figma:
|
||||
title: Figma Kit Pro
|
||||
description: Get all Nuxt UI Pro components in a Figma kit to design your next application before coding. Everything you need, from wire-framing to high-fidelity web integration.
|
||||
|
||||
@@ -34,10 +34,19 @@ useSeoMeta({
|
||||
<div class="flex flex-col bg-default gap-8 lg:gap-0">
|
||||
<UPricingPlan
|
||||
v-bind="page.pricing.freePlan"
|
||||
variant="naked"
|
||||
class="lg:rounded-none border-x border-default border-t border-b lg:border-b-0"
|
||||
class="lg:rounded-none ring-primary/15 ring-inset -mb-px bg-primary/5 z-[1]"
|
||||
:ui="{ description: 'mt-0 text-primary' }"
|
||||
>
|
||||
<template #description>
|
||||
<MDC :value="page.pricing.freePlan.description" unwrap="p" />
|
||||
</template>
|
||||
</UPricingPlan>
|
||||
|
||||
<UPricingPlan
|
||||
v-bind="page.pricing.devPlan"
|
||||
class="lg:rounded-none ring-inset -mb-px"
|
||||
/>
|
||||
<UPricingPlans compact>
|
||||
<UPricingPlans compact class="-space-x-px">
|
||||
<UPricingPlan
|
||||
v-for="(plan, index) in page.pricing.plans"
|
||||
:key="index"
|
||||
@@ -47,18 +56,17 @@ useSeoMeta({
|
||||
:discount="plan.discount"
|
||||
:billing-period="plan.billing_period"
|
||||
:billing-cycle="plan.billing_cycle"
|
||||
:variant="plan.highlight ? 'soft' : 'outline'"
|
||||
:class="['lg:rounded-none', { 'border-2 lg:border lg:border-x-0 border-primary lg:border-default': plan.highlight }]"
|
||||
:variant="plan.highlight ? 'subtle' : 'outline'"
|
||||
class="lg:rounded-none ring-inset -mb-px"
|
||||
:features="plan.features"
|
||||
:button="plan.button"
|
||||
/>
|
||||
</UPricingPlans>
|
||||
<UPricingPlan
|
||||
v-bind="page.pricing.figma"
|
||||
variant="naked"
|
||||
:billing-period="page.pricing.figma.billing_period"
|
||||
:billing-cycle="page.pricing.figma.billing_cycle"
|
||||
class="lg:rounded-none border lg:border-y-0 border-default"
|
||||
class="lg:rounded-none ring-inset -mb-px"
|
||||
>
|
||||
<template #features>
|
||||
<li v-for="(feature, index) in page.pricing.figma.features" :key="index" class="flex items-center gap-2 min-w-0">
|
||||
|
||||
@@ -13,22 +13,6 @@ const Button = z.object({
|
||||
target: z.enum(['_blank', '_self']).optional()
|
||||
})
|
||||
|
||||
const Image = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional()
|
||||
})
|
||||
|
||||
const Author = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
twitter: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
avatar: Image.optional()
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
category: z.enum(['layout', 'form', 'element', 'navigation', 'data', 'overlay']).optional(),
|
||||
framework: z.string().optional(),
|
||||
@@ -91,18 +75,5 @@ export const collections = {
|
||||
})
|
||||
}))
|
||||
})
|
||||
}),
|
||||
blog: defineCollection({
|
||||
type: 'page',
|
||||
source: 'blog/*',
|
||||
schema: z.object({
|
||||
image: z.string().editor({ input: 'media' }),
|
||||
authors: z.array(Author),
|
||||
date: z.string().date(),
|
||||
minRead: z.number(),
|
||||
draft: z.boolean().optional(),
|
||||
category: z.enum(['Release', 'Tutorial', 'Announcement', 'Article']),
|
||||
tags: z.array(z.string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,6 +225,27 @@ export default defineNuxtConfig({
|
||||
This option adds the `transition-colors` class on components with hover or active states.
|
||||
::
|
||||
|
||||
### `theme.defaultVariants` :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
Use the `theme.defaultVariants` option to override the default `color` and `size` variants for components.
|
||||
|
||||
- Default: `{ color: 'primary', size: 'md' }`{lang="ts-type"}
|
||||
|
||||
```ts [nuxt.config.ts]
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxt/ui'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
ui: {
|
||||
theme: {
|
||||
defaultVariants: {
|
||||
color: 'neutral',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Continuous Releases
|
||||
|
||||
Nuxt UI uses [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) for continuous preview releases, providing developers with instant access to the latest features and bug fixes without waiting for official releases.
|
||||
|
||||
@@ -333,6 +333,32 @@ export default defineConfig({
|
||||
This option adds the `transition-colors` class on components with hover or active states.
|
||||
::
|
||||
|
||||
### `theme.defaultVariants` :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
Use the `theme.defaultVariants` option to override the default `color` and `size` variants for components.
|
||||
|
||||
- Default: `{ color: 'primary', size: 'md' }`{lang="ts-type"}
|
||||
|
||||
```ts [vite.config.ts]
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
ui({
|
||||
theme: {
|
||||
defaultVariants: {
|
||||
color: 'neutral',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### `inertia`
|
||||
|
||||
Use the `inertia` option to enable compatibility with [Inertia.js](https://inertiajs.com/).
|
||||
|
||||
@@ -536,6 +536,33 @@ import { ModalExampleComponent } from '#components'
|
||||
</script>
|
||||
```
|
||||
|
||||
### Changed form validation
|
||||
|
||||
- The error object property for targeting form fields has been renamed from `path` to `name`:
|
||||
|
||||
```diff
|
||||
<script setup lang="ts">
|
||||
const validate = (state: any): FormError[] => {
|
||||
const errors = []
|
||||
if (!state.email) {
|
||||
errors.push({
|
||||
- path: 'email',
|
||||
+ name: 'email',
|
||||
message: 'Required'
|
||||
})
|
||||
}
|
||||
if (!state.password) {
|
||||
errors.push({
|
||||
- path: 'password',
|
||||
+ name: 'password',
|
||||
message: 'Required'
|
||||
})
|
||||
}
|
||||
return errors
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
::warning
|
||||
|
||||
@@ -87,7 +87,7 @@ Read more about this in the `@nuxt/icon` documentation.
|
||||
|
||||
You can use local SVG files to create a custom Iconify collection.
|
||||
|
||||
For example, place your icons' SVG files under a folder of your choice, for example, `./assets/icons`:
|
||||
For example, place your icons' SVG files under a folder of your choice, for example, `./app/assets/icons`:
|
||||
|
||||
```bash
|
||||
assets/icons
|
||||
@@ -104,7 +104,7 @@ export default defineNuxtConfig({
|
||||
icon: {
|
||||
customCollections: [{
|
||||
prefix: 'custom',
|
||||
dir: './assets/icons'
|
||||
dir: './app/assets/icons'
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -328,6 +328,17 @@ name: 'drawer-responsive-example'
|
||||
---
|
||||
::
|
||||
|
||||
### Nested drawers :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
You can nest drawers within each other by using the `nested` prop.
|
||||
|
||||
::component-example
|
||||
---
|
||||
prettier: true
|
||||
name: 'drawer-nested-example'
|
||||
---
|
||||
::
|
||||
|
||||
### With footer slot
|
||||
|
||||
Use the `#footer` slot to add content after the Drawer's body.
|
||||
|
||||
31
docs/content/3.components/file-upload.md
Normal file
31
docs/content/3.components/file-upload.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: FileUpload
|
||||
description: ''
|
||||
links:
|
||||
- label: GitHub
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/FileUpload.vue
|
||||
navigation.badge: Soon
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
## Examples
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
:component-props
|
||||
|
||||
### Slots
|
||||
|
||||
:component-slots
|
||||
|
||||
### Emits
|
||||
|
||||
:component-emits
|
||||
|
||||
## Theme
|
||||
|
||||
:component-theme
|
||||
@@ -757,6 +757,33 @@ name: 'input-menu-filter-fields-example'
|
||||
---
|
||||
::
|
||||
|
||||
### With full content width
|
||||
|
||||
You can expand the content to the full width of its items by using the `ui.content` key.
|
||||
|
||||
::component-example
|
||||
---
|
||||
name: 'input-menu-content-width-example'
|
||||
collapse: true
|
||||
---
|
||||
::
|
||||
|
||||
::tip
|
||||
You can also change the content width globally in your `app.config.ts`:
|
||||
|
||||
```
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
inputMenu: {
|
||||
slots: {
|
||||
content: 'min-w-fit'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
::
|
||||
|
||||
### As a CountryPicker
|
||||
|
||||
This example demonstrates using the InputMenu as a country picker with lazy loading - countries are only fetched when the menu is opened.
|
||||
|
||||
@@ -62,6 +62,19 @@ items:
|
||||
---
|
||||
::
|
||||
|
||||
### Color :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
Use the `color` prop to change the color of the Kbd.
|
||||
|
||||
::component-code
|
||||
---
|
||||
props:
|
||||
color: neutral
|
||||
slots:
|
||||
default: K
|
||||
---
|
||||
::
|
||||
|
||||
### Variant
|
||||
|
||||
Use the `variant` prop to change the variant of the Kbd.
|
||||
@@ -69,6 +82,7 @@ Use the `variant` prop to change the variant of the Kbd.
|
||||
::component-code
|
||||
---
|
||||
props:
|
||||
color: neutral
|
||||
variant: solid
|
||||
slots:
|
||||
default: K
|
||||
|
||||
@@ -790,6 +790,33 @@ name: 'select-menu-filter-fields-example'
|
||||
---
|
||||
::
|
||||
|
||||
### With full content width
|
||||
|
||||
You can expand the content to the full width of its items by using the `ui.content` key.
|
||||
|
||||
::component-example
|
||||
---
|
||||
name: 'select-menu-content-width-example'
|
||||
collapse: true
|
||||
---
|
||||
::
|
||||
|
||||
::tip
|
||||
You can also change the content width globally in your `app.config.ts`:
|
||||
|
||||
```
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
selectMenu: {
|
||||
slots: {
|
||||
content: 'min-w-fit'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
::
|
||||
|
||||
### As a CountryPicker
|
||||
|
||||
This example demonstrates using the SelectMenu as a country picker with lazy loading - countries are only fetched when the menu is opened.
|
||||
@@ -801,6 +828,8 @@ name: 'select-menu-countries-example'
|
||||
---
|
||||
::
|
||||
|
||||
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
@@ -695,6 +695,33 @@ collapse: true
|
||||
---
|
||||
::
|
||||
|
||||
### With full content width
|
||||
|
||||
You can expand the content to the full width of its items by using the `ui.content` key.
|
||||
|
||||
::component-example
|
||||
---
|
||||
name: 'select-content-width-example'
|
||||
collapse: true
|
||||
---
|
||||
::
|
||||
|
||||
::tip
|
||||
You can also change the content width globally in your `app.config.ts`:
|
||||
|
||||
```
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
select: {
|
||||
slots: {
|
||||
content: 'min-w-fit'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
::
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
@@ -83,6 +83,9 @@ Use the `columns` prop as an array of [ColumnDef](https://tanstack.com/table/lat
|
||||
- `class`:
|
||||
- `td`: [The classes to apply to the `td` element.]{class="text-muted"}
|
||||
- `th`: [The classes to apply to the `th` element.]{class="text-muted"}
|
||||
- `style`:
|
||||
- `td`: [The style to apply to the `td` element.]{class="text-muted"}
|
||||
- `th`: [The style to apply to the `th` element.]{class="text-muted"}
|
||||
|
||||
In order to render components or other HTML elements, you will need to use the Vue [`h` function](https://vuejs.org/api/render-function.html#h) inside the `header` and `cell` props. This is different from other components that use slots but allows for more flexibility.
|
||||
|
||||
@@ -112,6 +115,8 @@ Use the `meta` prop as an object ([TableMeta](https://tanstack.com/table/latest/
|
||||
|
||||
- `class`:
|
||||
- `tr`: [The classes to apply to the `tr` element.]{class="text-muted"}
|
||||
- `style`:
|
||||
- `tr`: [The style to apply to the `tr` element.]{class="text-muted"}
|
||||
|
||||
### Loading
|
||||
|
||||
|
||||
@@ -19,12 +19,13 @@ Use the `items` prop as an array of objects with the following properties:
|
||||
- `label?: string`{lang="ts-type"}
|
||||
- `icon?: string`{lang="ts-type"}
|
||||
- `avatar?: AvatarProps`{lang="ts-type"}
|
||||
- `badge?: string | number | BadgeProps`{lang="ts-type"}
|
||||
- `content?: string`{lang="ts-type"}
|
||||
- `value?: string | number`{lang="ts-type"}
|
||||
- `disabled?: boolean`{lang="ts-type"}
|
||||
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
|
||||
- `class?: any`{lang="ts-type"}
|
||||
- `ui?: { trigger?: ClassNameValue, leadingIcon?: ClassNameValue, leadingAvatar?: ClassNameValue, label?: ClassNameValue, content?: ClassNameValue }`{lang="ts-type"}
|
||||
- `ui?: { trigger?: ClassNameValue, leadingIcon?: ClassNameValue, leadingAvatar?: ClassNameValue, leadingAvatarSize?: ClassNameValue, label?: ClassNameValue, trailingBadge?: ClassNameValue, trailingBadgeSize?: ClassNameValue, content?: ClassNameValue }`{lang="ts-type"}
|
||||
|
||||
::component-code
|
||||
---
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
---
|
||||
title: Nuxt UI v3
|
||||
description: Nuxt UI v3 is out! After 1500+ commits, this major redesign brings
|
||||
improved accessibility, Tailwind CSS v4 support, and full Vue compatibility
|
||||
navigation: false
|
||||
image: /assets/blog/nuxt-ui-v3.png
|
||||
minRead: 7
|
||||
authors:
|
||||
- name: Benjamin Canac
|
||||
avatar:
|
||||
src: https://github.com/benjamincanac.png
|
||||
to: https://x.com/benjamincanac
|
||||
- name: Sébastien Chopin
|
||||
avatar:
|
||||
src: https://github.com/atinux.png
|
||||
to: https://x.com/atinux
|
||||
- name: Hugo Richard
|
||||
avatar:
|
||||
src: https://github.com/hugorcd.png
|
||||
to: https://x.com/hugorcd__
|
||||
date: 2025-03-12T10:00:00.000Z
|
||||
category: Release
|
||||
---
|
||||
|
||||
We are thrilled to announce the release of Nuxt UI v3, a complete redesign of our UI library that brings significant improvements in accessibility, performance, and developer experience. This major update represents over 1500 commits of hard work, collaboration, and innovation from our team and the community.
|
||||
|
||||
## 🚀 Reimagined from the Ground Up
|
||||
|
||||
Nuxt UI v3 represents a major leap forward in our journey to provide the most comprehensive UI solution for Vue and Nuxt developers. This version has been rebuilt from the ground up with modern technologies and best practices in mind.
|
||||
|
||||
### **From HeadlessUI to Reka UI**
|
||||
|
||||
With Reka UI at its core, Nuxt UI v3 delivers:
|
||||
|
||||
• Proper keyboard navigation across all interactive components
|
||||
|
||||
• ARIA attributes automatically handled for you
|
||||
|
||||
• Focus management that just works
|
||||
|
||||
• Screen reader friendly components out of the box
|
||||
|
||||
This means you can build applications that work for everyone without becoming an accessibility expert.
|
||||
|
||||
### **Tailwind CSS v4 Integration**
|
||||
|
||||
The integration with Tailwind CSS v4 brings huge performance improvements:
|
||||
|
||||
• **5x faster runtime** with optimized component rendering
|
||||
|
||||
• **100x faster build times** thanks to the new CSS-first engine
|
||||
|
||||
• Smaller bundle sizes with more efficient styling
|
||||
|
||||
Your applications will feel snappier, build quicker, and load faster for your users.
|
||||
|
||||
## 🎨 A Brand New Design System
|
||||
|
||||
```html
|
||||
<!-- Before: Inconsistent color usage with duplicate dark mode classes -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h2 class="text-gray-900 dark:text-white text-xl mb-2">User Profile</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">Account settings and preferences</p>
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded mt-2">Edit Profile</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- After: Semantic design tokens with automatic dark mode support -->
|
||||
<div class="bg-muted p-4 rounded-lg">
|
||||
<h2 class="text-highlighted text-xl mb-2">User Profile</h2>
|
||||
<p class="text-muted">Account settings and preferences</p>
|
||||
<UButton color="primary" size="sm" class="mt-2">Edit Profile</UButton>
|
||||
</div>
|
||||
```
|
||||
|
||||
Our new color system includes 7 semantic color aliases:
|
||||
|
||||
| Color | Default | Description |
|
||||
|-----------------------------------|----------|--------------------------------------------------|
|
||||
| :code[primary]{.text-primary} | `blue` | Primary color to represent the brand.
|
||||
| :code[secondary]{.text-secondary} | `blue` | Secondary color to complement the primary color.
|
||||
| :code[success]{.text-success} | `green` | Used for success states.
|
||||
| :code[info]{.text-info} | `blue` | Used for informational states.
|
||||
| :code[warning]{.text-warning} | `yellow` | Used for warning states.
|
||||
| :code[error]{.text-error} | `red` | Used for form error validation states. |
|
||||
| `neutral` | `slate` | Neutral color for backgrounds, text, etc. |
|
||||
|
||||
This approach makes your codebase more maintainable and your UI more consistent—especially when working in teams. With these semantic tokens, light and dark mode transitions become effortless, as the system automatically handles the appropriate color values for each theme without requiring duplicate class definitions.
|
||||
|
||||
## 💚 Complete Vue Compatibility
|
||||
|
||||
We're really happy to expand the scope of Nuxt UI beyond the Nuxt framework. With v3, both Nuxt UI and Nuxt UI Pro now work seamlessly in any Vue project, this means you can:
|
||||
|
||||
• Use the same components across all your Vue projects
|
||||
|
||||
• Benefit from Nuxt UI's theming system in any Vue application
|
||||
|
||||
• Enjoy auto-imports and TypeScript support outside of Nuxt
|
||||
|
||||
• Leverage both basic components and advanced Pro components in any Vue project
|
||||
|
||||
```ts [vite.config.ts]
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
ui()
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 📦 Components for Every Need
|
||||
|
||||
With 54 core components, 50 Pro components, and 42 Prose components, Nuxt UI v3 provides solutions for virtually any UI challenge:
|
||||
|
||||
• **Data Display**: Tables, charts, and visualizations that adapt to your data
|
||||
|
||||
• **Navigation**: Menus, tabs, and breadcrumbs that guide users intuitively
|
||||
|
||||
• **Feedback**: Toasts, alerts, and modals that communicate clearly
|
||||
|
||||
• **Forms**: Inputs, selectors, and validation that simplify data collection
|
||||
|
||||
• **Layout**: Grids, containers, and responsive systems that organize content beautifully
|
||||
|
||||
Each component is designed to be both beautiful out of the box and deeply customizable when needed.
|
||||
|
||||
## 🔷 Improved TypeScript Integration
|
||||
|
||||
We've completely revamped our TypeScript integration, with features that make you more productive:
|
||||
|
||||
- Complete type safety with helpful autocompletion
|
||||
- Generic-based components for flexible APIs
|
||||
- Type-safe theming through a clear, consistent API
|
||||
|
||||
```ts
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
button: {
|
||||
// Your IDE will show all available options
|
||||
slots: {
|
||||
base: 'font-bold rounded-lg'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## ⬆️ Upgrading to v3
|
||||
|
||||
We've prepared a comprehensive [migration](https://ui.nuxt.com/getting-started/migration) guide to help you upgrade from v2 to v3. While there are breaking changes due to our complete overhaul, we've worked hard to make the transition as smooth as possible.
|
||||
|
||||
## 🎯 Getting Started
|
||||
|
||||
Whether you're starting a new project or upgrading an existing one, getting started with Nuxt UI v3 is easy:
|
||||
|
||||
```bash
|
||||
# Create a new Nuxt project with Nuxt UI
|
||||
npx nuxi@latest init my-app -t ui
|
||||
```
|
||||
|
||||
::code-group{sync="pm"}
|
||||
```bash [pnpm]
|
||||
pnpm add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [yarn]
|
||||
yarn add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [npm]
|
||||
npm install @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [bun]
|
||||
bun add @nuxt/ui@latest
|
||||
```
|
||||
::
|
||||
|
||||
::warning
|
||||
If you're using **pnpm**, ensure that you either set [`shamefully-hoist=true`](https://pnpm.io/npmrc#shamefully-hoist) in your `.npmrc` file or install `tailwindcss` in your project's root directory.
|
||||
::
|
||||
|
||||
Visit our [documentation](https://ui.nuxt.com/getting-started) to explore all the components and features available in Nuxt UI v3.
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
This release represents thousands of hours of work from our team and the community. We'd like to thank everyone who contributed to making Nuxt UI v3 a reality.
|
||||
|
||||
We're excited to see what you'll build with Nuxt UI v3!
|
||||
@@ -1,198 +0,0 @@
|
||||
---
|
||||
title: Nuxt UI
|
||||
description: Nuxt UI v3 is out! After 1500+ commits, this major redesign brings
|
||||
improved accessibility, Tailwind CSS v4 support, and full Vue compatibility
|
||||
navigation: false
|
||||
image: /assets/blog/nuxt-ui-v3.png
|
||||
minRead: 7
|
||||
authors:
|
||||
- name: Benjamin Canac
|
||||
avatar:
|
||||
src: https://github.com/benjamincanac.png
|
||||
to: https://x.com/benjamincanac
|
||||
- name: Sébastien Chopin
|
||||
avatar:
|
||||
src: https://github.com/atinux.png
|
||||
to: https://x.com/atinux
|
||||
- name: Hugo Richard
|
||||
avatar:
|
||||
src: https://github.com/hugorcd.png
|
||||
to: https://x.com/hugorcd__
|
||||
date: 2025-03-12T09:00:00.000Z
|
||||
category: improvement
|
||||
---
|
||||
|
||||
We are thrilled to announce the release of Nuxt UI v3, a complete redesign of our UI library that brings significant improvements in accessibility, performance, and developer experience. This major update represents over 1500 commits of hard work, collaboration, and innovation from our team and the community.
|
||||
|
||||
## 🚀 Reimagined from the Ground Up
|
||||
|
||||
Nuxt UI v3 represents a major leap forward in our journey to provide the most comprehensive UI solution for Vue and Nuxt developers. This version has been rebuilt from the ground up with modern technologies and best practices in mind.
|
||||
|
||||
### **From HeadlessUI to Reka UI**
|
||||
|
||||
With Reka UI at its core, Nuxt UI v3 delivers:
|
||||
|
||||
• Proper keyboard navigation across all interactive components
|
||||
|
||||
• ARIA attributes automatically handled for you
|
||||
|
||||
• Focus management that just works
|
||||
|
||||
• Screen reader friendly components out of the box
|
||||
|
||||
This means you can build applications that work for everyone without becoming an accessibility expert.
|
||||
|
||||
### **Tailwind CSS v4 Integration**
|
||||
|
||||
The integration with Tailwind CSS v4 brings huge performance improvements:
|
||||
|
||||
• **5x faster runtime** with optimized component rendering
|
||||
|
||||
• **100x faster build times** thanks to the new CSS-first engine
|
||||
|
||||
• Smaller bundle sizes with more efficient styling
|
||||
|
||||
Your applications will feel snappier, build quicker, and load faster for your users.
|
||||
|
||||
## 🎨 A Brand New Design System
|
||||
|
||||
```html
|
||||
<!-- Before: Inconsistent color usage with duplicate dark mode classes -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h2 class="text-gray-900 dark:text-white text-xl mb-2">User Profile</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">Account settings and preferences</p>
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded mt-2">Edit Profile</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- After: Semantic design tokens with automatic dark mode support -->
|
||||
<div class="bg-muted p-4 rounded-lg">
|
||||
<h2 class="text-highlighted text-xl mb-2">User Profile</h2>
|
||||
<p class="text-muted">Account settings and preferences</p>
|
||||
<UButton color="primary" size="sm" class="mt-2">Edit Profile</UButton>
|
||||
</div>
|
||||
```
|
||||
|
||||
Our new color system includes 7 semantic color aliases:
|
||||
|
||||
| Color | Default | Description |
|
||||
|-----------------------------------|----------|--------------------------------------------------|
|
||||
| :code[primary]{.text-primary} | `blue` | Primary color to represent the brand.
|
||||
| :code[secondary]{.text-secondary} | `blue` | Secondary color to complement the primary color.
|
||||
| :code[success]{.text-success} | `green` | Used for success states.
|
||||
| :code[info]{.text-info} | `blue` | Used for informational states.
|
||||
| :code[warning]{.text-warning} | `yellow` | Used for warning states.
|
||||
| :code[error]{.text-error} | `red` | Used for form error validation states. |
|
||||
| `neutral` | `slate` | Neutral color for backgrounds, text, etc. |
|
||||
|
||||
This approach makes your codebase more maintainable and your UI more consistent—especially when working in teams. With these semantic tokens, light and dark mode transitions become effortless, as the system automatically handles the appropriate color values for each theme without requiring duplicate class definitions.
|
||||
|
||||
## 💚 Complete Vue Compatibility
|
||||
|
||||
We're really happy to expand the scope of Nuxt UI beyond the Nuxt framework. With v3, both Nuxt UI and Nuxt UI Pro now work seamlessly in any Vue project, this means you can:
|
||||
|
||||
• Use the same components across all your Vue projects
|
||||
|
||||
• Benefit from Nuxt UI's theming system in any Vue application
|
||||
|
||||
• Enjoy auto-imports and TypeScript support outside of Nuxt
|
||||
|
||||
• Leverage both basic components and advanced Pro components in any Vue project
|
||||
|
||||
```ts [vite.config.ts]
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
ui()
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 📦 Components for Every Need
|
||||
|
||||
With 54 core components, 50 Pro components, and 42 Prose components, Nuxt UI v3 provides solutions for virtually any UI challenge:
|
||||
|
||||
• **Data Display**: Tables, charts, and visualizations that adapt to your data
|
||||
|
||||
• **Navigation**: Menus, tabs, and breadcrumbs that guide users intuitively
|
||||
|
||||
• **Feedback**: Toasts, alerts, and modals that communicate clearly
|
||||
|
||||
• **Forms**: Inputs, selectors, and validation that simplify data collection
|
||||
|
||||
• **Layout**: Grids, containers, and responsive systems that organize content beautifully
|
||||
|
||||
Each component is designed to be both beautiful out of the box and deeply customizable when needed.
|
||||
|
||||
## 🔷 Improved TypeScript Integration
|
||||
|
||||
We've completely revamped our TypeScript integration, with features that make you more productive:
|
||||
|
||||
- Complete type safety with helpful autocompletion
|
||||
- Generic-based components for flexible APIs
|
||||
- Type-safe theming through a clear, consistent API
|
||||
|
||||
```ts
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
button: {
|
||||
// Your IDE will show all available options
|
||||
slots: {
|
||||
base: 'font-bold rounded-lg'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## ⬆️ Upgrading to v3
|
||||
|
||||
We've prepared a comprehensive [migration](https://ui.nuxt.com/getting-started/migration) guide to help you upgrade from v2 to v3. While there are breaking changes due to our complete overhaul, we've worked hard to make the transition as smooth as possible.
|
||||
|
||||
## 🎯 Getting Started
|
||||
|
||||
Whether you're starting a new project or upgrading an existing one, getting started with Nuxt UI v3 is easy:
|
||||
|
||||
```bash
|
||||
# Create a new Nuxt project with Nuxt UI
|
||||
npx nuxi@latest init my-app -t ui
|
||||
```
|
||||
|
||||
::code-group{sync="pm"}
|
||||
```bash [pnpm]
|
||||
pnpm add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [yarn]
|
||||
yarn add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [npm]
|
||||
npm install @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [bun]
|
||||
bun add @nuxt/ui@latest
|
||||
```
|
||||
::
|
||||
|
||||
::warning
|
||||
If you're using **pnpm**, ensure that you either set [`shamefully-hoist=true`](https://pnpm.io/npmrc#shamefully-hoist) in your `.npmrc` file or install `tailwindcss` in your project's root directory.
|
||||
::
|
||||
|
||||
Visit our [documentation](https://ui.nuxt.com/getting-started) to explore all the components and features available in Nuxt UI v3.
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
This release represents thousands of hours of work from our team and the community. We'd like to thank everyone who contributed to making Nuxt UI v3 a reality.
|
||||
|
||||
We're excited to see what you'll build with Nuxt UI v3!
|
||||
@@ -143,10 +143,6 @@ export default defineNuxtConfig({
|
||||
'/releases': { redirect: 'https://github.com/nuxt/ui/releases', prerender: false }
|
||||
},
|
||||
|
||||
future: {
|
||||
compatibilityVersion: 4
|
||||
},
|
||||
|
||||
compatibilityDate: '2024-07-09',
|
||||
|
||||
nitro: {
|
||||
|
||||
@@ -11,26 +11,26 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/vue": "^1.2.12",
|
||||
"@iconify-json/logos": "^1.2.4",
|
||||
"@iconify-json/lucide": "^1.2.56",
|
||||
"@iconify-json/simple-icons": "^1.2.42",
|
||||
"@iconify-json/lucide": "^1.2.57",
|
||||
"@iconify-json/simple-icons": "^1.2.44",
|
||||
"@iconify-json/vscode-icons": "^1.2.23",
|
||||
"@nuxt/content": "^3.6.3",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@22fdc5e",
|
||||
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@17684e4",
|
||||
"@nuxthub/core": "^0.9.0",
|
||||
"@nuxtjs/plausible": "^1.2.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@vueuse/integrations": "^13.5.0",
|
||||
"@vueuse/nuxt": "^13.5.0",
|
||||
"ai": "^4.3.16",
|
||||
"ai": "^4.3.19",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"capture-website": "^4.2.0",
|
||||
"joi": "^17.13.3",
|
||||
"maska": "^3.2.0",
|
||||
"motion-v": "^1.5.0",
|
||||
"nuxt": "^3.17.6",
|
||||
"nuxt": "^4.0.1",
|
||||
"nuxt-component-meta": "^0.12.1",
|
||||
"nuxt-llms": "^0.1.3",
|
||||
"nuxt-og-image": "^5.1.9",
|
||||
@@ -40,11 +40,11 @@
|
||||
"superstruct": "^2.0.2",
|
||||
"ufo": "^1.6.1",
|
||||
"valibot": "^1.1.0",
|
||||
"workers-ai-provider": "^0.7.1",
|
||||
"workers-ai-provider": "^0.7.2",
|
||||
"yup": "^1.6.1",
|
||||
"zod": "^3.25.75"
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.23.0"
|
||||
"wrangler": "^4.25.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 515 KiB |
16
package.json
16
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@nuxt/ui",
|
||||
"description": "A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.",
|
||||
"version": "3.2.0",
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nuxt/ui.git"
|
||||
@@ -116,8 +116,8 @@
|
||||
"@internationalized/number": "^3.6.3",
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/icon": "^1.15.0",
|
||||
"@nuxt/kit": "^3.17.6",
|
||||
"@nuxt/schema": "^3.17.6",
|
||||
"@nuxt/kit": "^4.0.1",
|
||||
"@nuxt/schema": "^4.0.1",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
@@ -155,16 +155,16 @@
|
||||
"vue-component-type-helpers": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^1.5.2",
|
||||
"@nuxt/eslint-config": "^1.6.0",
|
||||
"@nuxt/module-builder": "^1.0.1",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@release-it/conventional-changelog": "^10.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint": "^9.31.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nuxt": "^3.17.6",
|
||||
"release-it": "^19.0.3",
|
||||
"nuxt": "^4.0.1",
|
||||
"release-it": "^19.0.4",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest-environment-nuxt": "^1.0.1",
|
||||
"vue-tsc": "^3.0.1"
|
||||
@@ -177,7 +177,7 @@
|
||||
"valibot": "^1.0.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"yup": "^1.6.0",
|
||||
"zod": "^3.24.0"
|
||||
"zod": "^3.24.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@inertiajs/vue3": {
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.25.75"
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^7.0.5",
|
||||
"vue-tsc": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const components = [
|
||||
'command-palette',
|
||||
'drawer',
|
||||
'dropdown-menu',
|
||||
'file-upload',
|
||||
'form',
|
||||
'form-field',
|
||||
'input',
|
||||
|
||||
@@ -35,6 +35,7 @@ const components = [
|
||||
'command-palette',
|
||||
'drawer',
|
||||
'dropdown-menu',
|
||||
'file-upload',
|
||||
'form',
|
||||
'form-field',
|
||||
'input',
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const colorHex = ref('#9C27B0')
|
||||
|
||||
function handleColorChange(event: Event) {
|
||||
colorHex.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :style="{ backgroundColor: colorHex }" class="inline-flex w-5 h-5 rounded" />
|
||||
<code class="font-mono">{{ colorHex }}</code>
|
||||
<UInput :model-value="colorHex" @change="handleColorChange" />
|
||||
</div>
|
||||
<USeparator />
|
||||
<div class="flex justify-between gap-2">
|
||||
@@ -21,6 +25,6 @@ const colorHex = ref('#9C27B0')
|
||||
</UButton>
|
||||
</div>
|
||||
<USeparator />
|
||||
<UColorPicker v-model="colorHex" @update:model-value="() => console.log('model update')" />
|
||||
<UColorPicker v-model="colorHex" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,6 +28,20 @@ const inset = ref(false)
|
||||
</template>
|
||||
</UDrawer>
|
||||
|
||||
<UDrawer title="Drawer with nested" :inset="inset" :ui="{ content: 'h-full' }" should-scale-background>
|
||||
<UButton color="neutral" variant="outline" label="Open nested" />
|
||||
|
||||
<template #footer>
|
||||
<UDrawer :inset="inset" nested :ui="{ content: 'h-full' }">
|
||||
<UButton color="neutral" variant="outline" label="Open nested" />
|
||||
|
||||
<template #content>
|
||||
<Placeholder class="flex-1 m-4" />
|
||||
</template>
|
||||
</UDrawer>
|
||||
</template>
|
||||
</UDrawer>
|
||||
|
||||
<UDrawer title="Drawer with bottom direction" direction="bottom" :inset="inset">
|
||||
<UButton color="neutral" variant="outline" label="Open on bottom" />
|
||||
|
||||
|
||||
126
playground/app/pages/components/file-upload.vue
Normal file
126
playground/app/pages/components/file-upload.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import * as z from 'zod'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import theme from '#build/ui/file-upload'
|
||||
|
||||
const sizes = Object.keys(theme.variants.size) as Array<keyof typeof theme.variants.size>
|
||||
|
||||
const size = ref<keyof typeof theme.variants.size>('md')
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MIN_DIMENSIONS = { width: 200, height: 200 }
|
||||
const MAX_DIMENSIONS = { width: 4096, height: 4096 }
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
avatar: z
|
||||
.instanceof(File, {
|
||||
message: 'Please select an image file.'
|
||||
})
|
||||
.refine(file => file.size <= MAX_FILE_SIZE, {
|
||||
message: `The image is too large. Please choose an image smaller than ${formatBytes(MAX_FILE_SIZE)}.`
|
||||
})
|
||||
.refine(file => ACCEPTED_IMAGE_TYPES.includes(file.type), {
|
||||
message: 'Please upload a valid image file (JPEG, PNG, or WebP).'
|
||||
})
|
||||
.refine(
|
||||
file =>
|
||||
new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const meetsDimensions
|
||||
= img.width >= MIN_DIMENSIONS.width
|
||||
&& img.height >= MIN_DIMENSIONS.height
|
||||
&& img.width <= MAX_DIMENSIONS.width
|
||||
&& img.height <= MAX_DIMENSIONS.height
|
||||
resolve(meetsDimensions)
|
||||
}
|
||||
img.src = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
{
|
||||
message: `The image dimensions are invalid. Please upload an image between ${MIN_DIMENSIONS.width}x${MIN_DIMENSIONS.height} and ${MAX_DIMENSIONS.width}x${MAX_DIMENSIONS.height} pixels.`
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
type schema = z.output<typeof schema>
|
||||
|
||||
const state = reactive<Partial<schema>>({
|
||||
avatar: undefined
|
||||
})
|
||||
|
||||
const value = ref<File[]>([new File(['foo'], 'file1.txt', { type: 'text/plain' })])
|
||||
|
||||
const upload = useUpload('/api/blob', { method: 'PUT' })
|
||||
|
||||
function createObjectUrl(file: File): string {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<schema>) {
|
||||
const res = await upload(event.data.avatar)
|
||||
|
||||
console.log(res)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<USelect v-model="size" :items="sizes" />
|
||||
</div>
|
||||
|
||||
<UForm :schema="schema" :state="state" class="space-y-4 w-80" @submit="onSubmit">
|
||||
<UFormField name="avatar" label="Avatar" description="JPG, GIF or PNG. 1MB Max." :size="size">
|
||||
<UFileUpload v-slot="{ open, remove }" v-model="state.avatar" accept="image/*">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<UAvatar size="lg" :src="state.avatar ? createObjectUrl(state.avatar) : undefined" icon="i-lucide-image" />
|
||||
|
||||
<UButton :label="state.avatar ? 'Change image' : 'Upload image'" color="neutral" @click="open()" />
|
||||
</div>
|
||||
|
||||
<p v-if="state.avatar" class="text-xs text-muted mt-1.5">
|
||||
{{ state.avatar.name }}
|
||||
|
||||
<UButton
|
||||
label="Remove"
|
||||
color="error"
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="p-0"
|
||||
@click="remove()"
|
||||
/>
|
||||
</p>
|
||||
</UFileUpload>
|
||||
</UFormField>
|
||||
|
||||
<UButton label="Submit" type="submit" />
|
||||
</UForm>
|
||||
|
||||
<UFileUpload
|
||||
v-model="value"
|
||||
label="Drop your image here"
|
||||
description="SVG, PNG, JPG or GIF (max. 2MB)"
|
||||
class="w-full"
|
||||
multiple
|
||||
:size="size"
|
||||
>
|
||||
<template #files-bottom="{ remove }">
|
||||
<UButton label="Remove all" @click="remove()" />
|
||||
</template>
|
||||
</UFileUpload>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,20 +3,16 @@ import theme from '#build/ui/kbd'
|
||||
import { kbdKeysMap } from '@nuxt/ui/composables/useKbd.js'
|
||||
|
||||
const sizes = Object.keys(theme.variants.size) as Array<keyof typeof theme.variants.size>
|
||||
const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme.variants.variant>
|
||||
const colors = Object.keys(theme.variants.color) as Array<keyof typeof theme.variants.color>
|
||||
|
||||
const kbdKeys = Object.keys(kbdKeysMap)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<UKbd value="meta" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UKbd value="meta" variant="subtle" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UKbd value="meta" variant="solid" />
|
||||
<div v-for="color in colors" :key="color" class="flex items-center gap-1 ms-[-22px]">
|
||||
<UKbd v-for="variant in variants" :key="`${color}-${variant}`" value="meta" :variant="variant" :color="color" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ms-[-220px]">
|
||||
<UKbd v-for="(kdbKey, index) in kbdKeys" :key="index" :value="kdbKey" />
|
||||
|
||||
@@ -25,7 +25,8 @@ const items = [{
|
||||
label: 'Tab3',
|
||||
icon: 'i-lucide-bell',
|
||||
content: 'Finally, this is the content for Tab3',
|
||||
slot: 'custom' as const
|
||||
slot: 'custom' as const,
|
||||
badge: '300'
|
||||
}]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
future: {
|
||||
compatibilityVersion: 4
|
||||
},
|
||||
|
||||
compatibilityDate: '2024-07-09',
|
||||
|
||||
hub: {
|
||||
blob: true
|
||||
},
|
||||
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
// prevents reloading page when navigating between components
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.56",
|
||||
"@iconify-json/simple-icons": "^1.2.42",
|
||||
"@iconify-json/lucide": "^1.2.57",
|
||||
"@iconify-json/simple-icons": "^1.2.44",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxthub/core": "^0.9.0",
|
||||
"nuxt": "^3.17.6",
|
||||
"zod": "^3.25.75"
|
||||
"nuxt": "^4.0.1",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
12
playground/server/api/blob.put.ts
Normal file
12
playground/server/api/blob.put.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default eventHandler(async (event) => {
|
||||
return hubBlob().handleUpload(event, {
|
||||
formKey: 'files', // read file or files form the `formKey` field of request body (body should be a `FormData` object)
|
||||
multiple: true, // when `true`, the `formKey` field will be an array of `Blob` objects
|
||||
ensure: {
|
||||
types: ['image/jpeg', 'image/png'] // allowed types of the file
|
||||
},
|
||||
put: {
|
||||
addRandomSuffix: true
|
||||
}
|
||||
})
|
||||
})
|
||||
3181
pnpm-lock.yaml
generated
3181
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,9 @@ import { name, version } from '../package.json'
|
||||
|
||||
export type * from './runtime/types'
|
||||
|
||||
type Color = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | (string & {})
|
||||
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {})
|
||||
|
||||
export interface ModuleOptions {
|
||||
/**
|
||||
* Prefix for components
|
||||
@@ -38,7 +41,7 @@ export interface ModuleOptions {
|
||||
* @defaultValue `['primary', 'secondary', 'success', 'info', 'warning', 'error']`
|
||||
* @link https://ui.nuxt.com/getting-started/installation/nuxt#themecolors
|
||||
*/
|
||||
colors?: string[]
|
||||
colors?: Color[]
|
||||
|
||||
/**
|
||||
* Enable or disable transitions on components
|
||||
@@ -46,6 +49,20 @@ export interface ModuleOptions {
|
||||
* @link https://ui.nuxt.com/getting-started/installation/nuxt#themetransitions
|
||||
*/
|
||||
transitions?: boolean
|
||||
|
||||
defaultVariants?: {
|
||||
/**
|
||||
* The default color variant to use for components
|
||||
* @defaultValue `'primary'`
|
||||
*/
|
||||
color?: Color
|
||||
|
||||
/**
|
||||
* The default size variant to use for components
|
||||
* @defaultValue `'md'`
|
||||
*/
|
||||
size?: Size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +102,7 @@ export default defineNuxtModule<ModuleOptions>({
|
||||
|
||||
async function registerModule(name: string, key: string, options: Record<string, any>) {
|
||||
if (!hasNuxtModule(name)) {
|
||||
await installModule(name, options)
|
||||
await installModule(name, defu((nuxt.options as any)[key], options))
|
||||
} else {
|
||||
(nuxt.options as any)[key] = defu((nuxt.options as any)[key], options)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface AlertProps {
|
||||
}
|
||||
|
||||
export interface AlertEmits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
'update:open': [value: boolean]
|
||||
}
|
||||
|
||||
export interface AlertSlots {
|
||||
|
||||
@@ -256,6 +256,7 @@ const scrollSnaps = ref<number[]>([])
|
||||
function onInit(api: EmblaCarouselType) {
|
||||
scrollSnaps.value = api?.scrollSnapList() || []
|
||||
}
|
||||
|
||||
function onSelect(api: EmblaCarouselType) {
|
||||
canScrollNext.value = api?.canScrollNext() || false
|
||||
canScrollPrev.value = api?.canScrollPrev() || false
|
||||
@@ -300,8 +301,7 @@ defineExpose({
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
v-bind="dots ? { role: 'tabpanel' } : { 'role': 'group', 'aria-roledescription': 'slide' }"
|
||||
:class="ui.item({ class: [props.ui?.item, isCarouselItem(item) && item.ui?.item, isCarouselItem(item) && item.class] })"
|
||||
>
|
||||
<slot :item="item" :index="index" />
|
||||
@@ -333,13 +333,15 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="dots" :class="ui.dots({ class: props.ui?.dots })">
|
||||
<div v-if="dots" role="tablist" :aria-label="t('carousel.dots')" :class="ui.dots({ class: props.ui?.dots })">
|
||||
<template v-for="(_, index) in scrollSnaps" :key="index">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-label="t('carousel.goto', { slide: index + 1 })"
|
||||
:aria-selected="selectedIndex === index"
|
||||
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
|
||||
:data-state="selectedIndex === index ? 'active' : undefined"
|
||||
:aria-current="selectedIndex === index ? true : undefined"
|
||||
@click="scrollTo(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface ChipProps {
|
||||
}
|
||||
|
||||
export interface ChipEmits {
|
||||
(e: 'update:show', payload: boolean): void
|
||||
'update:show': [payload: boolean]
|
||||
}
|
||||
|
||||
export interface ChipSlots {
|
||||
|
||||
@@ -31,7 +31,7 @@ function HSVtoHSL(hsv: HSVColor): HSLObject {
|
||||
return {
|
||||
H: hsv.h,
|
||||
S: x === 0 || x === 200 ? 0 : Math.round(hsv.s * hsv.v / (x <= 100 ? x : 200 - x)),
|
||||
L: Math.round(x / 2)
|
||||
L: x / 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ const pickedColor = computed<HSVColor>({
|
||||
},
|
||||
set(value) {
|
||||
const color = new ColorTranslator(HSVtoHSL(value), {
|
||||
decimals: 2,
|
||||
labUnit: 'percent',
|
||||
cmykUnit: 'percent',
|
||||
cmykFunction: 'cmyk'
|
||||
|
||||
@@ -37,6 +37,11 @@ export interface DrawerProps extends Pick<DrawerRootProps, 'activeSnapPoint' | '
|
||||
* @defaultValue true
|
||||
*/
|
||||
portal?: boolean | string | HTMLElement
|
||||
/**
|
||||
* Whether the drawer is nested in another drawer.
|
||||
* @defaultValue false
|
||||
*/
|
||||
nested?: boolean
|
||||
class?: any
|
||||
ui?: Drawer['slots']
|
||||
}
|
||||
@@ -57,7 +62,7 @@ export interface DrawerSlots {
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { VisuallyHidden, useForwardPropsEmits } from 'reka-ui'
|
||||
import { DrawerRoot, DrawerTrigger, DrawerPortal, DrawerOverlay, DrawerContent, DrawerTitle, DrawerDescription, DrawerHandle } from 'vaul-vue'
|
||||
import { DrawerRoot, DrawerRootNested, DrawerTrigger, DrawerPortal, DrawerOverlay, DrawerContent, DrawerTitle, DrawerDescription, DrawerHandle } from 'vaul-vue'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { usePortal } from '../composables/usePortal'
|
||||
@@ -90,7 +95,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.drawer || {}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DrawerRoot v-bind="rootProps">
|
||||
<component :is="nested ? DrawerRootNested : DrawerRoot" v-bind="rootProps">
|
||||
<DrawerTrigger v-if="!!slots.default" as-child :class="props.class">
|
||||
<slot />
|
||||
</DrawerTrigger>
|
||||
@@ -144,5 +149,5 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.drawer || {}
|
||||
</slot>
|
||||
</DrawerContent>
|
||||
</DrawerPortal>
|
||||
</DrawerRoot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
289
src/runtime/components/FileUpload.vue
Normal file
289
src/runtime/components/FileUpload.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import type { UseFileDialogReturn } from '@vueuse/core'
|
||||
import theme from '#build/ui/file-upload'
|
||||
import type { ButtonProps } from '../types'
|
||||
import type { ComponentConfig } from '../types/utils'
|
||||
|
||||
type FileUpload = ComponentConfig<typeof theme, AppConfig, 'fileUpload'>
|
||||
|
||||
export interface FileUploadProps<M extends boolean = false> {
|
||||
/**
|
||||
* The element or component this component should render as.
|
||||
* @defaultValue 'div'
|
||||
*/
|
||||
as?: any
|
||||
id?: string
|
||||
name?: string
|
||||
/**
|
||||
* The icon to display.
|
||||
* @defaultValue appConfig.ui.icons.upload
|
||||
* @IconifyIcon
|
||||
*/
|
||||
icon?: string
|
||||
label?: string
|
||||
description?: string
|
||||
actions?: ButtonProps[]
|
||||
/**
|
||||
* @defaultValue 'primary'
|
||||
*/
|
||||
color?: FileUpload['variants']['color']
|
||||
/**
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: FileUpload['variants']['size']
|
||||
/** Highlight the ring color like a focus state. */
|
||||
highlight?: boolean
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
multiple?: M & boolean
|
||||
/**
|
||||
* Specifies the allowed file types for the input. Provide a comma-separated list of MIME types or file extensions (e.g., "image/png,application/pdf,.jpg").
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept
|
||||
* @defaultValue '*'
|
||||
*/
|
||||
accept?: string
|
||||
/**
|
||||
* Reset the file input when the dialog is opened.
|
||||
* @defaultValue false
|
||||
*/
|
||||
reset?: boolean
|
||||
/**
|
||||
* Create a zone that allows the user to drop files onto it.
|
||||
* @defaultValue true
|
||||
*/
|
||||
dropzone?: boolean
|
||||
/**
|
||||
* The icon to display for the file.
|
||||
* @defaultValue appConfig.ui.icons.file
|
||||
* @IconifyIcon
|
||||
*/
|
||||
fileIcon?: string
|
||||
class?: any
|
||||
ui?: FileUpload['slots']
|
||||
}
|
||||
|
||||
export interface FileUploadEmits<M extends boolean = false> {
|
||||
(e: 'update:modelValue', value: M extends true ? File[] : File | null): void
|
||||
(e: 'change', event: Event): void
|
||||
}
|
||||
|
||||
export interface FileUploadSlots<M extends boolean = false> {
|
||||
'default'(props: {
|
||||
open: UseFileDialogReturn['open']
|
||||
remove: (index?: number) => void
|
||||
}): any
|
||||
'leading'(props?: {}): any
|
||||
'label'(props?: {}): any
|
||||
'description'(props?: {}): any
|
||||
'actions'(props?: {}): any
|
||||
'files'(props: { files: M extends true ? File[] : File | null }): any
|
||||
'files-top'(props: { remove: (index?: number) => void }): any
|
||||
'files-bottom'(props: { remove: (index?: number) => void }): any
|
||||
'file'(props: { file: File, index: number }): any
|
||||
'file-leading'(props: { file: File, index: number }): any
|
||||
'file-name'(props: { file: File, index: number }): any
|
||||
'file-size'(props: { file: File, index: number }): any
|
||||
'file-trailing'(props: { file: File, index: number }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="M extends boolean = false">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { useFormField } from '../composables/useFormField'
|
||||
import { useFileUpload } from '../composables/useFileUpload'
|
||||
import { tv } from '../utils/tv'
|
||||
import UAvatar from './Avatar.vue'
|
||||
import UButton from './Button.vue'
|
||||
import UIcon from './Icon.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<FileUploadProps<M>>(), {
|
||||
accept: '*',
|
||||
multiple: false as never,
|
||||
reset: false,
|
||||
dropzone: true
|
||||
})
|
||||
const emits = defineEmits<FileUploadEmits<M>>()
|
||||
const slots = defineSlots<FileUploadSlots<M>>()
|
||||
|
||||
const modelValue = defineModel<(M extends true ? File[] : File) | null>()
|
||||
|
||||
const appConfig = useAppConfig() as FileUpload['AppConfig']
|
||||
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const dropzoneRef = ref<HTMLDivElement>()
|
||||
|
||||
const { isDragging, open } = useFileUpload({
|
||||
accept: props.accept,
|
||||
reset: props.reset,
|
||||
multiple: props.multiple,
|
||||
dropzone: props.dropzone,
|
||||
dropzoneRef,
|
||||
inputRef,
|
||||
onUpdate
|
||||
})
|
||||
const { emitFormInput, emitFormChange, id, name, disabled, ariaAttrs } = useFormField<FileUploadProps>(props, { deferInputValidation: true })
|
||||
|
||||
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.fileUpload || {}) })({
|
||||
dropzone: props.dropzone,
|
||||
color: props.color,
|
||||
size: props.size,
|
||||
highlight: props.highlight
|
||||
}))
|
||||
|
||||
function createObjectUrl(file: File): string {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) {
|
||||
return '0B'
|
||||
}
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
const size = bytes / Math.pow(k, i)
|
||||
const formattedSize = i === 0 ? size.toString() : size.toFixed(2)
|
||||
|
||||
return `${formattedSize}${sizes[i]}`
|
||||
}
|
||||
|
||||
function onUpdate(files: File[], reset = false) {
|
||||
if (props.multiple) {
|
||||
if (reset) {
|
||||
modelValue.value = files as (M extends true ? File[] : File) | null
|
||||
} else {
|
||||
const existingFiles = (modelValue.value as File[]) || []
|
||||
modelValue.value = [...existingFiles, ...(files || [])] as (M extends true ? File[] : File) | null
|
||||
}
|
||||
} else {
|
||||
modelValue.value = files?.[0] as (M extends true ? File[] : File) | null
|
||||
}
|
||||
|
||||
// @ts-expect-error - 'target' does not exist in type 'EventInit'
|
||||
const event = new Event('change', { target: { value: modelValue.value } })
|
||||
emits('change', event)
|
||||
emitFormChange()
|
||||
emitFormInput()
|
||||
}
|
||||
|
||||
function remove(index?: number) {
|
||||
if (!modelValue.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.multiple || index === undefined) {
|
||||
onUpdate([], true)
|
||||
return
|
||||
}
|
||||
|
||||
const files = [...modelValue.value as File[]]
|
||||
files.splice(index, 1)
|
||||
|
||||
onUpdate(files, true)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
inputRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })">
|
||||
<slot :open="open" :remove="remove">
|
||||
<div
|
||||
ref="dropzoneRef"
|
||||
role="button"
|
||||
:data-dragging="isDragging"
|
||||
:class="ui.base({ class: props.ui?.base })"
|
||||
tabindex="0"
|
||||
@click="open()"
|
||||
>
|
||||
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
|
||||
<div :class="ui.leading({ class: props.ui?.leading })">
|
||||
<slot name="leading">
|
||||
<UIcon :name="icon || appConfig.ui.icons.upload" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div v-if="label || !!slots.label" :class="ui.label({ class: props.ui?.label })">
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="description || !!slots.description" :class="ui.description({ class: props.ui?.description })">
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div v-if="actions?.length || !!slots.actions" :class="ui.actions({ class: props.ui?.actions })">
|
||||
<slot name="actions">
|
||||
<UButton v-for="(action, index) in actions" :key="index" size="xs" v-bind="action" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue" :class="ui.files({ class: props.ui?.files })">
|
||||
<slot name="files-top" :remove="remove" />
|
||||
|
||||
<slot name="files" :files="modelValue">
|
||||
<div v-for="(file, index) in Array.isArray(modelValue) ? modelValue : [modelValue]" :key="(file as File).name" :class="ui.file({ class: props.ui?.file })">
|
||||
<slot name="file" :file="file" :index="index">
|
||||
<slot name="file-leading" :file="file" :index="index">
|
||||
<UAvatar :src="createObjectUrl(file)" :icon="fileIcon || appConfig.ui.icons.file" :size="props.size" :class="ui.fileLeadingAvatar({ class: props.ui?.fileLeadingAvatar })" />
|
||||
</slot>
|
||||
|
||||
<div :class="ui.fileWrapper({ class: props.ui?.fileWrapper })">
|
||||
<span :class="ui.fileName({ class: props.ui?.fileName })">
|
||||
<slot name="file-name" :file="file" :index="index">
|
||||
{{ (file as File).name }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span :class="ui.fileSize({ class: props.ui?.fileSize })">
|
||||
<slot name="file-size" :file="file" :index="index">
|
||||
{{ formatFileSize((file as File).size) }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<slot name="file-trailing" :file="file" :index="index">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:size="size"
|
||||
:trailing-icon="appConfig.ui.icons.close"
|
||||
:class="ui.fileTrailing({ class: props.ui?.fileTrailing })"
|
||||
@click="remove(index)"
|
||||
/>
|
||||
</slot>
|
||||
</slot>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<slot name="files-bottom" :remove="remove" />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
:name="name"
|
||||
:accept="accept"
|
||||
:multiple="(multiple as boolean)"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
v-bind="{ ...$attrs, ...ariaAttrs }"
|
||||
hidden
|
||||
>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -53,8 +53,8 @@ export interface FormProps<S extends FormSchema, T extends boolean = true> {
|
||||
}
|
||||
|
||||
export interface FormEmits<S extends FormSchema, T extends boolean = true> {
|
||||
(e: 'submit', payload: FormSubmitEvent<FormData<S, T>>): void
|
||||
(e: 'error', payload: FormErrorEvent): void
|
||||
submit: [payload: FormSubmitEvent<FormData<S, T>>]
|
||||
error: [payload: FormErrorEvent]
|
||||
}
|
||||
|
||||
export interface FormSlots {
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface FormFieldProps {
|
||||
label?: string
|
||||
description?: string
|
||||
help?: string
|
||||
error?: string | boolean
|
||||
error?: boolean | string
|
||||
hint?: string
|
||||
/**
|
||||
* @defaultValue 'md'
|
||||
@@ -41,8 +41,8 @@ export interface FormFieldSlots {
|
||||
hint(props: { hint?: string }): any
|
||||
description(props: { description?: string }): any
|
||||
help(props: { help?: string }): any
|
||||
error(props: { error?: string | boolean }): any
|
||||
default(props: { error?: string | boolean }): any
|
||||
error(props: { error?: boolean | string }): any
|
||||
default(props: { error?: boolean | string }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,7 +121,7 @@ provide(formFieldInjectionKey, computed(() => ({
|
||||
{{ error }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-else-if="help || !!slots.help" :class="ui.help({ class: props.ui?.help })">
|
||||
<div v-else-if="help || !!slots.help" :id="`${ariaId}-help`" :class="ui.help({ class: props.ui?.help })">
|
||||
<slot name="help" :help="help">
|
||||
{{ help }}
|
||||
</slot>
|
||||
|
||||
@@ -52,9 +52,9 @@ export interface InputProps<T extends AcceptableValue = AcceptableValue> extends
|
||||
}
|
||||
|
||||
export interface InputEmits<T extends AcceptableValue = AcceptableValue> {
|
||||
(e: 'update:modelValue', payload: T): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
(e: 'change', event: Event): void
|
||||
'update:modelValue': [payload: T]
|
||||
'blur': [event: FocusEvent]
|
||||
'change': [event: Event]
|
||||
}
|
||||
|
||||
export interface InputSlots {
|
||||
|
||||
@@ -128,15 +128,16 @@ export interface InputMenuProps<T extends ArrayOrNested<InputMenuItem> = ArrayOr
|
||||
}
|
||||
|
||||
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]
|
||||
'change': [payload: Event]
|
||||
'blur': [payload: FocusEvent]
|
||||
'focus': [payload: FocusEvent]
|
||||
'create': [item: string]
|
||||
/** Event handler when highlighted element changes. */
|
||||
highlight: [payload: {
|
||||
'highlight': [payload: {
|
||||
ref: HTMLElement
|
||||
value: GetModelValue<A, VK, M>
|
||||
} | undefined]
|
||||
'remove-tag': [item: GetModelValue<A, VK, M>]
|
||||
} & GetModelValueEmits<A, VK, M>
|
||||
|
||||
type SlotProps<T extends InputMenuItem> = (props: { item: T, index: number }) => any
|
||||
@@ -171,7 +172,7 @@ export interface InputMenuSlots<
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
|
||||
import { computed, ref, toRef, onMounted, toRaw } from 'vue'
|
||||
import { computed, ref, toRef, onMounted, toRaw, nextTick } from 'vue'
|
||||
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits, useFilter } from 'reka-ui'
|
||||
import { defu } from 'defu'
|
||||
import { isEqual } from 'ohash/utils'
|
||||
@@ -233,11 +234,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputMenu ||
|
||||
}))
|
||||
|
||||
function displayValue(value: T): string {
|
||||
if (!props.valueKey) {
|
||||
return value && (typeof value === 'object' ? get(value, props.labelKey as string) : value)
|
||||
}
|
||||
|
||||
const item = items.value.find(item => compare(typeof item === 'object' ? get(item as Record<string, any>, props.valueKey as string) : item, value))
|
||||
const item = items.value.find(item => compare(typeof item === 'object' && props.valueKey ? get(item as Record<string, any>, props.valueKey as string) : item, value))
|
||||
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
|
||||
}
|
||||
|
||||
@@ -258,8 +255,12 @@ const filteredGroups = computed(() => {
|
||||
|
||||
const fields = Array.isArray(props.filterFields) ? props.filterFields : [props.labelKey] as string[]
|
||||
|
||||
return groups.value.map(group => group.filter((item) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return groups.value.map(items => items.filter((item) => {
|
||||
if (item === undefined || item === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof item !== 'object') {
|
||||
return contains(String(item), searchTerm.value)
|
||||
}
|
||||
|
||||
@@ -267,7 +268,10 @@ const filteredGroups = computed(() => {
|
||||
return true
|
||||
}
|
||||
|
||||
return fields.some(field => contains(get(item, field), searchTerm.value))
|
||||
return fields.some((field) => {
|
||||
const value = get(item, field)
|
||||
return value !== undefined && value !== null && contains(String(value), searchTerm.value)
|
||||
})
|
||||
})).filter(group => group.filter(item =>
|
||||
!isInputItem(item) || (!item.type || !['label', 'separator'].includes(item.type))
|
||||
).length > 0)
|
||||
@@ -298,6 +302,10 @@ function autoFocus() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
searchTerm.value = ''
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
@@ -359,6 +367,7 @@ function onRemoveTag(event: any) {
|
||||
const modelValue = props.modelValue as GetModelValue<T, VK, true>
|
||||
const filteredValue = modelValue.filter(value => !isEqual(value, event))
|
||||
emits('update:modelValue', filteredValue as GetModelValue<T, VK, M>)
|
||||
emits('remove-tag', event)
|
||||
onUpdate(filteredValue)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +441,7 @@ defineExpose({
|
||||
<TagsInputItem v-for="(item, index) in tags" :key="index" :value="item" :class="ui.tagsItem({ class: [props.ui?.tagsItem, isInputItem(item) && item.ui?.tagsItem] })">
|
||||
<TagsInputItemText :class="ui.tagsItemText({ class: [props.ui?.tagsItemText, isInputItem(item) && item.ui?.tagsItemText] })">
|
||||
<slot name="tags-item-text" :item="(item as NestedItem<T>)" :index="index">
|
||||
{{ displayValue(item as T) }}
|
||||
{{ displayValue(item as T) ?? item }}
|
||||
</slot>
|
||||
</TagsInputItemText>
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue
|
||||
}
|
||||
|
||||
export interface InputNumberEmits {
|
||||
(e: 'update:modelValue', payload: number): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
(e: 'change', payload: Event): void
|
||||
'update:modelValue': [payload: number]
|
||||
'blur': [event: FocusEvent]
|
||||
'change': [payload: Event]
|
||||
}
|
||||
|
||||
export interface InputNumberSlots {
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface KbdProps {
|
||||
*/
|
||||
as?: any
|
||||
value?: KbdKey | string
|
||||
/**
|
||||
* @defaultValue 'neutral'
|
||||
*/
|
||||
color?: Kbd['variants']['color']
|
||||
/**
|
||||
* @defaultValue 'outline'
|
||||
*/
|
||||
@@ -48,7 +52,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.kbd || {}) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :class="ui({ variant, size, class: props.class })">
|
||||
<Primitive :as="as" :class="ui({ class: props.class, color: props.color, variant: props.variant, size: props.size })">
|
||||
<slot>
|
||||
{{ getKbdKey(value) }}
|
||||
</slot>
|
||||
|
||||
@@ -177,6 +177,8 @@ import UBadge from './Badge.vue'
|
||||
import UPopover from './Popover.vue'
|
||||
import UTooltip from './Tooltip.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<NavigationMenuProps<T>>(), {
|
||||
orientation: 'horizontal',
|
||||
contentOrientation: 'horizontal',
|
||||
@@ -392,7 +394,7 @@ function getAccordionDefaultValue(list: NavigationMenuItem[], level = 0) {
|
||||
</component>
|
||||
</DefineItemTemplate>
|
||||
|
||||
<NavigationMenuRoot v-bind="rootProps" :data-collapsed="collapsed" :class="ui.root({ class: [props.ui?.root, props.class] })">
|
||||
<NavigationMenuRoot v-bind="{ ...rootProps, ...$attrs }" :data-collapsed="collapsed" :class="ui.root({ class: [props.ui?.root, props.class] })">
|
||||
<slot name="list-leading" />
|
||||
|
||||
<template v-for="(list, listIndex) in lists" :key="`list-${listIndex}`">
|
||||
|
||||
@@ -70,7 +70,9 @@ export type RadioGroupEmits = RadioGroupRootEmits & {
|
||||
change: [payload: Event]
|
||||
}
|
||||
|
||||
type SlotProps<T extends RadioGroupItem> = (props: { item: T & { id: string }, modelValue?: RadioGroupValue }) => any
|
||||
type NormalizeItem<T extends RadioGroupItem> = Exclude<T & { id: string }, RadioGroupValue>
|
||||
|
||||
type SlotProps<T extends RadioGroupItem> = (props: { item: NormalizeItem<T>, modelValue?: RadioGroupValue }) => any
|
||||
|
||||
export interface RadioGroupSlots<T extends RadioGroupItem = RadioGroupItem> {
|
||||
legend(props?: {}): any
|
||||
@@ -114,21 +116,21 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.radioGroup |
|
||||
indicator: props.indicator
|
||||
}))
|
||||
|
||||
function normalizeItem(item: any) {
|
||||
function normalizeItem(item: T): NormalizeItem<T> {
|
||||
if (item === null) {
|
||||
return {
|
||||
id: `${id}:null`,
|
||||
value: undefined,
|
||||
label: undefined
|
||||
}
|
||||
} as NormalizeItem<T>
|
||||
}
|
||||
|
||||
if (typeof item === 'string' || typeof item === 'number') {
|
||||
if (typeof item === 'string' || typeof item === 'number' || typeof item === 'bigint') {
|
||||
return {
|
||||
id: `${id}:${item}`,
|
||||
value: String(item),
|
||||
label: String(item)
|
||||
}
|
||||
} as NormalizeItem<T>
|
||||
}
|
||||
|
||||
const value = get(item, props.valueKey as string)
|
||||
@@ -136,7 +138,7 @@ function normalizeItem(item: any) {
|
||||
const description = get(item, props.descriptionKey as string)
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(item as NormalizeItem<T>),
|
||||
value,
|
||||
label,
|
||||
description,
|
||||
|
||||
@@ -234,11 +234,7 @@ function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): strin
|
||||
return values?.length ? values.join(', ') : undefined
|
||||
}
|
||||
|
||||
if (!props.valueKey) {
|
||||
return value && (typeof value === 'object' ? get(value, props.labelKey as string) : value)
|
||||
}
|
||||
|
||||
const item = items.value.find(item => compare(typeof item === 'object' ? get(item as Record<string, any>, props.valueKey as string) : item, value))
|
||||
const item = items.value.find(item => compare(typeof item === 'object' && props.valueKey ? get(item as Record<string, any>, props.valueKey as string) : item, value))
|
||||
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
|
||||
}
|
||||
|
||||
@@ -260,7 +256,11 @@ 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' || item === null) {
|
||||
if (item === undefined || item === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof item !== 'object') {
|
||||
return contains(String(item), searchTerm.value)
|
||||
}
|
||||
|
||||
@@ -268,7 +268,10 @@ const filteredGroups = computed(() => {
|
||||
return true
|
||||
}
|
||||
|
||||
return fields.some(field => contains(get(item, field), searchTerm.value))
|
||||
return fields.some((field) => {
|
||||
const value = get(item, field)
|
||||
return value !== undefined && value !== null && contains(String(value), searchTerm.value)
|
||||
})
|
||||
})).filter(group => group.filter(item =>
|
||||
!isSelectItem(item) || (!item.type || !['label', 'separator'].includes(item.type))
|
||||
).length > 0)
|
||||
|
||||
@@ -39,8 +39,8 @@ export interface SliderProps extends Pick<SliderRootProps, 'name' | 'disabled' |
|
||||
}
|
||||
|
||||
export interface SliderEmits<T extends number | number[] = number | number[]> {
|
||||
(e: 'update:modelValue', payload: T): void
|
||||
(e: 'change', payload: Event): void
|
||||
'update:modelValue': [payload: T]
|
||||
'change': [payload: Event]
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -45,12 +45,26 @@ declare module '@tanstack/table-core' {
|
||||
th?: string | ((cell: Header<TData, TValue>) => string)
|
||||
td?: string | ((cell: Cell<TData, TValue>) => string)
|
||||
}
|
||||
style?: {
|
||||
th?: string | Record<string, string> | ((cell: Header<TData, TValue>) => string | Record<string, string>)
|
||||
td?: string | Record<string, string> | ((cell: Cell<TData, TValue>) => string | Record<string, string>)
|
||||
}
|
||||
colspan?: {
|
||||
td?: string | ((cell: Cell<TData, TValue>) => string)
|
||||
}
|
||||
rowspan?: {
|
||||
td?: string | ((cell: Cell<TData, TValue>) => string)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface TableMeta<TData> {
|
||||
class?: {
|
||||
tr?: string | ((row: Row<TData>) => string)
|
||||
}
|
||||
style?: {
|
||||
tr?: string | Record<string, string> | ((row: Row<TData>) => string | Record<string, string>)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -369,6 +383,14 @@ function onRowContextmenu(e: Event, row: TableRow<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveValue<T, A = undefined>(prop: T | ((arg: A) => T), arg?: A): T | undefined {
|
||||
if (typeof prop === 'function') {
|
||||
// @ts-expect-error: TS can't know if prop is a function here
|
||||
return prop(arg)
|
||||
}
|
||||
return prop
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data, () => {
|
||||
data.value = props.data ? [...props.data] : []
|
||||
@@ -398,10 +420,11 @@ defineExpose({
|
||||
:data-pinned="header.column.getIsPinned()"
|
||||
:scope="header.colSpan > 1 ? 'colgroup' : 'col'"
|
||||
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
|
||||
:rowspan="header.rowSpan > 1 ? header.rowSpan : undefined"
|
||||
:class="ui.th({
|
||||
class: [
|
||||
props.ui?.th,
|
||||
typeof header.column.columnDef.meta?.class?.th === 'function' ? header.column.columnDef.meta.class.th(header) : header.column.columnDef.meta?.class?.th
|
||||
resolveValue(header.column.columnDef.meta?.class?.th, header)
|
||||
],
|
||||
pinned: !!header.column.getIsPinned()
|
||||
})"
|
||||
@@ -429,9 +452,10 @@ defineExpose({
|
||||
:class="ui.tr({
|
||||
class: [
|
||||
props.ui?.tr,
|
||||
typeof tableApi.options.meta?.class?.tr === 'function' ? tableApi.options.meta.class.tr(row) : tableApi.options.meta?.class?.tr
|
||||
resolveValue(tableApi.options.meta?.class?.tr, row)
|
||||
]
|
||||
})"
|
||||
:style="resolveValue(tableApi.options.meta?.style?.tr, row)"
|
||||
@click="onRowSelect($event, row)"
|
||||
@pointerenter="onRowHover($event, row)"
|
||||
@pointerleave="onRowHover($event, null)"
|
||||
@@ -441,13 +465,16 @@ defineExpose({
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:data-pinned="cell.column.getIsPinned()"
|
||||
:colspan="resolveValue(cell.column.columnDef.meta?.colspan?.td, cell)"
|
||||
:rowspan="resolveValue(cell.column.columnDef.meta?.rowspan?.td, cell)"
|
||||
:class="ui.td({
|
||||
class: [
|
||||
props.ui?.td,
|
||||
typeof cell.column.columnDef.meta?.class?.td === 'function' ? cell.column.columnDef.meta.class.td(cell) : cell.column.columnDef.meta?.class?.td
|
||||
resolveValue(cell.column.columnDef.meta?.class?.td, cell)
|
||||
],
|
||||
pinned: !!cell.column.getIsPinned()
|
||||
})"
|
||||
:style="resolveValue(cell.column.columnDef.meta?.style?.td, cell)"
|
||||
>
|
||||
<slot :name="`${cell.column.id}-cell`" v-bind="cell.getContext()">
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
@@ -488,13 +515,15 @@ defineExpose({
|
||||
:key="header.id"
|
||||
:data-pinned="header.column.getIsPinned()"
|
||||
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
|
||||
:rowspan="header.rowSpan > 1 ? header.rowSpan : undefined"
|
||||
:class="ui.th({
|
||||
class: [
|
||||
props.ui?.th,
|
||||
typeof header.column.columnDef.meta?.class?.th === 'function' ? header.column.columnDef.meta.class.th(header) : header.column.columnDef.meta?.class?.th
|
||||
resolveValue(header.column.columnDef.meta?.class?.th, header)
|
||||
],
|
||||
pinned: !!header.column.getIsPinned()
|
||||
})"
|
||||
:style="resolveValue(header.column.columnDef.meta?.style?.th, header)"
|
||||
>
|
||||
<slot :name="`${header.id}-footer`" v-bind="header.getContext()">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.footer" :props="header.getContext()" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { TabsRootProps, TabsRootEmits } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/tabs'
|
||||
import type { AvatarProps } from '../types'
|
||||
import type { AvatarProps, BadgeProps } from '../types'
|
||||
import type { DynamicSlots, ComponentConfig } from '../types/utils'
|
||||
|
||||
type Tabs = ComponentConfig<typeof theme, AppConfig, 'tabs'>
|
||||
@@ -15,13 +15,18 @@ export interface TabsItem {
|
||||
*/
|
||||
icon?: string
|
||||
avatar?: AvatarProps
|
||||
/**
|
||||
* Display a badge on the item.
|
||||
* `{ size: 'sm', color: 'neutral', variant: 'outline' }`{lang="ts-type"}
|
||||
*/
|
||||
badge?: string | number | BadgeProps
|
||||
slot?: string
|
||||
content?: string
|
||||
/** A unique value for the tab item. Defaults to the index. */
|
||||
value?: string | number
|
||||
disabled?: boolean
|
||||
class?: any
|
||||
ui?: Pick<Tabs['slots'], 'trigger' | 'leadingIcon' | 'leadingAvatar' | 'label' | 'content'>
|
||||
ui?: Pick<Tabs['slots'], 'trigger' | 'leadingIcon' | 'leadingAvatar' | 'leadingAvatarSize' | 'label' | 'trailingBadge' | 'trailingBadgeSize' | 'content'>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -134,14 +139,23 @@ defineExpose({
|
||||
>
|
||||
<slot name="leading" :item="item" :index="index">
|
||||
<UIcon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon({ class: [props.ui?.leadingIcon, item.ui?.leadingIcon] })" />
|
||||
<UAvatar v-else-if="item.avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.leadingAvatar({ class: [props.ui?.leadingAvatar, item.ui?.leadingAvatar] })" />
|
||||
<UAvatar v-else-if="item.avatar" :size="((item.ui?.leadingAvatarSize || props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="item.avatar" :class="ui.leadingAvatar({ class: [props.ui?.leadingAvatar, item.ui?.leadingAvatar] })" />
|
||||
</slot>
|
||||
|
||||
<span v-if="get(item, props.labelKey as string) || !!slots.default" :class="ui.label({ class: [props.ui?.label, item.ui?.label] })">
|
||||
<slot :item="item" :index="index">{{ get(item, props.labelKey as string) }}</slot>
|
||||
</span>
|
||||
|
||||
<slot name="trailing" :item="item" :index="index" />
|
||||
<slot name="trailing" :item="item" :index="index">
|
||||
<UBadge
|
||||
v-if="item.badge"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:size="((item.ui?.trailingBadgeSize || props.ui?.trailingBadgeSize || ui.trailingBadgeSize()) as BadgeProps['size'])"
|
||||
v-bind="(typeof item.badge === 'string' || typeof item.badge === 'number') ? { label: item.badge } : item.badge"
|
||||
:class="ui.trailingBadge({ class: [props.ui?.trailingBadge, item.ui?.trailingBadge] })"
|
||||
/>
|
||||
</slot>
|
||||
</TabsTrigger>
|
||||
|
||||
<slot name="list-trailing" />
|
||||
|
||||
@@ -55,9 +55,9 @@ export interface TextareaProps<T extends TextareaValue = TextareaValue> extends
|
||||
}
|
||||
|
||||
export interface TextareaEmits<T extends TextareaValue = TextareaValue> {
|
||||
(e: 'update:modelValue', payload: T): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
(e: 'change', event: Event): void
|
||||
'update:modelValue': [payload: T]
|
||||
'blur': [event: FocusEvent]
|
||||
'change': [event: Event]
|
||||
}
|
||||
|
||||
export interface TextareaSlots {
|
||||
|
||||
@@ -107,6 +107,8 @@ import { get } from '../utils'
|
||||
import { tv } from '../utils/tv'
|
||||
import UIcon from './Icon.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<TreeProps<T, VK, M>>(), {
|
||||
labelKey: 'label' as never,
|
||||
valueKey: 'value' as never
|
||||
@@ -161,7 +163,7 @@ const defaultExpanded = computed(() =>
|
||||
@toggle="item.onToggle"
|
||||
@select="item.onSelect"
|
||||
>
|
||||
<button :disabled="item.disabled || disabled" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], selected: isSelected, disabled: item.disabled || disabled })">
|
||||
<button type="button" :disabled="item.disabled || disabled" :class="ui.link({ class: [props.ui?.link, item.ui?.link, item.class], selected: isSelected, disabled: item.disabled || disabled })">
|
||||
<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
|
||||
@@ -199,7 +201,7 @@ const defaultExpanded = computed(() =>
|
||||
</DefineTreeTemplate>
|
||||
|
||||
<TreeRoot
|
||||
v-bind="(rootProps as unknown as TreeRootProps<NestedItem<T>>)"
|
||||
v-bind="{ ...(rootProps as unknown as TreeRootProps<NestedItem<T>>), ...$attrs }"
|
||||
:class="ui.root({ class: [props.ui?.root, props.class] })"
|
||||
:get-key="getItemValue"
|
||||
:default-expanded="defaultExpanded"
|
||||
|
||||
@@ -122,7 +122,7 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor
|
||||
|
||||
if (shortcut.enabled) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
shortcut.handler(e)
|
||||
}
|
||||
clearChainedInput()
|
||||
return
|
||||
|
||||
78
src/runtime/composables/useFileUpload.ts
Normal file
78
src/runtime/composables/useFileUpload.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, unref } from 'vue'
|
||||
import { useFileDialog, useDropZone } from '@vueuse/core'
|
||||
import type { MaybeRef, MaybeRefOrGetter } from '@vueuse/core'
|
||||
|
||||
export interface UseFileUploadOptions {
|
||||
/**
|
||||
* Specifies the allowed file types. Provide a comma-separated list of MIME types or file extensions.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept
|
||||
* @defaultValue '*'
|
||||
*/
|
||||
accept?: MaybeRef<string>
|
||||
reset?: boolean
|
||||
multiple?: boolean
|
||||
dropzone?: boolean
|
||||
dropzoneRef: MaybeRefOrGetter<HTMLElement | null | undefined>
|
||||
inputRef: Ref<HTMLInputElement | undefined>
|
||||
onUpdate: (files: File[]) => void
|
||||
}
|
||||
|
||||
function parseAcceptToDataTypes(accept: string): string[] | undefined {
|
||||
if (!accept || accept === '*') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const types = accept
|
||||
.split(',')
|
||||
.map(type => type.trim())
|
||||
.filter((type) => {
|
||||
return !type.startsWith('.')
|
||||
})
|
||||
|
||||
return types.length > 0 ? types : undefined
|
||||
}
|
||||
|
||||
export function useFileUpload(options: UseFileUploadOptions) {
|
||||
const {
|
||||
accept = '*',
|
||||
reset = false,
|
||||
multiple = false,
|
||||
dropzone = true,
|
||||
dropzoneRef,
|
||||
inputRef,
|
||||
onUpdate
|
||||
} = options
|
||||
|
||||
const dataTypes = computed(() => parseAcceptToDataTypes(unref(accept)))
|
||||
|
||||
const onDrop = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
if (files instanceof FileList) {
|
||||
files = Array.from(files)
|
||||
}
|
||||
if (files.length > 1 && !multiple) {
|
||||
files = [files[0]!]
|
||||
}
|
||||
onUpdate(files)
|
||||
}
|
||||
|
||||
const { isOverDropZone: isDragging } = dropzone
|
||||
? useDropZone(dropzoneRef, { dataTypes: dataTypes.value, onDrop })
|
||||
: { isOverDropZone: false }
|
||||
const { onChange, open } = useFileDialog({
|
||||
accept: unref(accept),
|
||||
multiple,
|
||||
input: unref(inputRef),
|
||||
reset
|
||||
})
|
||||
|
||||
onChange(fileList => onDrop(fileList))
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
open
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,15 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
|
||||
.filter(type => formField?.value?.[type])
|
||||
.map(type => `${formField?.value.ariaId}-${type}`) || []
|
||||
|
||||
return {
|
||||
'aria-describedby': descriptiveAttrs.join(' '),
|
||||
const attrs: Record<string, any> = {
|
||||
'aria-invalid': !!formField?.value.error
|
||||
}
|
||||
|
||||
if (descriptiveAttrs.length > 0) {
|
||||
attrs['aria-describedby'] = descriptiveAttrs.join(' ')
|
||||
}
|
||||
|
||||
return attrs
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ function _useOverlay() {
|
||||
isMounted: !!defaultOpen,
|
||||
destroyOnClose: !!destroyOnClose,
|
||||
originalProps: props || {},
|
||||
props: { ...(props || {}) }
|
||||
props: { ...props }
|
||||
})
|
||||
|
||||
overlays.push(options)
|
||||
@@ -87,11 +87,11 @@ function _useOverlay() {
|
||||
const open = <T extends Component>(id: symbol, props?: ComponentProps<T>): OpenedOverlay<T> => {
|
||||
const overlay = getOverlay(id)
|
||||
|
||||
// If props are provided, update the overlay's props
|
||||
// If props are provided, merge them with the original props, otherwise use the original props
|
||||
if (props) {
|
||||
patch(overlay.id, props)
|
||||
overlay.props = { ...overlay.originalProps, ...props }
|
||||
} else {
|
||||
patch(overlay.id, overlay.originalProps)
|
||||
overlay.props = { ...overlay.originalProps }
|
||||
}
|
||||
|
||||
overlay.isOpen = true
|
||||
@@ -135,7 +135,7 @@ function _useOverlay() {
|
||||
const patch = <T extends Component>(id: symbol, props: Partial<ComponentProps<T>>): void => {
|
||||
const overlay = getOverlay(id)
|
||||
|
||||
overlay.props = { ...props }
|
||||
overlay.props = { ...overlay.props, ...props }
|
||||
}
|
||||
|
||||
const getOverlay = (id: symbol): Overlay => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ref, onScopeDispose } from 'vue'
|
||||
import type { Ref, Plugin as VuePlugin } from 'vue'
|
||||
import { createHooks } from 'hookable'
|
||||
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import { useColorMode as useColorModeVueUse } from '@vueuse/core'
|
||||
import appConfig from '#build/app.config'
|
||||
import type { NuxtApp } from '#app'
|
||||
import { useColorMode as useColorModeVueUse } from '@vueuse/core'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
|
||||
export { useHead } from '@unhead/vue'
|
||||
|
||||
@@ -16,6 +15,7 @@ export { useLocale } from '../composables/useLocale'
|
||||
|
||||
export const useRoute = () => {
|
||||
const page = usePage()
|
||||
|
||||
return {
|
||||
fullPath: page.url
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export const useRouter = () => {
|
||||
|
||||
}
|
||||
|
||||
export const clearError = () => {
|
||||
|
||||
}
|
||||
|
||||
export const useColorMode = () => {
|
||||
if (!appConfig.colorMode) {
|
||||
return {
|
||||
|
||||
@@ -40,7 +40,8 @@ export default defineLocale<Messages>({
|
||||
carousel: {
|
||||
prev: 'السابق',
|
||||
next: 'التالي',
|
||||
goto: 'الذهاب إلي شريحة {slide}'
|
||||
dots: 'اختر الشريحة المراد عرضها',
|
||||
goto: 'الذهاب إلى شريحة {slide}'
|
||||
},
|
||||
modal: {
|
||||
close: 'إغلاق'
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineLocale<Messages>({
|
||||
carousel: {
|
||||
prev: 'Əvvəlki',
|
||||
next: 'Növbəti',
|
||||
dots: 'Göstərmək üçün slayd seçin',
|
||||
goto: 'Slayd {slide} keç'
|
||||
},
|
||||
modal: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineLocale<Messages>({
|
||||
carousel: {
|
||||
prev: 'Назад',
|
||||
next: 'Напред',
|
||||
dots: 'Изберете слайд за показване',
|
||||
goto: 'Отидете на слайд {slide}'
|
||||
},
|
||||
modal: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineLocale<Messages>({
|
||||
carousel: {
|
||||
prev: 'পূর্ববর্তী',
|
||||
next: 'পরবর্তী',
|
||||
dots: 'প্রদর্শনের জন্য স্লাইড নির্বাচন করুন',
|
||||
goto: 'স্লাইড {slide} এ যান'
|
||||
},
|
||||
modal: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineLocale<Messages>({
|
||||
carousel: {
|
||||
prev: 'Anterior',
|
||||
next: 'Següent',
|
||||
dots: 'Tria la diapositiva a mostrar',
|
||||
goto: 'Anar a la diapositiva {slide}'
|
||||
},
|
||||
modal: {
|
||||
|
||||
@@ -38,8 +38,9 @@ export default defineLocale<Messages>({
|
||||
close: 'داخستن'
|
||||
},
|
||||
carousel: {
|
||||
prev: 'پێشوو',
|
||||
prev: 'پێشووی',
|
||||
next: 'داهاتوو',
|
||||
dots: 'سلایدێک هەڵبژێرە بۆ پیشاندان',
|
||||
goto: 'بڕۆ بۆ سلایدی {slide}'
|
||||
},
|
||||
modal: {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineLocale<Messages>({
|
||||
carousel: {
|
||||
prev: 'Předchozí',
|
||||
next: 'Další',
|
||||
dots: 'Vyberte snímek k zobrazení',
|
||||
goto: 'Přejít na {slide}'
|
||||
},
|
||||
modal: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user