forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>