Skip to content

Commit 2f07010

Browse files
committed
feat: add keyboard navigation to compare page package search
1 parent 9aae078 commit 2f07010

1 file changed

Lines changed: 118 additions & 25 deletions

File tree

app/components/Compare/PackageSelector.vue

Lines changed: 118 additions & 25 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 { data: searchData, status } = useSearch(inputValue, { size: 15 })
1924
@@ -53,6 +58,20 @@ const filteredResults = computed(() => {
5358
.filter(r => !packages.value.includes(r.name))
5459
})
5560
61+
// Unified list of navigable items for keyboard navigation
62+
const navigableItems = computed(() => {
63+
const items: { type: 'no-dependency' | 'package'; name: string }[] = []
64+
if (showNoDependencyOption.value) {
65+
items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID })
66+
}
67+
for (const r of filteredResults.value) {
68+
items.push({ type: 'package', name: r.name })
69+
}
70+
return items
71+
})
72+
73+
const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0))
74+
5675
const numberFormatter = useNumberFormatter()
5776
5877
function addPackage(name: string) {
@@ -70,30 +89,93 @@ function addPackage(name: string) {
7089
packages.value = [...packages.value, name]
7190
}
7291
inputValue.value = ''
92+
highlightedIndex.value = -1
7393
}
7494
7595
function removePackage(name: string) {
7696
packages.value = packages.value.filter(p => p !== name)
7797
}
7898
7999
function handleKeydown(e: KeyboardEvent) {
80-
const inputValueTrim = inputValue.value.trim()
81-
const hasMatchInPackages = filteredResults.value.find(result => {
82-
return result.name === inputValueTrim
83-
})
84-
85-
if (e.key === 'Enter' && inputValueTrim) {
86-
e.preventDefault()
87-
if (showNoDependencyOption.value) {
88-
addPackage(NO_DEPENDENCY_ID)
89-
} else if (hasMatchInPackages) {
90-
addPackage(inputValueTrim)
100+
const items = navigableItems.value
101+
const count = items.length
102+
103+
switch (e.key) {
104+
case 'ArrowDown':
105+
e.preventDefault()
106+
if (count === 0) return
107+
highlightedIndex.value = Math.min(highlightedIndex.value + 1, count - 1)
108+
break
109+
110+
case 'ArrowUp':
111+
e.preventDefault()
112+
if (count === 0) return
113+
if (highlightedIndex.value > 0) {
114+
highlightedIndex.value--
115+
}
116+
break
117+
118+
case 'PageDown':
119+
e.preventDefault()
120+
if (count === 0) return
121+
if (highlightedIndex.value === -1) {
122+
highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1)
123+
} else {
124+
highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1)
125+
}
126+
break
127+
128+
case 'PageUp':
129+
e.preventDefault()
130+
if (count === 0) return
131+
highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0)
132+
break
133+
134+
case 'Enter': {
135+
const inputValueTrim = inputValue.value.trim()
136+
if (!inputValueTrim) return
137+
138+
e.preventDefault()
139+
140+
// If an item is highlighted, select it
141+
if (highlightedIndex.value >= 0 && highlightedIndex.value < count) {
142+
addPackage(items[highlightedIndex.value]!.name)
143+
return
144+
}
145+
146+
// Fallback: exact match or easter egg (preserves existing behavior)
147+
if (showNoDependencyOption.value) {
148+
addPackage(NO_DEPENDENCY_ID)
149+
} else {
150+
const hasMatch = filteredResults.value.find(r => r.name === inputValueTrim)
151+
if (hasMatch) {
152+
addPackage(inputValueTrim)
153+
}
154+
}
155+
break
91156
}
92-
} else if (e.key === 'Escape') {
93-
inputValue.value = ''
157+
158+
case 'Escape':
159+
inputValue.value = ''
160+
highlightedIndex.value = -1
161+
break
94162
}
95163
}
96164
165+
// Reset highlight when user types
166+
watch(inputValue, () => {
167+
highlightedIndex.value = -1
168+
})
169+
170+
// Scroll highlighted item into view
171+
watch(highlightedIndex, index => {
172+
if (index >= 0 && listRef.value) {
173+
const items = listRef.value.querySelectorAll('[data-navigable]')
174+
const item = items[index] as HTMLElement | undefined
175+
item?.scrollIntoView({ block: 'nearest' })
176+
}
177+
})
178+
97179
const { start, stop } = useTimeoutFn(() => {
98180
isInputFocused.value = false
99181
}, 200)
@@ -175,16 +257,17 @@ function handleFocus() {
175257
leave-to-class="opacity-0"
176258
>
177259
<div
178-
v-if="
179-
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
180-
"
260+
v-if="isInputFocused && (navigableItems.length > 0 || isSearching)"
261+
ref="listRef"
181262
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"
182263
>
183264
<!-- No dependency option (easter egg with James) -->
184-
<ButtonBase
265+
<div
185266
v-if="showNoDependencyOption"
186-
class="block w-full text-start"
187-
:aria-label="$t('compare.no_dependency.add_column')"
267+
data-navigable
268+
class="cursor-pointer px-4 py-3 transition-colors duration-100"
269+
:class="highlightedIndex === 0 ? 'bg-accent/15 text-fg' : 'hover:bg-bg-subtle'"
270+
@mouseenter="highlightedIndex = 0"
188271
@click="addPackage(NO_DEPENDENCY_ID)"
189272
>
190273
<span class="text-sm text-accent italic flex items-center gap-2">
@@ -194,15 +277,25 @@ function handleFocus() {
194277
<span class="text-xs text-fg-muted truncate mt-0.5">
195278
{{ $t('compare.no_dependency.typeahead_description') }}
196279
</span>
197-
</ButtonBase>
280+
</div>
198281

199-
<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
282+
<div
283+
v-if="isSearching && navigableItems.length === 0"
284+
class="px-4 py-3 text-sm text-fg-muted"
285+
>
200286
{{ $t('compare.selector.searching') }}
201287
</div>
202-
<ButtonBase
203-
v-for="result in filteredResults"
288+
<div
289+
v-for="(result, index) in filteredResults"
204290
:key="result.name"
205-
class="block w-full text-start"
291+
data-navigable
292+
class="cursor-pointer block w-full text-start px-4 py-3 transition-colors duration-100"
293+
:class="
294+
highlightedIndex === index + resultIndexOffset
295+
? 'bg-accent/15 text-fg'
296+
: 'hover:bg-bg-subtle'
297+
"
298+
@mouseenter="highlightedIndex = index + resultIndexOffset"
206299
@click="addPackage(result.name)"
207300
>
208301
<span class="font-mono text-sm text-fg block">{{ result.name }}</span>
@@ -212,7 +305,7 @@ function handleFocus() {
212305
>
213306
{{ result.description }}
214307
</span>
215-
</ButtonBase>
308+
</div>
216309
</div>
217310
</Transition>
218311
</div>

0 commit comments

Comments
 (0)