Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ defineExpose({ focus })
v-model="searchQuery"
type="search"
name="q"
data-global-search
:placeholder="$t('search.placeholder')"
no-correct
class="w-full min-w-25 ps-7 pe-8"
Expand Down
90 changes: 85 additions & 5 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import { normalizeSearchParam } from '#shared/utils/url'
import { nextTick } from 'vue'
import { debounce } from 'perfect-debounce'

// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

const SEARCH_DEBOUNCE_MS = 100

/**
* Returns the value of the focused global search input, if any.
* Only matches inputs explicitly marked with data-global-search attribute
* to avoid capturing page-local filter inputs.
*/
const getFocusedSearchInputValue = () => {
if (!import.meta.client) return ''

const active = document.activeElement
if (!(active instanceof HTMLInputElement)) return ''
if (!active.hasAttribute('data-global-search')) return ''
return active.value
}
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const { settings } = useSettings()
const { searchProvider } = useSearchProvider()
Expand All @@ -17,11 +31,21 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {

const router = useRouter()
const route = useRoute()

// Internally used searchQuery state
const searchQuery = useState<string>('search-query', () => {
// Skip reading focused input on pages with local filters - they use ?q for local state
if (pagesWithLocalFilter.has(route.name as string)) {
return ''
}

// Preserve fast typing before hydration (e.g. homepage autofocus search input).
// Only captures inputs with data-global-search marker attribute.
const focusedInputValue = getFocusedSearchInputValue()
if (focusedInputValue) {
return focusedInputValue
}

return normalizeSearchParam(route.query.q)
})

Expand All @@ -40,13 +64,24 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
}
})

// clean search input when navigating away from search page
// Sync URL query to input state only on search page.
// On other pages (e.g. home), keep the user's in-progress typing untouched.
watch(
() => route.query.q,
urlQuery => {
() => [route.name, route.query.q] as const,
([routeName, urlQuery]) => {
if (routeName !== 'search') return

const value = normalizeSearchParam(urlQuery)
if (!value) searchQuery.value = ''
if (!searchQuery.value) searchQuery.value = value
// Only skip when the focused input already reflects this URL value.
if (import.meta.client) {
const activeValue = getFocusedSearchInputValue()
if (activeValue && activeValue === value) {
return
}
}
if (searchQuery.value !== value) {
searchQuery.value = value
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
)

Expand Down Expand Up @@ -108,6 +143,51 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {
},
})

// When navigating back to the homepage (e.g. via logo click from /search),
// reset the global search state so the home input starts fresh and re-focus
// the dedicated home search input.
// Only register in one place (content instance) to avoid duplicate reset/refocus handlers
// when useGlobalSearch is called from multiple callsites (e.g., Header/SearchBox and page components).
if (import.meta.client && place === 'content') {
watch(
() => route.name,
name => {
if (name !== 'index') return
// Drop any in-flight URL/commit updates so they can't navigate
// back to /search or revive the old committed value after reset.
updateUrlQuery.cancel()
commitSearchQuery.cancel()
searchQuery.value = ''
committedSearchQuery.value = ''
// Use nextTick so we run after the homepage has rendered.
nextTick(() => {
const homeInput = document.getElementById('home-search')
if (homeInput instanceof HTMLInputElement) {
homeInput.focus()
homeInput.select()
}
})
},
{ flush: 'post' },
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// On hydration, useState can reuse SSR payload (often empty), skipping initializer.
// Recover fast-typed value from the focused input once on client mount.
// Skip on pages with local filters to avoid importing local ?q state.
// Only register in one place (content instance) to avoid duplicate hydration recovery.
if (import.meta.client && place === 'content') {
onMounted(() => {
if (pagesWithLocalFilter.has(route.name as string)) return
const focusedInputValue = getFocusedSearchInputValue()
if (!focusedInputValue) return
if (searchQuery.value) return

// Use model setter path to preserve instant-search behavior.
searchQueryValue.value = focusedInputValue
})
}

return {
model: searchQueryValue,
committedModel: committedSearchQuery,
Expand Down
1 change: 1 addition & 0 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ defineOgImage('Splash.takumi', {}, { alt: () => $t('seo.home.description') })
v-model="searchQuery"
type="search"
name="q"
data-global-search
autofocus
:placeholder="$t('search.placeholder')"
no-correct
Expand Down
Loading