Something went wrong. Try again.
[READ-ONLY]
a fast, modern browser for the npm registry forked from npmx.dev/npmx.dev
Something went wrong. Try again.
12 kB · 374 lines
Vue
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375<script setup lang="ts">import type { DownloadRange, SearchScope, SecurityFilter, StructuredFilters, UpdatedWithin,} from '#shared/types/preferences'import { DOWNLOAD_RANGES, SEARCH_SCOPE_VALUES, SECURITY_FILTER_VALUES, UPDATED_WITHIN_OPTIONS,} from '#shared/types/preferences'
const props = defineProps<{ filters: StructuredFilters availableKeywords?: string[]}>()
const emit = defineEmits<{ 'update:text': [value: string] 'update:searchScope': [value: SearchScope] 'update:downloadRange': [value: DownloadRange] 'update:security': [value: SecurityFilter] 'update:updatedWithin': [value: UpdatedWithin] 'toggleKeyword': [keyword: string]}>()
const { t } = useI18n()
const isExpanded = shallowRef(false)const showAllKeywords = shallowRef(false)
const filterText = computed({ get: () => props.filters.text, set: value => emit('update:text', value),})
const displayedKeywords = computed(() => { const keywords = props.availableKeywords ?? [] return showAllKeywords.value ? keywords : keywords.slice(0, 20)})
const searchPlaceholder = computed(() => { switch (props.filters.searchScope) { case 'name': return $t('filters.search_placeholder_name') case 'description': return $t('filters.search_placeholder_description') case 'keywords': return $t('filters.search_placeholder_keywords') case 'all': return $t('filters.search_placeholder_all') default: return $t('filters.search_placeholder_name') }})
const hasMoreKeywords = computed(() => { return !showAllKeywords.value && (props.availableKeywords?.length ?? 0) > 20})
// i18n mappings for filter optionsconst scopeLabelKeys = computed( () => ({ name: t('filters.scope_name'), description: t('filters.scope_description'), keywords: t('filters.scope_keywords'), all: t('filters.scope_all'), }) as const,)
const scopeDescriptionKeys = computed( () => ({ name: t('filters.scope_name_description'), description: t('filters.scope_description_description'), keywords: t('filters.scope_keywords_description'), all: t('filters.scope_all_description'), }) as const,)
const downloadRangeLabelKeys = computed( () => ({ 'any': t('filters.download_range.any'), 'lt100': t('filters.download_range.lt100'), '100-1k': t('filters.download_range.100_1k'), '1k-10k': t('filters.download_range.1k_10k'), '10k-100k': t('filters.download_range.10k_100k'), 'gt100k': t('filters.download_range.gt100k'), }) as const,)
const updatedWithinLabelKeys = computed( () => ({ any: t('filters.updated.any'), week: t('filters.updated.week'), month: t('filters.updated.month'), quarter: t('filters.updated.quarter'), year: t('filters.updated.year'), }) as const,)
const securityLabelKeys = computed( () => ({ all: t('filters.security_options.all'), secure: t('filters.security_options.secure'), warnings: t('filters.security_options.insecure'), }) as const,)
// Type-safe accessor functionsfunction getScopeLabelKey(value: SearchScope): string { return scopeLabelKeys.value[value]}
function getScopeDescriptionKey(value: SearchScope): string { return scopeDescriptionKeys.value[value]}
function getDownloadRangeLabelKey(value: DownloadRange): string { return downloadRangeLabelKeys.value[value]}
function getUpdatedWithinLabelKey(value: UpdatedWithin): string { return updatedWithinLabelKeys.value[value]}
function getSecurityLabelKey(value: SecurityFilter): string { return securityLabelKeys.value[value]}
// Compact summary of active filters for collapsed header using operator syntaxconst filterSummary = computed(() => { const parts: string[] = []
// Text search with operator format if (props.filters.text) { if (props.filters.searchScope === 'all') { // Show raw text (may already contain operators) parts.push(props.filters.text) } else { // Convert scope to operator format const operatorMap: Record<string, string> = { name: 'name', description: 'desc', keywords: 'kw', } const op = operatorMap[props.filters.searchScope] ?? 'name' parts.push(`${op}:${props.filters.text}`) } }
// Keywords from filter (not from text operators) if (props.filters.keywords.length > 0) { parts.push(`kw:${props.filters.keywords.join(',')}`) }
// Download range (use compact value, not human label) if (props.filters.downloadRange !== 'any') { parts.push(`dl:${props.filters.downloadRange}`) }
// Updated within (use compact value, not human label) if (props.filters.updatedWithin !== 'any') { parts.push(`updated:${props.filters.updatedWithin}`) }
// Security (when enabled) if (props.filters.security !== 'all') { const label = props.filters.security === 'secure' ? 'secure' : 'warnings' parts.push(`security:${label}`) }
return parts.length > 0 ? parts.join(' ') : null})
const hasActiveFilters = computed(() => !!filterSummary.value)</script>
<template> <div class="border border-border rounded-lg bg-bg-subtle"> <!-- Collapsed header --> <button type="button" 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" :aria-expanded="isExpanded" @click="isExpanded = !isExpanded" > <span class="flex items-center gap-2 text-sm font-mono text-fg shrink-0"> <span class="i-lucide:funnel w-4 h-4" aria-hidden="true" /> {{ $t('filters.title') }} </span> <span v-if="!isExpanded && hasActiveFilters" class="text-xs font-mono text-fg-muted truncate"> {{ filterSummary }} </span> <span class="i-lucide:chevron-down w-4 h-4 text-fg-subtle transition-transform duration-200 shrink-0 ms-auto" :class="{ 'rotate-180': isExpanded }" aria-hidden="true" /> </button>
<!-- Expanded content --> <Transition name="expand"> <div v-if="isExpanded" class="px-4 pb-5 border-t border-border"> <!-- Text search --> <div class="pt-4"> <div class="flex items-center gap-3 mb-1"> <label for="filter-search" class="text-sm font-mono text-fg-muted"> {{ $t('filters.search') }} </label> <!-- Search scope toggle --> <div class="inline-flex rounded-md border border-border p-0.5 bg-bg" role="group" :aria-label="$t('filters.search_scope')" > <button v-for="scope in SEARCH_SCOPE_VALUES" :key="scope" type="button" 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" :class=" filters.searchScope === scope ? 'bg-bg-muted text-fg' : 'text-fg-muted hover:text-fg' " :aria-pressed="filters.searchScope === scope" :title="getScopeDescriptionKey(scope)" @click="emit('update:searchScope', scope)" > {{ getScopeLabelKey(scope) }} </button> </div> </div> <InputBase id="filter-search" type="text" v-model="filterText" :placeholder="searchPlaceholder" autocomplete="off" class="w-full min-w-25" size="medium" no-correct /> </div>
<!-- Download range --> <fieldset class="border-0 p-0 m-0 mt-4"> <legend class="block text-sm font-mono text-fg-muted mb-1"> {{ $t('filters.weekly_downloads') }} </legend> <div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.weekly_downloads')" > <TagRadioButton v-for="range in DOWNLOAD_RANGES" :key="range.value" :model-value="filters.downloadRange" :value="range.value" @update:modelValue="emit('update:downloadRange', $event as DownloadRange)" name="range" > {{ getDownloadRangeLabelKey(range.value) }} </TagRadioButton> </div> </fieldset>
<!-- Updated within --> <fieldset class="border-0 p-0 m-0 mt-4"> <legend class="block text-sm font-mono text-fg-muted mb-1"> {{ $t('filters.updated_within') }} </legend> <div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.updated_within')" > <TagRadioButton v-for="option in UPDATED_WITHIN_OPTIONS" :key="option.value" :model-value="filters.updatedWithin" :value="option.value" name="updatedWithin" @update:modelValue="emit('update:updatedWithin', $event as UpdatedWithin)" > {{ getUpdatedWithinLabelKey(option.value) }} </TagRadioButton> </div> </fieldset>
<!-- Security --> <fieldset class="border-0 p-0 m-0 mt-4"> <legend class="flex items-center gap-2 text-sm font-mono text-fg-muted mb-1"> {{ $t('filters.security') }} <span class="text-xs px-1.5 py-0.5 rounded bg-bg-muted text-fg-subtle"> {{ $t('filters.columns.coming_soon') }} </span> </legend> <div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')"> <TagRadioButton v-for="security in SECURITY_FILTER_VALUES" :key="security" disabled :model-value="filters.security" :value="security" name="security" > {{ getSecurityLabelKey(security) }} </TagRadioButton> </div> </fieldset>
<!-- Keywords --> <fieldset v-if="displayedKeywords.length > 0" class="border-0 p-0 m-0 mt-4"> <legend class="block text-sm font-mono text-fg-muted mb-1"> {{ $t('filters.keywords') }} </legend> <div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')"> <ButtonBase v-for="keyword in displayedKeywords" :key="keyword" size="small" :aria-pressed="filters.keywords.includes(keyword)" @click="emit('toggleKeyword', keyword)" > {{ keyword }} </ButtonBase> <button v-if="hasMoreKeywords" type="button" 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" @click="showAllKeywords = true" > {{ $t('filters.more_keywords', { count: (availableKeywords?.length ?? 0) - 20 }) }} </button> </div> </fieldset> </div> </Transition> </div></template>
<style scoped>.expand-enter-active,.expand-leave-active { transition: opacity 0.2s ease, max-height 0.2s ease, padding 0.2s ease; overflow: hidden;}
.expand-enter-from,.expand-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0;}
.expand-enter-to,.expand-leave-from { max-height: 500px;}</style>