import { reactive, ref, watch, type Ref } from 'vue' import { useRoute, useRouter, type LocationQuery, type LocationQueryRaw } from 'vue-router' // --------------------------------------------------------------------------- // Public schema types // --------------------------------------------------------------------------- export type RouteFilterValue = string | number | boolean | string[] export type RouteFilterRecord = Record export interface RouteSortDefault { field?: string dir?: 'asc' | 'desc' | '' } export interface UseRouteFiltersSchema { filter: F draft?: D sort?: RouteSortDefault search?: string page?: number } // --------------------------------------------------------------------------- // Return types // --------------------------------------------------------------------------- export type RouteFilterState = { [K in keyof F]: F[K] extends string[] ? string[] : F[K] } export interface RouteSort { /** Current sort value, e.g. `'-created'`, `'+name'`, or `''` */ readonly value: string /** Current field being sorted (without sign prefix) */ readonly field: string /** Current direction */ readonly dir: 'asc' | 'desc' | '' /** * Toggle sort for `field`. * - none / other field → desc (`-field`) * - `-field` → asc (`+field`) * - `+field` → none (`''`) */ toggle: (field: string) => void set: (field: string, dir: 'asc' | 'desc') => void clear: () => void /** Icon hint for table header */ iconFor: (field: string) => 'ascending' | 'descending' | 'sort' } export interface RouteDraftControl { /** Reactive draft state — edit directly without .value */ readonly state: RouteFilterState /** Copy active filter values into draft (call before opening modal) */ open: () => void /** Apply draft values to active filter (call on save) */ apply: () => void /** Discard draft changes (call on cancel) */ cancel: () => void /** Reset draft to schema defaults */ reset: () => void } export interface UseRouteFiltersReturn { /** Reactive filter state — read/write directly without .value */ filter: RouteFilterState /** Draft state + controls (only when `draft` was provided in schema) */ draft: RouteDraftControl /** Sort controls */ sort: RouteSort /** Reactive search ref (for v-model on input) */ search: Ref /** Reactive page ref (for v-model / pagination) */ page: Ref /** Reset filter, search, page and sort to defaults */ reset: () => Promise /** Clear all schema-managed query params */ clear: () => Promise } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- const isArr = (v: RouteFilterValue): v is string[] => Array.isArray(v) const clone = (v: RouteFilterValue): RouteFilterValue => (isArr(v) ? [...v] : v) const cloneRecord = (defaults: F): RouteFilterState => { const out = {} as RouteFilterState for (const k in defaults) { out[k] = clone(defaults[k]) as RouteFilterState[typeof k] } return out } const isDefault = (v: RouteFilterValue, def: RouteFilterValue): boolean => { if (isArr(def)) return isArr(v) && v.length === 0 return v === def } const parseValue = (key: string, query: LocationQuery, def: RouteFilterValue): RouteFilterValue => { const raw = query[key] if (isArr(def)) { if (raw === undefined || raw === null) return [] if (Array.isArray(raw)) return raw.filter((x): x is string => x != null).map(String) return [String(raw)] } if (typeof def === 'boolean') return raw === 'true' if (typeof def === 'number') { if (raw === undefined || raw === null || raw === '') return def const n = Number(Array.isArray(raw) ? raw[0] : raw) return Number.isFinite(n) ? n : def } if (raw === undefined || raw === null) return def return String(Array.isArray(raw) ? raw[0] : raw) } const serializeValue = (v: RouteFilterValue, def: RouteFilterValue): LocationQueryRaw[string] | undefined => { if (isDefault(v, def)) return undefined if (isArr(def)) return isArr(v) && v.length > 0 ? v : undefined if (typeof def === 'boolean') return v === true ? 'true' : undefined if (typeof def === 'number') return String(v) if (typeof v === 'string' && v !== '') return v return undefined } const normalizeQuery = (q: LocationQuery | LocationQueryRaw): Record => { const out: Record = {} for (const k of Object.keys(q).sort()) { const v = q[k] if (v === undefined || v === null) continue if (Array.isArray(v)) { const items = v.filter((x) => x != null).map(String) if (items.length) out[k] = items } else { out[k] = String(v) } } return out } const queriesEqual = (a: LocationQuery | LocationQueryRaw, b: LocationQuery | LocationQueryRaw) => JSON.stringify(normalizeQuery(a)) === JSON.stringify(normalizeQuery(b)) const statesEqual = (a: RouteFilterState, b: RouteFilterState): boolean => { for (const k in a) { const av = a[k] as RouteFilterValue const bv = b[k] as RouteFilterValue if (isArr(av)) { if (!isArr(bv) || av.length !== bv.length || av.some((x, i) => x !== bv[i])) return false } else if (av !== bv) { return false } } return true } const applyInPlace = ( target: RouteFilterState, source: RouteFilterState, defaults: F, ) => { for (const k in defaults) { const next = source[k] as RouteFilterValue const current = target[k] as RouteFilterValue if (isArr(defaults[k])) { const na = next as string[] const ca = current as string[] if (na.length !== ca.length || na.some((x, i) => x !== ca[i])) { ca.splice(0, ca.length, ...na) } } else if (current !== next) { target[k] = next as RouteFilterState[typeof k] } } } /** Works on plain `Record` — used for draft operations */ const copyPlain = ( source: Record, target: Record, keys: string[], ) => { for (const k of keys) { const v = source[k] if (isArr(v)) { const ca = target[k] if (Array.isArray(ca)) { ca.splice(0, ca.length, ...v) } else { target[k] = [...v] } } else { target[k] = clone(v) } } } // --------------------------------------------------------------------------- // URL constants // --------------------------------------------------------------------------- const SEARCH_KEY = '_search' const PAGE_KEY = '_page' const SORT_KEY = '_sort' // --------------------------------------------------------------------------- // Main composable // --------------------------------------------------------------------------- export function useRouteFilters( schema: UseRouteFiltersSchema, ): UseRouteFiltersReturn { const route = useRoute() const router = useRouter() const filterDefaults = schema.filter const draftDefaults = (schema.draft ?? {}) as D const sortDefault = schema.sort ?? {} const searchDefault = schema.search ?? '' const pageDefault = schema.page ?? 0 const draftKeys = Object.keys(draftDefaults) as (keyof D & keyof F)[] const schemaQueryKeys = new Set([...Object.keys(filterDefaults), ...draftKeys, SEARCH_KEY, PAGE_KEY, SORT_KEY]) // ── reactive state ──────────────────────────────────────────────────────── const filterState = reactive(cloneRecord(filterDefaults)) as RouteFilterState const draftState = reactive(cloneRecord(draftDefaults as RouteFilterRecord)) as RouteFilterState const searchRef = ref(searchDefault) const pageRef = ref(pageDefault) // ── sort internal ref ───────────────────────────────────────────────────── const defaultSortValue = sortDefault.field && sortDefault.dir ? `${sortDefault.dir === 'desc' ? '-' : '+'}${sortDefault.field}` : '' const sortValueRef = ref(defaultSortValue) // ── convenience plain-object aliases for draft ops ─────────────────────── const filterRaw = filterState as unknown as Record const draftRaw = draftState as unknown as Record const draftKeyStrings = draftKeys as string[] // ── loop guards ─────────────────────────────────────────────────────────── let syncingFromRoute = false let syncingToRoute = false // ── init from URL ───────────────────────────────────────────────────────── const readFromRoute = () => { const q = route.query // filter const parsed = cloneRecord(filterDefaults) for (const k in filterDefaults) { parsed[k] = parseValue(k, q, filterDefaults[k]) as RouteFilterState[typeof k] } if (!statesEqual(filterState, parsed)) { applyInPlace(filterState, parsed, filterDefaults) } // search const rawSearch = q[SEARCH_KEY] const nextSearch = rawSearch !== undefined && rawSearch !== null ? String(Array.isArray(rawSearch) ? rawSearch[0] : rawSearch) : searchDefault if (searchRef.value !== nextSearch) searchRef.value = nextSearch // page const rawPage = q[PAGE_KEY] const nextPage = rawPage !== undefined && rawPage !== null ? Number(Array.isArray(rawPage) ? rawPage[0] : rawPage) || pageDefault : pageDefault if (pageRef.value !== nextPage) pageRef.value = nextPage // sort const rawSort = q[SORT_KEY] const nextSort = typeof rawSort === 'string' && rawSort !== '' ? rawSort : defaultSortValue if (sortValueRef.value !== nextSort) sortValueRef.value = nextSort // draft: initialise from filter if (draftKeys.length > 0) { copyPlain( filterState as unknown as Record, draftState as unknown as Record, draftKeys as string[], ) } } readFromRoute() // ── build query from state ──────────────────────────────────────────────── const buildQuery = (): LocationQueryRaw => { const q: LocationQueryRaw = {} // preserve non-schema keys for (const k in route.query) { if (!schemaQueryKeys.has(k)) q[k] = route.query[k] } // filter for (const k in filterDefaults) { const s = serializeValue(filterState[k] as RouteFilterValue, filterDefaults[k]) if (s !== undefined) q[k] = s } // search if (searchRef.value !== searchDefault && searchRef.value !== '') { q[SEARCH_KEY] = searchRef.value } // page if (pageRef.value !== pageDefault) { q[PAGE_KEY] = String(pageRef.value) } // sort if (sortValueRef.value !== '' && sortValueRef.value !== defaultSortValue) { q[SORT_KEY] = sortValueRef.value } else if (defaultSortValue !== '' && sortValueRef.value === defaultSortValue) { // default sort — omit from URL } return q } const pushRoute = (): Promise | void => { if (syncingFromRoute || syncingToRoute) return const next = buildQuery() if (queriesEqual(route.query, next)) return syncingToRoute = true return router .replace({ query: next }) .then(() => undefined) .finally(() => { syncingToRoute = false }) } // ── watchers ────────────────────────────────────────────────────────────── watch( () => route.query, () => { if (syncingToRoute) return syncingFromRoute = true readFromRoute() syncingFromRoute = false }, ) watch( filterState, () => { pushRoute() }, { deep: true }, ) watch(searchRef, () => { pushRoute() }) watch(pageRef, () => { pushRoute() }) watch(sortValueRef, () => { pushRoute() }) // ── sort control ────────────────────────────────────────────────────────── const sort: RouteSort = { get value() { return sortValueRef.value }, get field() { return sortValueRef.value.length > 1 ? sortValueRef.value.slice(1) : '' }, get dir(): 'asc' | 'desc' | '' { if (!sortValueRef.value) return '' return sortValueRef.value[0] === '-' ? 'desc' : 'asc' }, toggle(field: string) { if (sortValueRef.value === `-${field}`) { sortValueRef.value = `+${field}` } else if (sortValueRef.value === `+${field}`) { sortValueRef.value = '' } else { sortValueRef.value = `-${field}` } }, set(field: string, dir: 'asc' | 'desc') { sortValueRef.value = `${dir === 'desc' ? '-' : '+'}${field}` }, clear() { sortValueRef.value = '' }, iconFor(field: string): 'ascending' | 'descending' | 'sort' { if (sortValueRef.value.slice(1) !== field) return 'sort' return sortValueRef.value[0] === '-' ? 'descending' : 'ascending' }, } // ── draft control ───────────────────────────────────────────────────────── const draft: RouteDraftControl = { state: draftState, open() { copyPlain(filterRaw, draftRaw, draftKeyStrings) }, apply() { copyPlain(draftRaw, filterRaw, draftKeyStrings) }, cancel() { copyPlain(filterRaw, draftRaw, draftKeyStrings) }, reset() { const defClone = cloneRecord(draftDefaults as RouteFilterRecord) copyPlain(defClone as Record, draftRaw, draftKeyStrings) }, } // ── reset / clear ───────────────────────────────────────────────────────── const reset = async () => { syncingFromRoute = true applyInPlace(filterState, cloneRecord(filterDefaults), filterDefaults) searchRef.value = searchDefault pageRef.value = pageDefault sortValueRef.value = defaultSortValue if (draftKeys.length > 0) { copyPlain(filterRaw, draftRaw, draftKeyStrings) } syncingFromRoute = false await pushRoute() } const clear = async () => { syncingToRoute = true applyInPlace(filterState, cloneRecord(filterDefaults), filterDefaults) searchRef.value = searchDefault pageRef.value = pageDefault sortValueRef.value = defaultSortValue if (draftKeys.length > 0) { copyPlain(filterRaw, draftRaw, draftKeyStrings) } await router.replace({ query: {} }).finally(() => { syncingToRoute = false }) } return { filter: filterState, draft, sort, search: searchRef, page: pageRef, reset, clear, } } // --------------------------------------------------------------------------- // Legacy exports kept for backwards compat (re-exported under old names) // --------------------------------------------------------------------------- /** @deprecated Use `useRouteFilters` with the new schema API instead */ export type RouteFilterSchema = RouteFilterRecord /** @deprecated */ export const parseRouteFiltersFromQuery = ( query: LocationQuery, defaults: T, ): RouteFilterState => { const out = cloneRecord(defaults) for (const k in defaults) { out[k] = parseValue(k, query, defaults[k]) as RouteFilterState[typeof k] } return out } /** @deprecated */ export const serializeRouteFiltersToQuery = ( state: RouteFilterState, defaults: T, ): LocationQueryRaw => { const q: LocationQueryRaw = {} for (const k in defaults) { const s = serializeValue(state[k] as RouteFilterValue, defaults[k]) if (s !== undefined) q[k] = s } return q } export type { RouteFilterRecord as RouteFilterRefs }