search/app/composables/useSearchUrlSync.ts

147 lines
5.6 KiB
TypeScript

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<string>
page: Ref<number>
/** Computed or ref yielding the ID of the currently selected item */
selectedId: Ref<string | null> | ComputedRef<string | null>
/** The scrollable list container, if any (for scroll tracking & restoration) */
scrollEl?: Ref<HTMLElement | null> | ComputedRef<HTMLElement | null>
}
// ─── 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<typeof setTimeout> | null = null
let queryTimer: ReturnType<typeof setTimeout> | null = null
// ── Write a partial patch to the current URL ────────────────────────────
function patchUrl(params: Record<string, string | null>) {
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)
}