forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { MaybeRefOrGetter, Ref } from 'vue'
2
3export interface WindowVirtualizerHandle {
4 readonly scrollOffset: number
5 readonly viewportSize: number
6 findItemIndex: (offset: number) => number
7 getItemOffset: (index: number) => number
8 scrollToIndex: (
9 index: number,
10 opts?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
11 ) => void
12}
13
14export interface UseVirtualInfiniteScrollOptions {
15 /** Reference to the WindowVirtualizer component */
16 listRef: Ref<WindowVirtualizerHandle | null>
17 /** Current item count */
18 itemCount: Ref<number>
19 /** Whether there are more items to load */
20 hasMore: Ref<boolean>
21 /** Whether currently loading */
22 isLoading: Ref<boolean>
23 /** Page size for calculating current page (reactive) */
24 pageSize: MaybeRefOrGetter<number>
25 /** Threshold in items before end to trigger load */
26 threshold?: number
27 /** Callback to load more items */
28 onLoadMore: () => void
29 /** Callback when visible page changes (for URL updates) */
30 onPageChange?: (page: number) => void
31}
32
33/**
34 * Composable for handling infinite scroll with virtua's WindowVirtualizer
35 * Detects when user scrolls near the end and triggers loading more items
36 * Also tracks current visible page for URL persistence
37 */
38export function useVirtualInfiniteScroll(options: UseVirtualInfiniteScrollOptions) {
39 const {
40 listRef,
41 itemCount,
42 hasMore,
43 isLoading,
44 pageSize,
45 threshold = 5,
46 onLoadMore,
47 onPageChange,
48 } = options
49
50 // Track last fetched count to prevent duplicate fetches
51 const fetchedCountRef = shallowRef(-1)
52
53 // Track current page to avoid unnecessary updates
54 const currentPage = shallowRef(1)
55
56 function handleScroll() {
57 const list = listRef.value
58 if (!list) return
59
60 // Calculate current visible page based on first visible item
61 const startIndex = list.findItemIndex(list.scrollOffset)
62 const currentPageSize = toValue(pageSize)
63 const newPage = Math.floor(startIndex / currentPageSize) + 1
64
65 if (newPage !== currentPage.value && onPageChange) {
66 currentPage.value = newPage
67 onPageChange(newPage)
68 }
69
70 // Don't fetch if already loading or no more items
71 if (isLoading.value || !hasMore.value) return
72
73 // Don't fetch if we already fetched at this count
74 const count = itemCount.value
75 if (fetchedCountRef.value >= count) return
76
77 // Check if we're near the end
78 const endOffset = list.scrollOffset + list.viewportSize
79 const endIndex = list.findItemIndex(endOffset)
80
81 if (endIndex + threshold >= count) {
82 fetchedCountRef.value = count
83 onLoadMore()
84 }
85 }
86
87 /**
88 * Scroll to a specific page (1-indexed)
89 * Call this after data is loaded to restore scroll position
90 */
91 function scrollToPage(page: number) {
92 const list = listRef.value
93 if (!list || page < 1) return
94
95 const targetIndex = (page - 1) * toValue(pageSize)
96 list.scrollToIndex(targetIndex, { align: 'start' })
97 currentPage.value = page
98 }
99
100 // Reset state when item count changes significantly (new search)
101 watch(itemCount, (newCount, oldCount) => {
102 // If count decreased or reset, clear the fetched tracking
103 if (newCount < oldCount || newCount === 0) {
104 fetchedCountRef.value = -1
105 currentPage.value = 1
106 }
107 })
108
109 return {
110 handleScroll,
111 scrollToPage,
112 currentPage,
113 }
114}