Skip to content

Commit 2e82b99

Browse files
authored
feat: keep search box in header (#313)
1 parent bd47bae commit 2e82b99

3 files changed

Lines changed: 57 additions & 131 deletions

File tree

app/components/AppHeader.vue

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,48 @@ const { isConnected, npmUser } = useConnector()
1717
const router = useRouter()
1818
const route = useRoute()
1919
20-
const searchQuery = ref('')
2120
const isSearchFocused = ref(false)
2221
2322
const showSearchBar = computed(() => {
24-
return route.name !== 'search' && route.name !== 'index'
23+
return route.name !== 'index'
2524
})
2625
27-
const debouncedNavigate = debounce(async () => {
28-
const query = searchQuery.value.trim()
29-
await router.push({
26+
// Local input value (updates immediately as user types)
27+
const searchQuery = ref((route.query.q as string) ?? '')
28+
29+
// Debounced URL update for search query
30+
const updateUrlQuery = debounce((value: string) => {
31+
if (route.name === 'search') {
32+
router.replace({ query: { q: value || undefined } })
33+
return
34+
}
35+
if (!value) {
36+
return
37+
}
38+
39+
router.push({
3040
name: 'search',
31-
query: query ? { q: query } : undefined,
41+
query: {
42+
q: value,
43+
},
3244
})
33-
// allow time for the navigation to occur before resetting searchQuery
34-
setTimeout(() => (searchQuery.value = ''), 1000)
35-
}, 100)
45+
}, 250)
3646
37-
async function handleSearchInput() {
38-
debouncedNavigate()
39-
}
47+
// Watch input and debounce URL updates
48+
watch(searchQuery, value => {
49+
updateUrlQuery(value)
50+
})
4051
52+
// Sync input with URL when navigating (e.g., back button)
53+
watch(
54+
() => route.query.q,
55+
urlQuery => {
56+
const value = (urlQuery as string) ?? ''
57+
if (searchQuery.value !== value) {
58+
searchQuery.value = value
59+
}
60+
},
61+
)
4162
onKeyStroke(',', e => {
4263
// Don't trigger if user is typing in an input
4364
const target = e.target as HTMLElement
@@ -74,7 +95,7 @@ onKeyStroke(',', e => {
7495
<div class="flex-1 flex items-center justify-center gap-4 sm:gap-6">
7596
<!-- Search bar (shown on all pages except home and search) -->
7697
<search v-if="showSearchBar" class="hidden sm:block flex-1 max-w-md">
77-
<form method="GET" action="/search" class="relative" @submit.prevent="handleSearchInput">
98+
<form method="GET" action="/search" class="relative">
7899
<label for="header-search" class="sr-only">
79100
{{ $t('search.label') }}
80101
</label>
@@ -89,13 +110,13 @@ onKeyStroke(',', e => {
89110

90111
<input
91112
id="header-search"
113+
autofocus
92114
v-model="searchQuery"
93115
type="search"
94116
name="q"
95117
:placeholder="$t('search.placeholder')"
96118
v-bind="noCorrect"
97119
class="w-full bg-bg-subtle border border-border rounded-md ps-7 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-border-color duration-300 motion-reduce:transition-none focus:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
98-
@input="handleSearchInput"
99120
@focus="isSearchFocused = true"
100121
@blur="isSearchFocused = false"
101122
/>

app/pages/search.vue

Lines changed: 7 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import { formatNumber } from '#imports'
33
import type { FilterChip, SortOption } from '#shared/types/preferences'
4+
import { onKeyDown } from '@vueuse/core'
45
import { debounce } from 'perfect-debounce'
56
import { isValidNewPackageName, checkPackageExists } from '~/utils/package-name'
67
import { isPlatformSpecificPackage } from '~/utils/platform-packages'
@@ -18,14 +19,6 @@ const {
1819
resetColumns,
1920
} = usePackageListPreferences()
2021
21-
// Local input value (updates immediately as user types)
22-
const inputValue = ref((route.query.q as string) ?? '')
23-
24-
// Debounced URL update for search query
25-
const updateUrlQuery = debounce((value: string) => {
26-
router.replace({ query: { q: value || undefined } })
27-
}, 250)
28-
2922
// Debounced URL update for page (less aggressive to avoid too many URL changes)
3023
const updateUrlPage = debounce((page: number) => {
3124
router.replace({
@@ -36,37 +29,15 @@ const updateUrlPage = debounce((page: number) => {
3629
})
3730
}, 500)
3831
39-
// Watch input and debounce URL updates
40-
watch(inputValue, value => {
41-
updateUrlQuery(value)
42-
})
43-
4432
// The actual search query (from URL, used for API calls)
4533
const query = computed(() => (route.query.q as string) ?? '')
4634
47-
// Sync input with URL when navigating (e.g., back button)
48-
watch(
49-
() => route.query.q,
50-
urlQuery => {
51-
const value = (urlQuery as string) ?? ''
52-
if (inputValue.value !== value) {
53-
inputValue.value = value
54-
}
55-
},
56-
)
57-
58-
// For glow effect
59-
const searchInputRef = useTemplateRef('searchInputRef')
60-
const { focused: isSearchFocused } = useFocus(searchInputRef)
61-
6235
const selectedIndex = ref(0)
6336
const packageListRef = useTemplateRef('packageListRef')
6437
6538
// Track if page just loaded (for hiding "Searching..." during view transition)
6639
const hasInteracted = ref(false)
6740
onMounted(() => {
68-
// Focus search onMount
69-
isSearchFocused.value = true
7041
// Small delay to let view transition complete
7142
setTimeout(() => {
7243
hasInteracted.value = true
@@ -668,50 +639,22 @@ function scrollToSelectedItem() {
668639
}
669640
}
670641
671-
function focusSelectedItem() {
672-
const suggIdx = toSuggestionIndex(unifiedSelectedIndex.value)
673-
const pkgIdx = toPackageIndex(unifiedSelectedIndex.value)
674-
675-
nextTick(() => {
676-
if (suggIdx !== null) {
677-
const el = document.querySelector<HTMLElement>(`[data-suggestion-index="${suggIdx}"]`)
678-
el?.focus()
679-
} else if (pkgIdx !== null) {
680-
scrollToSelectedItem()
681-
nextTick(() => {
682-
const el = document.querySelector<HTMLElement>(`[data-result-index="${pkgIdx}"]`)
683-
el?.focus()
684-
})
685-
}
686-
})
687-
}
688-
689642
function handleResultsKeydown(e: KeyboardEvent) {
690643
if (totalSelectableCount.value <= 0) return
691644
692-
const isFromInput = (e.target as HTMLElement).tagName === 'INPUT'
693-
694645
if (e.key === 'ArrowDown') {
695646
e.preventDefault()
696647
userHasNavigated.value = true
697648
unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value + 1)
698-
if (isFromInput) {
699-
scrollToSelectedItem()
700-
} else {
701-
focusSelectedItem()
702-
}
649+
scrollToSelectedItem()
703650
return
704651
}
705652
706653
if (e.key === 'ArrowUp') {
707654
e.preventDefault()
708655
userHasNavigated.value = true
709656
unifiedSelectedIndex.value = clampUnifiedIndex(unifiedSelectedIndex.value - 1)
710-
if (isFromInput) {
711-
scrollToSelectedItem()
712-
} else {
713-
focusSelectedItem()
714-
}
657+
scrollToSelectedItem()
715658
return
716659
}
717660
@@ -737,6 +680,8 @@ function handleResultsKeydown(e: KeyboardEvent) {
737680
}
738681
}
739682
683+
onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown)
684+
740685
function handleSuggestionSelect(index: number) {
741686
// Convert suggestion index to unified index
742687
unifiedSelectedIndex.value = -(suggestionCount.value - index)
@@ -759,61 +704,9 @@ defineOgImageComponent('Default', {
759704

760705
<template>
761706
<main class="overflow-x-hidden">
762-
<!-- Sticky search header - positioned below AppHeader (h-14 = 56px) -->
763-
<header class="sticky top-14 z-40 bg-bg/95 backdrop-blur-sm border-b border-border">
764-
<div class="container-sm py-4">
765-
<h1 class="font-mono text-xl sm:text-2xl font-medium mb-4">{{ $t('nav.search') }}</h1>
766-
767-
<search>
768-
<form method="GET" action="/search" class="relative" @submit.prevent>
769-
<label for="search-input" class="sr-only">{{ $t('search.label') }}</label>
770-
771-
<div class="relative group" :class="{ 'is-focused': isSearchFocused }">
772-
<!-- Subtle glow effect -->
773-
<div
774-
class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100 motion-reduce:transition-none"
775-
/>
776-
777-
<div class="search-box relative flex items-center">
778-
<span
779-
class="absolute left-4 text-fg-subtle font-mono text-base pointer-events-none transition-colors duration-200 group-focus-within:text-accent"
780-
aria-hidden="true"
781-
>
782-
/
783-
</span>
784-
<input
785-
id="search-input"
786-
ref="searchInputRef"
787-
v-model="inputValue"
788-
type="search"
789-
name="q"
790-
:placeholder="$t('search.placeholder')"
791-
v-bind="noCorrect"
792-
autofocus
793-
class="w-full max-w-full bg-bg-subtle border border-border rounded-lg pl-8 pr-10 py-3 font-mono text-base text-fg placeholder:text-fg-subtle transition-colors duration-300 focus:border-accent focus-visible:outline-none appearance-none"
794-
@keydown="handleResultsKeydown"
795-
/>
796-
<button
797-
v-show="inputValue"
798-
type="button"
799-
class="absolute right-3 p-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
800-
:aria-label="$t('search.clear')"
801-
@click="inputValue = ''"
802-
>
803-
<span class="i-carbon-close-large block w-3.5 h-3.5" aria-hidden="true" />
804-
</button>
805-
<!-- Hidden submit button for accessibility (form must have submit button per WCAG) -->
806-
<button type="submit" class="sr-only">{{ $t('search.button') }}</button>
807-
</div>
808-
</div>
809-
</form>
810-
</search>
811-
</div>
812-
</header>
813-
814707
<!-- Results area with container padding -->
815-
<div class="container-sm pt-20 pb-6">
816-
<section v-if="query" :aria-label="$t('search.results')" @keydown="handleResultsKeydown">
708+
<div class="container-sm py-6">
709+
<section v-if="query" :aria-label="$t('search.results')">
817710
<!-- Initial loading (only after user interaction, not during view transition) -->
818711
<LoadingSpinner v-if="showSearching" :text="$t('search.searching')" />
819712

tests/interactions.spec.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@ test.describe('Search Pages', () => {
66

77
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
88

9-
const searchInput = page.locator('input[type="search"]')
10-
await expect(searchInput).toBeFocused()
11-
129
const firstResult = page.locator('[data-result-index="0"]').first()
1310
await expect(firstResult).toBeVisible()
1411

12+
const searchInput = page.locator('input[type="search"]')
13+
1514
// ArrowDown changes visual selection but keeps focus in input
1615
await page.keyboard.press('ArrowDown')
1716
await expect(searchInput).toBeFocused()
@@ -35,4 +34,17 @@ test.describe('Search Pages', () => {
3534
await page.keyboard.press('/')
3635
await expect(page.locator('input[type="search"]')).toBeFocused()
3736
})
37+
38+
test('/settings → search, keeps focus on search input', async ({ page, goto }) => {
39+
await goto('/settings', { waitUntil: 'domcontentloaded' })
40+
41+
const searchInput = page.locator('input[type="search"]')
42+
await searchInput.fill('vue')
43+
44+
await page.waitForLoadState('domcontentloaded')
45+
46+
await expect(page.locator('text=/found \\d+/i')).toBeVisible()
47+
48+
await expect(searchInput).toBeFocused()
49+
})
3850
})

0 commit comments

Comments
 (0)