Skip to content

Commit c28f514

Browse files
iiio2whitep4nth3r
andauthored
feat: add keyboard navigation to compare page package search (#1377)
Co-authored-by: Salma Alam-Naylor <52798353+whitep4nth3r@users.noreply.github.com>
1 parent eac8537 commit c28f514

File tree

1 file changed

+108
-18
lines changed

1 file changed

+108
-18
lines changed

app/components/Compare/PackageSelector.vue

Lines changed: 108 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const maxPackages = computed(() => props.max ?? 4)
1414
const inputValue = shallowRef('')
1515
const isInputFocused = shallowRef(false)
1616
17+
// Keyboard navigation state
18+
const highlightedIndex = shallowRef(-1)
19+
const listRef = useTemplateRef('listRef')
20+
const PAGE_JUMP = 5
21+
1722
// Use the shared search composable (supports both npm and Algolia providers)
1823
const { searchProvider } = useSearchProvider()
1924
const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 })
@@ -54,6 +59,20 @@ const filteredResults = computed(() => {
5459
.filter(r => !packages.value.includes(r.name))
5560
})
5661
62+
// Unified list of navigable items for keyboard navigation
63+
const navigableItems = computed(() => {
64+
const items: { type: 'no-dependency' | 'package'; name: string }[] = []
65+
if (showNoDependencyOption.value) {
66+
items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID })
67+
}
68+
for (const r of filteredResults.value) {
69+
items.push({ type: 'package', name: r.name })
70+
}
71+
return items
72+
})
73+
74+
const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0))
75+
5776
const numberFormatter = useNumberFormatter()
5877
5978
function addPackage(name: string) {
@@ -71,30 +90,93 @@ function addPackage(name: string) {
7190
packages.value = [...packages.value, name]
7291
}
7392
inputValue.value = ''
93+
highlightedIndex.value = -1
7494
}
7595
7696
function removePackage(name: string) {
7797
packages.value = packages.value.filter(p => p !== name)
7898
}
7999
80100
function handleKeydown(e: KeyboardEvent) {
81-
const inputValueTrim = inputValue.value.trim()
82-
const hasMatchInPackages = filteredResults.value.find(result => {
83-
return result.name === inputValueTrim
84-
})
85-
86-
if (e.key === 'Enter' && inputValueTrim) {
87-
e.preventDefault()
88-
if (showNoDependencyOption.value) {
89-
addPackage(NO_DEPENDENCY_ID)
90-
} else if (hasMatchInPackages) {
91-
addPackage(inputValueTrim)
101+
const items = navigableItems.value
102+
const count = items.length
103+
104+
switch (e.key) {
105+
case 'ArrowDown':
106+
e.preventDefault()
107+
if (count === 0) return
108+
highlightedIndex.value = Math.min(highlightedIndex.value + 1, count - 1)
109+
break
110+
111+
case 'ArrowUp':
112+
e.preventDefault()
113+
if (count === 0) return
114+
if (highlightedIndex.value > 0) {
115+
highlightedIndex.value--
116+
}
117+
break
118+
119+
case 'PageDown':
120+
e.preventDefault()
121+
if (count === 0) return
122+
if (highlightedIndex.value === -1) {
123+
highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1)
124+
} else {
125+
highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1)
126+
}
127+
break
128+
129+
case 'PageUp':
130+
e.preventDefault()
131+
if (count === 0) return
132+
highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0)
133+
break
134+
135+
case 'Enter': {
136+
const inputValueTrim = inputValue.value.trim()
137+
if (!inputValueTrim) return
138+
139+
e.preventDefault()
140+
141+
// If an item is highlighted, select it
142+
if (highlightedIndex.value >= 0 && highlightedIndex.value < count) {
143+
addPackage(items[highlightedIndex.value]!.name)
144+
return
145+
}
146+
147+
// Fallback: exact match or easter egg (preserves existing behavior)
148+
if (showNoDependencyOption.value) {
149+
addPackage(NO_DEPENDENCY_ID)
150+
} else {
151+
const hasMatch = filteredResults.value.find(r => r.name === inputValueTrim)
152+
if (hasMatch) {
153+
addPackage(inputValueTrim)
154+
}
155+
}
156+
break
92157
}
93-
} else if (e.key === 'Escape') {
94-
inputValue.value = ''
158+
159+
case 'Escape':
160+
inputValue.value = ''
161+
highlightedIndex.value = -1
162+
break
95163
}
96164
}
97165
166+
// Reset highlight when user types
167+
watch(inputValue, () => {
168+
highlightedIndex.value = -1
169+
})
170+
171+
// Scroll highlighted item into view
172+
watch(highlightedIndex, index => {
173+
if (index >= 0 && listRef.value) {
174+
const items = listRef.value.querySelectorAll('[data-navigable]')
175+
const item = items[index] as HTMLElement | undefined
176+
item?.scrollIntoView({ block: 'nearest' })
177+
}
178+
})
179+
98180
const { start, stop } = useTimeoutFn(() => {
99181
isInputFocused.value = false
100182
}, 200)
@@ -176,16 +258,18 @@ function handleFocus() {
176258
leave-to-class="opacity-0"
177259
>
178260
<div
179-
v-if="
180-
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
181-
"
261+
v-if="isInputFocused && (navigableItems.length > 0 || isSearching)"
262+
ref="listRef"
182263
class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto"
183264
>
184265
<!-- No dependency option (easter egg with James) -->
185266
<ButtonBase
186267
v-if="showNoDependencyOption"
268+
data-navigable
187269
class="block w-full text-start"
270+
:class="highlightedIndex === 0 ? '!bg-accent/15' : ''"
188271
:aria-label="$t('compare.no_dependency.add_column')"
272+
@mouseenter="highlightedIndex = 0"
189273
@click="addPackage(NO_DEPENDENCY_ID)"
190274
>
191275
<span class="text-sm text-accent italic flex items-center gap-2">
@@ -197,13 +281,19 @@ function handleFocus() {
197281
</span>
198282
</ButtonBase>
199283

200-
<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
284+
<div
285+
v-if="isSearching && navigableItems.length === 0"
286+
class="px-4 py-3 text-sm text-fg-muted"
287+
>
201288
{{ $t('compare.selector.searching') }}
202289
</div>
203290
<ButtonBase
204-
v-for="result in filteredResults"
291+
v-for="(result, index) in filteredResults"
205292
:key="result.name"
293+
data-navigable
206294
class="block w-full text-start"
295+
:class="highlightedIndex === index + resultIndexOffset ? '!bg-accent/15' : ''"
296+
@mouseenter="highlightedIndex = index + resultIndexOffset"
207297
@click="addPackage(result.name)"
208298
>
209299
<span class="font-mono text-sm text-fg block">{{ result.name }}</span>

0 commit comments

Comments
 (0)