Skip to content

Commit bd34920

Browse files
committed
feat: add toc to readme
1 parent 1975cb7 commit bd34920

File tree

9 files changed

+694
-17
lines changed

9 files changed

+694
-17
lines changed

app/components/ReadmeToc.vue

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<script setup lang="ts">
2+
import type { TocItem } from '#shared/types/readme'
3+
import { scrollToAnchor } from '~/utils/scrollToAnchor'
4+
5+
const props = defineProps<{
6+
toc: TocItem[]
7+
activeId?: string | null
8+
scrollToHeading?: (id: string) => void
9+
}>()
10+
11+
interface TocNode extends TocItem {
12+
children: TocNode[]
13+
}
14+
15+
function buildTocTree(items: TocItem[]): TocNode[] {
16+
const result: TocNode[] = []
17+
const stack: TocNode[] = []
18+
19+
for (const item of items) {
20+
const node: TocNode = { ...item, children: [] }
21+
22+
// Find parent: look for the last item with smaller depth
23+
while (stack.length > 0 && stack[stack.length - 1]!.depth >= item.depth) {
24+
stack.pop()
25+
}
26+
27+
if (stack.length === 0) {
28+
// Top-level item
29+
result.push(node)
30+
} else {
31+
// Child of the last item in stack
32+
stack[stack.length - 1]!.children.push(node)
33+
}
34+
35+
stack.push(node)
36+
}
37+
38+
return result
39+
}
40+
41+
const tocTree = computed(() => buildTocTree(props.toc))
42+
43+
function handleClick(event: MouseEvent, id: string) {
44+
event.preventDefault()
45+
scrollToAnchor(id, props.scrollToHeading)
46+
}
47+
</script>
48+
49+
<template>
50+
<nav v-if="toc.length > 0" class="readme-toc" aria-labelledby="toc-heading">
51+
<h3 id="toc-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
52+
{{ $t('package.readme.toc_title') }}
53+
</h3>
54+
<ul class="toc-list">
55+
<template v-for="node in tocTree" :key="node.id">
56+
<li class="toc-item">
57+
<a
58+
:href="`#${node.id}`"
59+
class="toc-link"
60+
:class="{ 'toc-link--active': activeId === node.id }"
61+
@click="handleClick($event, node.id)"
62+
>
63+
{{ node.text }}
64+
</a>
65+
<!-- Nested children -->
66+
<ul v-if="node.children.length > 0" class="toc-list toc-list--nested">
67+
<template v-for="child in node.children" :key="child.id">
68+
<li class="toc-item">
69+
<a
70+
:href="`#${child.id}`"
71+
class="toc-link"
72+
:class="{
73+
'toc-link--active': activeId === child.id,
74+
}"
75+
@click="handleClick($event, child.id)"
76+
>
77+
{{ child.text }}
78+
</a>
79+
<!-- Support for 3rd level nesting -->
80+
<ul v-if="child.children.length > 0" class="toc-list toc-list--nested">
81+
<li v-for="grandchild in child.children" :key="grandchild.id" class="toc-item">
82+
<a
83+
:href="`#${grandchild.id}`"
84+
class="toc-link"
85+
:class="{
86+
'toc-link--active': activeId === grandchild.id,
87+
}"
88+
@click="handleClick($event, grandchild.id)"
89+
>
90+
{{ grandchild.text }}
91+
</a>
92+
</li>
93+
</ul>
94+
</li>
95+
</template>
96+
</ul>
97+
</li>
98+
</template>
99+
</ul>
100+
</nav>
101+
</template>
102+
103+
<style scoped>
104+
.readme-toc {
105+
max-height: calc(100vh - 12rem);
106+
overflow-y: auto;
107+
scrollbar-width: thin;
108+
}
109+
110+
.toc-list {
111+
list-style: none;
112+
margin: 0;
113+
padding: 0;
114+
}
115+
116+
.toc-list--nested {
117+
padding-inline-start: 0.75rem;
118+
margin-block-start: 0.25rem;
119+
border-inline-start: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.1));
120+
}
121+
122+
.toc-item {
123+
margin: 0;
124+
}
125+
126+
.toc-link {
127+
display: block;
128+
padding: 0.25rem 0;
129+
font-size: 0.8125rem;
130+
line-height: 1.4;
131+
color: var(--color-fg-muted);
132+
text-decoration: none;
133+
transition: color 0.15s ease;
134+
overflow: hidden;
135+
text-overflow: ellipsis;
136+
white-space: nowrap;
137+
}
138+
139+
.toc-link:hover {
140+
color: var(--color-fg);
141+
}
142+
143+
.toc-link--active {
144+
color: var(--color-fg);
145+
font-weight: 500;
146+
}
147+
</style>
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, 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)