[READ-ONLY] a fast, modern browser for the npm registry
at main 250 lines 7.5 kB view raw
1<script setup lang="ts"> 2import type { TocItem } from '#shared/types/readme' 3import { onClickOutside, useEventListener } from '@vueuse/core' 4 5const props = defineProps<{ 6 toc: TocItem[] 7 activeId?: string | null 8}>() 9 10interface TocNode extends TocItem { 11 children: TocNode[] 12} 13 14function buildTocTree(items: TocItem[]): TocNode[] { 15 const result: TocNode[] = [] 16 const stack: TocNode[] = [] 17 18 for (const item of items) { 19 const node: TocNode = { ...item, children: [] } 20 21 // Find parent: look for the last item with smaller depth 22 while (stack.length > 0 && stack[stack.length - 1]!.depth >= item.depth) { 23 stack.pop() 24 } 25 26 if (stack.length === 0) { 27 result.push(node) 28 } else { 29 stack[stack.length - 1]!.children.push(node) 30 } 31 32 stack.push(node) 33 } 34 35 return result 36} 37 38const tocTree = computed(() => buildTocTree(props.toc)) 39 40// Create a map from id to index for efficient lookup 41const idToIndex = computed(() => { 42 const map = new Map<string, number>() 43 props.toc.forEach((item, index) => map.set(item.id, index)) 44 return map 45}) 46 47const listRef = useTemplateRef('listRef') 48const triggerRef = useTemplateRef('triggerRef') 49const isOpen = shallowRef(false) 50const highlightedIndex = shallowRef(-1) 51 52const dropdownPosition = shallowRef<{ top: number; right: number } | null>(null) 53 54function getDropdownStyle(): Record<string, string> { 55 if (!dropdownPosition.value) return {} 56 return { 57 top: `${dropdownPosition.value.top}px`, 58 right: `${document.documentElement.clientWidth - dropdownPosition.value.right}px`, 59 } 60} 61 62// Close on scroll (but not when scrolling inside the dropdown) 63function handleScroll(event: Event) { 64 if (!isOpen.value) return 65 if (listRef.value && event.target instanceof Node && listRef.value.contains(event.target)) { 66 return 67 } 68 close() 69} 70useEventListener('scroll', handleScroll, { passive: true }) 71 72// Generate unique ID for accessibility 73const inputId = useId() 74const listboxId = `${inputId}-toc-listbox` 75 76function toggle() { 77 if (isOpen.value) { 78 close() 79 } else { 80 const rect = triggerRef.value?.getBoundingClientRect() 81 if (rect) { 82 dropdownPosition.value = { 83 top: rect.bottom + 4, 84 right: rect.right, 85 } 86 } 87 isOpen.value = true 88 // Highlight active item if any 89 const activeIndex = idToIndex.value.get(props.activeId ?? '') 90 highlightedIndex.value = activeIndex ?? 0 91 } 92} 93 94function close() { 95 isOpen.value = false 96 highlightedIndex.value = -1 97} 98 99function select() { 100 close() 101 triggerRef.value?.focus() 102} 103 104function getIndex(id: string): number { 105 return idToIndex.value.get(id) ?? -1 106} 107 108// Check for reduced motion preference 109const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') 110 111onClickOutside(listRef, close, { ignore: [triggerRef] }) 112 113function handleKeydown(event: KeyboardEvent) { 114 if (!isOpen.value) return 115 116 const itemCount = props.toc.length 117 118 switch (event.key) { 119 case 'ArrowDown': 120 event.preventDefault() 121 highlightedIndex.value = (highlightedIndex.value + 1) % itemCount 122 break 123 case 'ArrowUp': 124 event.preventDefault() 125 highlightedIndex.value = 126 highlightedIndex.value <= 0 ? itemCount - 1 : highlightedIndex.value - 1 127 break 128 case 'Enter': { 129 event.preventDefault() 130 const item = props.toc[highlightedIndex.value] 131 if (item) { 132 select() 133 } 134 break 135 } 136 case 'Escape': 137 close() 138 triggerRef.value?.focus() 139 break 140 } 141} 142</script> 143 144<template> 145 <ButtonBase 146 ref="triggerRef" 147 type="button" 148 :aria-expanded="isOpen" 149 aria-haspopup="listbox" 150 :aria-label="$t('package.readme.toc_title')" 151 :aria-controls="listboxId" 152 @click="toggle" 153 @keydown="handleKeydown" 154 classicon="i-lucide:list" 155 class="px-2.5" 156 block 157 > 158 <span 159 class="i-lucide:chevron-down w-3 h-3" 160 :class="[ 161 { 'rotate-180': isOpen }, 162 prefersReducedMotion ? '' : 'transition-transform duration-200', 163 ]" 164 aria-hidden="true" 165 /> 166 </ButtonBase> 167 168 <Teleport to="body"> 169 <Transition 170 :enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'" 171 :enter-from-class="prefersReducedMotion ? '' : 'opacity-0'" 172 enter-to-class="opacity-100" 173 :leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'" 174 leave-from-class="opacity-100" 175 :leave-to-class="prefersReducedMotion ? '' : 'opacity-0'" 176 > 177 <div 178 v-if="isOpen" 179 :id="listboxId" 180 ref="listRef" 181 role="listbox" 182 :aria-activedescendant=" 183 highlightedIndex >= 0 ? `${listboxId}-${toc[highlightedIndex]?.id}` : undefined 184 " 185 :aria-label="$t('package.readme.toc_title')" 186 :style="getDropdownStyle()" 187 class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 max-h-80 overflow-y-auto w-56 overscroll-contain" 188 > 189 <template v-for="node in tocTree" :key="node.id"> 190 <NuxtLink 191 :id="`${listboxId}-${node.id}`" 192 :to="`#${node.id}`" 193 role="option" 194 :aria-selected="activeId === node.id" 195 class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150" 196 :class="[ 197 activeId === node.id ? 'text-fg font-medium' : 'text-fg-muted', 198 highlightedIndex === getIndex(node.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated', 199 ]" 200 dir="auto" 201 @click="select()" 202 @mouseenter="highlightedIndex = getIndex(node.id)" 203 > 204 <span class="truncate">{{ node.text }}</span> 205 </NuxtLink> 206 207 <template v-for="child in node.children" :key="child.id"> 208 <NuxtLink 209 :id="`${listboxId}-${child.id}`" 210 :to="`#${child.id}`" 211 role="option" 212 :aria-selected="activeId === child.id" 213 class="flex items-center gap-2 px-3 py-1.5 ps-6 text-sm cursor-pointer transition-colors duration-150" 214 :class="[ 215 activeId === child.id ? 'text-fg font-medium' : 'text-fg-subtle', 216 highlightedIndex === getIndex(child.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated', 217 ]" 218 dir="auto" 219 @click="select()" 220 @mouseenter="highlightedIndex = getIndex(child.id)" 221 > 222 <span class="truncate">{{ child.text }}</span> 223 </NuxtLink> 224 225 <NuxtLink 226 v-for="grandchild in child.children" 227 :id="`${listboxId}-${grandchild.id}`" 228 :to="`#${grandchild.id}`" 229 :key="grandchild.id" 230 role="option" 231 :aria-selected="activeId === grandchild.id" 232 class="flex items-center gap-2 px-3 py-1.5 ps-9 text-sm cursor-pointer transition-colors duration-150" 233 :class="[ 234 activeId === grandchild.id ? 'text-fg font-medium' : 'text-fg-subtle', 235 highlightedIndex === getIndex(grandchild.id) 236 ? 'bg-bg-elevated' 237 : 'hover:bg-bg-elevated', 238 ]" 239 dir="auto" 240 @click="select()" 241 @mouseenter="highlightedIndex = getIndex(grandchild.id)" 242 > 243 <span class="truncate">{{ grandchild.text }}</span> 244 </NuxtLink> 245 </template> 246 </template> 247 </div> 248 </Transition> 249 </Teleport> 250</template>