docs-please 0.2.0-beta.0 → 0.2.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/app.config.ts +9 -1
- package/app/assets/css/main.css +2 -2
- package/app/components/app/AppHeader.vue +92 -37
- package/app/components/app/AppHeaderCenter.vue +3 -0
- package/app/components/app/AppHeaderLogo.vue +43 -0
- package/app/components/app/AppHeaderSearch.vue +3 -0
- package/app/components/app/AppSearch.vue +189 -0
- package/app/components/app/AppSearchButton.vue +51 -0
- package/app/components/content/Accordion.vue +117 -0
- package/app/components/content/AccordionItem.vue +27 -0
- package/app/components/content/Badge.vue +42 -0
- package/app/components/content/Collapsible.vue +56 -0
- package/app/components/content/ProseKbd.vue +9 -0
- package/app/components/content/ProseTable.vue +2 -2
- package/app/components/content/ProseTh.vue +1 -1
- package/app/components/content/ProseTr.vue +1 -1
- package/app/components/ui/command/Command.vue +86 -0
- package/app/components/ui/command/CommandDialog.vue +21 -0
- package/app/components/ui/command/CommandEmpty.vue +23 -0
- package/app/components/ui/command/CommandGroup.vue +44 -0
- package/app/components/ui/command/CommandInput.vue +35 -0
- package/app/components/ui/command/CommandItem.vue +75 -0
- package/app/components/ui/command/CommandList.vue +21 -0
- package/app/components/ui/command/CommandSeparator.vue +20 -0
- package/app/components/ui/command/CommandShortcut.vue +14 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/kbd/Kbd.vue +21 -0
- package/app/components/ui/kbd/KbdGroup.vue +17 -0
- package/app/components/ui/kbd/index.ts +2 -0
- package/app/composables/useContentSearch.ts +52 -0
- package/app/layouts/default.vue +1 -0
- package/app/layouts/docs.vue +1 -0
- package/app/utils/navigation.ts +7 -0
- package/app/utils/prerender.ts +12 -0
- package/nuxt.config.ts +4 -0
- package/nuxt.schema.ts +44 -0
- package/package.json +7 -2
- package/server/routes/raw/[...slug].md.get.ts +45 -0
package/app/app.config.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export default defineAppConfig({
|
|
2
2
|
docs: {
|
|
3
3
|
title: 'Documentation',
|
|
4
|
-
description: 'Documentation site powered by
|
|
4
|
+
description: 'Documentation site powered by docs-please',
|
|
5
5
|
url: '',
|
|
6
|
+
header: {
|
|
7
|
+
title: '',
|
|
8
|
+
logo: {
|
|
9
|
+
light: '',
|
|
10
|
+
dark: '',
|
|
11
|
+
alt: '',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
6
14
|
github: {
|
|
7
15
|
owner: '',
|
|
8
16
|
name: '',
|
package/app/assets/css/main.css
CHANGED
|
@@ -354,11 +354,11 @@
|
|
|
354
354
|
}
|
|
355
355
|
|
|
356
356
|
.prose th {
|
|
357
|
-
@apply
|
|
357
|
+
@apply px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
.prose td {
|
|
361
|
-
@apply
|
|
361
|
+
@apply px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
.prose hr {
|
|
@@ -1,57 +1,112 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { Moon, Sun } from 'lucide-vue-next'
|
|
2
|
+
import { Github, Moon, Sun } from 'lucide-vue-next'
|
|
3
3
|
import { Button } from '~/components/ui/button'
|
|
4
|
+
import { Separator } from '~/components/ui/separator'
|
|
4
5
|
|
|
6
|
+
const appConfig = useAppConfig()
|
|
5
7
|
const colorMode = useColorMode()
|
|
6
8
|
|
|
9
|
+
const isDark = computed(() => colorMode.value === 'dark')
|
|
10
|
+
|
|
7
11
|
function toggleColorMode() {
|
|
8
|
-
colorMode.preference =
|
|
12
|
+
colorMode.preference = isDark.value ? 'light' : 'dark'
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
const
|
|
12
|
-
const
|
|
15
|
+
const links = computed(() => {
|
|
16
|
+
const result = []
|
|
17
|
+
if (appConfig.docs?.github?.url) {
|
|
18
|
+
result.push({
|
|
19
|
+
icon: Github,
|
|
20
|
+
to: appConfig.docs.github.url,
|
|
21
|
+
target: '_blank',
|
|
22
|
+
ariaLabel: 'GitHub',
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
})
|
|
13
27
|
</script>
|
|
14
28
|
|
|
15
29
|
<template>
|
|
16
30
|
<header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
31
|
+
<!-- Skip to main content link for accessibility -->
|
|
32
|
+
<a
|
|
33
|
+
href="#main-content"
|
|
34
|
+
class="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-[100] focus:rounded-md focus:border focus:bg-background focus:px-4 focus:py-2"
|
|
35
|
+
>
|
|
36
|
+
Skip to main content
|
|
37
|
+
</a>
|
|
38
|
+
|
|
17
39
|
<div class="container flex h-14 items-center">
|
|
18
40
|
<!-- Logo -->
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
<NuxtLink
|
|
42
|
+
to="/"
|
|
43
|
+
class="mr-4 flex items-center gap-2"
|
|
44
|
+
:aria-label="`${appConfig.docs?.title || 'Home'} - Go to homepage`"
|
|
45
|
+
>
|
|
46
|
+
<AppHeaderLogo class="h-6 w-auto shrink-0" />
|
|
47
|
+
</NuxtLink>
|
|
48
|
+
|
|
49
|
+
<!-- Center (Search) -->
|
|
50
|
+
<div class="hidden flex-1 justify-center lg:flex">
|
|
51
|
+
<AppHeaderCenter />
|
|
26
52
|
</div>
|
|
27
53
|
|
|
28
|
-
<!--
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
class="
|
|
54
|
+
<!-- Spacer for mobile -->
|
|
55
|
+
<div class="flex-1 lg:hidden" />
|
|
56
|
+
|
|
57
|
+
<!-- Right Actions -->
|
|
58
|
+
<div class="flex items-center gap-1">
|
|
59
|
+
<!-- Search button (mobile) -->
|
|
60
|
+
<AppHeaderSearch class="lg:hidden" />
|
|
61
|
+
|
|
62
|
+
<!-- Color mode toggle -->
|
|
63
|
+
<ClientOnly>
|
|
64
|
+
<Button
|
|
65
|
+
variant="ghost"
|
|
66
|
+
size="icon"
|
|
67
|
+
@click="toggleColorMode"
|
|
68
|
+
>
|
|
69
|
+
<Moon
|
|
70
|
+
v-if="isDark"
|
|
71
|
+
class="size-5"
|
|
72
|
+
/>
|
|
73
|
+
<Sun
|
|
74
|
+
v-else
|
|
75
|
+
class="size-5"
|
|
76
|
+
/>
|
|
77
|
+
<span class="sr-only">Toggle theme</span>
|
|
78
|
+
</Button>
|
|
79
|
+
|
|
80
|
+
<template #fallback>
|
|
81
|
+
<div class="size-8 animate-pulse rounded-md bg-muted" />
|
|
82
|
+
</template>
|
|
83
|
+
</ClientOnly>
|
|
84
|
+
|
|
85
|
+
<!-- External links (GitHub, etc.) -->
|
|
86
|
+
<template v-if="links.length">
|
|
87
|
+
<Separator
|
|
88
|
+
orientation="vertical"
|
|
89
|
+
class="mx-1 h-6"
|
|
52
90
|
/>
|
|
53
|
-
<
|
|
54
|
-
|
|
91
|
+
<Button
|
|
92
|
+
v-for="link in links"
|
|
93
|
+
:key="link.to"
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="icon"
|
|
96
|
+
as-child
|
|
97
|
+
>
|
|
98
|
+
<a
|
|
99
|
+
:href="link.to"
|
|
100
|
+
:target="link.target"
|
|
101
|
+
:aria-label="link.ariaLabel"
|
|
102
|
+
>
|
|
103
|
+
<component
|
|
104
|
+
:is="link.icon"
|
|
105
|
+
class="size-5"
|
|
106
|
+
/>
|
|
107
|
+
</a>
|
|
108
|
+
</Button>
|
|
109
|
+
</template>
|
|
55
110
|
</div>
|
|
56
111
|
</div>
|
|
57
112
|
</header>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const appConfig = useAppConfig()
|
|
3
|
+
const colorMode = useColorMode()
|
|
4
|
+
|
|
5
|
+
const logoSrc = computed(() => {
|
|
6
|
+
const header = appConfig.docs?.header
|
|
7
|
+
if (!header?.logo) return null
|
|
8
|
+
|
|
9
|
+
if (colorMode.value === 'dark' && header.logo.dark) {
|
|
10
|
+
return header.logo.dark
|
|
11
|
+
}
|
|
12
|
+
return header.logo.light || header.logo.dark
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const logoAlt = computed(() => {
|
|
16
|
+
return appConfig.docs?.header?.logo?.alt || appConfig.docs?.title || 'Logo'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const title = computed(() => {
|
|
20
|
+
return appConfig.docs?.header?.title || appConfig.docs?.title || 'Documentation'
|
|
21
|
+
})
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<ClientOnly>
|
|
26
|
+
<img
|
|
27
|
+
v-if="logoSrc"
|
|
28
|
+
:src="logoSrc"
|
|
29
|
+
:alt="logoAlt"
|
|
30
|
+
class="h-6 w-auto shrink-0"
|
|
31
|
+
>
|
|
32
|
+
<span
|
|
33
|
+
v-else
|
|
34
|
+
class="font-bold"
|
|
35
|
+
>
|
|
36
|
+
{{ title }}
|
|
37
|
+
</span>
|
|
38
|
+
|
|
39
|
+
<template #fallback>
|
|
40
|
+
<span class="font-bold">{{ title }}</span>
|
|
41
|
+
</template>
|
|
42
|
+
</ClientOnly>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ArrowRight, FileText, Monitor, Moon, Sun } from 'lucide-vue-next'
|
|
3
|
+
import { useFuse } from '@vueuse/integrations/useFuse'
|
|
4
|
+
import {
|
|
5
|
+
Command,
|
|
6
|
+
CommandEmpty,
|
|
7
|
+
CommandGroup,
|
|
8
|
+
CommandInput,
|
|
9
|
+
CommandItem,
|
|
10
|
+
CommandList,
|
|
11
|
+
CommandSeparator,
|
|
12
|
+
} from '~/components/ui/command'
|
|
13
|
+
import {
|
|
14
|
+
Dialog,
|
|
15
|
+
DialogContent,
|
|
16
|
+
DialogDescription,
|
|
17
|
+
DialogHeader,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
} from '~/components/ui/dialog'
|
|
20
|
+
import { flattenNavigation, useContentSearch } from '~/composables/useContentSearch'
|
|
21
|
+
|
|
22
|
+
const router = useRouter()
|
|
23
|
+
const colorMode = useColorMode()
|
|
24
|
+
const { open } = useContentSearch()
|
|
25
|
+
const { data: navigation } = await useNavigation()
|
|
26
|
+
|
|
27
|
+
const searchTerm = ref('')
|
|
28
|
+
|
|
29
|
+
// Flatten navigation for search
|
|
30
|
+
const searchItems = computed(() => flattenNavigation(navigation.value))
|
|
31
|
+
|
|
32
|
+
// Fuse search
|
|
33
|
+
const { results } = useFuse(searchTerm, searchItems, {
|
|
34
|
+
fuseOptions: {
|
|
35
|
+
keys: ['label', 'prefix'],
|
|
36
|
+
threshold: 0.3,
|
|
37
|
+
},
|
|
38
|
+
matchAllWhenSearchEmpty: true,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const filteredItems = computed(() => {
|
|
42
|
+
if (!searchTerm.value) {
|
|
43
|
+
return searchItems.value
|
|
44
|
+
}
|
|
45
|
+
return results.value.map(r => r.item)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Group items by prefix (parent title)
|
|
49
|
+
const groupedItems = computed(() => {
|
|
50
|
+
const groups = new Map<string, typeof searchItems.value>()
|
|
51
|
+
|
|
52
|
+
for (const item of filteredItems.value) {
|
|
53
|
+
const groupKey = item.prefix || 'Pages'
|
|
54
|
+
if (!groups.has(groupKey)) {
|
|
55
|
+
groups.set(groupKey, [])
|
|
56
|
+
}
|
|
57
|
+
groups.get(groupKey)!.push(item)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Array.from(groups.entries()).map(([label, items]) => ({
|
|
61
|
+
label,
|
|
62
|
+
items,
|
|
63
|
+
}))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
function runCommand(command: () => unknown) {
|
|
67
|
+
open.value = false
|
|
68
|
+
searchTerm.value = ''
|
|
69
|
+
command()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handleColorMode(mode: 'system' | 'light' | 'dark') {
|
|
73
|
+
runCommand(() => {
|
|
74
|
+
colorMode.preference = mode
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Keyboard shortcut
|
|
79
|
+
onMounted(() => {
|
|
80
|
+
const down = (e: KeyboardEvent) => {
|
|
81
|
+
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') {
|
|
82
|
+
if (
|
|
83
|
+
(e.target instanceof HTMLElement && e.target.isContentEditable)
|
|
84
|
+
|| e.target instanceof HTMLInputElement
|
|
85
|
+
|| e.target instanceof HTMLTextAreaElement
|
|
86
|
+
|| e.target instanceof HTMLSelectElement
|
|
87
|
+
) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
open.value = !open.value
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
document.addEventListener('keydown', down)
|
|
95
|
+
onUnmounted(() => document.removeEventListener('keydown', down))
|
|
96
|
+
})
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<Dialog v-model:open="open">
|
|
101
|
+
<DialogContent
|
|
102
|
+
class="overflow-hidden p-0 shadow-lg"
|
|
103
|
+
:show-close-button="false"
|
|
104
|
+
>
|
|
105
|
+
<DialogHeader class="sr-only">
|
|
106
|
+
<DialogTitle>Search documentation</DialogTitle>
|
|
107
|
+
<DialogDescription>Search for pages in the documentation</DialogDescription>
|
|
108
|
+
</DialogHeader>
|
|
109
|
+
<Command
|
|
110
|
+
v-model:search-term="searchTerm"
|
|
111
|
+
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5"
|
|
112
|
+
>
|
|
113
|
+
<CommandInput placeholder="Search documentation..." />
|
|
114
|
+
<CommandList class="max-h-[300px]">
|
|
115
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
116
|
+
|
|
117
|
+
<!-- Navigation groups -->
|
|
118
|
+
<CommandGroup
|
|
119
|
+
v-for="group in groupedItems"
|
|
120
|
+
:key="group.label"
|
|
121
|
+
:heading="group.label"
|
|
122
|
+
>
|
|
123
|
+
<CommandItem
|
|
124
|
+
v-for="item in group.items"
|
|
125
|
+
:key="item.id"
|
|
126
|
+
:value="item.label"
|
|
127
|
+
@select="() => runCommand(() => router.push(item.to!))"
|
|
128
|
+
>
|
|
129
|
+
<FileText
|
|
130
|
+
v-if="item.isDoc"
|
|
131
|
+
class="mr-2 size-4"
|
|
132
|
+
/>
|
|
133
|
+
<ArrowRight
|
|
134
|
+
v-else
|
|
135
|
+
class="mr-2 size-4"
|
|
136
|
+
/>
|
|
137
|
+
<span>{{ item.label }}</span>
|
|
138
|
+
</CommandItem>
|
|
139
|
+
</CommandGroup>
|
|
140
|
+
|
|
141
|
+
<CommandSeparator />
|
|
142
|
+
|
|
143
|
+
<!-- Theme group -->
|
|
144
|
+
<CommandGroup heading="Theme">
|
|
145
|
+
<CommandItem
|
|
146
|
+
value="System theme"
|
|
147
|
+
@select="() => handleColorMode('system')"
|
|
148
|
+
>
|
|
149
|
+
<Monitor class="mr-2 size-4" />
|
|
150
|
+
<span>System</span>
|
|
151
|
+
<span
|
|
152
|
+
v-if="colorMode.preference === 'system'"
|
|
153
|
+
class="ml-auto text-xs text-muted-foreground"
|
|
154
|
+
>
|
|
155
|
+
Active
|
|
156
|
+
</span>
|
|
157
|
+
</CommandItem>
|
|
158
|
+
<CommandItem
|
|
159
|
+
value="Light theme"
|
|
160
|
+
@select="() => handleColorMode('light')"
|
|
161
|
+
>
|
|
162
|
+
<Sun class="mr-2 size-4" />
|
|
163
|
+
<span>Light</span>
|
|
164
|
+
<span
|
|
165
|
+
v-if="colorMode.preference === 'light'"
|
|
166
|
+
class="ml-auto text-xs text-muted-foreground"
|
|
167
|
+
>
|
|
168
|
+
Active
|
|
169
|
+
</span>
|
|
170
|
+
</CommandItem>
|
|
171
|
+
<CommandItem
|
|
172
|
+
value="Dark theme"
|
|
173
|
+
@select="() => handleColorMode('dark')"
|
|
174
|
+
>
|
|
175
|
+
<Moon class="mr-2 size-4" />
|
|
176
|
+
<span>Dark</span>
|
|
177
|
+
<span
|
|
178
|
+
v-if="colorMode.preference === 'dark'"
|
|
179
|
+
class="ml-auto text-xs text-muted-foreground"
|
|
180
|
+
>
|
|
181
|
+
Active
|
|
182
|
+
</span>
|
|
183
|
+
</CommandItem>
|
|
184
|
+
</CommandGroup>
|
|
185
|
+
</CommandList>
|
|
186
|
+
</Command>
|
|
187
|
+
</DialogContent>
|
|
188
|
+
</Dialog>
|
|
189
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Search } from 'lucide-vue-next'
|
|
3
|
+
import { Button } from '~/components/ui/button'
|
|
4
|
+
import { Kbd, KbdGroup } from '~/components/ui/kbd'
|
|
5
|
+
import { useContentSearch } from '~/composables/useContentSearch'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
collapsed?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
withDefaults(defineProps<Props>(), {
|
|
12
|
+
collapsed: false,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const { open } = useContentSearch()
|
|
16
|
+
|
|
17
|
+
const isMac = computed(() => {
|
|
18
|
+
if (import.meta.server) return true
|
|
19
|
+
return navigator.platform.toUpperCase().includes('MAC')
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<Button
|
|
25
|
+
v-if="collapsed"
|
|
26
|
+
variant="ghost"
|
|
27
|
+
size="icon"
|
|
28
|
+
aria-label="Search documentation"
|
|
29
|
+
@click="open = true"
|
|
30
|
+
>
|
|
31
|
+
<Search class="size-5" />
|
|
32
|
+
</Button>
|
|
33
|
+
<Button
|
|
34
|
+
v-else
|
|
35
|
+
variant="outline"
|
|
36
|
+
class="relative h-8 w-full max-w-sm justify-start bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-48 lg:w-56 xl:w-64"
|
|
37
|
+
@click="open = true"
|
|
38
|
+
>
|
|
39
|
+
<Search class="mr-2 size-4" />
|
|
40
|
+
<span class="hidden lg:inline-flex">Search documentation...</span>
|
|
41
|
+
<span class="inline-flex lg:hidden">Search...</span>
|
|
42
|
+
<div class="absolute right-1.5 top-1.5 hidden gap-1 sm:flex">
|
|
43
|
+
<ClientOnly>
|
|
44
|
+
<KbdGroup>
|
|
45
|
+
<Kbd class="border">{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
|
|
46
|
+
<Kbd class="border">K</Kbd>
|
|
47
|
+
</KbdGroup>
|
|
48
|
+
</ClientOnly>
|
|
49
|
+
</div>
|
|
50
|
+
</Button>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes, VNode } from 'vue'
|
|
3
|
+
import { computed, onBeforeUpdate, ref } from 'vue'
|
|
4
|
+
import { cn } from '~/lib/utils'
|
|
5
|
+
import {
|
|
6
|
+
Accordion as UIAccordion,
|
|
7
|
+
AccordionContent as UIAccordionContent,
|
|
8
|
+
AccordionItem as UIAccordionItem,
|
|
9
|
+
AccordionTrigger as UIAccordionTrigger,
|
|
10
|
+
} from '~/components/ui/accordion'
|
|
11
|
+
|
|
12
|
+
export interface AccordionProps {
|
|
13
|
+
/**
|
|
14
|
+
* Accordion type - single or multiple items open at once.
|
|
15
|
+
* @default 'multiple'
|
|
16
|
+
*/
|
|
17
|
+
type?: 'single' | 'multiple'
|
|
18
|
+
/**
|
|
19
|
+
* Default open items (comma-separated indices for multiple, single index for single).
|
|
20
|
+
* @example '0,1' or '0'
|
|
21
|
+
*/
|
|
22
|
+
defaultValue?: string
|
|
23
|
+
class?: HTMLAttributes['class']
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = withDefaults(defineProps<AccordionProps>(), {
|
|
27
|
+
type: 'multiple',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const slots = defineSlots<{
|
|
31
|
+
default(): VNode[]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const rerenderCount = ref(1)
|
|
35
|
+
|
|
36
|
+
interface AccordionItemData {
|
|
37
|
+
value: string
|
|
38
|
+
label: string
|
|
39
|
+
icon?: string
|
|
40
|
+
component: VNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const items = computed<AccordionItemData[]>(() => {
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
45
|
+
rerenderCount.value
|
|
46
|
+
|
|
47
|
+
let counter = 0
|
|
48
|
+
function transformSlot(slot: VNode): AccordionItemData | null {
|
|
49
|
+
if (typeof slot.type === 'symbol') {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const slotProps = slot.props as { label?: string, icon?: string } | null
|
|
54
|
+
if (!slotProps?.label) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
value: String(counter++),
|
|
60
|
+
label: slotProps.label,
|
|
61
|
+
icon: slotProps.icon,
|
|
62
|
+
component: slot,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function flattenSlots(slot: VNode): VNode[] {
|
|
67
|
+
if (typeof slot.type === 'symbol') {
|
|
68
|
+
const children = slot.children as VNode[] | null
|
|
69
|
+
return children?.flatMap(flattenSlots) ?? []
|
|
70
|
+
}
|
|
71
|
+
return [slot]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return slots.default?.()?.flatMap(flattenSlots).map(transformSlot).filter((item): item is AccordionItemData => item !== null) || []
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const defaultValue = computed(() => {
|
|
78
|
+
if (props.defaultValue) {
|
|
79
|
+
return props.type === 'multiple'
|
|
80
|
+
? props.defaultValue.split(',').map(v => v.trim())
|
|
81
|
+
: props.defaultValue
|
|
82
|
+
}
|
|
83
|
+
return props.type === 'multiple' ? [] : undefined
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
onBeforeUpdate(() => rerenderCount.value++)
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<UIAccordion
|
|
91
|
+
:type="type"
|
|
92
|
+
:default-value="defaultValue"
|
|
93
|
+
:class="cn('mt-6', props.class)"
|
|
94
|
+
collapsible
|
|
95
|
+
>
|
|
96
|
+
<UIAccordionItem
|
|
97
|
+
v-for="item in items"
|
|
98
|
+
:key="item.value"
|
|
99
|
+
:value="item.value"
|
|
100
|
+
>
|
|
101
|
+
<UIAccordionTrigger class="items-center">
|
|
102
|
+
<div class="flex items-center text-left">
|
|
103
|
+
<Icon
|
|
104
|
+
v-if="item.icon"
|
|
105
|
+
:name="item.icon"
|
|
106
|
+
class="size-4 mr-2 shrink-0"
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
/>
|
|
109
|
+
<span>{{ item.label }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</UIAccordionTrigger>
|
|
112
|
+
<UIAccordionContent class="text-left">
|
|
113
|
+
<component :is="item.component" />
|
|
114
|
+
</UIAccordionContent>
|
|
115
|
+
</UIAccordionItem>
|
|
116
|
+
</UIAccordion>
|
|
117
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes, VNode } from 'vue'
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
|
|
5
|
+
export interface AccordionItemProps {
|
|
6
|
+
/**
|
|
7
|
+
* The label displayed in the accordion trigger.
|
|
8
|
+
*/
|
|
9
|
+
label: string
|
|
10
|
+
/**
|
|
11
|
+
* Optional icon name for the accordion item.
|
|
12
|
+
*/
|
|
13
|
+
icon?: string
|
|
14
|
+
class?: HTMLAttributes['class']
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const props = defineProps<AccordionItemProps>()
|
|
18
|
+
defineSlots<{
|
|
19
|
+
default(): VNode[]
|
|
20
|
+
}>()
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div :class="cn('text-left', props.class)">
|
|
25
|
+
<slot />
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes, VNode } from 'vue'
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
|
|
5
|
+
export interface BadgeProps {
|
|
6
|
+
/**
|
|
7
|
+
* Badge color variant.
|
|
8
|
+
* @default 'primary'
|
|
9
|
+
*/
|
|
10
|
+
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'muted'
|
|
11
|
+
class?: HTMLAttributes['class']
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<BadgeProps>(), {
|
|
15
|
+
color: 'primary',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
defineSlots<{
|
|
19
|
+
default(): VNode[]
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const colorClasses: Record<string, string> = {
|
|
23
|
+
primary: 'bg-primary/10 text-primary border-primary/20',
|
|
24
|
+
secondary: 'bg-secondary text-secondary-foreground border-secondary',
|
|
25
|
+
success: 'bg-green-500/10 text-green-600 border-green-500/20 dark:text-green-400',
|
|
26
|
+
warning: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20 dark:text-yellow-400',
|
|
27
|
+
destructive: 'bg-destructive/10 text-destructive border-destructive/20',
|
|
28
|
+
muted: 'bg-muted text-muted-foreground border-border',
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<span
|
|
34
|
+
:class="cn(
|
|
35
|
+
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
|
36
|
+
colorClasses[color],
|
|
37
|
+
props.class,
|
|
38
|
+
)"
|
|
39
|
+
>
|
|
40
|
+
<slot mdc-unwrap="p" />
|
|
41
|
+
</span>
|
|
42
|
+
</template>
|