|
1 | 1 | <script setup lang="ts"> |
| 2 | + // Framework |
| 3 | + import { createOverflow, Tabs } from '@vuetify/v0' |
| 4 | +
|
2 | 5 | // Composables |
3 | | - import { useHighlightCode } from '@/composables/useHighlightCode' |
4 | | - import { useSettings } from '@/composables/useSettings' |
| 6 | + import { useExamples } from '@/composables/useExamples' |
5 | 7 |
|
6 | 8 | // Utilities |
7 | | - import { computed, ref, shallowRef, toRef, useId, watch } from 'vue' |
| 9 | + import { toKebab } from '@/utilities/strings' |
| 10 | + import { computed, ref, useId, useSlots, useTemplateRef, watch } from 'vue' |
| 11 | +
|
| 12 | + // Types |
| 13 | + import type DocsExampleCodePaneType from './DocsExampleCodePane.vue' |
| 14 | + import type { ComponentPublicInstance } from 'vue' |
| 15 | +
|
| 16 | + export interface ExampleFile { |
| 17 | + name: string |
| 18 | + code: string |
| 19 | + language?: string |
| 20 | + } |
8 | 21 |
|
9 | 22 | const props = withDefaults(defineProps<{ |
10 | 23 | file?: string |
| 24 | + filePath?: string |
| 25 | + filePaths?: string[] |
11 | 26 | title?: string |
| 27 | + id?: string |
12 | 28 | code?: string |
| 29 | + files?: ExampleFile[] |
13 | 30 | peek?: boolean |
14 | 31 | peekLines?: number |
15 | 32 | }>(), { |
16 | 33 | peekLines: 6, |
17 | 34 | }) |
18 | 35 |
|
| 36 | + // Auto-resolve component and code from filePath(s) |
| 37 | + const { resolve, resolveMultiple } = useExamples() |
| 38 | + const auto = computed(() => { |
| 39 | + if (props.filePaths?.length) return resolveMultiple(props.filePaths) |
| 40 | + if (props.filePath) return resolve(props.filePath) |
| 41 | + return null |
| 42 | + }) |
| 43 | +
|
| 44 | + const resolvedCode = computed(() => |
| 45 | + props.code ?? ('code' in (auto.value || {}) ? (auto.value as { code?: string }).code : undefined), |
| 46 | + ) |
| 47 | + const resolvedFiles = computed(() => |
| 48 | + props.files ?? ('files' in (auto.value || {}) ? (auto.value as { files?: ExampleFile[] }).files : undefined), |
| 49 | + ) |
| 50 | +
|
| 51 | + const slots = useSlots() |
| 52 | + const hasDescription = computed(() => !!slots.description) |
| 53 | + const descriptionExpanded = ref(false) |
| 54 | +
|
| 55 | + const anchorId = computed(() => props.id ?? (props.title ? `example-${toKebab(props.title)}` : undefined)) |
| 56 | +
|
19 | 57 | const uid = useId() |
20 | 58 | const showCode = ref(false) |
21 | | - const expanded = ref(false) |
22 | | - const { highlightedCode, highlight, isLoading, showLoader } = useHighlightCode(toRef(() => props.code), { immediate: props.peek }) |
23 | | - const { lineWrap: defaultLineWrap } = useSettings() |
| 59 | + const peekExpanded = ref(false) |
| 60 | + const combinedView = ref(false) |
| 61 | +
|
| 62 | + // Multi-file support |
| 63 | + const hasMultipleFiles = computed(() => resolvedFiles.value && resolvedFiles.value.length > 1) |
| 64 | + const selectedTab = ref<string>() |
24 | 65 |
|
25 | | - // Local state initialized from global default, per-instance |
26 | | - const lineWrap = shallowRef(defaultLineWrap.value) |
| 66 | + watch(() => resolvedFiles.value, files => { |
| 67 | + if (files?.length && !selectedTab.value) { |
| 68 | + selectedTab.value = files[0]?.name |
| 69 | + } |
| 70 | + }, { immediate: true }) |
| 71 | +
|
| 72 | + // Overflow detection for file tabs |
| 73 | + const tabsContainer = useTemplateRef<HTMLElement>('tabs-container') |
| 74 | + const overflow = createOverflow({ |
| 75 | + container: tabsContainer, |
| 76 | + gap: 4, |
| 77 | + reserved: 80, |
| 78 | + }) |
| 79 | +
|
| 80 | + const visibleCount = computed(() => { |
| 81 | + if (!resolvedFiles.value?.length) return 0 |
| 82 | + const cap = overflow.capacity.value |
| 83 | + if (cap === Infinity || cap >= resolvedFiles.value.length) { |
| 84 | + return resolvedFiles.value.length |
| 85 | + } |
| 86 | + return Math.max(1, cap - 1) |
| 87 | + }) |
27 | 88 |
|
28 | | - // Sync when global setting changes |
29 | | - watch(defaultLineWrap, val => { |
30 | | - lineWrap.value = val |
| 89 | + const hiddenFiles = computed(() => { |
| 90 | + if (!resolvedFiles.value?.length) return [] |
| 91 | + return resolvedFiles.value.slice(visibleCount.value) |
31 | 92 | }) |
32 | 93 |
|
33 | | - const fileName = computed(() => props.file?.split('/').pop() || '') |
| 94 | + // Code pane refs for triggering highlight |
| 95 | + const codePaneRefs = ref<Map<string, InstanceType<typeof DocsExampleCodePaneType>>>(new Map()) |
| 96 | + const singleCodePane = useTemplateRef<InstanceType<typeof DocsExampleCodePaneType>>('single-code-pane') |
34 | 97 |
|
35 | | - // Peek mode: show partial code by default |
36 | | - const lineCount = computed(() => props.code?.split('\n').length ?? 0) |
37 | | - const shouldPeek = computed(() => props.peek && lineCount.value > props.peekLines) |
38 | | - const peekHeight = computed(() => `${props.peekLines * 1.5 + 1}rem`) |
| 98 | + const isLoading = computed(() => { |
| 99 | + if (hasMultipleFiles.value) { |
| 100 | + const pane = codePaneRefs.value.get(selectedTab.value ?? '') |
| 101 | + return pane?.isLoading ?? false |
| 102 | + } |
| 103 | + return singleCodePane.value?.isLoading ?? false |
| 104 | + }) |
| 105 | +
|
| 106 | + const hasHighlightedCode = computed(() => { |
| 107 | + if (hasMultipleFiles.value) { |
| 108 | + const pane = codePaneRefs.value.get(selectedTab.value ?? '') |
| 109 | + return !!pane?.highlightedCode |
| 110 | + } |
| 111 | + return !!singleCodePane.value?.highlightedCode |
| 112 | + }) |
39 | 113 |
|
40 | 114 | function toggleCode () { |
41 | 115 | showCode.value = !showCode.value |
42 | | - if (showCode.value && !highlightedCode.value && props.code) { |
43 | | - highlight(props.code) |
| 116 | + } |
| 117 | +
|
| 118 | + function setCodePaneRef (name: string, el: unknown) { |
| 119 | + if (el) { |
| 120 | + codePaneRefs.value.set(name, el as InstanceType<typeof DocsExampleCodePaneType>) |
| 121 | + } else { |
| 122 | + codePaneRefs.value.delete(name) |
44 | 123 | } |
45 | 124 | } |
| 125 | +
|
| 126 | + const fileName = computed(() => |
| 127 | + props.file?.split('/').pop() || (props.filePath ? `${props.filePath.split('/').pop()}.vue` : ''), |
| 128 | + ) |
46 | 129 | </script> |
47 | 130 |
|
48 | 131 | <template> |
49 | | - <div class="relative my-6" :class="shouldPeek && !expanded && 'mb-10'"> |
| 132 | + <div class="relative my-6" :class="peek && !peekExpanded && 'mb-10'"> |
50 | 133 | <div class="border border-divider rounded-lg overflow-hidden"> |
51 | | - <div |
52 | | - v-if="title" |
53 | | - class="px-4 py-3 font-semibold border-b border-divider bg-surface-tint" |
| 134 | + <!-- Description --> |
| 135 | + <DocsExampleDescription |
| 136 | + :anchor-id="anchorId" |
| 137 | + :title="title" |
54 | 138 | > |
55 | | - {{ title }} |
56 | | - </div> |
| 139 | + <template v-if="hasDescription"> |
| 140 | + <slot name="description" /> |
| 141 | + </template> |
| 142 | + </DocsExampleDescription> |
57 | 143 |
|
58 | | - <div class="p-6 bg-surface"> |
59 | | - <slot /> |
| 144 | + <!-- Preview --> |
| 145 | + <div class="p-6 bg-surface" :class="hasDescription && !descriptionExpanded && 'pt-8'"> |
| 146 | + <component :is="auto?.component" v-if="auto?.component" /> |
| 147 | + <slot v-else /> |
60 | 148 | </div> |
61 | 149 |
|
62 | | - <div v-if="!peek" class="border-t border-divider bg-surface-tint"> |
| 150 | + <!-- Code toggle button --> |
| 151 | + <div v-if="!peek && (resolvedCode || resolvedFiles?.length)" class="border-t border-divider bg-surface-tint"> |
63 | 152 | <button |
64 | | - :aria-controls="code ? `${uid}-code` : undefined" |
| 153 | + :aria-controls="`${uid}-code`" |
65 | 154 | :aria-expanded="showCode" |
66 | 155 | class="group w-full px-4 py-3 bg-transparent border-none font-inherit text-sm cursor-pointer flex items-center gap-2 text-on-surface transition-colors hover:bg-surface" |
67 | 156 | type="button" |
68 | 157 | @click="toggleCode" |
69 | 158 | > |
70 | | - <AppLoaderIcon v-if="showLoader" variant="orbit" /> |
71 | | - <AppIcon v-else-if="showCode && !isLoading" icon="chevron-up" :size="16" /> |
| 159 | + <AppLoaderIcon v-if="isLoading" variant="orbit" /> |
| 160 | + <AppIcon v-else-if="showCode && hasHighlightedCode" icon="chevron-up" :size="16" /> |
72 | 161 | <AppIcon v-else class="transition-colors group-hover:text-primary" icon="code" :size="16" /> |
73 | | - <span v-if="fileName" class="ml-auto opacity-60 font-mono text-[0.8125rem]">{{ fileName }}</span> |
| 162 | + <span v-if="hasMultipleFiles" class="ml-auto opacity-60 font-mono text-[0.8125rem]"> |
| 163 | + {{ resolvedFiles!.length }} file(s) |
| 164 | + </span> |
| 165 | + <span v-else-if="fileName" class="ml-auto opacity-60 font-mono text-[0.8125rem]">{{ fileName }}</span> |
74 | 166 | </button> |
75 | 167 | </div> |
76 | 168 |
|
77 | | - <div |
78 | | - v-if="(showCode || peek) && highlightedCode" |
79 | | - :id="code ? `${uid}-code` : undefined" |
80 | | - class="docs-example-code relative bg-pre group" |
81 | | - :class="{ |
82 | | - 'docs-example-code--wrap': lineWrap, |
83 | | - 'docs-example-code--expanded': !shouldPeek || expanded, |
84 | | - }" |
85 | | - > |
86 | | - <span |
87 | | - v-if="fileName && (!shouldPeek || expanded)" |
88 | | - class="absolute top-3 left-3 z-10 px-1.5 py-0.5 text-xs font-mono opacity-50" |
89 | | - > |
90 | | - {{ fileName }} |
91 | | - </span> |
| 169 | + <!-- Single file code display --> |
| 170 | + <DocsExampleCodePane |
| 171 | + v-if="(showCode || peek) && resolvedCode && !hasMultipleFiles" |
| 172 | + :id="`${uid}-code`" |
| 173 | + ref="single-code-pane" |
| 174 | + v-model:expanded="peekExpanded" |
| 175 | + :code="resolvedCode" |
| 176 | + :file-name="fileName" |
| 177 | + :language="file?.split('.').pop() || 'vue'" |
| 178 | + :peek="peek" |
| 179 | + :peek-lines="peekLines" |
| 180 | + :title="title || fileName" |
| 181 | + /> |
92 | 182 |
|
| 183 | + <!-- Multi-file tabs --> |
| 184 | + <Tabs.Root |
| 185 | + v-if="showCode && hasMultipleFiles" |
| 186 | + v-model="selectedTab" |
| 187 | + > |
| 188 | + <!-- Tab list with overflow --> |
93 | 189 | <div |
94 | | - v-if="!shouldPeek || expanded" |
95 | | - class="absolute top-3 right-3 z-10 flex gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity max-md:opacity-100" |
| 190 | + ref="tabs-container" |
| 191 | + class="flex items-center gap-1 px-3 py-3 bg-surface border-t border-divider min-h-12" |
96 | 192 | > |
97 | | - <DocsCodeActions |
98 | | - v-model:wrap="lineWrap" |
99 | | - bin |
100 | | - :code="code!" |
101 | | - language="vue" |
102 | | - playground |
103 | | - show-copy |
104 | | - show-wrap |
105 | | - :title="title || fileName" |
106 | | - /> |
| 193 | + <template v-if="!combinedView"> |
| 194 | + <Tabs.List class="contents" label="Example files"> |
| 195 | + <Tabs.Item |
| 196 | + v-for="(f, i) in resolvedFiles" |
| 197 | + :key="f.name" |
| 198 | + :ref="(el: unknown) => overflow.measure(i, (el as ComponentPublicInstance)?.$el)" |
| 199 | + class="h-[30px] px-2 text-xs font-medium rounded whitespace-nowrap inline-flex items-center cursor-pointer" |
| 200 | + :class="[ |
| 201 | + i >= visibleCount ? 'invisible absolute' : '', |
| 202 | + f.name === selectedTab |
| 203 | + ? 'bg-primary text-on-primary border border-transparent' |
| 204 | + : 'bg-surface-tint border border-divider text-on-surface-tint hover:bg-surface-variant' |
| 205 | + ]" |
| 206 | + :value="f.name" |
| 207 | + > |
| 208 | + {{ f.name }} |
| 209 | + </Tabs.Item> |
| 210 | + </Tabs.List> |
| 211 | + |
| 212 | + <!-- Dropdown for hidden files --> |
| 213 | + <select |
| 214 | + v-if="hiddenFiles.length > 0" |
| 215 | + aria-label="Additional files" |
| 216 | + class="ml-1 h-[30px] px-2 text-xs font-medium bg-surface-tint border border-divider rounded text-on-surface cursor-pointer" |
| 217 | + :value="hiddenFiles.some(f => f.name === selectedTab) ? selectedTab : ''" |
| 218 | + @change="selectedTab = ($event.target as HTMLSelectElement).value" |
| 219 | + > |
| 220 | + <option disabled value="">+{{ hiddenFiles.length }} more</option> |
| 221 | + <option v-for="f in hiddenFiles" :key="f.name" :value="f.name"> |
| 222 | + {{ f.name }} |
| 223 | + </option> |
| 224 | + </select> |
| 225 | + </template> |
| 226 | + |
| 227 | + <span |
| 228 | + v-else |
| 229 | + class="px-2 py-1 text-xs font-medium inline-flex items-center line-height-relaxed text-on-surface-variant opacity-60 border border-transparent" |
| 230 | + > |
| 231 | + All files |
| 232 | + </span> |
107 | 233 |
|
108 | 234 | <button |
109 | | - v-if="shouldPeek && expanded" |
110 | | - aria-label="Collapse code" |
111 | | - class="inline-flex items-center justify-center size-7 text-on-primary bg-primary rounded cursor-pointer transition-200 hover:bg-primary/85" |
112 | | - title="Collapse code" |
| 235 | + class="ml-auto size-[30px] rounded text-on-surface-variant hover:bg-surface-variant transition-colors inline-flex items-center justify-center" |
| 236 | + :title="combinedView ? 'Split files' : 'Combine files'" |
113 | 237 | type="button" |
114 | | - @click="expanded = false" |
| 238 | + @click="combinedView = !combinedView" |
115 | 239 | > |
116 | | - <AppIcon icon="fullscreen-exit" :size="16" /> |
| 240 | + <AppIcon :icon="combinedView ? 'split' : 'combine'" :size="16" /> |
117 | 241 | </button> |
118 | 242 | </div> |
119 | 243 |
|
120 | | - <div |
121 | | - class="overflow-hidden transition-[max-height] duration-300 ease-out" |
122 | | - :style="shouldPeek && !expanded ? { maxHeight: peekHeight } : undefined" |
123 | | - > |
124 | | - <div v-html="highlightedCode" /> |
125 | | - </div> |
| 244 | + <!-- Tabbed panels (single file view) --> |
| 245 | + <template v-if="!combinedView"> |
| 246 | + <Tabs.Panel |
| 247 | + v-for="f in resolvedFiles" |
| 248 | + :key="f.name" |
| 249 | + :value="f.name" |
| 250 | + > |
| 251 | + <DocsExampleCodePane |
| 252 | + :ref="(el: unknown) => setCodePaneRef(f.name, el)" |
| 253 | + :code="f.code" |
| 254 | + :file-name="f.name" |
| 255 | + :language="f.language || f.name.split('.').pop() || 'text'" |
| 256 | + :title="f.name" |
| 257 | + /> |
| 258 | + </Tabs.Panel> |
| 259 | + </template> |
126 | 260 |
|
127 | | - <div v-if="shouldPeek && !expanded" class="docs-example-fade absolute left-0 right-0 bottom-0 h-12 rounded-b-lg pointer-events-none" /> |
128 | | - </div> |
| 261 | + <!-- Combined view (all files stacked) --> |
| 262 | + <template v-else> |
| 263 | + <DocsExampleCodePane |
| 264 | + v-for="f in resolvedFiles" |
| 265 | + :key="f.name" |
| 266 | + :ref="(el: unknown) => setCodePaneRef(f.name, el)" |
| 267 | + :code="f.code" |
| 268 | + :file-name="f.name" |
| 269 | + :language="f.language || f.name.split('.').pop() || 'text'" |
| 270 | + :show-playground="false" |
| 271 | + :title="f.name" |
| 272 | + /> |
| 273 | + </template> |
| 274 | + </Tabs.Root> |
129 | 275 | </div> |
130 | 276 |
|
| 277 | + <!-- Peek expand button --> |
131 | 278 | <button |
132 | | - v-if="shouldPeek && !expanded" |
| 279 | + v-if="peek && !peekExpanded" |
133 | 280 | aria-label="Expand code" |
134 | 281 | class="absolute -bottom-3 left-1/2 -translate-x-1/2 z-10 inline-flex items-center justify-center gap-1 px-2 py-1 text-xs text-on-primary bg-primary rounded cursor-pointer transition-200 hover:bg-primary/85 touch-action-manipulation" |
135 | 282 | type="button" |
136 | | - @click="expanded = true" |
| 283 | + @click="peekExpanded = true" |
137 | 284 | > |
138 | 285 | <span>Expand</span> |
139 | 286 | <AppIcon icon="down" :size="14" /> |
140 | 287 | </button> |
141 | 288 | </div> |
142 | 289 | </template> |
143 | | - |
144 | | -<style scoped> |
145 | | - .docs-example-fade { |
146 | | - background: var(--v0-pre); |
147 | | - mask: linear-gradient(transparent, var(--v0-primary)); |
148 | | - } |
149 | | -</style> |
|
0 commit comments