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