fix: arrow key navigation in search skips keyword buttons#1704
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
📝 WalkthroughWalkthroughRemoved the data attribute bound to the keyword index on the keyword filter button in app/components/Package/Card.vue. Added a helper Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
app/pages/search.vue (1)
459-464: Make search-input targeting deterministic.
focusSearchInput()currently grabs the first matching search-like input in DOM order, which can become brittle if additional search fields are introduced. Prefer targeting#header-searchfirst, then fallback to a visible enabled search input.Proposed refactor
function focusSearchInput() { - const searchInput = document.querySelector<HTMLInputElement>( - 'input[type="search"], input[name="q"]', - ) - searchInput?.focus() + const headerSearch = document.querySelector<HTMLInputElement>('#header-search') + if (headerSearch && headerSearch.getClientRects().length > 0 && !headerSearch.disabled) { + headerSearch.focus() + return + } + + const fallback = Array.from( + document.querySelectorAll<HTMLInputElement>('input[type="search"], input[name="q"]'), + ).find(el => el.getClientRects().length > 0 && !el.disabled) + + fallback?.focus() }
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
app/components/Package/Card.vueapp/pages/search.vuetest/e2e/interactions.spec.ts
💤 Files with no reviewable changes (1)
- app/components/Package/Card.vue
app/pages/search.vue
Outdated
| // Escape returns focus to the search input from anywhere on the page | ||
| if (e.key === 'Escape') { | ||
| e.preventDefault() | ||
| focusSearchInput() | ||
| return | ||
| } |
There was a problem hiding this comment.
Escape is handled too globally.
At Line 469, preventDefault() runs for every Escape press on the page, even outside result navigation. This can interfere with expected Escape behaviour in other UI contexts (e.g. overlays/popovers). Scope this branch to when a result/suggestion item is focused (or when search navigation state is active).
There was a problem hiding this comment.
Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!
There was a problem hiding this comment.
Yes, we should have some checks here, since Escape is used for dismissing popover menus and dropdowns and, eventually, tooltips.
There was a problem hiding this comment.
Also, FWIW, the / key also returns the focus to the search box. Do we need an additional key?
There was a problem hiding this comment.
Thanks, friends! I was a bit overeager here. Removed the new Esc binding.
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
app/pages/search.vuetest/e2e/interactions.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- app/pages/search.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() | ||
| }) |
There was a problem hiding this comment.
Make the “from search input” precondition explicit in both new keyboard tests.
At Line 91 and Line 110, the tests assert behaviour “from input”, but they never focus/assert the header search input before the first ArrowDown. That allows false positives if global key handling changes.
Suggested hardening diff
test('/search?q=vue → ArrowDown navigates only between results, not keyword buttons', async ({
page,
goto,
}) => {
await goto('/search?q=vue', { waitUntil: 'hydration' })
@@
+ const headerSearchInput = page.locator('#header-search')
+ await headerSearchInput.focus()
+ await expect(headerSearchInput).toBeFocused()
+
const firstResult = page.locator('[data-result-index="0"]').first()
const secondResult = page.locator('[data-result-index="1"]').first()
@@
test('/search?q=vue → ArrowUp from first result returns focus to search input', async ({
page,
goto,
}) => {
await goto('/search?q=vue', { waitUntil: 'hydration' })
@@
+ const headerSearchInput = page.locator('#header-search')
+ await headerSearchInput.focus()
+ await expect(headerSearchInput).toBeFocused()
+
// 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()
+ await expect(headerSearchInput).toBeFocused()
})
🔗 Linked issue
Fixes #1078
🧭 Context
Pressing keyboard up/down in search results navigated through keyword filter buttons within each card instead of jumping between packages. This is unintuitive and bad for a11y.
📚 Description
Root cause:
ButtonBaseelements had their owndata-result-index, sogetFocusableElements()returned them alongside the actual result links.npmx.search.results.kbd.navigation.mp4