From 4081cc98fc7826cbfbbd437fc6cd8bd31ca11582 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 09:05:24 -0500 Subject: [PATCH 1/2] fix: arrow key navigation in search skips keyword buttons Pressing ArrowDown/ArrowUp in search results navigated through keyword filter buttons within each card instead of jumping between packages. Root cause: keyword ButtonBase elements in Package/Card.vue had data-result-index, so getFocusableElements() returned them alongside the actual result links. Also adds ArrowUp-to-input (from first result) and Escape-to-input, plus E2E tests covering all three behaviors. Fixes #1078 --- app/components/Package/Card.vue | 1 - app/pages/search.vue | 26 ++++++++++++-- test/e2e/interactions.spec.ts | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue index 12d25268b7..6c601cde1f 100644 --- a/app/components/Package/Card.vue +++ b/app/components/Package/Card.vue @@ -172,7 +172,6 @@ const numberFormatter = useNumberFormatter() size="small" :aria-pressed="props.filters?.keywords.includes(keyword)" :title="`Filter by ${keyword}`" - :data-result-index="index" @click.stop="emit('clickKeyword', keyword)" > {{ keyword }} diff --git a/app/pages/search.vue b/app/pages/search.vue index e71faaf252..9777039bb6 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -453,7 +453,24 @@ watch(displayResults, results => { } }) +/** + * Focus the header search input + */ +function focusSearchInput() { + const searchInput = document.querySelector( + 'input[type="search"], input[name="q"]', + ) + searchInput?.focus() +} + function handleResultsKeydown(e: KeyboardEvent) { + // Escape returns focus to the search input from anywhere on the page + if (e.key === 'Escape') { + e.preventDefault() + focusSearchInput() + return + } + // If the active element is an input, navigate to exact match or wait for results if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') { // Get value directly from input (not from route query, which may be debounced) @@ -489,7 +506,12 @@ function handleResultsKeydown(e: KeyboardEvent) { if (e.key === 'ArrowUp') { e.preventDefault() - const nextIndex = currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0) + // At first result or no result focused: return focus to search input + if (currentIndex <= 0) { + focusSearchInput() + return + } + const nextIndex = currentIndex - 1 const el = elements[nextIndex] if (el) focusElement(el) return @@ -508,7 +530,7 @@ function handleResultsKeydown(e: KeyboardEvent) { } } -onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) +onKeyDown(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'], handleResultsKeydown) useSeoMeta({ title: () => diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts index b900bbafa7..05a661f282 100644 --- a/test/e2e/interactions.spec.ts +++ b/test/e2e/interactions.spec.ts @@ -73,6 +73,66 @@ test.describe('Search Pages', () => { await expect(page).toHaveURL(/\/(package|org|user)\/vue/) }) + test('/search?q=vue → ArrowDown navigates only between results, not keyword buttons', async ({ + page, + goto, + }) => { + await goto('/search?q=vue', { waitUntil: 'hydration' }) + + await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({ + timeout: 15000, + }) + + const firstResult = page.locator('[data-result-index="0"]').first() + const secondResult = page.locator('[data-result-index="1"]').first() + await expect(firstResult).toBeVisible() + await expect(secondResult).toBeVisible() + + // ArrowDown from input focuses the first result + await page.keyboard.press('ArrowDown') + await expect(firstResult).toBeFocused() + + // Second ArrowDown focuses the second result (not a keyword button within the first) + await page.keyboard.press('ArrowDown') + await expect(secondResult).toBeFocused() + }) + + test('/search?q=vue → ArrowUp from first result returns focus to search input', async ({ + page, + goto, + }) => { + await goto('/search?q=vue', { waitUntil: 'hydration' }) + + await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({ + timeout: 15000, + }) + + // Navigate to first result + await page.keyboard.press('ArrowDown') + await expect(page.locator('[data-result-index="0"]').first()).toBeFocused() + + // ArrowUp returns to the search input + await page.keyboard.press('ArrowUp') + await expect(page.locator('input[type="search"]')).toBeFocused() + }) + + test('/search?q=vue → Escape returns focus to search input', async ({ page, goto }) => { + await goto('/search?q=vue', { waitUntil: 'hydration' }) + + await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({ + timeout: 15000, + }) + + // Navigate into results + await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowDown') + await expect(page.locator('[data-result-index="1"]').first()).toBeFocused() + + // Escape returns to the search input + await page.keyboard.press('Escape') + await expect(page.locator('input[type="search"]')).toBeFocused() + }) + test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => { await goto('/search?q=vue', { waitUntil: 'hydration' }) From af31ca9c747c80ef545e666eff53e9bda61f1d10 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 15:49:17 -0500 Subject: [PATCH 2/2] fix: undo overeager new escape hatch --- app/pages/search.vue | 9 +-------- test/e2e/interactions.spec.ts | 17 ----------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 9777039bb6..cd3faef3e2 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -464,13 +464,6 @@ function focusSearchInput() { } function handleResultsKeydown(e: KeyboardEvent) { - // Escape returns focus to the search input from anywhere on the page - if (e.key === 'Escape') { - e.preventDefault() - focusSearchInput() - return - } - // If the active element is an input, navigate to exact match or wait for results if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') { // Get value directly from input (not from route query, which may be debounced) @@ -530,7 +523,7 @@ function handleResultsKeydown(e: KeyboardEvent) { } } -onKeyDown(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'], handleResultsKeydown) +onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown) useSeoMeta({ title: () => diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts index 05a661f282..b981a70272 100644 --- a/test/e2e/interactions.spec.ts +++ b/test/e2e/interactions.spec.ts @@ -116,23 +116,6 @@ test.describe('Search Pages', () => { await expect(page.locator('input[type="search"]')).toBeFocused() }) - test('/search?q=vue → Escape returns focus to search input', async ({ page, goto }) => { - await goto('/search?q=vue', { waitUntil: 'hydration' }) - - await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({ - timeout: 15000, - }) - - // Navigate into results - await page.keyboard.press('ArrowDown') - await page.keyboard.press('ArrowDown') - await expect(page.locator('[data-result-index="1"]').first()).toBeFocused() - - // Escape returns to the search input - await page.keyboard.press('Escape') - await expect(page.locator('input[type="search"]')).toBeFocused() - }) - test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => { await goto('/search?q=vue', { waitUntil: 'hydration' })