-
-
Notifications
You must be signed in to change notification settings - Fork 425
Expand file tree
/
Copy pathList.vue
More file actions
299 lines (275 loc) · 9.96 KB
/
List.vue
File metadata and controls
299 lines (275 loc) · 9.96 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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
<script setup lang="ts">
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[]
/** Filters to apply to the results */
filters?: StructuredFilters
/** 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?: PageSize
/** Initial page to scroll to (1-indexed) */
initialPage?: number
/** Search query for highlighting exact matches */
searchQuery?: string
/** View mode: cards or table */
viewMode?: ViewMode
/** Column configuration for table view */
columns?: ColumnConfig[]
/** Pagination mode: infinite or paginated */
paginationMode?: PaginationMode
/** Current page (1-indexed) for paginated mode */
currentPage?: number
/** When true, shows search-specific UI (relevance sort, no filters) */
searchContext?: boolean
}>()
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 sort option changes (table view) */
'update:sortOption': [option: SortOption]
/** Emitted when a keyword is clicked */
'clickKeyword': [keyword: string]
}>()
// Reference to WindowVirtualizer for infinite scroll detection
const listRef = useTemplateRef<WindowVirtualizerHandle>('listRef')
/** Sort option for table header sorting */
const sortOption = defineModel<SortOption>('sortOption')
// View mode and columns
const viewMode = computed(() => props.viewMode ?? 'cards')
const columns = computed(() => {
const targetColumns = props.columns ?? DEFAULT_COLUMNS
if (props.searchContext) return targetColumns.map(column => ({ ...column, sortable: false }))
return targetColumns
})
// Table view forces pagination mode (no virtualization for tables)
const paginationMode = computed(() =>
viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'),
)
const currentPage = computed(() => props.currentPage ?? 1)
const pageSize = computed(() => props.pageSize ?? 25)
// Numeric page size for virtual scroll and arithmetic (use 25 as default)
const numericPageSize = computed(() => pageSize.value)
// Compute paginated results for paginated mode
const displayedResults = computed(() => {
if (paginationMode.value === 'infinite') {
return props.results
}
const start = (currentPage.value - 1) * numericPageSize.value
const end = start + numericPageSize.value
return props.results.slice(start, end)
})
// 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 { handleScroll, scrollToPage } = useVirtualInfiniteScroll({
listRef,
itemCount,
hasMore,
isLoading,
pageSize: numericPageSize,
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 },
)
// Tracks how many items came from the last new-search batch.
// Items at index < newSearchBatchSize are from the new search → no animation.
// Items at index >= newSearchBatchSize were loaded via scroll → animate with stagger.
// Using an index threshold avoids any timing dependency on nextTick / virtual list paint.
const newSearchBatchSize = shallowRef(Infinity)
// 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
newSearchBatchSize.value = newResults.length
}
},
)
function scrollToIndex(index: number, smooth = true) {
listRef.value?.scrollToIndex(index, { align: 'center', smooth })
}
defineExpose({
scrollToIndex,
})
</script>
<template>
<div>
<!-- Table View -->
<template v-if="viewMode === 'table'">
<PackageTable
:results="displayedResults"
:filters="filters"
:columns="columns"
v-model:sort-option="sortOption"
:is-loading="isLoading"
@click-keyword="emit('clickKeyword', $event)"
/>
</template>
<!-- Card View with Infinite Scroll -->
<template v-else-if="paginationMode === 'infinite'">
<!-- 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
:key="item.package.name"
:result="item"
:heading-level="headingLevel"
:show-publisher="showPublisher"
:index="index"
:search-query="searchQuery"
:class="
index >= newSearchBatchSize &&
'motion-safe:animate-fade-in motion-safe:animate-fill-both'
"
:style="
index >= newSearchBatchSize
? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
: {}
"
:filters="filters"
@click-keyword="emit('clickKeyword', $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"
:index="index"
:search-query="searchQuery"
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
</div>
</li>
</ol>
</template>
</ClientOnly>
</template>
<!-- Card View with Pagination -->
<template v-else>
<!-- Loading state when fetching page data -->
<div
v-if="isLoading && displayedResults.length === 0"
class="py-12 flex items-center justify-center"
>
<div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
<span
class="w-5 h-5 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin"
/>
{{ $t('common.loading') }}
</div>
</div>
<ol v-else class="list-none m-0 p-0">
<li v-for="(item, index) in displayedResults" :key="item.package.name" class="pb-4">
<PackageCard
:result="item"
:heading-level="headingLevel"
:show-publisher="showPublisher"
:index="(currentPage - 1) * numericPageSize + index"
:search-query="searchQuery"
:class="
index >= newSearchBatchSize &&
'motion-safe:animate-fade-in motion-safe:animate-fill-both'
"
:style="
index >= newSearchBatchSize
? { animationDelay: `${Math.min((index - newSearchBatchSize) * 0.02, 0.3)}s` }
: {}
"
:filters="filters"
@click-keyword="emit('clickKeyword', $event)"
/>
</li>
</ol>
</template>
<!-- Initial loading state (card view only - table has its own skeleton) -->
<div
v-if="isLoading && results.length === 0 && viewMode !== 'table'"
class="py-12 flex items-center justify-center"
>
<div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
<span
class="w-5 h-5 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin"
/>
{{ $t('common.loading') }}
</div>
</div>
<!-- Loading more indicator (infinite scroll mode only) -->
<div
v-else-if="isLoading && paginationMode === 'infinite'"
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 (infinite scroll mode only) -->
<p
v-else-if="!hasMore && results.length > 0 && paginationMode === 'infinite'"
class="py-4 text-center text-fg-subtle font-mono text-sm"
>
{{ $t('common.end_of_results') }}
</p>
<!-- Empty state (card view only - table has its own) -->
<p
v-if="results.length === 0 && !isLoading && viewMode !== 'table'"
class="py-12 text-center text-fg-subtle font-mono text-sm"
>
{{ $t('filters.table.no_packages') }}
</p>
</div>
</template>