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/npm-registry'
3import type {
4 ColumnConfig,
5 ColumnId,
6 SortKey,
7 SortOption,
8 StructuredFilters,
9} from '#shared/types/preferences'
10import { buildSortOption, parseSortOption, toggleDirection } from '#shared/types/preferences'
11
12const props = defineProps<{
13 results: NpmSearchResult[]
14 columns: ColumnConfig[]
15 filters?: StructuredFilters
16 isLoading?: boolean
17}>()
18
19const { t } = useI18n()
20
21const sortOption = defineModel<SortOption>('sortOption')
22
23const emit = defineEmits<{
24 clickKeyword: [keyword: string]
25}>()
26
27function isColumnVisible(id: string): boolean {
28 return props.columns.find(c => c.id === id)?.visible ?? false
29}
30
31function isSortable(id: string): boolean {
32 return props.columns.find(c => c.id === id)?.sortable ?? false
33}
34
35// Map column id to sort key
36const columnToSortKey: Record<string, SortKey> = {
37 name: 'name',
38 downloads: 'downloads-week',
39 updated: 'updated',
40 qualityScore: 'quality',
41 popularityScore: 'popularity',
42 maintenanceScore: 'maintenance',
43 combinedScore: 'score',
44}
45
46// Default direction for each column
47const columnDefaultDirection: Record<string, 'asc' | 'desc'> = {
48 name: 'asc',
49 downloads: 'desc',
50 updated: 'desc',
51 qualityScore: 'desc',
52 popularityScore: 'desc',
53 maintenanceScore: 'desc',
54 combinedScore: 'desc',
55}
56
57function isColumnSorted(id: string): boolean {
58 const option = sortOption.value
59 if (!option) return false
60 const { key } = parseSortOption(option)
61 return key === columnToSortKey[id]
62}
63
64function getSortDirection(id: string): 'asc' | 'desc' | null {
65 const option = sortOption.value
66 if (!option) return null
67 if (!isColumnSorted(id)) return null
68 const { direction } = parseSortOption(option)
69 return direction
70}
71
72function toggleSort(id: string) {
73 if (!isSortable(id)) return
74
75 const sortKey = columnToSortKey[id]
76 if (!sortKey) return
77
78 const isSorted = isColumnSorted(id)
79
80 if (!isSorted) {
81 // First click - use default direction
82 const defaultDir = columnDefaultDirection[id] ?? 'desc'
83 sortOption.value = buildSortOption(sortKey, defaultDir)
84 } else {
85 // Toggle direction
86 const currentDir = getSortDirection(id) ?? 'desc'
87 sortOption.value = buildSortOption(sortKey, toggleDirection(currentDir))
88 }
89}
90
91// Map column IDs to i18n keys
92const columnLabels = computed(() => ({
93 name: t('filters.columns.name'),
94 version: t('filters.columns.version'),
95 description: t('filters.columns.description'),
96 downloads: t('filters.columns.downloads'),
97 updated: t('filters.columns.published'),
98 maintainers: t('filters.columns.maintainers'),
99 keywords: t('filters.columns.keywords'),
100 qualityScore: t('filters.columns.quality_score'),
101 popularityScore: t('filters.columns.popularity_score'),
102 maintenanceScore: t('filters.columns.maintenance_score'),
103 combinedScore: t('filters.columns.combined_score'),
104 security: t('filters.columns.security'),
105}))
106
107function getColumnLabel(id: ColumnId): string {
108 return columnLabels.value[id]
109}
110</script>
111
112<template>
113 <div class="overflow-x-auto">
114 <table class="w-full text-start">
115 <thead class="border-b border-border">
116 <tr>
117 <!-- Name (always visible) -->
118 <th
119 scope="col"
120 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none"
121 :class="{
122 'hover:text-fg transition-colors duration-200': isSortable('name'),
123 }"
124 :aria-sort="
125 isColumnSorted('name')
126 ? getSortDirection('name') === 'asc'
127 ? 'ascending'
128 : 'descending'
129 : undefined
130 "
131 :tabindex="isSortable('name') ? 0 : undefined"
132 role="columnheader"
133 @click="toggleSort('name')"
134 @keydown.enter="toggleSort('name')"
135 @keydown.space.prevent="toggleSort('name')"
136 >
137 <span class="inline-flex items-center gap-1">
138 {{ getColumnLabel('name') }}
139 <template v-if="isSortable('name')">
140 <span
141 v-if="isColumnSorted('name')"
142 class="i-lucide:chevron-down w-3 h-3"
143 :class="getSortDirection('name') === 'asc' ? 'rotate-180' : ''"
144 aria-hidden="true"
145 />
146 <span
147 v-else
148 class="i-lucide:chevrons-up-down w-3 h-3 opacity-30"
149 aria-hidden="true"
150 />
151 </template>
152 </span>
153 </th>
154
155 <th
156 v-if="isColumnVisible('version')"
157 scope="col"
158 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none"
159 >
160 {{ getColumnLabel('version') }}
161 </th>
162
163 <th
164 v-if="isColumnVisible('description')"
165 scope="col"
166 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none"
167 >
168 {{ getColumnLabel('description') }}
169 </th>
170
171 <th
172 v-if="isColumnVisible('downloads')"
173 scope="col"
174 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none"
175 :class="{
176 'hover:text-fg transition-colors duration-200': isSortable('downloads'),
177 }"
178 :aria-sort="
179 isColumnSorted('downloads')
180 ? getSortDirection('downloads') === 'asc'
181 ? 'ascending'
182 : 'descending'
183 : undefined
184 "
185 :tabindex="isSortable('downloads') ? 0 : undefined"
186 role="columnheader"
187 @click="toggleSort('downloads')"
188 @keydown.enter="toggleSort('downloads')"
189 @keydown.space.prevent="toggleSort('downloads')"
190 >
191 <span class="inline-flex items-center gap-1 justify-end">
192 {{ getColumnLabel('downloads') }}
193 <template v-if="isSortable('downloads')">
194 <span
195 v-if="isColumnSorted('downloads')"
196 class="i-lucide:caret-down w-3 h-3"
197 :class="getSortDirection('downloads') === 'asc' ? 'rotate-180' : ''"
198 aria-hidden="true"
199 />
200 <span
201 v-else
202 class="i-lucide:chevrons-up-down w-3 h-3 opacity-30"
203 aria-hidden="true"
204 />
205 </template>
206 </span>
207 </th>
208
209 <th
210 v-if="isColumnVisible('updated')"
211 scope="col"
212 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset focus-visible:outline-none"
213 :class="{
214 'hover:text-fg transition-colors duration-200': isSortable('updated'),
215 }"
216 :aria-sort="
217 isColumnSorted('updated')
218 ? getSortDirection('updated') === 'asc'
219 ? 'ascending'
220 : 'descending'
221 : undefined
222 "
223 :tabindex="isSortable('updated') ? 0 : undefined"
224 role="columnheader"
225 @click="toggleSort('updated')"
226 @keydown.enter="toggleSort('updated')"
227 @keydown.space.prevent="toggleSort('updated')"
228 >
229 <span class="inline-flex items-center gap-1">
230 {{ getColumnLabel('updated') }}
231 <template v-if="isSortable('updated')">
232 <span
233 v-if="isColumnSorted('updated')"
234 class="i-lucide:caret-down w-3 h-3"
235 :class="getSortDirection('updated') === 'asc' ? 'rotate-180' : ''"
236 aria-hidden="true"
237 />
238 <span
239 v-else
240 class="i-lucide:chevrons-up-down w-3 h-3 opacity-30"
241 aria-hidden="true"
242 />
243 </template>
244 </span>
245 </th>
246
247 <th
248 v-if="isColumnVisible('maintainers')"
249 scope="col"
250 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
251 >
252 {{ getColumnLabel('maintainers') }}
253 </th>
254
255 <th
256 v-if="isColumnVisible('keywords')"
257 scope="col"
258 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
259 >
260 {{ getColumnLabel('keywords') }}
261 </th>
262
263 <th
264 v-if="isColumnVisible('qualityScore')"
265 scope="col"
266 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
267 >
268 {{ getColumnLabel('qualityScore') }}
269 </th>
270
271 <th
272 v-if="isColumnVisible('popularityScore')"
273 scope="col"
274 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
275 >
276 {{ getColumnLabel('popularityScore') }}
277 </th>
278
279 <th
280 v-if="isColumnVisible('maintenanceScore')"
281 scope="col"
282 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
283 >
284 {{ getColumnLabel('maintenanceScore') }}
285 </th>
286
287 <th
288 v-if="isColumnVisible('combinedScore')"
289 scope="col"
290 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
291 >
292 {{ getColumnLabel('combinedScore') }}
293 </th>
294
295 <th
296 v-if="isColumnVisible('security')"
297 scope="col"
298 class="py-3 px-3 text-xs text-start text-fg-muted font-mono font-medium uppercase tracking-wider whitespace-nowrap select-none text-end"
299 >
300 {{ getColumnLabel('security') }}
301 </th>
302 </tr>
303 </thead>
304 <tbody>
305 <!-- Loading skeleton rows -->
306 <template v-if="isLoading && results.length === 0">
307 <tr v-for="i in 5" :key="`skeleton-${i}`" class="border-b border-border">
308 <td class="py-3 px-3">
309 <div class="h-4 w-32 bg-bg-muted rounded animate-pulse" />
310 </td>
311 <td v-if="isColumnVisible('version')" class="py-3 px-3">
312 <div class="h-4 w-12 bg-bg-muted rounded animate-pulse" />
313 </td>
314 <td v-if="isColumnVisible('description')" class="py-3 px-3">
315 <div class="h-4 w-48 bg-bg-muted rounded animate-pulse" />
316 </td>
317 <td v-if="isColumnVisible('downloads')" class="py-3 px-3">
318 <div class="h-4 w-16 bg-bg-muted rounded animate-pulse ms-auto" />
319 </td>
320 <td v-if="isColumnVisible('updated')" class="py-3 px-3">
321 <div class="h-4 w-20 bg-bg-muted rounded animate-pulse ms-auto" />
322 </td>
323 <td v-if="isColumnVisible('maintainers')" class="py-3 px-3">
324 <div class="h-4 w-24 bg-bg-muted rounded animate-pulse ms-auto" />
325 </td>
326 <td v-if="isColumnVisible('keywords')" class="py-3 px-3">
327 <div class="h-4 w-32 bg-bg-muted rounded animate-pulse ms-auto" />
328 </td>
329 </tr>
330 </template>
331
332 <!-- Actual data rows -->
333 <template v-else>
334 <PackageTableRow
335 v-for="(result, index) in results"
336 :key="result.package.name"
337 :result="result"
338 :columns="columns"
339 :index="index"
340 :filters="filters"
341 @click-keyword="emit('clickKeyword', $event)"
342 />
343 </template>
344 </tbody>
345 </table>
346
347 <!-- Empty state (only when not loading) -->
348 <div
349 v-if="results.length === 0 && !isLoading"
350 class="py-12 text-center text-fg-subtle font-mono text-sm"
351 >
352 {{ $t('filters.table.no_packages') }}
353 </div>
354 </div>
355</template>