Skip to content

Commit 717de43

Browse files
Merge branch 'main' into fix/mobile-menu-improvement
2 parents e2821f6 + 920669d commit 717de43

14 files changed

Lines changed: 765 additions & 26 deletions

File tree

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

0 commit comments

Comments
 (0)