Skip to content

Commit 4e1e426

Browse files
docs(app): add component theme visualizer (#5628)
Co-authored-by: Romain Hamel <25613751+romhml@users.noreply.github.com>
1 parent 87d642d commit 4e1e426

File tree

3 files changed

+293
-61
lines changed

3 files changed

+293
-61
lines changed

docs/app/components/content/ComponentCode.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ ${props.slots?.default}
348348
349349
const codeKey = computed(() => `component-code-${name}-${hash(props)}`)
350350
351+
const wrapperContainer = ref<HTMLElement | null>(null)
352+
const componentContainer = ref<HTMLElement | null>(null)
353+
351354
const { data: ast } = await useAsyncData(codeKey, async () => {
352355
if (!props.prettier) {
353356
return parseMarkdown(code.value)
@@ -371,7 +374,7 @@ const { data: ast } = await useAsyncData(codeKey, async () => {
371374

372375
<template>
373376
<div class="my-5" :style="{ '--ui-header-height': '4rem' }">
374-
<div class="relative">
377+
<div ref="wrapperContainer" class="relative group/component">
375378
<div v-if="options.length" class="flex flex-wrap items-center gap-2.5 border border-muted border-b-0 relative rounded-t-md px-4 py-2.5 overflow-x-auto">
376379
<template v-for="option in options" :key="option.name">
377380
<UFormField
@@ -420,7 +423,7 @@ const { data: ast } = await useAsyncData(codeKey, async () => {
420423
</template>
421424
</div>
422425

423-
<div v-if="component" class="flex justify-center border border-b-0 border-muted relative p-4 z-[1]" :class="[!options.length && 'rounded-t-md', props.class, { 'overflow-hidden': props.overflowHidden, 'dark:bg-neutral-950/50': props.elevated }]">
426+
<div v-if="component" ref="componentContainer" class="flex justify-center border border-b-0 border-muted relative p-4 z-[1]" :class="[!options.length && 'rounded-t-md', props.class, { 'overflow-hidden': props.overflowHidden, 'dark:bg-neutral-950/50': props.elevated }]">
424427
<component :is="component" v-bind="{ ...componentProps, ...componentEvents }">
425428
<template v-for="slot in Object.keys(slots || {})" :key="slot" #[slot]>
426429
<slot :name="slot" mdc-unwrap="p">
@@ -429,6 +432,15 @@ const { data: ast } = await useAsyncData(codeKey, async () => {
429432
</template>
430433
</component>
431434
</div>
435+
436+
<ClientOnly>
437+
<LazyComponentThemeVisualizer
438+
:container="componentContainer"
439+
:position-container="wrapperContainer"
440+
:slug="props.slug"
441+
:prose="props.prose"
442+
/>
443+
</ClientOnly>
432444
</div>
433445

434446
<MDCRenderer v-if="ast" :body="ast.body" :data="ast.data" class="[&_pre]:!rounded-t-none [&_div.my-5]:!mt-0" />

docs/app/components/content/ComponentExample.vue

Lines changed: 70 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ const slots = defineSlots<{
7676
}>()
7777
7878
const el = ref<HTMLElement | null>(null)
79+
const wrapperContainer = ref<HTMLElement | null>(null)
80+
const componentContainer = ref<HTMLElement | null>(null)
7981
8082
const { $prettier } = useNuxtApp()
8183
const { width } = useElementSize(el)
@@ -157,68 +159,77 @@ const urlSearchParams = computed(() => {
157159
<template>
158160
<div ref="el" class="my-5" :style="{ '--ui-header-height': '4rem' }">
159161
<template v-if="preview">
160-
<div class="border border-muted relative z-[1]" :class="[{ 'border-b-0 rounded-t-md': props.source, 'rounded-md': !props.source, 'overflow-hidden': props.overflowHidden }]">
161-
<div v-if="props.options?.length || !!slots.options" class="flex gap-4 p-4 border-b border-muted">
162-
<slot name="options" />
163-
164-
<UFormField
165-
v-for="option in props.options"
166-
:key="option.name"
167-
:label="option.label"
168-
:name="option.name"
169-
size="sm"
170-
class="inline-flex ring ring-accented rounded-sm"
171-
:ui="{
172-
wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
173-
label: 'text-muted px-2 py-1.5',
174-
container: 'mt-0'
175-
}"
176-
>
177-
<USelectMenu
178-
v-if="option.items?.length"
179-
:model-value="get(optionsValues, option.name)"
180-
:items="option.items"
181-
:search-input="false"
182-
:value-key="option.name.toLowerCase().endsWith('color') ? 'value' : undefined"
183-
color="neutral"
184-
variant="soft"
185-
class="rounded-sm rounded-l-none min-w-12"
186-
:multiple="option.multiple"
187-
:class="[option.name.toLowerCase().endsWith('color') && 'pl-6']"
188-
:ui="{ itemLeadingChip: 'w-2' }"
189-
@update:model-value="set(optionsValues, option.name, $event)"
162+
<div ref="wrapperContainer" class="relative group/component">
163+
<div class="border border-muted relative z-[1]" :class="[{ 'border-b-0 rounded-t-md': props.source, 'rounded-md': !props.source, 'overflow-hidden': props.overflowHidden }]">
164+
<div v-if="props.options?.length || !!slots.options" class="flex gap-4 p-4 border-b border-muted">
165+
<slot name="options" />
166+
167+
<UFormField
168+
v-for="option in props.options"
169+
:key="option.name"
170+
:label="option.label"
171+
:name="option.name"
172+
size="sm"
173+
class="inline-flex ring ring-accented rounded-sm"
174+
:ui="{
175+
wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
176+
label: 'text-muted px-2 py-1.5',
177+
container: 'mt-0'
178+
}"
190179
>
191-
<template v-if="option.name.toLowerCase().endsWith('color')" #leading="{ modelValue, ui }">
192-
<UChip
193-
inset
194-
standalone
195-
:color="(modelValue as any)"
196-
:size="(ui.itemLeadingChipSize() as ChipProps['size'])"
197-
class="size-2"
198-
/>
199-
</template>
200-
</USelectMenu>
201-
<UInput
202-
v-else
203-
:model-value="get(optionsValues, option.name)"
204-
color="neutral"
205-
variant="soft"
206-
:ui="{ base: 'rounded-sm rounded-l-none min-w-12' }"
207-
@update:model-value="set(optionsValues, option.name, $event)"
208-
/>
209-
</UFormField>
180+
<USelectMenu
181+
v-if="option.items?.length"
182+
:model-value="get(optionsValues, option.name)"
183+
:items="option.items"
184+
:search-input="false"
185+
:value-key="option.name.toLowerCase().endsWith('color') ? 'value' : undefined"
186+
color="neutral"
187+
variant="soft"
188+
class="rounded-sm rounded-l-none min-w-12"
189+
:multiple="option.multiple"
190+
:class="[option.name.toLowerCase().endsWith('color') && 'pl-6']"
191+
:ui="{ itemLeadingChip: 'w-2' }"
192+
@update:model-value="set(optionsValues, option.name, $event)"
193+
>
194+
<template v-if="option.name.toLowerCase().endsWith('color')" #leading="{ modelValue, ui }">
195+
<UChip
196+
inset
197+
standalone
198+
:color="(modelValue as any)"
199+
:size="(ui.itemLeadingChipSize() as ChipProps['size'])"
200+
class="size-2"
201+
/>
202+
</template>
203+
</USelectMenu>
204+
<UInput
205+
v-else
206+
:model-value="get(optionsValues, option.name)"
207+
color="neutral"
208+
variant="soft"
209+
:ui="{ base: 'rounded-sm rounded-l-none min-w-12' }"
210+
@update:model-value="set(optionsValues, option.name, $event)"
211+
/>
212+
</UFormField>
213+
</div>
214+
215+
<iframe
216+
v-if="iframe"
217+
v-bind="typeof iframe === 'object' ? iframe : {}"
218+
:src="`/examples/${name}?${urlSearchParams}`"
219+
class="relative w-full"
220+
:class="[props.class, { 'dark:bg-neutral-950/50 rounded-t-md': props.elevated }, !iframeMobile && 'lg:left-1/2 lg:-translate-x-1/2 lg:w-[1024px]']"
221+
/>
222+
<div v-else ref="componentContainer" class="flex justify-center p-4" :class="[props.class, { 'dark:bg-neutral-950/50 rounded-t-md': props.elevated }]">
223+
<component :is="camelName" v-bind="{ ...componentProps, ...optionsValues }" />
224+
</div>
210225
</div>
211226

212-
<iframe
213-
v-if="iframe"
214-
v-bind="typeof iframe === 'object' ? iframe : {}"
215-
:src="`/examples/${name}?${urlSearchParams}`"
216-
class="relative w-full"
217-
:class="[props.class, { 'dark:bg-neutral-950/50 rounded-t-md': props.elevated }, !iframeMobile && 'lg:left-1/2 lg:-translate-x-1/2 lg:w-[1024px]']"
218-
/>
219-
<div v-else class="flex justify-center p-4" :class="[props.class, { 'dark:bg-neutral-950/50 rounded-t-md': props.elevated }]">
220-
<component :is="camelName" v-bind="{ ...componentProps, ...optionsValues }" />
221-
</div>
227+
<ClientOnly>
228+
<LazyComponentThemeVisualizer
229+
:container="componentContainer"
230+
:position-container="wrapperContainer"
231+
/>
232+
</ClientOnly>
222233
</div>
223234
</template>
224235

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
<script setup lang="ts">
2+
import { camelCase } from 'scule'
3+
import * as theme from '#build/ui'
4+
5+
const props = defineProps<{
6+
/**
7+
* The container element to find slots in.
8+
*/
9+
container: HTMLElement | null
10+
/**
11+
* The positioned ancestor for highlight positioning.
12+
* If not provided, uses container.
13+
*/
14+
positionContainer?: HTMLElement | null
15+
/**
16+
* Override the component slug taken from the route.
17+
*/
18+
slug?: string
19+
/**
20+
* Whether the component is a prose component.
21+
*/
22+
prose?: boolean
23+
}>()
24+
25+
const route = useRoute()
26+
27+
const camelName = computed(() => camelCase(props.slug ?? route.path.split('/').pop() ?? ''))
28+
const componentTheme = computed(() => ((props.prose ? theme.prose : theme) as any)[camelName.value])
29+
30+
// Get all slot names from theme definition
31+
const themeSlots = computed(() => Object.keys(componentTheme.value?.slots ?? {}))
32+
33+
const open = ref(false)
34+
const highlightedSlot = ref<string | null>(null)
35+
const highlightStyle = ref<{ left: string, top: string, width: string, height: string } | null>(null)
36+
const isPortalHighlight = ref(false)
37+
const popoverContentRef = useTemplateRef('popoverContentRef')
38+
39+
function getSlotClasses(slotName: string): string {
40+
const baseClasses = componentTheme.value?.slots?.[slotName] || ''
41+
return Array.isArray(baseClasses) ? baseClasses.filter(Boolean).join(' ') : baseClasses
42+
}
43+
44+
function findSlotElement(slotName: string): { element: Element, inPortal: boolean } | null {
45+
if (!props.container) return null
46+
47+
// First check in container
48+
const containerSlot = props.container.querySelector(`[data-slot="${slotName}"]`)
49+
if (containerSlot) {
50+
return { element: containerSlot, inPortal: false }
51+
}
52+
53+
// Then check in Reka UI portals (excluding our own popover's portal)
54+
for (const child of document.body.children) {
55+
const hasRekaAttr = Array.from(child.attributes).some(attr => attr.name.startsWith('data-reka-'))
56+
if (hasRekaAttr) {
57+
// Skip the portal that contains our popover content
58+
if (popoverContentRef.value && child.contains(popoverContentRef.value)) {
59+
continue
60+
}
61+
const portalSlot = child.querySelector(`[data-slot="${slotName}"]`)
62+
if (portalSlot) {
63+
return { element: portalSlot, inPortal: true }
64+
}
65+
}
66+
}
67+
68+
return null
69+
}
70+
71+
function getSlotPosition(slotName: string): { style: { left: string, top: string, width: string, height: string }, inPortal: boolean } | null {
72+
const result = findSlotElement(slotName)
73+
if (!result) return null
74+
75+
const targetRect = result.element.getBoundingClientRect()
76+
77+
if (result.inPortal) {
78+
// Use fixed positioning for portal elements
79+
return {
80+
style: {
81+
left: `${targetRect.left}px`,
82+
top: `${targetRect.top}px`,
83+
width: `${targetRect.width}px`,
84+
height: `${targetRect.height}px`
85+
},
86+
inPortal: true
87+
}
88+
}
89+
90+
// Use relative positioning for container elements
91+
const positionEl = props.positionContainer ?? props.container
92+
const positionRect = positionEl!.getBoundingClientRect()
93+
94+
return {
95+
style: {
96+
left: `${targetRect.left - positionRect.left}px`,
97+
top: `${targetRect.top - positionRect.top}px`,
98+
width: `${targetRect.width}px`,
99+
height: `${targetRect.height}px`
100+
},
101+
inPortal: false
102+
}
103+
}
104+
105+
// Initialize position to first rendered slot (so first hover can animate from there)
106+
function initializePosition() {
107+
for (const slotName of themeSlots.value) {
108+
const position = getSlotPosition(slotName)
109+
if (position) {
110+
highlightStyle.value = position.style
111+
isPortalHighlight.value = position.inPortal
112+
break
113+
}
114+
}
115+
}
116+
117+
function highlightSlot(slotName: string) {
118+
const position = getSlotPosition(slotName)
119+
if (!position) return
120+
121+
highlightedSlot.value = slotName
122+
highlightStyle.value = position.style
123+
isPortalHighlight.value = position.inPortal
124+
}
125+
126+
function clearHighlight() {
127+
highlightedSlot.value = null
128+
}
129+
130+
function getSlotRenderLocation(slotName: string): 'container' | 'portal' | 'none' {
131+
const result = findSlotElement(slotName)
132+
if (!result) return 'none'
133+
return result.inPortal ? 'portal' : 'container'
134+
}
135+
136+
// Initialize position when popover opens, clear when closes
137+
watch(open, (isOpen) => {
138+
if (isOpen) {
139+
initializePosition()
140+
} else {
141+
clearHighlight()
142+
highlightStyle.value = null
143+
}
144+
})
145+
</script>
146+
147+
<template>
148+
<template v-if="themeSlots.length">
149+
<UPopover
150+
v-model:open="open"
151+
:content="{ align: 'start' }"
152+
:ui="{ content: 'w-64 max-h-72 overflow-y-auto' }"
153+
:dismissible="false"
154+
>
155+
<UButton
156+
:icon="open ? 'i-lucide-x' : 'i-lucide-scan-eye'"
157+
color="neutral"
158+
variant="outline"
159+
size="sm"
160+
class="absolute -top-[11px] -right-[11px] z-1 rounded-full lg:opacity-0 lg:group-hover/component:opacity-100 ring-muted transition-opacity duration-200"
161+
:class="[open && 'lg:opacity-100 bg-elevated']"
162+
/>
163+
164+
<template #content>
165+
<div ref="popoverContentRef" class="px-2.5 py-1.5 text-xs font-semibold text-highlighted border-b border-default">
166+
Theme slots
167+
</div>
168+
<div class="p-1">
169+
<div
170+
v-for="slotName in themeSlots"
171+
:key="slotName"
172+
class="p-1.5 cursor-default hover:bg-elevated/50 transition-colors rounded"
173+
:class="[highlightedSlot === slotName && 'bg-elevated/50']"
174+
@mouseenter="highlightSlot(slotName)"
175+
@mouseleave="clearHighlight"
176+
>
177+
<div class="flex items-center gap-2">
178+
<code class="text-xs font-medium" :class="[getSlotRenderLocation(slotName) !== 'none' ? 'text-highlighted' : 'text-muted']">{{ slotName }}</code>
179+
<span v-if="getSlotRenderLocation(slotName) === 'portal'" class="text-[10px] text-muted">(in portal)</span>
180+
<span v-else-if="getSlotRenderLocation(slotName) === 'none'" class="text-[10px] text-muted">(not rendered)</span>
181+
</div>
182+
<div v-if="getSlotClasses(slotName)" class="mt-0.5 text-[10px] text-muted line-clamp-2 font-mono">
183+
{{ getSlotClasses(slotName) }}
184+
</div>
185+
</div>
186+
</div>
187+
</template>
188+
</UPopover>
189+
190+
<Teleport to="body" :disabled="!isPortalHighlight">
191+
<div
192+
v-if="highlightStyle"
193+
:style="highlightStyle"
194+
class="pointer-events-none border-2 border-dashed border-primary invert rounded transition-all duration-150"
195+
:class="[
196+
highlightedSlot ? 'opacity-100' : 'opacity-0',
197+
isPortalHighlight ? 'fixed z-2147483647' : 'absolute z-1'
198+
]"
199+
>
200+
<div
201+
v-if="highlightedSlot"
202+
class="absolute -top-6 -left-0.5 px-1.5 py-0.5 text-xs font-medium font-mono bg-primary text-highlighted rounded whitespace-nowrap"
203+
>
204+
{{ highlightedSlot }}
205+
</div>
206+
</div>
207+
</Teleport>
208+
</template>
209+
</template>

0 commit comments

Comments
 (0)