forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2import type {
3 ColumnConfig,
4 ColumnId,
5 DownloadRange,
6 FilterChip,
7 PageSize,
8 PaginationMode,
9 SearchScope,
10 SecurityFilter,
11 SortKey,
12 SortOption,
13 StructuredFilters,
14 UpdatedWithin,
15 ViewMode,
16} from '#shared/types/preferences'
17import {
18 buildSortOption,
19 parseSortOption,
20 SORT_KEYS,
21 toggleDirection,
22} from '#shared/types/preferences'
23
24const props = defineProps<{
25 filters: StructuredFilters
26 columns: ColumnConfig[]
27 totalCount: number
28 filteredCount: number
29 availableKeywords?: string[]
30 activeFilters: FilterChip[]
31 /** When true, shows search-specific UI (relevance sort, no filters) */
32 searchContext?: boolean
33 /** Sort keys to force-disable (e.g. when the current provider doesn't support them) */
34 disabledSortKeys?: SortKey[]
35}>()
36
37const { t } = useI18n()
38
39const sortOption = defineModel<SortOption>('sortOption', { required: true })
40const viewMode = defineModel<ViewMode>('viewMode', { required: true })
41const paginationMode = defineModel<PaginationMode>('paginationMode', { required: true })
42const pageSize = defineModel<PageSize>('pageSize', { required: true })
43
44const emit = defineEmits<{
45 'toggleColumn': [columnId: ColumnId]
46 'resetColumns': []
47 'clearFilter': [chip: FilterChip]
48 'clearAllFilters': []
49 'update:text': [value: string]
50 'update:searchScope': [value: SearchScope]
51 'update:downloadRange': [value: DownloadRange]
52 'update:security': [value: SecurityFilter]
53 'update:updatedWithin': [value: UpdatedWithin]
54 'toggleKeyword': [keyword: string]
55}>()
56
57const showingFiltered = computed(() => props.filteredCount !== props.totalCount)
58
59// Parse current sort option into key and direction
60const currentSort = computed(() => parseSortOption(sortOption.value))
61
62// Get available sort keys based on context
63const disabledSet = computed(() => new Set(props.disabledSortKeys ?? []))
64
65const availableSortKeys = computed(() => {
66 const applyDisabled = (k: (typeof SORT_KEYS)[number]) => ({
67 ...k,
68 disabled: k.disabled || disabledSet.value.has(k.key),
69 })
70
71 if (props.searchContext) {
72 // In search context: show relevance + non-disabled sorts (downloads, updated, name)
73 return SORT_KEYS.filter(k => !k.searchOnly || k.key === 'relevance').map(applyDisabled)
74 }
75 // In org/user context: hide search-only sorts
76 return SORT_KEYS.filter(k => !k.searchOnly).map(applyDisabled)
77})
78
79// Handle sort key change from dropdown
80const sortKeyModel = computed<SortKey>({
81 get: () => currentSort.value.key,
82 set: newKey => {
83 const config = SORT_KEYS.find(k => k.key === newKey)
84 const direction = config?.defaultDirection ?? 'desc'
85 sortOption.value = buildSortOption(newKey, direction)
86 },
87})
88
89// Toggle sort direction
90function handleToggleDirection() {
91 const { key, direction } = currentSort.value
92 sortOption.value = buildSortOption(key, toggleDirection(direction))
93}
94
95// Map sort key to i18n key
96const sortKeyLabelKeys = computed<Record<SortKey, string>>(() => ({
97 'relevance': t('filters.sort.relevance'),
98 'downloads-week': t('filters.sort.downloads_week'),
99 'downloads-day': t('filters.sort.downloads_day'),
100 'downloads-month': t('filters.sort.downloads_month'),
101 'downloads-year': t('filters.sort.downloads_year'),
102 'updated': t('filters.sort.published'),
103 'name': t('filters.sort.name'),
104 'quality': t('filters.sort.quality'),
105 'popularity': t('filters.sort.popularity'),
106 'maintenance': t('filters.sort.maintenance'),
107 'score': t('filters.sort.score'),
108}))
109
110function getSortKeyLabelKey(key: SortKey): string {
111 return sortKeyLabelKeys.value[key]
112}
113</script>
114
115<template>
116 <div class="space-y-3 mb-6">
117 <!-- Main toolbar row -->
118 <div class="flex flex-col sm:flex-row sm:items-center gap-3">
119 <!-- Count display (infinite scroll mode only) -->
120 <div
121 v-if="viewMode === 'cards' && paginationMode === 'infinite' && !searchContext"
122 class="text-sm font-mono text-fg-muted"
123 >
124 <template v-if="showingFiltered">
125 {{
126 $t(
127 'filters.count.showing_filtered',
128 {
129 filtered: $n(filteredCount),
130 count: $n(totalCount),
131 },
132 totalCount,
133 )
134 }}
135 </template>
136 <template v-else>
137 {{ $t('filters.count.showing_all', { count: $n(totalCount) }, totalCount) }}
138 </template>
139 </div>
140
141 <!-- Count display (paginated/table mode only) -->
142 <div
143 v-if="(viewMode === 'table' || paginationMode === 'paginated') && !searchContext"
144 class="text-sm font-mono text-fg-muted"
145 >
146 {{
147 $t(
148 'filters.count.showing_paginated',
149 {
150 pageSize: pageSize === 'all' ? $n(filteredCount) : Math.min(pageSize, filteredCount),
151 count: $n(filteredCount),
152 },
153 filteredCount,
154 )
155 }}
156 </div>
157
158 <div class="flex-1" />
159
160 <div
161 class="flex flex-wrap items-center gap-3 sm:justify-end justify-between w-full sm:w-auto"
162 >
163 <!-- Sort controls -->
164 <div class="flex items-center gap-1 shrink-0 order-1 sm:order-1">
165 <!-- Sort key dropdown -->
166 <SelectField
167 :label="$t('filters.sort.label')"
168 hidden-label
169 id="sort-select"
170 v-model="sortKeyModel"
171 :items="
172 availableSortKeys.map(keyConfig => ({
173 label: getSortKeyLabelKey(keyConfig.key),
174 value: keyConfig.key,
175 disabled: keyConfig.disabled,
176 }))
177 "
178 />
179
180 <!-- Sort direction toggle -->
181 <button
182 v-if="!searchContext || currentSort.key !== 'relevance'"
183 type="button"
184 class="p-1.5 rounded border border-border bg-bg-subtle text-fg-muted hover:text-fg hover:border-border-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
185 :aria-label="$t('filters.sort.toggle_direction')"
186 :title="
187 currentSort.direction === 'asc'
188 ? $t('filters.sort.ascending')
189 : $t('filters.sort.descending')
190 "
191 @click="handleToggleDirection"
192 >
193 <span
194 class="w-4 h-4 block transition-transform duration-200"
195 :class="
196 currentSort.direction === 'asc'
197 ? 'i-lucide:arrow-down-narrow-wide'
198 : 'i-lucide:arrow-down-wide-narrow'
199 "
200 aria-hidden="true"
201 />
202 </button>
203 </div>
204
205 <!-- View mode toggle - mobile (left side, row 2) -->
206 <div class="flex sm:hidden items-center gap-1 order-2">
207 <ViewModeToggle v-model="viewMode" />
208 </div>
209
210 <!-- Column picker - mobile (right side, row 2) -->
211 <ColumnPicker
212 v-if="viewMode === 'table'"
213 class="flex sm:hidden order-3"
214 :columns="columns"
215 @toggle="emit('toggleColumn', $event)"
216 @reset="emit('resetColumns')"
217 />
218
219 <!-- View mode toggle + Column picker - desktop (right side, row 1) -->
220 <div class="hidden sm:flex items-center gap-1 order-2">
221 <ViewModeToggle v-model="viewMode" />
222
223 <ColumnPicker
224 v-if="viewMode === 'table'"
225 :columns="columns"
226 @toggle="emit('toggleColumn', $event)"
227 @reset="emit('resetColumns')"
228 />
229 </div>
230 </div>
231 </div>
232
233 <!-- Filter panel (hidden in search context) -->
234 <FilterPanel
235 v-if="!searchContext"
236 :filters="filters"
237 :available-keywords="availableKeywords"
238 @update:text="emit('update:text', $event)"
239 @update:search-scope="emit('update:searchScope', $event)"
240 @update:download-range="emit('update:downloadRange', $event)"
241 @update:security="emit('update:security', $event)"
242 @update:updated-within="emit('update:updatedWithin', $event)"
243 @toggle-keyword="emit('toggleKeyword', $event)"
244 />
245
246 <!-- Active filter chips (hidden in search context) -->
247 <FilterChips
248 v-if="!searchContext"
249 :chips="activeFilters"
250 @remove="emit('clearFilter', $event)"
251 @clear-all="emit('clearAllFilters')"
252 />
253 </div>
254</template>