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..cd3faef3e2 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -453,6 +453,16 @@ 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) { // If the active element is an input, navigate to exact match or wait for results if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') { @@ -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 diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts index b900bbafa7..b981a70272 100644 --- a/test/e2e/interactions.spec.ts +++ b/test/e2e/interactions.spec.ts @@ -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() + }) + test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => { await goto('/search?q=vue', { waitUntil: 'hydration' })