Skip to content
Open
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
44 changes: 18 additions & 26 deletions app/components/ScrollToTop.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,36 @@ const route = useRoute()

// Pages where scroll-to-top should NOT be shown
const excludedRoutes = new Set(['index', 'code'])
const isPackagePage = computed(() => route.path.includes('/package/'))

const isActive = computed(() => !excludedRoutes.has(route.name as string))
const isActive = computed(() => !excludedRoutes.has(route.name as string) && !isPackagePage.value)

const SCROLL_TO_TOP_DURATION = 500

const isMounted = useMounted()
const isVisible = shallowRef(false)
const scrollThreshold = 300
const { scrollToTop, isTouchDeviceClient } = useScrollToTop({ duration: SCROLL_TO_TOP_DURATION })

const { y: scrollTop } = useScroll(window)
const isVisible = computed(() => {
if (supportsScrollStateQueries.value) return false
return scrollTop.value > SCROLL_TO_TOP_THRESHOLD
})
const { isSupported: supportsScrollStateQueries } = useCssSupports(
'container-type',
'scroll-state',
{ ssrValue: false },
)

function onScroll() {
if (!supportsScrollStateQueries.value) {
return
}
isVisible.value = window.scrollY > scrollThreshold
}

function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}

useEventListener('scroll', onScroll, { passive: true })

onMounted(() => {
onScroll()
})
const shouldShowButton = computed(() => isActive.value && isTouchDeviceClient.value)
</script>

<template>
<!-- When CSS scroll-state is supported, use CSS-only visibility -->
<button
v-if="isActive && supportsScrollStateQueries"
v-if="shouldShowButton && supportsScrollStateQueries"
type="button"
class="scroll-to-top-css fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
class="scroll-to-top-css fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
:aria-label="$t('common.scroll_to_top')"
@click="scrollToTop"
@click="() => scrollToTop()"
>
<span class="i-lucide:arrow-up w-5 h-5" aria-hidden="true" />
</button>
Expand All @@ -56,11 +48,11 @@ onMounted(() => {
leave-to-class="opacity-0 translate-y-2"
>
<button
v-if="isActive && isMounted && isVisible"
v-if="shouldShowButton && isMounted && isVisible"
type="button"
class="fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
class="fixed bottom-4 inset-ie-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
:aria-label="$t('common.scroll_to_top')"
@click="scrollToTop"
@click="() => scrollToTop()"
>
<span class="i-lucide:arrow-up w-5 h-5" aria-hidden="true" />
</button>
Expand Down
101 changes: 101 additions & 0 deletions app/composables/useScrollToTop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
interface UseScrollToTopOptions {
/**
* Duration of the scroll animation in milliseconds.
*/
duration?: number
}

// Easing function for the scroll animation
const easeOutQuad = (t: number) => t * (2 - t)

export const SCROLL_TO_TOP_THRESHOLD = 300

/**
* Scroll to the top of the page with a smooth animation.
* @param options - Configuration options for the scroll animation.
* @returns An object containing the scrollToTop function and a cancel function.
*/
export function useScrollToTop(options: UseScrollToTopOptions) {
const { duration = 500 } = options

// Check if prefers-reduced-motion is enabled
const preferReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

/**
* Active requestAnimationFrame id for the current auto-scroll animation
*/
let rafId: number | null = null
const isScrolling = ref(false)

/**
* Stop any in-flight auto-scroll before starting a new one.
*/
function cancel() {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
isScrolling.value = false
}

// Cancel scroll on user interaction
const onInteraction = () => {
if (isScrolling.value) {
cancel()
}
}

if (import.meta.client) {
const listenerOptions = { passive: true }
useEventListener(window, 'wheel', onInteraction, listenerOptions)
useEventListener(window, 'touchstart', onInteraction, listenerOptions)
useEventListener(window, 'mousedown', onInteraction, listenerOptions)
}

function scrollToTop() {
cancel()

if (preferReducedMotion.value) {
window.scrollTo({ top: 0, behavior: 'instant' })
return
}

const start = window.scrollY
if (start <= 0) return

isScrolling.value = true

const startTime = performance.now()
const change = -start

// Start the frame-by-frame scroll animation.
function animate() {
const elapsed = performance.now() - startTime
const t = Math.min(elapsed / duration, 1)
const y = start + change * easeOutQuad(t)

window.scrollTo({ top: y })

if (t < 1 && isScrolling.value) {
rafId = requestAnimationFrame(animate)
} else {
cancel()
}
}

rafId = requestAnimationFrame(animate)
}

tryOnScopeDispose(cancel)

const isTouchDeviceClient = shallowRef(false)
onMounted(() => {
isTouchDeviceClient.value = isTouchDevice()
})

return {
scrollToTop,
cancel,
isTouchDeviceClient,
}
}
24 changes: 21 additions & 3 deletions app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,13 @@ const { copied: copiedVersion, copy: copyVersion } = useClipboard({
copiedDuring: 2000,
})

const { scrollToTop, isTouchDeviceClient } = useScrollToTop({ duration: 500 })

const { y: scrollY } = useScroll(window)
const showScrollToTop = computed(
() => isTouchDeviceClient.value && scrollY.value > SCROLL_TO_TOP_THRESHOLD,
)

// Fetch dependency analysis (lazy, client-side)
// This is the same composable used by PackageVulnerabilityTree and PackageDeprecatedTree
const { data: vulnTree, status: vulnTreeStatus } = useDependencyAnalysis(
Expand Down Expand Up @@ -786,26 +793,37 @@ const showSkeleton = shallowRef(false)
:to="docsLink"
aria-keyshortcuts="d"
classicon="i-lucide:file-text"
:title="$t('package.links.docs')"
>
{{ $t('package.links.docs') }}
<span class="max-sm:sr-only">{{ $t('package.links.docs') }}</span>
</LinkBase>
<LinkBase
v-if="codeLink"
variant="button-secondary"
:to="codeLink"
aria-keyshortcuts="."
classicon="i-lucide:code"
:title="$t('package.links.code')"
>
{{ $t('package.links.code') }}
<span class="max-sm:sr-only">{{ $t('package.links.code') }}</span>
</LinkBase>
<LinkBase
variant="button-secondary"
:to="{ name: 'compare', query: { packages: pkg.name } }"
aria-keyshortcuts="c"
classicon="i-lucide:git-compare"
:title="$t('package.links.compare')"
>
{{ $t('package.links.compare') }}
<span class="max-sm:sr-only">{{ $t('package.links.compare') }}</span>
</LinkBase>
<ButtonBase
v-if="showScrollToTop"
variant="secondary"
:title="$t('common.scroll_to_top')"
:aria-label="$t('common.scroll_to_top')"
@click="() => scrollToTop()"
classicon="i-lucide:arrow-up"
/>
</ButtonGroup>

<!-- Package metrics -->
Expand Down
Loading