Skip to content

Commit 2700151

Browse files
committed
refactor(docs): extract DocsExample into composable sub-components
- Extract DocsExampleCodePane for reusable code blocks with auto-highlight - Extract DocsExampleDescription for collapsible description sections - Replace manual createSingle with Vuetify0 Tabs.Root/List/Item/Panel - Add raw code fallback while syntax highlighting loads - Add aria-label to overflow dropdown for accessibility Reduces DocsExample from 472 to ~280 lines by eliminating 3x code block duplication.
1 parent 8bd515a commit 2700151

File tree

8 files changed

+764
-81
lines changed

8 files changed

+764
-81
lines changed

apps/docs/src/components/docs/DocsExample.vue

Lines changed: 221 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,289 @@
11
<script setup lang="ts">
2+
// Framework
3+
import { createOverflow, Tabs } from '@vuetify/v0'
4+
25
// Composables
3-
import { useHighlightCode } from '@/composables/useHighlightCode'
4-
import { useSettings } from '@/composables/useSettings'
6+
import { useExamples } from '@/composables/useExamples'
57
68
// 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+
}
821
922
const props = withDefaults(defineProps<{
1023
file?: string
24+
filePath?: string
25+
filePaths?: string[]
1126
title?: string
27+
id?: string
1228
code?: string
29+
files?: ExampleFile[]
1330
peek?: boolean
1431
peekLines?: number
1532
}>(), {
1633
peekLines: 6,
1734
})
1835
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+
1957
const uid = useId()
2058
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>()
2465
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+
})
2788
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)
3192
})
3293
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')
3497
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+
})
39113
40114
function toggleCode () {
41115
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)
44123
}
45124
}
125+
126+
const fileName = computed(() =>
127+
props.file?.split('/').pop() || (props.filePath ? `${props.filePath.split('/').pop()}.vue` : ''),
128+
)
46129
</script>
47130

48131
<template>
49-
<div class="relative my-6" :class="shouldPeek && !expanded && 'mb-10'">
132+
<div class="relative my-6" :class="peek && !peekExpanded && 'mb-10'">
50133
<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"
54138
>
55-
{{ title }}
56-
</div>
139+
<template v-if="hasDescription">
140+
<slot name="description" />
141+
</template>
142+
</DocsExampleDescription>
57143

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 />
60148
</div>
61149

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">
63152
<button
64-
:aria-controls="code ? `${uid}-code` : undefined"
153+
:aria-controls="`${uid}-code`"
65154
:aria-expanded="showCode"
66155
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"
67156
type="button"
68157
@click="toggleCode"
69158
>
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" />
72161
<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>
74166
</button>
75167
</div>
76168

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+
/>
92182

183+
<!-- Multi-file tabs -->
184+
<Tabs.Root
185+
v-if="showCode && hasMultipleFiles"
186+
v-model="selectedTab"
187+
>
188+
<!-- Tab list with overflow -->
93189
<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"
96192
>
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>
107233

108234
<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'"
113237
type="button"
114-
@click="expanded = false"
238+
@click="combinedView = !combinedView"
115239
>
116-
<AppIcon icon="fullscreen-exit" :size="16" />
240+
<AppIcon :icon="combinedView ? 'split' : 'combine'" :size="16" />
117241
</button>
118242
</div>
119243

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>
126260

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>
129275
</div>
130276

277+
<!-- Peek expand button -->
131278
<button
132-
v-if="shouldPeek && !expanded"
279+
v-if="peek && !peekExpanded"
133280
aria-label="Expand code"
134281
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"
135282
type="button"
136-
@click="expanded = true"
283+
@click="peekExpanded = true"
137284
>
138285
<span>Expand</span>
139286
<AppIcon icon="down" :size="14" />
140287
</button>
141288
</div>
142289
</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

Comments
 (0)