diff --git a/app/components/Header/SearchBox.vue b/app/components/Header/SearchBox.vue index 50ddd4f826..e9c968fc6f 100644 --- a/app/components/Header/SearchBox.vue +++ b/app/components/Header/SearchBox.vue @@ -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" diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index 45bb478487..4661644588 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -1,4 +1,5 @@ import { normalizeSearchParam } from '#shared/utils/url' +import { nextTick } from 'vue' import { debounce } from 'perfect-debounce' // Pages that have their own local filter using ?q @@ -6,6 +7,19 @@ 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() @@ -17,11 +31,21 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { const router = useRouter() const route = useRoute() + // Internally used searchQuery state const searchQuery = useState('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) }) @@ -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 + } }, ) @@ -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' }, + ) + } + + // 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, diff --git a/app/pages/index.vue b/app/pages/index.vue index 90d6e38879..632dfdab6e 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -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