Files
Inventory/app/composables/useUrlState.ts
Matthieu 480aaa24b2 feat(navigation) : preserve list state in URL and use browser history for back buttons
Add useUrlState composable to sync page, search, sort and filter state
with URL query params. Back/forward navigation now restores the exact
list position. Replace hardcoded NuxtLink back buttons with
router.back() across all create/edit pages. Fix documents attachment
filter that checked non-existent ID fields instead of relation objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:40 +01:00

117 lines
2.8 KiB
TypeScript

import { ref, watch, nextTick, type Ref } from 'vue'
import { useRoute, useRouter } from '#imports'
interface ParamDef<T extends string | number = string | number> {
default: T
type?: 'string' | 'number'
/** Debounce URL writes (ms). Default: 0 (immediate). */
debounce?: number
}
type ParamDefs = Record<string, ParamDef>
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
type StateRefs<T extends ParamDefs> = {
[K in keyof T]: InferRef<T[K]>
}
interface UseUrlStateOptions {
/** Called when state is restored from URL (back/forward navigation). */
onRestore?: () => void
}
export function useUrlState<T extends ParamDefs>(
params: T,
options?: UseUrlStateOptions,
): StateRefs<T> {
const route = useRoute()
const router = useRouter()
const keys = Object.keys(params) as (keyof T & string)[]
const refs: Record<string, Ref<string | number>> = {}
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
for (const key of keys) {
refs[key] = ref(parseValue(route.query[key], params[key]!))
timers[key] = null
}
let isProgrammatic = false
const buildQuery = (): Record<string, string> => {
const q: Record<string, string> = {}
for (const key of keys) {
const val = refs[key]!.value
if (val !== params[key]!.default) {
q[key] = String(val)
}
}
return q
}
const pushToUrl = () => {
if (isProgrammatic) return
isProgrammatic = true
const query = buildQuery()
router
.replace({ path: route.path, query })
.catch(() => {})
.finally(() => {
nextTick(() => {
isProgrammatic = false
})
})
}
for (const key of keys) {
const ms = params[key]!.debounce ?? 0
watch(refs[key]!, () => {
if (isProgrammatic) return
if (ms > 0) {
if (timers[key]) clearTimeout(timers[key]!)
timers[key] = setTimeout(pushToUrl, ms)
} else {
pushToUrl()
}
})
}
watch(
() => ({ ...route.query }),
(newQuery) => {
if (isProgrammatic) return
isProgrammatic = true
let changed = false
for (const key of keys) {
const parsed = parseValue(newQuery[key], params[key]!)
if (refs[key]!.value !== parsed) {
refs[key]!.value = parsed
changed = true
}
}
nextTick(() => {
isProgrammatic = false
if (changed && options?.onRestore) {
options.onRestore()
}
})
},
)
return refs as StateRefs<T>
}
function parseValue(
raw: unknown,
def: ParamDef,
): string | number {
const str = typeof raw === 'string' ? raw : null
if (str === null) return def.default
if (def.type === 'number' || typeof def.default === 'number') {
const n = Number(str)
return Number.isFinite(n) ? n : def.default
}
return str
}