Skip to content

Commit dc4a43b

Browse files
committed
feat: keyboard navigation on search results
Closes #61
1 parent 4279ff7 commit dc4a43b

5 files changed

Lines changed: 121 additions & 10 deletions

File tree

app/app.vue

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ useHead({
1212
1313
// Global keyboard shortcut: "/" focuses search or navigates to search page
1414
function handleGlobalKeydown(e: KeyboardEvent) {
15-
// Ignore if user is typing in an input, textarea, or contenteditable
1615
const target = e.target as HTMLElement
17-
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
18-
return
19-
}
2016
21-
if (e.key === '/') {
17+
const isEditableTarget =
18+
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
19+
20+
if (e.key === '/' && !target.isContentEditable) {
2221
e.preventDefault()
2322
2423
// Try to find and focus search input on current page
@@ -28,10 +27,15 @@ function handleGlobalKeydown(e: KeyboardEvent) {
2827
2928
if (searchInput) {
3029
searchInput.focus()
31-
} else {
32-
// Navigate to search page
33-
router.push('/search')
30+
return
3431
}
32+
33+
router.push('/search')
34+
return
35+
}
36+
37+
if (isEditableTarget) {
38+
return
3539
}
3640
}
3741

app/components/PackageCard.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,24 @@ defineProps<{
99
/** Whether to show the publisher username */
1010
showPublisher?: boolean
1111
prefetch?: boolean
12+
selected?: boolean
13+
index?: number
14+
}>()
15+
16+
const emit = defineEmits<{
17+
focus: [index: number]
1218
}>()
1319
</script>
1420

1521
<template>
16-
<article class="group card-interactive">
22+
<article class="group card-interactive" :class="{ 'bg-bg-muted border-border-hover': selected }">
1723
<NuxtLink
1824
:to="{ name: 'package', params: { package: result.package.name.split('/') } }"
1925
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
2026
class="block focus:outline-none decoration-none"
27+
:data-result-index="index"
28+
@focus="emit('focus', index)"
29+
@mouseenter="emit('focus', index)"
2130
>
2231
<header class="flex items-start justify-between gap-4 mb-2">
2332
<component

app/components/PackageList.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ const props = defineProps<{
1818
pageSize?: number
1919
/** Initial page to scroll to (1-indexed) */
2020
initialPage?: number
21+
/** Selected result index (for keyboard navigation) */
22+
selectedIndex?: number
2123
}>()
2224
2325
const emit = defineEmits<{
2426
/** Emitted when scrolled near the bottom and more items should be loaded */
2527
loadMore: []
2628
/** Emitted when the visible page changes */
2729
pageChange: [page: number]
30+
/** Emitted when a result is hovered/focused */
31+
select: [index: number]
2832
}>()
2933
3034
// Reference to WindowVirtualizer for infinite scroll detection
@@ -97,8 +101,11 @@ watch(
97101
:result="item as NpmSearchResult"
98102
:heading-level="headingLevel"
99103
:show-publisher="showPublisher"
104+
:selected="index === (selectedIndex ?? -1)"
105+
:index="index"
100106
class="animate-fade-in animate-fill-both"
101107
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
108+
@focus="emit('select', $event)"
102109
/>
103110
</div>
104111
</template>

app/pages/search.vue

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,45 @@ watch(
4646
const isSearchFocused = ref(false)
4747
const searchInputRef = ref<HTMLInputElement>()
4848
49+
const selectedIndex = ref(0)
50+
51+
const resultCount = computed(() => visibleResults.value?.objects.length ?? 0)
52+
53+
function clampIndex(next: number) {
54+
if (resultCount.value <= 0) return 0
55+
return Math.max(0, Math.min(resultCount.value - 1, next))
56+
}
57+
58+
function focusSelectedResult() {
59+
const el = document.querySelector<HTMLElement>(`[data-result-index="${selectedIndex.value}"]`)
60+
el?.focus()
61+
}
62+
63+
function handleResultsKeydown(e: KeyboardEvent) {
64+
if (resultCount.value <= 0) return
65+
66+
if (e.key === 'ArrowDown') {
67+
e.preventDefault()
68+
selectedIndex.value = clampIndex(selectedIndex.value + 1)
69+
focusSelectedResult()
70+
return
71+
}
72+
73+
if (e.key === 'ArrowUp') {
74+
e.preventDefault()
75+
selectedIndex.value = clampIndex(selectedIndex.value - 1)
76+
focusSelectedResult()
77+
return
78+
}
79+
80+
if (e.key === 'Enter') {
81+
const el = document.querySelector<HTMLElement>(`[data-result-index="${selectedIndex.value}"]`)
82+
if (!el) return
83+
e.preventDefault()
84+
el.click()
85+
}
86+
}
87+
4988
// Track if page just loaded (for hiding "Searching..." during view transition)
5089
const hasInteracted = ref(false)
5190
onMounted(() => {
@@ -147,12 +186,25 @@ function handlePageChange(page: number) {
147186
updateUrlPage(page)
148187
}
149188
189+
function handleSelect(index: number) {
190+
if (index < 0) return
191+
selectedIndex.value = clampIndex(index)
192+
}
193+
150194
// Reset pages when query changes
151195
watch(query, () => {
152196
loadedPages.value = 1
153197
hasInteracted.value = true
154198
})
155199
200+
watch(
201+
() => visibleResults.value?.objects,
202+
objects => {
203+
if (!objects?.length) return
204+
selectedIndex.value = 0
205+
},
206+
)
207+
156208
useSeoMeta({
157209
title: () => (query.value ? `Search: ${query.value} - npmx` : 'Search Packages - npmx'),
158210
})
@@ -197,6 +249,7 @@ defineOgImageComponent('Default', {
197249
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-all duration-300 focus:(border-border-hover outline-none) appearance-none"
198250
@focus="isSearchFocused = true"
199251
@blur="isSearchFocused = false"
252+
@keydown="handleResultsKeydown"
200253
/>
201254
<!-- Hidden submit button for accessibility (form must have submit button per WCAG) -->
202255
<button type="submit" class="sr-only">Search</button>
@@ -209,7 +262,7 @@ defineOgImageComponent('Default', {
209262

210263
<!-- Results area with container padding -->
211264
<div class="container py-6">
212-
<section v-if="query" aria-label="Search results">
265+
<section v-if="query" aria-label="Search results" @keydown="handleResultsKeydown">
213266
<!-- Initial loading (only after user interaction, not during view transition) -->
214267
<LoadingSpinner v-if="showSearching" text="Searching..." />
215268

@@ -235,6 +288,7 @@ defineOgImageComponent('Default', {
235288
<PackageList
236289
v-if="visibleResults.objects.length > 0"
237290
:results="visibleResults.objects"
291+
:selected-index="selectedIndex"
238292
heading-level="h2"
239293
show-publisher
240294
:has-more="hasMore"
@@ -243,6 +297,7 @@ defineOgImageComponent('Default', {
243297
:initial-page="initialPage"
244298
@load-more="loadMore"
245299
@page-change="handlePageChange"
300+
@select="handleSelect"
246301
/>
247302
</div>
248303
</section>

tests/url-compatibility.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,42 @@ test.describe('npmjs.com URL Compatibility', () => {
5454
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
5555
})
5656

57+
test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
58+
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
59+
60+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
61+
62+
const searchInput = page.locator('input[type="search"]')
63+
await expect(searchInput).toBeFocused()
64+
65+
const firstResult = page.locator('[data-result-index="0"]').first()
66+
await expect(firstResult).toBeVisible()
67+
68+
await page.keyboard.press('Enter')
69+
await expect(page).toHaveURL(/\/package\//)
70+
71+
await page.goBack()
72+
await expect(searchInput).toBeFocused()
73+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
74+
75+
await page.keyboard.press('ArrowDown')
76+
const secondResult = page.locator('[data-result-index="1"]').first()
77+
await expect(secondResult).toBeFocused()
78+
79+
await page.keyboard.press('Enter')
80+
await expect(page).toHaveURL(/\/package\//)
81+
})
82+
83+
test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
84+
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
85+
86+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
87+
88+
await page.locator('[data-result-index="0"]').first().focus()
89+
await page.keyboard.press('/')
90+
await expect(page.locator('input[type="search"]')).toBeFocused()
91+
})
92+
5793
test('/search?q=keywords:framework → keyword search', async ({ page, goto }) => {
5894
await goto('/search?q=keywords:framework', { waitUntil: 'domcontentloaded' })
5995

0 commit comments

Comments
 (0)