Skip to content

Commit 4081cc9

Browse files
committed
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
1 parent 71eba9d commit 4081cc9

File tree

3 files changed

+84
-3
lines changed

3 files changed

+84
-3
lines changed

app/components/Package/Card.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,6 @@ const numberFormatter = useNumberFormatter()
172172
size="small"
173173
:aria-pressed="props.filters?.keywords.includes(keyword)"
174174
:title="`Filter by ${keyword}`"
175-
:data-result-index="index"
176175
@click.stop="emit('clickKeyword', keyword)"
177176
>
178177
{{ keyword }}

app/pages/search.vue

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,24 @@ watch(displayResults, results => {
453453
}
454454
})
455455
456+
/**
457+
* Focus the header search input
458+
*/
459+
function focusSearchInput() {
460+
const searchInput = document.querySelector<HTMLInputElement>(
461+
'input[type="search"], input[name="q"]',
462+
)
463+
searchInput?.focus()
464+
}
465+
456466
function handleResultsKeydown(e: KeyboardEvent) {
467+
// Escape returns focus to the search input from anywhere on the page
468+
if (e.key === 'Escape') {
469+
e.preventDefault()
470+
focusSearchInput()
471+
return
472+
}
473+
457474
// If the active element is an input, navigate to exact match or wait for results
458475
if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') {
459476
// Get value directly from input (not from route query, which may be debounced)
@@ -489,7 +506,12 @@ function handleResultsKeydown(e: KeyboardEvent) {
489506
490507
if (e.key === 'ArrowUp') {
491508
e.preventDefault()
492-
const nextIndex = currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0)
509+
// At first result or no result focused: return focus to search input
510+
if (currentIndex <= 0) {
511+
focusSearchInput()
512+
return
513+
}
514+
const nextIndex = currentIndex - 1
493515
const el = elements[nextIndex]
494516
if (el) focusElement(el)
495517
return
@@ -508,7 +530,7 @@ function handleResultsKeydown(e: KeyboardEvent) {
508530
}
509531
}
510532
511-
onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown)
533+
onKeyDown(['ArrowDown', 'ArrowUp', 'Enter', 'Escape'], handleResultsKeydown)
512534
513535
useSeoMeta({
514536
title: () =>

test/e2e/interactions.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,66 @@ test.describe('Search Pages', () => {
7373
await expect(page).toHaveURL(/\/(package|org|user)\/vue/)
7474
})
7575

76+
test('/search?q=vue → ArrowDown navigates only between results, not keyword buttons', async ({
77+
page,
78+
goto,
79+
}) => {
80+
await goto('/search?q=vue', { waitUntil: 'hydration' })
81+
82+
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
83+
timeout: 15000,
84+
})
85+
86+
const firstResult = page.locator('[data-result-index="0"]').first()
87+
const secondResult = page.locator('[data-result-index="1"]').first()
88+
await expect(firstResult).toBeVisible()
89+
await expect(secondResult).toBeVisible()
90+
91+
// ArrowDown from input focuses the first result
92+
await page.keyboard.press('ArrowDown')
93+
await expect(firstResult).toBeFocused()
94+
95+
// Second ArrowDown focuses the second result (not a keyword button within the first)
96+
await page.keyboard.press('ArrowDown')
97+
await expect(secondResult).toBeFocused()
98+
})
99+
100+
test('/search?q=vue → ArrowUp from first result returns focus to search input', async ({
101+
page,
102+
goto,
103+
}) => {
104+
await goto('/search?q=vue', { waitUntil: 'hydration' })
105+
106+
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
107+
timeout: 15000,
108+
})
109+
110+
// Navigate to first result
111+
await page.keyboard.press('ArrowDown')
112+
await expect(page.locator('[data-result-index="0"]').first()).toBeFocused()
113+
114+
// ArrowUp returns to the search input
115+
await page.keyboard.press('ArrowUp')
116+
await expect(page.locator('input[type="search"]')).toBeFocused()
117+
})
118+
119+
test('/search?q=vue → Escape returns focus to search input', async ({ page, goto }) => {
120+
await goto('/search?q=vue', { waitUntil: 'hydration' })
121+
122+
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
123+
timeout: 15000,
124+
})
125+
126+
// Navigate into results
127+
await page.keyboard.press('ArrowDown')
128+
await page.keyboard.press('ArrowDown')
129+
await expect(page.locator('[data-result-index="1"]').first()).toBeFocused()
130+
131+
// Escape returns to the search input
132+
await page.keyboard.press('Escape')
133+
await expect(page.locator('input[type="search"]')).toBeFocused()
134+
})
135+
76136
test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
77137
await goto('/search?q=vue', { waitUntil: 'hydration' })
78138

0 commit comments

Comments
 (0)