[READ-ONLY] a fast, modern browser for the npm registry

feat: use native scroll anchoring (#1209)

authored by

Thomas Deinhamer and committed by
GitHub
0c035c8b 80e8db3e

+43 -176
+1 -1
app/assets/main.css
··· 192 192 */ 193 193 194 194 html { 195 + @apply scroll-pt-20; 195 196 -webkit-font-smoothing: antialiased; 196 197 -moz-osx-font-smoothing: grayscale; 197 198 text-rendering: optimizeLegibility; 198 199 /* Offset for fixed header - otherwise anchor headers are cutted */ 199 - scroll-padding-top: 5rem; 200 200 scrollbar-gutter: stable; 201 201 } 202 202
+4 -13
app/components/CollapsibleSection.vue
··· 1 1 <script setup lang="ts"> 2 2 import { shallowRef, computed } from 'vue' 3 + import { LinkBase } from '#components' 3 4 4 5 interface Props { 5 6 title: string ··· 18 19 19 20 const buttonId = `${props.id}-collapsible-button` 20 21 const contentId = `${props.id}-collapsible-content` 21 - const headingId = `${props.id}-heading` 22 22 23 23 const isOpen = shallowRef(true) 24 24 ··· 75 75 </script> 76 76 77 77 <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"> 79 79 <div class="flex items-center justify-between mb-3 px-1"> 80 80 <component 81 81 :is="headingLevel" 82 - :id="headingId" 83 82 class="group text-xs text-fg-subtle uppercase tracking-wider flex items-center gap-2" 84 83 > 85 84 <button ··· 104 103 /> 105 104 </button> 106 105 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=""> 112 107 {{ 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> 118 109 </component> 119 110 120 111 <!-- Actions slot for buttons or other elements -->
+2 -2
app/components/Link/Base.vue
··· 73 73 /></span> 74 74 <NuxtLink 75 75 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" 77 77 :class="{ 78 78 'underline-offset-[0.2rem] underline decoration-1 decoration-fg/30': !isLinkAnchor && isLink, 79 79 'font-mono text-fg hover:(decoration-accent text-accent) focus-visible:(decoration-accent text-accent) transition-colors duration-200': ··· 103 103 /> 104 104 <span 105 105 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" 107 107 aria-hidden="true" 108 108 /> 109 109 <kbd
+2 -9
app/components/PackageProvenanceSection.vue
··· 9 9 <template> 10 10 <section id="provenance" aria-labelledby="provenance-heading" class="scroll-mt-20"> 11 11 <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"> 16 13 {{ $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> 22 15 </h2> 23 16 24 17 <div class="space-y-3 border border-border rounded-lg p-4 sm:p-5">
+8 -1
app/components/Readme.vue
··· 47 47 const href = anchor.getAttribute('href') 48 48 if (!href) return 49 49 50 + // Handle relative anchor links 51 + if (href.startsWith('#')) { 52 + event.preventDefault() 53 + router.push(href) 54 + return 55 + } 56 + 50 57 const match = href.match(/^(?:https?:\/\/)?(?:www\.)?npmjs\.(?:com|org)(\/.+)$/) 51 58 if (!match || !match[1]) return 52 59 ··· 95 102 .readme :deep(h4), 96 103 .readme :deep(h5), 97 104 .readme :deep(h6) { 105 + @apply font-mono scroll-mt-20; 98 106 color: var(--fg); 99 - @apply font-mono; 100 107 font-weight: 500; 101 108 margin-top: 1rem; 102 109 margin-bottom: 1rem;
+14 -14
app/components/ReadmeTocDropdown.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { TocItem } from '#shared/types/readme' 3 3 import { onClickOutside, useEventListener } from '@vueuse/core' 4 - import { scrollToAnchor } from '~/utils/scrollToAnchor' 5 4 6 5 const props = defineProps<{ 7 6 toc: TocItem[] 8 7 activeId?: string | null 9 - scrollToHeading?: (id: string) => void 10 8 }>() 11 9 12 10 interface TocNode extends TocItem { ··· 98 96 highlightedIndex.value = -1 99 97 } 100 98 101 - function select(id: string) { 102 - scrollToAnchor(id, { scrollFn: props.scrollToHeading }) 99 + function select() { 103 100 close() 104 101 triggerRef.value?.focus() 105 102 } ··· 132 129 event.preventDefault() 133 130 const item = props.toc[highlightedIndex.value] 134 131 if (item) { 135 - select(item.id) 132 + select() 136 133 } 137 134 break 138 135 } ··· 189 186 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 187 > 191 188 <template v-for="node in tocTree" :key="node.id"> 192 - <div 189 + <NuxtLink 193 190 :id="`${listboxId}-${node.id}`" 191 + :to="`#${node.id}`" 194 192 role="option" 195 193 :aria-selected="activeId === node.id" 196 194 class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150" ··· 199 197 highlightedIndex === getIndex(node.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated', 200 198 ]" 201 199 dir="auto" 202 - @click="select(node.id)" 200 + @click="select()" 203 201 @mouseenter="highlightedIndex = getIndex(node.id)" 204 202 > 205 203 <span class="truncate">{{ node.text }}</span> 206 - </div> 204 + </NuxtLink> 207 205 208 206 <template v-for="child in node.children" :key="child.id"> 209 - <div 207 + <NuxtLink 210 208 :id="`${listboxId}-${child.id}`" 209 + :to="`#${child.id}`" 211 210 role="option" 212 211 :aria-selected="activeId === child.id" 213 212 class="flex items-center gap-2 px-3 py-1.5 ps-6 text-sm cursor-pointer transition-colors duration-150" ··· 216 215 highlightedIndex === getIndex(child.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated', 217 216 ]" 218 217 dir="auto" 219 - @click="select(child.id)" 218 + @click="select()" 220 219 @mouseenter="highlightedIndex = getIndex(child.id)" 221 220 > 222 221 <span class="truncate">{{ child.text }}</span> 223 - </div> 222 + </NuxtLink> 224 223 225 - <div 224 + <NuxtLink 226 225 v-for="grandchild in child.children" 227 226 :id="`${listboxId}-${grandchild.id}`" 227 + :to="`#${grandchild.id}`" 228 228 :key="grandchild.id" 229 229 role="option" 230 230 :aria-selected="activeId === grandchild.id" ··· 236 236 : 'hover:bg-bg-elevated', 237 237 ]" 238 238 dir="auto" 239 - @click="select(grandchild.id)" 239 + @click="select()" 240 240 @mouseenter="highlightedIndex = getIndex(grandchild.id)" 241 241 > 242 242 <span class="truncate">{{ grandchild.text }}</span> 243 - </div> 243 + </NuxtLink> 244 244 </template> 245 245 </template> 246 246 </div>
+3 -87
app/composables/useActiveTocItem.ts
··· 1 1 import type { TocItem } from '#shared/types/readme' 2 2 import type { Ref } from 'vue' 3 - import { scrollToAnchor } from '~/utils/scrollToAnchor' 4 3 5 4 /** 6 5 * Composable for tracking the currently visible heading in a TOC. 7 6 * Uses IntersectionObserver to detect which heading is at the top of the viewport. 8 7 * 9 8 * @param toc - Reactive array of TOC items 10 - * @returns Object containing activeId and scrollToHeading function 9 + * @returns Object containing activeId 11 10 * @public 12 11 */ 13 12 export function useActiveTocItem(toc: Ref<TocItem[]>) { ··· 16 15 // Only run observer logic on client 17 16 if (import.meta.server) { 18 17 // eslint-disable-next-line @typescript-eslint/no-empty-function 19 - return { activeId, scrollToHeading: (_id: string) => {} } 18 + return { activeId } 20 19 } 21 20 22 21 let observer: IntersectionObserver | null = null 23 22 const headingElements = new Map<string, Element>() 24 - let scrollCleanup: (() => void) | null = null 25 23 26 24 const setupObserver = () => { 27 25 // Clean up previous observer ··· 92 90 } 93 91 } 94 92 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 - 173 93 // Set up observer when TOC changes 174 94 watch( 175 95 toc, ··· 182 102 183 103 // Clean up on unmount 184 104 onUnmounted(() => { 185 - if (scrollCleanup) { 186 - scrollCleanup() 187 - scrollCleanup = null 188 - } 189 105 if (observer) { 190 106 observer.disconnect() 191 107 observer = null 192 108 } 193 109 }) 194 110 195 - return { activeId, scrollToHeading } 111 + return { activeId } 196 112 }
+1 -2
app/pages/package/[[org]]/[name].vue
··· 72 72 73 73 // Track active TOC item based on scroll position 74 74 const tocItems = computed(() => readmeData.value?.toc ?? []) 75 - const { activeId: activeTocId, scrollToHeading } = useActiveTocItem(tocItems) 75 + const { activeId: activeTocId } = useActiveTocItem(tocItems) 76 76 77 77 // Check if package exists on JSR (only for scoped packages) 78 78 const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, { ··· 1079 1079 v-if="readmeData?.toc && readmeData.toc.length > 1" 1080 1080 :toc="readmeData.toc" 1081 1081 :active-id="activeTocId" 1082 - :scroll-to-heading="scrollToHeading" 1083 1082 /> 1084 1083 </div> 1085 1084 </ClientOnly>
-45
app/utils/scrollToAnchor.ts
··· 1 - export interface ScrollToAnchorOptions { 2 - /** Custom scroll function (e.g., from useActiveTocItem) */ 3 - scrollFn?: (id: string) => void 4 - /** Whether to update the URL hash (default: true) */ 5 - updateUrl?: boolean 6 - } 7 - 8 - /** 9 - * Scroll to an element by ID, using a custom scroll function if provided, 10 - * otherwise falling back to default scroll behavior with header offset. 11 - * 12 - * @param id - The element ID to scroll to 13 - * @param options - Optional configuration for scroll behavior 14 - */ 15 - export function scrollToAnchor(id: string, options?: ScrollToAnchorOptions): void { 16 - const { scrollFn, updateUrl = true } = options ?? {} 17 - 18 - // Use custom scroll function if provided 19 - if (scrollFn) { 20 - scrollFn(id) 21 - return 22 - } 23 - 24 - // Fallback: scroll with header offset 25 - const element = document.getElementById(id) 26 - if (!element) return 27 - 28 - // Calculate scroll position with header offset (matches scroll-padding-top in main.css) 29 - const HEADER_OFFSET = 80 30 - const PKG_STICKY_HEADER_OFFSET = 52 31 - const elementTop = element.getBoundingClientRect().top + window.scrollY 32 - const targetScrollY = elementTop - (HEADER_OFFSET + PKG_STICKY_HEADER_OFFSET) 33 - 34 - // Use scrollTo for precise control 35 - window.scrollTo({ 36 - top: targetScrollY, 37 - behavior: 'smooth', 38 - }) 39 - 40 - // Update URL hash after initiating scroll 41 - // Use replaceState to avoid triggering native scroll-to-anchor behavior 42 - if (updateUrl) { 43 - history.replaceState(null, '', `#${id}`) 44 - } 45 - }
+6
nuxt.config.ts
··· 79 79 description: 'A fast, modern browser for the npm registry', 80 80 }, 81 81 82 + router: { 83 + options: { 84 + scrollBehaviorType: 'smooth', 85 + }, 86 + }, 87 + 82 88 routeRules: { 83 89 // API routes 84 90 '/api/**': { isr: 60 },
+2 -2
test/nuxt/components/PackageVersions.spec.ts
··· 33 33 }) 34 34 35 35 describe('basic rendering', () => { 36 - it('renders the Versions heading', async () => { 36 + it('renders the Versions section', async () => { 37 37 const component = await mountSuspended(PackageVersions, { 38 38 props: { 39 39 packageName: 'test-package', ··· 45 45 }, 46 46 }) 47 47 48 - expect(component.find('#versions-heading').text()).toBe('Versions') 48 + expect(component.find('#versions').exists()).toBe(true) 49 49 }) 50 50 51 51 it('does not render when there are no dist-tags', async () => {