[READ-ONLY] a fast, modern browser for the npm registry
at main 374 lines 12 kB view raw
1<script setup lang="ts"> 2import type { 3 DownloadRange, 4 SearchScope, 5 SecurityFilter, 6 StructuredFilters, 7 UpdatedWithin, 8} from '#shared/types/preferences' 9import { 10 DOWNLOAD_RANGES, 11 SEARCH_SCOPE_VALUES, 12 SECURITY_FILTER_VALUES, 13 UPDATED_WITHIN_OPTIONS, 14} from '#shared/types/preferences' 15 16const props = defineProps<{ 17 filters: StructuredFilters 18 availableKeywords?: string[] 19}>() 20 21const emit = defineEmits<{ 22 'update:text': [value: string] 23 'update:searchScope': [value: SearchScope] 24 'update:downloadRange': [value: DownloadRange] 25 'update:security': [value: SecurityFilter] 26 'update:updatedWithin': [value: UpdatedWithin] 27 'toggleKeyword': [keyword: string] 28}>() 29 30const { t } = useI18n() 31 32const isExpanded = shallowRef(false) 33const showAllKeywords = shallowRef(false) 34 35const filterText = computed({ 36 get: () => props.filters.text, 37 set: value => emit('update:text', value), 38}) 39 40const displayedKeywords = computed(() => { 41 const keywords = props.availableKeywords ?? [] 42 return showAllKeywords.value ? keywords : keywords.slice(0, 20) 43}) 44 45const searchPlaceholder = computed(() => { 46 switch (props.filters.searchScope) { 47 case 'name': 48 return $t('filters.search_placeholder_name') 49 case 'description': 50 return $t('filters.search_placeholder_description') 51 case 'keywords': 52 return $t('filters.search_placeholder_keywords') 53 case 'all': 54 return $t('filters.search_placeholder_all') 55 default: 56 return $t('filters.search_placeholder_name') 57 } 58}) 59 60const hasMoreKeywords = computed(() => { 61 return !showAllKeywords.value && (props.availableKeywords?.length ?? 0) > 20 62}) 63 64// i18n mappings for filter options 65const scopeLabelKeys = computed( 66 () => 67 ({ 68 name: t('filters.scope_name'), 69 description: t('filters.scope_description'), 70 keywords: t('filters.scope_keywords'), 71 all: t('filters.scope_all'), 72 }) as const, 73) 74 75const scopeDescriptionKeys = computed( 76 () => 77 ({ 78 name: t('filters.scope_name_description'), 79 description: t('filters.scope_description_description'), 80 keywords: t('filters.scope_keywords_description'), 81 all: t('filters.scope_all_description'), 82 }) as const, 83) 84 85const downloadRangeLabelKeys = computed( 86 () => 87 ({ 88 'any': t('filters.download_range.any'), 89 'lt100': t('filters.download_range.lt100'), 90 '100-1k': t('filters.download_range.100_1k'), 91 '1k-10k': t('filters.download_range.1k_10k'), 92 '10k-100k': t('filters.download_range.10k_100k'), 93 'gt100k': t('filters.download_range.gt100k'), 94 }) as const, 95) 96 97const updatedWithinLabelKeys = computed( 98 () => 99 ({ 100 any: t('filters.updated.any'), 101 week: t('filters.updated.week'), 102 month: t('filters.updated.month'), 103 quarter: t('filters.updated.quarter'), 104 year: t('filters.updated.year'), 105 }) as const, 106) 107 108const securityLabelKeys = computed( 109 () => 110 ({ 111 all: t('filters.security_options.all'), 112 secure: t('filters.security_options.secure'), 113 warnings: t('filters.security_options.insecure'), 114 }) as const, 115) 116 117// Type-safe accessor functions 118function getScopeLabelKey(value: SearchScope): string { 119 return scopeLabelKeys.value[value] 120} 121 122function getScopeDescriptionKey(value: SearchScope): string { 123 return scopeDescriptionKeys.value[value] 124} 125 126function getDownloadRangeLabelKey(value: DownloadRange): string { 127 return downloadRangeLabelKeys.value[value] 128} 129 130function getUpdatedWithinLabelKey(value: UpdatedWithin): string { 131 return updatedWithinLabelKeys.value[value] 132} 133 134function getSecurityLabelKey(value: SecurityFilter): string { 135 return securityLabelKeys.value[value] 136} 137 138// Compact summary of active filters for collapsed header using operator syntax 139const filterSummary = computed(() => { 140 const parts: string[] = [] 141 142 // Text search with operator format 143 if (props.filters.text) { 144 if (props.filters.searchScope === 'all') { 145 // Show raw text (may already contain operators) 146 parts.push(props.filters.text) 147 } else { 148 // Convert scope to operator format 149 const operatorMap: Record<string, string> = { 150 name: 'name', 151 description: 'desc', 152 keywords: 'kw', 153 } 154 const op = operatorMap[props.filters.searchScope] ?? 'name' 155 parts.push(`${op}:${props.filters.text}`) 156 } 157 } 158 159 // Keywords from filter (not from text operators) 160 if (props.filters.keywords.length > 0) { 161 parts.push(`kw:${props.filters.keywords.join(',')}`) 162 } 163 164 // Download range (use compact value, not human label) 165 if (props.filters.downloadRange !== 'any') { 166 parts.push(`dl:${props.filters.downloadRange}`) 167 } 168 169 // Updated within (use compact value, not human label) 170 if (props.filters.updatedWithin !== 'any') { 171 parts.push(`updated:${props.filters.updatedWithin}`) 172 } 173 174 // Security (when enabled) 175 if (props.filters.security !== 'all') { 176 const label = props.filters.security === 'secure' ? 'secure' : 'warnings' 177 parts.push(`security:${label}`) 178 } 179 180 return parts.length > 0 ? parts.join(' ') : null 181}) 182 183const hasActiveFilters = computed(() => !!filterSummary.value) 184</script> 185 186<template> 187 <div class="border border-border rounded-lg bg-bg-subtle"> 188 <!-- Collapsed header --> 189 <button 190 type="button" 191 class="w-full flex items-center gap-3 px-4 py-3 text-start hover:bg-bg-muted transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset" 192 :aria-expanded="isExpanded" 193 @click="isExpanded = !isExpanded" 194 > 195 <span class="flex items-center gap-2 text-sm font-mono text-fg shrink-0"> 196 <span class="i-lucide:funnel w-4 h-4" aria-hidden="true" /> 197 {{ $t('filters.title') }} 198 </span> 199 <span v-if="!isExpanded && hasActiveFilters" class="text-xs font-mono text-fg-muted truncate"> 200 {{ filterSummary }} 201 </span> 202 <span 203 class="i-lucide:chevron-down w-4 h-4 text-fg-subtle transition-transform duration-200 shrink-0 ms-auto" 204 :class="{ 'rotate-180': isExpanded }" 205 aria-hidden="true" 206 /> 207 </button> 208 209 <!-- Expanded content --> 210 <Transition name="expand"> 211 <div v-if="isExpanded" class="px-4 pb-5 border-t border-border"> 212 <!-- Text search --> 213 <div class="pt-4"> 214 <div class="flex items-center gap-3 mb-1"> 215 <label for="filter-search" class="text-sm font-mono text-fg-muted"> 216 {{ $t('filters.search') }} 217 </label> 218 <!-- Search scope toggle --> 219 <div 220 class="inline-flex rounded-md border border-border p-0.5 bg-bg" 221 role="group" 222 :aria-label="$t('filters.search_scope')" 223 > 224 <button 225 v-for="scope in SEARCH_SCOPE_VALUES" 226 :key="scope" 227 type="button" 228 class="px-2 py-0.5 text-xs font-mono rounded-sm transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 229 :class=" 230 filters.searchScope === scope 231 ? 'bg-bg-muted text-fg' 232 : 'text-fg-muted hover:text-fg' 233 " 234 :aria-pressed="filters.searchScope === scope" 235 :title="getScopeDescriptionKey(scope)" 236 @click="emit('update:searchScope', scope)" 237 > 238 {{ getScopeLabelKey(scope) }} 239 </button> 240 </div> 241 </div> 242 <InputBase 243 id="filter-search" 244 type="text" 245 v-model="filterText" 246 :placeholder="searchPlaceholder" 247 autocomplete="off" 248 class="w-full min-w-25" 249 size="medium" 250 no-correct 251 /> 252 </div> 253 254 <!-- Download range --> 255 <fieldset class="border-0 p-0 m-0 mt-4"> 256 <legend class="block text-sm font-mono text-fg-muted mb-1"> 257 {{ $t('filters.weekly_downloads') }} 258 </legend> 259 <div 260 class="flex flex-wrap gap-2" 261 role="radiogroup" 262 :aria-label="$t('filters.weekly_downloads')" 263 > 264 <TagRadioButton 265 v-for="range in DOWNLOAD_RANGES" 266 :key="range.value" 267 :model-value="filters.downloadRange" 268 :value="range.value" 269 @update:modelValue="emit('update:downloadRange', $event as DownloadRange)" 270 name="range" 271 > 272 {{ getDownloadRangeLabelKey(range.value) }} 273 </TagRadioButton> 274 </div> 275 </fieldset> 276 277 <!-- Updated within --> 278 <fieldset class="border-0 p-0 m-0 mt-4"> 279 <legend class="block text-sm font-mono text-fg-muted mb-1"> 280 {{ $t('filters.updated_within') }} 281 </legend> 282 <div 283 class="flex flex-wrap gap-2" 284 role="radiogroup" 285 :aria-label="$t('filters.updated_within')" 286 > 287 <TagRadioButton 288 v-for="option in UPDATED_WITHIN_OPTIONS" 289 :key="option.value" 290 :model-value="filters.updatedWithin" 291 :value="option.value" 292 name="updatedWithin" 293 @update:modelValue="emit('update:updatedWithin', $event as UpdatedWithin)" 294 > 295 {{ getUpdatedWithinLabelKey(option.value) }} 296 </TagRadioButton> 297 </div> 298 </fieldset> 299 300 <!-- Security --> 301 <fieldset class="border-0 p-0 m-0 mt-4"> 302 <legend class="flex items-center gap-2 text-sm font-mono text-fg-muted mb-1"> 303 {{ $t('filters.security') }} 304 <span class="text-xs px-1.5 py-0.5 rounded bg-bg-muted text-fg-subtle"> 305 {{ $t('filters.columns.coming_soon') }} 306 </span> 307 </legend> 308 <div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')"> 309 <TagRadioButton 310 v-for="security in SECURITY_FILTER_VALUES" 311 :key="security" 312 disabled 313 :model-value="filters.security" 314 :value="security" 315 name="security" 316 > 317 {{ getSecurityLabelKey(security) }} 318 </TagRadioButton> 319 </div> 320 </fieldset> 321 322 <!-- Keywords --> 323 <fieldset v-if="displayedKeywords.length > 0" class="border-0 p-0 m-0 mt-4"> 324 <legend class="block text-sm font-mono text-fg-muted mb-1"> 325 {{ $t('filters.keywords') }} 326 </legend> 327 <div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')"> 328 <ButtonBase 329 v-for="keyword in displayedKeywords" 330 :key="keyword" 331 size="small" 332 :aria-pressed="filters.keywords.includes(keyword)" 333 @click="emit('toggleKeyword', keyword)" 334 > 335 {{ keyword }} 336 </ButtonBase> 337 <button 338 v-if="hasMoreKeywords" 339 type="button" 340 class="text-xs text-fg-subtle self-center font-mono hover:text-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 341 @click="showAllKeywords = true" 342 > 343 {{ $t('filters.more_keywords', { count: (availableKeywords?.length ?? 0) - 20 }) }} 344 </button> 345 </div> 346 </fieldset> 347 </div> 348 </Transition> 349 </div> 350</template> 351 352<style scoped> 353.expand-enter-active, 354.expand-leave-active { 355 transition: 356 opacity 0.2s ease, 357 max-height 0.2s ease, 358 padding 0.2s ease; 359 overflow: hidden; 360} 361 362.expand-enter-from, 363.expand-leave-to { 364 opacity: 0; 365 max-height: 0; 366 padding-top: 0; 367 padding-bottom: 0; 368} 369 370.expand-enter-to, 371.expand-leave-from { 372 max-height: 500px; 373} 374</style>