@@ -14,6 +14,11 @@ const maxPackages = computed(() => props.max ?? 4)
1414const inputValue = shallowRef (' ' )
1515const 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)
1823const { 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+
5675const numberFormatter = useNumberFormatter ()
5776
5877function 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
7595function removePackage(name : string ) {
7696 packages .value = packages .value .filter (p => p !== name )
7797}
7898
7999function 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+
97179const { 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