Merge branch 'v3' into feat/1058

This commit is contained in:
Hugo Richard
2025-06-25 16:00:18 +02:00
committed by GitHub
253 changed files with 14662 additions and 7765 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @benjamincanac

View File

@@ -10,7 +10,7 @@ body:
id: env id: env
attributes: attributes:
label: Environment label: Environment
description: You can use `npx nuxi info` to fill this section description: You can use `npx nuxt info` to fill this section
placeholder: | placeholder: |
- Operating System: `Darwin` - Operating System: `Darwin`
- Node Version: `v18.16.0` - Node Version: `v18.16.0`

View File

@@ -10,7 +10,7 @@ body:
id: env id: env
attributes: attributes:
label: Environment label: Environment
description: You can use `npx nuxi info` to fill this section description: You can use `npx nuxt info` to fill this section
placeholder: | placeholder: |
- Operating System: `Darwin` - Operating System: `Darwin`
- Node Version: `v18.16.0` - Node Version: `v18.16.0`

View File

@@ -6,10 +6,6 @@ jobs:
deploy: deploy:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
environment:
name: ${{ github.ref == 'refs/heads/v3' && 'production' || 'preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
@@ -40,14 +36,10 @@ jobs:
- name: Prepare build - name: Prepare build
run: pnpm run dev:prepare run: pnpm run dev:prepare
- name: Build application - name: Deploy to NuxtHub
run: pnpm run docs:build uses: nuxt-hub/action@v2
env: env:
NODE_OPTIONS: '--max-old-space-size=8192' NODE_OPTIONS: '--max-old-space-size=8192'
- name: Deploy to NuxtHub
uses: nuxt-hub/action@v1
id: deploy
with: with:
project-key: ui-7eg3 project-key: ui-7eg3
directory: docs/dist directory: docs

View File

@@ -93,7 +93,7 @@ jobs:
- name: Store commit SHA - name: Store commit SHA
run: | run: |
echo "COMMIT_SHA=$(echo ${{ github.workflow_sha }} | cut -c1-7)" >> $GITHUB_ENV echo "COMMIT_SHA=$(echo ${{ github.event.pull_request.head.sha || github.sha }} | cut -c1-7)" >> $GITHUB_ENV
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -111,7 +111,7 @@ jobs:
run: pnpm install --ignore-workspace run: pnpm install --ignore-workspace
- name: Prepare - name: Prepare
run: pnpm nuxi prepare run: pnpm nuxt prepare
- name: Typecheck - name: Typecheck
run: pnpm run typecheck run: pnpm run typecheck
@@ -138,7 +138,7 @@ jobs:
- name: Store commit SHA - name: Store commit SHA
run: | run: |
echo "COMMIT_SHA=$(echo ${{ github.workflow_sha }} | cut -c1-7)" >> $GITHUB_ENV echo "COMMIT_SHA=$(echo ${{ github.event.pull_request.head.sha || github.sha }} | cut -c1-7)" >> $GITHUB_ENV
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -183,7 +183,7 @@ jobs:
- name: Store commit SHA - name: Store commit SHA
run: | run: |
echo "COMMIT_SHA=$(echo ${{ github.workflow_sha }} | cut -c1-7)" >> $GITHUB_ENV echo "COMMIT_SHA=$(echo ${{ github.event.pull_request.head.sha || github.sha }} | cut -c1-7)" >> $GITHUB_ENV
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -235,7 +235,7 @@ jobs:
- name: Store commit SHA - name: Store commit SHA
run: | run: |
echo "COMMIT_SHA=$(echo ${{ github.workflow_sha }} | cut -c1-7)" >> $GITHUB_ENV echo "COMMIT_SHA=$(echo ${{ github.event.pull_request.head.sha || github.sha }} | cut -c1-7)" >> $GITHUB_ENV
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -9,10 +9,6 @@ jobs:
deploy: deploy:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
environment:
name: ${{ github.ref == 'refs/heads/v3' && 'production' || 'preview' }}
url: ${{ steps.deploy.outputs.deployment-url }}
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
@@ -40,14 +36,10 @@ jobs:
- name: Prepare build - name: Prepare build
run: pnpm run dev:prepare run: pnpm run dev:prepare
- name: Build application
run: pnpm run dev:build
env:
NITRO_PRESET: cloudflare-pages
- name: Deploy to NuxtHub - name: Deploy to NuxtHub
uses: nuxt-hub/action@v1 uses: nuxt-hub/action@v2
id: deploy env:
NODE_OPTIONS: '--max-old-space-size=8192'
with: with:
project-key: ui3-playground-pb9b project-key: ui3-playground-pb9b
directory: playground/dist directory: playground

27
.github/workflows/reproduction.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: reproduction
on:
workflow_dispatch:
schedule:
- cron: '30 1 * * *'
jobs:
reproduction:
runs-on: ubuntu-latest
permissions:
actions: write
issues: write
steps:
- uses: actions/stale@v9
with:
days-before-stale: -1 # Issues and PR will never be flagged stale automatically.
stale-issue-label: 'needs reproduction' # Label that flags an issue as stale.
only-labels: 'needs reproduction' # Only process these issues
days-before-issue-close: 7
ignore-updates: true
remove-stale-when-updated: false
close-issue-message: This issue was closed because it was open for 7 days without a reproduction.
close-issue-label: closed-by-bot
operations-per-run: 300 #default 30

View File

@@ -1,6 +1,7 @@
name: stale name: stale
on: on:
workflow_dispatch:
schedule: schedule:
- cron: '30 1 * * *' - cron: '30 1 * * *'
@@ -9,17 +10,28 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
actions: write
issues: write issues: write
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@4c023f01d613e60293d8004f251a18bfb9bbd71d
with: with:
days-before-stale: -1 # Issues and PR will never be flagged stale automatically. days-before-pr-stale: -1
stale-issue-label: 'needs reproduction' # Label that flags an issue as stale. days-before-stale: 60
only-labels: 'needs reproduction' # Only process these issues days-before-close: 7
days-before-issue-close: 7 stale-issue-label: 'stale'
ignore-updates: true close-issue-label: 'closed-by-bot'
remove-stale-when-updated: false close-issue-message: |
close-issue-message: This issue was closed because it was open for 7 days without a reproduction. Hi! 👋
close-issue-label: closed-by-bot
operations-per-run: 300 #default 30 This issue has been automatically **closed** due to prolonged inactivity.
We're a small team and can't address every report, but we appreciate your feedback and contributions.
If this issue is still relevant with the latest version of Nuxt UI, please feel free to reopen or create a new issue with updated details.
Thank you for your understanding and support!
— Nuxt UI Team
exempt-issue-labels: 'feature,announcement'
operations-per-run: 300

View File

@@ -1,5 +1,40 @@
# Changelog # Changelog
## [3.1.3](https://github.com/nuxt/ui/compare/v3.1.2...v3.1.3) (2025-05-26)
### ⚠ BREAKING CHANGES
* **NavigationMenu:** revert new `collapsible` field
### Features
* **locale:** add Kyrgyz language ([#4189](https://github.com/nuxt/ui/issues/4189)) ([4053047](https://github.com/nuxt/ui/commit/405304775e4b2b4e8b37a2364f3e5ee34b46036e))
* **locale:** add Lithuanian language ([#4171](https://github.com/nuxt/ui/issues/4171)) ([d86956e](https://github.com/nuxt/ui/commit/d86956e1d57482b3e98eef2d34bff13544284b0b))
* **locale:** add Malay language ([#4160](https://github.com/nuxt/ui/issues/4160)) ([c00f6e8](https://github.com/nuxt/ui/commit/c00f6e8cdfd88eeba58812b78d94a2326c13f164))
* **locale:** add Mongolian language ([#4214](https://github.com/nuxt/ui/issues/4214)) ([44ea02c](https://github.com/nuxt/ui/commit/44ea02c0d64322ef0cfda63b234369c00d3d0180))
* **Modal/Slideover:** add `after:enter` event ([#4187](https://github.com/nuxt/ui/issues/4187)) ([d9e9fea](https://github.com/nuxt/ui/commit/d9e9fea35e4b22d68324c9e85b3aa221a7987d0f))
* **NavigationMenu:** add `tooltip` and `popover` props ([f2682fd](https://github.com/nuxt/ui/commit/f2682fd2ae8abb7807977727fc22ef34cb5752e5)), closes [#4186](https://github.com/nuxt/ui/issues/4186)
* **NavigationMenu:** add `trigger` type in items ([9cf9f25](https://github.com/nuxt/ui/commit/9cf9f25f4424447691e03e9034155d1541badd43))
* **NavigationMenu:** handle `vertical` orientation with Accordion instead of Collapsible ([1e2a10b](https://github.com/nuxt/ui/commit/1e2a10b4bdebaef12316ac60f98a956dad21c1ec)), closes [#4072](https://github.com/nuxt/ui/issues/4072) [#3911](https://github.com/nuxt/ui/issues/3911)
* **Popover:** add `anchor` slot ([#4119](https://github.com/nuxt/ui/issues/4119)) ([473513c](https://github.com/nuxt/ui/commit/473513c2460d4329d7d2e0a0ea69bf1310a072d1))
### Bug Fixes
* **CheckboxGroup/RadioGroup:** variant `table` borders in RTL mode ([#4192](https://github.com/nuxt/ui/issues/4192)) ([43d281f](https://github.com/nuxt/ui/commit/43d281f6d1d8b0017ed61d929c5e311fb5b03447))
* **CommandPalette:** add `presentation` role to viewport ([2ba94db](https://github.com/nuxt/ui/commit/2ba94db09e1ba86020d5d289f1ca1e24ef706299))
* **ContextMenu/DropdownMenu:** wrap groups in a viewport ([dcf34a7](https://github.com/nuxt/ui/commit/dcf34a7ac236b96b1302ec2eae155b8f2d3784ef)), closes [#3315](https://github.com/nuxt/ui/issues/3315)
* **Drawer:** improve title & description accessibility ([41087d4](https://github.com/nuxt/ui/commit/41087d4c9569eb00c04bd748e055cd151c2f762c)), closes [#4199](https://github.com/nuxt/ui/issues/4199)
* **icons:** update `loading` icon ([#4163](https://github.com/nuxt/ui/issues/4163)) ([fe4e1f8](https://github.com/nuxt/ui/commit/fe4e1f859d42aa3c32bb7b75302e84a280abe525))
* **Input/Textarea:** define model modifiers types ([#4195](https://github.com/nuxt/ui/issues/4195)) ([3243fb8](https://github.com/nuxt/ui/commit/3243fb88f71c5475824bfdc4d7c4f303b2d6790b))
* **InputMenu/Select/SelectMenu:** manual viewport to display scrollbars ([f95abf8](https://github.com/nuxt/ui/commit/f95abf8d1d7b9149e400d7dc6f96f93f5154da7a)), closes [#4069](https://github.com/nuxt/ui/issues/4069)
* **NavigationMenu:** incorrect hover when disabled and active ([d0be599](https://github.com/nuxt/ui/commit/d0be59946bfe30c79a6f75476385ab8538aa51b8))
* **NavigationMenu:** only display `tooltip` when collapsed ([44f536f](https://github.com/nuxt/ui/commit/44f536fd0034facb3550d910fae71d4f9442ed19))
* **NavigationMenu:** remove `font-medium` in popover children ([0236399](https://github.com/nuxt/ui/commit/02363994d66d3c2d11b9913f31167fa25f5c5de2))
* **NavigationMenu:** revert new `collapsible` field ([3c78e2f](https://github.com/nuxt/ui/commit/3c78e2fd983f19b5cec65b4a94a8a8b14e548e5e))
* **Textarea:** missing imports ([#4207](https://github.com/nuxt/ui/issues/4207)) ([6aab62e](https://github.com/nuxt/ui/commit/6aab62ec30e266c5f0da0cd24aefbb7c53f447ac))
* **theme:** define `old-neutral` color as static ([#4193](https://github.com/nuxt/ui/issues/4193)) ([dae9f0b](https://github.com/nuxt/ui/commit/dae9f0b8631b3b9fb60ef47753f7aded0c36c4a2))
* **Tooltip:** increase padding for consistency ([0634a75](https://github.com/nuxt/ui/commit/0634a756a496f5131841abafd218ae7e4aaa61e5))
## [3.1.2](https://github.com/nuxt/ui/compare/v3.1.1...v3.1.2) (2025-05-15) ## [3.1.2](https://github.com/nuxt/ui/compare/v3.1.1...v3.1.2) (2025-05-15)
### Features ### Features

View File

@@ -31,8 +31,9 @@ const component = ({ name, primitive, pro, prose, content }) => {
? ` ? `
<script lang="ts"> <script lang="ts">
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
${pro ? `import type { ComponentConfig } from '@nuxt/ui'` : ''}
import theme from '#build/${path}/${prose ? 'prose/' : ''}${content ? 'content/' : ''}${kebabName}' import theme from '#build/${path}/${prose ? 'prose/' : ''}${content ? 'content/' : ''}${kebabName}'
import type { ComponentConfig } from '../types/utils' ${!pro ? `import type { ComponentConfig } from '../types/utils'` : ''}
type ${upperName} = ComponentConfig<typeof theme, AppConfig, ${upperName}${pro ? `, '${key}'` : ''}> type ${upperName} = ComponentConfig<typeof theme, AppConfig, ${upperName}${pro ? `, '${key}'` : ''}>
@@ -62,7 +63,7 @@ defineSlots<${upperName}Slots>()
const appConfig = useAppConfig() as ${upperName}['AppConfig'] const appConfig = useAppConfig() as ${upperName}['AppConfig']
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.${camelName} || {}) })()) const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.${pro ? 'uiPro' : 'ui'}?.${camelName} || {}) })())
</script> </script>
<template> <template>
@@ -75,8 +76,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.${camelName}
<script lang="ts"> <script lang="ts">
import type { ${upperName}RootProps, ${upperName}RootEmits } from 'reka-ui' import type { ${upperName}RootProps, ${upperName}RootEmits } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
${pro ? `import type { ComponentConfig } from '@nuxt/ui'` : ''}
import theme from '#build/${path}/${prose ? 'prose/' : ''}${content ? 'content/' : ''}${kebabName}' import theme from '#build/${path}/${prose ? 'prose/' : ''}${content ? 'content/' : ''}${kebabName}'
import type { ComponentConfig } from '../types/utils' ${!pro ? `import type { ComponentConfig } from '../types/utils'` : ''}
type ${upperName} = ComponentConfig<typeof theme, AppConfig, ${upperName}${pro ? `, '${key}'` : ''}> type ${upperName} = ComponentConfig<typeof theme, AppConfig, ${upperName}${pro ? `, '${key}'` : ''}>
@@ -105,7 +107,7 @@ const appConfig = useAppConfig() as ${upperName}['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props), emits) const rootProps = useForwardPropsEmits(reactivePick(props), emits)
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.${camelName} || {}) })()) const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.${pro ? 'uiPro' : 'ui'}?.${camelName} || {}) })())
</script> </script>
<template> <template>
@@ -186,6 +188,7 @@ links:${primitive
- label: GitHub - label: GitHub
icon: i-simple-icons-github icon: i-simple-icons-github
to: https://github.com/nuxt/${pro ? 'ui-pro' : 'ui'}/tree/v3/src/runtime/components/${upperName}.vue to: https://github.com/nuxt/${pro ? 'ui-pro' : 'ui'}/tree/v3/src/runtime/components/${upperName}.vue
navigation.badge: Soon
--- ---
## Usage ## Usage

View File

@@ -22,9 +22,7 @@ onMounted(() => {
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation') const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const githubLink = computed(() => { const githubLink = computed(() => `https://github.com/nuxt/${value.value}`)
return `https://github.com/nuxt/${value.value}`
})
const desktopLinks = computed(() => props.links.map(({ icon, ...link }) => link)) const desktopLinks = computed(() => props.links.map(({ icon, ...link }) => link))
const mobileLinks = computed(() => [ const mobileLinks = computed(() => [
@@ -36,6 +34,16 @@ const mobileLinks = computed(() => [
target: '_blank' target: '_blank'
} }
]) ])
const items = computed(() => {
const ui2 = { label: 'v2.22.0', to: 'https://ui2.nuxt.com' }
const uiPro1 = { label: 'v1.8.0', to: 'https://ui2.nuxt.com/pro' }
return [
{ label: `v${config.version}`, active: true, color: 'primary' as const, checked: true, type: 'checkbox' as const },
route.path === '/' ? ui2 : route.path.startsWith('/pro') ? uiPro1 : module.value === 'ui-pro' ? uiPro1 : ui2
]
})
</script> </script>
<template> <template>
@@ -53,7 +61,7 @@ const mobileLinks = computed(() => [
<UDropdownMenu <UDropdownMenu
v-slot="{ open }" v-slot="{ open }"
:modal="false" :modal="false"
:items="[{ label: `v${config.version}`, active: true, color: 'primary', checked: true, type: 'checkbox' }, { label: module === 'ui-pro' ? 'v1.7.1' : 'v2.21.1', to: module === 'ui-pro' ? 'https://ui2.nuxt.com/pro' : 'https://ui2.nuxt.com' }]" :items="items"
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-0' }" :ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-0' }"
size="xs" size="xs"
> >

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
const route = useRoute()
const toast = useToast()
const { copy, copied } = useClipboard()
const site = useSiteConfig()
const mdPath = computed(() => `${site.url}/raw${route.path}.md`)
const items = [
{
label: 'Copy Markdown link',
icon: 'i-lucide-link',
onSelect() {
copy(mdPath.value)
toast.add({
title: 'Copied to clipboard',
icon: 'i-lucide-check-circle'
})
}
},
{
label: 'View as Markdown',
icon: 'i-simple-icons:markdown',
target: '_blank',
to: `/raw${route.path}.md`
},
{
label: 'Open in ChatGPT',
icon: 'i-simple-icons:openai',
target: '_blank',
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
},
{
label: 'Open in Claude',
icon: 'i-simple-icons:anthropic',
target: '_blank',
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
}
]
async function copyPage() {
copy(await $fetch<string>(`/raw${route.path}.md`))
}
</script>
<template>
<UButtonGroup>
<UButton
label="Copy page"
:icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'"
color="neutral"
variant="outline"
:ui="{
leadingIcon: [copied ? 'text-primary' : 'text-neutral', 'size-3.5']
}"
@click="copyPage"
/>
<UDropdownMenu
:items="items"
:content="{
align: 'end',
side: 'bottom',
sideOffset: 8
}"
:ui="{
content: 'w-48'
}"
>
<UButton
icon="i-lucide-chevron-down"
size="sm"
color="neutral"
variant="outline"
/>
</UDropdownMenu>
</UButtonGroup>
</template>

View File

@@ -26,6 +26,7 @@ function getEmojiFlag(locale: string): string {
km: 'kh', // Khmer -> Cambodia km: 'kh', // Khmer -> Cambodia
ko: 'kr', // Korean -> South Korea ko: 'kr', // Korean -> South Korea
ky: 'kg', // Kyrgyz -> Kyrgyzstan ky: 'kg', // Kyrgyz -> Kyrgyzstan
lb: 'lu', // Luxembourgish -> Luxembourg
ms: 'my', // Malay -> Malaysia ms: 'my', // Malay -> Malaysia
nb: 'no', // Norwegian Bokmål -> Norway nb: 'no', // Norwegian Bokmål -> Norway
sl: 'si', // Slovenian -> Slovenia sl: 'si', // Slovenian -> Slovenia

View File

@@ -83,7 +83,7 @@ const groups = [
</template> </template>
<template #billing-label="{ item }"> <template #billing-label="{ item }">
{{ item.label }} <span class="font-medium text-primary">{{ item.label }}</span>
<UBadge variant="subtle" size="sm"> <UBadge variant="subtle" size="sm">
50% off 50% off

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
const toast = useToast()
const groups = [{
id: 'actions',
label: 'Actions',
items: [{
label: 'Create new',
icon: 'i-lucide-plus',
children: [{
label: 'New file',
icon: 'i-lucide-file-plus',
suffix: 'Create a new file in the current directory',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'New file created!' })
},
kbds: ['meta', 'N']
}, {
label: 'New folder',
icon: 'i-lucide-folder-plus',
suffix: 'Create a new folder in the current directory',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'New folder created!' })
},
kbds: ['meta', 'F']
}, {
label: 'New project',
icon: 'i-lucide-folder-git',
suffix: 'Create a new project from a template',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'New project created!' })
},
kbds: ['meta', 'P']
}]
}, {
label: 'Share',
icon: 'i-lucide-share',
children: [{
label: 'Copy link',
icon: 'i-lucide-link',
suffix: 'Copy a link to the current item',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Link copied to clipboard!' })
},
kbds: ['meta', 'L']
}, {
label: 'Share via email',
icon: 'i-lucide-mail',
suffix: 'Share the current item via email',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Share via email dialog opened!' })
}
}, {
label: 'Share on social',
icon: 'i-lucide-share-2',
suffix: 'Share the current item on social media',
children: [{
label: 'Twitter',
icon: 'i-simple-icons-twitter',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Shared on Twitter!' })
}
}, {
label: 'LinkedIn',
icon: 'i-simple-icons-linkedin',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Shared on LinkedIn!' })
}
}, {
label: 'Facebook',
icon: 'i-simple-icons-facebook',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Shared on Facebook!' })
}
}]
}]
}, {
label: 'Settings',
icon: 'i-lucide-settings',
children: [{
label: 'General',
icon: 'i-lucide-sliders',
suffix: 'Configure general settings',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'General settings opened!' })
}
}, {
label: 'Appearance',
icon: 'i-lucide-palette',
suffix: 'Customize the appearance',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Appearance settings opened!' })
}
}, {
label: 'Security',
icon: 'i-lucide-shield',
suffix: 'Manage security settings',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Security settings opened!' })
}
}]
}]
}]
</script>
<template>
<UCommandPalette :groups="groups" class="flex-1" />
</template>

View File

@@ -15,6 +15,9 @@ const schema = z.object({
select: z.string().refine(value => value === 'option-2', { select: z.string().refine(value => value === 'option-2', {
message: 'Select Option 2' message: 'Select Option 2'
}), }),
selectMultiple: z.array(z.string()).refine(values => values.includes('option-2'), {
message: 'Include Option 2'
}),
selectMenu: z.any().refine(option => option?.value === 'option-2', { selectMenu: z.any().refine(option => option?.value === 'option-2', {
message: 'Select Option 2' message: 'Select Option 2'
}), }),
@@ -81,6 +84,10 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
<USelect v-model="state.select" :items="items" class="w-full" /> <USelect v-model="state.select" :items="items" class="w-full" />
</UFormField> </UFormField>
<UFormField name="selectMultiple" label="Select (Multiple)">
<USelect v-model="state.selectMultiple" multiple :items="items" class="w-full" />
</UFormField>
<UFormField name="selectMenu" label="Select Menu"> <UFormField name="selectMenu" label="Select Menu">
<USelectMenu v-model="state.selectMenu" :items="items" class="w-full" /> <USelectMenu v-model="state.selectMenu" :items="items" class="w-full" />
</UFormField> </UFormField>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
const tags = ref(['Vue'])
</script>
<template>
<UFormField label="Tags" required>
<UInputTags v-model="tags" placeholder="Enter tags..." />
</UFormField>
</template>

View File

@@ -10,7 +10,7 @@ const domain = ref(domains[0])
v-model="value" v-model="value"
placeholder="nuxt" placeholder="nuxt"
:ui="{ :ui="{
base: 'pl-[57px]', base: 'pl-14.5',
leading: 'pointer-events-none' leading: 'pointer-events-none'
}" }"
> >

View File

@@ -1,15 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
const value = ref('npx nuxi module add ui') import { useClipboard } from '@vueuse/core'
const copied = ref(false)
function copy() { const value = ref('npx nuxt module add ui')
navigator.clipboard.writeText(value.value)
copied.value = true
setTimeout(() => { const { copy, copied } = useClipboard()
copied.value = false
}, 2000)
}
</script> </script>
<template> <template>
@@ -25,7 +19,7 @@ function copy() {
size="sm" size="sm"
:icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'" :icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'"
aria-label="Copy to clipboard" aria-label="Copy to clipboard"
@click="copy" @click="copy(value)"
/> />
</UTooltip> </UTooltip>
</template> </template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { vMaska } from 'maska/vue'
</script>
<template>
<div class="flex flex-col gap-2">
<UInput v-maska="'#### #### #### ####'" placeholder="4242 4242 4242 4242" icon="i-lucide-credit-card" />
<div class="flex items-center gap-2">
<UInput v-maska="'##/##'" placeholder="MM/YY" icon="i-lucide-calendar" />
<UInput v-maska="'###'" placeholder="CVC" />
</div>
</div>
</template>

View File

@@ -10,8 +10,8 @@ const open = ref(false)
<Placeholder class="h-48" /> <Placeholder class="h-48" />
</template> </template>
<template #footer> <template #footer="{ close }">
<UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> <UButton label="Cancel" color="neutral" variant="outline" @click="close" />
<UButton label="Submit" color="neutral" /> <UButton label="Submit" color="neutral" />
</template> </template>
</UModal> </UModal>

View File

@@ -10,8 +10,8 @@ const open = ref(false)
<Placeholder class="h-full" /> <Placeholder class="h-full" />
</template> </template>
<template #footer> <template #footer="{ close }">
<UButton label="Cancel" color="neutral" variant="outline" @click="open = false" /> <UButton label="Cancel" color="neutral" variant="outline" @click="close" />
<UButton label="Submit" color="neutral" /> <UButton label="Submit" color="neutral" />
</template> </template>
</USlideover> </USlideover>

View File

@@ -2,6 +2,7 @@
import { h, resolveComponent } from 'vue' import { h, resolveComponent } from 'vue'
import { upperFirst } from 'scule' import { upperFirst } from 'scule'
import type { TableColumn } from '@nuxt/ui' import type { TableColumn } from '@nuxt/ui'
import { useClipboard } from '@vueuse/core'
const UButton = resolveComponent('UButton') const UButton = resolveComponent('UButton')
const UCheckbox = resolveComponent('UCheckbox') const UCheckbox = resolveComponent('UCheckbox')
@@ -9,6 +10,7 @@ const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu') const UDropdownMenu = resolveComponent('UDropdownMenu')
const toast = useToast() const toast = useToast()
const { copy } = useClipboard()
type Payment = { type Payment = {
id: string id: string
@@ -220,7 +222,7 @@ const columns: TableColumn<Payment>[] = [{
}, { }, {
label: 'Copy payment ID', label: 'Copy payment ID',
onSelect() { onSelect() {
navigator.clipboard.writeText(row.original.id) copy(row.original.id)
toast.add({ toast.add({
title: 'Payment ID copied to clipboard!', title: 'Payment ID copied to clipboard!',

View File

@@ -2,12 +2,14 @@
import { h, resolveComponent } from 'vue' import { h, resolveComponent } from 'vue'
import type { TableColumn } from '@nuxt/ui' import type { TableColumn } from '@nuxt/ui'
import type { Row } from '@tanstack/vue-table' import type { Row } from '@tanstack/vue-table'
import { useClipboard } from '@vueuse/core'
const UButton = resolveComponent('UButton') const UButton = resolveComponent('UButton')
const UBadge = resolveComponent('UBadge') const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu') const UDropdownMenu = resolveComponent('UDropdownMenu')
const toast = useToast() const toast = useToast()
const { copy } = useClipboard()
type Payment = { type Payment = {
id: string id: string
@@ -119,7 +121,7 @@ function getRowItems(row: Row<Payment>) {
}, { }, {
label: 'Copy payment ID', label: 'Copy payment ID',
onSelect() { onSelect() {
navigator.clipboard.writeText(row.original.id) copy(row.original.id)
toast.add({ toast.add({
title: 'Payment ID copied to clipboard!', title: 'Payment ID copied to clipboard!',

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TableColumn, DropdownMenuItem } from '@nuxt/ui' import type { TableColumn, DropdownMenuItem } from '@nuxt/ui'
import { useClipboard } from '@vueuse/core'
interface User { interface User {
id: number id: number
@@ -10,6 +11,7 @@ interface User {
} }
const toast = useToast() const toast = useToast()
const { copy } = useClipboard()
const data = ref<User[]>([{ const data = ref<User[]>([{
id: 1, id: 1,
@@ -71,7 +73,8 @@ function getDropdownActions(user: User): DropdownMenuItem[][] {
label: 'Copy user Id', label: 'Copy user Id',
icon: 'i-lucide-copy', icon: 'i-lucide-copy',
onSelect: () => { onSelect: () => {
navigator.clipboard.writeText(user.id.toString()) copy(user.id.toString())
toast.add({ toast.add({
title: 'User ID copied to clipboard!', title: 'User ID copied to clipboard!',
color: 'success', color: 'success',

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { TimelineItem } from '@nuxt/ui'
const items: TimelineItem[] = [{
date: 'Mar 15, 2025',
title: 'Project Kickoff',
icon: 'i-lucide-rocket',
value: 'kickoff'
}, {
date: 'Mar 22, 2025',
title: 'Design Phase',
icon: 'i-lucide-palette',
value: 'design'
}, {
date: 'Mar 29, 2025',
title: 'Development Sprint',
icon: 'i-lucide-code',
value: 'development'
}, {
date: 'Apr 5, 2025',
title: 'Testing & Deployment',
icon: 'i-lucide-check-circle',
value: 'deployment'
}]
</script>
<template>
<UTimeline
:items="items"
:ui="{ item: 'even:flex-row-reverse even:-translate-x-[calc(100%-2rem)] even:text-right' }"
:default-value="2"
class="translate-x-[calc(50%-1rem)]"
/>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { TimelineItem } from '@nuxt/ui'
const items = [{
date: 'Mar 15, 2025',
title: 'Project Kickoff',
subtitle: 'Project Initiation',
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
icon: 'i-lucide-rocket',
value: 'kickoff'
}, {
date: 'Mar 22, 2025',
title: 'Design Phase',
description: 'User research and design workshops. Created wireframes and prototypes for user testing.',
icon: 'i-lucide-palette',
value: 'design'
}, {
date: 'Mar 29, 2025',
title: 'Development Sprint',
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
icon: 'i-lucide-code',
value: 'development',
slot: 'development' as const,
developers: [
{
src: 'https://github.com/J-Michalek.png'
}, {
src: 'https://github.com/benjamincanac.png'
}
]
}, {
date: 'Apr 5, 2025',
title: 'Testing & Deployment',
description: 'QA testing and performance optimization. Deployed the application to production.',
icon: 'i-lucide-check-circle',
value: 'deployment'
}] satisfies TimelineItem[]
</script>
<template>
<UTimeline :items="items" :default-value="2" class="w-96">
<template #development-title="{ item }">
<div class="flex items-center gap-1">
<span>{{ item.title }}</span>
<UAvatarGroup size="2xs">
<UAvatar v-for="(developer, index) of item.developers" :key="index" v-bind="developer" />
</UAvatarGroup>
</div>
</template>
</UTimeline>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { TimelineItem } from '@nuxt/ui'
const items: TimelineItem[] = [{
date: 'Mar 15, 2025',
title: 'Project Kickoff',
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
icon: 'i-lucide-rocket',
value: 'kickoff'
}, {
date: 'Mar 22, 2025',
title: 'Design Phase',
description: 'User research and design workshops. Created wireframes and prototypes for user testing.',
icon: 'i-lucide-palette',
value: 'design'
}, {
date: 'Mar 29, 2025',
title: 'Development Sprint',
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
icon: 'i-lucide-code',
value: 'development'
}, {
date: 'Apr 5, 2025',
title: 'Testing & Deployment',
description: 'QA testing and performance optimization. Deployed the application to production.',
icon: 'i-lucide-check-circle',
value: 'deployment'
}]
const active = ref(0)
// Note: This is for demonstration purposes only. Don't do this at home.
onMounted(() => {
setInterval(() => {
active.value = (active.value + 1) % items.length
}, 2000)
})
</script>
<template>
<UTimeline v-model="active" :items="items" class="w-96" />
</template>

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
import type { TimelineItem } from '@nuxt/ui'
import { useTimeAgo } from '@vueuse/core'
const items = [{
username: 'J-Michalek',
date: '2025-05-24T14:58:55Z',
action: 'opened this',
avatar: {
src: 'https://github.com/J-Michalek.png'
}
}, {
username: 'J-Michalek',
date: '2025-05-26T19:30:14+02:00',
action: 'marked this pull request as ready for review',
icon: 'i-lucide-check-circle'
}, {
username: 'benjamincanac',
date: '2025-05-27T11:01:20Z',
action: 'commented on this',
description: 'I\'ve made a few changes, let me know what you think! Basically I updated the design, removed unnecessary divs, used Avatar component for the indicator since it supports icon already.',
avatar: {
src: 'https://github.com/benjamincanac.png'
}
}, {
username: 'J-Michalek',
date: '2025-05-27T11:01:20Z',
action: 'commented on this',
description: 'Looks great! Good job on cleaning it up.',
avatar: {
src: 'https://github.com/J-Michalek.png'
}
}, {
username: 'benjamincanac',
date: '2025-05-27T11:01:20Z',
action: 'merged this',
icon: 'i-lucide-git-merge'
}] satisfies TimelineItem[]
</script>
<template>
<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'
}"
>
<template #title="{ item }">
<span>{{ item.username }}</span>
<span class="font-normal text-muted">&nbsp;{{ item.action }}</span>
</template>
<template #date="{ item }">
{{ useTimeAgo(new Date(item.date)) }}
</template>
</UTimeline>
</template>

View File

@@ -2,7 +2,8 @@
import { kebabCase } from 'scule' import { kebabCase } from 'scule'
import type { ContentNavigationItem } from '@nuxt/content' import type { ContentNavigationItem } from '@nuxt/content'
import type { PageLink } from '@nuxt/ui-pro' import type { PageLink } from '@nuxt/ui-pro'
import { findPageBreadcrumb, mapContentNavigation } from '@nuxt/ui-pro/utils/content' import { mapContentNavigation } from '@nuxt/ui-pro/utils/content'
import { findPageBreadcrumb } from '@nuxt/content/utils'
const route = useRoute() const route = useRoute()
const { framework, module } = useSharedData() const { framework, module } = useSharedData()
@@ -37,7 +38,7 @@ const { data: surround } = await useAsyncData(`${kebabCase(route.path)}-surround
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation') const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const breadcrumb = computed(() => mapContentNavigation(findPageBreadcrumb(navigation?.value, page.value)).map(({ icon, ...link }) => link)) const breadcrumb = computed(() => mapContentNavigation(findPageBreadcrumb(navigation?.value, page.value?.path, { indexAsChild: true })).map(({ icon, ...link }) => link))
if (!import.meta.prerender) { if (!import.meta.prerender) {
// Redirect to the correct framework version if the page is not the current framework // Redirect to the correct framework version if the page is not the current framework
@@ -141,7 +142,7 @@ const communityLinks = computed(() => [{
<MDC v-if="page.description" :value="page.description" unwrap="p" :cache-key="`${kebabCase(route.path)}-description`" /> <MDC v-if="page.description" :value="page.description" unwrap="p" :cache-key="`${kebabCase(route.path)}-description`" />
</template> </template>
<template v-if="page.links?.length" #links> <template #links>
<UButton <UButton
v-for="link in page.links" v-for="link in page.links"
:key="link.label" :key="link.label"
@@ -154,6 +155,7 @@ const communityLinks = computed(() => [{
<UAvatar v-bind="link.avatar" size="2xs" :alt="`${link.label} avatar`" /> <UAvatar v-bind="link.avatar" size="2xs" :alt="`${link.label} avatar`" />
</template> </template>
</UButton> </UButton>
<PageHeaderLinks />
</template> </template>
</UPageHeader> </UPageHeader>

View File

@@ -84,7 +84,7 @@ You can play with Nuxt UI components as well as your app components directly fro
Install the module to your Nuxt application with one command: Install the module to your Nuxt application with one command:
```bash [Terminal] ```bash [Terminal]
npx nuxi module add compodium npx nuxt module add compodium
``` ```
:: ::

View File

@@ -115,7 +115,7 @@ Start your project using the [nuxt/starter#ui](https://github.com/nuxt/starter/t
Create a new project locally by running the following command: Create a new project locally by running the following command:
```bash [Terminal] ```bash [Terminal]
npx nuxi init -t ui <my-app> npm create nuxt@latest -- -t ui
``` ```
::note ::note

View File

@@ -78,6 +78,22 @@ components.d.ts
:: ::
::tip
Internally, Nuxt UI relies on custom alias to resolve the theme types. If you're using TypeScript, you should add an alias to your `tsconfig` to enable auto-completion in your `vite.config.ts`.
```json [tsconfig.node.json]
{
"compilerOptions": {
"paths": {
"#build/ui": [
"./node_modules/@nuxt/ui/.nuxt/ui"
]
}
}
}
```
::
#### Use the Nuxt UI Vue plugin in your `main.ts` #### Use the Nuxt UI Vue plugin in your `main.ts`
```ts [main.ts]{3,14} ```ts [main.ts]{3,14}
@@ -179,7 +195,7 @@ Start your project using the [nuxtlabs/nuxt-ui-vue-starter](https://github.com/n
Create a new project locally by running the following command: Create a new project locally by running the following command:
```bash [Terminal] ```bash [Terminal]
npx nuxi init -t github:nuxtlabs/nuxt-ui-vue-starter <my-app> npm create nuxt@latest -- -t github:nuxtlabs/nuxt-ui-vue-starter
``` ```
::note ::note

View File

@@ -60,7 +60,7 @@ import { fr } from '@nuxt/ui-pro/locale'
### Custom locale ### Custom locale
You also have the option to add your own locale using `defineLocale`: You can create your own locale using the `defineLocale` composable:
::module-only ::module-only
@@ -125,6 +125,65 @@ Look at the `code` parameter, there you need to pass the iso code of the languag
:: ::
### Extend locale :badge{label="Soon" class="align-text-top"}
You can customize an existing locale by overriding its `messages` or `code` using the `extendLocale` composable:
::module-only
#ui
:::div
```vue [app.vue]
<script setup lang="ts">
import { en } from '@nuxt/ui/locale'
const locale = extendLocale(en, {
code: 'en-GB',
messages: {
commandPalette: {
placeholder: 'Search a component...'
}
}
})
</script>
<template>
<UApp :locale="locale">
<NuxtPage />
</UApp>
</template>
```
:::
#ui-pro
:::div
```vue [app.vue]
<script setup lang="ts">
import { en } from '@nuxt/ui-pro/locale'
const locale = extendLocale(en, {
code: 'en-GB',
messages: {
commandPalette: {
placeholder: 'Search a component...'
}
}
})
</script>
<template>
<UApp :locale="locale">
<NuxtPage />
</UApp>
</template>
```
:::
::
### Dynamic locale ### Dynamic locale
To dynamically switch between languages, you can use the [Nuxt I18n](https://i18n.nuxtjs.org/) module. To dynamically switch between languages, you can use the [Nuxt I18n](https://i18n.nuxtjs.org/) module.

View File

@@ -60,7 +60,7 @@ import { fr } from '@nuxt/ui-pro/locale'
### Custom locale ### Custom locale
You also have the option to add your locale using `defineLocale`: You can create your own locale using the `defineLocale` composable:
::module-only ::module-only
@@ -127,6 +127,67 @@ Look at the `code` parameter, there you need to pass the iso code of the languag
:: ::
### Extend locale :badge{label="Soon" class="align-text-top"}
You can customize an existing locale by overriding its `messages` or `code` using the `extendLocale` composable:
::module-only
#ui
:::div
```vue [App.vue]
<script setup lang="ts">
import { en } from '@nuxt/ui/locale'
import { extendLocale } from '@nuxt/ui/composables/defineLocale.js'
const locale = extendLocale(en, {
code: 'en-GB',
messages: {
commandPalette: {
placeholder: 'Search a component...'
}
}
})
</script>
<template>
<UApp :locale="locale">
<RouterView />
</UApp>
</template>
```
:::
#ui-pro
:::div
```vue [App.vue]
<script setup lang="ts">
import { en } from '@nuxt/ui-pro/locale'
import { extendLocale } from '@nuxt/ui/composables/defineLocale.js'
const locale = extendLocale(en, {
code: 'en-GB',
messages: {
commandPalette: {
placeholder: 'Search a component...'
}
}
})
</script>
<template>
<UApp :locale="locale">
<RouterView />
</UApp>
</template>
```
:::
::
### Dynamic locale ### Dynamic locale
To dynamically switch between languages, you can use the [Vue I18n](https://vue-i18n.intlify.dev/) plugin. To dynamically switch between languages, you can use the [Vue I18n](https://vue-i18n.intlify.dev/) plugin.

View File

@@ -1,6 +1,6 @@
--- ---
title: useOverlay title: useOverlay
description: "A composable to programmatically control overlays." description: 'A composable to programmatically control overlays.'
--- ---
## Usage ## Usage
@@ -9,9 +9,11 @@ Use the auto-imported `useOverlay` composable to programmatically control [Modal
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { LazyModalExample } from '#components'
const overlay = useOverlay() const overlay = useOverlay()
const modal = overlay.create(MyModal) const modal = overlay.create(LazyModalExample)
async function openModal() { async function openModal() {
modal.open() modal.open()
@@ -29,71 +31,73 @@ In order to return a value from the overlay, the `overlay.open().instance.result
### `create(component: T, options: OverlayOptions): OverlayInstance` ### `create(component: T, options: OverlayOptions): OverlayInstance`
Creates an overlay, and returns a factory instance Create an overlay, and return a factory instance.
- Parameters: - Parameters:
- `component`: The overlay component - `component`: The overlay component.
- `options` The overlay options - `options`:
- `defaultOpen?: boolean` Opens the overlay immediately after being created `default: false` - `defaultOpen?: boolean` Open the overlay immediately after being created. Defaults to `false`.
- `props?: ComponentProps`: An optional object of props to pass to the rendered component. - `props?: ComponentProps`: An optional object of props to pass to the rendered component.
- `destroyOnClose?: boolean` Removes the overlay from memory when closed `default: false` - `destroyOnClose?: boolean` Removes the overlay from memory when closed. Defaults to `false`.
### `open(id: symbol, props?: ComponentProps<T>): OpenedOverlay<T>` ### `open(id: symbol, props?: ComponentProps<T>): OpenedOverlay<T>`
Opens the overlay using its `id` Open an overlay by its `id`.
- Parameters: - Parameters:
- `id`: The identifier of the overlay - `id`: The identifier of the overlay.
- `props`: An optional object of props to pass to the rendered component. - `props`: An optional object of props to pass to the rendered component.
### `close(id: symbol, value?: any): void` ### `close(id: symbol, value?: any): void`
Close an overlay using its `id` Close an overlay by its `id`.
- Parameters: - Parameters:
- `id`: The identifier of the overlay - `id`: The identifier of the overlay.
- `value`: A value to resolve the overlay promise with - `value`: A value to resolve the overlay promise with.
### `patch(id: symbol, props: ComponentProps<T>): void` ### `patch(id: symbol, props: ComponentProps<T>): void`
Update an overlay using its `id` Update an overlay by its `id`.
- Parameters: - Parameters:
- `id`: The identifier of the overlay - `id`: The identifier of the overlay.
- `props`: An object of props to update on the rendered component. - `props`: An object of props to update on the rendered component.
### `unMount(id: symbol): void` ### `unmount(id: symbol): void`
Removes the overlay from the DOM using its `id` Remove an overlay from the DOM by its `id`.
- Parameters: - Parameters:
- `id`: The identifier of the overlay - `id`: The identifier of the overlay.
### `isOpen(id: symbol): boolean` ### `isOpen(id: symbol): boolean`
Checks if an overlay its open using its `id` Check if an overlay is open using its `id`.
- Parameters: - Parameters:
- `id`: The identifier of the overlay - `id`: The identifier of the overlay.
### `overlays: Overlay[]` ### `overlays: Overlay[]`
In-memory list of overlays that were created In-memory list of all overlays that were created.
## Overlay Instance API ## Instance API
### `open(props?: ComponentProps<T>): Promise<OpenedOverlay<T>>` ### `open(props?: ComponentProps<T>): Promise<OpenedOverlay<T>>`
Opens the overlay Open the overlay.
- Parameters: - Parameters:
- `props`: An optional object of props to pass to the rendered component. - `props`: An optional object of props to pass to the rendered component.
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { LazyModalExample } from '#components'
const overlay = useOverlay() const overlay = useOverlay()
const modal = overlay.create(MyModalContent) const modal = overlay.create(LazyModalExample)
function openModal() { function openModal() {
modal.open({ modal.open({
@@ -105,23 +109,25 @@ function openModal() {
### `close(value?: any): void` ### `close(value?: any): void`
Close the overlay Close the overlay.
- Parameters: - Parameters:
- `value`: A value to resolve the overlay promise with - `value`: A value to resolve the overlay promise with.
### `patch(props: ComponentProps<T>)` ### `patch(props: ComponentProps<T>)`
Updates the props of the overlay. Update the props of the overlay.
- Parameters: - Parameters:
- `props`: An object of props to update on the rendered component. - `props`: An object of props to update on the rendered component.
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { LazyModalExample } from '#components'
const overlay = useOverlay() const overlay = useOverlay()
const modal = overlay.create(MyModal, { const modal = overlay.create(LazyModalExample, {
title: 'Welcome' title: 'Welcome'
}) })
@@ -141,6 +147,8 @@ Here's a complete example of how to use the `useOverlay` composable:
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import { ModalA, ModalB, SlideoverA } from '#components'
const overlay = useOverlay() const overlay = useOverlay()
// Create with default props // Create with default props
@@ -150,7 +158,7 @@ const modalB = overlay.create(ModalB)
const slideoverA = overlay.create(SlideoverA) const slideoverA = overlay.create(SlideoverA)
const openModalA = () => { const openModalA = () => {
// Open Modal A, but override the title prop // Open modalA, but override the title prop
modalA.open({ title: 'Hello' }) modalA.open({ title: 'Hello' })
} }
@@ -160,16 +168,37 @@ const openModalB = async () => {
const input = await modalBInstance.result const input = await modalBInstance.result
// Pass the result from modalB to the slideover, and open it. // Pass the result from modalB to the slideover, and open it
slideoverA.open({ input }) slideoverA.open({ input })
} }
</script> </script>
<template> <template>
<div> <button @click="openModalA">Open Modal</button>
<button @click="openModal">Open Modal</button>
</div>
</template> </template>
``` ```
In this example, we're using the `useOverlay` composable to control multiple modals and slideovers. In this example, we're using the `useOverlay` composable to control multiple modals and slideovers.
## Caveats
### Provide / Inject
When opening overlays programmatically (e.g. modals, slideovers, etc), the overlay component can only access injected values from the component containing `UApp` (typically `app.vue` or layout components). This is because overlays are mounted outside of the page context by the `UApp` component.
As such, using `provide()` in pages or parent components isn't supported directly. To pass provided values to overlays, the recommended approach is to use props instead:
```vue
<script setup lang="ts">
import { LazyModalExample } from '#components'
const providedValue = inject('valueProvidedInPage')
const modal = overlay.create(LazyModalExample, {
props: {
providedValue,
otherData: someValue
}
})
</script>
```

View File

@@ -52,9 +52,11 @@ Each group contains an `items` array of objects that define the commands. Each i
- `loading?: boolean`{lang="ts-type"} - `loading?: boolean`{lang="ts-type"}
- `disabled?: boolean`{lang="ts-type"} - `disabled?: boolean`{lang="ts-type"}
- [`slot?: string`{lang="ts-type"}](#with-custom-slot) - [`slot?: string`{lang="ts-type"}](#with-custom-slot)
- `placeholder?: string`{lang="ts-type"} :badge{label="Soon"}
- `children?: CommandPaletteItem[]`{lang="ts-type"} :badge{label="Soon"}
- `onSelect?(e?: Event): void`{lang="ts-type"} - `onSelect?(e?: Event): void`{lang="ts-type"}
- `class?: any`{lang="ts-type"} - `class?: any`{lang="ts-type"}
- `ui?: { item?: ClassNameValue, itemLeadingIcon?: ClassNameValue, itemLeadingAvatarSize?: ClassNameValue, itemLeadingAvatar?: ClassNameValue, itemLeadingChipSize?: ClassNameValue, itemLeadingChip?: ClassNameValue, itemLabel?: ClassNameValue, itemLabelPrefix?: ClassNameValue, itemLabelBase?: ClassNameValue, itemLabelSuffix?: ClassNameValue, itemTrailing?: ClassNameValue, itemTrailingKbds?: ClassNameValue, itemTrailingKbdsSize?: ClassNameValue, itemTrailingHighlightedIcon?: ClassNameValue, itemTrailingIcon?: ClassNameValue,}`{lang="ts-type"} - `ui?: { item?: ClassNameValue, itemLeadingIcon?: ClassNameValue, itemLeadingAvatarSize?: ClassNameValue, itemLeadingAvatar?: ClassNameValue, itemLeadingChipSize?: ClassNameValue, itemLeadingChip?: ClassNameValue, itemLabel?: ClassNameValue, itemLabelPrefix?: ClassNameValue, itemLabelBase?: ClassNameValue, itemLabelSuffix?: ClassNameValue, itemTrailing?: ClassNameValue, itemTrailingKbds?: ClassNameValue, itemTrailingKbdsSize?: ClassNameValue, itemTrailingHighlightedIcon?: ClassNameValue, itemTrailingIcon?: ClassNameValue }`{lang="ts-type"}
You can pass any property from the [Link](/components/link#props) component such as `to`, `target`, etc. You can pass any property from the [Link](/components/link#props) component such as `to`, `target`, etc.
@@ -110,6 +112,10 @@ props:
--- ---
:: ::
::tip{to="#with-children-in-items"}
Each item can take a `children` array of objects with the following properties to create submenus:
::
### Multiple ### Multiple
Use the `multiple` prop to allow multiple selections. Use the `multiple` prop to allow multiple selections.
@@ -246,6 +252,128 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.se
::: :::
:: ::
### Selected Icon
Use the `selected-icon` prop to customize the selected item [Icon](/components/icon). Defaults to `i-lucide-check`.
::component-code
---
collapse: true
hide:
- autofocus
ignore:
- groups
- modelValue
- multiple
- class
external:
- groups
- modelValue
class: '!p-0'
props:
multiple: true
autofocus: false
modelValue:
- label: 'Benjamin Canac'
suffix: 'benjamincanac'
avatar:
src: 'https://github.com/benjamincanac.png'
selectedIcon: 'i-lucide-circle-check'
groups:
- id: 'users'
label: 'Users'
items:
- label: 'Benjamin Canac'
suffix: 'benjamincanac'
avatar:
src: 'https://github.com/benjamincanac.png'
- label: 'Sylvain Marroufin'
suffix: 'smarroufin'
avatar:
src: 'https://github.com/smarroufin.png'
- label: 'Sébastien Chopin'
suffix: 'atinux'
avatar:
src: 'https://github.com/atinux.png'
- label: 'Romain Hamel'
suffix: 'romhml'
avatar:
src: 'https://github.com/romhml.png'
- label: 'Haytham A. Salama'
suffix: 'Haythamasalama'
avatar:
src: 'https://github.com/Haythamasalama.png'
- label: 'Daniel Roe'
suffix: 'danielroe'
avatar:
src: 'https://github.com/danielroe.png'
- label: 'Neil Richter'
suffix: 'noook'
avatar:
src: 'https://github.com/noook.png'
class: 'flex-1'
---
::
::framework-only
#nuxt
:::tip{to="/getting-started/icons/nuxt#theme"}
You can customize this icon globally in your `app.config.ts` under `ui.icons.check` key.
:::
#vue
:::tip{to="/getting-started/icons/vue#theme"}
You can customize this icon globally in your `vite.config.ts` under `ui.icons.check` key.
:::
::
### Trailing Icon :badge{label="Soon" class="align-text-top"}
Use the `trailing-icon` prop to customize the trailing [Icon](/components/icon) when an item has children. Defaults to `i-lucide-chevron-right`.
::component-code
---
collapse: true
prettier: true
hide:
- autofocus
ignore:
- groups
- class
external:
- groups
class: '!p-0'
props:
autofocus: false
trailingIcon: 'i-lucide-arrow-right'
groups:
- id: 'actions'
items:
- label: 'Share'
icon: 'i-lucide-share'
children:
- label: 'Email'
icon: 'i-lucide-mail'
- label: 'Copy'
icon: 'i-lucide-copy'
- label: 'Link'
icon: 'i-lucide-link'
class: 'flex-1'
---
::
::framework-only
#nuxt
:::tip{to="/getting-started/icons/nuxt#theme"}
You can customize this icon globally in your `app.config.ts` under `ui.icons.chevronRight` key.
:::
#vue
:::tip{to="/getting-started/icons/vue#theme"}
You can customize this icon globally in your `vite.config.ts` under `ui.icons.chevronRight` key.
:::
::
### Loading ### Loading
Use the `loading` prop to show a loading icon on the CommandPalette. Use the `loading` prop to show a loading icon on the CommandPalette.
@@ -321,37 +449,6 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.lo
::: :::
:: ::
### Disabled
Use the `disabled` prop to disable the CommandPalette.
::component-code
---
collapse: true
hide:
- autofocus
ignore:
- groups
- class
external:
- groups
class: '!p-0'
props:
autofocus: false
disabled: true
groups:
- id: 'apps'
items:
- label: 'Calendar'
icon: 'i-lucide-calendar'
- label: 'Music'
icon: 'i-lucide-music'
- label: 'Maps'
icon: 'i-lucide-map'
class: 'flex-1'
---
::
### Close ### Close
Use the `close` prop to display a [Button](/components/button) to dismiss the CommandPalette. Use the `close` prop to display a [Button](/components/button) to dismiss the CommandPalette.
@@ -468,6 +565,124 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.cl
::: :::
:: ::
### Back :badge{label="Soon" class="align-text-top"}
Use the `back` prop to customize or hide the back button (with `false` value) displayed when navigating into a submenu.
You can pass any property from the [Button](/components/button) component to customize it.
::component-code
---
collapse: true
prettier: true
hide:
- autofocus
ignore:
- back.color
- groups
- class
external:
- groups
class: '!p-0'
props:
autofocus: false
back:
color: primary
groups:
- id: 'actions'
items:
- label: 'Share'
icon: 'i-lucide-share'
children:
- label: 'Email'
icon: 'i-lucide-mail'
- label: 'Copy'
icon: 'i-lucide-copy'
- label: 'Link'
icon: 'i-lucide-link'
class: 'flex-1'
---
::
### Back Icon :badge{label="Soon" class="align-text-top"}
Use the `back-icon` prop to customize the back button [Icon](/components/icon). Defaults to `i-lucide-arrow-left`.
::component-code
---
collapse: true
hide:
- autofocus
ignore:
- class
- groups
- back
external:
- groups
class: '!p-0'
props:
autofocus: false
back: true
backIcon: 'i-lucide-house'
groups:
- id: 'actions'
items:
- label: 'Share'
icon: 'i-lucide-share'
children:
- label: 'Email'
icon: 'i-lucide-mail'
- label: 'Copy'
icon: 'i-lucide-copy'
- label: 'Link'
icon: 'i-lucide-link'
class: 'flex-1'
---
::
::framework-only
#nuxt
:::tip{to="/getting-started/icons/nuxt#theme"}
You can customize this icon globally in your `app.config.ts` under `ui.icons.arrowLeft` key.
:::
#vue
:::tip{to="/getting-started/icons/vue#theme"}
You can customize this icon globally in your `vite.config.ts` under `ui.icons.arrowLeft` key.
:::
::
### Disabled
Use the `disabled` prop to disable the CommandPalette.
::component-code
---
collapse: true
hide:
- autofocus
ignore:
- groups
- class
external:
- groups
class: '!p-0'
props:
autofocus: false
disabled: true
groups:
- id: 'apps'
items:
- label: 'Calendar'
icon: 'i-lucide-calendar'
- label: 'Music'
icon: 'i-lucide-music'
- label: 'Maps'
icon: 'i-lucide-map'
class: 'flex-1'
---
::
## Examples ## Examples
### Control selected item(s) ### Control selected item(s)
@@ -502,6 +717,28 @@ props:
This example uses the `@update:model-value` event to reset the search term when an item is selected. This example uses the `@update:model-value` event to reset the search term when an item is selected.
:: ::
### With children in items :badge{label="Soon" class="align-text-top"}
You can create hierarchical menus by using the `children` property in items. When an item has children, it will automatically display a chevron icon and enable navigation into a submenu.
::component-example
---
collapse: true
prettier: true
name: 'command-palette-items-children-example'
class: '!p-0'
props:
autofocus: false
---
::
::note
When navigating into a submenu:
- The search term is reset
- A back button appears in the input
- You can go back to the previous group by pressing the :kbd{value="backspace"} key
::
### With fetched items ### With fetched items
You can fetch items from an API and use them in the CommandPalette. You can fetch items from an API and use them in the CommandPalette.
@@ -658,6 +895,7 @@ You will have access to the following slots:
::component-example ::component-example
--- ---
collapse: true
name: 'command-palette-custom-slot-example' name: 'command-palette-custom-slot-example'
class: '!p-0' class: '!p-0'
props: props:

View File

@@ -135,7 +135,7 @@ props:
### Multiple ### Multiple
Use the `multiple` prop to allow multiple selections, the selected items will be displayed as badges. Use the `multiple` prop to allow multiple selections, the selected items will be displayed as tags.
::component-code ::component-code
--- ---
@@ -166,7 +166,7 @@ Ensure to pass an array to the `default-value` prop or the `v-model` directive.
### Delete Icon ### Delete Icon
With `multiple`, use the `delete-icon` prop to customize the delete [Icon](/components/icon) in the badges. Defaults to `i-lucide-x`. With `multiple`, use the `delete-icon` prop to customize the delete [Icon](/components/icon) in the tags. Defaults to `i-lucide-x`.
::component-code ::component-code
--- ---
@@ -782,6 +782,14 @@ name: 'input-menu-countries-example'
:component-emits :component-emits
### Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
| ---- | ---- |
| `inputRef`{lang="ts-type"} | `Ref<InstanceType<typeof ComboboxTrigger> \| null>`{lang="ts-type"} |
## Theme ## Theme
:component-theme :component-theme

View File

@@ -1,6 +1,6 @@
--- ---
title: InputNumber title: InputNumber
description: Input numerical values with a customizable range. description: An input for numerical values with a customizable range.
category: form category: form
links: links:
- label: NumberField - label: NumberField
@@ -287,8 +287,8 @@ name: 'input-number-slots-example'
When accessing the component via a template ref, you can use the following: When accessing the component via a template ref, you can use the following:
| Name | Type | | Name | Type |
|----------------------------|-------------------------------------------------| | -------------------------- | ----------------------------------------------- |
| `inputRef`{lang="ts-type"} | `Ref<HTMLInputElement \| null>`{lang="ts-type"} | | `inputRef`{lang="ts-type"} | `Ref<InstanceType<typeof NumberFieldInput> \| null>`{lang="ts-type"} |
## Theme ## Theme

View File

@@ -0,0 +1,285 @@
---
title: InputTags
description: An input element that displays interactive tags.
category: form
links:
- label: InputTags
icon: i-custom-reka-ui
to: https://reka-ui.com/docs/components/tags-input
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/InputTags.vue
navigation.badge: Soon
---
## Usage
Use the `v-model` directive to control the value of the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
---
::
Use the `default-value` prop to set the initial value when you do not need to control its state.
::component-code
---
prettier: true
ignore:
- defaultValue
props:
defaultValue: ['Vue']
---
::
### Placeholder
Use the `placeholder` prop to set a placeholder text.
::component-code
---
props:
placeholder: 'Enter tags...'
---
::
### Color
Use the `color` prop to change the ring color when the InputTags is focused.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
color: neutral
highlight: true
---
::
::note
The `highlight` prop is used here to show the focus state. It's used internally when a validation error occurs.
::
### Variants
Use the `variant` prop to change the appearance of the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
variant: subtle
color: neutral
highlight: false
---
::
### Sizes
Use the `size` prop to adjust the size of the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
size: xl
---
::
### Icon
Use the `icon` prop to show an [Icon](/components/icon) inside the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
icon: 'i-lucide-search'
size: md
variant: outline
---
::
::note
Use the `leading` and `trailing` props to set the icon position or the `leading-icon` and `trailing-icon` props to set a different icon for each position.
::
### Avatar
Use the `avatar` prop to show an [Avatar](/components/avatar) inside the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
avatar:
src: 'https://github.com/vuejs.png'
size: md
variant: outline
---
::
### Delete Icon
Use the `delete-icon` prop to customize the delete [Icon](/components/icon) in the tags. Defaults to `i-lucide-x`.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
deleteIcon: 'i-lucide-trash'
---
::
::framework-only
#nuxt
:::tip{to="/getting-started/icons/nuxt#theme"}
You can customize this icon globally in your `app.config.ts` under `ui.icons.close` key.
:::
#vue
:::tip{to="/getting-started/icons/vue#theme"}
You can customize this icon globally in your `vite.config.ts` under `ui.icons.close` key.
:::
::
### Loading
Use the `loading` prop to show a loading icon on the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
loading: true
trailing: false
---
::
### Loading Icon
Use the `loading-icon` prop to customize the loading icon. Defaults to `i-lucide-loader-circle`.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
loading: true
loadingIcon: 'i-lucide-loader'
---
::
::framework-only
#nuxt
:::tip{to="/getting-started/icons/nuxt#theme"}
You can customize this icon globally in your `app.config.ts` under `ui.icons.loading` key.
:::
#vue
:::tip{to="/getting-started/icons/vue#theme"}
You can customize this icon globally in your `vite.config.ts` under `ui.icons.loading` key.
:::
::
### Disabled
Use the `disabled` prop to disable the InputTags.
::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: ['Vue']
disabled: true
---
::
## Examples
### Within a FormField
You can use the InputTags within a [FormField](/components/form-field) component to display a label, help text, required indicator, etc.
::component-example
---
name: 'input-tags-form-field-example'
---
::
## API
### Props
:component-props
### Slots
:component-slots
### Emits
:component-emits
### Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
| -------------------------- | ----------------------------------------------- |
| `inputRef`{lang="ts-type"} | `Ref<InstanceType<typeof TagsInputInput> \| null>`{lang="ts-type"} |
## Theme
:component-theme

View File

@@ -278,6 +278,16 @@ name: 'input-kbd-example'
This example uses the `defineShortcuts` composable to focus the Input when the :kbd{value="/"} key is pressed. This example uses the `defineShortcuts` composable to focus the Input when the :kbd{value="/"} key is pressed.
:: ::
### With mask
There's no built-in support for masks, but you can use libraries like [maska](https://github.com/beholdr/maska) to mask the Input.
::component-example
---
name: 'input-mask-example'
---
::
### With floating label ### With floating label
You can use the `#default` slot to add a floating label to the Input. You can use the `#default` slot to add a floating label to the Input.

View File

@@ -305,13 +305,13 @@ slots:
### Programmatic usage ### Programmatic usage
You can use the [`useOverlay`](/composables/use-overlay) composable to open a Modal programatically. You can use the [`useOverlay`](/composables/use-overlay) composable to open a Modal programmatically.
::warning ::warning
Make sure to wrap your app with the [`App`](/components/app) component which uses the [`OverlayProvider`](https://github.com/nuxt/ui/blob/v3/src/runtime/components/OverlayProvider.vue) component. Make sure to wrap your app with the [`App`](/components/app) component which uses the [`OverlayProvider`](https://github.com/nuxt/ui/blob/v3/src/runtime/components/OverlayProvider.vue) component.
:: ::
First, create a modal component that will be opened programatically: First, create a modal component that will be opened programmatically:
::component-example ::component-example
--- ---

View File

@@ -23,7 +23,7 @@ Use the `items` prop as an array of objects with the following properties:
- `badge?: string | number | BadgeProps`{lang="ts-type"} - `badge?: string | number | BadgeProps`{lang="ts-type"}
- `tooltip?: TooltipProps`{lang="ts-type"} - `tooltip?: TooltipProps`{lang="ts-type"}
- `trailingIcon?: string`{lang="ts-type"} - `trailingIcon?: string`{lang="ts-type"}
- `type?: 'label' | 'link'`{lang="ts-type"} - `type?: 'label' | 'trigger' | 'link'`{lang="ts-type"}
- `defaultOpen?: boolean`{lang="ts-type"} - `defaultOpen?: boolean`{lang="ts-type"}
- `open?: boolean`{lang="ts-type"} - `open?: boolean`{lang="ts-type"}
- `value?: string`{lang="ts-type"} - `value?: string`{lang="ts-type"}
@@ -994,7 +994,7 @@ props:
--- ---
:: ::
### With popover in items :badge{label="Soon" class="align-text-top"} ### With popover in items :badge{label="New" class="align-text-top"}
When orientation is `vertical` and the menu is `collapsed`, you can set the `popover` prop to `true` to display a [Popover](/components/popover) around items with their children but you can also use the `popover` property on each item to override the default popover. When orientation is `vertical` and the menu is `collapsed`, you can set the `popover` prop to `true` to display a [Popover](/components/popover) around items with their children but you can also use the `popover` property on each item to override the default popover.

View File

@@ -180,6 +180,8 @@ props:
:component-emits :component-emits
### Expose
When accessing the component via a template ref, you can use the following: When accessing the component via a template ref, you can use the following:
| Name | Type | | Name | Type |

View File

@@ -202,7 +202,7 @@ name: 'popover-command-palette-example'
--- ---
:: ::
### With anchor slot ### With anchor slot :badge{label="New" class="align-text-top"}
You can use the `#anchor` slot to position the Popover against a custom element. You can use the `#anchor` slot to position the Popover against a custom element.

View File

@@ -815,6 +815,14 @@ name: 'select-menu-countries-example'
:component-emits :component-emits
### Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
| ---- | ---- |
| `triggerRef`{lang="ts-type"} | `Ref<InstanceType<typeof ComboboxTrigger> \| null>`{lang="ts-type"} |
## Theme ## Theme
:component-theme :component-theme

View File

@@ -709,6 +709,14 @@ collapse: true
:component-emits :component-emits
### Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
| ---- | ---- |
| `triggerRef`{lang="ts-type"} | `Ref<InstanceType<typeof SelectTrigger> \| null>`{lang="ts-type"} |
## Theme ## Theme
:component-theme :component-theme

View File

@@ -304,13 +304,13 @@ slots:
### Programmatic usage ### Programmatic usage
You can use the [`useOverlay`](/composables/use-overlay) composable to open a Slideover programatically. You can use the [`useOverlay`](/composables/use-overlay) composable to open a Slideover programmatically.
::warning ::warning
Make sure to wrap your app with the [`App`](/components/app) component which uses the [`OverlayProvider`](https://github.com/nuxt/ui/blob/v3/src/runtime/components/OverlayProvider.vue) component. Make sure to wrap your app with the [`App`](/components/app) component which uses the [`OverlayProvider`](https://github.com/nuxt/ui/blob/v3/src/runtime/components/OverlayProvider.vue) component.
:: ::
First, create a slideover component that will be opened programatically: First, create a slideover component that will be opened programmatically:
::component-example ::component-example
--- ---

View File

@@ -200,6 +200,10 @@ Use the `#content` slot to customize the content of each item.
Use the `slot` property to customize a specific item. Use the `slot` property to customize a specific item.
You will have access to the following slots:
- `#{{ item.slot }}`{lang="ts-type"}
:component-example{name="stepper-custom-slot-example"} :component-example{name="stepper-custom-slot-example"}
## API ## API

View File

@@ -222,6 +222,10 @@ Use the `#content` slot to customize the content of each item.
Use the `slot` property to customize a specific item. Use the `slot` property to customize a specific item.
You will have access to the following slots:
- `#{{ item.slot }}`{lang="ts-type"}
:component-example{name="tabs-custom-slot-example"} :component-example{name="tabs-custom-slot-example"}
## API ## API
@@ -238,6 +242,14 @@ Use the `slot` property to customize a specific item.
:component-emits :component-emits
### Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
| ---- | ---- |
| `triggersRef`{lang="ts-type"} | `Ref<ComponentPublicInstance[]>`{lang="ts-type"} |
## Theme ## Theme
:component-theme :component-theme

View File

@@ -0,0 +1,269 @@
---
title: Timeline
description: 'A component that displays a sequence of events with dates, titles, icons or avatars.'
category: data
links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/Timeline.vue
navigation.badge: Soon
---
## Usage
### Items
Use the `items` prop as an array of objects with the following properties:
- `date?: string`{lang="ts-type"}
- `title?: string`{lang="ts-type"}
- `description?: AvatarProps`{lang="ts-type"}
- `icon?: string`{lang="ts-type"}
- `avatar?: AvatarProps`{lang="ts-type"}
- `value?: string | number`{lang="ts-type"}
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
- `class?: any`{lang="ts-type"}
- `ui?: { item?: ClassNameValue, container?: ClassNameValue, indicator?: ClassNameValue, separator?: ClassNameValue, wrapper?: ClassNameValue, date?: ClassNameValue, title?: ClassNameValue, description?: ClassNameValue }`{lang="ts-type"}
::component-code
---
ignore:
- items
- class
- defaultValue
external:
- items
externalTypes:
- TimelineItem[]
props:
defaultValue: 2
items:
- date: 'Mar 15, 2025'
title: 'Project Kickoff'
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.'
icon: 'i-lucide-rocket'
- date: 'Mar 22 2025'
title: 'Design Phase'
description: 'User research and design workshops. Created wireframes and prototypes for user testing.'
icon: 'i-lucide-palette'
- date: 'Mar 29 2025'
title: 'Development Sprint'
description: 'Frontend and backend development. Implemented core features and integrated with APIs.'
icon: 'i-lucide-code'
- date: 'Apr 5 2025'
title: 'Testing & Deployment'
description: 'QA testing and performance optimization. Deployed the application to production.'
icon: 'i-lucide-check-circle'
class: 'w-96'
---
::
### Color
Use the `color` prop to change the color of the active items in a Timeline.
::component-code
---
ignore:
- items
- class
- defaultValue
external:
- items
externalTypes:
- TimelineItem[]
props:
color: neutral
defaultValue: 2
items:
- date: 'Mar 15, 2025'
title: 'Project Kickoff'
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.'
icon: 'i-lucide-rocket'
- date: 'Mar 22 2025'
title: 'Design Phase'
description: 'User research and design workshops. Created wireframes and prototypes for user testing.'
icon: 'i-lucide-palette'
- date: 'Mar 29 2025'
title: 'Development Sprint'
description: 'Frontend and backend development. Implemented core features and integrated with APIs.'
icon: 'i-lucide-code'
- date: 'Apr 5 2025'
title: 'Testing & Deployment'
description: 'QA testing and performance optimization. Deployed the application to production.'
icon: 'i-lucide-check-circle'
class: 'w-96'
---
::
### Size
Use the `size` prop to change the size of the Timeline.
::component-code
---
ignore:
- items
- class
- defaultValue
external:
- items
externalTypes:
- TimelineItem[]
props:
size: xs
defaultValue: 2
items:
- date: 'Mar 15, 2025'
title: 'Project Kickoff'
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.'
icon: 'i-lucide-rocket'
- date: 'Mar 22 2025'
title: 'Design Phase'
description: 'User research and design workshops. Created wireframes and prototypes for user testing.'
icon: 'i-lucide-palette'
- date: 'Mar 29 2025'
title: 'Development Sprint'
description: 'Frontend and backend development. Implemented core features and integrated with APIs.'
icon: 'i-lucide-code'
- date: 'Apr 5 2025'
title: 'Testing & Deployment'
description: 'QA testing and performance optimization. Deployed the application to production.'
icon: 'i-lucide-check-circle'
class: 'w-96'
---
::
### Orientation
Use the `orientation` prop to change the orientation of the Timeline. Defaults to `vertical`.
::component-code
---
ignore:
- items
- class
- defaultValue
external:
- items
externalTypes:
- TimelineItem[]
props:
orientation: 'horizontal'
defaultValue: 2
items:
- date: 'Mar 15, 2025'
title: 'Project Kickoff'
description: 'Kicked off the project with team alignment.'
icon: 'i-lucide-rocket'
- date: 'Mar 22 2025'
title: 'Design Phase'
description: 'User research and design workshops.'
icon: 'i-lucide-palette'
- date: 'Mar 29 2025'
title: 'Development Sprint'
description: 'Frontend and backend development.'
icon: 'i-lucide-code'
- date: 'Apr 5 2025'
title: 'Testing & Deployment'
description: 'QA testing and performance optimization.'
icon: 'i-lucide-check-circle'
class: 'w-full'
class: 'overflow-x-auto'
---
::
### Reverse
Use the reverse prop to reverse the direction of the Timeline.
::component-code
---
ignore:
- items
- class
- defaultValue
external:
- items
externalTypes:
- TimelineItem[]
props:
reverse: true
modelValue: 2
orientation: 'vertical'
items:
- date: 'Mar 15, 2025'
title: 'Project Kickoff'
description: 'Kicked off the project with team alignment.'
icon: 'i-lucide-rocket'
- date: 'Mar 22 2025'
title: 'Design Phase'
description: 'User research and design workshops.'
icon: 'i-lucide-palette'
- date: 'Mar 29 2025'
title: 'Development Sprint'
description: 'Frontend and backend development.'
icon: 'i-lucide-code'
- date: 'Apr 5 2025'
title: 'Testing & Deployment'
description: 'QA testing and performance optimization.'
icon: 'i-lucide-check-circle'
class: 'w-full'
class: 'overflow-x-auto'
---
::
## Examples
### Control active item
You can control the active item by using the `default-value` prop or the `v-model` directive with the index of the item.
:component-example{name="timeline-model-value-example" prettier}
::tip
You can also pass the `value` of one of the items if provided.
::
### With alternating layout
Use the `ui` prop to create a Timeline with alternating layout.
:component-example{name="timeline-alternating-layout-example" prettier}
### With custom slot
Use the `slot` property to customize a specific item.
You will have access to the following slots:
- `#{{ item.slot }}-indicator`{lang="ts-type"}
- `#{{ item.slot }}-date`{lang="ts-type"}
- `#{{ item.slot }}-title`{lang="ts-type"}
- `#{{ item.slot }}-description`{lang="ts-type"}
:component-example{name="timeline-custom-slot-example" prettier}
### With slots
Use the available slots to create a more complex Timeline.
:component-example{name="timeline-slots-example" prettier}
## API
### Props
:component-props
### Slots
:component-slots
### Emits
:component-emits
## Theme
:component-theme

View File

@@ -407,7 +407,14 @@ This lets you select a parent item without expanding or collapsing its children.
### With custom slot ### With custom slot
Use the `item.slot` property to customize a specific item. Use the `slot` property to customize a specific item.
You will have access to the following slots:
- `#{{ item.slot }}`{lang="ts-type"}
- `#{{ item.slot }}-leading`{lang="ts-type"}
- `#{{ item.slot }}-label`{lang="ts-type"}
- `#{{ item.slot }}-trailing`{lang="ts-type"}
::component-example ::component-example
--- ---

View File

@@ -33,7 +33,7 @@ export default defineNuxtModule((_, nuxt) => {
} }
const name = template.name.toLowerCase().replace(/\s/g, '-') const name = template.name.toLowerCase().replace(/\s/g, '-')
const filename = join(process.cwd(), 'docs/public/assets/showcase', `${name}.png`) const filename = join(nuxt.options.rootDir, 'public/assets/showcase', `${name}.png`)
if (existsSync(filename)) { if (existsSync(filename)) {
continue continue

View File

@@ -2,41 +2,49 @@
"private": true, "private": true,
"name": "@nuxt/ui-docs", "name": "@nuxt/ui-docs",
"type": "module", "type": "module",
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"typecheck": "nuxt typecheck"
},
"dependencies": { "dependencies": {
"@ai-sdk/vue": "^1.2.12", "@ai-sdk/vue": "^1.2.12",
"@iconify-json/logos": "^1.2.4", "@iconify-json/logos": "^1.2.4",
"@iconify-json/lucide": "^1.2.44", "@iconify-json/lucide": "^1.2.51",
"@iconify-json/simple-icons": "^1.2.34", "@iconify-json/simple-icons": "^1.2.39",
"@iconify-json/vscode-icons": "^1.2.21", "@iconify-json/vscode-icons": "^1.2.23",
"@nuxt/content": "^3.5.1", "@nuxt/content": "^3.6.1",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxt/ui": "latest", "@nuxt/ui": "workspace:*",
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@9038c43", "@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@55e248c",
"@nuxthub/core": "^0.8.27", "@nuxthub/core": "^0.9.0",
"@nuxtjs/plausible": "^1.2.0", "@nuxtjs/plausible": "^1.2.0",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^22.0.0",
"@rollup/plugin-yaml": "^4.1.2", "@rollup/plugin-yaml": "^4.1.2",
"@vueuse/integrations": "^13.2.0", "@vueuse/integrations": "^13.4.0",
"@vueuse/nuxt": "^13.2.0", "@vueuse/nuxt": "^13.4.0",
"ai": "^4.3.16", "ai": "^4.3.16",
"better-sqlite3": "^12.0.0",
"capture-website": "^4.2.0", "capture-website": "^4.2.0",
"joi": "^17.13.3", "joi": "^17.13.3",
"motion-v": "^1.0.2", "maska": "^3.1.1",
"nuxt": "^3.17.4", "motion-v": "^1.3.0",
"nuxt": "^3.17.5",
"nuxt-component-meta": "^0.11.0", "nuxt-component-meta": "^0.11.0",
"nuxt-llms": "^0.1.2", "nuxt-llms": "^0.1.3",
"nuxt-og-image": "^5.1.3", "nuxt-og-image": "^5.1.7",
"prettier": "^3.5.3", "prettier": "^3.6.0",
"shiki-transformer-color-highlight": "^1.0.0", "shiki-transformer-color-highlight": "^1.0.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"superstruct": "^2.0.2", "superstruct": "^2.0.2",
"ufo": "^1.6.1", "ufo": "^1.6.1",
"valibot": "^1.1.0", "valibot": "^1.1.0",
"workers-ai-provider": "^0.4.1", "workers-ai-provider": "^0.7.0",
"yup": "^1.6.1", "yup": "^1.6.1",
"zod": "^3.24.4" "zod": "^3.25.67"
}, },
"devDependencies": { "devDependencies": {
"wrangler": "^4.15.1" "wrangler": "^4.20.5"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,412 +1,8 @@
import json5 from 'json5'
import { camelCase, kebabCase } from 'scule'
import { visit } from '@nuxt/content/runtime'
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
import type { PageCollectionItemBase } from '@nuxt/content' import type { PageCollectionItemBase } from '@nuxt/content'
import * as theme from '../../.nuxt/ui'
import * as themePro from '../../.nuxt/ui-pro'
import meta from '#nuxt-component-meta'
// @ts-expect-error - no types available
import components from '#component-example/nitro'
type ComponentAttributes = {
':pro'?: string
':prose'?: string
':props'?: string
':external'?: string
':externalTypes'?: string
':ignore'?: string
':hide'?: string
':slots'?: string
}
type ThemeConfig = {
pro: boolean
prose: boolean
componentName: string
}
type CodeConfig = {
pro: boolean
props: Record<string, unknown>
external: string[]
externalTypes: string[]
ignore: string[]
hide: string[]
componentName: string
slots?: Record<string, string>
}
type Document = {
title: string
body: any
}
const parseBoolean = (value?: string): boolean => value === 'true'
function getComponentMeta(componentName: string) {
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
const strategies = [
`U${pascalCaseName}`,
`Prose${pascalCaseName}`,
pascalCaseName
]
let componentMeta: any
let finalMetaComponentName: string = pascalCaseName
for (const nameToTry of strategies) {
finalMetaComponentName = nameToTry
const metaAttempt = (meta as Record<string, any>)[nameToTry]?.meta
if (metaAttempt) {
componentMeta = metaAttempt
break
}
}
if (!componentMeta) {
console.warn(`[getComponentMeta] Metadata not found for ${pascalCaseName} using strategies: U, Prose, or no prefix. Last tried: ${finalMetaComponentName}`)
}
return {
pascalCaseName,
metaComponentName: finalMetaComponentName,
componentMeta
}
}
function replaceNodeWithPre(node: any[], language: string, code: string, filename?: string) {
node[0] = 'pre'
node[1] = { language, code }
if (filename) node[1].filename = filename
}
function visitAndReplace(doc: Document, type: string, handler: (node: any[]) => void) {
visit(doc.body, (node) => {
if (Array.isArray(node) && node[0] === type) {
handler(node)
}
return true
}, node => node)
}
function generateTSInterface(
name: string,
items: any[],
itemHandler: (item: any) => string,
description: string
) {
let code = `/**\n * ${description}\n */\ninterface ${name} {\n`
for (const item of items) {
code += itemHandler(item)
}
code += `}`
return code
}
function propItemHandler(propValue: any): string {
if (!propValue?.name) return ''
const propName = propValue.name
const propType = propValue.type
? Array.isArray(propValue.type)
? propValue.type.map((t: any) => t.name || t).join(' | ')
: propValue.type.name || propValue.type
: 'any'
const isRequired = propValue.required || false
const hasDescription = propValue.description && propValue.description.trim().length > 0
const hasDefault = propValue.default !== undefined
let result = ''
if (hasDescription || hasDefault) {
result += ` /**\n`
if (hasDescription) {
const descLines = propValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
}
if (hasDefault) {
let defaultValue = propValue.default
if (typeof defaultValue === 'string') {
defaultValue = `"${defaultValue.replace(/"/g, '\\"')}"`
} else {
defaultValue = JSON.stringify(defaultValue)
}
result += ` * @default ${defaultValue}\n`
}
result += ` */\n`
}
result += ` ${propName}${isRequired ? '' : '?'}: ${propType};\n`
return result
}
function slotItemHandler(slotValue: any): string {
if (!slotValue?.name) return ''
const slotName = slotValue.name
const hasDescription = slotValue.description && slotValue.description.trim().length > 0
let result = ''
if (hasDescription) {
result += ` /**\n`
const descLines = slotValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
if (slotValue.bindings && Object.keys(slotValue.bindings).length > 0) {
let bindingsType = '{\n'
Object.entries(slotValue.bindings).forEach(([bindingName, bindingValue]: [string, any]) => {
const bindingType = bindingValue.type || 'any'
bindingsType += ` ${bindingName}: ${bindingType};\n`
})
bindingsType += ' }'
result += ` ${slotName}(bindings: ${bindingsType}): any;\n`
} else {
result += ` ${slotName}(): any;\n`
}
return result
}
function emitItemHandler(event: any): string {
if (!event?.name) return ''
let payloadType = 'void'
if (event.type) {
payloadType = Array.isArray(event.type)
? event.type.map((t: any) => t.name || t).join(' | ')
: event.type.name || event.type
}
let result = ''
if (event.description && event.description.trim().length > 0) {
result += ` /**\n`
event.description.split(/\r?\n/).forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
result += ` ${event.name}: (payload: ${payloadType}) => void;\n`
return result
}
const generateThemeConfig = ({ pro, prose, componentName }: ThemeConfig) => {
const computedTheme = pro ? (prose ? themePro.prose : themePro) : theme
const componentTheme = computedTheme[componentName as keyof typeof computedTheme]
return {
[pro ? 'uiPro' : 'ui']: prose
? { prose: { [componentName]: componentTheme } }
: { [componentName]: componentTheme }
}
}
const generateComponentCode = ({
pro,
props,
external,
externalTypes,
hide,
componentName,
slots
}: CodeConfig) => {
const filteredProps = Object.fromEntries(
Object.entries(props).filter(([key]) => !hide.includes(key))
)
const imports = pro
? ''
: external
.filter((_, index) => externalTypes[index] && externalTypes[index] !== 'undefined')
.map((ext, index) => {
const type = externalTypes[index]?.replace(/[[\]]/g, '')
return `import type { ${type} } from '@nuxt/${pro ? 'ui-pro' : 'ui'}'`
})
.join('\n')
let itemsCode = ''
if (props.items) {
itemsCode = pro
? `const items = ref(${json5.stringify(props.items, null, 2)})`
: `const items = ref<${externalTypes[0]}>(${json5.stringify(props.items, null, 2)})`
delete filteredProps.items
}
let calendarValueCode = ''
if (componentName === 'calendar' && props.modelValue && Array.isArray(props.modelValue)) {
calendarValueCode = `const value = ref(new CalendarDate(${props.modelValue.join(', ')}))`
}
const propsString = Object.entries(filteredProps)
.map(([key, value]) => {
const formattedKey = kebabCase(key)
if (typeof value === 'string') {
return `${formattedKey}="${value}"`
} else if (typeof value === 'number') {
return `:${formattedKey}="${value}"`
} else if (typeof value === 'boolean') {
return value ? formattedKey : `:${formattedKey}="false"`
}
return ''
})
.filter(Boolean)
.join(' ')
const itemsProp = props.items ? ':items="items"' : ''
const vModelProp = componentName === 'calendar' && props.modelValue ? 'v-model="value"' : ''
const allProps = [propsString, itemsProp, vModelProp].filter(Boolean).join(' ')
const formattedProps = allProps ? ` ${allProps}` : ''
let scriptSetup = ''
if (imports || itemsCode || calendarValueCode) {
scriptSetup = '<script setup lang="ts">'
if (imports) scriptSetup += `\n${imports}`
if (imports && (itemsCode || calendarValueCode)) scriptSetup += '\n'
if (calendarValueCode) scriptSetup += `\n${calendarValueCode}`
if (itemsCode) scriptSetup += `\n${itemsCode}`
scriptSetup += '\n</script>\n\n'
}
let componentContent = ''
let slotContent = ''
if (slots && Object.keys(slots).length > 0) {
const defaultSlot = slots.default?.trim()
if (defaultSlot) {
const indentedContent = defaultSlot
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
componentContent = `\n${indentedContent}\n `
}
Object.entries(slots).forEach(([slotName, content]) => {
if (slotName !== 'default' && content?.trim()) {
const indentedSlotContent = content.trim()
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
slotContent += `\n <template #${slotName}>\n${indentedSlotContent}\n </template>`
}
})
}
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
let componentTemplate = ''
if (componentContent || slotContent) {
componentTemplate = `<U${pascalCaseName}${formattedProps}>${componentContent}${slotContent}</U${pascalCaseName}>` // Removed space before closing tag
} else {
componentTemplate = `<U${pascalCaseName}${formattedProps} />`
}
return `${scriptSetup}<template>
${componentTemplate}
</template>`
}
export default defineNitroPlugin((nitroApp) => { export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:llms:generate:document', async (_: H3Event, doc: PageCollectionItemBase) => { nitroApp.hooks.hook('content:llms:generate:document', async (_: H3Event, doc: PageCollectionItemBase) => {
const componentName = camelCase(doc.title) transformMDC(doc as any)
visitAndReplace(doc, 'component-theme', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.slug
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const pro = parseBoolean(attributes[':pro'])
const prose = parseBoolean(attributes[':prose'])
const appConfig = generateThemeConfig({ pro, prose, componentName: finalComponentName })
replaceNodeWithPre(
node,
'ts',
`export default defineAppConfig(${json5.stringify(appConfig, null, 2)?.replace(/,([ |\t\n]+[}|\])])/g, '$1')})`,
'app.config.ts'
)
})
visitAndReplace(doc, 'component-code', (node) => {
const attributes = node[1] as ComponentAttributes
const pro = parseBoolean(attributes[':pro'])
const props = attributes[':props'] ? json5.parse(attributes[':props']) : {}
const external = attributes[':external'] ? json5.parse(attributes[':external']) : []
const externalTypes = attributes[':externalTypes'] ? json5.parse(attributes[':externalTypes']) : []
const ignore = attributes[':ignore'] ? json5.parse(attributes[':ignore']) : []
const hide = attributes[':hide'] ? json5.parse(attributes[':hide']) : []
const slots = attributes[':slots'] ? json5.parse(attributes[':slots']) : {}
const code = generateComponentCode({
pro,
props,
external,
externalTypes,
ignore,
hide,
componentName,
slots
})
replaceNodeWithPre(node, 'vue', code)
})
visitAndReplace(doc, 'component-props', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.name
const isProse = parseBoolean(attributes[':prose'])
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const { pascalCaseName, componentMeta } = getComponentMeta(finalComponentName)
if (!componentMeta?.props) return
const interfaceName = isProse ? `Prose${pascalCaseName}Props` : `${pascalCaseName}Props`
const interfaceCode = generateTSInterface(
interfaceName,
Object.values(componentMeta.props),
propItemHandler,
`Props for the ${isProse ? 'Prose' : ''}${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-slots', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
if (!componentMeta?.slots) return
const interfaceCode = generateTSInterface(
`${pascalCaseName}Slots`,
Object.values(componentMeta.slots),
slotItemHandler,
`Slots for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-emits', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
const hasEvents = componentMeta?.events && Object.keys(componentMeta.events).length > 0
if (hasEvents) {
const interfaceCode = generateTSInterface(
`${pascalCaseName}Emits`,
Object.values(componentMeta.events),
emitItemHandler,
`Emitted events for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
} else {
node[0] = 'p'
node[1] = {}
node[2] = 'No events available for this component.'
}
})
visitAndReplace(doc, 'component-example', (node) => {
const camelName = camelCase(node[1]['name'])
const name = camelName.charAt(0).toUpperCase() + camelName.slice(1)
const code = components[name].code
replaceNodeWithPre(node, 'vue', code, `${name}.vue`)
})
}) })
}) })

View File

@@ -0,0 +1,30 @@
import { stringify } from 'minimark/stringify'
import { withLeadingSlash } from 'ufo'
export default eventHandler(async (event) => {
const slug = getRouterParams(event)['slug.md']
if (!slug?.endsWith('.md')) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const path = withLeadingSlash(slug.replace('.md', ''))
// @ts-expect-error TODO: fix this
const page = await queryCollection(event, 'content').path(path).first()
if (!page) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
// Add title and description to the top of the page if missing
if (page.body.value[0]?.[0] !== 'h1') {
page.body.value.unshift(['blockquote', {}, page.description])
page.body.value.unshift(['h1', {}, page.title])
}
const transformedPage = transformMDC({
title: page.title,
body: page.body
})
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
return stringify({ ...transformedPage.body, type: 'minimark' }, { format: 'markdown/html' })
})

View File

@@ -0,0 +1,410 @@
import json5 from 'json5'
import { camelCase, kebabCase } from 'scule'
import { visit } from '@nuxt/content/runtime'
import * as theme from '../../.nuxt/ui'
import * as themePro from '../../.nuxt/ui-pro'
import meta from '#nuxt-component-meta'
// @ts-expect-error - no types available
import components from '#component-example/nitro'
type ComponentAttributes = {
':pro'?: string
':prose'?: string
':props'?: string
':external'?: string
':externalTypes'?: string
':ignore'?: string
':hide'?: string
':slots'?: string
}
type ThemeConfig = {
pro: boolean
prose: boolean
componentName: string
}
type CodeConfig = {
pro: boolean
props: Record<string, unknown>
external: string[]
externalTypes: string[]
ignore: string[]
hide: string[]
componentName: string
slots?: Record<string, string>
}
type Document = {
title: string
body: any
}
const parseBoolean = (value?: string): boolean => value === 'true'
function getComponentMeta(componentName: string) {
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
const strategies = [
`U${pascalCaseName}`,
`Prose${pascalCaseName}`,
pascalCaseName
]
let componentMeta: any
let finalMetaComponentName: string = pascalCaseName
for (const nameToTry of strategies) {
finalMetaComponentName = nameToTry
const metaAttempt = (meta as Record<string, any>)[nameToTry]?.meta
if (metaAttempt) {
componentMeta = metaAttempt
break
}
}
if (!componentMeta) {
console.warn(`[getComponentMeta] Metadata not found for ${pascalCaseName} using strategies: U, Prose, or no prefix. Last tried: ${finalMetaComponentName}`)
}
return {
pascalCaseName,
metaComponentName: finalMetaComponentName,
componentMeta
}
}
function replaceNodeWithPre(node: any[], language: string, code: string, filename?: string) {
node[0] = 'pre'
node[1] = { language, code }
if (filename) node[1].filename = filename
}
function visitAndReplace(doc: Document, type: string, handler: (node: any[]) => void) {
visit(doc.body, (node) => {
if (Array.isArray(node) && node[0] === type) {
handler(node)
}
return true
}, node => node)
}
function generateTSInterface(
name: string,
items: any[],
itemHandler: (item: any) => string,
description: string
) {
let code = `/**\n * ${description}\n */\ninterface ${name} {\n`
for (const item of items) {
code += itemHandler(item)
}
code += `}`
return code
}
function propItemHandler(propValue: any): string {
if (!propValue?.name) return ''
const propName = propValue.name
const propType = propValue.type
? Array.isArray(propValue.type)
? propValue.type.map((t: any) => t.name || t).join(' | ')
: propValue.type.name || propValue.type
: 'any'
const isRequired = propValue.required || false
const hasDescription = propValue.description && propValue.description.trim().length > 0
const hasDefault = propValue.default !== undefined
let result = ''
if (hasDescription || hasDefault) {
result += ` /**\n`
if (hasDescription) {
const descLines = propValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
}
if (hasDefault) {
let defaultValue = propValue.default
if (typeof defaultValue === 'string') {
defaultValue = `"${defaultValue.replace(/"/g, '\\"')}"`
} else {
defaultValue = JSON.stringify(defaultValue)
}
result += ` * @default ${defaultValue}\n`
}
result += ` */\n`
}
result += ` ${propName}${isRequired ? '' : '?'}: ${propType};\n`
return result
}
function slotItemHandler(slotValue: any): string {
if (!slotValue?.name) return ''
const slotName = slotValue.name
const hasDescription = slotValue.description && slotValue.description.trim().length > 0
let result = ''
if (hasDescription) {
result += ` /**\n`
const descLines = slotValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
if (slotValue.bindings && Object.keys(slotValue.bindings).length > 0) {
let bindingsType = '{\n'
Object.entries(slotValue.bindings).forEach(([bindingName, bindingValue]: [string, any]) => {
const bindingType = bindingValue.type || 'any'
bindingsType += ` ${bindingName}: ${bindingType};\n`
})
bindingsType += ' }'
result += ` ${slotName}(bindings: ${bindingsType}): any;\n`
} else {
result += ` ${slotName}(): any;\n`
}
return result
}
function emitItemHandler(event: any): string {
if (!event?.name) return ''
let payloadType = 'void'
if (event.type) {
payloadType = Array.isArray(event.type)
? event.type.map((t: any) => t.name || t).join(' | ')
: event.type.name || event.type
}
let result = ''
if (event.description && event.description.trim().length > 0) {
result += ` /**\n`
event.description.split(/\r?\n/).forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
result += ` ${event.name}: (payload: ${payloadType}) => void;\n`
return result
}
const generateThemeConfig = ({ pro, prose, componentName }: ThemeConfig) => {
const computedTheme = pro ? (prose ? themePro.prose : themePro) : theme
const componentTheme = computedTheme[componentName as keyof typeof computedTheme]
return {
[pro ? 'uiPro' : 'ui']: prose
? { prose: { [componentName]: componentTheme } }
: { [componentName]: componentTheme }
}
}
const generateComponentCode = ({
pro,
props,
external,
externalTypes,
hide,
componentName,
slots
}: CodeConfig) => {
const filteredProps = Object.fromEntries(
Object.entries(props).filter(([key]) => !hide.includes(key))
)
const imports = pro
? ''
: external
.filter((_, index) => externalTypes[index] && externalTypes[index] !== 'undefined')
.map((ext, index) => {
const type = externalTypes[index]?.replace(/[[\]]/g, '')
return `import type { ${type} } from '@nuxt/${pro ? 'ui-pro' : 'ui'}'`
})
.join('\n')
let itemsCode = ''
if (props.items) {
itemsCode = pro
? `const items = ref(${json5.stringify(props.items, null, 2)})`
: `const items = ref<${externalTypes[0]}>(${json5.stringify(props.items, null, 2)})`
delete filteredProps.items
}
let calendarValueCode = ''
if (componentName === 'calendar' && props.modelValue && Array.isArray(props.modelValue)) {
calendarValueCode = `const value = ref(new CalendarDate(${props.modelValue.join(', ')}))`
}
const propsString = Object.entries(filteredProps)
.map(([key, value]) => {
const formattedKey = kebabCase(key)
if (typeof value === 'string') {
return `${formattedKey}="${value}"`
} else if (typeof value === 'number') {
return `:${formattedKey}="${value}"`
} else if (typeof value === 'boolean') {
return value ? formattedKey : `:${formattedKey}="false"`
}
return ''
})
.filter(Boolean)
.join(' ')
const itemsProp = props.items ? ':items="items"' : ''
const vModelProp = componentName === 'calendar' && props.modelValue ? 'v-model="value"' : ''
const allProps = [propsString, itemsProp, vModelProp].filter(Boolean).join(' ')
const formattedProps = allProps ? ` ${allProps}` : ''
let scriptSetup = ''
if (imports || itemsCode || calendarValueCode) {
scriptSetup = '<script setup lang="ts">'
if (imports) scriptSetup += `\n${imports}`
if (imports && (itemsCode || calendarValueCode)) scriptSetup += '\n'
if (calendarValueCode) scriptSetup += `\n${calendarValueCode}`
if (itemsCode) scriptSetup += `\n${itemsCode}`
scriptSetup += '\n</script>\n\n'
}
let componentContent = ''
let slotContent = ''
if (slots && Object.keys(slots).length > 0) {
const defaultSlot = slots.default?.trim()
if (defaultSlot) {
const indentedContent = defaultSlot
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
componentContent = `\n${indentedContent}\n `
}
Object.entries(slots).forEach(([slotName, content]) => {
if (slotName !== 'default' && content?.trim()) {
const indentedSlotContent = content.trim()
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
slotContent += `\n <template #${slotName}>\n${indentedSlotContent}\n </template>`
}
})
}
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
let componentTemplate = ''
if (componentContent || slotContent) {
componentTemplate = `<U${pascalCaseName}${formattedProps}>${componentContent}${slotContent}</U${pascalCaseName}>` // Removed space before closing tag
} else {
componentTemplate = `<U${pascalCaseName}${formattedProps} />`
}
return `${scriptSetup}<template>
${componentTemplate}
</template>`
}
export function transformMDC(doc: Document): Document {
const componentName = camelCase(doc.title)
visitAndReplace(doc, 'component-theme', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.slug
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const pro = parseBoolean(attributes[':pro'])
const prose = parseBoolean(attributes[':prose'])
const appConfig = generateThemeConfig({ pro, prose, componentName: finalComponentName })
replaceNodeWithPre(
node,
'ts',
`export default defineAppConfig(${json5.stringify(appConfig, null, 2)?.replace(/,([ |\t\n]+[}|\])])/g, '$1')})`,
'app.config.ts'
)
})
visitAndReplace(doc, 'component-code', (node) => {
const attributes = node[1] as ComponentAttributes
const pro = parseBoolean(attributes[':pro'])
const props = attributes[':props'] ? json5.parse(attributes[':props']) : {}
const external = attributes[':external'] ? json5.parse(attributes[':external']) : []
const externalTypes = attributes[':externalTypes'] ? json5.parse(attributes[':externalTypes']) : []
const ignore = attributes[':ignore'] ? json5.parse(attributes[':ignore']) : []
const hide = attributes[':hide'] ? json5.parse(attributes[':hide']) : []
const slots = attributes[':slots'] ? json5.parse(attributes[':slots']) : {}
const code = generateComponentCode({
pro,
props,
external,
externalTypes,
ignore,
hide,
componentName,
slots
})
replaceNodeWithPre(node, 'vue', code)
})
visitAndReplace(doc, 'component-props', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.name
const isProse = parseBoolean(attributes[':prose'])
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const { pascalCaseName, componentMeta } = getComponentMeta(finalComponentName)
if (!componentMeta?.props) return
const interfaceName = isProse ? `Prose${pascalCaseName}Props` : `${pascalCaseName}Props`
const interfaceCode = generateTSInterface(
interfaceName,
Object.values(componentMeta.props),
propItemHandler,
`Props for the ${isProse ? 'Prose' : ''}${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-slots', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
if (!componentMeta?.slots) return
const interfaceCode = generateTSInterface(
`${pascalCaseName}Slots`,
Object.values(componentMeta.slots),
slotItemHandler,
`Slots for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-emits', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
const hasEvents = componentMeta?.events && Object.keys(componentMeta.events).length > 0
if (hasEvents) {
const interfaceCode = generateTSInterface(
`${pascalCaseName}Emits`,
Object.values(componentMeta.events),
emitItemHandler,
`Emitted events for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
} else {
node[0] = 'p'
node[1] = {}
node[2] = 'No events available for this component.'
}
})
visitAndReplace(doc, 'component-example', (node) => {
const camelName = camelCase(node[1]['name'])
const name = camelName.charAt(0).toUpperCase() + camelName.slice(1)
const code = components[name].code
replaceNodeWithPre(node, 'vue', code, `${name}.vue`)
})
return doc
}

View File

@@ -1,8 +1,8 @@
{ {
"name": "@nuxt/ui", "name": "@nuxt/ui",
"description": "A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.", "description": "A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.",
"version": "3.1.2", "version": "3.1.3",
"packageManager": "pnpm@10.11.0", "packageManager": "pnpm@10.12.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/nuxt/ui.git" "url": "git+https://github.com/nuxt/ui.git"
@@ -96,38 +96,37 @@
"scripts": { "scripts": {
"build": "nuxt-module-build build", "build": "nuxt-module-build build",
"prepack": "pnpm build", "prepack": "pnpm build",
"dev": "nuxi dev playground --uiDev", "dev": "nuxt dev playground --uiDev",
"dev:build": "nuxi build playground", "dev:build": "nuxt build playground",
"dev:vue": "vite playground-vue -- --uiDev", "dev:vue": "vite playground-vue -- --uiDev",
"dev:vue:build": "vite build playground-vue", "dev:vue:build": "vite build playground-vue",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground && nuxi prepare docs && vite build playground-vue", "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground && nuxt prepare docs && vite build playground-vue",
"docs": "nuxi dev docs --uiDev", "docs": "nuxt dev docs --uiDev",
"docs:build": "nuxi build docs", "docs:build": "nuxt build docs",
"docs:prepare": "nuxt-component-meta docs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"typecheck": "vue-tsc --noEmit && nuxi typecheck playground && nuxi typecheck docs && cd playground-vue && vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit && nuxt typecheck playground && nuxt typecheck docs && cd playground-vue && vue-tsc --noEmit",
"test": "vitest", "test": "vitest",
"test:vue": "vitest -c vitest.vue.config.ts", "test:vue": "vitest -c vitest.vue.config.ts",
"release": "release-it" "release": "release-it"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
"@internationalized/date": "^3.8.0", "@internationalized/date": "^3.8.2",
"@internationalized/number": "^3.6.1", "@internationalized/number": "^3.6.3",
"@nuxt/fonts": "^0.11.4", "@nuxt/fonts": "^0.11.4",
"@nuxt/icon": "^1.13.0", "@nuxt/icon": "^1.14.0",
"@nuxt/kit": "^3.17.4", "@nuxt/kit": "^3.17.5",
"@nuxt/schema": "^3.17.4", "@nuxt/schema": "^3.17.5",
"@nuxtjs/color-mode": "^3.5.2", "@nuxtjs/color-mode": "^3.5.2",
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.10",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.10",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@unhead/vue": "^2.0.9", "@unhead/vue": "^2.0.10",
"@vueuse/core": "^13.2.0", "@vueuse/core": "^13.4.0",
"@vueuse/integrations": "^13.2.0", "@vueuse/integrations": "^13.4.0",
"colortranslator": "^4.1.0", "colortranslator": "^5.0.0",
"consola": "^3.4.2", "consola": "^3.4.2",
"defu": "^6.1.4", "defu": "^6.1.4",
"embla-carousel-auto-height": "^8.6.0", "embla-carousel-auto-height": "^8.6.0",
@@ -144,29 +143,29 @@
"mlly": "^1.7.4", "mlly": "^1.7.4",
"ohash": "^2.0.11", "ohash": "^2.0.11",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"reka-ui": "^2.2.1", "reka-ui": "2.3.1",
"scule": "^1.3.0", "scule": "^1.3.0",
"tailwind-variants": "^1.0.0", "tailwind-variants": "^1.0.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.10",
"tinyglobby": "^0.2.13", "tinyglobby": "^0.2.14",
"unplugin": "^2.3.4", "unplugin": "^2.3.5",
"unplugin-auto-import": "^19.2.0", "unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.5.0", "unplugin-vue-components": "^28.7.0",
"vaul-vue": "^0.4.1", "vaul-vue": "0.4.1",
"vue-component-type-helpers": "^2.2.10" "vue-component-type-helpers": "^2.2.10"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint-config": "^1.4.0", "@nuxt/eslint-config": "^1.4.1",
"@nuxt/module-builder": "^1.0.1", "@nuxt/module-builder": "^1.0.1",
"@nuxt/test-utils": "^3.19.0", "@nuxt/test-utils": "^3.19.1",
"@release-it/conventional-changelog": "^10.0.1", "@release-it/conventional-changelog": "^10.0.1",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"embla-carousel": "^8.6.0", "embla-carousel": "^8.6.0",
"eslint": "^9.27.0", "eslint": "^9.29.0",
"happy-dom": "^17.4.7", "happy-dom": "^18.0.1",
"nuxt": "^3.17.4", "nuxt": "^3.17.5",
"release-it": "^19.0.2", "release-it": "^19.0.3",
"vitest": "^3.1.3", "vitest": "^3.2.4",
"vitest-environment-nuxt": "^1.0.1", "vitest-environment-nuxt": "^1.0.1",
"vue-tsc": "^2.2.10" "vue-tsc": "^2.2.10"
}, },
@@ -209,7 +208,7 @@
"debug": "4.3.7", "debug": "4.3.7",
"rollup": "4.34.9", "rollup": "4.34.9",
"unimport": "4.1.1", "unimport": "4.1.1",
"unplugin": "^2.3.4" "unplugin": "^2.3.5"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

View File

@@ -10,10 +10,10 @@
"typecheck": "vue-tsc -p ./tsconfig.app.json" "typecheck": "vue-tsc -p ./tsconfig.app.json"
}, },
"dependencies": { "dependencies": {
"@nuxt/ui": "latest", "@nuxt/ui": "workspace:*",
"vue": "^3.5.14", "vue": "^3.5.17",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"zod": "^3.24.4" "zod": "^3.25.67"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.4",

View File

@@ -40,6 +40,7 @@ const components = [
'input', 'input',
'input-menu', 'input-menu',
'input-number', 'input-number',
'input-tags',
'kbd', 'kbd',
'link', 'link',
'modal', 'modal',
@@ -61,6 +62,7 @@ const components = [
'tabs', 'tabs',
'table', 'table',
'textarea', 'textarea',
'timeline',
'toast', 'toast',
'tooltip', 'tooltip',
'tree' 'tree'

View File

@@ -40,6 +40,7 @@ const components = [
'input', 'input',
'input-menu', 'input-menu',
'input-number', 'input-number',
'input-tags',
'kbd', 'kbd',
'link', 'link',
'modal', 'modal',
@@ -61,6 +62,7 @@ const components = [
'tabs', 'tabs',
'table', 'table',
'textarea', 'textarea',
'timeline',
'toast', 'toast',
'tooltip', 'tooltip',
'tree' 'tree'

View File

@@ -2,19 +2,23 @@
import { CalendarDate } from '@internationalized/date' import { CalendarDate } from '@internationalized/date'
const singleValue = shallowRef(new CalendarDate(2022, 1, 10)) const singleValue = shallowRef(new CalendarDate(2022, 1, 10))
const multipleValue = shallowRef({ const multipleValue = shallowRef([new CalendarDate(2022, 1, 10), new CalendarDate(2022, 1, 20)])
const rangeValue = shallowRef({
start: new CalendarDate(2022, 1, 10), start: new CalendarDate(2022, 1, 10),
end: new CalendarDate(2022, 1, 20) end: new CalendarDate(2022, 1, 20)
}) })
</script> </script>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex gap-4">
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
<UCalendar v-model="singleValue" /> <UCalendar v-model="singleValue" />
</div> </div>
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
<UCalendar v-model="multipleValue" range /> <UCalendar v-model="multipleValue" multiple />
</div>
<div class="flex justify-center gap-2">
<UCalendar v-model="rangeValue" range />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -74,6 +74,51 @@ const groups = computed(() => [{
toast.add({ title: 'Label added!' }) toast.add({ title: 'Label added!' })
}, },
kbds: ['meta', 'L'] kbds: ['meta', 'L']
}, {
label: 'More actions',
placeholder: 'Search actions...',
children: [{
label: 'Create new file',
suffix: 'Create a new file in the current directory or workspace.',
icon: 'i-lucide-file-plus',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'New file added!' })
}
}, {
label: 'Create new folder',
suffix: 'Create a new folder in the current directory or workspace.',
icon: 'i-lucide-folder-plus',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'New folder added!' })
}
}, {
label: 'Share',
placeholder: 'Search share options...',
icon: 'i-lucide-share',
children: [{
label: 'Share with everyone',
suffix: 'Share with everyone in the current directory or workspace.',
icon: 'i-lucide-share',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Shared with everyone!' })
}
}, {
label: 'Share with team',
suffix: 'Share with the team in the current directory or workspace.',
icon: 'i-lucide-users',
onSelect(e: Event) {
e.preventDefault()
toast.add({ title: 'Shared with team!' })
}
}]
}]
}] }]
}]) }])

View File

@@ -145,5 +145,18 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
class="w-48" class="w-48"
/> />
</div> </div>
<div class="flex items-center gap-4">
<UInputMenu
v-for="variant in variants"
:key="variant"
:items="items"
:model-value="[fruits[0]!]"
multiple
icon="i-lucide-search"
placeholder="Search..."
:variant="variant"
class="w-48"
/>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { upperFirst } from 'scule'
import theme from '#build/ui/input-tags'
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 tags = ref(['Vue', 'Nuxt'])
</script>
<template>
<div class="flex flex-col items-center gap-4">
<div class="flex flex-col gap-4 w-48">
<UInputTags
v-model="tags"
placeholder="Enter tags..."
autofocus
/>
</div>
<div class="flex items-center gap-2">
<UInputTags
v-for="variant in variants"
:key="variant"
:placeholder="upperFirst(variant)"
:variant="variant"
class="w-48"
/>
</div>
<div class="flex items-center gap-2">
<UInputTags
v-for="variant in variants"
:key="variant"
:placeholder="upperFirst(variant)"
:variant="variant"
color="neutral"
class="w-48"
/>
</div>
<div class="flex items-center gap-2">
<UInputTags
v-for="variant in variants"
:key="variant"
:placeholder="upperFirst(variant)"
:variant="variant"
color="error"
highlight
class="w-48"
/>
</div>
<div class="flex flex-col gap-4 w-48">
<UInputTags placeholder="Disabled" disabled />
<UInputTags placeholder="Required" required />
<UInputTags loading placeholder="Loading..." />
<UInputTags loading trailing placeholder="Loading..." />
</div>
<div class="flex items-center gap-4">
<UInputTags
v-for="size in sizes"
:key="size"
:size="size"
:placeholder="upperFirst(size)"
class="w-48"
/>
</div>
<div class="flex items-center gap-4">
<UInputTags
v-for="size in sizes"
:key="size"
icon="i-lucide-search"
placeholder="Search..."
:size="size"
class="w-48"
/>
</div>
<div class="flex items-center gap-4">
<UInputTags
v-for="size in sizes"
:key="size"
icon="i-lucide-search"
trailing
placeholder="Search..."
:size="size"
class="w-48"
/>
</div>
</div>
</template>

View File

@@ -69,5 +69,13 @@ function openModal() {
</UModal> </UModal>
<UButton label="Open programmatically" color="neutral" variant="outline" @click="openModal" /> <UButton label="Open programmatically" color="neutral" variant="outline" @click="openModal" />
<UModal title="First modal">
<UButton color="neutral" variant="outline" label="Close with scoped slot close" />
<template #footer="{ close }">
<UButton label="Close with scoped slot close" @click="close" />
</template>
</UModal>
</div> </div>
</template> </template>

View File

@@ -5,7 +5,7 @@ const sizes = Object.keys(theme.variants.size) as Array<keyof typeof theme.varia
const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme.variants.variant> const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme.variants.variant>
const onComplete = (e: string[]) => { const onComplete = (e: string[]) => {
alert(e.join('')) console.log(e)
} }
</script> </script>

View File

@@ -125,5 +125,21 @@ function openSlideover() {
</USlideover> </USlideover>
<UButton label="Open programmatically" color="neutral" variant="outline" @click="openSlideover" /> <UButton label="Open programmatically" color="neutral" variant="outline" @click="openSlideover" />
<USlideover title="Slideover with scoped slot close" description="This slideover has a scoped slot close that can be used to close the slideover from within the content.">
<UButton color="neutral" variant="subtle" label="Open with scoped slot close" />
<template #header="{ close }">
<UButton label="Close with scoped slot close" @click="close" />
</template>
<template #body="{ close }">
<UButton label="Close with scoped slot close" @click="close" />
</template>
<template #footer="{ close }">
<UButton label="Close with scoped slot close" @click="close" />
</template>
</USlideover>
</div> </div>
</template> </template>

View File

@@ -3,6 +3,7 @@ import { h, resolveComponent } from 'vue'
import { upperFirst } from 'scule' import { upperFirst } from 'scule'
import type { TableColumn, TableRow } from '@nuxt/ui' import type { TableColumn, TableRow } from '@nuxt/ui'
import { getPaginationRowModel } from '@tanstack/vue-table' import { getPaginationRowModel } from '@tanstack/vue-table'
import { useClipboard } from '@vueuse/core'
const UButton = resolveComponent('UButton') const UButton = resolveComponent('UButton')
const UCheckbox = resolveComponent('UCheckbox') const UCheckbox = resolveComponent('UCheckbox')
@@ -10,6 +11,7 @@ const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu') const UDropdownMenu = resolveComponent('UDropdownMenu')
const toast = useToast() const toast = useToast()
const { copy } = useClipboard()
type Payment = { type Payment = {
id: string id: string
@@ -231,7 +233,7 @@ const columns: TableColumn<Payment>[] = [{
}, { }, {
label: 'Copy payment ID', label: 'Copy payment ID',
onSelect() { onSelect() {
navigator.clipboard.writeText(row.original.id) copy(row.original.id)
toast.add({ toast.add({
title: 'Payment ID copied to clipboard!', title: 'Payment ID copied to clipboard!',

View File

@@ -0,0 +1,63 @@
<script lang="ts" setup>
import type { TimelineItem } from '@nuxt/ui'
import theme from '#build/ui/timeline'
const sizes = Object.keys(theme.variants.size)
const colors = Object.keys(theme.variants.color)
const orientations = Object.keys(theme.variants.orientation)
const orientation = ref('vertical' as const)
const color = ref('primary' as const)
const size = ref('md' as const)
const reverse = ref(false)
const items = [{
date: 'Mar 15, 2025',
title: 'Project Kickoff',
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
icon: 'i-lucide-rocket',
value: 'kickoff'
}, {
date: 'Mar 22, 2025',
title: 'Design Phase',
description: 'User research and design workshops. Created wireframes and prototypes for user testing',
icon: 'i-lucide-palette',
value: 'design'
}, {
date: 'Mar 29, 2025',
title: 'Development Sprint',
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
icon: 'i-lucide-code',
value: 'development'
}, {
date: 'Apr 5, 2025',
title: 'Testing & Deployment',
description: 'QA testing and performance optimization. Deployed the application to production.',
icon: 'i-lucide-check-circle',
value: 'deployment'
}] satisfies TimelineItem[]
const value = ref('kickoff')
</script>
<template>
<div class="flex flex-col gap-10">
<div class="flex items-center justify-center gap-2">
<USelect v-model="color" :items="colors" placeholder="Color" />
<USelect v-model="orientation" :items="orientations" placeholder="Orientation" />
<USelect v-model="size" :items="sizes" placeholder="Size" />
<USelect v-model="value" :items="items.map(item => item.value)" placeholder="Value" />
<USelect v-model="reverse" :items="[true, false]" placeholder="Reverse" />
</div>
<UTimeline
v-model="value"
:color="color"
:orientation="orientation"
:size="size"
:items="items"
:reverse="reverse"
class="data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-96"
/>
</div>
</template>

View File

@@ -3,18 +3,19 @@
"name": "@nuxt/ui-playground", "name": "@nuxt/ui-playground",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "nuxi dev", "dev": "nuxt dev",
"build": "nuxi build", "build": "nuxt build",
"generate": "nuxi generate", "generate": "nuxt generate",
"typecheck": "nuxt typecheck" "typecheck": "nuxt typecheck"
}, },
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.44", "@iconify-json/lucide": "^1.2.51",
"@iconify-json/simple-icons": "^1.2.34", "@iconify-json/simple-icons": "^1.2.39",
"@nuxt/ui": "latest", "@internationalized/date": "^3.8.2",
"@nuxthub/core": "^0.8.27", "@nuxt/ui": "workspace:*",
"nuxt": "^3.17.4", "@nuxthub/core": "^0.9.0",
"zod": "^3.24.4" "nuxt": "^3.17.5",
"zod": "^3.25.67"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.8.3", "typescript": "^5.8.3",

5082
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,8 @@
}, { }, {
"groupName": "reka-ui", "groupName": "reka-ui",
"matchPackageNames": [ "matchPackageNames": [
"reka-ui" "reka-ui",
"vaul-vue"
] ]
}, { }, {
"matchDepTypes": ["peerDependencies"], "matchDepTypes": ["peerDependencies"],

View File

@@ -133,7 +133,6 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.alert || {})
<UButton <UButton
v-if="close" v-if="close"
:icon="closeIcon || appConfig.ui.icons.close" :icon="closeIcon || appConfig.ui.icons.close"
size="md"
color="neutral" color="neutral"
variant="link" variant="link"
:aria-label="t('alert.close')" :aria-label="t('alert.close')"

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/avatar' import theme from '#build/ui/avatar'
import type { ChipProps } from '../types'
import type { ComponentConfig } from '../types/utils' import type { ComponentConfig } from '../types/utils'
type Avatar = ComponentConfig<typeof theme, AppConfig, 'avatar'> type Avatar = ComponentConfig<typeof theme, AppConfig, 'avatar'>
@@ -22,6 +23,7 @@ export interface AvatarProps {
* @defaultValue 'md' * @defaultValue 'md'
*/ */
size?: Avatar['variants']['size'] size?: Avatar['variants']['size']
chip?: boolean | ChipProps
class?: any class?: any
style?: any style?: any
ui?: Avatar['slots'] ui?: Avatar['slots']
@@ -40,6 +42,7 @@ import ImageComponent from '#build/ui-image-component'
import { useAvatarGroup } from '../composables/useAvatarGroup' import { useAvatarGroup } from '../composables/useAvatarGroup'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
import UIcon from './Icon.vue' import UIcon from './Icon.vue'
import UChip from './Chip.vue'
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
@@ -81,7 +84,13 @@ function onError() {
</script> </script>
<template> <template>
<Primitive :as="as" :class="ui.root({ class: [props.ui?.root, props.class] })" :style="props.style"> <component
:is="props.chip ? UChip : Primitive"
:as="as"
v-bind="props.chip ? (typeof props.chip === 'object' ? { inset: true, ...props.chip } : { inset: true }) : {}"
:class="ui.root({ class: [props.ui?.root, props.class] })"
:style="props.style"
>
<component <component
:is="ImageComponent" :is="ImageComponent"
v-if="src && !error" v-if="src && !error"
@@ -101,5 +110,5 @@ function onError() {
<span v-else :class="ui.fallback({ class: props.ui?.fallback })">{{ fallback || '&nbsp;' }}</span> <span v-else :class="ui.fallback({ class: props.ui?.fallback })">{{ fallback || '&nbsp;' }}</span>
</slot> </slot>
</Slot> </Slot>
</Primitive> </component>
</template> </template>

View File

@@ -153,8 +153,8 @@ const Calendar = computed(() => props.range ? RangeCalendar : SingleCalendar)
<Calendar.Root <Calendar.Root
v-slot="{ weekDays, grid }" v-slot="{ weekDays, grid }"
v-bind="rootProps" v-bind="rootProps"
:model-value="modelValue" :model-value="(modelValue as DateValue | DateValue[])"
:default-value="defaultValue" :default-value="(defaultValue as DateValue)"
:locale="locale" :locale="locale"
:dir="dir" :dir="dir"
:class="ui.root({ class: [props.ui?.root, props.class] })" :class="ui.root({ class: [props.ui?.root, props.class] })"

View File

@@ -310,7 +310,6 @@ defineExpose({
<UButton <UButton
:disabled="!canScrollPrev" :disabled="!canScrollPrev"
:icon="prevIcon" :icon="prevIcon"
size="md"
color="neutral" color="neutral"
variant="outline" variant="outline"
:aria-label="t('carousel.prev')" :aria-label="t('carousel.prev')"
@@ -321,7 +320,6 @@ defineExpose({
<UButton <UButton
:disabled="!canScrollNext" :disabled="!canScrollNext"
:icon="nextIcon" :icon="nextIcon"
size="md"
color="neutral" color="neutral"
variant="outline" variant="outline"
:aria-label="t('carousel.next')" :aria-label="t('carousel.next')"
@@ -336,6 +334,7 @@ defineExpose({
<button <button
:aria-label="t('carousel.goto', { slide: index + 1 })" :aria-label="t('carousel.goto', { slide: index + 1 })"
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })" :class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
:data-state="selectedIndex === index ? 'active' : undefined"
@click="scrollTo(index)" @click="scrollTo(index)"
/> />
</template> </template>

View File

@@ -26,13 +26,18 @@ export interface CommandPaletteItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
loading?: boolean loading?: boolean
disabled?: boolean disabled?: boolean
slot?: string slot?: string
/**
* The placeholder to display when the item has children.
*/
placeholder?: string
children?: CommandPaletteItem[]
onSelect?(e?: Event): void onSelect?(e?: Event): void
class?: any class?: any
ui?: Pick<CommandPalette['slots'], 'item' | 'itemLeadingIcon' | 'itemLeadingAvatarSize' | 'itemLeadingAvatar' | 'itemLeadingChipSize' | 'itemLeadingChip' | 'itemLabel' | 'itemLabelPrefix' | 'itemLabelBase' | 'itemLabelSuffix' | 'itemTrailing' | 'itemTrailingKbds' | 'itemTrailingKbdsSize' | 'itemTrailingHighlightedIcon' | 'itemTrailingIcon'> ui?: Pick<CommandPalette['slots'], 'item' | 'itemLeadingIcon' | 'itemLeadingAvatarSize' | 'itemLeadingAvatar' | 'itemLeadingChipSize' | 'itemLeadingChip' | 'itemLabel' | 'itemLabelPrefix' | 'itemLabelBase' | 'itemLabelSuffix' | 'itemTrailing' | 'itemTrailingKbds' | 'itemTrailingKbdsSize' | 'itemTrailingHighlightedIcon' | 'itemTrailingIcon'>
[key: string]: any [key: string]: any
} }
export interface CommandPaletteGroup<T> { export interface CommandPaletteGroup<T extends CommandPaletteItem = CommandPaletteItem> {
id: string id: string
label?: string label?: string
slot?: string slot?: string
@@ -52,7 +57,7 @@ export interface CommandPaletteGroup<T> {
highlightedIcon?: string highlightedIcon?: string
} }
export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multiple' | 'disabled' | 'modelValue' | 'defaultValue' | 'highlightOnHover'>, Pick<UseComponentIconsProps, 'loading' | 'loadingIcon'> { export interface CommandPaletteProps<G extends CommandPaletteGroup<T> = CommandPaletteGroup<any>, T extends CommandPaletteItem = CommandPaletteItem> extends Pick<ListboxRootProps, 'multiple' | 'disabled' | 'modelValue' | 'defaultValue' | 'highlightOnHover' | 'selectionBehavior'>, Pick<UseComponentIconsProps, 'loading' | 'loadingIcon'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -70,6 +75,12 @@ export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multi
* @IconifyIcon * @IconifyIcon
*/ */
selectedIcon?: string selectedIcon?: string
/**
* The icon displayed when an item has children.
* @defaultValue appConfig.ui.icons.chevronRight
* @IconifyIcon
*/
trailingIcon?: string
/** /**
* The placeholder text for the input. * The placeholder text for the input.
* @defaultValue t('commandPalette.placeholder') * @defaultValue t('commandPalette.placeholder')
@@ -93,6 +104,18 @@ export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multi
* @IconifyIcon * @IconifyIcon
*/ */
closeIcon?: string closeIcon?: string
/**
* Display a button to navigate back in history.
* `{ size: 'md', color: 'neutral', variant: 'link' }`{lang="ts-type"}
* @defaultValue true
*/
back?: boolean | ButtonProps
/**
* The icon displayed in the back button.
* @defaultValue appConfig.ui.icons.arrowLeft
* @IconifyIcon
*/
backIcon?: string
groups?: G[] groups?: G[]
/** /**
* Options for [useFuse](https://vueuse.org/integrations/useFuse). * Options for [useFuse](https://vueuse.org/integrations/useFuse).
@@ -116,14 +139,15 @@ export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multi
ui?: CommandPalette['slots'] ui?: CommandPalette['slots']
} }
export type CommandPaletteEmits<T> = ListboxRootEmits<T> & { export type CommandPaletteEmits<T extends CommandPaletteItem = CommandPaletteItem> = ListboxRootEmits<T> & {
'update:open': [value: boolean] 'update:open': [value: boolean]
} }
type SlotProps<T> = (props: { item: T, index: number }) => any type SlotProps<T> = (props: { item: T, index: number }) => any
export type CommandPaletteSlots<G extends { slot?: string }, T extends { slot?: string }> = { export type CommandPaletteSlots<G extends CommandPaletteGroup<T> = CommandPaletteGroup<any>, T extends CommandPaletteItem = CommandPaletteItem> = {
'empty'(props: { searchTerm?: string }): any 'empty'(props: { searchTerm?: string }): any
'back'(props: { ui: { [K in keyof Required<CommandPalette['slots']>]: (props?: Record<string, any>) => string } }): any
'close'(props: { ui: { [K in keyof Required<CommandPalette['slots']>]: (props?: Record<string, any>) => string } }): any 'close'(props: { ui: { [K in keyof Required<CommandPalette['slots']>]: (props?: Record<string, any>) => string } }): any
'item': SlotProps<T> 'item': SlotProps<T>
'item-leading': SlotProps<T> 'item-leading': SlotProps<T>
@@ -134,7 +158,7 @@ export type CommandPaletteSlots<G extends { slot?: string }, T extends { slot?:
</script> </script>
<script setup lang="ts" generic="G extends CommandPaletteGroup<T>, T extends CommandPaletteItem"> <script setup lang="ts" generic="G extends CommandPaletteGroup<T>, T extends CommandPaletteItem">
import { computed } from 'vue' import { computed, ref, useTemplateRef } from 'vue'
import { ListboxRoot, ListboxFilter, ListboxContent, ListboxGroup, ListboxGroupLabel, ListboxItem, ListboxItemIndicator, useForwardProps, useForwardPropsEmits } from 'reka-ui' import { ListboxRoot, ListboxFilter, ListboxContent, ListboxGroup, ListboxGroupLabel, ListboxItem, ListboxItemIndicator, useForwardProps, useForwardPropsEmits } from 'reka-ui'
import { defu } from 'defu' import { defu } from 'defu'
import { reactivePick } from '@vueuse/core' import { reactivePick } from '@vueuse/core'
@@ -157,7 +181,8 @@ import UKbd from './Kbd.vue'
const props = withDefaults(defineProps<CommandPaletteProps<G, T>>(), { const props = withDefaults(defineProps<CommandPaletteProps<G, T>>(), {
modelValue: '', modelValue: '',
labelKey: 'label', labelKey: 'label',
autofocus: true autofocus: true,
back: true
}) })
const emits = defineEmits<CommandPaletteEmits<T>>() const emits = defineEmits<CommandPaletteEmits<T>>()
const slots = defineSlots<CommandPaletteSlots<G, T>>() const slots = defineSlots<CommandPaletteSlots<G, T>>()
@@ -167,7 +192,7 @@ const searchTerm = defineModel<string>('searchTerm', { default: '' })
const { t } = useLocale() const { t } = useLocale()
const appConfig = useAppConfig() as CommandPalette['AppConfig'] const appConfig = useAppConfig() as CommandPalette['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'disabled', 'multiple', 'modelValue', 'defaultValue', 'highlightOnHover'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'disabled', 'multiple', 'modelValue', 'defaultValue', 'highlightOnHover', 'selectionBehavior'), emits)
const inputProps = useForwardProps(reactivePick(props, 'loading', 'loadingIcon')) const inputProps = useForwardProps(reactivePick(props, 'loading', 'loadingIcon'))
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
@@ -183,18 +208,22 @@ const fuse = computed(() => defu({}, props.fuse, {
matchAllWhenSearchEmpty: true matchAllWhenSearchEmpty: true
})) }))
const items = computed(() => props.groups?.filter((group) => { const history = ref<(CommandPaletteGroup & { placeholder?: string })[]>([])
const placeholder = computed(() => history.value[history.value.length - 1]?.placeholder || props.placeholder || t('commandPalette.placeholder'))
const groups = computed(() => history.value?.length ? [history.value[history.value.length - 1] as G] : props.groups)
const items = computed(() => groups.value?.filter((group) => {
if (!group.id) { if (!group.id) {
console.warn(`[@nuxt/ui] CommandPalette group is missing an \`id\` property`) console.warn(`[@nuxt/ui] CommandPalette group is missing an \`id\` property`)
return false return false
} }
if (group.ignoreFilter) { if (group.ignoreFilter) {
return false return false
} }
return true return true
}).flatMap(group => group.items?.map(item => ({ ...item, group: group.id })) || []) || []) })?.flatMap(group => group.items?.map(item => ({ ...item, group: group.id })) || []) || [])
const { results: fuseResults } = useFuse<typeof items.value[number]>(searchTerm, items, fuse) const { results: fuseResults } = useFuse<typeof items.value[number]>(searchTerm, items, fuse)
@@ -215,7 +244,7 @@ function getGroupWithItems(group: G, items: (T & { matches?: FuseResult<T>['matc
} }
} }
const groups = computed(() => { const filteredGroups = computed(() => {
const groupsById = fuseResults.value.reduce((acc, result) => { const groupsById = fuseResults.value.reduce((acc, result) => {
const { item, matches } = result const { item, matches } = result
if (!item.group) { if (!item.group) {
@@ -229,7 +258,7 @@ const groups = computed(() => {
}, {} as Record<string, (T & { matches?: FuseResult<T>['matches'] })[]>) }, {} as Record<string, (T & { matches?: FuseResult<T>['matches'] })[]>)
const fuseGroups = Object.entries(groupsById).map(([id, items]) => { const fuseGroups = Object.entries(groupsById).map(([id, items]) => {
const group = props.groups?.find(group => group.id === id) const group = groups.value?.find(group => group.id === id)
if (!group) { if (!group) {
return return
} }
@@ -237,7 +266,7 @@ const groups = computed(() => {
return getGroupWithItems(group, items) return getGroupWithItems(group, items)
}).filter(group => !!group) }).filter(group => !!group)
const nonFuseGroups = props.groups const nonFuseGroups = groups.value
?.map((group, index) => ({ ...group, index })) ?.map((group, index) => ({ ...group, index }))
?.filter(group => group.ignoreFilter && group.items?.length) ?.filter(group => group.ignoreFilter && group.items?.length)
?.map(group => ({ ...getGroupWithItems(group, group.items || []), index: group.index })) || [] ?.map(group => ({ ...getGroupWithItems(group, group.items || []), index: group.index })) || []
@@ -247,26 +276,88 @@ const groups = computed(() => {
return acc return acc
}, [...fuseGroups]) }, [...fuseGroups])
}) })
const listboxRootRef = useTemplateRef('listboxRootRef')
function navigate(item: T) {
if (!item.children?.length) {
return
}
history.value.push({
id: `history-${history.value.length}`,
label: item.label,
slot: item.slot,
placeholder: item.placeholder,
items: item.children
} as any)
searchTerm.value = ''
listboxRootRef.value?.highlightFirstItem()
}
function navigateBack() {
if (!history.value.length) {
return
}
history.value.pop()
searchTerm.value = ''
listboxRootRef.value?.highlightFirstItem()
}
function onBackspace() {
if (!searchTerm.value) {
navigateBack()
}
}
function onSelect(e: Event, item: T) {
if (item.children?.length) {
e.preventDefault()
navigate(item)
} else {
item.onSelect?.(e)
}
}
</script> </script>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<ListboxRoot v-bind="rootProps" :class="ui.root({ class: [props.ui?.root, props.class] })"> <ListboxRoot v-bind="rootProps" ref="listboxRootRef" :class="ui.root({ class: [props.ui?.root, props.class] })">
<ListboxFilter v-model="searchTerm" as-child> <ListboxFilter v-model="searchTerm" as-child>
<UInput <UInput
:placeholder="placeholder || t('commandPalette.placeholder')" :placeholder="placeholder"
variant="none" variant="none"
:autofocus="autofocus" :autofocus="autofocus"
v-bind="inputProps" v-bind="inputProps"
:icon="icon || appConfig.ui.icons.search" :icon="icon || appConfig.ui.icons.search"
:class="ui.input({ class: props.ui?.input })" :class="ui.input({ class: props.ui?.input })"
@keydown.backspace="onBackspace"
> >
<template v-if="history?.length && (back || !!slots.back)" #leading>
<slot name="back" :ui="ui">
<UButton
:icon="backIcon || appConfig.ui.icons.arrowLeft"
color="neutral"
variant="link"
:aria-label="t('commandPalette.back')"
v-bind="(typeof back === 'object' ? back as Partial<ButtonProps> : {})"
:class="ui.back({ class: props.ui?.back })"
@click="navigateBack"
/>
</slot>
</template>
<template v-if="close || !!slots.close" #trailing> <template v-if="close || !!slots.close" #trailing>
<slot name="close" :ui="ui"> <slot name="close" :ui="ui">
<UButton <UButton
v-if="close" v-if="close"
:icon="closeIcon || appConfig.ui.icons.close" :icon="closeIcon || appConfig.ui.icons.close"
size="md"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
:aria-label="t('commandPalette.close')" :aria-label="t('commandPalette.close')"
@@ -280,8 +371,8 @@ const groups = computed(() => {
</ListboxFilter> </ListboxFilter>
<ListboxContent :class="ui.content({ class: props.ui?.content })"> <ListboxContent :class="ui.content({ class: props.ui?.content })">
<div v-if="groups?.length" role="presentation" :class="ui.viewport({ class: props.ui?.viewport })"> <div v-if="filteredGroups?.length" role="presentation" :class="ui.viewport({ class: props.ui?.viewport })">
<ListboxGroup v-for="group in groups" :key="`group-${group.id}`" :class="ui.group({ class: props.ui?.group })"> <ListboxGroup v-for="group in filteredGroups" :key="`group-${group.id}`" :class="ui.group({ class: props.ui?.group })">
<ListboxGroupLabel v-if="get(group, props.labelKey as string)" :class="ui.label({ class: props.ui?.label })"> <ListboxGroupLabel v-if="get(group, props.labelKey as string)" :class="ui.label({ class: props.ui?.label })">
{{ get(group, props.labelKey as string) }} {{ get(group, props.labelKey as string) }}
</ListboxGroupLabel> </ListboxGroupLabel>
@@ -289,10 +380,10 @@ const groups = computed(() => {
<ListboxItem <ListboxItem
v-for="(item, index) in group.items" v-for="(item, index) in group.items"
:key="`group-${group.id}-${index}`" :key="`group-${group.id}-${index}`"
:value="omit(item, ['matches' as any, 'group' as any, 'onSelect', 'labelHtml', 'suffixHtml'])" :value="omit(item, ['matches' as any, 'group' as any, 'onSelect', 'labelHtml', 'suffixHtml', 'children'])"
:disabled="item.disabled" :disabled="item.disabled"
as-child as-child
@select="item.onSelect" @select="onSelect($event, item)"
> >
<ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom> <ULink v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item)" custom>
<ULinkBase v-bind="slotProps" :class="ui.item({ class: [props.ui?.item, item.ui?.item, item.class], active: active || item.active })"> <ULinkBase v-bind="slotProps" :class="ui.item({ class: [props.ui?.item, item.ui?.item, item.class], active: active || item.active })">
@@ -323,13 +414,20 @@ const groups = computed(() => {
<span :class="ui.itemTrailing({ class: [props.ui?.itemTrailing, item.ui?.itemTrailing] })"> <span :class="ui.itemTrailing({ class: [props.ui?.itemTrailing, item.ui?.itemTrailing] })">
<slot :name="((item.slot ? `${item.slot}-trailing` : group.slot ? `${group.slot}-trailing` : `item-trailing`) as keyof CommandPaletteSlots<G, T>)" :item="(item as any)" :index="index"> <slot :name="((item.slot ? `${item.slot}-trailing` : group.slot ? `${group.slot}-trailing` : `item-trailing`) as keyof CommandPaletteSlots<G, T>)" :item="(item as any)" :index="index">
<span v-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: [props.ui?.itemTrailingKbds, item.ui?.itemTrailingKbds] })"> <UIcon
v-if="item.children && item.children.length > 0"
:name="trailingIcon || appConfig.ui.icons.chevronRight"
:class="ui.itemTrailingIcon({ class: [props.ui?.itemTrailingIcon, item.ui?.itemTrailingIcon] })"
/>
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds({ class: [props.ui?.itemTrailingKbds, item.ui?.itemTrailingKbds] })">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((item.ui?.itemTrailingKbdsSize || props.ui?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" /> <UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" :size="((item.ui?.itemTrailingKbdsSize || props.ui?.itemTrailingKbdsSize || ui.itemTrailingKbdsSize()) as KbdProps['size'])" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span> </span>
<UIcon v-else-if="group.highlightedIcon" :name="group.highlightedIcon" :class="ui.itemTrailingHighlightedIcon({ class: [props.ui?.itemTrailingHighlightedIcon, item.ui?.itemTrailingHighlightedIcon] })" /> <UIcon v-else-if="group.highlightedIcon" :name="group.highlightedIcon" :class="ui.itemTrailingHighlightedIcon({ class: [props.ui?.itemTrailingHighlightedIcon, item.ui?.itemTrailingHighlightedIcon] })" />
</slot> </slot>
<ListboxItemIndicator as-child> <ListboxItemIndicator v-if="!item.children?.length" as-child>
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: [props.ui?.itemTrailingIcon, item.ui?.itemTrailingIcon] })" /> <UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingIcon({ class: [props.ui?.itemTrailingIcon, item.ui?.itemTrailingIcon] })" />
</ListboxItemIndicator> </ListboxItemIndicator>
</span> </span>

View File

@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { DeepReadonly } from 'vue'
import type { AppConfig } from '@nuxt/schema' import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/form' import theme from '#build/ui/form'
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId, InferInput, InferOutput } from '../types/form' import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId, InferInput, InferOutput, FormData } from '../types/form'
import type { ComponentConfig } from '../types/utils' import type { ComponentConfig } from '../types/utils'
type FormConfig = ComponentConfig<typeof theme, AppConfig, 'form'> type FormConfig = ComponentConfig<typeof theme, AppConfig, 'form'>
export interface FormProps<S extends FormSchema> { export interface FormProps<S extends FormSchema, T extends boolean = true> {
id?: string | number id?: string | number
/** Schema to validate the form state. Supports Standard Schema objects, Yup, Joi, and Superstructs. */ /** Schema to validate the form state. Supports Standard Schema objects, Yup, Joi, and Superstructs. */
schema?: S schema?: S
@@ -35,7 +34,7 @@ export interface FormProps<S extends FormSchema> {
* If true, schema transformations will be applied to the state on submit. * If true, schema transformations will be applied to the state on submit.
* @defaultValue `true` * @defaultValue `true`
*/ */
transform?: boolean transform?: T
/** /**
* If true, this form will attach to its parent Form (if any) and validate at the same time. * If true, this form will attach to its parent Form (if any) and validate at the same time.
@@ -50,21 +49,21 @@ export interface FormProps<S extends FormSchema> {
*/ */
loadingAuto?: boolean loadingAuto?: boolean
class?: any class?: any
onSubmit?: ((event: FormSubmitEvent<InferOutput<S>>) => void | Promise<void>) | (() => void | Promise<void>) onSubmit?: ((event: FormSubmitEvent<FormData<S, T>>) => void | Promise<void>) | (() => void | Promise<void>)
} }
export interface FormEmits<S extends FormSchema> { export interface FormEmits<S extends FormSchema, T extends boolean = true> {
(e: 'submit', payload: FormSubmitEvent<InferOutput<S>>): void (e: 'submit', payload: FormSubmitEvent<FormData<S, T>>): void
(e: 'error', payload: FormErrorEvent): void (e: 'error', payload: FormErrorEvent): void
} }
export interface FormSlots { export interface FormSlots {
default(props?: { errors: FormError[] }): any default(props?: { errors: FormError[], loading: boolean }): any
} }
</script> </script>
<script lang="ts" setup generic="S extends FormSchema"> <script lang="ts" setup generic="S extends FormSchema, T extends boolean = true">
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue' import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly, reactive } from 'vue'
import { useEventBus } from '@vueuse/core' import { useEventBus } from '@vueuse/core'
import { useAppConfig } from '#imports' import { useAppConfig } from '#imports'
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField' import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
@@ -75,17 +74,17 @@ import { FormValidationException } from '../types/form'
type I = InferInput<S> type I = InferInput<S>
type O = InferOutput<S> type O = InferOutput<S>
const props = withDefaults(defineProps<FormProps<S>>(), { const props = withDefaults(defineProps<FormProps<S, T>>(), {
validateOn() { validateOn() {
return ['input', 'blur', 'change'] as FormInputEvents[] return ['input', 'blur', 'change'] as FormInputEvents[]
}, },
validateOnInputDelay: 300, validateOnInputDelay: 300,
attach: true, attach: true,
transform: true, transform: () => true as T,
loadingAuto: true loadingAuto: true
}) })
const emits = defineEmits<FormEmits<S>>() const emits = defineEmits<FormEmits<S, T>>()
defineSlots<FormSlots>() defineSlots<FormSlots>()
const appConfig = useAppConfig() as FormConfig['AppConfig'] const appConfig = useAppConfig() as FormConfig['AppConfig']
@@ -155,9 +154,9 @@ provide('form-errors', errors)
const inputs = ref<{ [P in keyof I]?: { id?: string, pattern?: RegExp } }>({}) const inputs = ref<{ [P in keyof I]?: { id?: string, pattern?: RegExp } }>({})
provide(formInputsInjectionKey, inputs as any) provide(formInputsInjectionKey, inputs as any)
const dirtyFields = new Set<keyof I>() const dirtyFields: Set<keyof I> = reactive(new Set<keyof I>())
const touchedFields = new Set<keyof I>() const touchedFields: Set<keyof I> = reactive(new Set<keyof I>())
const blurredFields = new Set<keyof I>() const blurredFields: Set<keyof I> = reactive(new Set<keyof I>())
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] { function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
return errs.map(err => ({ return errs.map(err => ({
@@ -183,10 +182,10 @@ async function getErrors(): Promise<FormErrorWithId[]> {
return resolveErrorIds(errs) return resolveErrorIds(errs)
} }
type ValidateOpts<Silent extends boolean> = { name?: keyof I | (keyof I)[], silent?: Silent, nested?: boolean, transform?: boolean } type ValidateOpts<Silent extends boolean, Transform extends boolean> = { name?: keyof I | (keyof I)[], silent?: Silent, nested?: boolean, transform?: Transform }
async function _validate(opts: ValidateOpts<false>): Promise<O> async function _validate<T extends boolean>(opts: ValidateOpts<false, T>): Promise<FormData<S, T>>
async function _validate(opts: ValidateOpts<true>): Promise<O | false> async function _validate<T extends boolean>(opts: ValidateOpts<true, T>): Promise<FormData<S, T> | false>
async function _validate(opts: ValidateOpts<boolean> = { silent: false, nested: true, transform: false }): Promise<O | false> { async function _validate<T extends boolean>(opts: ValidateOpts<boolean, boolean> = { silent: false, nested: true, transform: false }): Promise<FormData<S, T> | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof O)[] const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof O)[]
const nestedValidatePromises = !names && opts.nested const nestedValidatePromises = !names && opts.nested
@@ -227,7 +226,7 @@ async function _validate(opts: ValidateOpts<boolean> = { silent: false, nested:
Object.assign(props.state, transformedState.value) Object.assign(props.state, transformedState.value)
} }
return props.state as O return props.state as FormData<S, T>
} }
const loading = ref(false) const loading = ref(false)
@@ -236,7 +235,7 @@ provide(formLoadingInjectionKey, readonly(loading))
async function onSubmitWrapper(payload: Event) { async function onSubmitWrapper(payload: Event) {
loading.value = props.loadingAuto && true loading.value = props.loadingAuto && true
const event = payload as FormSubmitEvent<O> const event = payload as FormSubmitEvent<FormData<S, T>>
try { try {
event.data = await _validate({ nested: true, transform: props.transform }) event.data = await _validate({ nested: true, transform: props.transform })
@@ -265,7 +264,7 @@ provide(formOptionsInjectionKey, computed(() => ({
validateOnInputDelay: props.validateOnInputDelay validateOnInputDelay: props.validateOnInputDelay
}))) })))
defineExpose<Form<I>>({ defineExpose<Form<S>>({
validate: _validate, validate: _validate,
errors, errors,
@@ -302,9 +301,9 @@ defineExpose<Form<I>>({
loading, loading,
dirty: computed(() => !!dirtyFields.size), dirty: computed(() => !!dirtyFields.size),
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof I>>, dirtyFields: readonly(dirtyFields),
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof I>>, blurredFields: readonly(blurredFields),
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof I>> touchedFields: readonly(touchedFields)
}) })
</script> </script>
@@ -315,6 +314,6 @@ defineExpose<Form<I>>({
:class="ui({ class: props.class })" :class="ui({ class: props.class })"
@submit.prevent="onSubmitWrapper" @submit.prevent="onSubmitWrapper"
> >
<slot :errors="errors" /> <slot :errors="errors" :loading="loading" />
</component> </component>
</template> </template>

View File

@@ -379,6 +379,7 @@ function onSelect(e: Event, item: InputMenuItem) {
function isInputItem(item: InputMenuItem): item is _InputMenuItem { function isInputItem(item: InputMenuItem): item is _InputMenuItem {
return typeof item === 'object' && item !== null return typeof item === 'object' && item !== null
} }
defineExpose({ defineExpose({
inputRef inputRef
}) })

View File

@@ -7,7 +7,7 @@ import type { ComponentConfig } from '../types/utils'
type InputNumber = ComponentConfig<typeof theme, AppConfig, 'inputNumber'> type InputNumber = ComponentConfig<typeof theme, AppConfig, 'inputNumber'>
export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'stepSnapping' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions' | 'disableWheelChange'> { export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'stepSnapping' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions' | 'disableWheelChange' | 'invertWheelChange'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -98,7 +98,7 @@ defineSlots<InputNumberSlots>()
const { t, code: codeLocale } = useLocale() const { t, code: codeLocale } = useLocale()
const appConfig = useAppConfig() as InputNumber['AppConfig'] const appConfig = useAppConfig() as InputNumber['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'stepSnapping', 'formatOptions', 'disableWheelChange'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange'), emits)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props) const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formGroupSize, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputNumberProps>(props) const { orientation, size: buttonGroupSize } = useButtonGroup<InputNumberProps>(props)

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import type { AppConfig } from '@nuxt/schema'
import type { TagsInputRootProps, TagsInputRootEmits, AcceptableInputValue } from 'reka-ui'
import theme from '#build/ui/input-tags'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import type { AvatarProps } from '../types'
import type { ComponentConfig } from '../types/utils'
type InputTags = ComponentConfig<typeof theme, AppConfig, 'inputTags'>
export type InputTagItem = AcceptableInputValue
export interface InputTagsProps<T extends InputTagItem = InputTagItem> extends Pick<TagsInputRootProps<T>, 'modelValue' | 'defaultValue' | 'addOnPaste' | 'addOnTab' | 'addOnBlur' | 'duplicate' | 'disabled' | 'delimiter' | 'max' | 'id' | 'convertValue' | 'displayValue' | 'name' | 'required'>, UseComponentIconsProps {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
/** The placeholder text when the input is empty. */
placeholder?: string
/**
* @defaultValue 'primary'
*/
color?: InputTags['variants']['color']
/**
* @defaultValue 'outline'
*/
variant?: InputTags['variants']['variant']
/**
* @defaultValue 'md'
*/
size?: InputTags['variants']['size']
autofocus?: boolean
autofocusDelay?: number
/**
* The icon displayed to delete a tag.
* @defaultValue appConfig.ui.icons.close
* @IconifyIcon
*/
deleteIcon?: string
/** Highlight the ring color like a focus state. */
highlight?: boolean
class?: any
ui?: InputTags['slots']
}
export interface InputTagsEmits<T extends InputTagItem> extends TagsInputRootEmits<T> {
change: [event: Event]
blur: [event: FocusEvent]
focus: [event: FocusEvent]
}
type SlotProps<T extends InputTagItem> = (props: { item: T, index: number }) => any
export interface InputTagsSlots<T extends InputTagItem = InputTagItem> {
'leading'(props?: {}): any
'default'(props?: {}): any
'trailing'(props?: {}): any
'item-text': SlotProps<T>
'item-delete': SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends InputTagItem">
import { computed, ref, onMounted, toRaw } from 'vue'
import { TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { tv } from '../utils/tv'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputTagsProps<T>>(), {
type: 'text',
autofocusDelay: 0
})
const emits = defineEmits<InputTagsEmits<T>>()
const slots = defineSlots<InputTagsSlots<T>>()
const appConfig = useAppConfig() as InputTags['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue', 'required'), emits)
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputTagsProps>(props)
const { orientation, size: buttonGroupSize } = useButtonGroup<InputTagsProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
const inputSize = computed(() => buttonGroupSize.value || formGroupSize.value)
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputTags || {}) })({
color: color.value,
variant: props.variant,
size: inputSize?.value,
loading: props.loading,
highlight: highlight.value,
leading: isLeading.value || !!props.avatar || !!slots.leading,
trailing: isTrailing.value || !!slots.trailing,
buttonGroup: orientation.value
}))
const inputRef = ref<InstanceType<typeof TagsInputInput> | null>(null)
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function autoFocus() {
if (props.autofocus) {
inputRef.value?.$el?.focus()
}
}
function onUpdate(value: T[]) {
if (toRaw(props.modelValue) === value) {
return
}
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
emitFormChange()
emitFormInput()
}
function onBlur(event: FocusEvent) {
emits('blur', event)
emitFormBlur()
}
function onFocus(event: FocusEvent) {
emits('focus', event)
emitFormFocus()
}
defineExpose({
inputRef
})
</script>
<!-- eslint-disable vue/no-template-shadow -->
<template>
<TagsInputRoot
:id="id"
v-slot="{ modelValue: tags }"
:model-value="modelValue"
:default-value="defaultValue"
:class="ui.root({ class: [ui.base({ class: props.ui?.base }), props.ui?.root, props.class] })"
v-bind="rootProps"
:name="name"
:disabled="disabled"
@update:model-value="onUpdate"
@blur="onBlur"
@focus="onFocus"
>
<TagsInputItem
v-for="(item, index) in tags"
:key="index"
:value="item"
:class="ui.item({ class: [props.ui?.item] })"
>
<TagsInputItemText :class="ui.itemText({ class: [props.ui?.itemText] })">
<slot v-if="!!slots['item-text']" name="item-text" :item="(item as T)" :index="index" />
</TagsInputItemText>
<TagsInputItemDelete
:class="ui.itemDelete({ class: [props.ui?.itemDelete] })"
:disabled="disabled"
>
<slot name="item-delete" :item="(item as T)" :index="index">
<UIcon :name="deleteIcon || appConfig.ui.icons.close" :class="ui.itemDeleteIcon({ class: [props.ui?.itemDeleteIcon] })" />
</slot>
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput
ref="inputRef"
v-bind="{ ...$attrs, ...ariaAttrs }"
:placeholder="placeholder"
:class="ui.input({ class: props.ui?.input })"
/>
<slot />
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
<UAvatar v-else-if="!!avatar" :size="((props.ui?.leadingAvatarSize || ui.leadingAvatarSize()) as AvatarProps['size'])" v-bind="avatar" :class="ui.leadingAvatar({ class: props.ui?.leadingAvatar })" />
</slot>
</span>
<span v-if="isTrailing || !!slots.trailing" :class="ui.trailing({ class: props.ui?.trailing })">
<slot name="trailing">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon({ class: props.ui?.trailingIcon })" />
</slot>
</span>
</TagsInputRoot>
</template>

View File

@@ -61,13 +61,14 @@ export interface ModalEmits extends DialogRootEmits {
export interface ModalSlots { export interface ModalSlots {
default(props: { open: boolean }): any default(props: { open: boolean }): any
content(props?: {}): any content(props: { close: () => void }): any
header(props?: {}): any header(props: { close: () => void }): any
title(props?: {}): any title(props?: {}): any
description(props?: {}): any description(props?: {}): any
close(props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }): any actions(props?: {}): any
body(props?: {}): any close(props: { close: () => void, ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }): any
footer(props?: {}): any body(props: { close: () => void }): any
footer(props: { close: () => void }): any
} }
</script> </script>
@@ -104,15 +105,15 @@ const contentEvents = computed(() => {
} }
if (!props.dismissible) { if (!props.dismissible) {
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown', 'closeAutoFocus'] as const const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown']
type EventType = typeof events[number]
return events.reduce((acc, curr) => { return events.reduce((acc, curr) => {
acc[curr] = (e: Event) => { acc[curr] = (e: Event) => {
e.preventDefault() e.preventDefault()
emits('close:prevent') emits('close:prevent')
} }
return acc return acc
}, {} as Record<EventType, (e: Event) => void>) }, defaultEvents as Record<typeof events[number] | keyof typeof defaultEvents, (e: Event) => void>)
} }
return defaultEvents return defaultEvents
@@ -124,8 +125,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
})) }))
</script> </script>
<!-- eslint-disable vue/no-template-shadow -->
<template> <template>
<DialogRoot v-slot="{ open }" v-bind="rootProps"> <DialogRoot v-slot="{ open, close }" v-bind="rootProps">
<DialogTrigger v-if="!!slots.default" as-child :class="props.class"> <DialogTrigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" /> <slot :open="open" />
</DialogTrigger> </DialogTrigger>
@@ -148,9 +150,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
</DialogDescription> </DialogDescription>
</VisuallyHidden> </VisuallyHidden>
<slot name="content"> <slot name="content" :close="close">
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })"> <div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (props.close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
<slot name="header"> <slot name="header" :close="close">
<div :class="ui.wrapper({ class: props.ui?.wrapper })"> <div :class="ui.wrapper({ class: props.ui?.wrapper })">
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })"> <DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title"> <slot name="title">
@@ -165,16 +167,17 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
</DialogDescription> </DialogDescription>
</div> </div>
<DialogClose v-if="close || !!slots.close" as-child> <slot name="actions" />
<slot name="close" :ui="ui">
<DialogClose v-if="props.close || !!slots.close" as-child>
<slot name="close" :close="close" :ui="ui">
<UButton <UButton
v-if="close" v-if="props.close"
:icon="closeIcon || appConfig.ui.icons.close" :icon="closeIcon || appConfig.ui.icons.close"
size="md"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
:aria-label="t('modal.close')" :aria-label="t('modal.close')"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})" v-bind="(typeof props.close === 'object' ? props.close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })" :class="ui.close({ class: props.ui?.close })"
/> />
</slot> </slot>
@@ -183,11 +186,11 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
</div> </div>
<div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })"> <div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
<slot name="body" /> <slot name="body" :close="close" />
</div> </div>
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })"> <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
<slot name="footer" /> <slot name="footer" :close="close" />
</div> </div>
</slot> </slot>
</DialogContent> </DialogContent>

View File

@@ -246,20 +246,13 @@ const lists = computed<NavigationMenuItem[][]>(() =>
: [] : []
) )
function getAccordionDefaultValue(list: NavigationMenuItem[]) { function getAccordionDefaultValue(list: NavigationMenuItem[], level = 0) {
function findItemsWithDefaultOpen(items: NavigationMenuItem[], level = 0): string[] { const indexes = list.reduce((acc: string[], item, index) => {
return items.reduce((acc: string[], item, index) => { if (item.defaultOpen || item.open) {
if (item.defaultOpen || item.open) { acc.push(item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`))
acc.push(item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`)) }
} return acc
if (item.children?.length) { }, [])
acc.push(...findItemsWithDefaultOpen(item.children, level + 1))
}
return acc
}, [])
}
const indexes = findItemsWithDefaultOpen(list)
return props.type === 'single' ? indexes[0] : indexes return props.type === 'single' ? indexes[0] : indexes
} }
@@ -393,7 +386,14 @@ function getAccordionDefaultValue(list: NavigationMenuItem[]) {
</ULink> </ULink>
<AccordionContent v-if="orientation === 'vertical' && item.children?.length && !collapsed" :class="ui.content({ class: [props.ui?.content, item.ui?.content] })"> <AccordionContent v-if="orientation === 'vertical' && item.children?.length && !collapsed" :class="ui.content({ class: [props.ui?.content, item.ui?.content] })">
<ul :class="ui.childList({ class: props.ui?.childList })"> <AccordionRoot
v-bind="({
...accordionProps,
defaultValue: getAccordionDefaultValue(item.children, level + 1)
} as AccordionRootProps)"
as="ul"
:class="ui.childList({ class: props.ui?.childList })"
>
<ReuseItemTemplate <ReuseItemTemplate
v-for="(childItem, childIndex) in item.children" v-for="(childItem, childIndex) in item.children"
:key="childIndex" :key="childIndex"
@@ -402,7 +402,7 @@ function getAccordionDefaultValue(list: NavigationMenuItem[]) {
:level="level + 1" :level="level + 1"
:class="ui.childItem({ class: [props.ui?.childItem, childItem.ui?.childItem] })" :class="ui.childItem({ class: [props.ui?.childItem, childItem.ui?.childItem] })"
/> />
</ul> </AccordionRoot>
</AccordionContent> </AccordionContent>
</component> </component>
</DefineItemTemplate> </DefineItemTemplate>

View File

@@ -2,13 +2,13 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useOverlay, type Overlay } from '../composables/useOverlay' import { useOverlay, type Overlay } from '../composables/useOverlay'
const { overlays, unMount, close } = useOverlay() const { overlays, unmount, close } = useOverlay()
const mountedOverlays = computed(() => overlays.filter((overlay: Overlay) => overlay.isMounted)) const mountedOverlays = computed(() => overlays.filter((overlay: Overlay) => overlay.isMounted))
const onAfterLeave = (id: symbol) => { const onAfterLeave = (id: symbol) => {
close(id) close(id)
unMount(id) unmount(id)
} }
const onClose = (id: symbol, value: any) => { const onClose = (id: symbol, value: any) => {

View File

@@ -111,7 +111,6 @@ import { tv } from '../utils/tv'
import UButton from './Button.vue' import UButton from './Button.vue'
const props = withDefaults(defineProps<PaginationProps>(), { const props = withDefaults(defineProps<PaginationProps>(), {
size: 'md',
color: 'neutral', color: 'neutral',
variant: 'outline', variant: 'outline',
activeColor: 'primary', activeColor: 'primary',

View File

@@ -7,7 +7,9 @@ import type { ComponentConfig } from '../types/utils'
type PinInput = ComponentConfig<typeof theme, AppConfig, 'pinInput'> type PinInput = ComponentConfig<typeof theme, AppConfig, 'pinInput'>
export interface PinInputProps extends Pick<PinInputRootProps, 'defaultValue' | 'disabled' | 'id' | 'mask' | 'modelValue' | 'name' | 'otp' | 'placeholder' | 'required' | 'type'> { type PinInputType = 'text' | 'number'
export interface PinInputProps<T extends PinInputType = 'text'> extends Pick<PinInputRootProps<T>, 'defaultValue' | 'disabled' | 'id' | 'mask' | 'modelValue' | 'name' | 'otp' | 'placeholder' | 'required' | 'type'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -37,14 +39,14 @@ export interface PinInputProps extends Pick<PinInputRootProps, 'defaultValue' |
ui?: PinInput['slots'] ui?: PinInput['slots']
} }
export type PinInputEmits = PinInputRootEmits & { export type PinInputEmits<T extends PinInputType = 'text'> = PinInputRootEmits<T> & {
change: [payload: Event] change: [payload: Event]
blur: [payload: Event] blur: [payload: Event]
} }
</script> </script>
<script setup lang="ts"> <script setup lang="ts" generic="T extends PinInputType = 'text'">
import type { ComponentPublicInstance } from 'vue' import type { ComponentPublicInstance } from 'vue'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { PinInputInput, PinInputRoot, useForwardPropsEmits } from 'reka-ui' import { PinInputInput, PinInputRoot, useForwardPropsEmits } from 'reka-ui'
@@ -54,16 +56,16 @@ import { useFormField } from '../composables/useFormField'
import { looseToNumber } from '../utils' import { looseToNumber } from '../utils'
import { tv } from '../utils/tv' import { tv } from '../utils/tv'
const props = withDefaults(defineProps<PinInputProps>(), { const props = withDefaults(defineProps<PinInputProps<T>>(), {
type: 'text', type: 'text' as never,
length: 5, length: 5,
autofocusDelay: 0 autofocusDelay: 0
}) })
const emits = defineEmits<PinInputEmits>() const emits = defineEmits<PinInputEmits<T>>()
const appConfig = useAppConfig() as PinInput['AppConfig'] const appConfig = useAppConfig() as PinInput['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'required', 'type'), emits)
const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props) const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
@@ -77,7 +79,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.pinInput ||
const inputsRef = ref<ComponentPublicInstance[]>([]) const inputsRef = ref<ComponentPublicInstance[]>([])
const completed = ref(false) const completed = ref(false)
function onComplete(value: string[]) { function onComplete(value: string[] | number[]) {
// @ts-expect-error - 'target' does not exist in type 'EventInit' // @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } }) const event = new Event('change', { target: { value } })
emits('change', event) emits('change', event)
@@ -113,6 +115,7 @@ defineExpose({
v-bind="{ ...rootProps, ...ariaAttrs }" v-bind="{ ...rootProps, ...ariaAttrs }"
:id="id" :id="id"
:name="name" :name="name"
:placeholder="placeholder"
:class="ui.root({ class: [props.ui?.root, props.class] })" :class="ui.root({ class: [props.ui?.root, props.class] })"
@update:model-value="emitFormInput()" @update:model-value="emitFormInput()"
@complete="onComplete" @complete="onComplete"

View File

@@ -75,15 +75,15 @@ const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as PopoverContentProps) const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as PopoverContentProps)
const contentEvents = computed(() => { const contentEvents = computed(() => {
if (!props.dismissible) { if (!props.dismissible) {
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown'] as const const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown']
type EventType = typeof events[number]
return events.reduce((acc, curr) => { return events.reduce((acc, curr) => {
acc[curr] = (e: Event) => { acc[curr] = (e: Event) => {
e.preventDefault() e.preventDefault()
emits('close:prevent') emits('close:prevent')
} }
return acc return acc
}, {} as Record<EventType, (e: Event) => void>) }, {} as Record<typeof events[number], (e: Event) => void>)
} }
return {} return {}

View File

@@ -7,7 +7,7 @@ import type { ComponentConfig } from '../types/utils'
type Progress = ComponentConfig<typeof theme, AppConfig, 'progress'> type Progress = ComponentConfig<typeof theme, AppConfig, 'progress'>
export interface ProgressProps extends Pick<ProgressRootProps, 'getValueLabel' | 'modelValue'> { export interface ProgressProps extends Pick<ProgressRootProps, 'getValueLabel' | 'getValueText' | 'modelValue'> {
/** /**
* The element or component this component should render as. * The element or component this component should render as.
* @defaultValue 'div' * @defaultValue 'div'
@@ -70,7 +70,7 @@ const slots = defineSlots<ProgressSlots>()
const { dir } = useLocale() const { dir } = useLocale()
const appConfig = useAppConfig() as Progress['AppConfig'] const appConfig = useAppConfig() as Progress['AppConfig']
const rootProps = useForwardPropsEmits(reactivePick(props, 'getValueLabel', 'modelValue'), emits) const rootProps = useForwardPropsEmits(reactivePick(props, 'getValueLabel', 'getValueText', 'modelValue'), emits)
const isIndeterminate = computed(() => rootProps.value.modelValue === null) const isIndeterminate = computed(() => rootProps.value.modelValue === null)
const hasSteps = computed(() => Array.isArray(props.max)) const hasSteps = computed(() => Array.isArray(props.max))

View File

@@ -92,6 +92,8 @@ export interface SelectProps<T extends ArrayOrNested<SelectItem> = ArrayOrNested
multiple?: M & boolean multiple?: M & boolean
/** Highlight the ring color like a focus state. */ /** Highlight the ring color like a focus state. */
highlight?: boolean highlight?: boolean
autofocus?: boolean
autofocusDelay?: number
class?: any class?: any
ui?: Select['slots'] ui?: Select['slots']
} }
@@ -134,7 +136,7 @@ export interface SelectSlots<
</script> </script>
<script setup lang="ts" generic="T extends ArrayOrNested<SelectItem>, VK extends GetItemKeys<T> = 'value', M extends boolean = false"> <script setup lang="ts" generic="T extends ArrayOrNested<SelectItem>, VK extends GetItemKeys<T> = 'value', M extends boolean = false">
import { computed, toRef } from 'vue' import { ref, computed, onMounted, toRef } from 'vue'
import { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'reka-ui' import { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectLabel, SelectGroup, SelectItem, SelectItemIndicator, SelectItemText, SelectSeparator, useForwardPropsEmits } from 'reka-ui'
import { defu } from 'defu' import { defu } from 'defu'
import { reactivePick } from '@vueuse/core' import { reactivePick } from '@vueuse/core'
@@ -154,7 +156,8 @@ defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<SelectProps<T, VK, M>>(), { const props = withDefaults(defineProps<SelectProps<T, VK, M>>(), {
valueKey: 'value' as never, valueKey: 'value' as never,
labelKey: 'label' as never, labelKey: 'label' as never,
portal: true portal: true,
autofocusDelay: 0
}) })
const emits = defineEmits<SelectEmits<T, VK, M>>() const emits = defineEmits<SelectEmits<T, VK, M>>()
const slots = defineSlots<SelectSlots<T, VK, M>>() const slots = defineSlots<SelectSlots<T, VK, M>>()
@@ -193,15 +196,32 @@ const groups = computed<SelectItem[][]>(() =>
// eslint-disable-next-line vue/no-dupe-keys // eslint-disable-next-line vue/no-dupe-keys
const items = computed(() => groups.value.flatMap(group => group) as T[]) const items = computed(() => groups.value.flatMap(group => group) as T[])
function displayValue(value?: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string { function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string | undefined {
if (props.multiple && Array.isArray(value)) { if (props.multiple && Array.isArray(value)) {
return value.map(v => displayValue(v)).filter(Boolean).join(', ') const values = value.map(v => displayValue(v)).filter(Boolean)
return values?.length ? values.join(', ') : undefined
} }
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' ? get(item as Record<string, any>, props.valueKey as string) : item, value))
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item) return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
} }
const triggerRef = ref<InstanceType<typeof SelectTrigger> | null>(null)
function autoFocus() {
if (props.autofocus) {
triggerRef.value?.$el?.focus({
focusVisible: true
})
}
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function onUpdate(value: any) { function onUpdate(value: any) {
// @ts-expect-error - 'target' does not exist in type 'EventInit' // @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } }) const event = new Event('change', { target: { value } })
@@ -225,6 +245,10 @@ function onUpdateOpen(value: boolean) {
function isSelectItem(item: SelectItem): item is SelectItemBase { function isSelectItem(item: SelectItem): item is SelectItemBase {
return typeof item === 'object' && item !== null return typeof item === 'object' && item !== null
} }
defineExpose({
triggerRef
})
</script> </script>
<!-- eslint-disable vue/no-template-shadow --> <!-- eslint-disable vue/no-template-shadow -->
@@ -240,7 +264,7 @@ function isSelectItem(item: SelectItem): item is SelectItemBase {
@update:model-value="onUpdate" @update:model-value="onUpdate"
@update:open="onUpdateOpen" @update:open="onUpdateOpen"
> >
<SelectTrigger :id="id" :class="ui.base({ class: [props.ui?.base, props.class] })" v-bind="{ ...$attrs, ...ariaAttrs }"> <SelectTrigger :id="id" ref="triggerRef" :class="ui.base({ class: [props.ui?.base, props.class] })" v-bind="{ ...$attrs, ...ariaAttrs }">
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })"> <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui"> <slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" /> <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
@@ -250,7 +274,7 @@ function isSelectItem(item: SelectItem): item is SelectItemBase {
<slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open"> <slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
<template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue"> <template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue">
<span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })"> <span v-if="displayedModelValue !== undefined && displayedModelValue !== null" :class="ui.value({ class: props.ui?.value })">
{{ displayedModelValue }} {{ displayedModelValue }}
</span> </span>
<span v-else :class="ui.placeholder({ class: props.ui?.placeholder })"> <span v-else :class="ui.placeholder({ class: props.ui?.placeholder })">

View File

@@ -115,6 +115,8 @@ export interface SelectMenuProps<T extends ArrayOrNested<SelectMenuItem> = Array
* @defaultValue false * @defaultValue false
*/ */
ignoreFilter?: boolean ignoreFilter?: boolean
autofocus?: boolean
autofocusDelay?: number
class?: any class?: any
ui?: SelectMenu['slots'] ui?: SelectMenu['slots']
} }
@@ -165,7 +167,7 @@ export interface SelectMenuSlots<
</script> </script>
<script setup lang="ts" generic="T extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false"> <script setup lang="ts" generic="T extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false">
import { computed, toRef, toRaw } from 'vue' import { ref, computed, onMounted, toRef, toRaw } from 'vue'
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui' import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope, useForwardPropsEmits, useFilter } from 'reka-ui'
import { defu } from 'defu' import { defu } from 'defu'
import { reactivePick, createReusableTemplate } from '@vueuse/core' import { reactivePick, createReusableTemplate } from '@vueuse/core'
@@ -189,7 +191,8 @@ const props = withDefaults(defineProps<SelectMenuProps<T, VK, M>>(), {
searchInput: true, searchInput: true,
labelKey: 'label' as never, labelKey: 'label' as never,
resetSearchTermOnBlur: true, resetSearchTermOnBlur: true,
resetSearchTermOnSelect: true resetSearchTermOnSelect: true,
autofocusDelay: 0
}) })
const emits = defineEmits<SelectMenuEmits<T, VK, M>>() const emits = defineEmits<SelectMenuEmits<T, VK, M>>()
const slots = defineSlots<SelectMenuSlots<T, VK, M>>() const slots = defineSlots<SelectMenuSlots<T, VK, M>>()
@@ -225,9 +228,10 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.selectMenu |
buttonGroup: orientation.value buttonGroup: orientation.value
})) }))
function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string { function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string | undefined {
if (props.multiple && Array.isArray(value)) { if (props.multiple && Array.isArray(value)) {
return value.map(v => displayValue(v)).filter(Boolean).join(', ') const values = value.map(v => displayValue(v)).filter(Boolean)
return values?.length ? values.join(', ') : undefined
} }
if (!props.valueKey) { if (!props.valueKey) {
@@ -286,6 +290,22 @@ const createItem = computed(() => {
}) })
const createItemPosition = computed(() => typeof props.createItem === 'object' ? props.createItem.position : 'bottom') const createItemPosition = computed(() => typeof props.createItem === 'object' ? props.createItem.position : 'bottom')
const triggerRef = ref<InstanceType<typeof ComboboxTrigger> | null>(null)
function autoFocus() {
if (props.autofocus) {
triggerRef.value?.$el?.focus({
focusVisible: true
})
}
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function onUpdate(value: any) { function onUpdate(value: any) {
if (toRaw(props.modelValue) === value) { if (toRaw(props.modelValue) === value) {
return return
@@ -343,6 +363,10 @@ function onSelect(e: Event, item: SelectMenuItem) {
function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem { function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
return typeof item === 'object' && item !== null return typeof item === 'object' && item !== null
} }
defineExpose({
triggerRef
})
</script> </script>
<!-- eslint-disable vue/no-template-shadow --> <!-- eslint-disable vue/no-template-shadow -->
@@ -375,7 +399,7 @@ function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
@update:open="onUpdateOpen" @update:open="onUpdateOpen"
> >
<ComboboxAnchor as-child> <ComboboxAnchor as-child>
<ComboboxTrigger :class="ui.base({ class: [props.ui?.base, props.class] })" tabindex="0"> <ComboboxTrigger ref="triggerRef" :class="ui.base({ class: [props.ui?.base, props.class] })" tabindex="0">
<span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })"> <span v-if="isLeading || !!avatar || !!slots.leading" :class="ui.leading({ class: props.ui?.leading })">
<slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui"> <slot name="leading" :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open" :ui="ui">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" /> <UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon({ class: props.ui?.leadingIcon })" />
@@ -385,7 +409,7 @@ function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
<slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open"> <slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
<template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue"> <template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue">
<span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })"> <span v-if="displayedModelValue !== undefined && displayedModelValue !== null" :class="ui.value({ class: props.ui?.value })">
{{ displayedModelValue }} {{ displayedModelValue }}
</span> </span>
<span v-else :class="ui.placeholder({ class: props.ui?.placeholder })"> <span v-else :class="ui.placeholder({ class: props.ui?.placeholder })">

View File

@@ -61,13 +61,14 @@ export interface SlideoverEmits extends DialogRootEmits {
export interface SlideoverSlots { export interface SlideoverSlots {
default(props: { open: boolean }): any default(props: { open: boolean }): any
content(props?: {}): any content(props: { close: () => void }): any
header(props?: {}): any header(props: { close: () => void }): any
title(props?: {}): any title(props?: {}): any
description(props?: {}): any description(props?: {}): any
close(props: { ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }): any actions(props?: {}): any
body(props?: {}): any close(props: { close: () => void, ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }): any
footer(props?: {}): any body(props: { close: () => void }): any
footer(props: { close: () => void }): any
} }
</script> </script>
@@ -103,16 +104,17 @@ const contentEvents = computed(() => {
const defaultEvents = { const defaultEvents = {
closeAutoFocus: (e: Event) => e.preventDefault() closeAutoFocus: (e: Event) => e.preventDefault()
} }
if (!props.dismissible) { if (!props.dismissible) {
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown', 'closeAutoFocus'] as const const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown']
type EventType = typeof events[number]
return events.reduce((acc, curr) => { return events.reduce((acc, curr) => {
acc[curr] = (e: Event) => { acc[curr] = (e: Event) => {
e.preventDefault() e.preventDefault()
emits('close:prevent') emits('close:prevent')
} }
return acc return acc
}, {} as Record<EventType, (e: Event) => void>) }, defaultEvents as Record<typeof events[number] | keyof typeof defaultEvents, (e: Event) => void>)
} }
return defaultEvents return defaultEvents
@@ -124,8 +126,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
})) }))
</script> </script>
<!-- eslint-disable vue/no-template-shadow -->
<template> <template>
<DialogRoot v-slot="{ open }" v-bind="rootProps"> <DialogRoot v-slot="{ open, close }" v-bind="rootProps">
<DialogTrigger v-if="!!slots.default" as-child :class="props.class"> <DialogTrigger v-if="!!slots.default" as-child :class="props.class">
<slot :open="open" /> <slot :open="open" />
</DialogTrigger> </DialogTrigger>
@@ -155,9 +158,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
</DialogDescription> </DialogDescription>
</VisuallyHidden> </VisuallyHidden>
<slot name="content"> <slot name="content" :close="close">
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })"> <div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (props.close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
<slot name="header"> <slot name="header" :close="close">
<div :class="ui.wrapper({ class: props.ui?.wrapper })"> <div :class="ui.wrapper({ class: props.ui?.wrapper })">
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })"> <DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
<slot name="title"> <slot name="title">
@@ -172,16 +175,17 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
</DialogDescription> </DialogDescription>
</div> </div>
<DialogClose v-if="close || !!slots.close" as-child> <slot name="actions" />
<slot name="close" :ui="ui">
<DialogClose v-if="props.close || !!slots.close" as-child>
<slot name="close" :close="close" :ui="ui">
<UButton <UButton
v-if="close" v-if="props.close"
:icon="closeIcon || appConfig.ui.icons.close" :icon="closeIcon || appConfig.ui.icons.close"
size="md"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
:aria-label="t('slideover.close')" :aria-label="t('slideover.close')"
v-bind="(typeof close === 'object' ? close as Partial<ButtonProps> : {})" v-bind="(typeof props.close === 'object' ? props.close as Partial<ButtonProps> : {})"
:class="ui.close({ class: props.ui?.close })" :class="ui.close({ class: props.ui?.close })"
/> />
</slot> </slot>
@@ -190,11 +194,11 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
</div> </div>
<div :class="ui.body({ class: props.ui?.body })"> <div :class="ui.body({ class: props.ui?.body })">
<slot name="body" /> <slot name="body" :close="close" />
</div> </div>
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })"> <div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
<slot name="footer" /> <slot name="footer" :close="close" />
</div> </div>
</slot> </slot>
</DialogContent> </DialogContent>

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