Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
17 changes: 16 additions & 1 deletion app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,16 @@ watch(displayResults, results => {
}
})

/**
* Focus the header search input
*/
function focusSearchInput() {
const searchInput = document.querySelector<HTMLInputElement>(
'input[type="search"], input[name="q"]',
)
searchInput?.focus()
}

function handleResultsKeydown(e: KeyboardEvent) {
// If the active element is an input, navigate to exact match or wait for results
if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') {
Expand Down Expand Up @@ -489,7 +499,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
Expand Down
43 changes: 43 additions & 0 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,49 @@ 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()
})
Comment on lines +76 to +117
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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()
   })


test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
await goto('/search?q=vue', { waitUntil: 'hydration' })

Expand Down
Loading