Skip to content

Commit e795eed

Browse files
serhalpdanielroe
andauthored
feat: enhance package list UX with table view, filters, and sorts (#211)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 36474fe commit e795eed

27 files changed

Lines changed: 4831 additions & 258 deletions

CONTRIBUTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,18 @@ import { hasProtocol } from 'ufo'
189189
| Constants | SCREAMING_SNAKE_CASE | `NPM_REGISTRY`, `ALLOWED_TAGS` |
190190
| Types/Interfaces | PascalCase | `NpmSearchResponse` |
191191

192+
> [!TIP]
193+
> Exports in `app/composables/`, `app/utils/`, and `server/utils/` are auto-imported by Nuxt. To prevent [knip](https://knip.dev/) from flagging them as unused, add a `@public` JSDoc annotation:
194+
>
195+
> ```typescript
196+
> /**
197+
> * @public
198+
> */
199+
> export function myAutoImportedFunction() {
200+
> // ...
201+
> }
202+
> ```
203+
192204
### Vue components
193205
194206
- Use Composition API with `<script setup lang="ts">`

app/components/ColumnPicker.vue

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

app/components/FilterChips.vue

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
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 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2"
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:
48+
opacity 0.2s ease,
49+
transform 0.2s ease;
50+
}
51+
52+
.chip-enter-from,
53+
.chip-leave-to {
54+
opacity: 0;
55+
transform: scale(0.8);
56+
}
57+
58+
.chip-move {
59+
transition: transform 0.2s ease;
60+
}
61+
</style>

0 commit comments

Comments
 (0)