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
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).
291 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|