Skip to content

Commit b3eced0

Browse files
garthdwdanielroe
andauthored
feat: keep search box in header (#313)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 0805720 commit b3eced0

4 files changed

Lines changed: 141 additions & 177 deletions

File tree

app/components/AppHeader.vue

Lines changed: 17 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
<script setup lang="ts">
2-
import { debounce } from 'perfect-debounce'
3-
42
withDefaults(
53
defineProps<{
64
showLogo?: boolean
@@ -15,28 +13,8 @@ withDefaults(
1513
const { isConnected, npmUser } = useConnector()
1614
1715
const router = useRouter()
18-
const route = useRoute()
19-
20-
const searchQuery = ref('')
21-
const isSearchFocused = ref(false)
22-
23-
const showSearchBar = computed(() => {
24-
return route.name !== 'search' && route.name !== 'index'
25-
})
26-
27-
const debouncedNavigate = debounce(async () => {
28-
const query = searchQuery.value.trim()
29-
await router.push({
30-
name: 'search',
31-
query: query ? { q: query } : undefined,
32-
})
33-
// allow time for the navigation to occur before resetting searchQuery
34-
setTimeout(() => (searchQuery.value = ''), 1000)
35-
}, 100)
3616
37-
async function handleSearchInput() {
38-
debouncedNavigate()
39-
}
17+
const showFullSearch = ref(false)
4018
4119
onKeyStroke(',', e => {
4220
// Don't trigger if user is typing in an input
@@ -57,7 +35,7 @@ onKeyStroke(',', e => {
5735
class="container h-14 flex items-center justify-start"
5836
>
5937
<!-- Start: Logo -->
60-
<div class="flex-shrink-0">
38+
<div :class="{ 'hidden sm:block': showFullSearch }" class="flex-shrink-0">
6139
<NuxtLink
6240
v-if="showLogo"
6341
to="/"
@@ -71,41 +49,17 @@ onKeyStroke(',', e => {
7149
</div>
7250

7351
<!-- Center: Search bar + nav items -->
74-
<div class="flex-1 flex items-center justify-center gap-4 sm:gap-6">
75-
<!-- Search bar (shown on all pages except home and search) -->
76-
<search v-if="showSearchBar" class="hidden sm:block flex-1 max-w-md">
77-
<form method="GET" action="/search" class="relative" @submit.prevent="handleSearchInput">
78-
<label for="header-search" class="sr-only">
79-
{{ $t('search.label') }}
80-
</label>
81-
82-
<div class="relative group" :class="{ 'is-focused': isSearchFocused }">
83-
<div class="search-box relative flex items-center">
84-
<span
85-
class="absolute inset-is-3 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 motion-reduce:transition-none group-focus-within:text-accent z-1"
86-
>
87-
/
88-
</span>
89-
90-
<input
91-
id="header-search"
92-
v-model="searchQuery"
93-
type="search"
94-
name="q"
95-
:placeholder="$t('search.placeholder')"
96-
v-bind="noCorrect"
97-
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"
99-
@focus="isSearchFocused = true"
100-
@blur="isSearchFocused = false"
101-
/>
102-
<button type="submit" class="sr-only">{{ $t('search.button') }}</button>
103-
</div>
104-
</div>
105-
</form>
106-
</search>
107-
108-
<ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0">
52+
<div class="flex-1 flex items-center justify-center md:gap-6 mx-2">
53+
<!-- Search bar (shown on all pages except home) -->
54+
<SearchBox
55+
:inputClass="showFullSearch ? '' : 'max-w[6rem]'"
56+
@focus="showFullSearch = true"
57+
@blur="showFullSearch = false"
58+
/>
59+
<ul
60+
:class="{ 'hidden sm:flex': showFullSearch }"
61+
class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0"
62+
>
10963
<!-- Packages dropdown (when connected) -->
11064
<li v-if="isConnected && npmUser" class="flex items-center">
11165
<HeaderPackagesDropdown :username="npmUser" />
@@ -119,7 +73,10 @@ onKeyStroke(',', e => {
11973
</div>
12074

12175
<!-- End: User status + GitHub -->
122-
<div class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0">
76+
<div
77+
:class="{ 'hidden sm:flex': showFullSearch }"
78+
class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0"
79+
>
12380
<NuxtLink
12481
to="/about"
12582
class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"

app/components/SearchBox.vue

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<script setup lang="ts">
2+
import { debounce } from 'perfect-debounce'
3+
4+
withDefaults(
5+
defineProps<{
6+
inputClass?: string
7+
}>(),
8+
{
9+
inputClass: 'inline sm:block',
10+
},
11+
)
12+
13+
const emit = defineEmits(['blur', 'focus'])
14+
15+
const router = useRouter()
16+
const route = useRoute()
17+
18+
const isSearchFocused = ref(false)
19+
20+
const showSearchBar = computed(() => {
21+
return route.name !== 'index'
22+
})
23+
24+
// Local input value (updates immediately as user types)
25+
const searchQuery = ref((route.query.q as string) ?? '')
26+
27+
// Debounced URL update for search query
28+
const updateUrlQuery = debounce((value: string) => {
29+
if (route.name === 'search') {
30+
router.replace({ query: { q: value || undefined } })
31+
return
32+
}
33+
if (!value) {
34+
return
35+
}
36+
37+
router.push({
38+
name: 'search',
39+
query: {
40+
q: value,
41+
},
42+
})
43+
}, 250)
44+
45+
// Watch input and debounce URL updates
46+
watch(searchQuery, value => {
47+
updateUrlQuery(value)
48+
})
49+
50+
// Sync input with URL when navigating (e.g., back button)
51+
watch(
52+
() => route.query.q,
53+
urlQuery => {
54+
const value = (urlQuery as string) ?? ''
55+
if (searchQuery.value !== value) {
56+
searchQuery.value = value
57+
}
58+
},
59+
)
60+
61+
function handleSearchBlur() {
62+
isSearchFocused.value = false
63+
emit('blur')
64+
}
65+
function handleSearchFocus() {
66+
isSearchFocused.value = true
67+
emit('focus')
68+
}
69+
</script>
70+
<template>
71+
<search v-if="showSearchBar" :class="'flex-1 sm:max-w-md ' + inputClass">
72+
<form method="GET" action="/search" class="relative">
73+
<label for="header-search" class="sr-only">
74+
{{ $t('search.label') }}
75+
</label>
76+
77+
<div class="relative group" :class="{ 'is-focused': isSearchFocused }">
78+
<div class="search-box relative flex items-center">
79+
<span
80+
class="absolute inset-is-3 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 motion-reduce:transition-none group-focus-within:text-accent z-1"
81+
>
82+
/
83+
</span>
84+
85+
<input
86+
id="header-search"
87+
autofocus
88+
v-model="searchQuery"
89+
type="search"
90+
name="q"
91+
:placeholder="$t('search.placeholder')"
92+
v-bind="noCorrect"
93+
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"
94+
@focus="handleSearchFocus"
95+
@blur="handleSearchBlur"
96+
/>
97+
<button type="submit" class="sr-only">{{ $t('search.button') }}</button>
98+
</div>
99+
</div>
100+
</form>
101+
</search>
102+
</template>

0 commit comments

Comments
 (0)