Skip to content

Commit dd17a76

Browse files
danielroeFlo0806cullophid
authored
fix: mobile + desktop search focus fixes (#526)
Co-authored-by: Florian Heuberger <fh@flogersoft.de> Co-authored-by: cullophid <andreas.moller@gmail.com>
1 parent 731d9a6 commit dd17a76

8 files changed

Lines changed: 107 additions & 42 deletions

File tree

app/components/AppHeader.vue

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ function expandMobileSearch() {
3030
})
3131
}
3232
33+
watch(
34+
isOnSearchPage,
35+
visible => {
36+
if (!visible) return
37+
38+
searchBoxRef.value?.focus()
39+
nextTick(() => {
40+
searchBoxRef.value?.focus()
41+
})
42+
},
43+
{ flush: 'sync' },
44+
)
45+
3346
function handleSearchBlur() {
3447
showFullSearch.value = false
3548
// Collapse expanded search on mobile after blur (with delay for click handling)

app/components/SearchBox.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<script setup lang="ts">
22
import { debounce } from 'perfect-debounce'
33
4-
const isMobile = useIsMobile()
5-
64
withDefaults(
75
defineProps<{
86
inputClass?: string
@@ -107,7 +105,6 @@ defineExpose({ focus })
107105
<input
108106
id="header-search"
109107
ref="inputRef"
110-
:autofocus="!isMobile"
111108
v-model="searchQuery"
112109
type="search"
113110
name="q"

app/pages/index.vue

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
<script setup lang="ts">
22
import { debounce } from 'perfect-debounce'
33
4-
const router = useRouter()
54
const searchQuery = shallowRef('')
65
const searchInputRef = useTemplateRef('searchInputRef')
76
const { focused: isSearchFocused } = useFocus(searchInputRef)
87
9-
const isMobile = useIsMobile()
10-
11-
const debouncedNavigate = debounce(() => {
12-
router.push({
8+
async function search() {
9+
const query = searchQuery.value.trim()
10+
await navigateTo({
1311
path: '/search',
14-
query: searchQuery.value.trim() ? { q: searchQuery.value.trim() } : undefined,
12+
query: query ? { q: query } : undefined,
1513
})
16-
}, 250)
17-
18-
function handleSearch() {
19-
// If input is empty, navigate immediately (no need to debounce)
20-
return searchQuery.value.trim() ? debouncedNavigate() : router.push('/search')
14+
const newQuery = searchQuery.value.trim()
15+
if (newQuery !== query) {
16+
await search()
17+
}
2118
}
2219
20+
const handleInput = isTouchDevice()
21+
? search
22+
: debounce(search, 250, { leading: true, trailing: true })
23+
2324
useSeoMeta({
2425
title: () => $t('seo.home.title'),
2526
description: () => $t('seo.home.description'),
@@ -64,7 +65,7 @@ defineOgImageComponent('Default', {
6465
class="w-full max-w-xl motion-safe:animate-slide-up motion-safe:animate-fill-both"
6566
style="animation-delay: 0.2s"
6667
>
67-
<form method="GET" action="/search" class="relative" @submit.prevent="handleSearch">
68+
<form method="GET" action="/search" class="relative" @submit.prevent.trim="search">
6869
<label for="home-search" class="sr-only">
6970
{{ $t('search.label') }}
7071
</label>
@@ -89,11 +90,11 @@ defineOgImageComponent('Default', {
8990
v-model="searchQuery"
9091
type="search"
9192
name="q"
93+
autofocus
9294
:placeholder="$t('search.placeholder')"
9395
v-bind="noCorrect"
94-
:autofocus="!isMobile"
9596
class="w-full bg-bg-subtle border border-border rounded-lg ps-8 pe-24 py-4 font-mono text-base text-fg placeholder:text-fg-subtle transition-border-color duration-300 focus:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
96-
@input="handleSearch"
97+
@input="handleInput"
9798
/>
9899

99100
<button

app/utils/responsive.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @public */
2+
export function isTouchDevice() {
3+
if (import.meta.server) {
4+
return false
5+
}
6+
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
7+
}

test/e2e/create-command.spec.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { expect, test } from '@nuxt/test-utils/playwright'
22

33
test.describe('Create Command', () => {
4+
// TODO: these tests depend on external npm registry API - we should add data fixtures
5+
test.describe.configure({ retries: 2 })
6+
47
test.describe('Visibility', () => {
58
test('/vite - should show create command (same maintainers)', async ({ page, goto }) => {
69
await goto('/vite', { waitUntil: 'domcontentloaded' })
@@ -80,13 +83,15 @@ test.describe('Create Command', () => {
8083
test('hovering create command shows copy button', async ({ page, goto }) => {
8184
await goto('/vite', { waitUntil: 'hydration' })
8285

83-
// Wait for package analysis API to load (create command requires this)
84-
// First ensure the package page has loaded
85-
await expect(page.locator('h1')).toContainText('vite')
86+
await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
87+
88+
await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({
89+
timeout: 15000,
90+
})
8691

8792
// Find the create command container (wait longer for API response)
8893
const createCommandContainer = page.locator('.group\\/createcmd').first()
89-
await expect(createCommandContainer).toBeVisible({ timeout: 15000 })
94+
await expect(createCommandContainer).toBeVisible({ timeout: 20000 })
9095

9196
// Copy button should initially be hidden (opacity-0)
9297
const copyButton = createCommandContainer.locator('button')
@@ -108,9 +113,15 @@ test.describe('Create Command', () => {
108113
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
109114

110115
await goto('/vite', { waitUntil: 'hydration' })
116+
await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
117+
118+
await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({
119+
timeout: 15000,
120+
})
111121

112-
// Find and hover over the create command container
113122
const createCommandContainer = page.locator('.group\\/createcmd').first()
123+
await expect(createCommandContainer).toBeVisible({ timeout: 20000 })
124+
114125
await createCommandContainer.hover()
115126

116127
// Click the copy button

test/e2e/interactions.spec.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,78 @@
11
import { expect, test } from '@nuxt/test-utils/playwright'
22

33
test.describe('Search Pages', () => {
4+
// TODO: these tests depend on external npm registry API - we should add data fixtures
5+
test.describe.configure({ retries: 2 })
46
test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
5-
await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
7+
await goto('/search?q=vue', { waitUntil: 'hydration' })
68

7-
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
9+
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
10+
timeout: 15000,
11+
})
812

913
const firstResult = page.locator('[data-result-index="0"]').first()
1014
await expect(firstResult).toBeVisible()
1115

12-
const searchInput = page.locator('input[type="search"]')
13-
14-
// ArrowDown changes visual selection but keeps focus in input
16+
// Global keyboard navigation works regardless of focus
17+
// ArrowDown selects the next result
1518
await page.keyboard.press('ArrowDown')
16-
await expect(searchInput).toBeFocused()
1719

18-
// ArrowUp goes back to first result
20+
// ArrowUp selects the previous result
1921
await page.keyboard.press('ArrowUp')
20-
await expect(searchInput).toBeFocused()
2122

22-
// First result is selected, Enter navigates to it
23+
// Enter navigates to the selected result
2324
// URL is /vue not /package/vue (cleaner URLs)
2425
await page.keyboard.press('Enter')
2526
await expect(page).toHaveURL(/\/vue/)
2627
})
2728

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

31-
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
32+
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
33+
timeout: 15000,
34+
})
3235

3336
await page.locator('[data-result-index="0"]').first().focus()
3437
await page.keyboard.press('/')
3538
await expect(page.locator('input[type="search"]')).toBeFocused()
3639
})
3740

41+
test('/ (homepage) → search, keeps focus on search input', async ({ page, goto }) => {
42+
await goto('/', { waitUntil: 'hydration' })
43+
44+
const homeSearchInput = page.locator('#home-search')
45+
await homeSearchInput.click()
46+
await page.keyboard.type('vue')
47+
48+
// Wait for navigation to /search (debounce is 250ms)
49+
await expect(page).toHaveURL(/\/search/, { timeout: 10000 })
50+
51+
await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 })
52+
53+
// Home search input should be gone (we're on /search now)
54+
await expect(homeSearchInput).not.toBeVisible()
55+
56+
// Header search input should now exist and be focused
57+
const headerSearchInput = page.locator('#header-search')
58+
await expect(headerSearchInput).toBeVisible()
59+
await expect(headerSearchInput).toBeFocused()
60+
})
61+
3862
test('/settings → search, keeps focus on search input', async ({ page, goto }) => {
39-
await goto('/settings', { waitUntil: 'domcontentloaded' })
63+
await goto('/settings', { waitUntil: 'hydration' })
4064

4165
const searchInput = page.locator('input[type="search"]')
66+
await expect(searchInput).toBeVisible()
67+
68+
await searchInput.click()
4269
await searchInput.fill('vue')
4370

44-
await page.waitForURL(/\/search/)
71+
await expect(page).toHaveURL(/\/search/, { timeout: 10000 })
4572

46-
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
73+
await expect(page.locator('[data-result-index="0"]').first()).toBeVisible({ timeout: 15000 })
4774

48-
await expect(searchInput).toBeFocused()
75+
const headerSearchInput = page.locator('#header-search')
76+
await expect(headerSearchInput).toBeFocused()
4977
})
5078
})

test/e2e/package-manager-select.spec.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import { expect, test } from '@nuxt/test-utils/playwright'
22

33
test.describe('Package Page', () => {
44
test('/vue → package manager select dropdown works', async ({ page, goto }) => {
5-
await goto('/vue', { waitUntil: 'domcontentloaded' })
5+
await goto('/vue', { waitUntil: 'hydration' })
6+
7+
await expect(page.locator('h1')).toContainText('vue', { timeout: 15000 })
68

79
const packageManagerButton = page.locator('button[aria-haspopup="listbox"]').first()
810
await expect(packageManagerButton).toBeVisible()
911

1012
// Open dropdown
1113
await packageManagerButton.click()
1214
const packageManagerDropdown = page.locator('[role="listbox"]')
13-
await expect(packageManagerDropdown).toBeVisible()
15+
await expect(packageManagerDropdown).toBeVisible({ timeout: 5000 })
1416

1517
// Arrow keys navigate the listbox
1618
await packageManagerButton.press('ArrowDown')
@@ -26,6 +28,7 @@ test.describe('Package Page', () => {
2628

2729
// Enter selects option and closes dropdown
2830
await packageManagerButton.click()
31+
await expect(packageManagerDropdown).toBeVisible({ timeout: 5000 })
2932
await packageManagerButton.press('ArrowDown')
3033
await packageManagerButton.press('Enter')
3134
await expect(packageManagerDropdown).not.toBeVisible()

test/e2e/url-compatibility.spec.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { expect, test } from '@nuxt/test-utils/playwright'
22

33
test.describe('npmjs.com URL Compatibility', () => {
4+
// TODO: these tests depend on external npm registry API - we should add data fixtures
5+
test.describe.configure({ retries: 2 })
6+
47
test.describe('Package Pages', () => {
58
test('/package/vue → package page', async ({ page, goto }) => {
69
await goto('/package/vue', { waitUntil: 'domcontentloaded' })
@@ -73,12 +76,14 @@ test.describe('npmjs.com URL Compatibility', () => {
7376

7477
test.describe('User Profile Pages', () => {
7578
test('/~sindresorhus → user profile', async ({ page, goto }) => {
76-
await goto('/~sindresorhus', { waitUntil: 'domcontentloaded' })
79+
await goto('/~sindresorhus', { waitUntil: 'hydration' })
7780

7881
// Should show username
7982
await expect(page.locator('h1')).toContainText('~sindresorhus')
80-
// Should show packages heading (user has packages)
81-
await expect(page.getByRole('heading', { name: 'Packages' })).toBeVisible()
83+
84+
await expect(page.locator('text=/\\d+\\s+public\\s+package/i').first()).toBeVisible({
85+
timeout: 15000,
86+
})
8287
})
8388

8489
test('/~nonexistent-user-12345 → empty user handling', async ({ page, goto }) => {

0 commit comments

Comments
 (0)