-
-
Notifications
You must be signed in to change notification settings - Fork 424
Expand file tree
/
Copy pathPackageList.vue
More file actions
167 lines (152 loc) · 5.12 KB
/
PackageList.vue
File metadata and controls
167 lines (152 loc) · 5.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
<script setup lang="ts">
import type { NpmSearchResult } from '#shared/types'
import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll'
import { WindowVirtualizer } from 'virtua/vue'
/** Number of items to render statically during SSR */
const SSR_COUNT = 20
const props = defineProps<{
/** List of search results to display */
results: NpmSearchResult[]
/** Heading level for package names */
headingLevel?: 'h2' | 'h3'
/** Whether to show publisher username on cards */
showPublisher?: boolean
/** Whether there are more items to load */
hasMore?: boolean
/** Whether currently loading more items */
isLoading?: boolean
/** Page size for tracking current page */
pageSize?: number
/** Initial page to scroll to (1-indexed) */
initialPage?: number
/** Selected result index (for keyboard navigation) */
selectedIndex?: number
/** Search query for highlighting exact matches */
searchQuery?: string
}>()
const emit = defineEmits<{
/** Emitted when scrolled near the bottom and more items should be loaded */
loadMore: []
/** Emitted when the visible page changes */
pageChange: [page: number]
/** Emitted when a result is hovered/focused */
select: [index: number]
}>()
// Reference to WindowVirtualizer for infinite scroll detection
const listRef = useTemplateRef<WindowVirtualizerHandle>('listRef')
// Set up infinite scroll if hasMore is provided
const hasMore = computed(() => props.hasMore ?? false)
const isLoading = computed(() => props.isLoading ?? false)
const itemCount = computed(() => props.results.length)
const pageSize = computed(() => props.pageSize ?? 20)
const { handleScroll, scrollToPage } = useVirtualInfiniteScroll({
listRef,
itemCount,
hasMore,
isLoading,
pageSize: pageSize.value,
threshold: 5,
onLoadMore: () => emit('loadMore'),
onPageChange: page => emit('pageChange', page),
})
// Scroll to initial page once list is ready and has items
const hasScrolledToInitial = shallowRef(false)
watch(
[() => props.results.length, () => props.initialPage, listRef],
([length, initialPage, list]) => {
if (!hasScrolledToInitial.value && list && length > 0 && initialPage && initialPage > 1) {
// Wait for next tick to ensure list is rendered
nextTick(() => {
scrollToPage(initialPage)
hasScrolledToInitial.value = true
})
}
},
{ immediate: true },
)
// Reset scroll state when results change significantly (new search)
watch(
() => props.results,
(newResults, oldResults) => {
// If this looks like a new search (different first item or much shorter), reset
if (
!oldResults ||
newResults.length === 0 ||
(oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
) {
hasScrolledToInitial.value = false
}
},
)
function scrollToIndex(index: number, smooth = true) {
listRef.value?.scrollToIndex(index, { align: 'center', smooth })
}
defineExpose({
scrollToIndex,
})
</script>
<template>
<div>
<!-- SSR: Render static list for first page, replaced by virtual list on client -->
<ClientOnly>
<WindowVirtualizer
ref="listRef"
:data="results"
:item-size="140"
as="ol"
item="li"
class="list-none m-0 p-0"
@scroll="handleScroll"
>
<template #default="{ item, index }">
<div class="pb-4">
<PackageCard
:result="item as NpmSearchResult"
:heading-level="headingLevel"
:show-publisher="showPublisher"
:selected="index === (selectedIndex ?? -1)"
:index="index"
:search-query="searchQuery"
class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
@focus="emit('select', $event)"
/>
</div>
</template>
</WindowVirtualizer>
<!-- SSR fallback: static list of first page results -->
<template #fallback>
<ol class="list-none m-0 p-0">
<li v-for="(item, index) in results.slice(0, SSR_COUNT)" :key="item.package.name">
<div class="pb-4">
<PackageCard
:result="item"
:heading-level="headingLevel"
:show-publisher="showPublisher"
:selected="index === (selectedIndex ?? -1)"
:index="index"
:search-query="searchQuery"
/>
</div>
</li>
</ol>
</template>
</ClientOnly>
<!-- Loading indicator -->
<div v-if="isLoading" class="py-4 flex items-center justify-center">
<div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
<span
class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin"
/>
{{ $t('common.loading_more') }}
</div>
</div>
<!-- End of results -->
<p
v-else-if="!hasMore && results.length > 0"
class="py-4 text-center text-fg-subtle font-mono text-sm"
>
{{ $t('common.end_of_results') }}
</p>
</div>
</template>