Skip to content

Commit 3589f6b

Browse files
committed
feat: enhance package list UX with table view, filters, and sorts
Add advanced filtering, sorting, and view options to package list pages. - Add card/table view mode toggle with localStorage persistence - Add table view with sortable columns (name, downloads, last updated), with asc/desc toggle - Add structured filtering: text search, download range, keywords (AND), updated since - Add a few search operators for power users: `name:`, `desc:`, `kw:` (e.g. `name:react kw:hooks`) - Add column picker for table view with disabled "coming soon" columns - Add pagination options (infinite scroll vs paginated with page size) - When "Filters" panel is collapsed, show a summary of active filters in operator syntax! - Make keywords in table clickable to add to filters - Make maintainers clickable links to user pages
1 parent 64d0bc6 commit 3589f6b

21 files changed

Lines changed: 3882 additions & 131 deletions

app/components/ColumnPicker.vue

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<script setup lang="ts">
2+
import type { ColumnConfig, ColumnId } from '~~/shared/types/preferences'
3+
4+
const props = defineProps<{
5+
columns: ColumnConfig[]
6+
}>()
7+
8+
const emit = defineEmits<{
9+
toggle: [columnId: ColumnId]
10+
reset: []
11+
}>()
12+
13+
const isOpen = ref(false)
14+
const buttonRef = ref<HTMLButtonElement>()
15+
const menuId = useId()
16+
17+
// Close on click outside
18+
function handleClickOutside(event: MouseEvent) {
19+
if (buttonRef.value && !buttonRef.value.contains(event.target as Node)) {
20+
isOpen.value = false
21+
}
22+
}
23+
24+
// Close on Escape key
25+
function handleKeydown(event: KeyboardEvent) {
26+
if (event.key === 'Escape' && isOpen.value) {
27+
isOpen.value = false
28+
buttonRef.value?.focus()
29+
}
30+
}
31+
32+
onMounted(() => {
33+
document.addEventListener('click', handleClickOutside)
34+
document.addEventListener('keydown', handleKeydown)
35+
})
36+
37+
onUnmounted(() => {
38+
document.removeEventListener('click', handleClickOutside)
39+
document.removeEventListener('keydown', handleKeydown)
40+
})
41+
42+
// Columns that can be toggled (name is always visible)
43+
const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name'))
44+
45+
const { t } = useI18n()
46+
47+
// Map column IDs to i18n keys
48+
const columnLabelKey: Record<string, string> = {
49+
name: 'filters.columns.name',
50+
version: 'filters.columns.version',
51+
description: 'filters.columns.description',
52+
downloads: 'filters.columns.downloads',
53+
updated: 'filters.columns.updated',
54+
maintainers: 'filters.columns.maintainers',
55+
keywords: 'filters.columns.keywords',
56+
qualityScore: 'filters.columns.quality_score',
57+
popularityScore: 'filters.columns.popularity_score',
58+
maintenanceScore: 'filters.columns.maintenance_score',
59+
combinedScore: 'filters.columns.combined_score',
60+
security: 'filters.columns.security',
61+
}
62+
63+
function getColumnLabel(id: string): string {
64+
const key = columnLabelKey[id]
65+
return key ? t(key) : id
66+
}
67+
68+
function handleReset() {
69+
emit('reset')
70+
isOpen.value = false
71+
}
72+
</script>
73+
74+
<template>
75+
<div class="relative">
76+
<button
77+
ref="buttonRef"
78+
type="button"
79+
class="btn-ghost inline-flex items-center gap-1.5 px-3 py-1.5 border border-border rounded-md hover:border-border-hover"
80+
:aria-expanded="isOpen"
81+
aria-haspopup="true"
82+
:aria-controls="menuId"
83+
@click.stop="isOpen = !isOpen"
84+
>
85+
<span class="i-carbon-column w-4 h-4" aria-hidden="true" />
86+
<span class="font-mono text-sm">{{ $t('filters.columns.title') }}</span>
87+
</button>
88+
89+
<Transition name="dropdown">
90+
<div
91+
v-if="isOpen"
92+
:id="menuId"
93+
class="absolute right-0 mt-2 w-56 bg-bg-subtle border border-border rounded-lg shadow-lg z-20"
94+
role="group"
95+
:aria-label="$t('filters.columns.show')"
96+
@click.stop
97+
>
98+
<div class="py-1">
99+
<div
100+
class="px-3 py-2 text-xs font-mono text-fg-subtle uppercase tracking-wider border-b border-border"
101+
aria-hidden="true"
102+
>
103+
{{ $t('filters.columns.show') }}
104+
</div>
105+
106+
<div class="py-1 max-h-64 overflow-y-auto">
107+
<label
108+
v-for="column in toggleableColumns"
109+
:key="column.id"
110+
class="flex items-center px-3 py-2 transition-colors duration-200"
111+
:class="
112+
column.disabled
113+
? 'opacity-50 cursor-not-allowed'
114+
: 'hover:bg-bg-muted cursor-pointer'
115+
"
116+
>
117+
<input
118+
type="checkbox"
119+
:checked="column.visible"
120+
:disabled="column.disabled"
121+
:aria-describedby="column.disabled ? `${column.id}-disabled-reason` : undefined"
122+
class="w-4 h-4 accent-fg bg-bg-muted border-border rounded disabled:opacity-50"
123+
@change="!column.disabled && emit('toggle', column.id)"
124+
/>
125+
<span class="ml-2 text-sm text-fg-muted font-mono flex-1">
126+
{{ getColumnLabel(column.id) }}
127+
</span>
128+
<span
129+
v-if="column.disabled"
130+
:id="`${column.id}-disabled-reason`"
131+
class="text-xs text-fg-subtle italic"
132+
>
133+
{{ $t('filters.columns.coming_soon') }}
134+
</span>
135+
</label>
136+
</div>
137+
138+
<div class="border-t border-border py-1">
139+
<button
140+
type="button"
141+
class="w-full px-3 py-2 text-left text-sm font-mono text-fg-muted hover:bg-bg-muted hover:text-fg transition-colors duration-200"
142+
@click="handleReset"
143+
>
144+
{{ $t('filters.columns.reset') }}
145+
</button>
146+
</div>
147+
</div>
148+
</div>
149+
</Transition>
150+
</div>
151+
</template>
152+
153+
<style scoped>
154+
.dropdown-enter-active,
155+
.dropdown-leave-active {
156+
transition: all 0.15s ease;
157+
}
158+
159+
.dropdown-enter-from,
160+
.dropdown-leave-to {
161+
opacity: 0;
162+
transform: translateY(-4px);
163+
}
164+
</style>

