Skip to content
Open
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
72 changes: 67 additions & 5 deletions app/composables/useGlobalSearch.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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
const getFocusedSearchInputValue = () => {
if (!import.meta.client) return ''

const active = document.activeElement
if (!(active instanceof HTMLInputElement)) return ''
if (active.type !== 'search' && active.name !== 'q') return ''
return active.value
}
export function useGlobalSearch(place: 'header' | 'content' = 'content') {
const { settings } = useSettings()
const { searchProvider } = useSearchProvider()
Expand All @@ -17,8 +25,15 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') {

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

// Internally used searchQuery state
const searchQuery = useState<string>('search-query', () => {
// Preserve fast typing before hydration (e.g. homepage autofocus search input).
const focusedInputValue = getFocusedSearchInputValue()
if (focusedInputValue) {
return focusedInputValue
}

if (pagesWithLocalFilter.has(route.name as string)) {
return ''
}
Expand All @@ -40,13 +55,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 +134,42 @@ 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.
if (import.meta.client) {
watch(
() => route.name,
name => {
if (name !== 'index') return
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' },
)
}

// On hydration, useState can reuse SSR payload (often empty), skipping initializer.
// Recover fast-typed value from the focused input once on client mount.
if (import.meta.client) {
onMounted(() => {
const focusedInputValue = getFocusedSearchInputValue()
if (!focusedInputValue) return
if (searchQuery.value) return

// Use model setter path to preserve instant-search behavior.
searchQueryValue.value = focusedInputValue
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return {
model: searchQueryValue,
committedModel: committedSearchQuery,
Expand Down
Loading