Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 9 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ function expandMobileSearch() {
})
}

watch(isOnSearchPage, visible => {
if (!visible) return

searchBoxRef.value?.focus()
nextTick(() => {
searchBoxRef.value?.focus()
})
})
Comment thread
danielroe marked this conversation as resolved.
Outdated

function handleSearchBlur() {
showFullSearch.value = false
// Collapse expanded search on mobile after blur (with delay for click handling)
Expand Down
3 changes: 0 additions & 3 deletions app/components/SearchBox.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'

const isMobile = useIsMobile()

withDefaults(
defineProps<{
inputClass?: string
Expand Down Expand Up @@ -107,7 +105,6 @@ defineExpose({ focus })
<input
id="header-search"
ref="inputRef"
:autofocus="!isMobile"
v-model="searchQuery"
type="search"
name="q"
Expand Down
24 changes: 10 additions & 14 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'

const router = useRouter()
const searchQuery = shallowRef('')
const searchInputRef = useTemplateRef('searchInputRef')
const { focused: isSearchFocused } = useFocus(searchInputRef)

const isMobile = useIsMobile()

const debouncedNavigate = debounce(() => {
router.push({
async function search() {
const query = searchQuery.value.trim()
await navigateTo({
path: '/search',
query: searchQuery.value.trim() ? { q: searchQuery.value.trim() } : undefined,
query: query ? { q: query } : undefined,
})
}, 250)

function handleSearch() {
// If input is empty, navigate immediately (no need to debounce)
return searchQuery.value.trim() ? debouncedNavigate() : router.push('/search')
}

const handleInput = isTouchDevice()
? search
: debounce(search, 250, { leading: true, trailing: true })

useSeoMeta({
title: () => $t('seo.home.title'),
description: () => $t('seo.home.description'),
Expand Down Expand Up @@ -64,7 +61,7 @@ defineOgImageComponent('Default', {
class="w-full max-w-xl motion-safe:animate-slide-up motion-safe:animate-fill-both"
style="animation-delay: 0.2s"
>
<form method="GET" action="/search" class="relative" @submit.prevent="handleSearch">
<form method="GET" action="/search" class="relative" @submit.prevent.trim="search">
<label for="home-search" class="sr-only">
{{ $t('search.label') }}
</label>
Expand All @@ -91,9 +88,8 @@ defineOgImageComponent('Default', {
name="q"
:placeholder="$t('search.placeholder')"
v-bind="noCorrect"
:autofocus="!isMobile"
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"
@input="handleSearch"
@input="handleInput"
/>

<button
Expand Down
7 changes: 7 additions & 0 deletions app/utils/responsive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @public */
export function isTouchDevice() {
if (import.meta.server) {
return false
}
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
20 changes: 20 additions & 0 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ test.describe('Search Pages', () => {
await expect(page.locator('input[type="search"]')).toBeFocused()
})

test('/ (homepage) → search, keeps focus on search input', async ({ page, goto }) => {
await goto('/', { waitUntil: 'domcontentloaded' })

const homeSearchInput = page.locator('#home-search')
await homeSearchInput.click()
await page.keyboard.type('vue')

// Wait for navigation to /search (debounce is 250ms)
await expect(page).toHaveURL(/\/search/, { timeout: 10000 })
await expect(page.locator('text=/found \\d+/i')).toBeVisible({ timeout: 15000 })

// Home search input should be gone (we're on /search now)
await expect(homeSearchInput).not.toBeVisible()

// Header search input should now exist and be focused
const headerSearchInput = page.locator('#header-search')
await expect(headerSearchInput).toBeVisible()
await expect(headerSearchInput).toBeFocused()
})

test('/settings → search, keeps focus on search input', async ({ page, goto }) => {
await goto('/settings', { waitUntil: 'domcontentloaded' })

Expand Down
Loading