@@ -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 { searchProvider } = useSearchProvider ()
1924const { 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+
5776const numberFormatter = useNumberFormatter ()
5877
5978function 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
7696function removePackage(name : string ) {
7797 packages .value = packages .value .filter (p => p !== name )
7898}
7999
80100function 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+
98180const { 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