mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-15 04:29:37 +01:00
Compare commits
93 Commits
feat/form-
...
feat/init-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5d3d7231 | ||
|
|
9009b51f78 | ||
|
|
ec569e427b | ||
|
|
1d052ec565 | ||
|
|
1ba8a55bcb | ||
|
|
63730d684b | ||
|
|
9ab184cc24 | ||
|
|
ad0e4ddbf4 | ||
|
|
6a93556aed | ||
|
|
b6dc5b98f2 | ||
|
|
9debce737c | ||
|
|
772631cde9 | ||
|
|
d7aefa53b2 | ||
|
|
8922c7388e | ||
|
|
c355cacd43 | ||
|
|
a0e71d9e29 | ||
|
|
127e06ae83 | ||
|
|
09c1ed8bf4 | ||
|
|
a05102fab3 | ||
|
|
d2624313ae | ||
|
|
09caf44d0d | ||
|
|
15482aae76 | ||
|
|
f903ec396f | ||
|
|
b00e07f13d | ||
|
|
5c573b37b6 | ||
|
|
f62c5ec20c | ||
|
|
b96a1ccbab | ||
|
|
c98265ee32 | ||
|
|
4ce654076c | ||
|
|
fb9e7bb856 | ||
|
|
69a7b957d5 | ||
|
|
3b67d54833 | ||
|
|
df8f20232f | ||
|
|
347694b4b5 | ||
|
|
021880328b | ||
|
|
9c1f423555 | ||
|
|
6cb737e038 | ||
|
|
231b82fe4c | ||
|
|
57a5037b13 | ||
|
|
752e2b69bd | ||
|
|
6237663a01 | ||
|
|
44cfa00e4d | ||
|
|
8cbbab9a6b | ||
|
|
2d51e20939 | ||
|
|
268e29b041 | ||
|
|
b0364b96b7 | ||
|
|
ba3c6e8788 | ||
|
|
01da3cbf31 | ||
|
|
595fc64515 | ||
|
|
81569713e9 | ||
|
|
1a8feb751e | ||
|
|
1d281e915a | ||
|
|
c3adc381c9 | ||
|
|
edca3bcb74 | ||
|
|
8f32ee3d24 | ||
|
|
9172bb7dc2 | ||
|
|
2835ea669b | ||
|
|
7f20093993 | ||
|
|
ac884bc2db | ||
|
|
32dae2e002 | ||
|
|
be41aed1f3 | ||
|
|
bf678412ca | ||
|
|
42d7ddde48 | ||
|
|
a999600e9f | ||
|
|
04f12adc5b | ||
|
|
abfd0ede03 | ||
|
|
842760d777 | ||
|
|
2fa8db64dd | ||
|
|
52f1963833 | ||
|
|
9a83c9c7f4 | ||
|
|
f2510cb342 | ||
|
|
4dd56c8111 | ||
|
|
6e3ec6a077 | ||
|
|
917849f638 | ||
|
|
1368b49de3 | ||
|
|
7ea84e3e47 | ||
|
|
8045ec7c03 | ||
|
|
fb021c4f70 | ||
|
|
58b8681a53 | ||
|
|
5ed63fa147 | ||
|
|
30b9d11098 | ||
|
|
3228f402e8 | ||
|
|
e9b80da977 | ||
|
|
c097b6fae2 | ||
|
|
7050a0cecf | ||
|
|
391feb62df | ||
|
|
23adf96db7 | ||
|
|
63d92d074f | ||
|
|
85cf840fde | ||
|
|
64d574ba6e | ||
|
|
9589a7ffcf | ||
|
|
4f6cb68b97 | ||
|
|
7901e5733a |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## [3.2.0](https://github.com/nuxt/ui/compare/v3.1.3...v3.2.0) (2025-06-25)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **useOverlay:** correct spelling of `unmount` function (#4051)
|
||||
|
||||
### Features
|
||||
|
||||
* **Avatar:** add `chip` prop ([#4224](https://github.com/nuxt/ui/issues/4224)) ([03ac395](https://github.com/nuxt/ui/commit/03ac395164c02c964361c68743268b1bc90aae59))
|
||||
* **Carousel:** allow customization of active dot color ([#4229](https://github.com/nuxt/ui/issues/4229)) ([2ee1c5a](https://github.com/nuxt/ui/commit/2ee1c5ac2e20ab9ce2f4037a8e8c64e561b0428b))
|
||||
* **CommandPalette:** handle `children` in items ([#4226](https://github.com/nuxt/ui/issues/4226)) ([59c26ec](https://github.com/nuxt/ui/commit/59c26ec1230375a24fbaf8a630a696ae854700c7))
|
||||
* **extendLocale:** new composable ([0f558fc](https://github.com/nuxt/ui/commit/0f558fc0d014d51549222accfc50286d1770d1aa)), closes [#3729](https://github.com/nuxt/ui/issues/3729)
|
||||
* **Form:** expose loading state to default slot ([#4247](https://github.com/nuxt/ui/issues/4247)) ([ea0c459](https://github.com/nuxt/ui/commit/ea0c459306be585bacaaf5b433114d072550c824))
|
||||
* **InputTags:** new component ([#4261](https://github.com/nuxt/ui/issues/4261)) ([54bb228](https://github.com/nuxt/ui/commit/54bb2282c58d3bf5a7dde4cdee687c68efd934a0))
|
||||
* **locale:** add Luxembourgish language ([#4264](https://github.com/nuxt/ui/issues/4264)) ([43cbb94](https://github.com/nuxt/ui/commit/43cbb94ee25106b414fc8fe979fa65ebaa9ccc76))
|
||||
* **Modal/Slideover:** add `actions` slot ([#4358](https://github.com/nuxt/ui/issues/4358)) ([8156971](https://github.com/nuxt/ui/commit/81569713e9da9d5531ecdf4614660b84c686fa81))
|
||||
* **Modal/Slideover:** add `close` method in slots ([#4219](https://github.com/nuxt/ui/issues/4219)) ([5835eb5](https://github.com/nuxt/ui/commit/5835eb5f0f835b5f03646dec78f85b2f556a109b))
|
||||
* **Select/SelectMenu/Tabs:** expose trigger refs ([7a2bd4e](https://github.com/nuxt/ui/commit/7a2bd4e6179373902ba6f285903ea896fd1d378f)), closes [#4292](https://github.com/nuxt/ui/issues/4292)
|
||||
* **Select/SelectMenu:** handle dynamic `autofocus` ([1a4de49](https://github.com/nuxt/ui/commit/1a4de49c1665c9ef65279315be0393d6272447b9)), closes [#4324](https://github.com/nuxt/ui/issues/4324)
|
||||
* **Table:** add `body-top` / `body-bottom` slots ([#4354](https://github.com/nuxt/ui/issues/4354)) ([595fc64](https://github.com/nuxt/ui/commit/595fc64515613fe82c3a56fc5518f2e3fcce6e19))
|
||||
* **Timeline:** add `reverse` prop ([#4316](https://github.com/nuxt/ui/issues/4316)) ([5170cfd](https://github.com/nuxt/ui/commit/5170cfd7eb44a25c64673cf12979f9ca1049695f))
|
||||
* **Timeline:** new component ([#4215](https://github.com/nuxt/ui/issues/4215)) ([8017767](https://github.com/nuxt/ui/commit/80177679f2aa0d7f0e39e639a02d527a06e6172c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Card/Drawer/Modal:** prevent scrollbars overflow ([#4368](https://github.com/nuxt/ui/issues/4368)) ([c3adc38](https://github.com/nuxt/ui/commit/c3adc381c90dad7152e27fc303ee678efc7c4c94))
|
||||
* **components:** remove default `md` size on buttons ([#4357](https://github.com/nuxt/ui/issues/4357)) ([be41aed](https://github.com/nuxt/ui/commit/be41aed1f3d3476801e1840dbb8766926bc93c05))
|
||||
* **defineShortcuts:** allow `meta_-` shortcut ([#4321](https://github.com/nuxt/ui/issues/4321)) ([4e7c1c9](https://github.com/nuxt/ui/commit/4e7c1c9c305b45dd76d4c238e70a6aeedae78c8b))
|
||||
* **Form:** conditionally type form data via `transform` prop ([#4188](https://github.com/nuxt/ui/issues/4188)) ([37abcc6](https://github.com/nuxt/ui/commit/37abcc6a5b0a678be626673af5067956657a50d6))
|
||||
* **Form:** expose reactive fields ([#4386](https://github.com/nuxt/ui/issues/4386)) ([1a8feb7](https://github.com/nuxt/ui/commit/1a8feb751e6827c414ef82fe9fb259ba7dcc7e08))
|
||||
* **InputMenu/SelectMenu:** dynamic `empty` size ([ba3c6e8](https://github.com/nuxt/ui/commit/ba3c6e8788ed75d86d4406749797da52d7816b84)), closes [#4377](https://github.com/nuxt/ui/issues/4377)
|
||||
* **InputTags:** extend emits interface ([8781a07](https://github.com/nuxt/ui/commit/8781a079096def0d3bae5b8d896db0df6ce37e23))
|
||||
* **Modal/Slideover:** don't emit `close:prevent` on `closeAutoFocus` ([150b334](https://github.com/nuxt/ui/commit/150b334b1d242c6dc132193e23359c03e6f35666))
|
||||
* **NavigationMenu:** nested accordion context at every level ([#4363](https://github.com/nuxt/ui/issues/4363)) ([2fa8db6](https://github.com/nuxt/ui/commit/2fa8db64ddf4c92a19e73774143518d87d001b72))
|
||||
* **NavigationMenu:** set content `max-height` in `horizontal` orientation ([62bc7b2](https://github.com/nuxt/ui/commit/62bc7b25a2d205d8dffb47a109196f91ff3e823a)), closes [#4208](https://github.com/nuxt/ui/issues/4208)
|
||||
* **Pagination:** match default button `size` ([#4350](https://github.com/nuxt/ui/issues/4350)) ([4dd56c8](https://github.com/nuxt/ui/commit/4dd56c8111e5a224105b82d541b7742b46abb34a))
|
||||
* **Select/SelectMenu:** display falsy values ([7df7ee3](https://github.com/nuxt/ui/commit/7df7ee336a925d7ee07f866551dad9350785c9fc))
|
||||
* **Select/SelectMenu:** prevent empty string display when multiple ([483e473](https://github.com/nuxt/ui/commit/483e473e3f5681cc97c3766ea47283dc95f76345))
|
||||
* **SelectMenu:** dynamic input size ([b0364b9](https://github.com/nuxt/ui/commit/b0364b96b73b9e543781a35962c03b5a983352c4))
|
||||
* **Table:** use `tr` as separator ([#4083](https://github.com/nuxt/ui/issues/4083)) ([edca3bc](https://github.com/nuxt/ui/commit/edca3bcb743c7eb63e6abbaa801d3858342a8777))
|
||||
* **Toast:** calc height on next tick ([3bf5acb](https://github.com/nuxt/ui/commit/3bf5acb683f0ad09735b2417d265d6fcfd901b11)), closes [#4265](https://github.com/nuxt/ui/issues/4265)
|
||||
* **Toaster:** smoother visibility transition for stacked toasts ([#4367](https://github.com/nuxt/ui/issues/4367)) ([abfd0ed](https://github.com/nuxt/ui/commit/abfd0ede036fa2953f9abc841d77ac71bbd3bba9))
|
||||
* **useOverlay:** correct spelling of `unmount` function ([#4051](https://github.com/nuxt/ui/issues/4051)) ([546df57](https://github.com/nuxt/ui/commit/546df572fca60325315bed17c9be3367052fb7a9))
|
||||
* **useOverlay:** set props to original props when `defaultOpen` is set ([#4308](https://github.com/nuxt/ui/issues/4308)) ([66355ba](https://github.com/nuxt/ui/commit/66355ba301d569b9f44527bafc5f8f09bcda63c0))
|
||||
* **useOverlay:** use original props when not provided to `open` ([#4269](https://github.com/nuxt/ui/issues/4269)) ([bf56e15](https://github.com/nuxt/ui/commit/bf56e15a2eed7d51199d5641649a822e91ca41ba))
|
||||
|
||||
## [3.1.3](https://github.com/nuxt/ui/compare/v3.1.2...v3.1.3) (2025-05-26)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
@@ -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>
|
||||
@@ -145,7 +147,8 @@ const test = ({ name, prose, content }) => {
|
||||
? undefined
|
||||
: `
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import ${upperName}, { type ${upperName}Props, type ${upperName}Slots } from '../../${content ? '../' : ''}src/runtime/components/${content ? 'content/' : ''}${upperName}.vue'
|
||||
import ${upperName} from '../../${content ? '../' : ''}src/runtime/components/${content ? 'content/' : ''}${upperName}.vue'
|
||||
import type { ${upperName}Props, ${upperName}Slots } from '../../${content ? '../' : ''}src/runtime/components/${content ? 'content/' : ''}${upperName}.vue'
|
||||
import ComponentRender from '../${content ? '../' : ''}component-render'
|
||||
|
||||
describe('${upperName}', () => {
|
||||
@@ -186,6 +189,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<UBanner
|
||||
id="ui3-launch"
|
||||
title="Nuxt UI v3 is officially released!"
|
||||
icon="i-lucide-rocket"
|
||||
:actions="[
|
||||
{
|
||||
@@ -10,9 +11,5 @@
|
||||
}
|
||||
]"
|
||||
close
|
||||
>
|
||||
<template #title>
|
||||
<span class="font-semibold">Nuxt UI v3</span> is officially released.
|
||||
</template>
|
||||
</UBanner>
|
||||
/>
|
||||
</template>
|
||||
|
||||
77
docs/app/components/PageHeaderLinks.vue
Normal file
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>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
const groups = [
|
||||
{
|
||||
id: 'actions',
|
||||
items: [
|
||||
{
|
||||
label: 'Add new file',
|
||||
suffix: 'Create a new file in the current directory',
|
||||
icon: 'i-lucide-file-plus',
|
||||
kbds: ['meta', 'N']
|
||||
},
|
||||
{
|
||||
label: 'Add new folder',
|
||||
suffix: 'Create a new folder in the current directory',
|
||||
icon: 'i-lucide-folder-plus',
|
||||
kbds: ['meta', 'F']
|
||||
},
|
||||
{
|
||||
label: 'Search files',
|
||||
suffix: 'Search across all files in the project',
|
||||
icon: 'i-lucide-search',
|
||||
kbds: ['meta', 'P']
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
suffix: 'Open application settings',
|
||||
icon: 'i-lucide-settings',
|
||||
kbds: ['meta', ',']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'recent',
|
||||
label: 'Recent',
|
||||
items: [
|
||||
{
|
||||
label: 'project.vue',
|
||||
suffix: 'components/',
|
||||
icon: 'i-vscode-icons-file-type-vue'
|
||||
},
|
||||
{
|
||||
label: 'readme.md',
|
||||
suffix: 'docs/',
|
||||
icon: 'i-vscode-icons-file-type-markdown'
|
||||
},
|
||||
{
|
||||
label: 'package.json',
|
||||
suffix: 'root/',
|
||||
icon: 'i-vscode-icons-file-type-node'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCommandPalette :groups="groups" class="flex-1 h-80">
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UIcon name="i-simple-icons-nuxtdotjs" class="size-5 text-dimmed ml-1" />
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton color="neutral" variant="ghost" label="Open Command" class="text-dimmed" size="xs">
|
||||
<template #trailing>
|
||||
<UKbd value="enter" />
|
||||
</template>
|
||||
</UButton>
|
||||
<USeparator orientation="vertical" class="h-4" />
|
||||
<UButton color="neutral" variant="ghost" label="Actions" class="text-dimmed" size="xs">
|
||||
<template #trailing>
|
||||
<UKbd value="meta" />
|
||||
<UKbd value="k" />
|
||||
</template>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCommandPalette>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { object, string, nonempty, refine, type Infer } from 'superstruct'
|
||||
import { object, string, nonempty, refine } from 'superstruct'
|
||||
import type { Infer } from 'superstruct'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
const schema = object({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { object, string, type InferType } from 'yup'
|
||||
import { object, string } from 'yup'
|
||||
import type { InferType } from 'yup'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
const schema = object({
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const value = ref('npx nuxt module add ui')
|
||||
const copied = ref(false)
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(value.value)
|
||||
copied.value = true
|
||||
|
||||
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,43 @@
|
||||
<script setup lang="ts">
|
||||
const open = ref(false)
|
||||
const anchor = ref({ x: 0, y: 0 })
|
||||
|
||||
const reference = computed(() => ({
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: anchor.value.x,
|
||||
right: anchor.value.x,
|
||||
top: anchor.value.y,
|
||||
bottom: anchor.value.y,
|
||||
...anchor.value
|
||||
} as DOMRect)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPopover
|
||||
:open="open"
|
||||
:reference="reference"
|
||||
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-md border border-dashed border-accented text-sm aspect-video w-72"
|
||||
@pointerenter="open = true"
|
||||
@pointerleave="open = false"
|
||||
@pointermove="(ev) => {
|
||||
anchor.x = ev.clientX
|
||||
anchor.y = ev.clientY
|
||||
}"
|
||||
>
|
||||
Hover me
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
<div class="p-4">
|
||||
{{ anchor.x.toFixed(0) }} - {{ anchor.y.toFixed(0) }}
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { TableColumn, TableRow } from '@nuxt/ui'
|
||||
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
|
||||
type Payment = {
|
||||
id: string
|
||||
date: string
|
||||
status: 'paid' | 'failed' | 'refunded'
|
||||
email: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
const data = ref<Payment[]>([{
|
||||
id: '4600',
|
||||
date: '2024-03-11T15:30:00',
|
||||
status: 'paid',
|
||||
email: 'james.anderson@example.com',
|
||||
amount: 594
|
||||
}, {
|
||||
id: '4599',
|
||||
date: '2024-03-11T10:10:00',
|
||||
status: 'failed',
|
||||
email: 'mia.white@example.com',
|
||||
amount: 276
|
||||
}, {
|
||||
id: '4598',
|
||||
date: '2024-03-11T08:50:00',
|
||||
status: 'refunded',
|
||||
email: 'william.brown@example.com',
|
||||
amount: 315
|
||||
}, {
|
||||
id: '4597',
|
||||
date: '2024-03-10T19:45:00',
|
||||
status: 'paid',
|
||||
email: 'emma.davis@example.com',
|
||||
amount: 529
|
||||
}, {
|
||||
id: '4596',
|
||||
date: '2024-03-10T15:55:00',
|
||||
status: 'paid',
|
||||
email: 'ethan.harris@example.com',
|
||||
amount: 639
|
||||
}])
|
||||
|
||||
const columns: TableColumn<Payment>[] = [{
|
||||
accessorKey: 'id',
|
||||
header: '#',
|
||||
cell: ({ row }) => `#${row.getValue('id')}`
|
||||
}, {
|
||||
accessorKey: 'date',
|
||||
header: 'Date',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.getValue('date')).toLocaleString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
}, {
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const color = ({
|
||||
paid: 'success' as const,
|
||||
failed: 'error' as const,
|
||||
refunded: 'neutral' as const
|
||||
})[row.getValue('status') as string]
|
||||
|
||||
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
|
||||
}
|
||||
}, {
|
||||
accessorKey: 'email',
|
||||
header: 'Email'
|
||||
}, {
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
footer: ({ column }) => {
|
||||
const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')), 0)
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(total)
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const amount = Number.parseFloat(row.getValue('amount'))
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount)
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, formatted)
|
||||
}
|
||||
}]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTable :data="data" :columns="columns" class="flex-1" />
|
||||
</template>
|
||||
@@ -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!',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import { getGroupedRowModel, type GroupingOptions } from '@tanstack/vue-table'
|
||||
import { getGroupedRowModel } from '@tanstack/vue-table'
|
||||
import type { GroupingOptions } from '@tanstack/vue-table'
|
||||
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { ContextMenuItem, TableColumn, TableRow } from '@nuxt/ui'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UCheckbox = resolveComponent('UCheckbox')
|
||||
|
||||
const toast = useToast()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
type Payment = {
|
||||
id: string
|
||||
date: string
|
||||
status: 'paid' | 'failed' | 'refunded'
|
||||
email: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
const data = ref<Payment[]>([{
|
||||
id: '4600',
|
||||
date: '2024-03-11T15:30:00',
|
||||
status: 'paid',
|
||||
email: 'james.anderson@example.com',
|
||||
amount: 594
|
||||
}, {
|
||||
id: '4599',
|
||||
date: '2024-03-11T10:10:00',
|
||||
status: 'failed',
|
||||
email: 'mia.white@example.com',
|
||||
amount: 276
|
||||
}, {
|
||||
id: '4598',
|
||||
date: '2024-03-11T08:50:00',
|
||||
status: 'refunded',
|
||||
email: 'william.brown@example.com',
|
||||
amount: 315
|
||||
}, {
|
||||
id: '4597',
|
||||
date: '2024-03-10T19:45:00',
|
||||
status: 'paid',
|
||||
email: 'emma.davis@example.com',
|
||||
amount: 529
|
||||
}, {
|
||||
id: '4596',
|
||||
date: '2024-03-10T15:55:00',
|
||||
status: 'paid',
|
||||
email: 'ethan.harris@example.com',
|
||||
amount: 639
|
||||
}])
|
||||
|
||||
const columns: TableColumn<Payment>[] = [{
|
||||
id: 'select',
|
||||
header: ({ table }) => h(UCheckbox, {
|
||||
'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
'aria-label': 'Select all'
|
||||
}),
|
||||
cell: ({ row }) => h(UCheckbox, {
|
||||
'modelValue': row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
'aria-label': 'Select row'
|
||||
})
|
||||
}, {
|
||||
accessorKey: 'id',
|
||||
header: '#',
|
||||
cell: ({ row }) => `#${row.getValue('id')}`
|
||||
}, {
|
||||
accessorKey: 'date',
|
||||
header: 'Date',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.getValue('date')).toLocaleString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
}, {
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const color = ({
|
||||
paid: 'success' as const,
|
||||
failed: 'error' as const,
|
||||
refunded: 'neutral' as const
|
||||
})[row.getValue('status') as string]
|
||||
|
||||
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
|
||||
}
|
||||
}, {
|
||||
accessorKey: 'email',
|
||||
header: 'Email'
|
||||
}, {
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = Number.parseFloat(row.getValue('amount'))
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount)
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, formatted)
|
||||
}
|
||||
}]
|
||||
|
||||
const items = ref<ContextMenuItem[]>([])
|
||||
|
||||
function getRowItems(row: TableRow<Payment>) {
|
||||
return [{
|
||||
type: 'label' as const,
|
||||
label: 'Actions'
|
||||
}, {
|
||||
label: 'Copy payment ID',
|
||||
onSelect() {
|
||||
copy(row.original.id)
|
||||
|
||||
toast.add({
|
||||
title: 'Payment ID copied to clipboard!',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check'
|
||||
})
|
||||
}
|
||||
}, {
|
||||
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
|
||||
onSelect() {
|
||||
row.toggleExpanded()
|
||||
}
|
||||
}, {
|
||||
type: 'separator' as const
|
||||
}, {
|
||||
label: 'View customer'
|
||||
}, {
|
||||
label: 'View payment details'
|
||||
}]
|
||||
}
|
||||
|
||||
function onContextmenu(_e: Event, row: TableRow<Payment>) {
|
||||
items.value = getRowItems(row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContextMenu :items="items">
|
||||
<UTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
class="flex-1"
|
||||
@contextmenu="onContextmenu"
|
||||
>
|
||||
<template #expanded="{ row }">
|
||||
<pre>{{ row.original }}</pre>
|
||||
</template>
|
||||
</UTable>
|
||||
</UContextMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { TableColumn, TableRow } from '@nuxt/ui'
|
||||
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UCheckbox = resolveComponent('UCheckbox')
|
||||
|
||||
type Payment = {
|
||||
id: string
|
||||
date: string
|
||||
status: 'paid' | 'failed' | 'refunded'
|
||||
email: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
const data = ref<Payment[]>([{
|
||||
id: '4600',
|
||||
date: '2024-03-11T15:30:00',
|
||||
status: 'paid',
|
||||
email: 'james.anderson@example.com',
|
||||
amount: 594
|
||||
}, {
|
||||
id: '4599',
|
||||
date: '2024-03-11T10:10:00',
|
||||
status: 'failed',
|
||||
email: 'mia.white@example.com',
|
||||
amount: 276
|
||||
}, {
|
||||
id: '4598',
|
||||
date: '2024-03-11T08:50:00',
|
||||
status: 'refunded',
|
||||
email: 'william.brown@example.com',
|
||||
amount: 315
|
||||
}, {
|
||||
id: '4597',
|
||||
date: '2024-03-10T19:45:00',
|
||||
status: 'paid',
|
||||
email: 'emma.davis@example.com',
|
||||
amount: 529
|
||||
}, {
|
||||
id: '4596',
|
||||
date: '2024-03-10T15:55:00',
|
||||
status: 'paid',
|
||||
email: 'ethan.harris@example.com',
|
||||
amount: 639
|
||||
}])
|
||||
|
||||
const columns: TableColumn<Payment>[] = [{
|
||||
id: 'select',
|
||||
header: ({ table }) => h(UCheckbox, {
|
||||
'modelValue': table.getIsSomePageRowsSelected() ? 'indeterminate' : table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => table.toggleAllPageRowsSelected(!!value),
|
||||
'aria-label': 'Select all'
|
||||
}),
|
||||
cell: ({ row }) => h(UCheckbox, {
|
||||
'modelValue': row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
||||
'aria-label': 'Select row'
|
||||
})
|
||||
}, {
|
||||
accessorKey: 'id',
|
||||
header: '#',
|
||||
cell: ({ row }) => `#${row.getValue('id')}`
|
||||
}, {
|
||||
accessorKey: 'date',
|
||||
header: 'Date',
|
||||
cell: ({ row }) => {
|
||||
return new Date(row.getValue('date')).toLocaleString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
}, {
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => {
|
||||
const color = ({
|
||||
paid: 'success' as const,
|
||||
failed: 'error' as const,
|
||||
refunded: 'neutral' as const
|
||||
})[row.getValue('status') as string]
|
||||
|
||||
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () => row.getValue('status'))
|
||||
}
|
||||
}, {
|
||||
accessorKey: 'email',
|
||||
header: 'Email'
|
||||
}, {
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
cell: ({ row }) => {
|
||||
const amount = Number.parseFloat(row.getValue('amount'))
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount)
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, formatted)
|
||||
}
|
||||
}]
|
||||
|
||||
const anchor = ref({ x: 0, y: 0 })
|
||||
|
||||
const reference = computed(() => ({
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: anchor.value.x,
|
||||
right: anchor.value.x,
|
||||
top: anchor.value.y,
|
||||
bottom: anchor.value.y,
|
||||
...anchor.value
|
||||
} as DOMRect)
|
||||
}))
|
||||
|
||||
const open = ref(false)
|
||||
const openDebounced = refDebounced(open, 10)
|
||||
const selectedRow = ref<TableRow<Payment> | null>(null)
|
||||
|
||||
function onHover(_e: Event, row: TableRow<Payment> | null) {
|
||||
selectedRow.value = row
|
||||
|
||||
open.value = !!row
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-1 gap-1">
|
||||
<UTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
class="flex-1"
|
||||
@pointermove="(ev: PointerEvent) => {
|
||||
anchor.x = ev.clientX
|
||||
anchor.y = ev.clientY
|
||||
}"
|
||||
@hover="onHover"
|
||||
/>
|
||||
|
||||
<UPopover
|
||||
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
|
||||
:open="openDebounced"
|
||||
:reference="reference"
|
||||
>
|
||||
<template #content>
|
||||
<div class="p-4">
|
||||
{{ selectedRow?.original?.id }}
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -112,7 +112,7 @@ function onSelect(row: TableRow<Payment>, e?: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class=" flex w-full flex-1 gap-1">
|
||||
<div class="flex w-full flex-1 gap-1">
|
||||
<div class="flex-1">
|
||||
<UTable
|
||||
ref="table"
|
||||
@@ -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,16 @@
|
||||
<script setup lang="ts">
|
||||
const toast = useToast()
|
||||
|
||||
function showToast() {
|
||||
toast.add({
|
||||
title: 'Uh oh! Something went wrong.',
|
||||
description: 'There was a problem with your request.',
|
||||
icon: 'i-lucide-wifi',
|
||||
progress: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton label="Show toast" color="neutral" variant="outline" @click="showToast" />
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
const open = ref(false)
|
||||
const anchor = ref({ x: 0, y: 0 })
|
||||
|
||||
const reference = computed(() => ({
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: anchor.value.x,
|
||||
right: anchor.value.x,
|
||||
top: anchor.value.y,
|
||||
bottom: anchor.value.y,
|
||||
...anchor.value
|
||||
} as DOMRect)
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTooltip
|
||||
:open="open"
|
||||
:reference="reference"
|
||||
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-md border border-dashed border-accented text-sm aspect-video w-72"
|
||||
@pointerenter="open = true"
|
||||
@pointerleave="open = false"
|
||||
@pointermove="(ev) => {
|
||||
anchor.x = ev.clientX
|
||||
anchor.y = ev.clientY
|
||||
}"
|
||||
>
|
||||
Hover me
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
{{ anchor.x.toFixed(0) }} - {{ anchor.y.toFixed(0) }}
|
||||
</template>
|
||||
</UTooltip>
|
||||
</template>
|
||||
@@ -107,6 +107,10 @@ export function useLinks() {
|
||||
to: 'https://github.com/Justineo/tempad-dev-plugin-nuxt-ui',
|
||||
target: '_blank'
|
||||
}]
|
||||
}, {
|
||||
label: 'Blog',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/blog'
|
||||
}, {
|
||||
label: 'Releases',
|
||||
icon: 'i-lucide-rocket',
|
||||
|
||||
@@ -57,6 +57,10 @@ export function useSearchLinks() {
|
||||
description: 'Meet the team behind Nuxt UI.',
|
||||
icon: 'i-lucide-users',
|
||||
to: '/team'
|
||||
}, {
|
||||
label: 'Blog',
|
||||
icon: 'i-lucide-file-text',
|
||||
to: '/blog'
|
||||
}, {
|
||||
label: 'Releases',
|
||||
icon: 'i-lucide-rocket',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
7
docs/app/pages/blog/.blog.yml
Normal file
7
docs/app/pages/blog/.blog.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
seo:
|
||||
title: Nuxt UI Blog
|
||||
description: Read the latest news, tutorials, and updates about Nuxt UI.
|
||||
title: Nuxt [UI]{.text-primary} Blog
|
||||
navigation.title: Blog
|
||||
description: Read the latest news, tutorials, and updates about Nuxt UI.
|
||||
navigation.icon: i-lucide-newspaper
|
||||
183
docs/app/pages/blog/[...slug].vue
Normal file
183
docs/app/pages/blog/[...slug].vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { kebabCase } from 'scule'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const [{ data: page }, { data: surround }] = await Promise.all([
|
||||
useAsyncData(kebabCase(route.path), () => queryCollection('blog').path(route.path).first()),
|
||||
useAsyncData(`${kebabCase(route.path)}-surround`, () => {
|
||||
return queryCollectionItemSurroundings('blog', route.path, {
|
||||
fields: ['description']
|
||||
}).order('date', 'DESC')
|
||||
})
|
||||
])
|
||||
|
||||
if (!page.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const title = page.value.seo?.title || page.value.title
|
||||
const description = page.value.seo?.description || page.value.description
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogDescription: description,
|
||||
ogTitle: title
|
||||
})
|
||||
|
||||
if (page.value.image) {
|
||||
defineOgImage({ url: page.value.image })
|
||||
} else {
|
||||
defineOgImageComponent('Docs', {
|
||||
headline: 'Blog',
|
||||
title,
|
||||
description
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).toUpperCase()
|
||||
}
|
||||
|
||||
const getCategoryVariant = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'solid'
|
||||
case 'tutorial': return 'soft'
|
||||
case 'improvement': return 'soft'
|
||||
default: return 'soft'
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'i-lucide-rocket'
|
||||
case 'tutorial': return 'i-lucide-book-open'
|
||||
case 'improvement': return 'i-lucide-trending-up'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="page" class="min-h-screen">
|
||||
<div class="border-b border-default">
|
||||
<UContainer class="py-4">
|
||||
<ULink to="/blog" class="flex items-center gap-2 text-sm">
|
||||
<UIcon name="i-lucide-chevron-left" class="size-4" />
|
||||
Back to Blog
|
||||
</ULink>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
||||
<div class="py-16 sm:pt-20 pb-10">
|
||||
<UContainer class="max-w-4xl">
|
||||
<div class="text-center space-y-6">
|
||||
<div class="flex items-center justify-center gap-4 text-sm">
|
||||
<UBadge
|
||||
v-if="page.category"
|
||||
:variant="getCategoryVariant(page.category)"
|
||||
size="sm"
|
||||
class="font-mono text-xs gap-2"
|
||||
>
|
||||
<UIcon :name="getCategoryIcon(page.category)" class="size-3" />
|
||||
{{ page.category?.toUpperCase() }}
|
||||
</UBadge>
|
||||
|
||||
<span class="text-muted font-mono text-xs">
|
||||
{{ formatDate(page.date) }}
|
||||
</span>
|
||||
|
||||
<span v-if="page.minRead" class="text-muted font-mono text-xs">
|
||||
{{ page.minRead }} MIN READ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ duration: 0.6 }"
|
||||
>
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-highlighted leading-tight">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
</Motion>
|
||||
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.1, duration: 0.6 }"
|
||||
>
|
||||
<p class="text-lg text-muted max-w-2xl mx-auto leading-relaxed">
|
||||
{{ page.description }}
|
||||
</p>
|
||||
</Motion>
|
||||
|
||||
<Motion
|
||||
v-if="page.authors?.length"
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.2, duration: 0.6 }"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<UAvatarGroup>
|
||||
<ULink
|
||||
v-for="(author, index) in page.authors"
|
||||
:key="index"
|
||||
:to="author.to"
|
||||
raw
|
||||
>
|
||||
<UAvatar v-bind="author.avatar" />
|
||||
</ULink>
|
||||
</UAvatarGroup>
|
||||
</Motion>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
||||
<div v-if="page.image" class="py-4">
|
||||
<UContainer class="max-w-6xl">
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 30 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.3, duration: 0.8 }"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="page.image"
|
||||
:alt="page.title"
|
||||
class="w-full max-h-[400px] object-cover object-center max-w-5xl mx-auto"
|
||||
/>
|
||||
</Motion>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
||||
<div class="py-12 sm:py-16">
|
||||
<UContainer class="max-w-3xl">
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.4, duration: 0.6 }"
|
||||
>
|
||||
<ContentRenderer
|
||||
v-if="page.body"
|
||||
:value="page"
|
||||
/>
|
||||
</Motion>
|
||||
|
||||
<div v-if="surround?.length" class="mt-16 pt-8 border-t border-default">
|
||||
<Motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: 0.6, duration: 0.6 }"
|
||||
>
|
||||
<UContentSurround :surround="surround" />
|
||||
</Motion>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
255
docs/app/pages/blog/index.vue
Normal file
255
docs/app/pages/blog/index.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-expect-error - yaml import not typed
|
||||
import page from '.blog.yml'
|
||||
|
||||
const { data: posts } = await useAsyncData('blogs', () =>
|
||||
queryCollection('blog').order('date', 'DESC').all()
|
||||
)
|
||||
|
||||
const title = page.seo?.title || page.title
|
||||
const description = page.seo?.description || page.description
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: description
|
||||
})
|
||||
|
||||
const selectedFilter = ref('all')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const availableFilters = computed(() => {
|
||||
if (!posts.value?.length) return [{ key: 'all', label: 'ALL', count: 0 }]
|
||||
|
||||
const postsData = posts.value
|
||||
const categories = new Set(postsData.map(post => post.category?.toLowerCase()).filter(Boolean))
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: 'ALL', count: postsData.length }
|
||||
]
|
||||
|
||||
categories.forEach((category) => {
|
||||
const count = postsData.filter(p => p.category?.toLowerCase() === category).length
|
||||
const label = category.replace(/\b\w/g, l => l.toUpperCase()).replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
|
||||
filters.push({
|
||||
key: category,
|
||||
label: label,
|
||||
count
|
||||
})
|
||||
})
|
||||
|
||||
return filters.sort((a, b) => {
|
||||
if (a.key === 'all') return -1
|
||||
if (b.key === 'all') return 1
|
||||
return b.count - a.count
|
||||
})
|
||||
})
|
||||
|
||||
const filteredPosts = computed(() => {
|
||||
if (!posts.value) return []
|
||||
|
||||
let filtered = posts.value
|
||||
|
||||
if (selectedFilter.value !== 'all') {
|
||||
filtered = filtered.filter(post => post.category?.toLowerCase() === selectedFilter.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(post =>
|
||||
post.title?.toLowerCase().includes(query)
|
||||
|| post.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit'
|
||||
}).toUpperCase()
|
||||
}
|
||||
|
||||
const getCategoryVariant = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'solid'
|
||||
case 'tutorial': return 'soft'
|
||||
case 'improvement': return 'soft'
|
||||
default: return 'soft'
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'release': return 'i-lucide-rocket'
|
||||
case 'tutorial': return 'i-lucide-book-open'
|
||||
case 'improvement': return 'i-lucide-trending-up'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="page" class="relative grid grid-rows-[auto_auto_1fr] min-h-[calc(100vh-150px)]">
|
||||
<UPageHero :links="page.links" :ui="{ container: 'relative py-10 sm:py-16 lg:py-24' }">
|
||||
<LazyStarsBg />
|
||||
|
||||
<div aria-hidden="true" class="absolute z-[-1] border-x border-default inset-0 mx-4 sm:mx-6 lg:mx-8" />
|
||||
|
||||
<template #title>
|
||||
<MDC :value="page.title" unwrap="p" cache-key="pro-templates-hero-title" />
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<MDC :value="page.description" unwrap="p" cache-key="pro-templates-hero-description" />
|
||||
</template>
|
||||
</UPageHero>
|
||||
|
||||
<UPageBody class="!my-0 !py-0 border-y border-default">
|
||||
<UContainer>
|
||||
<div class="border-x border-default px-4 sm:px-6 py-6 sm:py-8">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-6 lg:gap-8 sm:mb-6">
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<Motion
|
||||
v-for="(filter, index) in availableFilters"
|
||||
:key="filter.key"
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
:transition="{ delay: index * 0.1 }"
|
||||
>
|
||||
<UButton
|
||||
:variant="selectedFilter === filter.key ? 'solid' : 'ghost'"
|
||||
:color="selectedFilter === filter.key ? 'primary' : 'neutral'"
|
||||
size="sm"
|
||||
class="font-medium transition-all duration-200 hover:scale-105 focus:scale-100 rounded-none text-xs sm:text-sm"
|
||||
:leading-icon="selectedFilter === filter.key ? 'i-lucide-check' : 'i-lucide-circle'"
|
||||
:label="filter.label"
|
||||
@click="selectedFilter = filter.key"
|
||||
>
|
||||
<template #trailing>
|
||||
<UBadge
|
||||
:variant="selectedFilter === filter.key ? 'solid' : 'soft'"
|
||||
size="xs"
|
||||
>
|
||||
{{ filter.count }}
|
||||
</UBadge>
|
||||
</template>
|
||||
</UButton>
|
||||
</Motion>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
|
||||
<div class="relative">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Search posts..."
|
||||
icon="i-lucide-search"
|
||||
class="w-full sm:w-64"
|
||||
:ui="{
|
||||
base: 'rounded-none'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
variant="ghost"
|
||||
class="rounded-none whitespace-nowrap"
|
||||
icon="i-lucide-external-link"
|
||||
label="Follow @nuxt_js on X"
|
||||
to="https://x.com/nuxt_js"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-t border-default !gap-0">
|
||||
<Motion
|
||||
v-for="(post, index) in filteredPosts"
|
||||
:key="post.path"
|
||||
:initial="{ opacity: 0, x: -20 }"
|
||||
:animate="{ opacity: 1, x: 0 }"
|
||||
:transition="{ delay: index * 0.05, type: 'spring', stiffness: 300, damping: 30 }"
|
||||
class="group border-b border-default last:border-b-0"
|
||||
>
|
||||
<ULink
|
||||
:to="post.path"
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between p-4 sm:p-6 hover:bg-muted/30 transition-all duration-200 gap-4 sm:gap-6"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6 flex-1 min-w-0">
|
||||
<div class="text-xs text-muted font-mono shrink-0 sm:min-w-[60px]">
|
||||
{{ formatDate(post.date) }}
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:variant="getCategoryVariant(post.category)"
|
||||
size="sm"
|
||||
class="font-mono text-xs justify-center gap-2 shrink-0 self-start sm:self-center"
|
||||
>
|
||||
<UIcon :name="getCategoryIcon(post.category)" class="size-3" />
|
||||
{{ post.category?.toUpperCase() || 'POST' }}
|
||||
</UBadge>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-highlighted group-hover:text-primary transition-colors duration-200 truncate sm:text-base">
|
||||
{{ post.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted mt-1 line-clamp-2 sm:line-clamp-1">
|
||||
{{ post.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between sm:justify-end gap-3 sm:gap-2 shrink-0">
|
||||
<UAvatarGroup v-if="post.authors?.length" size="sm" class="sm:size-sm">
|
||||
<UAvatar
|
||||
v-for="author in post.authors.slice(0, 3)"
|
||||
:key="author.name"
|
||||
:src="author.avatar?.src"
|
||||
:alt="author.name"
|
||||
size="sm"
|
||||
/>
|
||||
</UAvatarGroup>
|
||||
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 text-muted group-hover:text-highlighted transition-colors duration-200 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</ULink>
|
||||
</Motion>
|
||||
|
||||
<Motion
|
||||
v-if="filteredPosts.length === 0"
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:animate="{ opacity: 1, y: 0 }"
|
||||
class="text-center py-12 sm:py-16 px-4 sm:px-6"
|
||||
>
|
||||
<UIcon name="i-lucide-search-x" class="size-10 sm:size-12 text-muted mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium mb-2">
|
||||
No posts found
|
||||
</h3>
|
||||
<p class="text-muted mb-4 text-sm sm:text-base">
|
||||
{{ searchQuery ? `No posts match "${searchQuery}"` : 'No posts in this category yet' }}
|
||||
</p>
|
||||
<UButton
|
||||
v-if="selectedFilter !== 'all' || searchQuery"
|
||||
variant="outline"
|
||||
label="Clear filters"
|
||||
class="rounded-none"
|
||||
@click="selectedFilter = 'all'; searchQuery = ''"
|
||||
/>
|
||||
</Motion>
|
||||
</div>
|
||||
</UContainer>
|
||||
</UPageBody>
|
||||
|
||||
<UContainer class="relative min-h-24">
|
||||
<div aria-hidden="true" class="absolute z-[-1] border-x border-default inset-0 mx-4 sm:mx-6 lg:mx-8" />
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,12 +16,12 @@ function handleMessage(message) {
|
||||
async function handleFormatMessage(message) {
|
||||
if (!globalThis.prettier) {
|
||||
await Promise.all([
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.5.2/standalone.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.5.2/plugins/babel.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.5.2/plugins/estree.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.5.2/plugins/html.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.5.2/plugins/markdown.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.5.2/plugins/typescript.js')
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.6.2/standalone.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.6.2/plugins/babel.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.6.2/plugins/estree.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.6.2/plugins/html.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.6.2/plugins/markdown.js'),
|
||||
import('https://cdn.jsdelivr.net/npm/prettier@3.6.2/plugins/typescript.js')
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,22 @@ const Button = z.object({
|
||||
target: z.enum(['_blank', '_self']).optional()
|
||||
})
|
||||
|
||||
const Image = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional()
|
||||
})
|
||||
|
||||
const Author = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
twitter: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
avatar: Image.optional()
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
category: z.enum(['layout', 'form', 'element', 'navigation', 'data', 'overlay']).optional(),
|
||||
framework: z.string().optional(),
|
||||
@@ -75,5 +91,18 @@ export const collections = {
|
||||
})
|
||||
}))
|
||||
})
|
||||
}),
|
||||
blog: defineCollection({
|
||||
type: 'page',
|
||||
source: 'blog/*',
|
||||
schema: z.object({
|
||||
image: z.string().editor({ input: 'media' }),
|
||||
authors: z.array(Author),
|
||||
date: z.string().date(),
|
||||
minRead: z.number(),
|
||||
draft: z.boolean().optional(),
|
||||
category: z.enum(['Release', 'Tutorial', 'Announcement', 'Article']),
|
||||
tags: z.array(z.string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ props:
|
||||
You can use any name from the <https://icones.js.org> collection.
|
||||
::
|
||||
|
||||
::warning
|
||||
When using collections with a dash (`-`), you need to separate the icon name from the collection name with a colon (`:`) as `@iconify/vue` does not handle this case like `@nuxt/icon`. For example, instead of `i-simple-icons-github` you need to write `i-simple-icons:github` or `simple-icons:github`.
|
||||
|
||||
Learn more about the [Iconify naming convention](https://iconify.design/docs/icon-components/vue/#icon).
|
||||
::
|
||||
|
||||
### Component Props
|
||||
|
||||
Some components also have an `icon` prop to display an icon, like the [Button](/components/button) for example:
|
||||
|
||||
@@ -125,7 +125,7 @@ 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"}
|
||||
### Extend locale :badge{label="New" class="align-text-top"}
|
||||
|
||||
You can customize an existing locale by overriding its `messages` or `code` using the `extendLocale` composable:
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ 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"}
|
||||
### Extend locale :badge{label="New" class="align-text-top"}
|
||||
|
||||
You can customize an existing locale by overriding its `messages` or `code` using the `extendLocale` composable:
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ links:
|
||||
- label: GitHub
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/CheckboxGroup.vue
|
||||
navigation.badge: New
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Variant :badge{label="New" class="align-text-top"}
|
||||
### Variant
|
||||
|
||||
Use the `variant` prop to change the variant of the Checkbox.
|
||||
|
||||
@@ -190,7 +190,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Indicator :badge{label="New" class="align-text-top"}
|
||||
### Indicator
|
||||
|
||||
Use the `indicator` prop to change the position or hide the indicator. Defaults to `start`.
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ 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"}
|
||||
- `placeholder?: string`{lang="ts-type"}
|
||||
- `children?: CommandPaletteItem[]`{lang="ts-type"}
|
||||
- `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"}
|
||||
@@ -327,7 +327,7 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.ch
|
||||
:::
|
||||
::
|
||||
|
||||
### Trailing Icon :badge{label="Soon" class="align-text-top"}
|
||||
### Trailing Icon :badge{label="New" 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`.
|
||||
|
||||
@@ -565,7 +565,7 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.cl
|
||||
:::
|
||||
::
|
||||
|
||||
### Back :badge{label="Soon" class="align-text-top"}
|
||||
### Back :badge{label="New" class="align-text-top"}
|
||||
|
||||
Use the `back` prop to customize or hide the back button (with `false` value) displayed when navigating into a submenu.
|
||||
|
||||
@@ -604,7 +604,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Back Icon :badge{label="Soon" class="align-text-top"}
|
||||
### Back Icon :badge{label="New" class="align-text-top"}
|
||||
|
||||
Use the `back-icon` prop to customize the back button [Icon](/components/icon). Defaults to `i-lucide-arrow-left`.
|
||||
|
||||
@@ -717,7 +717,7 @@ 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"}
|
||||
### With children in items :badge{label="New" 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.
|
||||
|
||||
@@ -877,6 +877,20 @@ props:
|
||||
This can be useful when using the CommandPalette inside a [`Modal`](/components/modal) for example.
|
||||
::
|
||||
|
||||
### With footer slot :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
Use the `#footer` slot to add custom content at the bottom of the CommandPalette, such as keyboard shortcuts help or additional actions.
|
||||
|
||||
::component-example
|
||||
---
|
||||
collapse: true
|
||||
name: 'command-palette-footer-slot-example'
|
||||
class: '!p-0'
|
||||
props:
|
||||
autofocus: false
|
||||
---
|
||||
::
|
||||
|
||||
### With custom slot
|
||||
|
||||
Use the `slot` property to customize a specific item or group.
|
||||
|
||||
@@ -9,7 +9,7 @@ links:
|
||||
- label: GitHub
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/InputTags.vue
|
||||
navigation.badge: Soon
|
||||
navigation.badge: New
|
||||
---
|
||||
|
||||
## Usage
|
||||
@@ -51,6 +51,17 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Max Length :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
Use the `max-length` prop to set the maximum number of characters allowed in a tag.
|
||||
|
||||
::component-code
|
||||
---
|
||||
props:
|
||||
maxLength: 4
|
||||
---
|
||||
::
|
||||
|
||||
### Color
|
||||
|
||||
Use the `color` prop to change the ring color when the InputTags is focused.
|
||||
|
||||
@@ -889,7 +889,7 @@ You can inspect the DOM to see each item's content being rendered.
|
||||
|
||||
## Examples
|
||||
|
||||
### With tooltip in items :badge{label="New" class="align-text-top"}
|
||||
### With tooltip in items
|
||||
|
||||
When orientation is `vertical` and the menu is `collapsed`, you can set the `tooltip` prop to `true` to display a [Tooltip](/components/tooltip) around items with their label but you can also use the `tooltip` property on each item to override the default tooltip.
|
||||
|
||||
@@ -994,7 +994,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### With popover in items :badge{label="New" class="align-text-top"}
|
||||
### With popover in items
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -202,7 +202,17 @@ name: 'popover-command-palette-example'
|
||||
---
|
||||
::
|
||||
|
||||
### With anchor slot :badge{label="New" class="align-text-top"}
|
||||
### With following cursor :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
You can make the Popover follow the cursor when hovering over an element using the [`reference`](https://reka-ui.com/docs/components/tooltip#trigger) prop:
|
||||
|
||||
::component-example
|
||||
---
|
||||
name: 'popover-cursor-example'
|
||||
---
|
||||
::
|
||||
|
||||
### With anchor slot
|
||||
|
||||
You can use the `#anchor` slot to position the Popover against a custom element.
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Variant :badge{label="New" class="align-text-top"}
|
||||
### Variant
|
||||
|
||||
Use the `variant` prop to change the variant of the RadioGroup.
|
||||
|
||||
@@ -240,7 +240,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Indicator :badge{label="New" class="align-text-top"}
|
||||
### Indicator
|
||||
|
||||
Use the `indicator` prop to change the position or hide the indicator. Defaults to `start`.
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Tooltip :badge{label="New" class="align-text-top"}
|
||||
### Tooltip
|
||||
|
||||
Use the `tooltip` prop to display a [Tooltip](/components/tooltip) around the Slider thumbs with the current value. You can set it to `true` for default behavior or pass an object to customize it with any property from the [Tooltip](/components/tooltip#props) component.
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ Use the `columns` prop as an array of [ColumnDef](https://tanstack.com/table/lat
|
||||
|
||||
- `accessorKey`: [The key of the row object to use when extracting the value for the column.]{class="text-muted"}
|
||||
- `header`: [The header to display for the column. If a string is passed, it can be used as a default for the column ID. If a function is passed, it will be passed a props object for the header and should return the rendered header value (the exact type depends on the adapter being used).]{class="text-muted"}
|
||||
- `footer`: [The footer to display for the column. Works exactly like header, but is displayed under the table.]{class="text-muted"}
|
||||
- `cell`: [The cell to display each row for the column. If a function is passed, it will be passed a props object for the cell and should return the rendered cell value (the exact type depends on the adapter being used).]{class="text-muted"}
|
||||
- `meta`: [Extra properties for the column.]{class="text-muted"}
|
||||
- `class`:
|
||||
@@ -161,7 +162,7 @@ props:
|
||||
|
||||
### Sticky
|
||||
|
||||
Use the `sticky` prop to make the header sticky.
|
||||
Use the `sticky` prop to make the header or footer sticky.
|
||||
|
||||
::component-code
|
||||
---
|
||||
@@ -172,6 +173,10 @@ ignore:
|
||||
- class
|
||||
external:
|
||||
- data
|
||||
items:
|
||||
sticky:
|
||||
- true
|
||||
- false
|
||||
props:
|
||||
sticky: true
|
||||
data:
|
||||
@@ -266,8 +271,8 @@ You can group rows based on a given column value and show/hide sub rows via some
|
||||
|
||||
#### Important parts:
|
||||
|
||||
* Add prop `grouping` to `UTable` component with an array of column ids you want to group by.
|
||||
* Add prop `grouping-options` to `UTable`. It must include `getGroupedRowModel`, you can import it from `@tanstack/vue-table` or implement your own.
|
||||
* Add `grouping` prop with an array of column ids you want to group by.
|
||||
* Add `grouping-options` prop. It must include `getGroupedRowModel`, you can import it from `@tanstack/vue-table` or implement your own.
|
||||
* Expand rows via `row.toggleExpanded()` method on any cell of the row. Keep in mind, it also toggles `#expanded` slot.
|
||||
* Use `aggregateFn` on column definition to define how to aggregate the rows.
|
||||
* `agregatedCell` renderer on column definition only works if there is no `cell` renderer.
|
||||
@@ -304,19 +309,19 @@ class: '!p-0'
|
||||
You can use the `row-selection` prop to control the selection state of the rows (can be binded with `v-model`).
|
||||
::
|
||||
|
||||
### With `@select` event
|
||||
### With row select event
|
||||
|
||||
You can add a `@select` listener to make rows clickable. The handler function receives the `TableRow` instance as the first argument and an optional `Event` as the second argument.
|
||||
You can add a `@select` listener to make rows clickable with or without a checkbox column.
|
||||
|
||||
::note
|
||||
You can use this to navigate to a page, open a modal or even to select the row manually.
|
||||
The handler function receives the `TableRow` instance as the first argument and an optional `Event` as the second argument.
|
||||
::
|
||||
|
||||
::component-example
|
||||
---
|
||||
prettier: true
|
||||
collapse: true
|
||||
name: 'table-row-selection-event-example'
|
||||
name: 'table-row-select-event-example'
|
||||
highlights:
|
||||
- 123
|
||||
- 130
|
||||
@@ -324,6 +329,70 @@ class: '!p-0'
|
||||
---
|
||||
::
|
||||
|
||||
::tip
|
||||
You can use this to navigate to a page, open a modal or even to select the row manually.
|
||||
::
|
||||
|
||||
### With row context menu event :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
You can add a `@contextmenu` listener to make rows right clickable and wrap the Table in a [ContextMenu](/components/context-menu) component to display row actions for example.
|
||||
|
||||
::note
|
||||
The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.
|
||||
::
|
||||
|
||||
::component-example
|
||||
---
|
||||
prettier: true
|
||||
collapse: true
|
||||
name: 'table-row-context-menu-event-example'
|
||||
highlights:
|
||||
- 130
|
||||
- 170
|
||||
class: '!p-0'
|
||||
---
|
||||
::
|
||||
|
||||
### With row hover event :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
You can add a `@hover` listener to make rows hoverable and use a [Popover](/components/popover) or a [Tooltip](/components/tooltip) component to display row details for example.
|
||||
|
||||
::note
|
||||
The handler function receives the `Event` and `TableRow` instance as the first and second arguments respectively.
|
||||
::
|
||||
|
||||
::component-example
|
||||
---
|
||||
prettier: true
|
||||
collapse: true
|
||||
name: 'table-row-hover-event-example'
|
||||
highlights:
|
||||
- 126
|
||||
- 149
|
||||
class: '!p-0'
|
||||
---
|
||||
::
|
||||
|
||||
::note
|
||||
This example is similar as the Popover [with following cursor example](/components/popover#with-following-cursor) and uses a [`refDebounced`](https://vueuse.org/shared/refDebounced/#refdebounced) to prevent the Popover from opening and closing too quickly when moving the cursor from one row to another.
|
||||
::
|
||||
|
||||
### With column footer :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
You can add a `footer` property to the column definition to render a footer for the column.
|
||||
|
||||
::component-example
|
||||
---
|
||||
prettier: true
|
||||
collapse: true
|
||||
name: 'table-column-footer-example'
|
||||
highlights:
|
||||
- 94
|
||||
- 108
|
||||
class: '!p-0'
|
||||
---
|
||||
::
|
||||
|
||||
### With column sorting
|
||||
|
||||
You can update a column `header` to render a [Button](/components/button) component inside the `header` to toggle the sorting state using the TanStack Table [Sorting APIs](https://tanstack.com/table/latest/docs/api/features/sorting).
|
||||
|
||||
@@ -124,7 +124,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Icon :badge{label="New" class="align-text-top"}
|
||||
### Icon
|
||||
|
||||
Use the `icon` prop to show an [Icon](/components/icon) inside the Textarea.
|
||||
|
||||
@@ -157,7 +157,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Avatar :badge{label="New" class="align-text-top"}
|
||||
### Avatar
|
||||
|
||||
Use the `avatar` prop to show an [Avatar](/components/avatar) inside the Textarea.
|
||||
|
||||
@@ -176,7 +176,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Loading :badge{label="New" class="align-text-top"}
|
||||
### Loading
|
||||
|
||||
Use the `loading` prop to show a loading icon on the Textarea.
|
||||
|
||||
@@ -192,7 +192,7 @@ props:
|
||||
---
|
||||
::
|
||||
|
||||
### Loading Icon :badge{label="New" class="align-text-top"}
|
||||
### Loading Icon
|
||||
|
||||
Use the `loading-icon` prop to customize the loading icon. Defaults to `i-lucide-loader-circle`.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ links:
|
||||
- label: GitHub
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/Timeline.vue
|
||||
navigation.badge: Soon
|
||||
navigation.badge: New
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -107,7 +107,7 @@ name: 'toast-color-example'
|
||||
|
||||
### Close
|
||||
|
||||
Pass a `close` field to customize or hide the close button (with `false` value).
|
||||
Pass a `close` field to customize or hide the close [Button](/components/button) (with `false` value).
|
||||
|
||||
::component-example
|
||||
---
|
||||
@@ -143,7 +143,7 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.cl
|
||||
|
||||
### Actions
|
||||
|
||||
Pass an `actions` field to add some [Button](/components/button) actions to the Alert.
|
||||
Pass an `actions` field to add some [Button](/components/button) actions to the Toast.
|
||||
|
||||
::component-example
|
||||
---
|
||||
@@ -155,9 +155,23 @@ name: 'toast-actions-example'
|
||||
---
|
||||
::
|
||||
|
||||
### Progress :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
Pass a `progress` field to customize or hide the [Progress](/components/progress) bar (with `false` value).
|
||||
|
||||
::tip
|
||||
The Progress bar inherits the Toast color by default, but you can override it using the `progress.color` field.
|
||||
::
|
||||
|
||||
::component-example
|
||||
---
|
||||
name: 'toast-progress-example'
|
||||
---
|
||||
::
|
||||
|
||||
### Orientation
|
||||
|
||||
Use the `orientation` prop to change the orientation of the Toast.
|
||||
Pass an `orientation` field to the `toast.add` method to change the orientation of the Toast.
|
||||
|
||||
::component-example
|
||||
---
|
||||
|
||||
@@ -186,6 +186,16 @@ name: 'tooltip-open-example'
|
||||
In this example, leveraging [`defineShortcuts`](/composables/define-shortcuts), you can toggle the Tooltip by pressing :kbd{value="O"}.
|
||||
::
|
||||
|
||||
### With following cursor :badge{label="Soon" class="align-text-top"}
|
||||
|
||||
You can make the Tooltip follow the cursor when hovering over an element using the [`reference`](https://reka-ui.com/docs/components/tooltip#trigger) prop:
|
||||
|
||||
::component-example
|
||||
---
|
||||
name: 'tooltip-cursor-example'
|
||||
---
|
||||
::
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
198
docs/content/blog/nuxt-ui-v3.md
Normal file
198
docs/content/blog/nuxt-ui-v3.md
Normal file
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Nuxt UI v3
|
||||
description: Nuxt UI v3 is out! After 1500+ commits, this major redesign brings
|
||||
improved accessibility, Tailwind CSS v4 support, and full Vue compatibility
|
||||
navigation: false
|
||||
image: /assets/blog/nuxt-ui-v3.png
|
||||
minRead: 7
|
||||
authors:
|
||||
- name: Benjamin Canac
|
||||
avatar:
|
||||
src: https://github.com/benjamincanac.png
|
||||
to: https://x.com/benjamincanac
|
||||
- name: Sébastien Chopin
|
||||
avatar:
|
||||
src: https://github.com/atinux.png
|
||||
to: https://x.com/atinux
|
||||
- name: Hugo Richard
|
||||
avatar:
|
||||
src: https://github.com/hugorcd.png
|
||||
to: https://x.com/hugorcd__
|
||||
date: 2025-03-12T10:00:00.000Z
|
||||
category: Release
|
||||
---
|
||||
|
||||
We are thrilled to announce the release of Nuxt UI v3, a complete redesign of our UI library that brings significant improvements in accessibility, performance, and developer experience. This major update represents over 1500 commits of hard work, collaboration, and innovation from our team and the community.
|
||||
|
||||
## 🚀 Reimagined from the Ground Up
|
||||
|
||||
Nuxt UI v3 represents a major leap forward in our journey to provide the most comprehensive UI solution for Vue and Nuxt developers. This version has been rebuilt from the ground up with modern technologies and best practices in mind.
|
||||
|
||||
### **From HeadlessUI to Reka UI**
|
||||
|
||||
With Reka UI at its core, Nuxt UI v3 delivers:
|
||||
|
||||
• Proper keyboard navigation across all interactive components
|
||||
|
||||
• ARIA attributes automatically handled for you
|
||||
|
||||
• Focus management that just works
|
||||
|
||||
• Screen reader friendly components out of the box
|
||||
|
||||
This means you can build applications that work for everyone without becoming an accessibility expert.
|
||||
|
||||
### **Tailwind CSS v4 Integration**
|
||||
|
||||
The integration with Tailwind CSS v4 brings huge performance improvements:
|
||||
|
||||
• **5x faster runtime** with optimized component rendering
|
||||
|
||||
• **100x faster build times** thanks to the new CSS-first engine
|
||||
|
||||
• Smaller bundle sizes with more efficient styling
|
||||
|
||||
Your applications will feel snappier, build quicker, and load faster for your users.
|
||||
|
||||
## 🎨 A Brand New Design System
|
||||
|
||||
```html
|
||||
<!-- Before: Inconsistent color usage with duplicate dark mode classes -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h2 class="text-gray-900 dark:text-white text-xl mb-2">User Profile</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">Account settings and preferences</p>
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded mt-2">Edit Profile</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- After: Semantic design tokens with automatic dark mode support -->
|
||||
<div class="bg-muted p-4 rounded-lg">
|
||||
<h2 class="text-highlighted text-xl mb-2">User Profile</h2>
|
||||
<p class="text-muted">Account settings and preferences</p>
|
||||
<UButton color="primary" size="sm" class="mt-2">Edit Profile</UButton>
|
||||
</div>
|
||||
```
|
||||
|
||||
Our new color system includes 7 semantic color aliases:
|
||||
|
||||
| Color | Default | Description |
|
||||
|-----------------------------------|----------|--------------------------------------------------|
|
||||
| :code[primary]{.text-primary} | `blue` | Primary color to represent the brand.
|
||||
| :code[secondary]{.text-secondary} | `blue` | Secondary color to complement the primary color.
|
||||
| :code[success]{.text-success} | `green` | Used for success states.
|
||||
| :code[info]{.text-info} | `blue` | Used for informational states.
|
||||
| :code[warning]{.text-warning} | `yellow` | Used for warning states.
|
||||
| :code[error]{.text-error} | `red` | Used for form error validation states. |
|
||||
| `neutral` | `slate` | Neutral color for backgrounds, text, etc. |
|
||||
|
||||
This approach makes your codebase more maintainable and your UI more consistent—especially when working in teams. With these semantic tokens, light and dark mode transitions become effortless, as the system automatically handles the appropriate color values for each theme without requiring duplicate class definitions.
|
||||
|
||||
## 💚 Complete Vue Compatibility
|
||||
|
||||
We're really happy to expand the scope of Nuxt UI beyond the Nuxt framework. With v3, both Nuxt UI and Nuxt UI Pro now work seamlessly in any Vue project, this means you can:
|
||||
|
||||
• Use the same components across all your Vue projects
|
||||
|
||||
• Benefit from Nuxt UI's theming system in any Vue application
|
||||
|
||||
• Enjoy auto-imports and TypeScript support outside of Nuxt
|
||||
|
||||
• Leverage both basic components and advanced Pro components in any Vue project
|
||||
|
||||
```ts [vite.config.ts]
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
ui()
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 📦 Components for Every Need
|
||||
|
||||
With 54 core components, 50 Pro components, and 42 Prose components, Nuxt UI v3 provides solutions for virtually any UI challenge:
|
||||
|
||||
• **Data Display**: Tables, charts, and visualizations that adapt to your data
|
||||
|
||||
• **Navigation**: Menus, tabs, and breadcrumbs that guide users intuitively
|
||||
|
||||
• **Feedback**: Toasts, alerts, and modals that communicate clearly
|
||||
|
||||
• **Forms**: Inputs, selectors, and validation that simplify data collection
|
||||
|
||||
• **Layout**: Grids, containers, and responsive systems that organize content beautifully
|
||||
|
||||
Each component is designed to be both beautiful out of the box and deeply customizable when needed.
|
||||
|
||||
## 🔷 Improved TypeScript Integration
|
||||
|
||||
We've completely revamped our TypeScript integration, with features that make you more productive:
|
||||
|
||||
- Complete type safety with helpful autocompletion
|
||||
- Generic-based components for flexible APIs
|
||||
- Type-safe theming through a clear, consistent API
|
||||
|
||||
```ts
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
button: {
|
||||
// Your IDE will show all available options
|
||||
slots: {
|
||||
base: 'font-bold rounded-lg'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## ⬆️ Upgrading to v3
|
||||
|
||||
We've prepared a comprehensive [migration](https://ui.nuxt.com/getting-started/migration) guide to help you upgrade from v2 to v3. While there are breaking changes due to our complete overhaul, we've worked hard to make the transition as smooth as possible.
|
||||
|
||||
## 🎯 Getting Started
|
||||
|
||||
Whether you're starting a new project or upgrading an existing one, getting started with Nuxt UI v3 is easy:
|
||||
|
||||
```bash
|
||||
# Create a new Nuxt project with Nuxt UI
|
||||
npx nuxi@latest init my-app -t ui
|
||||
```
|
||||
|
||||
::code-group{sync="pm"}
|
||||
```bash [pnpm]
|
||||
pnpm add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [yarn]
|
||||
yarn add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [npm]
|
||||
npm install @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [bun]
|
||||
bun add @nuxt/ui@latest
|
||||
```
|
||||
::
|
||||
|
||||
::warning
|
||||
If you're using **pnpm**, ensure that you either set [`shamefully-hoist=true`](https://pnpm.io/npmrc#shamefully-hoist) in your `.npmrc` file or install `tailwindcss` in your project's root directory.
|
||||
::
|
||||
|
||||
Visit our [documentation](https://ui.nuxt.com/getting-started) to explore all the components and features available in Nuxt UI v3.
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
This release represents thousands of hours of work from our team and the community. We'd like to thank everyone who contributed to making Nuxt UI v3 a reality.
|
||||
|
||||
We're excited to see what you'll build with Nuxt UI v3!
|
||||
198
docs/content/blog/nuxt-ui.md
Normal file
198
docs/content/blog/nuxt-ui.md
Normal file
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Nuxt UI
|
||||
description: Nuxt UI v3 is out! After 1500+ commits, this major redesign brings
|
||||
improved accessibility, Tailwind CSS v4 support, and full Vue compatibility
|
||||
navigation: false
|
||||
image: /assets/blog/nuxt-ui-v3.png
|
||||
minRead: 7
|
||||
authors:
|
||||
- name: Benjamin Canac
|
||||
avatar:
|
||||
src: https://github.com/benjamincanac.png
|
||||
to: https://x.com/benjamincanac
|
||||
- name: Sébastien Chopin
|
||||
avatar:
|
||||
src: https://github.com/atinux.png
|
||||
to: https://x.com/atinux
|
||||
- name: Hugo Richard
|
||||
avatar:
|
||||
src: https://github.com/hugorcd.png
|
||||
to: https://x.com/hugorcd__
|
||||
date: 2025-03-12T09:00:00.000Z
|
||||
category: improvement
|
||||
---
|
||||
|
||||
We are thrilled to announce the release of Nuxt UI v3, a complete redesign of our UI library that brings significant improvements in accessibility, performance, and developer experience. This major update represents over 1500 commits of hard work, collaboration, and innovation from our team and the community.
|
||||
|
||||
## 🚀 Reimagined from the Ground Up
|
||||
|
||||
Nuxt UI v3 represents a major leap forward in our journey to provide the most comprehensive UI solution for Vue and Nuxt developers. This version has been rebuilt from the ground up with modern technologies and best practices in mind.
|
||||
|
||||
### **From HeadlessUI to Reka UI**
|
||||
|
||||
With Reka UI at its core, Nuxt UI v3 delivers:
|
||||
|
||||
• Proper keyboard navigation across all interactive components
|
||||
|
||||
• ARIA attributes automatically handled for you
|
||||
|
||||
• Focus management that just works
|
||||
|
||||
• Screen reader friendly components out of the box
|
||||
|
||||
This means you can build applications that work for everyone without becoming an accessibility expert.
|
||||
|
||||
### **Tailwind CSS v4 Integration**
|
||||
|
||||
The integration with Tailwind CSS v4 brings huge performance improvements:
|
||||
|
||||
• **5x faster runtime** with optimized component rendering
|
||||
|
||||
• **100x faster build times** thanks to the new CSS-first engine
|
||||
|
||||
• Smaller bundle sizes with more efficient styling
|
||||
|
||||
Your applications will feel snappier, build quicker, and load faster for your users.
|
||||
|
||||
## 🎨 A Brand New Design System
|
||||
|
||||
```html
|
||||
<!-- Before: Inconsistent color usage with duplicate dark mode classes -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h2 class="text-gray-900 dark:text-white text-xl mb-2">User Profile</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">Account settings and preferences</p>
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded mt-2">Edit Profile</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- After: Semantic design tokens with automatic dark mode support -->
|
||||
<div class="bg-muted p-4 rounded-lg">
|
||||
<h2 class="text-highlighted text-xl mb-2">User Profile</h2>
|
||||
<p class="text-muted">Account settings and preferences</p>
|
||||
<UButton color="primary" size="sm" class="mt-2">Edit Profile</UButton>
|
||||
</div>
|
||||
```
|
||||
|
||||
Our new color system includes 7 semantic color aliases:
|
||||
|
||||
| Color | Default | Description |
|
||||
|-----------------------------------|----------|--------------------------------------------------|
|
||||
| :code[primary]{.text-primary} | `blue` | Primary color to represent the brand.
|
||||
| :code[secondary]{.text-secondary} | `blue` | Secondary color to complement the primary color.
|
||||
| :code[success]{.text-success} | `green` | Used for success states.
|
||||
| :code[info]{.text-info} | `blue` | Used for informational states.
|
||||
| :code[warning]{.text-warning} | `yellow` | Used for warning states.
|
||||
| :code[error]{.text-error} | `red` | Used for form error validation states. |
|
||||
| `neutral` | `slate` | Neutral color for backgrounds, text, etc. |
|
||||
|
||||
This approach makes your codebase more maintainable and your UI more consistent—especially when working in teams. With these semantic tokens, light and dark mode transitions become effortless, as the system automatically handles the appropriate color values for each theme without requiring duplicate class definitions.
|
||||
|
||||
## 💚 Complete Vue Compatibility
|
||||
|
||||
We're really happy to expand the scope of Nuxt UI beyond the Nuxt framework. With v3, both Nuxt UI and Nuxt UI Pro now work seamlessly in any Vue project, this means you can:
|
||||
|
||||
• Use the same components across all your Vue projects
|
||||
|
||||
• Benefit from Nuxt UI's theming system in any Vue application
|
||||
|
||||
• Enjoy auto-imports and TypeScript support outside of Nuxt
|
||||
|
||||
• Leverage both basic components and advanced Pro components in any Vue project
|
||||
|
||||
```ts [vite.config.ts]
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
ui()
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 📦 Components for Every Need
|
||||
|
||||
With 54 core components, 50 Pro components, and 42 Prose components, Nuxt UI v3 provides solutions for virtually any UI challenge:
|
||||
|
||||
• **Data Display**: Tables, charts, and visualizations that adapt to your data
|
||||
|
||||
• **Navigation**: Menus, tabs, and breadcrumbs that guide users intuitively
|
||||
|
||||
• **Feedback**: Toasts, alerts, and modals that communicate clearly
|
||||
|
||||
• **Forms**: Inputs, selectors, and validation that simplify data collection
|
||||
|
||||
• **Layout**: Grids, containers, and responsive systems that organize content beautifully
|
||||
|
||||
Each component is designed to be both beautiful out of the box and deeply customizable when needed.
|
||||
|
||||
## 🔷 Improved TypeScript Integration
|
||||
|
||||
We've completely revamped our TypeScript integration, with features that make you more productive:
|
||||
|
||||
- Complete type safety with helpful autocompletion
|
||||
- Generic-based components for flexible APIs
|
||||
- Type-safe theming through a clear, consistent API
|
||||
|
||||
```ts
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
button: {
|
||||
// Your IDE will show all available options
|
||||
slots: {
|
||||
base: 'font-bold rounded-lg'
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## ⬆️ Upgrading to v3
|
||||
|
||||
We've prepared a comprehensive [migration](https://ui.nuxt.com/getting-started/migration) guide to help you upgrade from v2 to v3. While there are breaking changes due to our complete overhaul, we've worked hard to make the transition as smooth as possible.
|
||||
|
||||
## 🎯 Getting Started
|
||||
|
||||
Whether you're starting a new project or upgrading an existing one, getting started with Nuxt UI v3 is easy:
|
||||
|
||||
```bash
|
||||
# Create a new Nuxt project with Nuxt UI
|
||||
npx nuxi@latest init my-app -t ui
|
||||
```
|
||||
|
||||
::code-group{sync="pm"}
|
||||
```bash [pnpm]
|
||||
pnpm add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [yarn]
|
||||
yarn add @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [npm]
|
||||
npm install @nuxt/ui@latest
|
||||
```
|
||||
|
||||
```bash [bun]
|
||||
bun add @nuxt/ui@latest
|
||||
```
|
||||
::
|
||||
|
||||
::warning
|
||||
If you're using **pnpm**, ensure that you either set [`shamefully-hoist=true`](https://pnpm.io/npmrc#shamefully-hoist) in your `.npmrc` file or install `tailwindcss` in your project's root directory.
|
||||
::
|
||||
|
||||
Visit our [documentation](https://ui.nuxt.com/getting-started) to explore all the components and features available in Nuxt UI v3.
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
This release represents thousands of hours of work from our team and the community. We'd like to thank everyone who contributed to making Nuxt UI v3 a reality.
|
||||
|
||||
We're excited to see what you'll build with Nuxt UI v3!
|
||||
@@ -11,39 +11,40 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/vue": "^1.2.12",
|
||||
"@iconify-json/logos": "^1.2.4",
|
||||
"@iconify-json/lucide": "^1.2.47",
|
||||
"@iconify-json/simple-icons": "^1.2.38",
|
||||
"@iconify-json/vscode-icons": "^1.2.22",
|
||||
"@nuxt/content": "^3.5.1",
|
||||
"@iconify-json/lucide": "^1.2.56",
|
||||
"@iconify-json/simple-icons": "^1.2.42",
|
||||
"@iconify-json/vscode-icons": "^1.2.23",
|
||||
"@nuxt/content": "^3.6.3",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@beebbd4",
|
||||
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@22fdc5e",
|
||||
"@nuxthub/core": "^0.9.0",
|
||||
"@nuxtjs/plausible": "^1.2.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@vueuse/integrations": "^13.3.0",
|
||||
"@vueuse/nuxt": "^13.3.0",
|
||||
"@vueuse/integrations": "^13.5.0",
|
||||
"@vueuse/nuxt": "^13.5.0",
|
||||
"ai": "^4.3.16",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"capture-website": "^4.2.0",
|
||||
"joi": "^17.13.3",
|
||||
"maska": "^3.1.1",
|
||||
"motion-v": "^1.2.1",
|
||||
"nuxt": "^3.17.5",
|
||||
"nuxt-component-meta": "^0.11.0",
|
||||
"maska": "^3.2.0",
|
||||
"motion-v": "^1.5.0",
|
||||
"nuxt": "^3.17.6",
|
||||
"nuxt-component-meta": "^0.12.1",
|
||||
"nuxt-llms": "^0.1.3",
|
||||
"nuxt-og-image": "^5.1.6",
|
||||
"prettier": "^3.5.3",
|
||||
"nuxt-og-image": "^5.1.9",
|
||||
"prettier": "^3.6.2",
|
||||
"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.6.0",
|
||||
"workers-ai-provider": "^0.7.1",
|
||||
"yup": "^1.6.1",
|
||||
"zod": "^3.25.57"
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.19.1"
|
||||
"wrangler": "^4.23.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
docs/public/assets/blog/nuxt-ui-v3.png
Normal file
BIN
docs/public/assets/blog/nuxt-ui-v3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 515 KiB |
BIN
docs/public/components/dark/changelog-version.png
Normal file
BIN
docs/public/components/dark/changelog-version.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
docs/public/components/dark/changelog-versions.png
Normal file
BIN
docs/public/components/dark/changelog-versions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/public/components/light/changelog-version.png
Normal file
BIN
docs/public/components/light/changelog-version.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
docs/public/components/light/changelog-versions.png
Normal file
BIN
docs/public/components/light/changelog-versions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 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
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
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
|
||||
}
|
||||
48
package.json
48
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.3",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"version": "3.2.0",
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nuxt/ui.git"
|
||||
@@ -98,9 +98,9 @@
|
||||
"prepack": "pnpm build",
|
||||
"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 && nuxt prepare playground && nuxt prepare docs && vite build playground-vue",
|
||||
"dev:vue": "pnpm --filter playground-vue dev -- --uiDev",
|
||||
"dev:vue:build": "pnpm --filter playground-vue build",
|
||||
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground && nuxt prepare docs && pnpm dev:vue:build",
|
||||
"docs": "nuxt dev docs --uiDev",
|
||||
"docs:build": "nuxt build docs",
|
||||
"lint": "eslint .",
|
||||
@@ -115,17 +115,17 @@
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@internationalized/number": "^3.6.3",
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/icon": "^1.13.0",
|
||||
"@nuxt/kit": "^3.17.5",
|
||||
"@nuxt/schema": "^3.17.5",
|
||||
"@nuxt/icon": "^1.15.0",
|
||||
"@nuxt/kit": "^3.17.6",
|
||||
"@nuxt/schema": "^3.17.6",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unhead/vue": "^2.0.10",
|
||||
"@vueuse/core": "^13.3.0",
|
||||
"@vueuse/integrations": "^13.3.0",
|
||||
"@unhead/vue": "^2.0.12",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/integrations": "^13.5.0",
|
||||
"colortranslator": "^5.0.0",
|
||||
"consola": "^3.4.2",
|
||||
"defu": "^6.1.4",
|
||||
@@ -143,31 +143,31 @@
|
||||
"mlly": "^1.7.4",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"reka-ui": "2.3.1",
|
||||
"reka-ui": "2.3.2",
|
||||
"scule": "^1.3.0",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"unplugin": "^2.3.5",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.7.0",
|
||||
"unplugin-vue-components": "^28.8.0",
|
||||
"vaul-vue": "0.4.1",
|
||||
"vue-component-type-helpers": "^2.2.10"
|
||||
"vue-component-type-helpers": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^1.4.1",
|
||||
"@nuxt/eslint-config": "^1.5.2",
|
||||
"@nuxt/module-builder": "^1.0.1",
|
||||
"@nuxt/test-utils": "^3.19.1",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@release-it/conventional-changelog": "^10.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"eslint": "^9.28.0",
|
||||
"happy-dom": "^17.6.3",
|
||||
"nuxt": "^3.17.5",
|
||||
"eslint": "^9.30.1",
|
||||
"happy-dom": "^18.0.1",
|
||||
"nuxt": "^3.17.6",
|
||||
"release-it": "^19.0.3",
|
||||
"vitest": "^3.2.3",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest-environment-nuxt": "^1.0.1",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"vue-tsc": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@inertiajs/vue3": "^2.0.7",
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"vue": "^3.5.16",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"zod": "^3.25.57"
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"vue-tsc": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,27 @@ defineShortcuts({
|
||||
multiple
|
||||
class="sm:max-h-80"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UIcon name="i-simple-icons-nuxtdotjs" class="size-5 text-dimmed ml-1" />
|
||||
<div class="flex items-center gap-1">
|
||||
<UButton color="neutral" variant="ghost" label="Open Command" class="text-dimmed" size="xs">
|
||||
<template #trailing>
|
||||
<UKbd value="enter" />
|
||||
</template>
|
||||
</UButton>
|
||||
<USeparator orientation="vertical" class="h-4" />
|
||||
<UButton color="neutral" variant="ghost" label="Actions" class="text-dimmed" size="xs">
|
||||
<template #trailing>
|
||||
<UKbd value="meta" />
|
||||
<UKbd value="k" />
|
||||
</template>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCommandPalette>
|
||||
</DefineTemplate>
|
||||
|
||||
<div class="flex-1 flex flex-col gap-12 w-full max-w-lg">
|
||||
|
||||
@@ -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, refDebounced } 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
|
||||
@@ -145,6 +147,35 @@ const data = ref<Payment[]>([{
|
||||
|
||||
const currentID = ref(4601)
|
||||
|
||||
function getRowItems(row: TableRow<Payment>) {
|
||||
return [{
|
||||
type: 'label' as const,
|
||||
label: 'Actions'
|
||||
}, {
|
||||
label: 'Copy payment ID',
|
||||
onSelect() {
|
||||
copy(row.original.id)
|
||||
|
||||
toast.add({
|
||||
title: 'Payment ID copied to clipboard!',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check'
|
||||
})
|
||||
}
|
||||
}, {
|
||||
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
|
||||
onSelect() {
|
||||
row.toggleExpanded()
|
||||
}
|
||||
}, {
|
||||
type: 'separator' as const
|
||||
}, {
|
||||
label: 'View customer'
|
||||
}, {
|
||||
label: 'View payment details'
|
||||
}]
|
||||
}
|
||||
|
||||
const columns: TableColumn<Payment>[] = [{
|
||||
id: 'select',
|
||||
header: ({ table }) => h(UCheckbox, {
|
||||
@@ -211,6 +242,16 @@ const columns: TableColumn<Payment>[] = [{
|
||||
}, {
|
||||
accessorKey: 'amount',
|
||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
||||
footer: ({ column }) => {
|
||||
const total = column.getFacetedRowModel().rows.reduce((acc: number, row: TableRow<Payment>) => acc + Number.parseFloat(row.getValue('amount')), 0)
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(total)
|
||||
|
||||
return h('div', { class: 'text-right font-medium' }, `Total: ${formatted}`)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const amount = Number.parseFloat(row.getValue('amount'))
|
||||
|
||||
@@ -225,38 +266,11 @@ const columns: TableColumn<Payment>[] = [{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const items = [{
|
||||
type: 'label',
|
||||
label: 'Actions'
|
||||
}, {
|
||||
label: 'Copy payment ID',
|
||||
onSelect() {
|
||||
navigator.clipboard.writeText(row.original.id)
|
||||
|
||||
toast.add({
|
||||
title: 'Payment ID copied to clipboard!',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check'
|
||||
})
|
||||
}
|
||||
}, {
|
||||
label: row.getIsExpanded() ? 'Collapse' : 'Expand',
|
||||
onSelect() {
|
||||
row.toggleExpanded()
|
||||
}
|
||||
}, {
|
||||
type: 'separator'
|
||||
}, {
|
||||
label: 'View customer'
|
||||
}, {
|
||||
label: 'View payment details'
|
||||
}]
|
||||
|
||||
return h('div', { class: 'text-right' }, h(UDropdownMenu, {
|
||||
'content': {
|
||||
align: 'end'
|
||||
},
|
||||
items,
|
||||
'items': getRowItems(row),
|
||||
'aria-label': 'Actions dropdown'
|
||||
}, () => h(UButton, {
|
||||
'icon': 'i-lucide-ellipsis-vertical',
|
||||
@@ -294,8 +308,41 @@ function randomize() {
|
||||
data.value = data.value.sort(() => Math.random() - 0.5)
|
||||
}
|
||||
|
||||
const rowSelection = ref<Record<string, boolean>>({})
|
||||
|
||||
function onSelect(row: TableRow<Payment>) {
|
||||
console.log(row)
|
||||
row.toggleSelected(!row.getIsSelected())
|
||||
}
|
||||
|
||||
const contextmenuRow = ref<TableRow<Payment> | null>(null)
|
||||
const contextmenuItems = computed(() => contextmenuRow.value ? getRowItems(contextmenuRow.value) : [])
|
||||
|
||||
function onContextmenu(e: Event, row: TableRow<Payment>) {
|
||||
contextmenuRow.value = row
|
||||
}
|
||||
|
||||
const popoverOpen = ref(false)
|
||||
const popoverOpenDebounced = refDebounced(popoverOpen, 1)
|
||||
const popoverAnchor = ref({ x: 0, y: 0 })
|
||||
const popoverRow = ref<TableRow<Payment> | null>(null)
|
||||
|
||||
const reference = computed(() => ({
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
width: 0,
|
||||
height: 0,
|
||||
left: popoverAnchor.value.x,
|
||||
right: popoverAnchor.value.x,
|
||||
top: popoverAnchor.value.y,
|
||||
bottom: popoverAnchor.value.y,
|
||||
...popoverAnchor.value
|
||||
} as DOMRect)
|
||||
}))
|
||||
|
||||
function onHover(_e: Event, row: TableRow<Payment> | null) {
|
||||
popoverRow.value = row
|
||||
|
||||
popoverOpen.value = !!row
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -342,27 +389,44 @@ onMounted(() => {
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
|
||||
<UTable
|
||||
ref="table"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:column-pinning="columnPinning"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:pagination-options="{
|
||||
getPaginationRowModel: getPaginationRowModel()
|
||||
}"
|
||||
:ui="{
|
||||
tr: 'divide-x divide-default'
|
||||
}"
|
||||
sticky
|
||||
class="border border-accented rounded-sm"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #expanded="{ row }">
|
||||
<pre>{{ row.original }}</pre>
|
||||
<UContextMenu :items="contextmenuItems">
|
||||
<UTable
|
||||
ref="table"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:column-pinning="columnPinning"
|
||||
:row-selection="rowSelection"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:pagination-options="{
|
||||
getPaginationRowModel: getPaginationRowModel()
|
||||
}"
|
||||
:ui="{
|
||||
tr: 'divide-x divide-default'
|
||||
}"
|
||||
sticky
|
||||
class="border border-accented rounded-sm"
|
||||
@select="onSelect"
|
||||
@contextmenu="onContextmenu"
|
||||
@pointermove="(ev: PointerEvent) => {
|
||||
popoverAnchor.x = ev.clientX
|
||||
popoverAnchor.y = ev.clientY
|
||||
}"
|
||||
@hover="onHover"
|
||||
>
|
||||
<template #expanded="{ row }">
|
||||
<pre>{{ row.original }}</pre>
|
||||
</template>
|
||||
</UTable>
|
||||
</UContextMenu>
|
||||
|
||||
<UPopover :content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }" :open="popoverOpenDebounced" :reference="reference">
|
||||
<template #content>
|
||||
<div class="p-4">
|
||||
{{ popoverRow?.original?.id }}
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</UPopover>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm text-muted">
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "^1.2.47",
|
||||
"@iconify-json/simple-icons": "^1.2.38",
|
||||
"@iconify-json/lucide": "^1.2.56",
|
||||
"@iconify-json/simple-icons": "^1.2.42",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxthub/core": "^0.9.0",
|
||||
"nuxt": "^3.17.5",
|
||||
"zod": "^3.25.57"
|
||||
"nuxt": "^3.17.6",
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"vue-tsc": "^3.0.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"unimport": "4.1.1"
|
||||
|
||||
4832
pnpm-lock.yaml
generated
4832
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,12 @@
|
||||
"reka-ui",
|
||||
"vaul-vue"
|
||||
]
|
||||
}, {
|
||||
"groupName": "vue-tsc",
|
||||
"matchPackageNames": [
|
||||
"vue-tsc",
|
||||
"vue-component-type-helpers"
|
||||
]
|
||||
}, {
|
||||
"matchDepTypes": ["peerDependencies"],
|
||||
"enabled": false
|
||||
|
||||
@@ -11,7 +11,7 @@ import { defu } from 'defu'
|
||||
/**
|
||||
* This plugin adds all the Nuxt UI components as auto-imports.
|
||||
*/
|
||||
export default function ComponentImportPlugin(options: NuxtUIOptions & { prefix: NonNullable<NuxtUIOptions['prefix']> }, meta: UnpluginContextMeta) {
|
||||
export default function ComponentImportPlugin(options: NuxtUIOptions & { prefix: NonNullable<NuxtUIOptions['prefix']>, extraRuntimeDir?: string }, meta: UnpluginContextMeta) {
|
||||
const components = globSync('**/*.vue', { cwd: join(runtimeDir, 'components') })
|
||||
const componentNames = new Set(components.map(c => `${options.prefix}${c.replace(/\.vue$/, '')}`))
|
||||
|
||||
@@ -50,13 +50,15 @@ export default function ComponentImportPlugin(options: NuxtUIOptions & { prefix:
|
||||
name: 'nuxt:ui:components',
|
||||
enforce: 'pre',
|
||||
resolveId(id, importer) {
|
||||
// only apply to runtime nuxt ui components
|
||||
if (!importer || !normalize(importer).includes(runtimeDir)) {
|
||||
if (!importer) {
|
||||
return
|
||||
}
|
||||
if (!normalize(importer).includes(runtimeDir) && (!options.extraRuntimeDir || !normalize(importer).includes(options.extraRuntimeDir))) {
|
||||
return
|
||||
}
|
||||
|
||||
// only apply to relative imports
|
||||
if (!RELATIVE_IMPORT_RE.test(id)) {
|
||||
// only apply to relative imports or nuxt ui runtime components
|
||||
if (!RELATIVE_IMPORT_RE.test(id) && !id.startsWith('@nuxt/ui/components/')) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { normalize } from 'pathe'
|
||||
import { resolvePathSync } from 'mlly'
|
||||
import MagicString from 'magic-string'
|
||||
|
||||
import { runtimeDir, type NuxtUIOptions } from '../unplugin'
|
||||
import { runtimeDir } from '../unplugin'
|
||||
import type { NuxtUIOptions } from '../unplugin'
|
||||
|
||||
/**
|
||||
* This plugin normalises Nuxt environment (#imports) and `import.meta.client` within the Nuxt UI components.
|
||||
|
||||
@@ -4,7 +4,8 @@ import { genSafeVariableName } from 'knitwork'
|
||||
import MagicString from 'magic-string'
|
||||
import { resolvePathSync } from 'mlly'
|
||||
|
||||
import { runtimeDir, type NuxtUIOptions } from '../unplugin'
|
||||
import { runtimeDir } from '../unplugin'
|
||||
import type { NuxtUIOptions } from '../unplugin'
|
||||
|
||||
import type { UnpluginOptions } from 'unplugin'
|
||||
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -42,14 +42,15 @@ export interface ButtonSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, computed, ref, inject } from 'vue'
|
||||
import { computed, ref, inject } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { useForwardProps } from 'reka-ui'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { useComponentIcons } from '../composables/useComponentIcons'
|
||||
import { useButtonGroup } from '../composables/useButtonGroup'
|
||||
import { formLoadingInjectionKey } from '../composables/useFormField'
|
||||
import { omit } from '../utils'
|
||||
import { omit, mergeClasses } from '../utils'
|
||||
import { tv } from '../utils/tv'
|
||||
import { pickLinkProps } from '../utils/link'
|
||||
import UIcon from './Icon.vue'
|
||||
@@ -57,11 +58,7 @@ import UAvatar from './Avatar.vue'
|
||||
import ULink from './Link.vue'
|
||||
import ULinkBase from './LinkBase.vue'
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
active: undefined,
|
||||
activeClass: '',
|
||||
inactiveClass: ''
|
||||
})
|
||||
const props = defineProps<ButtonProps>()
|
||||
const slots = defineSlots<ButtonSlots>()
|
||||
|
||||
const appConfig = useAppConfig() as Button['AppConfig']
|
||||
@@ -96,10 +93,10 @@ const ui = computed(() => tv({
|
||||
variants: {
|
||||
active: {
|
||||
true: {
|
||||
base: props.activeClass
|
||||
base: mergeClasses(appConfig.ui?.button?.variants?.active?.true?.base, props.activeClass)
|
||||
},
|
||||
false: {
|
||||
base: props.inactiveClass
|
||||
base: mergeClasses(appConfig.ui?.button?.variants?.active?.false?.base, props.inactiveClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface CarouselEmits {
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import useEmblaCarousel from 'embla-carousel-vue'
|
||||
import { Primitive, useForwardProps } from 'reka-ui'
|
||||
import { reactivePick, computedAsync } from '@vueuse/core'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { useLocale } from '../composables/useLocale'
|
||||
import { tv } from '../utils/tv'
|
||||
@@ -175,41 +175,45 @@ const options = computed<EmblaOptionsType>(() => ({
|
||||
direction: dir.value === 'rtl' ? 'rtl' : 'ltr'
|
||||
}))
|
||||
|
||||
const plugins = computedAsync<EmblaPluginType[]>(async () => {
|
||||
const plugins = []
|
||||
const plugins = ref<EmblaPluginType[]>([])
|
||||
|
||||
async function loadPlugins() {
|
||||
const emblaPlugins: EmblaPluginType[] = []
|
||||
|
||||
if (props.autoplay) {
|
||||
const AutoplayPlugin = await import('embla-carousel-autoplay').then(r => r.default)
|
||||
plugins.push(AutoplayPlugin(typeof props.autoplay === 'boolean' ? {} : props.autoplay))
|
||||
emblaPlugins.push(AutoplayPlugin(typeof props.autoplay === 'boolean' ? {} : props.autoplay))
|
||||
}
|
||||
|
||||
if (props.autoScroll) {
|
||||
const AutoScrollPlugin = await import('embla-carousel-auto-scroll').then(r => r.default)
|
||||
plugins.push(AutoScrollPlugin(typeof props.autoScroll === 'boolean' ? {} : props.autoScroll))
|
||||
emblaPlugins.push(AutoScrollPlugin(typeof props.autoScroll === 'boolean' ? {} : props.autoScroll))
|
||||
}
|
||||
|
||||
if (props.autoHeight) {
|
||||
const AutoHeightPlugin = await import('embla-carousel-auto-height').then(r => r.default)
|
||||
plugins.push(AutoHeightPlugin(typeof props.autoHeight === 'boolean' ? {} : props.autoHeight))
|
||||
emblaPlugins.push(AutoHeightPlugin(typeof props.autoHeight === 'boolean' ? {} : props.autoHeight))
|
||||
}
|
||||
|
||||
if (props.classNames) {
|
||||
const ClassNamesPlugin = await import('embla-carousel-class-names').then(r => r.default)
|
||||
plugins.push(ClassNamesPlugin(typeof props.classNames === 'boolean' ? {} : props.classNames))
|
||||
emblaPlugins.push(ClassNamesPlugin(typeof props.classNames === 'boolean' ? {} : props.classNames))
|
||||
}
|
||||
|
||||
if (props.fade) {
|
||||
const FadePlugin = await import('embla-carousel-fade').then(r => r.default)
|
||||
plugins.push(FadePlugin(typeof props.fade === 'boolean' ? {} : props.fade))
|
||||
emblaPlugins.push(FadePlugin(typeof props.fade === 'boolean' ? {} : props.fade))
|
||||
}
|
||||
|
||||
if (props.wheelGestures) {
|
||||
const { WheelGesturesPlugin } = await import('embla-carousel-wheel-gestures')
|
||||
plugins.push(WheelGesturesPlugin(typeof props.wheelGestures === 'boolean' ? {} : props.wheelGestures))
|
||||
emblaPlugins.push(WheelGesturesPlugin(typeof props.wheelGestures === 'boolean' ? {} : props.wheelGestures))
|
||||
}
|
||||
|
||||
return plugins
|
||||
})
|
||||
plugins.value = emblaPlugins
|
||||
}
|
||||
|
||||
watch(() => [props.autoplay, props.autoScroll, props.autoHeight, props.classNames, props.fade, props.wheelGestures], loadPlugins, { immediate: true })
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options.value, plugins.value)
|
||||
|
||||
@@ -310,7 +314,6 @@ defineExpose({
|
||||
<UButton
|
||||
:disabled="!canScrollPrev"
|
||||
:icon="prevIcon"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('carousel.prev')"
|
||||
@@ -321,7 +324,6 @@ defineExpose({
|
||||
<UButton
|
||||
:disabled="!canScrollNext"
|
||||
:icon="nextIcon"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('carousel.next')"
|
||||
@@ -337,6 +339,7 @@ defineExpose({
|
||||
:aria-label="t('carousel.goto', { slide: index + 1 })"
|
||||
:class="ui.dot({ class: props.ui?.dot, active: selectedIndex === index })"
|
||||
:data-state="selectedIndex === index ? 'active' : undefined"
|
||||
:aria-current="selectedIndex === index ? true : undefined"
|
||||
@click="scrollTo(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -147,6 +147,7 @@ type SlotProps<T> = (props: { item: T, index: number }) => any
|
||||
|
||||
export type CommandPaletteSlots<G extends CommandPaletteGroup<T> = CommandPaletteGroup<any>, T extends CommandPaletteItem = CommandPaletteItem> = {
|
||||
'empty'(props: { searchTerm?: string }): any
|
||||
'footer'(props: { ui: { [K in keyof Required<CommandPalette['slots']>]: (props?: Record<string, any>) => 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>
|
||||
@@ -343,7 +344,6 @@ function onSelect(e: Event, item: T) {
|
||||
<slot name="back" :ui="ui">
|
||||
<UButton
|
||||
:icon="backIcon || appConfig.ui.icons.arrowLeft"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:aria-label="t('commandPalette.back')"
|
||||
@@ -359,7 +359,6 @@ function onSelect(e: Event, item: T) {
|
||||
<UButton
|
||||
v-if="close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('commandPalette.close')"
|
||||
@@ -446,5 +445,9 @@ function onSelect(e: Event, item: T) {
|
||||
</slot>
|
||||
</div>
|
||||
</ListboxContent>
|
||||
|
||||
<div v-if="!!slots.footer" :class="ui.footer({ class: props.ui?.footer })">
|
||||
<slot name="footer" :ui="ui" />
|
||||
</div>
|
||||
</ListboxRoot>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +47,6 @@ import ULink from './Link.vue'
|
||||
import UAvatar from './Avatar.vue'
|
||||
import UIcon from './Icon.vue'
|
||||
import UKbd from './Kbd.vue'
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import UContextMenuContent from './ContextMenuContent.vue'
|
||||
|
||||
const props = defineProps<ContextMenuContentProps<T>>()
|
||||
|
||||
@@ -53,7 +53,6 @@ import ULink from './Link.vue'
|
||||
import UAvatar from './Avatar.vue'
|
||||
import UIcon from './Icon.vue'
|
||||
import UKbd from './Kbd.vue'
|
||||
// eslint-disable-next-line import/no-self-import
|
||||
import UDropdownMenuContent from './DropdownMenuContent.vue'
|
||||
|
||||
const props = defineProps<DropdownMenuContentProps<T>>()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<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, FormData } from '../types/form'
|
||||
@@ -64,7 +63,7 @@ export interface FormSlots {
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup generic="S extends FormSchema, T extends boolean = true">
|
||||
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
|
||||
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly, reactive } from 'vue'
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
|
||||
@@ -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 => ({
|
||||
@@ -302,9 +301,9 @@ defineExpose<Form<S>>({
|
||||
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>
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ export interface FormFieldSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, inject, provide, type Ref, useId } from 'vue'
|
||||
import { computed, ref, inject, provide, useId } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { Primitive, Label } from 'reka-ui'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { formFieldInjectionKey, inputIdInjectionKey } from '../composables/useFormField'
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AcceptableValue, ComponentConfig } from '../types/utils'
|
||||
|
||||
type Input = ComponentConfig<typeof theme, AppConfig, 'input'>
|
||||
|
||||
export interface InputProps extends UseComponentIconsProps {
|
||||
export interface InputProps<T extends AcceptableValue = AcceptableValue> extends UseComponentIconsProps {
|
||||
/**
|
||||
* The element or component this component should render as.
|
||||
* @defaultValue 'div'
|
||||
@@ -38,6 +38,8 @@ export interface InputProps extends UseComponentIconsProps {
|
||||
disabled?: boolean
|
||||
/** Highlight the ring color like a focus state. */
|
||||
highlight?: boolean
|
||||
modelValue?: T
|
||||
defaultValue?: T
|
||||
modelModifiers?: {
|
||||
string?: boolean
|
||||
number?: boolean
|
||||
@@ -65,6 +67,7 @@ export interface InputSlots {
|
||||
<script setup lang="ts" generic="T extends AcceptableValue">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { useButtonGroup } from '../composables/useButtonGroup'
|
||||
import { useComponentIcons } from '../composables/useComponentIcons'
|
||||
@@ -76,7 +79,7 @@ import UAvatar from './Avatar.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<InputProps>(), {
|
||||
const props = withDefaults(defineProps<InputProps<T>>(), {
|
||||
type: 'text',
|
||||
autocomplete: 'off',
|
||||
autofocusDelay: 0
|
||||
@@ -84,13 +87,12 @@ const props = withDefaults(defineProps<InputProps>(), {
|
||||
const emits = defineEmits<InputEmits<T>>()
|
||||
const slots = defineSlots<InputSlots>()
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const [modelValue, modelModifiers] = defineModel<T>()
|
||||
const modelValue = useVModel<InputProps<T>, 'modelValue', 'update:modelValue'>(props, 'modelValue', emits, { defaultValue: props.defaultValue })
|
||||
|
||||
const appConfig = useAppConfig() as Input['AppConfig']
|
||||
|
||||
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps>(props, { deferInputValidation: true })
|
||||
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
||||
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps<T>>(props, { deferInputValidation: true })
|
||||
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps<T>>(props)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
const inputSize = computed(() => buttonGroupSize.value || formGroupSize.value)
|
||||
@@ -111,15 +113,15 @@ const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Custom function to handle the v-model properties
|
||||
function updateInput(value: string | null) {
|
||||
if (modelModifiers.trim) {
|
||||
if (props.modelModifiers?.trim) {
|
||||
value = value?.trim() ?? null
|
||||
}
|
||||
|
||||
if (modelModifiers.number || props.type === 'number') {
|
||||
if (props.modelModifiers?.number || props.type === 'number') {
|
||||
value = looseToNumber(value)
|
||||
}
|
||||
|
||||
if (modelModifiers.nullify) {
|
||||
if (props.modelModifiers?.nullify) {
|
||||
value ||= null
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ function updateInput(value: string | null) {
|
||||
}
|
||||
|
||||
function onInput(event: Event) {
|
||||
if (!modelModifiers.lazy) {
|
||||
if (!props.modelModifiers?.lazy) {
|
||||
updateInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
}
|
||||
@@ -136,12 +138,12 @@ function onInput(event: Event) {
|
||||
function onChange(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
|
||||
if (modelModifiers.lazy) {
|
||||
if (props.modelModifiers?.lazy) {
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed input so that it has same behavior as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
if (modelModifiers.trim) {
|
||||
if (props.modelModifiers?.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface InputTagsProps<T extends InputTagItem = InputTagItem> extends P
|
||||
as?: any
|
||||
/** The placeholder text when the input is empty. */
|
||||
placeholder?: string
|
||||
/** The maximum number of character allowed. */
|
||||
maxLength?: number
|
||||
/**
|
||||
* @defaultValue 'primary'
|
||||
*/
|
||||
@@ -182,6 +184,7 @@ defineExpose({
|
||||
ref="inputRef"
|
||||
v-bind="{ ...$attrs, ...ariaAttrs }"
|
||||
:placeholder="placeholder"
|
||||
:max-length="maxLength"
|
||||
:class="ui.input({ class: props.ui?.input })"
|
||||
/>
|
||||
|
||||
|
||||
@@ -88,11 +88,12 @@ export interface LinkSlots {
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { defu } from 'defu'
|
||||
import { isEqual } from 'ohash/utils'
|
||||
import { useForwardProps } from 'reka-ui'
|
||||
import { defu } from 'defu'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { useRoute, useAppConfig } from '#imports'
|
||||
import { mergeClasses } from '../utils'
|
||||
import { tv } from '../utils/tv'
|
||||
import { isPartiallyEqual } from '../utils/link'
|
||||
import ULinkBase from './LinkBase.vue'
|
||||
@@ -103,9 +104,7 @@ const props = withDefaults(defineProps<LinkProps>(), {
|
||||
as: 'button',
|
||||
type: 'button',
|
||||
ariaCurrentValue: 'page',
|
||||
active: undefined,
|
||||
activeClass: '',
|
||||
inactiveClass: ''
|
||||
active: undefined
|
||||
})
|
||||
defineSlots<LinkSlots>()
|
||||
|
||||
@@ -119,8 +118,8 @@ const ui = computed(() => tv({
|
||||
...defu({
|
||||
variants: {
|
||||
active: {
|
||||
true: props.activeClass,
|
||||
false: props.inactiveClass
|
||||
true: mergeClasses(appConfig.ui?.link?.variants?.active?.true, props.activeClass),
|
||||
false: mergeClasses(appConfig.ui?.link?.variants?.active?.false, props.inactiveClass)
|
||||
}
|
||||
}
|
||||
}, appConfig.ui?.link || {})
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ModalSlots {
|
||||
header(props: { close: () => void }): any
|
||||
title(props?: {}): any
|
||||
description(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
|
||||
@@ -166,12 +167,13 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.modal || {})
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<slot name="actions" />
|
||||
|
||||
<DialogClose v-if="props.close || !!slots.close" as-child>
|
||||
<slot name="close" :close="close" :ui="ui">
|
||||
<UButton
|
||||
v-if="props.close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('modal.close')"
|
||||
|
||||
@@ -236,20 +236,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
|
||||
}
|
||||
@@ -378,7 +371,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"
|
||||
@@ -387,7 +387,7 @@ function getAccordionDefaultValue(list: NavigationMenuItem[]) {
|
||||
:level="level + 1"
|
||||
:class="ui.childItem({ class: [props.ui?.childItem, childItem.ui?.childItem] })"
|
||||
/>
|
||||
</ul>
|
||||
</AccordionRoot>
|
||||
</AccordionContent>
|
||||
</component>
|
||||
</DefineItemTemplate>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useOverlay, type Overlay } from '../composables/useOverlay'
|
||||
import { useOverlay } from '../composables/useOverlay'
|
||||
import type { Overlay } from '../composables/useOverlay'
|
||||
|
||||
const { overlays, unmount, close } = useOverlay()
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { PopoverRootProps, HoverCardRootProps, PopoverRootEmits, PopoverContentProps, PopoverContentEmits, PopoverArrowProps } from 'reka-ui'
|
||||
import type { PopoverRootProps, HoverCardRootProps, PopoverRootEmits, PopoverContentProps, PopoverContentEmits, PopoverArrowProps, HoverCardTriggerProps } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/popover'
|
||||
import type { EmitsToProps, ComponentConfig } from '../types/utils'
|
||||
@@ -27,6 +27,12 @@ export interface PopoverProps extends PopoverRootProps, Pick<HoverCardRootProps,
|
||||
* @defaultValue true
|
||||
*/
|
||||
portal?: boolean | string | HTMLElement
|
||||
/**
|
||||
* The reference (or anchor) element that is being referred to for positioning.
|
||||
*
|
||||
* If not provided will use the current component as anchor.
|
||||
*/
|
||||
reference?: HoverCardTriggerProps['reference']
|
||||
/**
|
||||
* When `false`, the popover will not close when clicking outside or pressing escape.
|
||||
* @defaultValue true
|
||||
@@ -100,7 +106,7 @@ const Component = computed(() => props.mode === 'hover' ? HoverCard : Popover)
|
||||
|
||||
<template>
|
||||
<Component.Root v-slot="{ open }" v-bind="rootProps">
|
||||
<Component.Trigger v-if="!!slots.default" as-child :class="props.class">
|
||||
<Component.Trigger v-if="!!slots.default || !!reference" as-child :reference="reference" :class="props.class">
|
||||
<slot :open="open" />
|
||||
</Component.Trigger>
|
||||
|
||||
|
||||
@@ -432,7 +432,7 @@ defineExpose({
|
||||
<slot name="content-top" />
|
||||
|
||||
<ComboboxInput v-if="!!searchInput" v-model="searchTerm" :display-value="() => searchTerm" as-child>
|
||||
<UInput autofocus autocomplete="off" v-bind="searchInputProps" :class="ui.input({ class: props.ui?.input })" />
|
||||
<UInput autofocus autocomplete="off" :size="size" v-bind="searchInputProps" :class="ui.input({ class: props.ui?.input })" />
|
||||
</ComboboxInput>
|
||||
|
||||
<ComboboxEmpty :class="ui.empty({ class: props.ui?.empty })">
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface SlideoverSlots {
|
||||
header(props: { close: () => void }): any
|
||||
title(props?: {}): any
|
||||
description(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
|
||||
@@ -174,12 +175,13 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover ||
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<slot name="actions" />
|
||||
|
||||
<DialogClose v-if="props.close || !!slots.close" as-child>
|
||||
<slot name="close" :close="close" :ui="ui">
|
||||
<UButton
|
||||
v-if="props.close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('slideover.close')"
|
||||
|
||||
@@ -83,10 +83,10 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
|
||||
*/
|
||||
empty?: string
|
||||
/**
|
||||
* Whether the table should have a sticky header.
|
||||
* Whether the table should have a sticky header or footer. True for both, 'header' for header only, 'footer' for footer only.
|
||||
* @defaultValue false
|
||||
*/
|
||||
sticky?: boolean
|
||||
sticky?: boolean | 'header' | 'footer'
|
||||
/** Whether the table should be in loading state. */
|
||||
loading?: boolean
|
||||
/**
|
||||
@@ -165,19 +165,24 @@ export interface TableProps<T extends TableData = TableData> extends TableOption
|
||||
*/
|
||||
facetedOptions?: FacetedOptions<T>
|
||||
onSelect?: (row: TableRow<T>, e?: Event) => void
|
||||
onHover?: (e: Event, row: TableRow<T> | null) => void
|
||||
onContextmenu?: ((e: Event, row: TableRow<T>) => void) | Array<((e: Event, row: TableRow<T>) => void)>
|
||||
class?: any
|
||||
ui?: Table['slots']
|
||||
}
|
||||
|
||||
type DynamicHeaderSlots<T, K = keyof T> = Record<string, (props: HeaderContext<T, unknown>) => any> & Record<`${K extends string ? K : never}-header`, (props: HeaderContext<T, unknown>) => any>
|
||||
type DynamicFooterSlots<T, K = keyof T> = Record<string, (props: HeaderContext<T, unknown>) => any> & Record<`${K extends string ? K : never}-footer`, (props: HeaderContext<T, unknown>) => any>
|
||||
type DynamicCellSlots<T, K = keyof T> = Record<string, (props: CellContext<T, unknown>) => any> & Record<`${K extends string ? K : never}-cell`, (props: CellContext<T, unknown>) => any>
|
||||
|
||||
export type TableSlots<T extends TableData = TableData> = {
|
||||
expanded: (props: { row: Row<T> }) => any
|
||||
empty: (props?: {}) => any
|
||||
loading: (props?: {}) => any
|
||||
caption: (props?: {}) => any
|
||||
} & DynamicHeaderSlots<T> & DynamicCellSlots<T>
|
||||
'expanded': (props: { row: Row<T> }) => any
|
||||
'empty': (props?: {}) => any
|
||||
'loading': (props?: {}) => any
|
||||
'caption': (props?: {}) => any
|
||||
'body-top': (props?: {}) => any
|
||||
'body-bottom': (props?: {}) => any
|
||||
} & DynamicHeaderSlots<T> & DynamicFooterSlots<T> & DynamicCellSlots<T>
|
||||
|
||||
</script>
|
||||
|
||||
@@ -212,6 +217,22 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.table || {})
|
||||
loadingAnimation: props.loadingAnimation
|
||||
}))
|
||||
|
||||
const hasFooter = computed(() => {
|
||||
function hasFooterRecursive(columns: TableColumn<T>[]): boolean {
|
||||
for (const column of columns) {
|
||||
if ('footer' in column) {
|
||||
return true
|
||||
}
|
||||
if ('columns' in column && hasFooterRecursive(column.columns as TableColumn<T>[])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return hasFooterRecursive(columns.value)
|
||||
})
|
||||
|
||||
const globalFilterState = defineModel<string>('globalFilter', { default: undefined })
|
||||
const columnFiltersState = defineModel<ColumnFiltersState>('columnFilters', { default: [] })
|
||||
const columnOrderState = defineModel<ColumnOrderState>('columnOrder', { default: [] })
|
||||
@@ -231,7 +252,9 @@ const tableRef = ref<HTMLTableElement | null>(null)
|
||||
const tableApi = useVueTable({
|
||||
...reactiveOmit(props, 'as', 'data', 'columns', 'caption', 'sticky', 'loading', 'loadingColor', 'loadingAnimation', 'class', 'ui'),
|
||||
data,
|
||||
columns: columns.value,
|
||||
get columns() {
|
||||
return columns.value
|
||||
},
|
||||
meta: meta.value,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(props.globalFilterOptions || {}),
|
||||
@@ -309,7 +332,7 @@ function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
|
||||
ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue
|
||||
}
|
||||
|
||||
function handleRowSelect(row: TableRow<T>, e: Event) {
|
||||
function onRowSelect(e: Event, row: TableRow<T>) {
|
||||
if (!props.onSelect) {
|
||||
return
|
||||
}
|
||||
@@ -322,9 +345,30 @@ function handleRowSelect(row: TableRow<T>, e: Event) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// FIXME: `e` should be the first argument for consistency
|
||||
props.onSelect(row, e)
|
||||
}
|
||||
|
||||
function onRowHover(e: Event, row: TableRow<T> | null) {
|
||||
if (!props.onHover) {
|
||||
return
|
||||
}
|
||||
|
||||
props.onHover(e, row)
|
||||
}
|
||||
|
||||
function onRowContextmenu(e: Event, row: TableRow<T>) {
|
||||
if (!props.onContextmenu) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(props.onContextmenu)) {
|
||||
props.onContextmenu.forEach(fn => fn(e, row))
|
||||
} else {
|
||||
props.onContextmenu(e, row)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data, () => {
|
||||
data.value = props.data ? [...props.data] : []
|
||||
@@ -352,6 +396,7 @@ defineExpose({
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:data-pinned="header.column.getIsPinned()"
|
||||
:scope="header.colSpan > 1 ? 'colgroup' : 'col'"
|
||||
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
|
||||
:class="ui.th({
|
||||
class: [
|
||||
@@ -366,14 +411,18 @@ defineExpose({
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<tr :class="ui.separator({ class: [props.ui?.separator] })" />
|
||||
</thead>
|
||||
|
||||
<tbody :class="ui.tbody({ class: [props.ui?.tbody] })">
|
||||
<slot name="body-top" />
|
||||
|
||||
<template v-if="tableApi.getRowModel().rows?.length">
|
||||
<template v-for="row in tableApi.getRowModel().rows" :key="row.id">
|
||||
<tr
|
||||
:data-selected="row.getIsSelected()"
|
||||
:data-selectable="!!props.onSelect"
|
||||
:data-selectable="!!props.onSelect || !!props.onHover || !!props.onContextmenu"
|
||||
:data-expanded="row.getIsExpanded()"
|
||||
:role="props.onSelect ? 'button' : undefined"
|
||||
:tabindex="props.onSelect ? 0 : undefined"
|
||||
@@ -383,7 +432,10 @@ defineExpose({
|
||||
typeof tableApi.options.meta?.class?.tr === 'function' ? tableApi.options.meta.class.tr(row) : tableApi.options.meta?.class?.tr
|
||||
]
|
||||
})"
|
||||
@click="handleRowSelect(row, $event)"
|
||||
@click="onRowSelect($event, row)"
|
||||
@pointerenter="onRowHover($event, row)"
|
||||
@pointerleave="onRowHover($event, null)"
|
||||
@contextmenu="onRowContextmenu($event, row)"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
@@ -423,7 +475,33 @@ defineExpose({
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<slot name="body-bottom" />
|
||||
</tbody>
|
||||
|
||||
<tfoot v-if="hasFooter" :class="ui.tfoot({ class: [props.ui?.tfoot] })">
|
||||
<tr :class="ui.separator({ class: [props.ui?.separator] })" />
|
||||
|
||||
<tr v-for="footerGroup in tableApi.getFooterGroups()" :key="footerGroup.id" :class="ui.tr({ class: [props.ui?.tr] })">
|
||||
<th
|
||||
v-for="header in footerGroup.headers"
|
||||
:key="header.id"
|
||||
:data-pinned="header.column.getIsPinned()"
|
||||
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
|
||||
:class="ui.th({
|
||||
class: [
|
||||
props.ui?.th,
|
||||
typeof header.column.columnDef.meta?.class?.th === 'function' ? header.column.columnDef.meta.class.th(header) : header.column.columnDef.meta?.class?.th
|
||||
],
|
||||
pinned: !!header.column.getIsPinned()
|
||||
})"
|
||||
>
|
||||
<slot :name="`${header.id}-footer`" v-bind="header.getContext()">
|
||||
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.footer" :props="header.getContext()" />
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ type Textarea = ComponentConfig<typeof theme, AppConfig, 'textarea'>
|
||||
|
||||
type TextareaValue = string | number | null
|
||||
|
||||
export interface TextareaProps extends UseComponentIconsProps {
|
||||
export interface TextareaProps<T extends TextareaValue = TextareaValue> extends UseComponentIconsProps {
|
||||
/**
|
||||
* The element or component this component should render as.
|
||||
* @defaultValue 'div'
|
||||
@@ -41,8 +41,11 @@ export interface TextareaProps extends UseComponentIconsProps {
|
||||
maxrows?: number
|
||||
/** Highlight the ring color like a focus state. */
|
||||
highlight?: boolean
|
||||
modelValue?: T
|
||||
defaultValue?: T
|
||||
modelModifiers?: {
|
||||
string?: boolean
|
||||
number?: boolean
|
||||
trim?: boolean
|
||||
lazy?: boolean
|
||||
nullify?: boolean
|
||||
@@ -67,6 +70,7 @@ export interface TextareaSlots {
|
||||
<script setup lang="ts" generic="T extends TextareaValue">
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { useComponentIcons } from '../composables/useComponentIcons'
|
||||
import { useFormField } from '../composables/useFormField'
|
||||
@@ -77,7 +81,7 @@ import UAvatar from './Avatar.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
const props = withDefaults(defineProps<TextareaProps<T>>(), {
|
||||
rows: 3,
|
||||
maxrows: 0,
|
||||
autofocusDelay: 0,
|
||||
@@ -86,12 +90,11 @@ const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
const emits = defineEmits<TextareaEmits<T>>()
|
||||
const slots = defineSlots<TextareaSlots>()
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const [modelValue, modelModifiers] = defineModel<T>()
|
||||
const modelValue = useVModel<TextareaProps<T>, 'modelValue', 'update:modelValue'>(props, 'modelValue', emits, { defaultValue: props.defaultValue })
|
||||
|
||||
const appConfig = useAppConfig() as Textarea['AppConfig']
|
||||
|
||||
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps>(props, { deferInputValidation: true })
|
||||
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps<T>>(props, { deferInputValidation: true })
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.textarea || {}) })({
|
||||
@@ -109,15 +112,15 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Custom function to handle the v-model properties
|
||||
function updateInput(value: string | null) {
|
||||
if (modelModifiers.trim) {
|
||||
if (props.modelModifiers?.trim) {
|
||||
value = value?.trim() ?? null
|
||||
}
|
||||
|
||||
if (modelModifiers.number) {
|
||||
if (props.modelModifiers?.number) {
|
||||
value = looseToNumber(value)
|
||||
}
|
||||
|
||||
if (modelModifiers.nullify) {
|
||||
if (props.modelModifiers?.nullify) {
|
||||
value ||= null
|
||||
}
|
||||
|
||||
@@ -128,7 +131,7 @@ function updateInput(value: string | null) {
|
||||
function onInput(event: Event) {
|
||||
autoResize()
|
||||
|
||||
if (!modelModifiers.lazy) {
|
||||
if (!props.modelModifiers?.lazy) {
|
||||
updateInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
}
|
||||
@@ -136,12 +139,12 @@ function onInput(event: Event) {
|
||||
function onChange(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
|
||||
if (modelModifiers.lazy) {
|
||||
if (props.modelModifiers?.lazy) {
|
||||
updateInput(value)
|
||||
}
|
||||
|
||||
// Update trimmed textarea so that it has same behavior as native textarea https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
|
||||
if (modelModifiers.trim) {
|
||||
if (props.modelModifiers?.trim) {
|
||||
(event.target as HTMLInputElement).value = value.trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ToastRootProps, ToastRootEmits } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/toast'
|
||||
import type { AvatarProps, ButtonProps } from '../types'
|
||||
import type { AvatarProps, ButtonProps, ProgressProps } from '../types'
|
||||
import type { StringOrVNode, ComponentConfig } from '../types/utils'
|
||||
|
||||
type Toast = ComponentConfig<typeof theme, AppConfig, 'toast'>
|
||||
@@ -29,18 +29,6 @@ export interface ToastProps extends Pick<ToastRootProps, 'defaultOpen' | 'open'
|
||||
* @defaultValue 'vertical'
|
||||
*/
|
||||
orientation?: Toast['variants']['orientation']
|
||||
/**
|
||||
* Whether to show the progress bar.
|
||||
* @defaultValue true
|
||||
*/
|
||||
progress?: boolean
|
||||
/**
|
||||
* Display a list of actions:
|
||||
* - under the title and description when orientation is `vertical`
|
||||
* - next to the close button when orientation is `horizontal`
|
||||
* `{ size: 'xs' }`{lang="ts-type"}
|
||||
*/
|
||||
actions?: ButtonProps[]
|
||||
/**
|
||||
* Display a close button to dismiss the toast.
|
||||
* `{ size: 'md', color: 'neutral', variant: 'link' }`{lang="ts-type"}
|
||||
@@ -53,6 +41,19 @@ export interface ToastProps extends Pick<ToastRootProps, 'defaultOpen' | 'open'
|
||||
* @IconifyIcon
|
||||
*/
|
||||
closeIcon?: string
|
||||
/**
|
||||
* Display a list of actions:
|
||||
* - under the title and description when orientation is `vertical`
|
||||
* - next to the close button when orientation is `horizontal`
|
||||
* `{ size: 'xs' }`{lang="ts-type"}
|
||||
*/
|
||||
actions?: ButtonProps[]
|
||||
/**
|
||||
* Display a progress bar showing the toast's remaining duration.
|
||||
* `{ size: 'sm' }`{lang="ts-type"}
|
||||
* @defaultValue true
|
||||
*/
|
||||
progress?: boolean | Pick<ProgressProps, 'color'>
|
||||
class?: any
|
||||
ui?: Toast['slots']
|
||||
}
|
||||
@@ -78,10 +79,11 @@ import { tv } from '../utils/tv'
|
||||
import UIcon from './Icon.vue'
|
||||
import UAvatar from './Avatar.vue'
|
||||
import UButton from './Button.vue'
|
||||
import UProgress from './Progress.vue'
|
||||
|
||||
const props = withDefaults(defineProps<ToastProps>(), {
|
||||
close: true,
|
||||
orientation: 'vertical',
|
||||
close: true,
|
||||
progress: true
|
||||
})
|
||||
const emits = defineEmits<ToastEmits>()
|
||||
@@ -119,7 +121,7 @@ defineExpose({
|
||||
<template>
|
||||
<ToastRoot
|
||||
ref="el"
|
||||
v-slot="{ remaining, duration }"
|
||||
v-slot="{ remaining, duration, open }"
|
||||
v-bind="rootProps"
|
||||
:data-orientation="orientation"
|
||||
:class="ui.root({ class: [props.ui?.root, props.class] })"
|
||||
@@ -173,7 +175,6 @@ defineExpose({
|
||||
<UButton
|
||||
v-if="close"
|
||||
:icon="closeIcon || appConfig.ui.icons.close"
|
||||
size="md"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
:aria-label="t('toast.close')"
|
||||
@@ -185,6 +186,13 @@ defineExpose({
|
||||
</ToastClose>
|
||||
</div>
|
||||
|
||||
<div v-if="progress && remaining > 0 && duration" :class="ui.progress({ class: props.ui?.progress })" :style="{ width: `${remaining / duration * 100}%` }" />
|
||||
<UProgress
|
||||
v-if="progress && open && remaining > 0 && duration"
|
||||
:model-value="remaining / duration * 100"
|
||||
:color="color"
|
||||
v-bind="(typeof progress === 'object' ? progress as Partial<ProgressProps> : {})"
|
||||
size="sm"
|
||||
:class="ui.progress({ class: props.ui?.progress })"
|
||||
/>
|
||||
</ToastRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { TooltipRootProps, TooltipRootEmits, TooltipContentProps, TooltipContentEmits, TooltipArrowProps } from 'reka-ui'
|
||||
import type { TooltipRootProps, TooltipRootEmits, TooltipContentProps, TooltipContentEmits, TooltipArrowProps, TooltipTriggerProps } from 'reka-ui'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/tooltip'
|
||||
import type { KbdProps } from '../types'
|
||||
@@ -27,6 +27,12 @@ export interface TooltipProps extends TooltipRootProps {
|
||||
* @defaultValue true
|
||||
*/
|
||||
portal?: boolean | string | HTMLElement
|
||||
/**
|
||||
* The reference (or anchor) element that is being referred to for positioning.
|
||||
*
|
||||
* If not provided will use the current component as anchor.
|
||||
*/
|
||||
reference?: TooltipTriggerProps['reference']
|
||||
class?: any
|
||||
ui?: Tooltip['slots']
|
||||
}
|
||||
@@ -70,7 +76,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.tooltip || {
|
||||
|
||||
<template>
|
||||
<TooltipRoot v-slot="{ open }" v-bind="rootProps">
|
||||
<TooltipTrigger v-if="!!slots.default" v-bind="$attrs" as-child :class="props.class">
|
||||
<TooltipTrigger v-if="!!slots.default || !!reference" v-bind="$attrs" as-child :reference="reference" :class="props.class">
|
||||
<slot :open="open" />
|
||||
</TooltipTrigger>
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ interface Shortcut {
|
||||
|
||||
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
|
||||
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
|
||||
// keyboard keys which can be combined with Shift modifier (in addition to alphabet keys)
|
||||
const shiftableKeys = ['arrowleft', 'arrowright', 'arrowup', 'arrowright', 'tab', 'escape', 'enter', 'backspace']
|
||||
|
||||
export function extractShortcuts(items: any[] | any[][]) {
|
||||
const shortcuts: Record<string, Handler> = {}
|
||||
@@ -76,7 +78,8 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor
|
||||
return
|
||||
}
|
||||
|
||||
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
|
||||
const alphabetKey = /^[a-z]{1}$/i.test(e.key)
|
||||
const shiftableKey = shiftableKeys.includes(e.key.toLowerCase())
|
||||
|
||||
let chainedKey
|
||||
chainedInputs.value.push(e.key)
|
||||
@@ -109,9 +112,9 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor
|
||||
if (e.ctrlKey !== shortcut.ctrlKey) {
|
||||
continue
|
||||
}
|
||||
// shift modifier is only checked in combination with alphabetical keys
|
||||
// (shift with non-alphabetical keys would change the key)
|
||||
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) {
|
||||
// shift modifier is only checked in combination with alphabet keys and some extra keys
|
||||
// (shift with special characters would change the key)
|
||||
if ((alphabetKey || shiftableKey) && e.shiftKey !== shortcut.shiftKey) {
|
||||
continue
|
||||
}
|
||||
// alt modifier changes the combined key anyways
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inject, provide, computed, type ComputedRef, type InjectionKey } from 'vue'
|
||||
import { inject, provide, computed } from 'vue'
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
import type { AvatarGroupProps } from '../types'
|
||||
|
||||
export const avatarGroupInjectionKey: InjectionKey<ComputedRef<{ size: AvatarGroupProps['size'] }>> = Symbol('nuxt-ui.avatar-group')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useAppConfig } from '#imports'
|
||||
import type { AvatarProps } from '../types'
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { inject, computed, type InjectionKey, type Ref, type ComputedRef, provide } from 'vue'
|
||||
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
|
||||
import { inject, computed, provide } from 'vue'
|
||||
import type { InjectionKey, Ref, ComputedRef } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { UseEventBusReturn } from '@vueuse/core'
|
||||
import type { FormFieldProps } from '../types'
|
||||
import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '../types/form'
|
||||
import type { GetObjectField } from '../types/utils'
|
||||
|
||||
@@ -3,9 +3,34 @@ import { reactive, markRaw, shallowReactive } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { ComponentProps, ComponentEmit } from 'vue-component-type-helpers'
|
||||
|
||||
// Extracts the first argument of the close event
|
||||
type CloseEventArgType<T> = T extends (event: 'close', args_0: infer R) => void ? R : never
|
||||
|
||||
/**
|
||||
* This is a workaround for a design limitation in TypeScript.
|
||||
*
|
||||
* Conditional types only match the last function overload, not a union of all possible
|
||||
* parameter types. This workaround forces TypeScript to properly extract the 'close'
|
||||
* event argument type from component emits with multiple event signatures.
|
||||
*
|
||||
* @see https://github.com/microsoft/TypeScript/issues/32164
|
||||
*/
|
||||
type CloseEventArgType<T> = T extends {
|
||||
(event: 'close', arg_0: infer Arg, ...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
(...args: any[]): void
|
||||
} ? Arg : never
|
||||
export type OverlayOptions<OverlayAttrs = Record<string, any>> = {
|
||||
defaultOpen?: boolean
|
||||
props?: OverlayAttrs
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { inject, provide, computed, type Ref, type InjectionKey } from 'vue'
|
||||
import { inject, provide, computed } from 'vue'
|
||||
import type { Ref, InjectionKey } from 'vue'
|
||||
|
||||
export const portalTargetInjectionKey: InjectionKey<Ref<string | HTMLElement>> = Symbol('nuxt-ui.portal-target')
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@ export interface Form<S extends FormSchema> {
|
||||
dirty: ComputedRef<boolean>
|
||||
loading: Ref<boolean>
|
||||
|
||||
dirtyFields: DeepReadonly<Set<keyof FormData<S, false>>>
|
||||
touchedFields: DeepReadonly<Set<keyof FormData<S, false>>>
|
||||
blurredFields: DeepReadonly<Set<keyof FormData<S, false>>>
|
||||
dirtyFields: ReadonlySet<DeepReadonly<keyof FormData<S, false>>>
|
||||
touchedFields: ReadonlySet<DeepReadonly<keyof FormData<S, false>>>
|
||||
blurredFields: ReadonlySet<DeepReadonly<keyof FormData<S, false>>>
|
||||
}
|
||||
|
||||
export type FormSchema<I extends object = object, O extends object = I> =
|
||||
| YupObjectSchema<I>
|
||||
| JoiSchema<I>
|
||||
| SuperstructSchema<any, any>
|
||||
| StandardSchemaV1<I, O>
|
||||
export type FormSchema<I extends object = object, O extends object = I>
|
||||
= | YupObjectSchema<I>
|
||||
| JoiSchema<I>
|
||||
| SuperstructSchema<any, any>
|
||||
| StandardSchemaV1<I, O>
|
||||
|
||||
// Define a utility type to infer the input type based on the schema type
|
||||
export type InferInput<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<Schema>
|
||||
@@ -83,10 +83,10 @@ export type FormInputEvent<T extends object> = {
|
||||
eager?: boolean
|
||||
}
|
||||
|
||||
export type FormEvent<T extends object> =
|
||||
| FormInputEvent<T>
|
||||
| FormChildAttachEvent
|
||||
| FormChildDetachEvent
|
||||
export type FormEvent<T extends object>
|
||||
= | FormInputEvent<T>
|
||||
| FormChildAttachEvent
|
||||
| FormChildDetachEvent
|
||||
|
||||
export interface FormInjectedOptions {
|
||||
disabled?: boolean
|
||||
|
||||
@@ -30,8 +30,8 @@ type ComponentSlots<T extends { slots?: Record<string, any> }> = Id<{
|
||||
[K in keyof T['slots']]?: ClassValue
|
||||
}>
|
||||
|
||||
type GetComponentAppConfig<A, U extends string, K extends string> =
|
||||
A extends Record<U, Record<K, any>> ? A[U][K] : {}
|
||||
type GetComponentAppConfig<A, U extends string, K extends string>
|
||||
= A extends Record<U, Record<K, any>> ? A[U][K] : {}
|
||||
|
||||
type ComponentAppConfig<
|
||||
T,
|
||||
|
||||
@@ -44,8 +44,8 @@ export type MergeTypes<T extends object> = {
|
||||
|
||||
export type GetItemKeys<I> = keyof Extract<NestedItem<I>, object>
|
||||
|
||||
export type GetItemValue<I, VK extends GetItemKeys<I> | undefined, T extends NestedItem<I> = NestedItem<I>> =
|
||||
T extends object
|
||||
export type GetItemValue<I, VK extends GetItemKeys<I> | undefined, T extends NestedItem<I> = NestedItem<I>>
|
||||
= T extends object
|
||||
? VK extends undefined
|
||||
? T
|
||||
: VK extends keyof T
|
||||
@@ -70,10 +70,10 @@ export type GetModelValueEmits<
|
||||
'update:modelValue': [payload: GetModelValue<T, VK, M>]
|
||||
}
|
||||
|
||||
export type StringOrVNode =
|
||||
| string
|
||||
| VNode
|
||||
| (() => VNode)
|
||||
export type StringOrVNode
|
||||
= | string
|
||||
| VNode
|
||||
| (() => VNode)
|
||||
|
||||
export type EmitsToProps<T> = {
|
||||
[K in keyof T as `on${Capitalize<string & K>}`]: T[K] extends [...args: infer Args]
|
||||
|
||||
@@ -85,3 +85,14 @@ export function compare<T>(value?: T, currentValue?: T, comparator?: string | ((
|
||||
export function isArrayOfArray<A>(item: A[] | A[][]): item is A[][] {
|
||||
return Array.isArray(item[0])
|
||||
}
|
||||
|
||||
export function mergeClasses(appConfigClass?: string | string[], propClass?: string) {
|
||||
if (!appConfigClass && !propClass) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return [
|
||||
...(Array.isArray(appConfigClass) ? appConfigClass : [appConfigClass]),
|
||||
propClass
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTV, type defaultConfig } from 'tailwind-variants'
|
||||
import { createTV } from 'tailwind-variants'
|
||||
import type { defaultConfig } from 'tailwind-variants'
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import appConfig from '#build/app.config'
|
||||
|
||||
|
||||
@@ -158,13 +158,13 @@ import colors from 'tailwindcss/colors'
|
||||
|
||||
const icons = ${JSON.stringify(uiConfig.icons)};
|
||||
|
||||
type NeutralColor = 'slate' | 'gray' | 'zinc' | 'neutral' | 'stone' | (string & {})
|
||||
type NeutralColor = 'slate' | 'gray' | 'zinc' | 'neutral' | 'stone'
|
||||
type Color = Exclude<keyof typeof colors, 'inherit' | 'current' | 'transparent' | 'black' | 'white' | NeutralColor> | (string & {})
|
||||
|
||||
type AppConfigUI = {
|
||||
colors?: {
|
||||
${options.theme?.colors?.map(color => `'${color}'?: Color`).join('\n\t\t')}
|
||||
neutral?: NeutralColor
|
||||
neutral?: NeutralColor | (string & {})
|
||||
}
|
||||
icons?: Partial<typeof icons>
|
||||
tv?: typeof defaultConfig
|
||||
|
||||
@@ -86,51 +86,51 @@ export default (options: Required<ModuleOptions>) => ({
|
||||
compoundVariants: [...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'solid',
|
||||
class: `text-inverted bg-${color} hover:bg-${color}/75 disabled:bg-${color} aria-disabled:bg-${color} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-${color}`
|
||||
class: `text-inverted bg-${color} hover:bg-${color}/75 active:bg-${color}/75 disabled:bg-${color} aria-disabled:bg-${color} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-${color}`
|
||||
})), ...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'outline',
|
||||
class: `ring ring-inset ring-${color}/50 text-${color} hover:bg-${color}/10 disabled:bg-transparent aria-disabled:bg-transparent dark:disabled:bg-transparent dark:aria-disabled:bg-transparent focus:outline-none focus-visible:ring-2 focus-visible:ring-${color}`
|
||||
class: `ring ring-inset ring-${color}/50 text-${color} hover:bg-${color}/10 active:bg-${color}/10 disabled:bg-transparent aria-disabled:bg-transparent dark:disabled:bg-transparent dark:aria-disabled:bg-transparent focus:outline-none focus-visible:ring-2 focus-visible:ring-${color}`
|
||||
})), ...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'soft',
|
||||
class: `text-${color} bg-${color}/10 hover:bg-${color}/15 focus:outline-none focus-visible:bg-${color}/15 disabled:bg-${color}/10 aria-disabled:bg-${color}/10`
|
||||
class: `text-${color} bg-${color}/10 hover:bg-${color}/15 active:bg-${color}/15 focus:outline-none focus-visible:bg-${color}/15 disabled:bg-${color}/10 aria-disabled:bg-${color}/10`
|
||||
})), ...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'subtle',
|
||||
class: `text-${color} ring ring-inset ring-${color}/25 bg-${color}/10 hover:bg-${color}/15 disabled:bg-${color}/10 aria-disabled:bg-${color}/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-${color}`
|
||||
class: `text-${color} ring ring-inset ring-${color}/25 bg-${color}/10 hover:bg-${color}/15 active:bg-${color}/15 disabled:bg-${color}/10 aria-disabled:bg-${color}/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-${color}`
|
||||
})), ...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'ghost',
|
||||
class: `text-${color} hover:bg-${color}/10 focus:outline-none focus-visible:bg-${color}/10 disabled:bg-transparent aria-disabled:bg-transparent dark:disabled:bg-transparent dark:aria-disabled:bg-transparent`
|
||||
class: `text-${color} hover:bg-${color}/10 active:bg-${color}/10 focus:outline-none focus-visible:bg-${color}/10 disabled:bg-transparent aria-disabled:bg-transparent dark:disabled:bg-transparent dark:aria-disabled:bg-transparent`
|
||||
})), ...(options.theme.colors || []).map((color: string) => ({
|
||||
color,
|
||||
variant: 'link',
|
||||
class: `text-${color} hover:text-${color}/75 disabled:text-${color} aria-disabled:text-${color} focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}`
|
||||
class: `text-${color} hover:text-${color}/75 active:text-${color}/75 disabled:text-${color} aria-disabled:text-${color} focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-${color}`
|
||||
})), {
|
||||
color: 'neutral',
|
||||
variant: 'solid',
|
||||
class: 'text-inverted bg-inverted hover:bg-inverted/90 disabled:bg-inverted aria-disabled:bg-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-inverted'
|
||||
class: 'text-inverted bg-inverted hover:bg-inverted/90 active:bg-inverted/90 disabled:bg-inverted aria-disabled:bg-inverted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-inverted'
|
||||
}, {
|
||||
color: 'neutral',
|
||||
variant: 'outline',
|
||||
class: 'ring ring-inset ring-accented text-default bg-default hover:bg-elevated disabled:bg-default aria-disabled:bg-default focus:outline-none focus-visible:ring-2 focus-visible:ring-inverted'
|
||||
class: 'ring ring-inset ring-accented text-default bg-default hover:bg-elevated active:bg-elevated disabled:bg-default aria-disabled:bg-default focus:outline-none focus-visible:ring-2 focus-visible:ring-inverted'
|
||||
}, {
|
||||
color: 'neutral',
|
||||
variant: 'soft',
|
||||
class: 'text-default bg-elevated hover:bg-accented/75 focus:outline-none focus-visible:bg-accented/75 disabled:bg-elevated aria-disabled:bg-elevated'
|
||||
class: 'text-default bg-elevated hover:bg-accented/75 active:bg-accented/75 focus:outline-none focus-visible:bg-accented/75 disabled:bg-elevated aria-disabled:bg-elevated'
|
||||
}, {
|
||||
color: 'neutral',
|
||||
variant: 'subtle',
|
||||
class: 'ring ring-inset ring-accented text-default bg-elevated hover:bg-accented/75 disabled:bg-elevated aria-disabled:bg-elevated focus:outline-none focus-visible:ring-2 focus-visible:ring-inverted'
|
||||
class: 'ring ring-inset ring-accented text-default bg-elevated hover:bg-accented/75 active:bg-accented/75 disabled:bg-elevated aria-disabled:bg-elevated focus:outline-none focus-visible:ring-2 focus-visible:ring-inverted'
|
||||
}, {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
class: 'text-default hover:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent'
|
||||
class: 'text-default hover:bg-elevated active:bg-elevated focus:outline-none focus-visible:bg-elevated hover:disabled:bg-transparent dark:hover:disabled:bg-transparent hover:aria-disabled:bg-transparent dark:hover:aria-disabled:bg-transparent'
|
||||
}, {
|
||||
color: 'neutral',
|
||||
variant: 'link',
|
||||
class: 'text-muted hover:text-default disabled:text-muted aria-disabled:text-muted focus:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-inverted'
|
||||
class: 'text-muted hover:text-default active:text-default disabled:text-muted aria-disabled:text-muted focus:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-inverted'
|
||||
}, {
|
||||
size: 'xs',
|
||||
square: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
slots: {
|
||||
root: 'rounded-lg',
|
||||
root: 'rounded-lg overflow-hidden',
|
||||
header: 'p-4 sm:px-6',
|
||||
body: 'p-4 sm:p-6',
|
||||
footer: 'p-4 sm:px-6'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user