import { watch, onBeforeUnmount } from 'vue' import type { Ref, ComputedRef } from 'vue' // ─── Types ─────────────────────────────────────────────────────────────────── export interface UrlSearchState { query: string page: number scroll: number selectedId: string | null } export interface UseSearchUrlSyncOptions { query: Ref page: Ref /** Computed or ref yielding the ID of the currently selected item */ selectedId: Ref | ComputedRef /** The scrollable list container, if any (for scroll tracking & restoration) */ scrollEl?: Ref | ComputedRef } // ─── Read initial state from URL ───────────────────────────────────────────── /** * Reads search state from the current route's query params. * Safe to call during SSR (falls back to empty defaults). * * Call this BEFORE creating your reactive refs and initialise them with * the returned values so the first search uses the URL state directly — * this avoids a redundant re-search triggered by the debounced watcher. * * @example * const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState() * const query = ref(q0) // debouncedQuery starts at q0 → watcher won't re-fire * const activePage = ref(p0) */ export function useSearchUrlState(): UrlSearchState { const route = useRoute() return { query: String(route.query.q ?? ''), page: Math.max(1, parseInt(String(route.query.page ?? '1'), 10) || 1), scroll: Math.max(0, parseInt(String(route.query.scroll ?? '0'), 10) || 0), selectedId: route.query.selected ? String(route.query.selected) : null, } } // ─── Keep state in sync with URL ───────────────────────────────────────────── /** * Watches query, page, selectedId and (optionally) scroll on a container * element, writing each change to the URL via history.replaceState so the * state can be copied and restored by any user. * * Uses replaceState (not pushState) so the browser history stack stays clean. * Existing URL params unrelated to search are preserved. */ export function useSearchUrlSync(options: UseSearchUrlSyncOptions): void { if (!import.meta.client) return let scrollTimer: ReturnType | null = null let queryTimer: ReturnType | null = null // ── Write a partial patch to the current URL ──────────────────────────── function patchUrl(params: Record) { const url = new URL(window.location.href) for (const [key, val] of Object.entries(params)) { if (val !== null && val !== '') { url.searchParams.set(key, val) } else { url.searchParams.delete(key) } } history.replaceState(history.state ?? null, '', url.toString()) } // ── Watchers ──────────────────────────────────────────────────────────── // Query: debounce to avoid thrashing on every keystroke. // Also resets page and scroll since a new query starts fresh. watch(options.query, (q) => { if (queryTimer) clearTimeout(queryTimer) queryTimer = setTimeout( () => patchUrl({ q: q || null, page: null, scroll: null }), 150, ) }) // Page: immediate — user navigated intentionally. // Scroll position is no longer meaningful when page changes. watch(options.page, (p) => { patchUrl({ page: p > 1 ? String(p) : null, scroll: null }) }) // Selected item: immediate. watch(options.selectedId, (id) => { patchUrl({ selected: id || null }) }) // ── Scroll tracking ───────────────────────────────────────────────────── if (options.scrollEl) { function handleScroll(ev: Event) { if (scrollTimer) clearTimeout(scrollTimer) scrollTimer = setTimeout(() => { const top = (ev.target as HTMLElement).scrollTop patchUrl({ scroll: top > 0 ? String(Math.round(top)) : null }) }, 400) } watch( options.scrollEl, (el, oldEl) => { ;(oldEl as HTMLElement | null)?.removeEventListener('scroll', handleScroll) ;(el as HTMLElement | null)?.addEventListener('scroll', handleScroll, { passive: true }) }, { immediate: true }, ) onBeforeUnmount(() => { ;(options.scrollEl!.value as HTMLElement | null) ?.removeEventListener('scroll', handleScroll) }) } onBeforeUnmount(() => { if (scrollTimer) clearTimeout(scrollTimer) if (queryTimer) clearTimeout(queryTimer) }) } // ─── Scroll restoration helper ──────────────────────────────────────────────── /** * Restores a scroll position on an element after a short settling delay. * Call this inside onMounted, after awaiting the first search, so the list * has had time to render its items. */ export function restoreScrollPosition( scrollEl: HTMLElement | null | undefined, scrollY: number, delayMs = 80, ): void { if (!scrollY || !scrollEl || !import.meta.client) return setTimeout(() => { scrollEl.scrollTop = scrollY }, delayMs) }