app/components/FilterChips.vue

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<script setup lang="ts">
2+
import type { FilterChip } from '~~/shared/types/preferences'
3+
4+
defineProps<{
5+
chips: FilterChip[]
6+
}>()
7+
8+
const emit = defineEmits<{
9+
remove: [chip: FilterChip]
10+
clearAll: []
11+
}>()
12+
</script>
13+
14+
<template>
15+
<div v-if="chips.length > 0" class="flex flex-wrap items-center gap-2">
16+
<TransitionGroup name="chip">
17+
<span v-for="chip in chips" :key="chip.id" class="tag gap-1">
18+
<span class="text-fg-subtle text-xs">{{ chip.label }}:</span>
19+
<span class="max-w-32 truncate">{{
20+
Array.isArray(chip.value) ? chip.value.join(', ') : chip.value
21+
}}</span>
22+
<button
23+
type="button"
24+
class="ml-0.5 hover:text-fg rounded-full p-0.5 transition-colors duration-200"
25+
:aria-label="$t('filters.remove_filter', { label: chip.label })"
26+
@click="emit('remove', chip)"
27+
>
28+
<span class="i-carbon-close w-3 h-3" aria-hidden="true" />
29+
</button>
30+
</span>
31+
</TransitionGroup>
32+
33+
<button
34+
v-if="chips.length > 1"
35+
type="button"
36+
class="text-sm text-fg-subtle hover:text-fg underline transition-colors duration-200"
37+
@click="emit('clearAll')"
38+
>
39+
{{ $t('filters.clear_all') }}
40+
</button>
41+
</div>
42+
</template>
43+
44+
<style scoped>
45+
.chip-enter-active,
46+
.chip-leave-active {
47+
transition: all 0.2s ease;
48+
}
49+
50+
.chip-enter-from,
51+
.chip-leave-to {
52+
opacity: 0;
53+
transform: scale(0.8);
54+
}
55+
56+
.chip-move {
57+
transition: transform 0.2s ease;
58+
}
59+
</style>

0 commit comments

Comments
 (0)