[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: keyboard navigation on search results (#64)

authored by

chv and committed by
GitHub
5a032e3c 263651ea

+170 -16
+2
README.md
··· 35 35 - **Install size** – total install size including dependencies 36 36 - **Playground links** – quick access to StackBlitz, CodeSandbox, and other demo environments from READMEs 37 37 - **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 38 39 - **Claim new packages** – register new package names directly from search results (via local connector) 39 40 40 41 ### User & org pages ··· 65 66 | Vulnerability warnings | ✅ | ✅ | 66 67 | Download charts | ✅ | ✅ | 67 68 | Playground links | ❌ | ✅ | 69 + | Keyboard navigation | ❌ | ✅ | 68 70 | Dependents list | ✅ | 🚧 | 69 71 | Package admin (access/owners) | ✅ | 🚧 | 70 72 | Org/team management | ✅ | 🚧 |
+13 -12
app/app.vue
··· 1 1 <script setup lang="ts"> 2 + import { useEventListener } from '@vueuse/core' 3 + 2 4 const route = useRoute() 3 5 const router = useRouter() 4 6 ··· 12 14 13 15 // Global keyboard shortcut: "/" focuses search or navigates to search page 14 16 function handleGlobalKeydown(e: KeyboardEvent) { 15 - // Ignore if user is typing in an input, textarea, or contenteditable 16 17 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) { 18 23 return 19 24 } 20 25 ··· 28 33 29 34 if (searchInput) { 30 35 searchInput.focus() 31 - } else { 32 - // Navigate to search page 33 - router.push('/search') 36 + return 34 37 } 38 + 39 + router.push('/search') 35 40 } 36 41 } 37 42 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 + } 45 46 </script> 46 47 47 48 <template>
+14 -2
app/components/PackageCard.vue
··· 9 9 /** Whether to show the publisher username */ 10 10 showPublisher?: boolean 11 11 prefetch?: boolean 12 + selected?: boolean 13 + index?: number 14 + }>() 15 + 16 + const emit = defineEmits<{ 17 + focus: [index: number] 12 18 }>() 13 19 </script> 14 20 15 21 <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 + > 17 26 <NuxtLink 18 27 :to="{ name: 'package', params: { package: result.package.name.split('/') } }" 19 28 :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)" 21 33 > 22 34 <header class="flex items-start justify-between gap-4 mb-2"> 23 35 <component
+15
app/components/PackageList.vue
··· 18 18 pageSize?: number 19 19 /** Initial page to scroll to (1-indexed) */ 20 20 initialPage?: number 21 + /** Selected result index (for keyboard navigation) */ 22 + selectedIndex?: number 21 23 }>() 22 24 23 25 const emit = defineEmits<{ ··· 25 27 loadMore: [] 26 28 /** Emitted when the visible page changes */ 27 29 pageChange: [page: number] 30 + /** Emitted when a result is hovered/focused */ 31 + select: [index: number] 28 32 }>() 29 33 30 34 // Reference to WindowVirtualizer for infinite scroll detection ··· 78 82 } 79 83 }, 80 84 ) 85 + 86 + function scrollToIndex(index: number, smooth = true) { 87 + listRef.value?.scrollToIndex(index, { align: 'center', smooth }) 88 + } 89 + 90 + defineExpose({ 91 + scrollToIndex, 92 + }) 81 93 </script> 82 94 83 95 <template> ··· 97 109 :result="item as NpmSearchResult" 98 110 :heading-level="headingLevel" 99 111 :show-publisher="showPublisher" 112 + :selected="index === (selectedIndex ?? -1)" 113 + :index="index" 100 114 class="animate-fade-in animate-fill-both" 101 115 :style="{ animationDelay: `${Math.min(index * 0.02, 0.3)}s` }" 116 + @focus="emit('select', $event)" 102 117 /> 103 118 </div> 104 119 </template>
+4 -1
app/composables/useVirtualInfiniteScroll.ts
··· 5 5 readonly viewportSize: number 6 6 findItemIndex: (offset: number) => number 7 7 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 9 12 } 10 13 11 14 export interface UseVirtualInfiniteScrollOptions {
+76 -1
app/pages/search.vue
··· 47 47 const isSearchFocused = ref(false) 48 48 const searchInputRef = ref<HTMLInputElement>() 49 49 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 + 50 111 // Track if page just loaded (for hiding "Searching..." during view transition) 51 112 const hasInteracted = ref(false) 52 113 onMounted(() => { ··· 148 209 updateUrlPage(page) 149 210 } 150 211 212 + function handleSelect(index: number) { 213 + if (index < 0) return 214 + selectedIndex.value = clampIndex(index) 215 + } 216 + 151 217 // Reset pages when query changes 152 218 watch(query, () => { 153 219 loadedPages.value = 1 154 220 hasInteracted.value = true 155 221 }) 156 222 223 + // Reset selection when query changes (new search) 224 + watch(query, () => { 225 + selectedIndex.value = 0 226 + }) 227 + 157 228 // Check if current query could be a valid package name 158 229 const isValidPackageName = computed(() => isValidNewPackageName(query.value.trim())) 159 230 ··· 299 370 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" 300 371 @focus="isSearchFocused = true" 301 372 @blur="isSearchFocused = false" 373 + @keydown="handleResultsKeydown" 302 374 /> 303 375 <!-- Hidden submit button for accessibility (form must have submit button per WCAG) --> 304 376 <button type="submit" class="sr-only">Search</button> ··· 311 383 312 384 <!-- Results area with container padding --> 313 385 <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"> 315 387 <!-- Initial loading (only after user interaction, not during view transition) --> 316 388 <LoadingSpinner v-if="showSearching" text="Searching…" /> 317 389 ··· 370 442 371 443 <PackageList 372 444 v-if="visibleResults.objects.length > 0" 445 + ref="packageListRef" 373 446 :results="visibleResults.objects" 447 + :selected-index="selectedIndex" 374 448 heading-level="h2" 375 449 show-publisher 376 450 :has-more="hasMore" ··· 379 453 :initial-page="initialPage" 380 454 @load-more="loadMore" 381 455 @page-change="handlePageChange" 456 + @select="handleSelect" 382 457 /> 383 458 </div> 384 459 </section>
+46
tests/interactions.spec.ts
··· 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 + })