Merge branch 'v3' into feat/1058
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @benjamincanac
|
||||
2
.github/ISSUE_TEMPLATE/bug-report-v3.yml
vendored
@@ -10,7 +10,7 @@ body:
|
||||
id: env
|
||||
attributes:
|
||||
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: |
|
||||
- Operating System: `Darwin`
|
||||
- Node Version: `v18.16.0`
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -10,7 +10,7 @@ body:
|
||||
id: env
|
||||
attributes:
|
||||
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: |
|
||||
- Operating System: `Darwin`
|
||||
- Node Version: `v18.16.0`
|
||||
|
||||
14
.github/workflows/docs.yml
vendored
@@ -6,10 +6,6 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/v3' && 'production' || 'preview' }}
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -40,14 +36,10 @@ jobs:
|
||||
- name: Prepare build
|
||||
run: pnpm run dev:prepare
|
||||
|
||||
- name: Build application
|
||||
run: pnpm run docs:build
|
||||
- name: Deploy to NuxtHub
|
||||
uses: nuxt-hub/action@v2
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
|
||||
- name: Deploy to NuxtHub
|
||||
uses: nuxt-hub/action@v1
|
||||
id: deploy
|
||||
with:
|
||||
project-key: ui-7eg3
|
||||
directory: docs/dist
|
||||
directory: docs
|
||||
|
||||
10
.github/workflows/module.yml
vendored
@@ -93,7 +93,7 @@ jobs:
|
||||
|
||||
- name: Store commit SHA
|
||||
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
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
run: pnpm install --ignore-workspace
|
||||
|
||||
- name: Prepare
|
||||
run: pnpm nuxi prepare
|
||||
run: pnpm nuxt prepare
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
|
||||
- name: Store commit SHA
|
||||
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
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
|
||||
- name: Store commit SHA
|
||||
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
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
|
||||
- name: Store commit SHA
|
||||
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
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
16
.github/workflows/playground.yml
vendored
@@ -9,10 +9,6 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/v3' && 'production' || 'preview' }}
|
||||
url: ${{ steps.deploy.outputs.deployment-url }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
@@ -40,14 +36,10 @@ jobs:
|
||||
- name: Prepare build
|
||||
run: pnpm run dev:prepare
|
||||
|
||||
- name: Build application
|
||||
run: pnpm run dev:build
|
||||
env:
|
||||
NITRO_PRESET: cloudflare-pages
|
||||
|
||||
- name: Deploy to NuxtHub
|
||||
uses: nuxt-hub/action@v1
|
||||
id: deploy
|
||||
uses: nuxt-hub/action@v2
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
with:
|
||||
project-key: ui3-playground-pb9b
|
||||
directory: playground/dist
|
||||
directory: playground
|
||||
|
||||
27
.github/workflows/reproduction.yml
vendored
Normal 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
|
||||
32
.github/workflows/stale.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: stale
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
|
||||
@@ -9,17 +10,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@4c023f01d613e60293d8004f251a18bfb9bbd71d
|
||||
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
|
||||
days-before-pr-stale: -1
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
stale-issue-label: 'stale'
|
||||
close-issue-label: 'closed-by-bot'
|
||||
close-issue-message: |
|
||||
Hi! 👋
|
||||
|
||||
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
|
||||
35
CHANGELOG.md
@@ -1,5 +1,40 @@
|
||||
# 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)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -31,8 +31,9 @@ const component = ({ name, primitive, pro, prose, content }) => {
|
||||
? `
|
||||
<script lang="ts">
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
${pro ? `import type { ComponentConfig } from '@nuxt/ui'` : ''}
|
||||
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}'` : ''}>
|
||||
|
||||
@@ -62,7 +63,7 @@ defineSlots<${upperName}Slots>()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -75,8 +76,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.${camelName}
|
||||
<script lang="ts">
|
||||
import type { ${upperName}RootProps, ${upperName}RootEmits } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
${pro ? `import type { ComponentConfig } from '@nuxt/ui'` : ''}
|
||||
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}'` : ''}>
|
||||
|
||||
@@ -105,7 +107,7 @@ const appConfig = useAppConfig() as ${upperName}['AppConfig']
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -186,6 +188,7 @@ links:${primitive
|
||||
- label: GitHub
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/${pro ? 'ui-pro' : 'ui'}/tree/v3/src/runtime/components/${upperName}.vue
|
||||
navigation.badge: Soon
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -22,9 +22,7 @@ onMounted(() => {
|
||||
|
||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
||||
|
||||
const githubLink = computed(() => {
|
||||
return `https://github.com/nuxt/${value.value}`
|
||||
})
|
||||
const githubLink = computed(() => `https://github.com/nuxt/${value.value}`)
|
||||
|
||||
const desktopLinks = computed(() => props.links.map(({ icon, ...link }) => link))
|
||||
const mobileLinks = computed(() => [
|
||||
@@ -36,6 +34,16 @@ const mobileLinks = computed(() => [
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -53,7 +61,7 @@ const mobileLinks = computed(() => [
|
||||
<UDropdownMenu
|
||||
v-slot="{ open }"
|
||||
: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' }"
|
||||
size="xs"
|
||||
>
|
||||
|
||||
77
docs/app/components/PageHeaderLinks.vue
Normal 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>
|
||||
@@ -26,6 +26,7 @@ function getEmojiFlag(locale: string): string {
|
||||
km: 'kh', // Khmer -> Cambodia
|
||||
ko: 'kr', // Korean -> South Korea
|
||||
ky: 'kg', // Kyrgyz -> Kyrgyzstan
|
||||
lb: 'lu', // Luxembourgish -> Luxembourg
|
||||
ms: 'my', // Malay -> Malaysia
|
||||
nb: 'no', // Norwegian Bokmål -> Norway
|
||||
sl: 'si', // Slovenian -> Slovenia
|
||||
|
||||
@@ -83,7 +83,7 @@ const groups = [
|
||||
</template>
|
||||
|
||||
<template #billing-label="{ item }">
|
||||
{{ item.label }}
|
||||
<span class="font-medium text-primary">{{ item.label }}</span>
|
||||
|
||||
<UBadge variant="subtle" size="sm">
|
||||
50% off
|
||||
|
||||
@@ -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>
|
||||
@@ -15,6 +15,9 @@ const schema = z.object({
|
||||
select: z.string().refine(value => value === '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', {
|
||||
message: 'Select Option 2'
|
||||
}),
|
||||
@@ -81,6 +84,10 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
<USelect v-model="state.select" :items="items" class="w-full" />
|
||||
</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">
|
||||
<USelectMenu v-model="state.selectMenu" :items="items" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
@@ -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>
|
||||
@@ -10,7 +10,7 @@ const domain = ref(domains[0])
|
||||
v-model="value"
|
||||
placeholder="nuxt"
|
||||
:ui="{
|
||||
base: 'pl-[57px]',
|
||||
base: 'pl-14.5',
|
||||
leading: 'pointer-events-none'
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
const value = ref('npx nuxi module add ui')
|
||||
const copied = ref(false)
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(value.value)
|
||||
copied.value = true
|
||||
const value = ref('npx nuxt module add ui')
|
||||
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
const { copy, copied } = useClipboard()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,7 +19,7 @@ function copy() {
|
||||
size="sm"
|
||||
:icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'"
|
||||
aria-label="Copy to clipboard"
|
||||
@click="copy"
|
||||
@click="copy(value)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -10,8 +10,8 @@ const open = ref(false)
|
||||
<Placeholder class="h-48" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
|
||||
<template #footer="{ close }">
|
||||
<UButton label="Cancel" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="Submit" color="neutral" />
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
@@ -10,8 +10,8 @@ const open = ref(false)
|
||||
<Placeholder class="h-full" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UButton label="Cancel" color="neutral" variant="outline" @click="open = false" />
|
||||
<template #footer="{ close }">
|
||||
<UButton label="Cancel" color="neutral" variant="outline" @click="close" />
|
||||
<UButton label="Submit" color="neutral" />
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import { upperFirst } from 'scule'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
const UCheckbox = resolveComponent('UCheckbox')
|
||||
@@ -9,6 +10,7 @@ const UBadge = resolveComponent('UBadge')
|
||||
const UDropdownMenu = resolveComponent('UDropdownMenu')
|
||||
|
||||
const toast = useToast()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
type Payment = {
|
||||
id: string
|
||||
@@ -220,7 +222,7 @@ const columns: TableColumn<Payment>[] = [{
|
||||
}, {
|
||||
label: 'Copy payment ID',
|
||||
onSelect() {
|
||||
navigator.clipboard.writeText(row.original.id)
|
||||
copy(row.original.id)
|
||||
|
||||
toast.add({
|
||||
title: 'Payment ID copied to clipboard!',
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import type { Row } from '@tanstack/vue-table'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UDropdownMenu = resolveComponent('UDropdownMenu')
|
||||
|
||||
const toast = useToast()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
type Payment = {
|
||||
id: string
|
||||
@@ -119,7 +121,7 @@ function getRowItems(row: Row<Payment>) {
|
||||
}, {
|
||||
label: 'Copy payment ID',
|
||||
onSelect() {
|
||||
navigator.clipboard.writeText(row.original.id)
|
||||
copy(row.original.id)
|
||||
|
||||
toast.add({
|
||||
title: 'Payment ID copied to clipboard!',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumn, DropdownMenuItem } from '@nuxt/ui'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -10,6 +11,7 @@ interface User {
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const data = ref<User[]>([{
|
||||
id: 1,
|
||||
@@ -71,7 +73,8 @@ function getDropdownActions(user: User): DropdownMenuItem[][] {
|
||||
label: 'Copy user Id',
|
||||
icon: 'i-lucide-copy',
|
||||
onSelect: () => {
|
||||
navigator.clipboard.writeText(user.id.toString())
|
||||
copy(user.id.toString())
|
||||
|
||||
toast.add({
|
||||
title: 'User ID copied to clipboard!',
|
||||
color: 'success',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> {{ item.action }}</span>
|
||||
</template>
|
||||
|
||||
<template #date="{ item }">
|
||||
{{ useTimeAgo(new Date(item.date)) }}
|
||||
</template>
|
||||
</UTimeline>
|
||||
</template>
|
||||
@@ -2,7 +2,8 @@
|
||||
import { kebabCase } from 'scule'
|
||||
import type { ContentNavigationItem } from '@nuxt/content'
|
||||
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 { framework, module } = useSharedData()
|
||||
@@ -37,7 +38,7 @@ const { data: surround } = await useAsyncData(`${kebabCase(route.path)}-surround
|
||||
|
||||
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) {
|
||||
// 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`" />
|
||||
</template>
|
||||
|
||||
<template v-if="page.links?.length" #links>
|
||||
<template #links>
|
||||
<UButton
|
||||
v-for="link in page.links"
|
||||
:key="link.label"
|
||||
@@ -154,6 +155,7 @@ const communityLinks = computed(() => [{
|
||||
<UAvatar v-bind="link.avatar" size="2xs" :alt="`${link.label} avatar`" />
|
||||
</template>
|
||||
</UButton>
|
||||
<PageHeaderLinks />
|
||||
</template>
|
||||
</UPageHeader>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
```bash [Terminal]
|
||||
npx nuxi module add compodium
|
||||
npx nuxt module add compodium
|
||||
```
|
||||
::
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
```bash [Terminal]
|
||||
npx nuxi init -t ui <my-app>
|
||||
npm create nuxt@latest -- -t ui
|
||||
```
|
||||
|
||||
::note
|
||||
|
||||
@@ -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`
|
||||
|
||||
```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:
|
||||
|
||||
```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
|
||||
|
||||
@@ -60,7 +60,7 @@ import { fr } from '@nuxt/ui-pro/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
|
||||
|
||||
@@ -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
|
||||
|
||||
To dynamically switch between languages, you can use the [Nuxt I18n](https://i18n.nuxtjs.org/) module.
|
||||
|
||||
@@ -60,7 +60,7 @@ import { fr } from '@nuxt/ui-pro/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
|
||||
|
||||
@@ -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
|
||||
|
||||
To dynamically switch between languages, you can use the [Vue I18n](https://vue-i18n.intlify.dev/) plugin.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: useOverlay
|
||||
description: "A composable to programmatically control overlays."
|
||||
description: 'A composable to programmatically control overlays.'
|
||||
---
|
||||
|
||||
## Usage
|
||||
@@ -9,9 +9,11 @@ Use the auto-imported `useOverlay` composable to programmatically control [Modal
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { LazyModalExample } from '#components'
|
||||
|
||||
const overlay = useOverlay()
|
||||
|
||||
const modal = overlay.create(MyModal)
|
||||
const modal = overlay.create(LazyModalExample)
|
||||
|
||||
async function openModal() {
|
||||
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`
|
||||
|
||||
Creates an overlay, and returns a factory instance
|
||||
Create an overlay, and return a factory instance.
|
||||
|
||||
- Parameters:
|
||||
- `component`: The overlay component
|
||||
- `options` The overlay options
|
||||
- `defaultOpen?: boolean` Opens the overlay immediately after being created `default: false`
|
||||
- `component`: The overlay component.
|
||||
- `options`:
|
||||
- `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.
|
||||
- `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>`
|
||||
|
||||
Opens the overlay using its `id`
|
||||
Open an overlay by its `id`.
|
||||
|
||||
- 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.
|
||||
|
||||
### `close(id: symbol, value?: any): void`
|
||||
|
||||
Close an overlay using its `id`
|
||||
Close an overlay by its `id`.
|
||||
|
||||
- Parameters:
|
||||
- `id`: The identifier of the overlay
|
||||
- `value`: A value to resolve the overlay promise with
|
||||
- `id`: The identifier of the overlay.
|
||||
- `value`: A value to resolve the overlay promise with.
|
||||
|
||||
### `patch(id: symbol, props: ComponentProps<T>): void`
|
||||
|
||||
Update an overlay using its `id`
|
||||
Update an overlay by its `id`.
|
||||
|
||||
- Parameters:
|
||||
- `id`: The identifier of the overlay
|
||||
- `id`: The identifier of the overlay.
|
||||
- `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:
|
||||
- `id`: The identifier of the overlay
|
||||
- `id`: The identifier of the overlay.
|
||||
|
||||
### `isOpen(id: symbol): boolean`
|
||||
|
||||
Checks if an overlay its open using its `id`
|
||||
Check if an overlay is open using its `id`.
|
||||
|
||||
- Parameters:
|
||||
- `id`: The identifier of the overlay
|
||||
- `id`: The identifier of the 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>>`
|
||||
|
||||
Opens the overlay
|
||||
Open the overlay.
|
||||
|
||||
- Parameters:
|
||||
- `props`: An optional object of props to pass to the rendered component.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { LazyModalExample } from '#components'
|
||||
|
||||
const overlay = useOverlay()
|
||||
|
||||
const modal = overlay.create(MyModalContent)
|
||||
const modal = overlay.create(LazyModalExample)
|
||||
|
||||
function openModal() {
|
||||
modal.open({
|
||||
@@ -105,23 +109,25 @@ function openModal() {
|
||||
|
||||
### `close(value?: any): void`
|
||||
|
||||
Close the overlay
|
||||
Close the overlay.
|
||||
|
||||
- Parameters:
|
||||
- `value`: A value to resolve the overlay promise with
|
||||
- `value`: A value to resolve the overlay promise with.
|
||||
|
||||
### `patch(props: ComponentProps<T>)`
|
||||
|
||||
Updates the props of the overlay.
|
||||
Update the props of the overlay.
|
||||
|
||||
- Parameters:
|
||||
- `props`: An object of props to update on the rendered component.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { LazyModalExample } from '#components'
|
||||
|
||||
const overlay = useOverlay()
|
||||
|
||||
const modal = overlay.create(MyModal, {
|
||||
const modal = overlay.create(LazyModalExample, {
|
||||
title: 'Welcome'
|
||||
})
|
||||
|
||||
@@ -141,6 +147,8 @@ Here's a complete example of how to use the `useOverlay` composable:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ModalA, ModalB, SlideoverA } from '#components'
|
||||
|
||||
const overlay = useOverlay()
|
||||
|
||||
// Create with default props
|
||||
@@ -150,7 +158,7 @@ const modalB = overlay.create(ModalB)
|
||||
const slideoverA = overlay.create(SlideoverA)
|
||||
|
||||
const openModalA = () => {
|
||||
// Open Modal A, but override the title prop
|
||||
// Open modalA, but override the title prop
|
||||
modalA.open({ title: 'Hello' })
|
||||
}
|
||||
|
||||
@@ -160,16 +168,37 @@ const openModalB = async () => {
|
||||
|
||||
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 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button @click="openModal">Open Modal</button>
|
||||
</div>
|
||||
<button @click="openModalA">Open Modal</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
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>
|
||||
```
|
||||
@@ -52,9 +52,11 @@ Each group contains an `items` array of objects that define the commands. Each i
|
||||
- `loading?: boolean`{lang="ts-type"}
|
||||
- `disabled?: boolean`{lang="ts-type"}
|
||||
- [`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"}
|
||||
- `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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
### 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.
|
||||
::
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
---
|
||||
collapse: true
|
||||
name: 'command-palette-custom-slot-example'
|
||||
class: '!p-0'
|
||||
props:
|
||||
|
||||
@@ -135,7 +135,7 @@ props:
|
||||
|
||||
### 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
|
||||
---
|
||||
@@ -166,7 +166,7 @@ Ensure to pass an array to the `default-value` prop or the `v-model` directive.
|
||||
|
||||
### 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
|
||||
---
|
||||
@@ -782,6 +782,14 @@ name: 'input-menu-countries-example'
|
||||
|
||||
: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
|
||||
|
||||
:component-theme
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: InputNumber
|
||||
description: Input numerical values with a customizable range.
|
||||
description: An input for numerical values with a customizable range.
|
||||
category: form
|
||||
links:
|
||||
- label: NumberField
|
||||
@@ -287,8 +287,8 @@ name: 'input-number-slots-example'
|
||||
When accessing the component via a template ref, you can use the following:
|
||||
|
||||
| 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
|
||||
|
||||
|
||||
285
docs/content/3.components/input-tags.md
Normal 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
|
||||
@@ -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.
|
||||
::
|
||||
|
||||
### 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
|
||||
|
||||
You can use the `#default` slot to add a floating label to the Input.
|
||||
|
||||
@@ -305,13 +305,13 @@ slots:
|
||||
|
||||
### 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
|
||||
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
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@ Use the `items` prop as an array of objects with the following properties:
|
||||
- `badge?: string | number | BadgeProps`{lang="ts-type"}
|
||||
- `tooltip?: TooltipProps`{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"}
|
||||
- `open?: boolean`{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.
|
||||
|
||||
|
||||
@@ -180,6 +180,8 @@ props:
|
||||
|
||||
:component-emits
|
||||
|
||||
### Expose
|
||||
|
||||
When accessing the component via a template ref, you can use the following:
|
||||
|
||||
| Name | Type |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -815,6 +815,14 @@ name: 'select-menu-countries-example'
|
||||
|
||||
: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
|
||||
|
||||
:component-theme
|
||||
|
||||
@@ -709,6 +709,14 @@ collapse: true
|
||||
|
||||
: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
|
||||
|
||||
:component-theme
|
||||
|
||||
@@ -304,13 +304,13 @@ slots:
|
||||
|
||||
### 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
|
||||
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
|
||||
---
|
||||
|
||||
@@ -200,6 +200,10 @@ Use the `#content` slot to customize the content of each 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"}
|
||||
|
||||
## API
|
||||
|
||||
@@ -222,6 +222,10 @@ Use the `#content` slot to customize the content of each 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"}
|
||||
|
||||
## API
|
||||
@@ -238,6 +242,14 @@ Use the `slot` property to customize a specific item.
|
||||
|
||||
: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
|
||||
|
||||
:component-theme
|
||||
|
||||
269
docs/content/3.components/timeline.md
Normal 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
|
||||
@@ -407,7 +407,14 @@ This lets you select a parent item without expanding or collapsing its children.
|
||||
|
||||
### 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
|
||||
---
|
||||
|
||||
@@ -33,7 +33,7 @@ export default defineNuxtModule((_, nuxt) => {
|
||||
}
|
||||
|
||||
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)) {
|
||||
continue
|
||||
|
||||
@@ -2,41 +2,49 @@
|
||||
"private": true,
|
||||
"name": "@nuxt/ui-docs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/vue": "^1.2.12",
|
||||
"@iconify-json/logos": "^1.2.4",
|
||||
"@iconify-json/lucide": "^1.2.44",
|
||||
"@iconify-json/simple-icons": "^1.2.34",
|
||||
"@iconify-json/vscode-icons": "^1.2.21",
|
||||
"@nuxt/content": "^3.5.1",
|
||||
"@iconify-json/lucide": "^1.2.51",
|
||||
"@iconify-json/simple-icons": "^1.2.39",
|
||||
"@iconify-json/vscode-icons": "^1.2.23",
|
||||
"@nuxt/content": "^3.6.1",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxt/ui": "latest",
|
||||
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@9038c43",
|
||||
"@nuxthub/core": "^0.8.27",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@55e248c",
|
||||
"@nuxthub/core": "^0.9.0",
|
||||
"@nuxtjs/plausible": "^1.2.0",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@vueuse/integrations": "^13.2.0",
|
||||
"@vueuse/nuxt": "^13.2.0",
|
||||
"@vueuse/integrations": "^13.4.0",
|
||||
"@vueuse/nuxt": "^13.4.0",
|
||||
"ai": "^4.3.16",
|
||||
"better-sqlite3": "^12.0.0",
|
||||
"capture-website": "^4.2.0",
|
||||
"joi": "^17.13.3",
|
||||
"motion-v": "^1.0.2",
|
||||
"nuxt": "^3.17.4",
|
||||
"maska": "^3.1.1",
|
||||
"motion-v": "^1.3.0",
|
||||
"nuxt": "^3.17.5",
|
||||
"nuxt-component-meta": "^0.11.0",
|
||||
"nuxt-llms": "^0.1.2",
|
||||
"nuxt-og-image": "^5.1.3",
|
||||
"prettier": "^3.5.3",
|
||||
"nuxt-llms": "^0.1.3",
|
||||
"nuxt-og-image": "^5.1.7",
|
||||
"prettier": "^3.6.0",
|
||||
"shiki-transformer-color-highlight": "^1.0.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"superstruct": "^2.0.2",
|
||||
"ufo": "^1.6.1",
|
||||
"valibot": "^1.1.0",
|
||||
"workers-ai-provider": "^0.4.1",
|
||||
"workers-ai-provider": "^0.7.0",
|
||||
"yup": "^1.6.1",
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.15.1"
|
||||
"wrangler": "^4.20.5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
docs/public/components/dark/changelog-version.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
docs/public/components/dark/changelog-versions.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/public/components/dark/input-tags.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/public/components/dark/timeline.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/public/components/light/changelog-version.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
docs/public/components/light/changelog-versions.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
docs/public/components/light/input-tags.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
docs/public/components/light/timeline.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -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 { 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) => {
|
||||
nitroApp.hooks.hook('content:llms:generate:document', async (_: H3Event, doc: PageCollectionItemBase) => {
|
||||
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`)
|
||||
})
|
||||
transformMDC(doc as any)
|
||||
})
|
||||
})
|
||||
|
||||
30
docs/server/routes/raw/[...slug].md.get.ts
Normal 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' })
|
||||
})
|
||||
410
docs/server/utils/transformMDC.ts
Normal 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
|
||||
}
|
||||
69
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@nuxt/ui",
|
||||
"description": "A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.",
|
||||
"version": "3.1.2",
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"version": "3.1.3",
|
||||
"packageManager": "pnpm@10.12.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nuxt/ui.git"
|
||||
@@ -96,38 +96,37 @@
|
||||
"scripts": {
|
||||
"build": "nuxt-module-build build",
|
||||
"prepack": "pnpm build",
|
||||
"dev": "nuxi dev playground --uiDev",
|
||||
"dev:build": "nuxi build playground",
|
||||
"dev": "nuxt dev playground --uiDev",
|
||||
"dev:build": "nuxt build playground",
|
||||
"dev:vue": "vite playground-vue -- --uiDev",
|
||||
"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",
|
||||
"docs": "nuxi dev docs --uiDev",
|
||||
"docs:build": "nuxi build docs",
|
||||
"docs:prepare": "nuxt-component-meta docs",
|
||||
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground && nuxt prepare docs && vite build playground-vue",
|
||||
"docs": "nuxt dev docs --uiDev",
|
||||
"docs:build": "nuxt build docs",
|
||||
"lint": "eslint .",
|
||||
"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:vue": "vitest -c vitest.vue.config.ts",
|
||||
"release": "release-it"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@internationalized/date": "^3.8.0",
|
||||
"@internationalized/number": "^3.6.1",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@internationalized/number": "^3.6.3",
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/icon": "^1.13.0",
|
||||
"@nuxt/kit": "^3.17.4",
|
||||
"@nuxt/schema": "^3.17.4",
|
||||
"@nuxt/icon": "^1.14.0",
|
||||
"@nuxt/kit": "^3.17.5",
|
||||
"@nuxt/schema": "^3.17.5",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unhead/vue": "^2.0.9",
|
||||
"@vueuse/core": "^13.2.0",
|
||||
"@vueuse/integrations": "^13.2.0",
|
||||
"colortranslator": "^4.1.0",
|
||||
"@unhead/vue": "^2.0.10",
|
||||
"@vueuse/core": "^13.4.0",
|
||||
"@vueuse/integrations": "^13.4.0",
|
||||
"colortranslator": "^5.0.0",
|
||||
"consola": "^3.4.2",
|
||||
"defu": "^6.1.4",
|
||||
"embla-carousel-auto-height": "^8.6.0",
|
||||
@@ -144,29 +143,29 @@
|
||||
"mlly": "^1.7.4",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"reka-ui": "^2.2.1",
|
||||
"reka-ui": "2.3.1",
|
||||
"scule": "^1.3.0",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tinyglobby": "^0.2.13",
|
||||
"unplugin": "^2.3.4",
|
||||
"unplugin-auto-import": "^19.2.0",
|
||||
"unplugin-vue-components": "^28.5.0",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"unplugin": "^2.3.5",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.7.0",
|
||||
"vaul-vue": "0.4.1",
|
||||
"vue-component-type-helpers": "^2.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^1.4.0",
|
||||
"@nuxt/eslint-config": "^1.4.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",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"eslint": "^9.27.0",
|
||||
"happy-dom": "^17.4.7",
|
||||
"nuxt": "^3.17.4",
|
||||
"release-it": "^19.0.2",
|
||||
"vitest": "^3.1.3",
|
||||
"eslint": "^9.29.0",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nuxt": "^3.17.5",
|
||||
"release-it": "^19.0.3",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest-environment-nuxt": "^1.0.1",
|
||||
"vue-tsc": "^2.2.10"
|
||||
},
|
||||
@@ -209,7 +208,7 @@
|
||||
"debug": "4.3.7",
|
||||
"rollup": "4.34.9",
|
||||
"unimport": "4.1.1",
|
||||
"unplugin": "^2.3.4"
|
||||
"unplugin": "^2.3.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"typecheck": "vue-tsc -p ./tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "latest",
|
||||
"vue": "^3.5.14",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
|
||||
@@ -40,6 +40,7 @@ const components = [
|
||||
'input',
|
||||
'input-menu',
|
||||
'input-number',
|
||||
'input-tags',
|
||||
'kbd',
|
||||
'link',
|
||||
'modal',
|
||||
@@ -61,6 +62,7 @@ const components = [
|
||||
'tabs',
|
||||
'table',
|
||||
'textarea',
|
||||
'timeline',
|
||||
'toast',
|
||||
'tooltip',
|
||||
'tree'
|
||||
|
||||
@@ -40,6 +40,7 @@ const components = [
|
||||
'input',
|
||||
'input-menu',
|
||||
'input-number',
|
||||
'input-tags',
|
||||
'kbd',
|
||||
'link',
|
||||
'modal',
|
||||
@@ -61,6 +62,7 @@ const components = [
|
||||
'tabs',
|
||||
'table',
|
||||
'textarea',
|
||||
'timeline',
|
||||
'toast',
|
||||
'tooltip',
|
||||
'tree'
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
import { CalendarDate } from '@internationalized/date'
|
||||
|
||||
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),
|
||||
end: new CalendarDate(2022, 1, 20)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex justify-center gap-2">
|
||||
<UCalendar v-model="singleValue" />
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -74,6 +74,51 @@ const groups = computed(() => [{
|
||||
toast.add({ title: 'Label added!' })
|
||||
},
|
||||
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!' })
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}])
|
||||
|
||||
|
||||
@@ -145,5 +145,18 @@ const { data: users, status } = await useFetch('https://jsonplaceholder.typicode
|
||||
class="w-48"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
87
playground/app/pages/components/input-tags.vue
Normal 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>
|
||||
@@ -69,5 +69,13 @@ function openModal() {
|
||||
</UModal>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -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 onComplete = (e: string[]) => {
|
||||
alert(e.join(''))
|
||||
console.log(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -125,5 +125,21 @@ function openSlideover() {
|
||||
</USlideover>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { h, resolveComponent } from 'vue'
|
||||
import { upperFirst } from 'scule'
|
||||
import type { TableColumn, TableRow } from '@nuxt/ui'
|
||||
import { getPaginationRowModel } from '@tanstack/vue-table'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
const UCheckbox = resolveComponent('UCheckbox')
|
||||
@@ -10,6 +11,7 @@ const UBadge = resolveComponent('UBadge')
|
||||
const UDropdownMenu = resolveComponent('UDropdownMenu')
|
||||
|
||||
const toast = useToast()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
type Payment = {
|
||||
id: string
|
||||
@@ -231,7 +233,7 @@ const columns: TableColumn<Payment>[] = [{
|
||||
}, {
|
||||
label: 'Copy payment ID',
|
||||
onSelect() {
|
||||
navigator.clipboard.writeText(row.original.id)
|
||||
copy(row.original.id)
|
||||
|
||||
toast.add({
|
||||
title: 'Payment ID copied to clipboard!',
|
||||
|
||||
63
playground/app/pages/components/timeline.vue
Normal 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>
|
||||
@@ -3,18 +3,19 @@
|
||||
"name": "@nuxt/ui-playground",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nuxi dev",
|
||||
"build": "nuxi build",
|
||||
"generate": "nuxi generate",
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
"generate": "nuxt generate",
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.44",
|
||||
"@iconify-json/simple-icons": "^1.2.34",
|
||||
"@nuxt/ui": "latest",
|
||||
"@nuxthub/core": "^0.8.27",
|
||||
"nuxt": "^3.17.4",
|
||||
"zod": "^3.24.4"
|
||||
"@iconify-json/lucide": "^1.2.51",
|
||||
"@iconify-json/simple-icons": "^1.2.39",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxthub/core": "^0.9.0",
|
||||
"nuxt": "^3.17.5",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
5082
pnpm-lock.yaml
generated
@@ -19,7 +19,8 @@
|
||||
}, {
|
||||
"groupName": "reka-ui",
|
||||
"matchPackageNames": [
|
||||
"reka-ui"
|
||||
"reka-ui",
|
||||
"vaul-vue"
|
||||
]
|
||||
}, {
|
||||
"matchDepTypes": ["peerDependencies"],
|
||||
|
||||
@@ -133,7 +133,6 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.alert || {})
|
||||
<UButton
|
||||
v-if="close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:aria-label="t('alert.close')"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/avatar'
|
||||
import type { ChipProps } from '../types'
|
||||
import type { ComponentConfig } from '../types/utils'
|
||||
|
||||
type Avatar = ComponentConfig<typeof theme, AppConfig, 'avatar'>
|
||||
@@ -22,6 +23,7 @@ export interface AvatarProps {
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: Avatar['variants']['size']
|
||||
chip?: boolean | ChipProps
|
||||
class?: any
|
||||
style?: any
|
||||
ui?: Avatar['slots']
|
||||
@@ -40,6 +42,7 @@ import ImageComponent from '#build/ui-image-component'
|
||||
import { useAvatarGroup } from '../composables/useAvatarGroup'
|
||||
import { tv } from '../utils/tv'
|
||||
import UIcon from './Icon.vue'
|
||||
import UChip from './Chip.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
@@ -81,7 +84,13 @@ function onError() {
|
||||
</script>
|
||||
|
||||
<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
|
||||
:is="ImageComponent"
|
||||
v-if="src && !error"
|
||||
@@ -101,5 +110,5 @@ function onError() {
|
||||
<span v-else :class="ui.fallback({ class: props.ui?.fallback })">{{ fallback || ' ' }}</span>
|
||||
</slot>
|
||||
</Slot>
|
||||
</Primitive>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -153,8 +153,8 @@ const Calendar = computed(() => props.range ? RangeCalendar : SingleCalendar)
|
||||
<Calendar.Root
|
||||
v-slot="{ weekDays, grid }"
|
||||
v-bind="rootProps"
|
||||
:model-value="modelValue"
|
||||
:default-value="defaultValue"
|
||||
:model-value="(modelValue as DateValue | DateValue[])"
|
||||
:default-value="(defaultValue as DateValue)"
|
||||
:locale="locale"
|
||||
:dir="dir"
|
||||
:class="ui.root({ class: [props.ui?.root, props.class] })"
|
||||
|
||||
@@ -310,7 +310,6 @@ defineExpose({
|
||||
<UButton
|
||||
:disabled="!canScrollPrev"
|
||||
:icon="prevIcon"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('carousel.prev')"
|
||||
@@ -321,7 +320,6 @@ defineExpose({
|
||||
<UButton
|
||||
:disabled="!canScrollNext"
|
||||
:icon="nextIcon"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('carousel.next')"
|
||||
@@ -336,6 +334,7 @@ defineExpose({
|
||||
<button
|
||||
:aria-label="t('carousel.goto', { slide: index + 1 })"
|
||||
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
|
||||
:data-state="selectedIndex === index ? 'active' : undefined"
|
||||
@click="scrollTo(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -26,13 +26,18 @@ export interface CommandPaletteItem extends Omit<LinkProps, 'type' | 'raw' | 'cu
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
slot?: string
|
||||
/**
|
||||
* The placeholder to display when the item has children.
|
||||
*/
|
||||
placeholder?: string
|
||||
children?: CommandPaletteItem[]
|
||||
onSelect?(e?: Event): void
|
||||
class?: any
|
||||
ui?: Pick<CommandPalette['slots'], 'item' | 'itemLeadingIcon' | 'itemLeadingAvatarSize' | 'itemLeadingAvatar' | 'itemLeadingChipSize' | 'itemLeadingChip' | 'itemLabel' | 'itemLabelPrefix' | 'itemLabelBase' | 'itemLabelSuffix' | 'itemTrailing' | 'itemTrailingKbds' | 'itemTrailingKbdsSize' | 'itemTrailingHighlightedIcon' | 'itemTrailingIcon'>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface CommandPaletteGroup<T> {
|
||||
export interface CommandPaletteGroup<T extends CommandPaletteItem = CommandPaletteItem> {
|
||||
id: string
|
||||
label?: string
|
||||
slot?: string
|
||||
@@ -52,7 +57,7 @@ export interface CommandPaletteGroup<T> {
|
||||
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.
|
||||
* @defaultValue 'div'
|
||||
@@ -70,6 +75,12 @@ export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multi
|
||||
* @IconifyIcon
|
||||
*/
|
||||
selectedIcon?: string
|
||||
/**
|
||||
* The icon displayed when an item has children.
|
||||
* @defaultValue appConfig.ui.icons.chevronRight
|
||||
* @IconifyIcon
|
||||
*/
|
||||
trailingIcon?: string
|
||||
/**
|
||||
* The placeholder text for the input.
|
||||
* @defaultValue t('commandPalette.placeholder')
|
||||
@@ -93,6 +104,18 @@ export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multi
|
||||
* @IconifyIcon
|
||||
*/
|
||||
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[]
|
||||
/**
|
||||
* Options for [useFuse](https://vueuse.org/integrations/useFuse).
|
||||
@@ -116,14 +139,15 @@ export interface CommandPaletteProps<G, T> extends Pick<ListboxRootProps, 'multi
|
||||
ui?: CommandPalette['slots']
|
||||
}
|
||||
|
||||
export type CommandPaletteEmits<T> = ListboxRootEmits<T> & {
|
||||
export type CommandPaletteEmits<T extends CommandPaletteItem = CommandPaletteItem> = ListboxRootEmits<T> & {
|
||||
'update:open': [value: boolean]
|
||||
}
|
||||
|
||||
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
|
||||
'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
|
||||
'item': SlotProps<T>
|
||||
'item-leading': SlotProps<T>
|
||||
@@ -134,7 +158,7 @@ export type CommandPaletteSlots<G extends { slot?: string }, T extends { slot?:
|
||||
</script>
|
||||
|
||||
<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 { defu } from 'defu'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
@@ -157,7 +181,8 @@ import UKbd from './Kbd.vue'
|
||||
const props = withDefaults(defineProps<CommandPaletteProps<G, T>>(), {
|
||||
modelValue: '',
|
||||
labelKey: 'label',
|
||||
autofocus: true
|
||||
autofocus: true,
|
||||
back: true
|
||||
})
|
||||
const emits = defineEmits<CommandPaletteEmits<T>>()
|
||||
const slots = defineSlots<CommandPaletteSlots<G, T>>()
|
||||
@@ -167,7 +192,7 @@ const searchTerm = defineModel<string>('searchTerm', { default: '' })
|
||||
const { t } = useLocale()
|
||||
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'))
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
@@ -183,18 +208,22 @@ const fuse = computed(() => defu({}, props.fuse, {
|
||||
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) {
|
||||
console.warn(`[@nuxt/ui] CommandPalette group is missing an \`id\` property`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (group.ignoreFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 { item, matches } = result
|
||||
if (!item.group) {
|
||||
@@ -229,7 +258,7 @@ const groups = computed(() => {
|
||||
}, {} as Record<string, (T & { matches?: FuseResult<T>['matches'] })[]>)
|
||||
|
||||
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) {
|
||||
return
|
||||
}
|
||||
@@ -237,7 +266,7 @@ const groups = computed(() => {
|
||||
return getGroupWithItems(group, items)
|
||||
}).filter(group => !!group)
|
||||
|
||||
const nonFuseGroups = props.groups
|
||||
const nonFuseGroups = groups.value
|
||||
?.map((group, index) => ({ ...group, index }))
|
||||
?.filter(group => group.ignoreFilter && group.items?.length)
|
||||
?.map(group => ({ ...getGroupWithItems(group, group.items || []), index: group.index })) || []
|
||||
@@ -247,26 +276,88 @@ const groups = computed(() => {
|
||||
return acc
|
||||
}, [...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>
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<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>
|
||||
<UInput
|
||||
:placeholder="placeholder || t('commandPalette.placeholder')"
|
||||
:placeholder="placeholder"
|
||||
variant="none"
|
||||
:autofocus="autofocus"
|
||||
v-bind="inputProps"
|
||||
:icon="icon || appConfig.ui.icons.search"
|
||||
: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>
|
||||
<slot name="close" :ui="ui">
|
||||
<UButton
|
||||
v-if="close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('commandPalette.close')"
|
||||
@@ -280,8 +371,8 @@ const groups = computed(() => {
|
||||
</ListboxFilter>
|
||||
|
||||
<ListboxContent :class="ui.content({ class: props.ui?.content })">
|
||||
<div v-if="groups?.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 })">
|
||||
<div v-if="filteredGroups?.length" role="presentation" :class="ui.viewport({ class: props.ui?.viewport })">
|
||||
<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 })">
|
||||
{{ get(group, props.labelKey as string) }}
|
||||
</ListboxGroupLabel>
|
||||
@@ -289,10 +380,10 @@ const groups = computed(() => {
|
||||
<ListboxItem
|
||||
v-for="(item, index) in group.items"
|
||||
: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"
|
||||
as-child
|
||||
@select="item.onSelect"
|
||||
@select="onSelect($event, item)"
|
||||
>
|
||||
<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 })">
|
||||
@@ -323,13 +414,20 @@ const groups = computed(() => {
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
</span>
|
||||
|
||||
<UIcon v-else-if="group.highlightedIcon" :name="group.highlightedIcon" :class="ui.itemTrailingHighlightedIcon({ class: [props.ui?.itemTrailingHighlightedIcon, item.ui?.itemTrailingHighlightedIcon] })" />
|
||||
</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] })" />
|
||||
</ListboxItemIndicator>
|
||||
</span>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { DeepReadonly } from 'vue'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
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'
|
||||
|
||||
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
|
||||
/** Schema to validate the form state. Supports Standard Schema objects, Yup, Joi, and Superstructs. */
|
||||
schema?: S
|
||||
@@ -35,7 +34,7 @@ export interface FormProps<S extends FormSchema> {
|
||||
* If true, schema transformations will be applied to the state on submit.
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
transform?: boolean
|
||||
transform?: T
|
||||
|
||||
/**
|
||||
* 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
|
||||
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> {
|
||||
(e: 'submit', payload: FormSubmitEvent<InferOutput<S>>): void
|
||||
export interface FormEmits<S extends FormSchema, T extends boolean = true> {
|
||||
(e: 'submit', payload: FormSubmitEvent<FormData<S, T>>): void
|
||||
(e: 'error', payload: FormErrorEvent): void
|
||||
}
|
||||
|
||||
export interface FormSlots {
|
||||
default(props?: { errors: FormError[] }): any
|
||||
default(props?: { errors: FormError[], loading: boolean }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup generic="S extends FormSchema">
|
||||
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
|
||||
<script lang="ts" setup generic="S extends FormSchema, T extends boolean = true">
|
||||
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly, reactive } from 'vue'
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
|
||||
@@ -75,17 +74,17 @@ import { FormValidationException } from '../types/form'
|
||||
type I = InferInput<S>
|
||||
type O = InferOutput<S>
|
||||
|
||||
const props = withDefaults(defineProps<FormProps<S>>(), {
|
||||
const props = withDefaults(defineProps<FormProps<S, T>>(), {
|
||||
validateOn() {
|
||||
return ['input', 'blur', 'change'] as FormInputEvents[]
|
||||
},
|
||||
validateOnInputDelay: 300,
|
||||
attach: true,
|
||||
transform: true,
|
||||
transform: () => true as T,
|
||||
loadingAuto: true
|
||||
})
|
||||
|
||||
const emits = defineEmits<FormEmits<S>>()
|
||||
const emits = defineEmits<FormEmits<S, T>>()
|
||||
defineSlots<FormSlots>()
|
||||
|
||||
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 } }>({})
|
||||
provide(formInputsInjectionKey, inputs as any)
|
||||
|
||||
const dirtyFields = new Set<keyof I>()
|
||||
const touchedFields = new Set<keyof I>()
|
||||
const blurredFields = new Set<keyof I>()
|
||||
const dirtyFields: Set<keyof I> = reactive(new Set<keyof I>())
|
||||
const touchedFields: Set<keyof I> = reactive(new Set<keyof I>())
|
||||
const blurredFields: Set<keyof I> = reactive(new Set<keyof I>())
|
||||
|
||||
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
|
||||
return errs.map(err => ({
|
||||
@@ -183,10 +182,10 @@ async function getErrors(): Promise<FormErrorWithId[]> {
|
||||
return resolveErrorIds(errs)
|
||||
}
|
||||
|
||||
type ValidateOpts<Silent extends boolean> = { name?: keyof I | (keyof I)[], silent?: Silent, nested?: boolean, transform?: boolean }
|
||||
async function _validate(opts: ValidateOpts<false>): Promise<O>
|
||||
async function _validate(opts: ValidateOpts<true>): Promise<O | false>
|
||||
async function _validate(opts: ValidateOpts<boolean> = { silent: false, nested: true, transform: false }): Promise<O | false> {
|
||||
type ValidateOpts<Silent extends boolean, Transform extends boolean> = { name?: keyof I | (keyof I)[], silent?: Silent, nested?: boolean, transform?: Transform }
|
||||
async function _validate<T extends boolean>(opts: ValidateOpts<false, T>): Promise<FormData<S, T>>
|
||||
async function _validate<T extends boolean>(opts: ValidateOpts<true, T>): Promise<FormData<S, T> | 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 nestedValidatePromises = !names && opts.nested
|
||||
@@ -227,7 +226,7 @@ async function _validate(opts: ValidateOpts<boolean> = { silent: false, nested:
|
||||
Object.assign(props.state, transformedState.value)
|
||||
}
|
||||
|
||||
return props.state as O
|
||||
return props.state as FormData<S, T>
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -236,7 +235,7 @@ provide(formLoadingInjectionKey, readonly(loading))
|
||||
async function onSubmitWrapper(payload: Event) {
|
||||
loading.value = props.loadingAuto && true
|
||||
|
||||
const event = payload as FormSubmitEvent<O>
|
||||
const event = payload as FormSubmitEvent<FormData<S, T>>
|
||||
|
||||
try {
|
||||
event.data = await _validate({ nested: true, transform: props.transform })
|
||||
@@ -265,7 +264,7 @@ provide(formOptionsInjectionKey, computed(() => ({
|
||||
validateOnInputDelay: props.validateOnInputDelay
|
||||
})))
|
||||
|
||||
defineExpose<Form<I>>({
|
||||
defineExpose<Form<S>>({
|
||||
validate: _validate,
|
||||
errors,
|
||||
|
||||
@@ -302,9 +301,9 @@ defineExpose<Form<I>>({
|
||||
loading,
|
||||
dirty: computed(() => !!dirtyFields.size),
|
||||
|
||||
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof I>>,
|
||||
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof I>>,
|
||||
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof I>>
|
||||
dirtyFields: readonly(dirtyFields),
|
||||
blurredFields: readonly(blurredFields),
|
||||
touchedFields: readonly(touchedFields)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -315,6 +314,6 @@ defineExpose<Form<I>>({
|
||||
:class="ui({ class: props.class })"
|
||||
@submit.prevent="onSubmitWrapper"
|
||||
>
|
||||
<slot :errors="errors" />
|
||||
<slot :errors="errors" :loading="loading" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -379,6 +379,7 @@ function onSelect(e: Event, item: InputMenuItem) {
|
||||
function isInputItem(item: InputMenuItem): item is _InputMenuItem {
|
||||
return typeof item === 'object' && item !== null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
inputRef
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ComponentConfig } from '../types/utils'
|
||||
|
||||
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.
|
||||
* @defaultValue 'div'
|
||||
@@ -98,7 +98,7 @@ defineSlots<InputNumberSlots>()
|
||||
const { t, code: codeLocale } = useLocale()
|
||||
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 { orientation, size: buttonGroupSize } = useButtonGroup<InputNumberProps>(props)
|
||||
|
||||
203
src/runtime/components/InputTags.vue
Normal 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>
|
||||
@@ -61,13 +61,14 @@ export interface ModalEmits extends DialogRootEmits {
|
||||
|
||||
export interface ModalSlots {
|
||||
default(props: { open: boolean }): any
|
||||
content(props?: {}): any
|
||||
header(props?: {}): any
|
||||
content(props: { close: () => void }): any
|
||||
header(props: { close: () => void }): any
|
||||
title(props?: {}): any
|
||||
description(props?: {}): any
|
||||
close(props: { ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }): any
|
||||
body(props?: {}): any
|
||||
footer(props?: {}): any
|
||||
actions(props?: {}): any
|
||||
close(props: { close: () => void, ui: { [K in keyof Required<Modal['slots']>]: (props?: Record<string, any>) => string } }): any
|
||||
body(props: { close: () => void }): any
|
||||
footer(props: { close: () => void }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -104,15 +105,15 @@ const contentEvents = computed(() => {
|
||||
}
|
||||
|
||||
if (!props.dismissible) {
|
||||
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown', 'closeAutoFocus'] as const
|
||||
type EventType = typeof events[number]
|
||||
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown']
|
||||
|
||||
return events.reduce((acc, curr) => {
|
||||
acc[curr] = (e: Event) => {
|
||||
e.preventDefault()
|
||||
emits('close:prevent')
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<EventType, (e: Event) => void>)
|
||||
}, defaultEvents as Record<typeof events[number] | keyof typeof defaultEvents, (e: Event) => void>)
|
||||
}
|
||||
|
||||
return defaultEvents
|
||||
@@ -124,8 +125,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
|
||||
}))
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
<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">
|
||||
<slot :open="open" />
|
||||
</DialogTrigger>
|
||||
@@ -148,9 +150,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
|
||||
<slot name="content">
|
||||
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
|
||||
<slot name="header">
|
||||
<slot name="content" :close="close">
|
||||
<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" :close="close">
|
||||
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
|
||||
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
|
||||
<slot name="title">
|
||||
@@ -165,16 +167,17 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogClose v-if="close || !!slots.close" as-child>
|
||||
<slot name="close" :ui="ui">
|
||||
<slot name="actions" />
|
||||
|
||||
<DialogClose v-if="props.close || !!slots.close" as-child>
|
||||
<slot name="close" :close="close" :ui="ui">
|
||||
<UButton
|
||||
v-if="close"
|
||||
v-if="props.close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
: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 })"
|
||||
/>
|
||||
</slot>
|
||||
@@ -183,11 +186,11 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
|
||||
</div>
|
||||
|
||||
<div v-if="!!slots.body" :class="ui.body({ class: props.ui?.body })">
|
||||
<slot name="body" />
|
||||
<slot name="body" :close="close" />
|
||||
</div>
|
||||
|
||||
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
|
||||
<slot name="footer" />
|
||||
<slot name="footer" :close="close" />
|
||||
</div>
|
||||
</slot>
|
||||
</DialogContent>
|
||||
|
||||
@@ -246,20 +246,13 @@ const lists = computed<NavigationMenuItem[][]>(() =>
|
||||
: []
|
||||
)
|
||||
|
||||
function getAccordionDefaultValue(list: NavigationMenuItem[]) {
|
||||
function findItemsWithDefaultOpen(items: NavigationMenuItem[], level = 0): string[] {
|
||||
return items.reduce((acc: string[], item, index) => {
|
||||
if (item.defaultOpen || item.open) {
|
||||
acc.push(item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`))
|
||||
}
|
||||
if (item.children?.length) {
|
||||
acc.push(...findItemsWithDefaultOpen(item.children, level + 1))
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
const indexes = findItemsWithDefaultOpen(list)
|
||||
function getAccordionDefaultValue(list: NavigationMenuItem[], level = 0) {
|
||||
const indexes = list.reduce((acc: string[], item, index) => {
|
||||
if (item.defaultOpen || item.open) {
|
||||
acc.push(item.value || (level > 0 ? `item-${level}-${index}` : `item-${index}`))
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return props.type === 'single' ? indexes[0] : indexes
|
||||
}
|
||||
@@ -393,7 +386,14 @@ function getAccordionDefaultValue(list: NavigationMenuItem[]) {
|
||||
</ULink>
|
||||
|
||||
<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
|
||||
v-for="(childItem, childIndex) in item.children"
|
||||
:key="childIndex"
|
||||
@@ -402,7 +402,7 @@ function getAccordionDefaultValue(list: NavigationMenuItem[]) {
|
||||
:level="level + 1"
|
||||
:class="ui.childItem({ class: [props.ui?.childItem, childItem.ui?.childItem] })"
|
||||
/>
|
||||
</ul>
|
||||
</AccordionRoot>
|
||||
</AccordionContent>
|
||||
</component>
|
||||
</DefineItemTemplate>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { computed } from 'vue'
|
||||
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 onAfterLeave = (id: symbol) => {
|
||||
close(id)
|
||||
unMount(id)
|
||||
unmount(id)
|
||||
}
|
||||
|
||||
const onClose = (id: symbol, value: any) => {
|
||||
|
||||
@@ -111,7 +111,6 @@ import { tv } from '../utils/tv'
|
||||
import UButton from './Button.vue'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationProps>(), {
|
||||
size: 'md',
|
||||
color: 'neutral',
|
||||
variant: 'outline',
|
||||
activeColor: 'primary',
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { ComponentConfig } from '../types/utils'
|
||||
|
||||
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.
|
||||
* @defaultValue 'div'
|
||||
@@ -37,14 +39,14 @@ export interface PinInputProps extends Pick<PinInputRootProps, 'defaultValue' |
|
||||
ui?: PinInput['slots']
|
||||
}
|
||||
|
||||
export type PinInputEmits = PinInputRootEmits & {
|
||||
export type PinInputEmits<T extends PinInputType = 'text'> = PinInputRootEmits<T> & {
|
||||
change: [payload: Event]
|
||||
blur: [payload: Event]
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts" generic="T extends PinInputType = 'text'">
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { PinInputInput, PinInputRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
@@ -54,16 +56,16 @@ import { useFormField } from '../composables/useFormField'
|
||||
import { looseToNumber } from '../utils'
|
||||
import { tv } from '../utils/tv'
|
||||
|
||||
const props = withDefaults(defineProps<PinInputProps>(), {
|
||||
type: 'text',
|
||||
const props = withDefaults(defineProps<PinInputProps<T>>(), {
|
||||
type: 'text' as never,
|
||||
length: 5,
|
||||
autofocusDelay: 0
|
||||
})
|
||||
const emits = defineEmits<PinInputEmits>()
|
||||
const emits = defineEmits<PinInputEmits<T>>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -77,7 +79,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.pinInput ||
|
||||
const inputsRef = ref<ComponentPublicInstance[]>([])
|
||||
|
||||
const completed = ref(false)
|
||||
function onComplete(value: string[]) {
|
||||
function onComplete(value: string[] | number[]) {
|
||||
// @ts-expect-error - 'target' does not exist in type 'EventInit'
|
||||
const event = new Event('change', { target: { value } })
|
||||
emits('change', event)
|
||||
@@ -113,6 +115,7 @@ defineExpose({
|
||||
v-bind="{ ...rootProps, ...ariaAttrs }"
|
||||
:id="id"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
:class="ui.root({ class: [props.ui?.root, props.class] })"
|
||||
@update:model-value="emitFormInput()"
|
||||
@complete="onComplete"
|
||||
|
||||
@@ -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 contentEvents = computed(() => {
|
||||
if (!props.dismissible) {
|
||||
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown'] as const
|
||||
type EventType = typeof events[number]
|
||||
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown']
|
||||
|
||||
return events.reduce((acc, curr) => {
|
||||
acc[curr] = (e: Event) => {
|
||||
e.preventDefault()
|
||||
emits('close:prevent')
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<EventType, (e: Event) => void>)
|
||||
}, {} as Record<typeof events[number], (e: Event) => void>)
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ComponentConfig } from '../types/utils'
|
||||
|
||||
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.
|
||||
* @defaultValue 'div'
|
||||
@@ -70,7 +70,7 @@ const slots = defineSlots<ProgressSlots>()
|
||||
const { dir } = useLocale()
|
||||
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 hasSteps = computed(() => Array.isArray(props.max))
|
||||
|
||||
@@ -92,6 +92,8 @@ export interface SelectProps<T extends ArrayOrNested<SelectItem> = ArrayOrNested
|
||||
multiple?: M & boolean
|
||||
/** Highlight the ring color like a focus state. */
|
||||
highlight?: boolean
|
||||
autofocus?: boolean
|
||||
autofocusDelay?: number
|
||||
class?: any
|
||||
ui?: Select['slots']
|
||||
}
|
||||
@@ -134,7 +136,7 @@ export interface SelectSlots<
|
||||
</script>
|
||||
|
||||
<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 { defu } from 'defu'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
@@ -154,7 +156,8 @@ defineOptions({ inheritAttrs: false })
|
||||
const props = withDefaults(defineProps<SelectProps<T, VK, M>>(), {
|
||||
valueKey: 'value' as never,
|
||||
labelKey: 'label' as never,
|
||||
portal: true
|
||||
portal: true,
|
||||
autofocusDelay: 0
|
||||
})
|
||||
const emits = defineEmits<SelectEmits<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
|
||||
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)) {
|
||||
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))
|
||||
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) {
|
||||
// @ts-expect-error - 'target' does not exist in type 'EventInit'
|
||||
const event = new Event('change', { target: { value } })
|
||||
@@ -225,6 +245,10 @@ function onUpdateOpen(value: boolean) {
|
||||
function isSelectItem(item: SelectItem): item is SelectItemBase {
|
||||
return typeof item === 'object' && item !== null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
@@ -240,7 +264,7 @@ function isSelectItem(item: SelectItem): item is SelectItemBase {
|
||||
@update:model-value="onUpdate"
|
||||
@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 })">
|
||||
<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 })" />
|
||||
@@ -250,7 +274,7 @@ function isSelectItem(item: SelectItem): item is SelectItemBase {
|
||||
|
||||
<slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
|
||||
<template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue">
|
||||
<span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })">
|
||||
<span v-if="displayedModelValue !== undefined && displayedModelValue !== null" :class="ui.value({ class: props.ui?.value })">
|
||||
{{ displayedModelValue }}
|
||||
</span>
|
||||
<span v-else :class="ui.placeholder({ class: props.ui?.placeholder })">
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface SelectMenuProps<T extends ArrayOrNested<SelectMenuItem> = Array
|
||||
* @defaultValue false
|
||||
*/
|
||||
ignoreFilter?: boolean
|
||||
autofocus?: boolean
|
||||
autofocusDelay?: number
|
||||
class?: any
|
||||
ui?: SelectMenu['slots']
|
||||
}
|
||||
@@ -165,7 +167,7 @@ export interface SelectMenuSlots<
|
||||
</script>
|
||||
|
||||
<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 { defu } from 'defu'
|
||||
import { reactivePick, createReusableTemplate } from '@vueuse/core'
|
||||
@@ -189,7 +191,8 @@ const props = withDefaults(defineProps<SelectMenuProps<T, VK, M>>(), {
|
||||
searchInput: true,
|
||||
labelKey: 'label' as never,
|
||||
resetSearchTermOnBlur: true,
|
||||
resetSearchTermOnSelect: true
|
||||
resetSearchTermOnSelect: true,
|
||||
autofocusDelay: 0
|
||||
})
|
||||
const emits = defineEmits<SelectMenuEmits<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
|
||||
}))
|
||||
|
||||
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)) {
|
||||
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) {
|
||||
@@ -286,6 +290,22 @@ const createItem = computed(() => {
|
||||
})
|
||||
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) {
|
||||
if (toRaw(props.modelValue) === value) {
|
||||
return
|
||||
@@ -343,6 +363,10 @@ function onSelect(e: Event, item: SelectMenuItem) {
|
||||
function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
|
||||
return typeof item === 'object' && item !== null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
@@ -375,7 +399,7 @@ function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
|
||||
@update:open="onUpdateOpen"
|
||||
>
|
||||
<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 })">
|
||||
<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 })" />
|
||||
@@ -385,7 +409,7 @@ function isSelectItem(item: SelectMenuItem): item is _SelectMenuItem {
|
||||
|
||||
<slot :model-value="(modelValue as GetModelValue<T, VK, M>)" :open="open">
|
||||
<template v-for="displayedModelValue in [displayValue(modelValue as GetModelValue<T, VK, M>)]" :key="displayedModelValue">
|
||||
<span v-if="displayedModelValue" :class="ui.value({ class: props.ui?.value })">
|
||||
<span v-if="displayedModelValue !== undefined && displayedModelValue !== null" :class="ui.value({ class: props.ui?.value })">
|
||||
{{ displayedModelValue }}
|
||||
</span>
|
||||
<span v-else :class="ui.placeholder({ class: props.ui?.placeholder })">
|
||||
|
||||
@@ -61,13 +61,14 @@ export interface SlideoverEmits extends DialogRootEmits {
|
||||
|
||||
export interface SlideoverSlots {
|
||||
default(props: { open: boolean }): any
|
||||
content(props?: {}): any
|
||||
header(props?: {}): any
|
||||
content(props: { close: () => void }): any
|
||||
header(props: { close: () => void }): any
|
||||
title(props?: {}): any
|
||||
description(props?: {}): any
|
||||
close(props: { ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }): any
|
||||
body(props?: {}): any
|
||||
footer(props?: {}): any
|
||||
actions(props?: {}): any
|
||||
close(props: { close: () => void, ui: { [K in keyof Required<Slideover['slots']>]: (props?: Record<string, any>) => string } }): any
|
||||
body(props: { close: () => void }): any
|
||||
footer(props: { close: () => void }): any
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -103,16 +104,17 @@ const contentEvents = computed(() => {
|
||||
const defaultEvents = {
|
||||
closeAutoFocus: (e: Event) => e.preventDefault()
|
||||
}
|
||||
|
||||
if (!props.dismissible) {
|
||||
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown', 'closeAutoFocus'] as const
|
||||
type EventType = typeof events[number]
|
||||
const events = ['pointerDownOutside', 'interactOutside', 'escapeKeyDown']
|
||||
|
||||
return events.reduce((acc, curr) => {
|
||||
acc[curr] = (e: Event) => {
|
||||
e.preventDefault()
|
||||
emits('close:prevent')
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<EventType, (e: Event) => void>)
|
||||
}, defaultEvents as Record<typeof events[number] | keyof typeof defaultEvents, (e: Event) => void>)
|
||||
}
|
||||
|
||||
return defaultEvents
|
||||
@@ -124,8 +126,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
|
||||
}))
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-template-shadow -->
|
||||
<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">
|
||||
<slot :open="open" />
|
||||
</DialogTrigger>
|
||||
@@ -155,9 +158,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
|
||||
<slot name="content">
|
||||
<div v-if="!!slots.header || (title || !!slots.title) || (description || !!slots.description) || (close || !!slots.close)" :class="ui.header({ class: props.ui?.header })">
|
||||
<slot name="header">
|
||||
<slot name="content" :close="close">
|
||||
<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" :close="close">
|
||||
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
|
||||
<DialogTitle v-if="title || !!slots.title" :class="ui.title({ class: props.ui?.title })">
|
||||
<slot name="title">
|
||||
@@ -172,16 +175,17 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogClose v-if="close || !!slots.close" as-child>
|
||||
<slot name="close" :ui="ui">
|
||||
<slot name="actions" />
|
||||
|
||||
<DialogClose v-if="props.close || !!slots.close" as-child>
|
||||
<slot name="close" :close="close" :ui="ui">
|
||||
<UButton
|
||||
v-if="close"
|
||||
v-if="props.close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
: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 })"
|
||||
/>
|
||||
</slot>
|
||||
@@ -190,11 +194,11 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
|
||||
</div>
|
||||
|
||||
<div :class="ui.body({ class: props.ui?.body })">
|
||||
<slot name="body" />
|
||||
<slot name="body" :close="close" />
|
||||
</div>
|
||||
|
||||
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
|
||||
<slot name="footer" />
|
||||
<slot name="footer" :close="close" />
|
||||
</div>
|
||||
</slot>
|
||||
</DialogContent>
|
||||
|
||||