[READ-ONLY] a fast, modern browser for the npm registry
at main 254 lines 8.4 kB view raw
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>