147 lines
5.6 KiB
TypeScript
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)
|
|
}
|