Files
arboretum/packages/web/src/composables/useListControls.ts
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

291 lines
10 KiB
TypeScript

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<T> {
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<T> {
key: string;
labelKey: string;
options: FilterOption[];
predicate: (item: T, active: Set<string>) => boolean;
}
export interface ListControlsOptions<T> {
source: () => readonly T[];
sorts: SortDef<T>[];
/** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */
filters?: FilterDef<T>[] | (() => FilterDef<T>[]);
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<T> {
search: Ref<string>;
sortKey: Ref<string>;
sortDir: Ref<SortDir>;
filters: Ref<Record<string, Set<string>>>;
total: ComputedRef<number>;
activeFilterCount: ComputedRef<number>;
setSearch(v: string): void;
setSort(key: string): void;
toggleFilter(key: string, value: string): void;
clearAll(): void;
sortDefs: SortDef<T>[];
filterDefs: ComputedRef<FilterDef<T>[]>;
}
export interface ListControls<T> extends ListToolbarControls<T> {
page: Ref<number>;
pageSize: Ref<number>;
processed: ComputedRef<T[]>;
pageCount: ComputedRef<number>;
setPage(p: number): void;
setPageSize(n: number): void;
}
export function useListControls<T>(opts: ListControlsOptions<T>): ListControls<T> {
const sortDefs = opts.sorts;
const filterDefs = computed<FilterDef<T>[]>(() =>
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<SortDir>(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc');
const filters = ref<Record<string, Set<string>>>({});
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<T[]>(() => {
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<T[]>(() => {
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<T[]>(() => {
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, Set<string>>): 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<string, string> {
const out: Record<string, string> = {};
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, string>): string =>
Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&');
function readOwned(query: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
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<string, unknown>): 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<string, Set<string>> = {};
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<string, unknown> = {};
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<string, string> });
},
{ deep: true },
);
}
return {
search,
sortKey,
sortDir,
filters,
page,
pageSize,
processed,
total,
pageCount,
activeFilterCount,
setSearch,
setSort,
toggleFilter,
clearAll,
setPage,
setPageSize,
sortDefs,
filterDefs,
};
}