|
1 | 1 | <script setup lang="ts"> |
2 | 2 | import type { NpmSearchResult } from '#shared/types' |
| 3 | +import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll' |
| 4 | +import { WindowVirtualizer } from 'virtua/vue' |
3 | 5 |
|
4 | | -defineProps<{ |
| 6 | +const props = defineProps<{ |
5 | 7 | /** List of search results to display */ |
6 | 8 | results: NpmSearchResult[] |
7 | 9 | /** Heading level for package names */ |
8 | 10 | headingLevel?: 'h2' | 'h3' |
9 | 11 | /** Whether to show publisher username on cards */ |
10 | 12 | showPublisher?: boolean |
| 13 | + /** Whether there are more items to load */ |
| 14 | + hasMore?: boolean |
| 15 | + /** Whether currently loading more items */ |
| 16 | + isLoading?: boolean |
| 17 | + /** Page size for tracking current page */ |
| 18 | + pageSize?: number |
| 19 | + /** Initial page to scroll to (1-indexed) */ |
| 20 | + initialPage?: number |
11 | 21 | }>() |
| 22 | +
|
| 23 | +const emit = defineEmits<{ |
| 24 | + /** Emitted when scrolled near the bottom and more items should be loaded */ |
| 25 | + loadMore: [] |
| 26 | + /** Emitted when the visible page changes */ |
| 27 | + pageChange: [page: number] |
| 28 | +}>() |
| 29 | +
|
| 30 | +// Reference to WindowVirtualizer for infinite scroll detection |
| 31 | +const listRef = ref<WindowVirtualizerHandle>() |
| 32 | +
|
| 33 | +// Set up infinite scroll if hasMore is provided |
| 34 | +const hasMore = computed(() => props.hasMore ?? false) |
| 35 | +const isLoading = computed(() => props.isLoading ?? false) |
| 36 | +const itemCount = computed(() => props.results.length) |
| 37 | +const pageSize = computed(() => props.pageSize ?? 20) |
| 38 | +
|
| 39 | +const { handleScroll, scrollToPage } = useVirtualInfiniteScroll({ |
| 40 | + listRef, |
| 41 | + itemCount, |
| 42 | + hasMore, |
| 43 | + isLoading, |
| 44 | + pageSize: pageSize.value, |
| 45 | + threshold: 5, |
| 46 | + onLoadMore: () => emit('loadMore'), |
| 47 | + onPageChange: page => emit('pageChange', page), |
| 48 | +}) |
| 49 | +
|
| 50 | +// Scroll to initial page once list is ready and has items |
| 51 | +const hasScrolledToInitial = ref(false) |
| 52 | +
|
| 53 | +watch( |
| 54 | + [() => props.results.length, () => props.initialPage, listRef], |
| 55 | + ([length, initialPage, list]) => { |
| 56 | + if (!hasScrolledToInitial.value && list && length > 0 && initialPage && initialPage > 1) { |
| 57 | + // Wait for next tick to ensure list is rendered |
| 58 | + nextTick(() => { |
| 59 | + scrollToPage(initialPage) |
| 60 | + hasScrolledToInitial.value = true |
| 61 | + }) |
| 62 | + } |
| 63 | + }, |
| 64 | + { immediate: true }, |
| 65 | +) |
| 66 | +
|
| 67 | +// Reset scroll state when results change significantly (new search) |
| 68 | +watch( |
| 69 | + () => props.results, |
| 70 | + (newResults, oldResults) => { |
| 71 | + // If this looks like a new search (different first item or much shorter), reset |
| 72 | + if ( |
| 73 | + !oldResults || |
| 74 | + newResults.length === 0 || |
| 75 | + (oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name) |
| 76 | + ) { |
| 77 | + hasScrolledToInitial.value = false |
| 78 | + } |
| 79 | + }, |
| 80 | +) |
12 | 81 | </script> |
13 | 82 |
|
14 | 83 | <template> |
15 | | - <ol class="space-y-3 list-none m-0 p-0"> |
16 | | - <li |
17 | | - v-for="(result, index) in results" |
18 | | - :key="result.package.name" |
19 | | - class="animate-fade-in animate-fill-both" |
20 | | - :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" |
| 84 | + <div> |
| 85 | + <WindowVirtualizer |
| 86 | + ref="listRef" |
| 87 | + :data="results" |
| 88 | + :item-size="140" |
| 89 | + as="ol" |
| 90 | + item="li" |
| 91 | + class="list-none m-0 p-0" |
| 92 | + @scroll="handleScroll" |
| 93 | + > |
| 94 | + <template #default="{ item, index }"> |
| 95 | + <div class="pb-4"> |
| 96 | + <PackageCard |
| 97 | + :result="item as NpmSearchResult" |
| 98 | + :heading-level="headingLevel" |
| 99 | + :show-publisher="showPublisher" |
| 100 | + class="animate-fade-in animate-fill-both" |
| 101 | + :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" |
| 102 | + /> |
| 103 | + </div> |
| 104 | + </template> |
| 105 | + </WindowVirtualizer> |
| 106 | + |
| 107 | + <!-- Loading indicator --> |
| 108 | + <div v-if="isLoading" class="py-4 flex items-center justify-center"> |
| 109 | + <div class="flex items-center gap-3 text-fg-muted font-mono text-sm"> |
| 110 | + <span class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full animate-spin" /> |
| 111 | + Loading more... |
| 112 | + </div> |
| 113 | + </div> |
| 114 | + |
| 115 | + <!-- End of results --> |
| 116 | + <p |
| 117 | + v-else-if="!hasMore && results.length > 0" |
| 118 | + class="py-4 text-center text-fg-subtle font-mono text-sm" |
21 | 119 | > |
22 | | - <PackageCard :result="result" :heading-level="headingLevel" :show-publisher="showPublisher" /> |
23 | | - </li> |
24 | | - </ol> |
| 120 | + End of results |
| 121 | + </p> |
| 122 | + </div> |
25 | 123 | </template> |
0 commit comments