Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,11 @@
*/

html {
@apply scroll-pt-20;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* Offset for fixed header - otherwise anchor headers are cutted */
scroll-padding-top: 5rem;
scrollbar-gutter: stable;
}

Expand Down
17 changes: 4 additions & 13 deletions app/components/CollapsibleSection.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { shallowRef, computed } from 'vue'
import { LinkBase } from '#components'

interface Props {
title: string
Expand All @@ -18,7 +19,6 @@ const appSettings = useSettings()

const buttonId = `${props.id}-collapsible-button`
const contentId = `${props.id}-collapsible-content`
const headingId = `${props.id}-heading`

const isOpen = shallowRef(true)

Expand Down Expand Up @@ -75,11 +75,10 @@ useHead({
</script>

<template>
<section class="scroll-mt-20" :data-anchor-id="id">
<section :id="id" :data-anchor-id="id" class="scroll-mt-20 xl:scroll-mt-0">
<div class="flex items-center justify-between mb-3 px-1">
<component
:is="headingLevel"
:id="headingId"
class="group text-xs text-fg-subtle uppercase tracking-wider flex items-center gap-2"
>
<button
Expand All @@ -104,17 +103,9 @@ useHead({
/>
</button>

<a
:href="`#${id}`"
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
>
<span v-if="icon" :class="icon" aria-hidden="true" />
<LinkBase :to="`#${id}`" class="">
{{ title }}
<span
class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
aria-hidden="true"
/>
</a>
</LinkBase>
</component>

<!-- Actions slot for buttons or other elements -->
Expand Down
4 changes: 2 additions & 2 deletions app/components/Link/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && props.variant !
/></span>
<NuxtLink
v-else
class="group inline-flex gap-x-1 items-center justify-center"
class="group/link inline-flex gap-x-1 items-center justify-center"
:class="{
'underline-offset-[0.2rem] underline decoration-1 decoration-fg/30': !isLinkAnchor && isLink,
'font-mono text-fg hover:(decoration-accent text-accent) focus-visible:(decoration-accent text-accent) transition-colors duration-200':
Expand Down Expand Up @@ -103,7 +103,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && props.variant !
/>
<span
v-else-if="isLinkAnchor && isLink"
class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
class="i-carbon:link w-3 h-3 opacity-0 group-hover/link:opacity-100 transition-opacity duration-200"
aria-hidden="true"
/>
<kbd
Expand Down
11 changes: 2 additions & 9 deletions app/components/PackageProvenanceSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,9 @@ defineProps<{
<template>
<section id="provenance" aria-labelledby="provenance-heading" class="scroll-mt-20">
<h2 id="provenance-heading" class="group text-xs text-fg-subtle uppercase tracking-wider mb-3">
<a
href="#provenance"
class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline"
>
<LinkBase to="#provenance">
{{ $t('package.provenance_section.title') }}
<span
class="i-carbon-link w-3 h-3 block opacity-0 group-hover:opacity-100 transition-opacity duration-200"
aria-hidden="true"
/>
</a>
</LinkBase>
</h2>

<div class="space-y-3 border border-border rounded-lg p-4 sm:p-5">
Expand Down
9 changes: 8 additions & 1 deletion app/components/Readme.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ function handleClick(event: MouseEvent) {
const href = anchor.getAttribute('href')
if (!href) return

// Handle relative anchor links
if (href.startsWith('#')) {
event.preventDefault()
router.push(href)
return
}

const match = href.match(/^(?:https?:\/\/)?(?:www\.)?npmjs\.(?:com|org)(\/.+)$/)
if (!match || !match[1]) return

Expand Down Expand Up @@ -95,8 +102,8 @@ function handleClick(event: MouseEvent) {
.readme :deep(h4),
.readme :deep(h5),
.readme :deep(h6) {
@apply font-mono scroll-mt-20;
color: var(--fg);
@apply font-mono;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 1rem;
Expand Down
28 changes: 14 additions & 14 deletions app/components/ReadmeTocDropdown.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
<script setup lang="ts">
import type { TocItem } from '#shared/types/readme'
import { onClickOutside, useEventListener } from '@vueuse/core'
import { scrollToAnchor } from '~/utils/scrollToAnchor'

const props = defineProps<{
toc: TocItem[]
activeId?: string | null
scrollToHeading?: (id: string) => void
}>()

interface TocNode extends TocItem {
Expand Down Expand Up @@ -98,8 +96,7 @@ function close() {
highlightedIndex.value = -1
}

function select(id: string) {
scrollToAnchor(id, { scrollFn: props.scrollToHeading })
function select() {
close()
triggerRef.value?.focus()
}
Expand Down Expand Up @@ -132,7 +129,7 @@ function handleKeydown(event: KeyboardEvent) {
event.preventDefault()
const item = props.toc[highlightedIndex.value]
if (item) {
select(item.id)
select()
}
break
}
Expand Down Expand Up @@ -189,8 +186,9 @@ function handleKeydown(event: KeyboardEvent) {
class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 max-h-80 overflow-y-auto w-56 overscroll-contain"
>
<template v-for="node in tocTree" :key="node.id">
<div
<NuxtLink
:id="`${listboxId}-${node.id}`"
:to="`#${node.id}`"
role="option"
:aria-selected="activeId === node.id"
class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150"
Expand All @@ -199,15 +197,16 @@ function handleKeydown(event: KeyboardEvent) {
highlightedIndex === getIndex(node.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated',
]"
dir="auto"
@click="select(node.id)"
@click="select()"
@mouseenter="highlightedIndex = getIndex(node.id)"
>
<span class="truncate">{{ node.text }}</span>
</div>
</NuxtLink>

<template v-for="child in node.children" :key="child.id">
<div
<NuxtLink
:id="`${listboxId}-${child.id}`"
:to="`#${child.id}`"
role="option"
:aria-selected="activeId === child.id"
class="flex items-center gap-2 px-3 py-1.5 ps-6 text-sm cursor-pointer transition-colors duration-150"
Expand All @@ -216,15 +215,16 @@ function handleKeydown(event: KeyboardEvent) {
highlightedIndex === getIndex(child.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated',
]"
dir="auto"
@click="select(child.id)"
@click="select()"
@mouseenter="highlightedIndex = getIndex(child.id)"
>
<span class="truncate">{{ child.text }}</span>
</div>
</NuxtLink>

<div
<NuxtLink
v-for="grandchild in child.children"
:id="`${listboxId}-${grandchild.id}`"
:to="`#${grandchild.id}`"
:key="grandchild.id"
role="option"
:aria-selected="activeId === grandchild.id"
Expand All @@ -236,11 +236,11 @@ function handleKeydown(event: KeyboardEvent) {
: 'hover:bg-bg-elevated',
]"
dir="auto"
@click="select(grandchild.id)"
@click="select()"
@mouseenter="highlightedIndex = getIndex(grandchild.id)"
>
<span class="truncate">{{ grandchild.text }}</span>
</div>
</NuxtLink>
</template>
</template>
</div>
Expand Down
90 changes: 3 additions & 87 deletions app/composables/useActiveTocItem.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { TocItem } from '#shared/types/readme'
import type { Ref } from 'vue'
import { scrollToAnchor } from '~/utils/scrollToAnchor'

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

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

const setupObserver = () => {
// Clean up previous observer
Expand Down Expand Up @@ -92,84 +90,6 @@ export function useActiveTocItem(toc: Ref<TocItem[]>) {
}
}

// Scroll to a heading with observer disconnection during scroll
const scrollToHeading = (id: string) => {
if (!document.getElementById(id)) return

// Clean up any previous scroll monitoring
if (scrollCleanup) {
scrollCleanup()
scrollCleanup = null
}

// Immediately set activeId
activeId.value = id

// Disconnect observer to prevent interference during scroll
if (observer) {
observer.disconnect()
}

// Scroll, but do not update url until scroll ends
scrollToAnchor(id, { updateUrl: false })

const handleScrollEnd = () => {
history.replaceState(null, '', `#${id}`)
setupObserver()
scrollCleanup = null
}

// Check for scrollend support (Chrome 114+, Firefox 109+, Safari 18+)
const supportsScrollEnd = 'onscrollend' in window

if (supportsScrollEnd) {
window.addEventListener('scrollend', handleScrollEnd, { once: true })
scrollCleanup = () => window.removeEventListener('scrollend', handleScrollEnd)
} else {
// Fallback: use RAF polling for older browsers
let lastScrollY = window.scrollY
let stableFrames = 0
let rafId: number | null = null
const STABLE_THRESHOLD = 5 // Number of frames with no movement to consider settled

const checkScrollSettled = () => {
const currentScrollY = window.scrollY

if (Math.abs(currentScrollY - lastScrollY) < 1) {
stableFrames++
if (stableFrames >= STABLE_THRESHOLD) {
handleScrollEnd()
return
}
} else {
stableFrames = 0
}

lastScrollY = currentScrollY
rafId = requestAnimationFrame(checkScrollSettled)
}

rafId = requestAnimationFrame(checkScrollSettled)

scrollCleanup = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
}

// Safety timeout - reconnect observer after max scroll time
setTimeout(() => {
if (scrollCleanup) {
scrollCleanup()
scrollCleanup = null
history.replaceState(null, '', `#${id}`)
setupObserver()
}
}, 1000)
}

// Set up observer when TOC changes
watch(
toc,
Expand All @@ -182,15 +102,11 @@ export function useActiveTocItem(toc: Ref<TocItem[]>) {

// Clean up on unmount
onUnmounted(() => {
if (scrollCleanup) {
scrollCleanup()
scrollCleanup = null
}
if (observer) {
observer.disconnect()
observer = null
}
})

return { activeId, scrollToHeading }
return { activeId }
}
3 changes: 1 addition & 2 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const { copied: copiedReadme, copy: copyReadme } = useClipboard({

// Track active TOC item based on scroll position
const tocItems = computed(() => readmeData.value?.toc ?? [])
const { activeId: activeTocId, scrollToHeading } = useActiveTocItem(tocItems)
const { activeId: activeTocId } = useActiveTocItem(tocItems)

// Check if package exists on JSR (only for scoped packages)
const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, {
Expand Down Expand Up @@ -1079,7 +1079,6 @@ onKeyStroke(
v-if="readmeData?.toc && readmeData.toc.length > 1"
:toc="readmeData.toc"
:active-id="activeTocId"
:scroll-to-heading="scrollToHeading"
/>
</div>
</ClientOnly>
Expand Down
Loading
Loading