forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type { NpmSearchResult } from '#shared/types'
3import type { WindowVirtualizerHandle } from '~/composables/useVirtualInfiniteScroll'
4import type {
5 ColumnConfig,
6 PageSize,
7 PaginationMode,
8 SortOption,
9 ViewMode,
10} from '#shared/types/preferences'
11import { DEFAULT_COLUMNS } from '#shared/types/preferences'
12import { WindowVirtualizer } from 'virtua/vue'
13
14/** Number of items to render statically during SSR */
15const SSR_COUNT = 20
16
17const props = defineProps<{
18 /** List of search results to display */
19 results: NpmSearchResult[]
20 /** Filters to apply to the results */
21 filters?: StructuredFilters
22 /** Heading level for package names */
23 headingLevel?: 'h2' | 'h3'
24 /** Whether to show publisher username on cards */
25 showPublisher?: boolean
26 /** Whether there are more items to load */
27 hasMore?: boolean
28 /** Whether currently loading more items */
29 isLoading?: boolean
30 /** Page size for tracking current page */
31 pageSize?: PageSize
32 /** Initial page to scroll to (1-indexed) */
33 initialPage?: number
34 /** Search query for highlighting exact matches */
35 searchQuery?: string
36 /** View mode: cards or table */
37 viewMode?: ViewMode
38 /** Column configuration for table view */
39 columns?: ColumnConfig[]
40 /** Pagination mode: infinite or paginated */
41 paginationMode?: PaginationMode
42 /** Current page (1-indexed) for paginated mode */
43 currentPage?: number
44 /** When true, shows search-specific UI (relevance sort, no filters) */
45 searchContext?: boolean
46}>()
47
48const emit = defineEmits<{
49 /** Emitted when scrolled near the bottom and more items should be loaded */
50 'loadMore': []
51 /** Emitted when the visible page changes */
52 'pageChange': [page: number]
53 /** Emitted when sort option changes (table view) */
54 'update:sortOption': [option: SortOption]
55 /** Emitted when a keyword is clicked */
56 'clickKeyword': [keyword: string]
57}>()
58
59// Reference to WindowVirtualizer for infinite scroll detection
60const listRef = useTemplateRef<WindowVirtualizerHandle>('listRef')
61
62/** Sort option for table header sorting */
63const sortOption = defineModel<SortOption>('sortOption')
64
65// View mode and columns
66const viewMode = computed(() => props.viewMode ?? 'cards')
67const columns = computed(() => {
68 const targetColumns = props.columns ?? DEFAULT_COLUMNS
69 if (props.searchContext) return targetColumns.map(column => ({ ...column, sortable: false }))
70 return targetColumns
71})
72// Table view forces pagination mode (no virtualization for tables)
73const paginationMode = computed(() =>
74 viewMode.value === 'table' ? 'paginated' : (props.paginationMode ?? 'infinite'),
75)
76const currentPage = computed(() => props.currentPage ?? 1)
77const pageSize = computed(() => props.pageSize ?? 25)
78// Numeric page size for virtual scroll and arithmetic (when 'all' is selected, use 25 as default)
79const numericPageSize = computed(() => (pageSize.value === 'all' ? 25 : pageSize.value))
80
81// Compute paginated results for paginated mode
82const displayedResults = computed(() => {
83 if (paginationMode.value === 'infinite') {
84 return props.results
85 }
86 // 'all' page size means show everything (YOLO)
87 if (pageSize.value === 'all') {
88 return props.results
89 }
90 const start = (currentPage.value - 1) * numericPageSize.value
91 const end = start + numericPageSize.value
92 return props.results.slice(start, end)
93})
94
95// Set up infinite scroll if hasMore is provided
96const hasMore = computed(() => props.hasMore ?? false)
97const isLoading = computed(() => props.isLoading ?? false)
98const itemCount = computed(() => props.results.length)
99
100const { handleScroll, scrollToPage } = useVirtualInfiniteScroll({
101 listRef,
102 itemCount,
103 hasMore,
104 isLoading,
105 pageSize: numericPageSize,
106 threshold: 5,
107 onLoadMore: () => emit('loadMore'),
108 onPageChange: page => emit('pageChange', page),
109})
110
111// Scroll to initial page once list is ready and has items
112const hasScrolledToInitial = shallowRef(false)
113
114watch(
115 [() => props.results.length, () => props.initialPage, listRef],
116 ([length, initialPage, list]) => {
117 if (!hasScrolledToInitial.value && list && length > 0 && initialPage && initialPage > 1) {
118 // Wait for next tick to ensure list is rendered
119 nextTick(() => {
120 scrollToPage(initialPage)
121 hasScrolledToInitial.value = true
122 })
123 }
124 },
125 { immediate: true },
126)
127
128// Reset scroll state when results change significantly (new search)
129watch(
130 () => props.results,
131 (newResults, oldResults) => {
132 // If this looks like a new search (different first item or much shorter), reset
133 if (
134 !oldResults ||
135 newResults.length === 0 ||
136 (oldResults.length > 0 && newResults[0]?.package.name !== oldResults[0]?.package.name)
137 ) {
138 hasScrolledToInitial.value = false
139 }
140 },
141)
142
143function scrollToIndex(index: number, smooth = true) {
144 listRef.value?.scrollToIndex(index, { align: 'center', smooth })
145}
146
147defineExpose({
148 scrollToIndex,
149})
150</script>
151
152<template>
153 <div>
154 <!-- Table View -->
155 <template v-if="viewMode === 'table'">
156 <PackageTable
157 :results="displayedResults"
158 :filters="filters"
159 :columns="columns"
160 v-model:sort-option="sortOption"
161 :is-loading="isLoading"
162 @click-keyword="emit('clickKeyword', $event)"
163 />
164 </template>
165
166 <!-- Card View with Infinite Scroll -->
167 <template v-else-if="paginationMode === 'infinite'">
168 <!-- SSR: Render static list for first page, replaced by virtual list on client -->
169 <ClientOnly>
170 <WindowVirtualizer
171 ref="listRef"
172 :data="results"
173 :item-size="140"
174 as="ol"
175 item="li"
176 class="list-none m-0 p-0"
177 @scroll="handleScroll"
178 >
179 <template #default="{ item, index }">
180 <div class="pb-4">
181 <PackageCard
182 :result="item as NpmSearchResult"
183 :heading-level="headingLevel"
184 :show-publisher="showPublisher"
185 :index="index"
186 :search-query="searchQuery"
187 class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
188 :filters="filters"
189 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
190 @click-keyword="emit('clickKeyword', $event)"
191 />
192 </div>
193 </template>
194 </WindowVirtualizer>
195
196 <!-- SSR fallback: static list of first page results -->
197 <template #fallback>
198 <ol class="list-none m-0 p-0">
199 <li v-for="(item, index) in results.slice(0, SSR_COUNT)" :key="item.package.name">
200 <div class="pb-4">
201 <PackageCard
202 :result="item"
203 :heading-level="headingLevel"
204 :show-publisher="showPublisher"
205 :index="index"
206 :search-query="searchQuery"
207 :filters="filters"
208 @click-keyword="emit('clickKeyword', $event)"
209 />
210 </div>
211 </li>
212 </ol>
213 </template>
214 </ClientOnly>
215 </template>
216
217 <!-- Card View with Pagination -->
218 <template v-else>
219 <!-- Loading state when fetching page data -->
220 <div
221 v-if="isLoading && displayedResults.length === 0"
222 class="py-12 flex items-center justify-center"
223 >
224 <div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
225 <span
226 class="w-5 h-5 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin"
227 />
228 {{ $t('common.loading') }}
229 </div>
230 </div>
231 <ol v-else class="list-none m-0 p-0">
232 <li v-for="(item, index) in displayedResults" :key="item.package.name" class="pb-4">
233 <PackageCard
234 :result="item"
235 :heading-level="headingLevel"
236 :show-publisher="showPublisher"
237 :index="index"
238 :search-query="searchQuery"
239 class="motion-safe:animate-fade-in motion-safe:animate-fill-both"
240 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
241 :filters="filters"
242 @click-keyword="emit('clickKeyword', $event)"
243 />
244 </li>
245 </ol>
246 </template>
247
248 <!-- Initial loading state (card view only - table has its own skeleton) -->
249 <div
250 v-if="isLoading && results.length === 0 && viewMode !== 'table'"
251 class="py-12 flex items-center justify-center"
252 >
253 <div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
254 <span
255 class="w-5 h-5 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin"
256 />
257 {{ $t('common.loading') }}
258 </div>
259 </div>
260
261 <!-- Loading more indicator (infinite scroll mode only) -->
262 <div
263 v-else-if="isLoading && paginationMode === 'infinite'"
264 class="py-4 flex items-center justify-center"
265 >
266 <div class="flex items-center gap-3 text-fg-muted font-mono text-sm">
267 <span
268 class="w-4 h-4 border-2 border-fg-subtle border-t-fg rounded-full motion-safe:animate-spin"
269 />
270 {{ $t('common.loading_more') }}
271 </div>
272 </div>
273
274 <!-- End of results (infinite scroll mode only) -->
275 <p
276 v-else-if="!hasMore && results.length > 0 && paginationMode === 'infinite'"
277 class="py-4 text-center text-fg-subtle font-mono text-sm"
278 >
279 {{ $t('common.end_of_results') }}
280 </p>
281
282 <!-- Empty state (card view only - table has its own) -->
283 <p
284 v-if="results.length === 0 && !isLoading && viewMode !== 'table'"
285 class="py-12 text-center text-fg-subtle font-mono text-sm"
286 >
287 {{ $t('filters.table.no_packages') }}
288 </p>
289 </div>
290</template>