@@ -47,6 +47,67 @@ watch(
4747const isSearchFocused = ref (false )
4848const searchInputRef = ref <HTMLInputElement >()
4949
50+ const selectedIndex = ref (0 )
51+ const packageListRef = useTemplateRef (' packageListRef' )
52+
53+ const resultCount = computed (() => visibleResults .value ?.objects .length ?? 0 )
54+
55+ function clampIndex(next : number ) {
56+ if (resultCount .value <= 0 ) return 0
57+ return Math .max (0 , Math .min (resultCount .value - 1 , next ))
58+ }
59+
60+ function scrollToSelectedResult() {
61+ // Use virtualizer's scrollToIndex to ensure the item is rendered and visible
62+ packageListRef .value ?.scrollToIndex (selectedIndex .value )
63+ }
64+
65+ function focusSelectedResult() {
66+ // First ensure the item is rendered by scrolling to it
67+ scrollToSelectedResult ()
68+ // Then focus it after a tick to allow rendering
69+ nextTick (() => {
70+ const el = document .querySelector <HTMLElement >(` [data-result-index="${selectedIndex .value }"] ` )
71+ el ?.focus ()
72+ })
73+ }
74+
75+ function handleResultsKeydown(e : KeyboardEvent ) {
76+ if (resultCount .value <= 0 ) return
77+
78+ const isFromInput = (e .target as HTMLElement ).tagName === ' INPUT'
79+
80+ if (e .key === ' ArrowDown' ) {
81+ e .preventDefault ()
82+ selectedIndex .value = clampIndex (selectedIndex .value + 1 )
83+ // Only move focus if already in results, not when typing in search input
84+ if (isFromInput ) {
85+ scrollToSelectedResult ()
86+ } else {
87+ focusSelectedResult ()
88+ }
89+ return
90+ }
91+
92+ if (e .key === ' ArrowUp' ) {
93+ e .preventDefault ()
94+ selectedIndex .value = clampIndex (selectedIndex .value - 1 )
95+ if (isFromInput ) {
96+ scrollToSelectedResult ()
97+ } else {
98+ focusSelectedResult ()
99+ }
100+ return
101+ }
102+
103+ if (e .key === ' Enter' ) {
104+ const el = document .querySelector <HTMLElement >(` [data-result-index="${selectedIndex .value }"] ` )
105+ if (! el ) return
106+ e .preventDefault ()
107+ el .click ()
108+ }
109+ }
110+
50111// Track if page just loaded (for hiding "Searching..." during view transition)
51112const hasInteracted = ref (false )
52113onMounted (() => {
@@ -148,12 +209,22 @@ function handlePageChange(page: number) {
148209 updateUrlPage (page )
149210}
150211
212+ function handleSelect(index : number ) {
213+ if (index < 0 ) return
214+ selectedIndex .value = clampIndex (index )
215+ }
216+
151217// Reset pages when query changes
152218watch (query , () => {
153219 loadedPages .value = 1
154220 hasInteracted .value = true
155221})
156222
223+ // Reset selection when query changes (new search)
224+ watch (query , () => {
225+ selectedIndex .value = 0
226+ })
227+
157228// Check if current query could be a valid package name
158229const isValidPackageName = computed (() => isValidNewPackageName (query .value .trim ()))
159230
@@ -299,6 +370,7 @@ defineOgImageComponent('Default', {
299370 class =" w-full max-w-full bg-bg-subtle border border-border rounded-lg pl-8 pr-4 py-3 font-mono text-base text-fg placeholder:text-fg-subtle transition-colors duration-300 focus:(border-border-hover outline-none) appearance-none"
300371 @focus =" isSearchFocused = true"
301372 @blur =" isSearchFocused = false"
373+ @keydown =" handleResultsKeydown"
302374 />
303375 <!-- Hidden submit button for accessibility (form must have submit button per WCAG) -->
304376 <button type =" submit" class =" sr-only" >Search</button >
@@ -311,7 +383,7 @@ defineOgImageComponent('Default', {
311383
312384 <!-- Results area with container padding -->
313385 <div class =" container pt-20 pb-6" >
314- <section v-if =" query" aria-label =" Search results" >
386+ <section v-if =" query" aria-label =" Search results" @keydown = " handleResultsKeydown " >
315387 <!-- Initial loading (only after user interaction, not during view transition) -->
316388 <LoadingSpinner v-if =" showSearching" text =" Searching…" />
317389
@@ -370,7 +442,9 @@ defineOgImageComponent('Default', {
370442
371443 <PackageList
372444 v-if =" visibleResults.objects.length > 0"
445+ ref =" packageListRef"
373446 :results =" visibleResults.objects"
447+ :selected-index =" selectedIndex"
374448 heading-level =" h2"
375449 show-publisher
376450 :has-more =" hasMore"
@@ -379,6 +453,7 @@ defineOgImageComponent('Default', {
379453 :initial-page =" initialPage"
380454 @load-more =" loadMore"
381455 @page-change =" handlePageChange"
456+ @select =" handleSelect"
382457 />
383458 </div >
384459 </section >
0 commit comments