forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>