Skip to content

Commit 9cb1825

Browse files
authored
feat: keyboard navigation on search results (npmx-dev#64)
1 parent 9aae420 commit 9cb1825

7 files changed

Lines changed: 170 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
3535
- **Install size** – total install size including dependencies
3636
- **Playground links** – quick access to StackBlitz, CodeSandbox, and other demo environments from READMEs
3737
- **Infinite search** – auto-load additional search pages as you scroll
38+
- **Keyboard navigation** – press `/` to focus search, arrow keys to navigate results, Enter to select
3839
- **Claim new packages** – register new package names directly from search results (via local connector)
3940

4041
### User & org pages
@@ -65,6 +66,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
6566
| Vulnerability warnings |||
6667
| Download charts |||
6768
| Playground links |||
69+
| Keyboard navigation |||
6870
| Dependents list || 🚧 |
6971
| Package admin (access/owners) || 🚧 |
7072
| Org/team management || 🚧 |

app/app.vue

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import { useEventListener } from '@vueuse/core'
3+
24
const route = useRoute()
35
const router = useRouter()
46
@@ -12,9 +14,12 @@ useHead({
1214
1315
// Global keyboard shortcut: "/" focuses search or navigates to search page
1416
function handleGlobalKeydown(e: KeyboardEvent) {
15-
// Ignore if user is typing in an input, textarea, or contenteditable
1617
const target = e.target as HTMLElement
17-
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
18+
19+
const isEditableTarget =
20+
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
21+
22+
if (isEditableTarget) {
1823
return
1924
}
2025
@@ -28,20 +33,16 @@ function handleGlobalKeydown(e: KeyboardEvent) {
2833
2934
if (searchInput) {
3035
searchInput.focus()
31-
} else {
32-
// Navigate to search page
33-
router.push('/search')
36+
return
3437
}
38+
39+
router.push('/search')
3540
}
3641
}
3742
38-
onMounted(() => {
39-
document.addEventListener('keydown', handleGlobalKeydown)
40-
})
41-
42-
onUnmounted(() => {
43-
document.removeEventListener('keydown', handleGlobalKeydown)
44-
})
43+
if (import.meta.client) {
44+
useEventListener(document, 'keydown', handleGlobalKeydown)
45+
}
4546
</script>
4647

4748
<template>

app/components/PackageCard.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,27 @@ 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
23+
class="group card-interactive scroll-mt-48 scroll-mb-6"
24+
:class="{ 'bg-bg-muted border-border-hover': selected }"
25+
>
1726
<NuxtLink
1827
:to="{ name: 'package', params: { package: result.package.name.split('/') } }"
1928
:prefetch-on="prefetch ? 'visibility' : 'interaction'"
20-
class="block focus:outline-none decoration-none"
29+
class="block focus:outline-none decoration-none scroll-mt-48 scroll-mb-6"
30+
:data-result-index="index"
31+
@focus="index != null && emit('focus', index)"
32+
@mouseenter="index != null && emit('focus', index)"
2133
>
2234
<header class="flex items-start justify-between gap-4 mb-2">
2335
<component

app/components/PackageList.vue

Lines changed: 15 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
@@ -78,6 +82,14 @@ watch(
7882
}
7983
},
8084
)
85+
86+
function scrollToIndex(index: number, smooth = true) {
87+
listRef.value?.scrollToIndex(index, { align: 'center', smooth })
88+
}
89+
90+
defineExpose({
91+
scrollToIndex,
92+
})
8193
</script>
8294

8395
<template>
@@ -97,8 +109,11 @@ watch(
97109
:result="item as NpmSearchResult"
98110
:heading-level="headingLevel"
99111
:show-publisher="showPublisher"
112+
:selected="index === (selectedIndex ?? -1)"
113+
:index="index"
100114
class="animate-fade-in animate-fill-both"
101115
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
116+
@focus="emit('select', $event)"
102117
/>
103118
</div>
104119
</template>

app/composables/useVirtualInfiniteScroll.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ export interface WindowVirtualizerHandle {
55
readonly viewportSize: number
66
findItemIndex: (offset: number) => number
77
getItemOffset: (index: number) => number
8-
scrollToIndex: (index: number, opts?: { align?: 'start' | 'center' | 'end' }) => void
8+
scrollToIndex: (
9+
index: number,
10+
opts?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
11+
) => void
912
}
1013

1114
export interface UseVirtualInfiniteScrollOptions {

app/pages/search.vue

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,67 @@ watch(
4747
const isSearchFocused = ref(false)
4848
const 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)
51112
const hasInteracted = ref(false)
52113
onMounted(() => {
@@ -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
152218
watch(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
158229
const 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>

tests/interactions.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { expect, test } from '@nuxt/test-utils/playwright'
2+
3+
test.describe('Search Pages', () => {
4+
test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
5+
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
6+
7+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
8+
9+
const searchInput = page.locator('input[type="search"]')
10+
await expect(searchInput).toBeFocused()
11+
12+
const firstResult = page.locator('[data-result-index="0"]').first()
13+
await expect(firstResult).toBeVisible()
14+
15+
// First result is selected by default, Enter navigates to it
16+
// URL is /vue not /package/vue (cleaner URLs)
17+
await page.keyboard.press('Enter')
18+
await expect(page).toHaveURL(/\/vue/)
19+
20+
await page.goBack()
21+
// Wait for search page to be ready
22+
await expect(page).toHaveURL(/\/search/)
23+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
24+
// Search input is autofocused on mount
25+
await expect(searchInput).toBeFocused()
26+
27+
// ArrowDown changes visual selection but keeps focus in input
28+
await page.keyboard.press('ArrowDown')
29+
await expect(searchInput).toBeFocused()
30+
31+
// Enter navigates to the now-selected second result
32+
await page.keyboard.press('Enter')
33+
// Second result could be vue-router, vuex, etc - just check we navigated away
34+
await expect(page).not.toHaveURL(/\/search/)
35+
})
36+
37+
test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
38+
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
39+
40+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
41+
42+
await page.locator('[data-result-index="0"]').first().focus()
43+
await page.keyboard.press('/')
44+
await expect(page.locator('input[type="search"]')).toBeFocused()
45+
})
46+
})

0 commit comments

Comments
 (0)