Skip to content

Commit 0c035c8

Browse files
authored
feat: use native scroll anchoring (#1209)
1 parent 80e8db3 commit 0c035c8

File tree

11 files changed

+43
-176
lines changed

11 files changed

+43
-176
lines changed

app/assets/main.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,11 @@
192192
*/
193193

194194
html {
195+
@apply scroll-pt-20;
195196
-webkit-font-smoothing: antialiased;
196197
-moz-osx-font-smoothing: grayscale;
197198
text-rendering: optimizeLegibility;
198199
/* Offset for fixed header - otherwise anchor headers are cutted */
199-
scroll-padding-top: 5rem;
200200
scrollbar-gutter: stable;
201201
}
202202

app/components/CollapsibleSection.vue

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { shallowRef, computed } from 'vue'
3+
import { LinkBase } from '#components'
34
45
interface Props {
56
title: string
@@ -18,7 +19,6 @@ const appSettings = useSettings()
1819
1920
const buttonId = `${props.id}-collapsible-button`
2021
const contentId = `${props.id}-collapsible-content`
21-
const headingId = `${props.id}-heading`
2222
2323
const isOpen = shallowRef(true)
2424
@@ -75,11 +75,10 @@ useHead({
7575
</script>
7676

7777
<template>
78-
<section class="scroll-mt-20" :data-anchor-id="id">
78+
<section :id="id" :data-anchor-id="id" class="scroll-mt-20 xl:scroll-mt-0">
7979
<div class="flex items-center justify-between mb-3 px-1">
8080
<component
8181
:is="headingLevel"
82-
:id="headingId"
8382
class="group text-xs text-fg-subtle uppercase tracking-wider flex items-center gap-2"
8483
>
8584
<button
@@ -104,17 +103,9 @@ useHead({
104103
/>
105104
</button>
106105

107-
<a
108-
:href="`#${id}`"
109-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
110-
>
111-
<span v-if="icon" :class="icon" aria-hidden="true" />
106+
<LinkBase :to="`#${id}`" class="">
112107
{{ title }}
113-
<span
114-
class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
115-
aria-hidden="true"
116-
/>
117-
</a>
108+
</LinkBase>
118109
</component>
119110

120111
<!-- Actions slot for buttons or other elements -->

app/components/Link/Base.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && props.variant !
7373
/></span>
7474
<NuxtLink
7575
v-else
76-
class="group inline-flex gap-x-1 items-center justify-center"
76+
class="group/link inline-flex gap-x-1 items-center justify-center"
7777
:class="{
7878
'underline-offset-[0.2rem] underline decoration-1 decoration-fg/30': !isLinkAnchor && isLink,
7979
'font-mono text-fg hover:(decoration-accent text-accent) focus-visible:(decoration-accent text-accent) transition-colors duration-200':
@@ -103,7 +103,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && props.variant !
103103
/>
104104
<span
105105
v-else-if="isLinkAnchor && isLink"
106-
class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
106+
class="i-carbon:link w-3 h-3 opacity-0 group-hover/link:opacity-100 transition-opacity duration-200"
107107
aria-hidden="true"
108108
/>
109109
<kbd

app/components/PackageProvenanceSection.vue

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,9 @@ defineProps<{
99
<template>
1010
<section id="provenance" aria-labelledby="provenance-heading" class="scroll-mt-20">
1111
<h2 id="provenance-heading" class="group text-xs text-fg-subtle uppercase tracking-wider mb-3">
12-
<a
13-
href="#provenance"
14-
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
15-
>
12+
<LinkBase to="#provenance">
1613
{{ $t('package.provenance_section.title') }}
17-
<span
18-
class="i-carbon-link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
19-
aria-hidden="true"
20-
/>
21-
</a>
14+
</LinkBase>
2215
</h2>
2316

2417
<div class="space-y-3 border border-border rounded-lg p-4 sm:p-5">

app/components/Readme.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ function handleClick(event: MouseEvent) {
4747
const href = anchor.getAttribute('href')
4848
if (!href) return
4949
50+
// Handle relative anchor links
51+
if (href.startsWith('#')) {
52+
event.preventDefault()
53+
router.push(href)
54+
return
55+
}
56+
5057
const match = href.match(/^(?:https?:\/\/)?(?:www\.)?npmjs\.(?:com|org)(\/.+)$/)
5158
if (!match || !match[1]) return
5259
@@ -95,8 +102,8 @@ function handleClick(event: MouseEvent) {
95102
.readme :deep(h4),
96103
.readme :deep(h5),
97104
.readme :deep(h6) {
105+
@apply font-mono scroll-mt-20;
98106
color: var(--fg);
99-
@apply font-mono;
100107
font-weight: 500;
101108
margin-top: 1rem;
102109
margin-bottom: 1rem;

app/components/ReadmeTocDropdown.vue

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
<script setup lang="ts">
22
import type { TocItem } from '#shared/types/readme'
33
import { onClickOutside, useEventListener } from '@vueuse/core'
4-
import { scrollToAnchor } from '~/utils/scrollToAnchor'
54
65
const props = defineProps<{
76
toc: TocItem[]
87
activeId?: string | null
9-
scrollToHeading?: (id: string) => void
108
}>()
119
1210
interface TocNode extends TocItem {
@@ -98,8 +96,7 @@ function close() {
9896
highlightedIndex.value = -1
9997
}
10098
101-
function select(id: string) {
102-
scrollToAnchor(id, { scrollFn: props.scrollToHeading })
99+
function select() {
103100
close()
104101
triggerRef.value?.focus()
105102
}
@@ -132,7 +129,7 @@ function handleKeydown(event: KeyboardEvent) {
132129
event.preventDefault()
133130
const item = props.toc[highlightedIndex.value]
134131
if (item) {
135-
select(item.id)
132+
select()
136133
}
137134
break
138135
}
@@ -189,8 +186,9 @@ function handleKeydown(event: KeyboardEvent) {
189186
class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 max-h-80 overflow-y-auto w-56 overscroll-contain"
190187
>
191188
<template v-for="node in tocTree" :key="node.id">
192-
<div
189+
<NuxtLink
193190
:id="`${listboxId}-${node.id}`"
191+
:to="`#${node.id}`"
194192
role="option"
195193
:aria-selected="activeId === node.id"
196194
class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150"
@@ -199,15 +197,16 @@ function handleKeydown(event: KeyboardEvent) {
199197
highlightedIndex === getIndex(node.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated',
200198
]"
201199
dir="auto"
202-
@click="select(node.id)"
200+
@click="select()"
203201
@mouseenter="highlightedIndex = getIndex(node.id)"
204202
>
205203
<span class="truncate">{{ node.text }}</span>
206-
</div>
204+
</NuxtLink>
207205

208206
<template v-for="child in node.children" :key="child.id">
209-
<div
207+
<NuxtLink
210208
:id="`${listboxId}-${child.id}`"
209+
:to="`#${child.id}`"
211210
role="option"
212211
:aria-selected="activeId === child.id"
213212
class="flex items-center gap-2 px-3 py-1.5 ps-6 text-sm cursor-pointer transition-colors duration-150"
@@ -216,15 +215,16 @@ function handleKeydown(event: KeyboardEvent) {
216215
highlightedIndex === getIndex(child.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated',
217216
]"
218217
dir="auto"
219-
@click="select(child.id)"
218+
@click="select()"
220219
@mouseenter="highlightedIndex = getIndex(child.id)"
221220
>
222221
<span class="truncate">{{ child.text }}</span>
223-
</div>
222+
</NuxtLink>
224223

225-
<div
224+
<NuxtLink
226225
v-for="grandchild in child.children"
227226
:id="`${listboxId}-${grandchild.id}`"
227+
:to="`#${grandchild.id}`"
228228
:key="grandchild.id"
229229
role="option"
230230
:aria-selected="activeId === grandchild.id"
@@ -236,11 +236,11 @@ function handleKeydown(event: KeyboardEvent) {
236236
: 'hover:bg-bg-elevated',
237237
]"
238238
dir="auto"
239-
@click="select(grandchild.id)"
239+
@click="select()"
240240
@mouseenter="highlightedIndex = getIndex(grandchild.id)"
241241
>
242242
<span class="truncate">{{ grandchild.text }}</span>
243-
</div>
243+
</NuxtLink>
244244
</template>
245245
</template>
246246
</div>
Lines changed: 3 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import type { TocItem } from '#shared/types/readme'
22
import type { Ref } from 'vue'
3-
import { scrollToAnchor } from '~/utils/scrollToAnchor'
43

54
/**
65
* Composable for tracking the currently visible heading in a TOC.
76
* Uses IntersectionObserver to detect which heading is at the top of the viewport.
87
*
98
* @param toc - Reactive array of TOC items
10-
* @returns Object containing activeId and scrollToHeading function
9+
* @returns Object containing activeId
1110
* @public
1211
*/
1312
export function useActiveTocItem(toc: Ref<TocItem[]>) {
@@ -16,12 +15,11 @@ export function useActiveTocItem(toc: Ref<TocItem[]>) {
1615
// Only run observer logic on client
1716
if (import.meta.server) {
1817
// eslint-disable-next-line @typescript-eslint/no-empty-function
19-
return { activeId, scrollToHeading: (_id: string) => {} }
18+
return { activeId }
2019
}
2120

2221
let observer: IntersectionObserver | null = null
2322
const headingElements = new Map<string, Element>()
24-
let scrollCleanup: (() => void) | null = null
2523

2624
const setupObserver = () => {
2725
// Clean up previous observer
@@ -92,84 +90,6 @@ export function useActiveTocItem(toc: Ref<TocItem[]>) {
9290
}
9391
}
9492

95-
// Scroll to a heading with observer disconnection during scroll
96-
const scrollToHeading = (id: string) => {
97-
if (!document.getElementById(id)) return
98-
99-
// Clean up any previous scroll monitoring
100-
if (scrollCleanup) {
101-
scrollCleanup()
102-
scrollCleanup = null
103-
}
104-
105-
// Immediately set activeId
106-
activeId.value = id
107-
108-
// Disconnect observer to prevent interference during scroll
109-
if (observer) {
110-
observer.disconnect()
111-
}
112-
113-
// Scroll, but do not update url until scroll ends
114-
scrollToAnchor(id, { updateUrl: false })
115-
116-
const handleScrollEnd = () => {
117-
history.replaceState(null, '', `#${id}`)
118-
setupObserver()
119-
scrollCleanup = null
120-
}
121-
122-
// Check for scrollend support (Chrome 114+, Firefox 109+, Safari 18+)
123-
const supportsScrollEnd = 'onscrollend' in window
124-
125-
if (supportsScrollEnd) {
126-
window.addEventListener('scrollend', handleScrollEnd, { once: true })
127-
scrollCleanup = () => window.removeEventListener('scrollend', handleScrollEnd)
128-
} else {
129-
// Fallback: use RAF polling for older browsers
130-
let lastScrollY = window.scrollY
131-
let stableFrames = 0
132-
let rafId: number | null = null
133-
const STABLE_THRESHOLD = 5 // Number of frames with no movement to consider settled
134-
135-
const checkScrollSettled = () => {
136-
const currentScrollY = window.scrollY
137-
138-
if (Math.abs(currentScrollY - lastScrollY) < 1) {
139-
stableFrames++
140-
if (stableFrames >= STABLE_THRESHOLD) {
141-
handleScrollEnd()
142-
return
143-
}
144-
} else {
145-
stableFrames = 0
146-
}
147-
148-
lastScrollY = currentScrollY
149-
rafId = requestAnimationFrame(checkScrollSettled)
150-
}
151-
152-
rafId = requestAnimationFrame(checkScrollSettled)
153-
154-
scrollCleanup = () => {
155-
if (rafId !== null) {
156-
cancelAnimationFrame(rafId)
157-
rafId = null
158-
}
159-
}
160-
}
161-
162-
// Safety timeout - reconnect observer after max scroll time
163-
setTimeout(() => {
164-
if (scrollCleanup) {
165-
scrollCleanup()
166-
scrollCleanup = null
167-
history.replaceState(null, '', `#${id}`)
168-
setupObserver()
169-
}
170-
}, 1000)
171-
}
172-
17393
// Set up observer when TOC changes
17494
watch(
17595
toc,
@@ -182,15 +102,11 @@ export function useActiveTocItem(toc: Ref<TocItem[]>) {
182102

183103
// Clean up on unmount
184104
onUnmounted(() => {
185-
if (scrollCleanup) {
186-
scrollCleanup()
187-
scrollCleanup = null
188-
}
189105
if (observer) {
190106
observer.disconnect()
191107
observer = null
192108
}
193109
})
194110

195-
return { activeId, scrollToHeading }
111+
return { activeId }
196112
}

app/pages/package/[[org]]/[name].vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const { copied: copiedReadme, copy: copyReadme } = useClipboard({
7272
7373
// Track active TOC item based on scroll position
7474
const tocItems = computed(() => readmeData.value?.toc ?? [])
75-
const { activeId: activeTocId, scrollToHeading } = useActiveTocItem(tocItems)
75+
const { activeId: activeTocId } = useActiveTocItem(tocItems)
7676
7777
// Check if package exists on JSR (only for scoped packages)
7878
const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, {
@@ -1079,7 +1079,6 @@ onKeyStroke(
10791079
v-if="readmeData?.toc && readmeData.toc.length > 1"
10801080
:toc="readmeData.toc"
10811081
:active-id="activeTocId"
1082-
:scroll-to-heading="scrollToHeading"
10831082
/>
10841083
</div>
10851084
</ClientOnly>

0 commit comments

Comments
 (0)