diff --git a/README.md b/README.md
index 428b0951be..f7bdb80c00 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
- **Install size** – total install size including dependencies
- **Playground links** – quick access to StackBlitz, CodeSandbox, and other demo environments from READMEs
- **Infinite search** – auto-load additional search pages as you scroll
+- **Keyboard navigation** – press `/` to focus search, arrow keys to navigate results, Enter to select
- **Claim new packages** – register new package names directly from search results (via local connector)
### User & org pages
@@ -65,6 +66,7 @@ The aim of [npmx.dev](https://npmx.dev) is to provide a better browser for the n
| Vulnerability warnings | ✅ | ✅ |
| Download charts | ✅ | ✅ |
| Playground links | ❌ | ✅ |
+| Keyboard navigation | ❌ | ✅ |
| Dependents list | ✅ | 🚧 |
| Package admin (access/owners) | ✅ | 🚧 |
| Org/team management | ✅ | 🚧 |
diff --git a/app/app.vue b/app/app.vue
index 984c94e964..040daa6114 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -1,4 +1,6 @@
diff --git a/app/components/PackageCard.vue b/app/components/PackageCard.vue
index 44e0999a4c..6b34240550 100644
--- a/app/components/PackageCard.vue
+++ b/app/components/PackageCard.vue
@@ -9,15 +9,27 @@ defineProps<{
/** Whether to show the publisher username */
showPublisher?: boolean
prefetch?: boolean
+ selected?: boolean
+ index?: number
+}>()
+
+const emit = defineEmits<{
+ focus: [index: number]
}>()
-
+
()
const emit = defineEmits<{
@@ -25,6 +27,8 @@ const emit = defineEmits<{
loadMore: []
/** Emitted when the visible page changes */
pageChange: [page: number]
+ /** Emitted when a result is hovered/focused */
+ select: [index: number]
}>()
// Reference to WindowVirtualizer for infinite scroll detection
@@ -78,6 +82,14 @@ watch(
}
},
)
+
+function scrollToIndex(index: number, smooth = true) {
+ listRef.value?.scrollToIndex(index, { align: 'center', smooth })
+}
+
+defineExpose({
+ scrollToIndex,
+})
@@ -97,8 +109,11 @@ watch(
:result="item as NpmSearchResult"
:heading-level="headingLevel"
:show-publisher="showPublisher"
+ :selected="index === (selectedIndex ?? -1)"
+ :index="index"
class="animate-fade-in animate-fill-both"
:style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }"
+ @focus="emit('select', $event)"
/>
diff --git a/app/composables/useVirtualInfiniteScroll.ts b/app/composables/useVirtualInfiniteScroll.ts
index c5afcfb438..5b904069ec 100644
--- a/app/composables/useVirtualInfiniteScroll.ts
+++ b/app/composables/useVirtualInfiniteScroll.ts
@@ -5,7 +5,10 @@ export interface WindowVirtualizerHandle {
readonly viewportSize: number
findItemIndex: (offset: number) => number
getItemOffset: (index: number) => number
- scrollToIndex: (index: number, opts?: { align?: 'start' | 'center' | 'end' }) => void
+ scrollToIndex: (
+ index: number,
+ opts?: { align?: 'start' | 'center' | 'end'; smooth?: boolean },
+ ) => void
}
export interface UseVirtualInfiniteScrollOptions {
diff --git a/app/pages/search.vue b/app/pages/search.vue
index 63ab145234..8b8d2cf896 100644
--- a/app/pages/search.vue
+++ b/app/pages/search.vue
@@ -47,6 +47,67 @@ watch(
const isSearchFocused = ref(false)
const searchInputRef = ref()
+const selectedIndex = ref(0)
+const packageListRef = useTemplateRef('packageListRef')
+
+const resultCount = computed(() => visibleResults.value?.objects.length ?? 0)
+
+function clampIndex(next: number) {
+ if (resultCount.value <= 0) return 0
+ return Math.max(0, Math.min(resultCount.value - 1, next))
+}
+
+function scrollToSelectedResult() {
+ // Use virtualizer's scrollToIndex to ensure the item is rendered and visible
+ packageListRef.value?.scrollToIndex(selectedIndex.value)
+}
+
+function focusSelectedResult() {
+ // First ensure the item is rendered by scrolling to it
+ scrollToSelectedResult()
+ // Then focus it after a tick to allow rendering
+ nextTick(() => {
+ const el = document.querySelector(`[data-result-index="${selectedIndex.value}"]`)
+ el?.focus()
+ })
+}
+
+function handleResultsKeydown(e: KeyboardEvent) {
+ if (resultCount.value <= 0) return
+
+ const isFromInput = (e.target as HTMLElement).tagName === 'INPUT'
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ selectedIndex.value = clampIndex(selectedIndex.value + 1)
+ // Only move focus if already in results, not when typing in search input
+ if (isFromInput) {
+ scrollToSelectedResult()
+ } else {
+ focusSelectedResult()
+ }
+ return
+ }
+
+ if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ selectedIndex.value = clampIndex(selectedIndex.value - 1)
+ if (isFromInput) {
+ scrollToSelectedResult()
+ } else {
+ focusSelectedResult()
+ }
+ return
+ }
+
+ if (e.key === 'Enter') {
+ const el = document.querySelector(`[data-result-index="${selectedIndex.value}"]`)
+ if (!el) return
+ e.preventDefault()
+ el.click()
+ }
+}
+
// Track if page just loaded (for hiding "Searching..." during view transition)
const hasInteracted = ref(false)
onMounted(() => {
@@ -148,12 +209,22 @@ function handlePageChange(page: number) {
updateUrlPage(page)
}
+function handleSelect(index: number) {
+ if (index < 0) return
+ selectedIndex.value = clampIndex(index)
+}
+
// Reset pages when query changes
watch(query, () => {
loadedPages.value = 1
hasInteracted.value = true
})
+// Reset selection when query changes (new search)
+watch(query, () => {
+ selectedIndex.value = 0
+})
+
// Check if current query could be a valid package name
const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim()))
@@ -299,6 +370,7 @@ defineOgImageComponent('Default', {
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"
@focus="isSearchFocused = true"
@blur="isSearchFocused = false"
+ @keydown="handleResultsKeydown"
/>
@@ -311,7 +383,7 @@ defineOgImageComponent('Default', {
-
+
@@ -370,7 +442,9 @@ defineOgImageComponent('Default', {
diff --git a/tests/interactions.spec.ts b/tests/interactions.spec.ts
new file mode 100644
index 0000000000..137fca43e8
--- /dev/null
+++ b/tests/interactions.spec.ts
@@ -0,0 +1,46 @@
+import { expect, test } from '@nuxt/test-utils/playwright'
+
+test.describe('Search Pages', () => {
+ test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
+ await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
+
+ await expect(page.locator('text=/found \\d+/i')).toBeVisible()
+
+ const searchInput = page.locator('input[type="search"]')
+ await expect(searchInput).toBeFocused()
+
+ const firstResult = page.locator('[data-result-index="0"]').first()
+ await expect(firstResult).toBeVisible()
+
+ // First result is selected by default, Enter navigates to it
+ // URL is /vue not /package/vue (cleaner URLs)
+ await page.keyboard.press('Enter')
+ await expect(page).toHaveURL(/\/vue/)
+
+ await page.goBack()
+ // Wait for search page to be ready
+ await expect(page).toHaveURL(/\/search/)
+ await expect(page.locator('text=/found \\d+/i')).toBeVisible()
+ // Search input is autofocused on mount
+ await expect(searchInput).toBeFocused()
+
+ // ArrowDown changes visual selection but keeps focus in input
+ await page.keyboard.press('ArrowDown')
+ await expect(searchInput).toBeFocused()
+
+ // Enter navigates to the now-selected second result
+ await page.keyboard.press('Enter')
+ // Second result could be vue-router, vuex, etc - just check we navigated away
+ await expect(page).not.toHaveURL(/\/search/)
+ })
+
+ test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
+ await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
+
+ await expect(page.locator('text=/found \\d+/i')).toBeVisible()
+
+ await page.locator('[data-result-index="0"]').first().focus()
+ await page.keyboard.press('/')
+ await expect(page.locator('input[type="search"]')).toBeFocused()
+ })
+})