import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { useDebouncedRef } from './useDebouncedRef'; export type SortDir = 'asc' | 'desc'; /** Comparateur d'une clé de tri, toujours écrit en ASC ; la direction est appliquée par le composable. */ export interface SortDef { key: string; labelKey: string; compare: (a: T, b: T) => number; defaultDir?: SortDir; } export interface FilterOption { value: string; labelKey?: string; label?: string; } /** Un filtre activable par une ou plusieurs valeurs (OR intra-filtre, AND inter-filtres). */ export interface FilterDef { key: string; labelKey: string; options: FilterOption[]; predicate: (item: T, active: Set) => boolean; } export interface ListControlsOptions { source: () => readonly T[]; sorts: SortDef[]; /** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */ filters?: FilterDef[] | (() => FilterDef[]); searchAccessor?: (item: T) => string; defaultSortKey?: string; defaultPageSize?: number; // 0 = tout afficher /** Si fourni, l'état est synchronisé dans l'URL sous des params préfixés (liens partageables). */ urlKey?: string; } /** Sous-ensemble consommé par ListToolbar (sans pagination) : implémentable aussi par un store partagé. */ export interface ListToolbarControls { search: Ref; sortKey: Ref; sortDir: Ref; filters: Ref>>; total: ComputedRef; activeFilterCount: ComputedRef; setSearch(v: string): void; setSort(key: string): void; toggleFilter(key: string, value: string): void; clearAll(): void; sortDefs: SortDef[]; filterDefs: ComputedRef[]>; } export interface ListControls extends ListToolbarControls { page: Ref; pageSize: Ref; processed: ComputedRef; pageCount: ComputedRef; setPage(p: number): void; setPageSize(n: number): void; } export function useListControls(opts: ListControlsOptions): ListControls { const sortDefs = opts.sorts; const filterDefs = computed[]>(() => typeof opts.filters === 'function' ? opts.filters() : (opts.filters ?? []), ); const defaultSortKey = opts.defaultSortKey ?? sortDefs[0]?.key ?? ''; const defaultPageSize = opts.defaultPageSize ?? 25; const search = ref(''); const sortKey = ref(defaultSortKey); const sortDir = ref(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc'); const filters = ref>>({}); const page = ref(1); const pageSize = ref(defaultPageSize); const debouncedSearch = useDebouncedRef(search, 150); // ---- pipeline : filtres → recherche → tri → pagination (computed purs, aucune mutation de store) ---- const filtered = computed(() => { let items = [...opts.source()]; for (const def of filterDefs.value) { const active = filters.value[def.key]; if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active)); } const q = debouncedSearch.value.trim().toLowerCase(); if (q && opts.searchAccessor) { items = items.filter((it) => opts.searchAccessor!(it).toLowerCase().includes(q)); } return items; }); const sorted = computed(() => { const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0]; if (!def) return filtered.value; const dir = sortDir.value === 'desc' ? -1 : 1; return [...filtered.value].sort((a, b) => def.compare(a, b) * dir); }); const total = computed(() => filtered.value.length); const pageCount = computed(() => (pageSize.value === 0 ? 1 : Math.max(1, Math.ceil(total.value / pageSize.value)))); const processed = computed(() => { if (pageSize.value === 0) return sorted.value; const start = (page.value - 1) * pageSize.value; return sorted.value.slice(start, start + pageSize.value); }); const activeFilterCount = computed(() => Object.values(filters.value).reduce((n, set) => n + set.size, 0), ); // ramène la page dans les bornes quand un filtre réduit le total. watch(pageCount, (count) => { if (page.value > count) page.value = count; }); // ---- setters (toute action utilisateur revient page 1) ---- function setSort(key: string): void { if (sortKey.value === key) { sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'; } else { sortKey.value = key; sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc'; } page.value = 1; } function toggleFilter(key: string, value: string): void { const next = { ...filters.value }; const set = new Set(next[key] ?? []); if (set.has(value)) set.delete(value); else set.add(value); if (set.size === 0) delete next[key]; else next[key] = set; filters.value = next; page.value = 1; } function clearAll(): void { search.value = ''; filters.value = {}; page.value = 1; } function setPage(p: number): void { page.value = Math.min(Math.max(1, p), pageCount.value); } function setPageSize(n: number): void { pageSize.value = n; page.value = 1; } // saisie utilisateur : retour page 1 de façon synchrone (l'hydratation URL écrit search.value // directement, sans passer par ici → ne réinitialise pas une page fournie par l'URL). function setSearch(v: string): void { search.value = v; page.value = 1; } // suspend l'écriture état→URL pendant l'application des params depuis l'URL. let hydrating = false; // ---- synchro URL (optionnelle) ---- if (opts.urlKey) { const k = opts.urlKey; const P = { q: `${k}_q`, sort: `${k}_sort`, dir: `${k}_dir`, filter: `${k}_filter`, page: `${k}_page`, size: `${k}_size`, }; const route = useRoute(); const router = useRouter(); // sérialise les filtres de façon canonique : key:v1,v2;key2:v3 (clés/valeurs triées). const serializeFilters = (f: Record>): string => Object.entries(f) .filter(([, set]) => set.size > 0) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, set]) => `${key}:${[...set].sort().join(',')}`) .join(';'); // params possédés par cette liste (non-défauts uniquement → URLs propres). function ownedParams(): Record { const out: Record = {}; if (search.value.trim()) out[P.q] = search.value.trim(); if (sortKey.value !== defaultSortKey) out[P.sort] = sortKey.value; const baseDir = sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc'; if (sortDir.value !== baseDir) out[P.dir] = sortDir.value; const fser = serializeFilters(filters.value); if (fser) out[P.filter] = fser; if (page.value > 1) out[P.page] = String(page.value); if (pageSize.value !== defaultPageSize) out[P.size] = String(pageSize.value); return out; } const canonical = (o: Record): string => Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&'); function readOwned(query: Record): Record { const out: Record = {}; for (const key of Object.values(P)) { const v = query[key]; if (typeof v === 'string' && v) out[key] = v; } return out; } function applyFromQuery(query: Record): void { hydrating = true; const str = (key: string): string => (typeof query[key] === 'string' ? (query[key] as string) : ''); search.value = str(P.q); sortKey.value = sortDefs.some((s) => s.key === str(P.sort)) ? str(P.sort) : defaultSortKey; const dir = str(P.dir); sortDir.value = dir === 'asc' || dir === 'desc' ? dir : sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc'; const parsedFilters: Record> = {}; const fser = str(P.filter); if (fser) { for (const part of fser.split(';')) { const [fk, vals] = part.split(':'); if (!fk || !vals) continue; const def = filterDefs.value.find((d) => d.key === fk); if (!def) continue; const allowed = new Set(def.options.map((o) => o.value)); const set = new Set(vals.split(',').filter((v) => allowed.has(v))); if (set.size) parsedFilters[fk] = set; } } filters.value = parsedFilters; const sz = Number(str(P.size)); pageSize.value = Number.isFinite(sz) && sz >= 0 ? sz : defaultPageSize; const pg = Number(str(P.page)); page.value = Number.isFinite(pg) && pg >= 1 ? pg : 1; // libère le garde après le flush des watchers déclenchés par les assignations ci-dessus. void Promise.resolve().then(() => { hydrating = false; }); } // URL → état (init + navigations arrière/avant) ; ignore si déjà en phase avec l'état courant. watch( () => route.query, (query) => { if (canonical(readOwned(query)) === canonical(ownedParams())) return; applyFromQuery(query); }, { immediate: true, deep: true }, ); // état → URL : remplace (pas d'entrée d'historique), préserve les params non possédés. watch( [search, sortKey, sortDir, filters, page, pageSize], () => { if (hydrating) return; const owned = ownedParams(); const preserved: Record = {}; for (const [key, v] of Object.entries(route.query)) { if (!Object.values(P).includes(key)) preserved[key] = v; } const next = { ...preserved, ...owned }; // no-op si la query possédée est inchangée (anti-boucle / anti-spam). if (canonical(readOwned(route.query)) === canonical(owned)) return; void router.replace({ query: next as Record }); }, { deep: true }, ); } return { search, sortKey, sortDir, filters, page, pageSize, processed, total, pageCount, activeFilterCount, setSearch, setSort, toggleFilter, clearAll, setPage, setPageSize, sortDefs, filterDefs, }; }