diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js
index 37438e8..a18f18f 100644
--- a/src/interface/libelles/participants.js
+++ b/src/interface/libelles/participants.js
@@ -7,7 +7,9 @@
// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans
// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle
// d'une valeur de la fiche ; participants.statut.* nomme chaque statut
-// calculé (§ 4.2), par cleDeStatut.
+// calculé (§ 4.2), par cleDeStatut ; participants.groupe.* porte le
+// groupement de la liste : le sélecteur de chaque niveau, l'en-tête d'un
+// groupe et ses commandes.
// Un nombre suivi de son nom, au singulier pour 0 et 1.
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
@@ -47,6 +49,20 @@ export const TABLE = new Map([
['participants.grille.vide', 'Aucun participant. Ajoutez une personne, ou collez une liste dans la grille.'],
['participants.grille.aucunResultat', 'Aucune personne ne correspond au filtre.'],
+ // --- La liste groupée par une colonne ou plusieurs ------------------------
+ ['participants.groupe.grouperPar', 'Grouper par'],
+ ['participants.groupe.puisPar', ({ niveau }) => `Puis par (niveau ${niveau})`],
+ ['participants.groupe.aucun', 'Aucun groupement'],
+ ['participants.groupe.aucunSousGroupe', 'Aucun sous-groupe'],
+ ['participants.groupe.statut', 'Statut'],
+ ['participants.groupe.vide', '(vide)'],
+ ['participants.groupe.statutInconnu', 'statut non calculé'],
+ ['participants.groupe.compte', ({ compte: n }) => compte(n, 'personne', 'personnes')],
+ ['participants.groupe.replier', 'Replier'],
+ ['participants.groupe.deplier', 'Déplier'],
+ ['participants.groupe.toutReplier', 'Tout replier'],
+ ['participants.groupe.toutDeplier', 'Tout déplier'],
+
// --- Les commandes de la section ------------------------------------------
['participants.commande.fiche', 'Fiche'],
['participants.commande.exclure', 'Exclure'],
diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte
index 8499ae0..2cddb69 100644
--- a/src/interface/participants/Grille.svelte
+++ b/src/interface/participants/Grille.svelte
@@ -30,14 +30,26 @@
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
// 10.2) : le texte part à l'état de navigation, intact, et rien ne
// s'écrit ici.
+ //
+ // Groupée (groupes non null, ceux de groupesGrille), la grille porte un
+ // corps par groupe du premier niveau. Chaque groupe, de tout niveau, y
+ // rend un en-tête, en retrait selon son niveau, dont le bouton replie ou
+ // déplie le groupe et dit son état en texte et dans aria-expanded, puis,
+ // déplié, ses sous-groupes, ou ses lignes au dernier niveau. Une ligne
+ // porte la clé du plus étroit groupe qui la compte. Les lignes visibles
+ // sont celles de lignesVisibles : flèches et Tab ne parcourent qu'elles,
+ // jamais un en-tête ni une ligne repliée. Une saisie validée dont la
+ // ligne quitte le rendu — tri ou groupe changé — rend le focus à la même
+ // cellule de sa nouvelle ligne, et chaque groupe replié qui la reçoit, de
+ // tout niveau, se déplie. Replier et déplier n'écrivent rien.
import { getContext, onDestroy, tick } from 'svelte';
import { creerMachineChamp } from '../primitives/champ.js';
import Commande from '../primitives/Commande.svelte';
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
- import { COLONNES, celluleSuivante, cleDeColonne, entreeValide } from './grille.js';
+ import { COLONNES, basculerGroupe, celluleSuivante, cheminDe, cleDeColonne, entreeValide, lignesVisibles } from './grille.js';
- let { lignes, tri = $bindable(null), supprimer } = $props();
+ let { lignes, groupes = null, replies = $bindable(new Set()), tri = $bindable(null), supprimer } = $props();
const t = getContext('t');
const seance = getContext('seance');
@@ -61,11 +73,13 @@
let tableau;
let entree = $state(null);
+ // Les lignes que la grille montre, dans leur ordre, et le rang de chacune
+ // parmi elles, par identifiant.
+ const visibles = $derived(groupes === null ? lignes : lignesVisibles(groupes, replies));
+ const rangs = $derived(new Map(visibles.map(({ id }, rang) => [id, rang])));
+
// Le rang de la ligne active ; la première quand l'active n'est plus affichée.
- const rangActif = $derived.by(() => {
- const rang = lignes.findIndex(({ id }) => id === actif.id);
- return rang < 0 ? 0 : rang;
- });
+ const rangActif = $derived(rangs.get(actif.id) ?? 0);
const idRefus = (id, colonne) => `${uid}-refus-${id}-${colonne}`;
const enEdition = (id, colonne) => edition !== null && edition.id === id && edition.colonne === colonne;
@@ -91,7 +105,7 @@
}
async function activer(rang, colonne) {
- const ligne = lignes[rang];
+ const ligne = visibles[rang];
actif = { id: ligne.id, colonne };
await tick();
cellule(ligne.id, COLONNES[colonne])?.focus();
@@ -146,8 +160,29 @@
} catch (erreur) {
return echouer(enCours, id, colonne, erreur);
}
+ const garderFocus = entree !== null && document.activeElement === entree;
fermerEdition();
- return Promise.resolve(promesse).catch((erreur) => echouer(enCours, id, colonne, erreur));
+ return Promise.resolve(promesse).then(
+ () => suivreLigne(id, colonne, garderFocus),
+ (erreur) => echouer(enCours, id, colonne, erreur),
+ );
+ }
+
+ // Après une modification écrite, quand la cellule avait le focus : la
+ // ligne que la valeur a menée sous des groupes repliés, de tout niveau,
+ // les déplie, puis la cellule reprend le focus que le retrait de son
+ // ancienne ligne a rendu au document. Un focus posé ailleurs entre-temps
+ // reste où il est.
+ async function suivreLigne(id, colonne, garderFocus) {
+ if (!garderFocus) return;
+ await tick();
+ const chemin = groupes === null ? [] : cheminDe(groupes, id);
+ if (chemin.some((cle) => replies.has(cle))) {
+ replies = new Set([...replies].filter((cle) => !chemin.includes(cle)));
+ await tick();
+ }
+ const actuel = document.activeElement;
+ if (actuel === null || actuel === document.body) cellule(id, colonne)?.focus();
}
async function echouer(enCours, id, colonne, erreur) {
@@ -202,10 +237,10 @@
// Tab mène à la première cellule de celle-ci, Maj+Tab à la dernière de la
// ligne d'avant ; hors de la grille, le focus reste où il est.
function tabulerDepuis(id, rangAvant, colonne, touche) {
- const rang = lignes.findIndex((personne) => personne.id === id);
+ const rang = rangs.get(id) ?? -1;
if (rang >= 0) return deplacer(rang, colonne, touche);
const suivante = touche === 'Tab' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 };
- if (suivante.ligne < 0 || suivante.ligne >= lignes.length) return false;
+ if (suivante.ligne < 0 || suivante.ligne >= visibles.length) return false;
activer(suivante.ligne, suivante.colonne);
return true;
}
@@ -216,7 +251,7 @@
}
function deplacer(rang, colonne, touche) {
- const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: lignes.length, colonnes: COLONNES.length });
+ const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: visibles.length, colonnes: COLONNES.length });
if (suivante === null) return false;
activer(suivante.ligne, suivante.colonne);
return true;
@@ -258,6 +293,117 @@
onDestroy(() => geste.liberer());
+{#snippet rangee(personne, rang, dans)}
+
@@ -411,6 +492,58 @@
font-weight: normal;
}
+ /* L'en-tête d'un groupe : un bouton qui porte son état en texte, le
+ libellé du groupe en gras et son compte. */
+ .groupe button {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--espace-2);
+ padding: var(--espace-1) var(--espace-2);
+ border: 1px solid var(--couleur-page-filet);
+ border-radius: var(--rayon-1);
+ background-color: var(--couleur-page-fond);
+ color: var(--couleur-page-texte);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ }
+
+ /* Le retrait d'un en-tête croît avec le niveau de son groupe, jusqu'au
+ dernier niveau possible, un par colonne de GROUPEMENTS. */
+ .groupe[data-niveau='2'] button {
+ margin-left: var(--espace-5);
+ }
+
+ .groupe[data-niveau='3'] button {
+ margin-left: calc(2 * var(--espace-5));
+ }
+
+ .groupe[data-niveau='4'] button {
+ margin-left: calc(3 * var(--espace-5));
+ }
+
+ .groupe[data-niveau='5'] button {
+ margin-left: calc(4 * var(--espace-5));
+ }
+
+ .groupe[data-niveau='6'] button {
+ margin-left: calc(5 * var(--espace-5));
+ }
+
+ .groupe[data-niveau='7'] button {
+ margin-left: calc(6 * var(--espace-5));
+ }
+
+ .groupe[data-niveau='8'] button {
+ margin-left: calc(7 * var(--espace-5));
+ }
+
+ .libelle {
+ font-weight: bold;
+ overflow-wrap: anywhere;
+ }
+
.commandes {
display: flex;
flex-wrap: wrap;
diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js
index 1d5db15..e3a83d3 100644
--- a/src/interface/participants/Participants.navigateur.test.js
+++ b/src/interface/participants/Participants.navigateur.test.js
@@ -5,7 +5,8 @@
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
// collante, la grille au clavier, le tri, le filtre et le compte,
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
-// et l'itinéraire, l'export et le collage. Chaque épreuve monte App par
+// et l'itinéraire, l'export, le collage, et la liste groupée par une
+// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés. Chaque épreuve monte App par
// monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque
// écriture se relève (trace) et chaque entrée se compte dans le journal. Les
// gestes passent par des entrées que Chromium traite comme réelles
@@ -19,7 +20,8 @@ import { flushSync, tick } from 'svelte';
import { bilanChangementDefaut } from '../../application/bilans.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
-import { apercevoir, decoder, exporterParticipants } from '../../application/csv.js';
+import { apercevoir, cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js';
+import { statuts } from '../../application/places.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js';
import { configurationDepuisCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
@@ -30,7 +32,7 @@ import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { visible } from '../../../test/navigateur/rendu.js';
import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url';
import { TABLES_INTERFACE } from '../libelles.js';
-import { COLONNES, cleDeStatut } from './grille.js';
+import { COLONNES, GROUPEMENTS, cheminDe, cleDeGroupement, cleDeStatut, groupesGrille, lignesGrille, lignesVisibles } from './grille.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@@ -891,3 +893,465 @@ describe('export et collage (§ 10.2)', () => {
expect(m.trace.length).toBe(trace);
});
});
+
+describe('la liste groupée par une colonne ou plusieurs', () => {
+ // Cinq personnes : deux du Club des Merles sous deux orthographes, deux
+ // du Cercle des Pinsons dont une exclue, une sans appartenance.
+ const CINQ = [
+ { nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB, titre: 'hôtesse' },
+ { nom: 'Pervenche', prenom: 'Théo', appartenance: 'CLUB DES MERLES' },
+ { nom: 'Lacasse', prenom: 'Ondine' },
+ { nom: 'Grisaille', appartenance: 'Cercle des Pinsons', exclu: true },
+ { nom: 'Brindelle', appartenance: 'Cercle des Pinsons' },
+ ];
+ const PINSONS = 'texte:cercle des pinsons';
+ const MERLES = 'texte:club des merles';
+
+ // Le sélecteur du niveau donné, 1 pour le plus large, et tous ceux
+ // affichés.
+ const selecteur = (m, niveau = 1) => section(m).querySelector(`[data-champ="grouper"][data-niveau="${niveau}"]`);
+ const selecteurs = (m) => [...section(m).querySelectorAll('[data-champ="grouper"]')];
+ // Les en-têtes de groupe affichés, de tout niveau, dans l'ordre du rendu ;
+ // l'en-tête d'un groupe porte sa clé, chaque ligne celle du plus étroit
+ // groupe qui la compte.
+ const groupes = (m) => [...grille(m).querySelectorAll('[data-groupe]')];
+ const groupe = (m, cle) => grille(m).querySelector(`[data-groupe="${cle}"]`);
+ const bascule = (m, cle) => groupe(m, cle).querySelector('[data-commande="basculerGroupe"]');
+ const idsDuGroupe = (m, cle) =>
+ lignes(m)
+ .filter(({ dataset: { groupeLigne } }) => groupeLigne === cle || groupeLigne.startsWith(`${cle}/`))
+ .map((l) => Number(l.dataset.participant));
+ const ouverts = (m) => groupes(m).map((g) => bascule(m, g.dataset.groupe).getAttribute('aria-expanded'));
+ // Tous les groupes d'un arbre de groupesGrille, chacun avant ses
+ // sous-groupes : l'ordre des en-têtes déplié.
+ const aplatir = (arbre) => arbre.flatMap((g) => [g, ...(g.sousGroupes === null ? [] : aplatir(g.sousGroupes))]);
+ // Ce que montre l'en-tête d'un groupe : son libellé et son compte.
+ const entete = (m, cle) => ({
+ libelle: groupe(m, cle).querySelector('[data-groupe-libelle]').textContent,
+ compte: groupe(m, cle).querySelector('[data-groupe-compte]').textContent,
+ });
+ // Le libellé et le compte qu'un groupe de groupesGrille doit afficher.
+ const enteteAttendu = ({ libelle, cleLibelle, compte }) => ({
+ libelle: libelle ?? t(cleLibelle),
+ compte: t('participants.groupe.compte', { compte }),
+ });
+
+ // Choisit la colonne d'un niveau du groupement au clavier seul : le
+ // sélecteur de ce niveau prend le focus, puis les flèches le mènent à
+ // l'option voulue.
+ async function grouperPar(m, colonne, niveau = 1) {
+ const choix = selecteur(m, niveau);
+ const valeurs = [...choix.options].map((option) => option.value);
+ const cible = valeurs.indexOf(colonne);
+ expect(cible, colonne).toBeGreaterThanOrEqual(0);
+ choix.focus();
+ for (let essai = 0; essai < valeurs.length && choix.selectedIndex !== cible; essai += 1) {
+ await touche(choix.selectedIndex < cible ? 'ArrowDown' : 'ArrowUp');
+ await rendu();
+ }
+ expect(choix.value).toBe(colonne);
+ await rendu();
+ }
+
+ // Le groupement par appartenance puis par statut, Ombrelle ancrée par une
+ // réservation : les autres présentes sont mobiles.
+ async function appartenancePuisStatut() {
+ const m = await evenementOuvert({ tables: 1, personnes: CINQ });
+ const charge = m.seance.etat().charge;
+ await m.seance.executer('reserver', { participant: idDe(m, 'Ombrelle'), table: charge.tables[0].id, siege: null, portee: 'tous', tour: null });
+ await attendreSeance(m);
+ await grouperPar(m, 'appartenance', 1);
+ await grouperPar(m, 'statut', 2);
+ return m;
+ }
+
+ // Le journal, l'état et la trace d'écriture, à comparer après des gestes
+ // de regard.
+ const empreinte = (m) => ({ journal: texteJournal(m), etat: texteEtat(m), trace: m.trace.length });
+
+ test('le sélecteur offre « Aucun » puis chaque colonne groupable ; sans groupement, aucun en-tête de groupe', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ const options = [...selecteur(m).options];
+ expect(options.map((o) => o.value)).toEqual(['', ...GROUPEMENTS]);
+ expect(options.map((o) => o.textContent)).toEqual([t('participants.groupe.aucun'), ...GROUPEMENTS.map((c) => t(cleDeGroupement(c)))]);
+ expect(selecteur(m).value).toBe('');
+ expect(selecteurs(m)).toHaveLength(1);
+ expect(groupes(m)).toHaveLength(0);
+ expect(lignes(m)).toHaveLength(5);
+ expect(section(m).querySelector('[data-commande="toutReplier"]')).toBeNull();
+ });
+
+ test('chaque colonne choisie ouvre le niveau suivant, aux colonnes pas encore prises ; « Aucun » retire un niveau et les suivants', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ await grouperPar(m, 'appartenance', 1);
+ expect(selecteurs(m)).toHaveLength(2);
+ expect(selecteur(m, 2).value).toBe('');
+ const deuxieme = [...selecteur(m, 2).options];
+ expect(deuxieme.map((o) => o.value)).toEqual(['', ...GROUPEMENTS.filter((c) => c !== 'appartenance')]);
+ expect(deuxieme[0].textContent).toBe(t('participants.groupe.aucunSousGroupe'));
+ // Chaque sélecteur a son étiquette, distincte des autres.
+ const etiquettes = selecteurs(m).map((choix) => choix.labels[0]?.textContent);
+ expect(etiquettes).toEqual([t('participants.groupe.grouperPar'), t('participants.groupe.puisPar', { niveau: 2 })]);
+
+ await grouperPar(m, 'statut', 2);
+ expect(selecteurs(m).map((choix) => choix.value)).toEqual(['appartenance', 'statut', '']);
+ expect([...selecteur(m, 3).options].map((o) => o.value)).toEqual(['', ...GROUPEMENTS.filter((c) => c !== 'appartenance' && c !== 'statut')]);
+ expect(new Set(groupes(m).map((g) => g.dataset.niveau))).toEqual(new Set(['1', '2']));
+
+ // La colonne d'un niveau prise plus haut quitte le niveau plus bas.
+ await grouperPar(m, 'statut', 1);
+ expect(selecteurs(m).map((choix) => choix.value)).toEqual(['statut', '']);
+ await grouperPar(m, 'nom', 2);
+ await grouperPar(m, 'prenom', 3);
+ expect(selecteurs(m).map((choix) => choix.value)).toEqual(['statut', 'nom', 'prenom', '']);
+ await grouperPar(m, '', 2);
+ expect(selecteurs(m).map((choix) => choix.value)).toEqual(['statut', '']);
+ expect(new Set(groupes(m).map((g) => g.dataset.niveau))).toEqual(new Set(['1']));
+ await grouperPar(m, '', 1);
+ expect(selecteurs(m).map((choix) => choix.value)).toEqual(['']);
+ expect(groupes(m)).toHaveLength(0);
+ expect(lignes(m)).toHaveLength(5);
+
+ // Toutes les colonnes prises : aucun sélecteur de plus.
+ for (const [rang, colonne] of GROUPEMENTS.entries()) await grouperPar(m, colonne, rang + 1);
+ expect(selecteurs(m)).toHaveLength(GROUPEMENTS.length);
+ });
+
+ test('par appartenance : un en-tête par groupe, son libellé et son compte, ses lignes dessous ; puis chaque colonne groupable suit groupesGrille ; rien ne s’écrit', async () => {
+ const m = await evenementOuvert({ tables: 1, personnes: CINQ });
+ const charge = m.seance.etat().charge;
+ await m.seance.executer('reserver', { participant: idDe(m, 'Ombrelle'), table: charge.tables[0].id, siege: null, portee: 'tous', tour: null });
+ await attendreSeance(m);
+ const avant = empreinte(m);
+
+ await grouperPar(m, 'appartenance');
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']);
+ expect(entete(m, PINSONS)).toEqual({ libelle: 'Cercle des Pinsons', compte: t('participants.groupe.compte', { compte: 2 }) });
+ expect(entete(m, MERLES)).toEqual({ libelle: CLUB, compte: t('participants.groupe.compte', { compte: 2 }) });
+ expect(entete(m, 'vide')).toEqual({ libelle: t('participants.groupe.vide'), compte: t('participants.groupe.compte', { compte: 1 }) });
+ expect(idsDuGroupe(m, PINSONS)).toEqual([idDe(m, 'Grisaille'), idDe(m, 'Brindelle')]);
+ expect(idsDuGroupe(m, MERLES)).toEqual([idDe(m, 'Ombrelle'), idDe(m, 'Pervenche')]);
+ expect(idsDuGroupe(m, 'vide')).toEqual([idDe(m, 'Lacasse')]);
+ // L'en-tête précède les lignes de son groupe, dans le rendu.
+ const premiere = ligne(m, idDe(m, 'Grisaille')).getBoundingClientRect();
+ expect(bascule(m, PINSONS).getBoundingClientRect().bottom).toBeLessThanOrEqual(premiere.top + 1);
+
+ await grouperPar(m, 'statut');
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(['statut:ancre', 'statut:mobile', 'statut:exclue']);
+ expect(entete(m, 'statut:ancre').libelle).toBe(t('participants.statut.ancre'));
+ expect(idsDuGroupe(m, 'statut:ancre')).toEqual([idDe(m, 'Ombrelle')]);
+ expect(idsDuGroupe(m, 'statut:exclue')).toEqual([idDe(m, 'Grisaille')]);
+
+ expect(GROUPEMENTS.length).toBeGreaterThan(0);
+ const lignesAttendues = lignesGrille(participants(m), { tri: null, filtre: '' }, cleNormalisee);
+ for (const colonne of GROUPEMENTS) {
+ await grouperPar(m, colonne);
+ const attendus = groupesGrille(lignesAttendues, [colonne], { tri: null, cleNormalisee, statuts: statuts(m.seance.etat().charge).parId });
+ expect(attendus.length, colonne).toBeGreaterThan(0);
+ expect(groupes(m).map((g) => g.dataset.groupe), colonne).toEqual(attendus.map(({ cle }) => cle));
+ for (const attendu of attendus) {
+ expect(entete(m, attendu.cle), `${colonne} ${attendu.cle}`).toEqual(enteteAttendu(attendu));
+ expect(idsDuGroupe(m, attendu.cle), `${colonne} ${attendu.cle}`).toEqual(attendu.lignes.map(({ id }) => id));
+ expect(bascule(m, attendu.cle).getAttribute('aria-expanded')).toBe('true');
+ }
+ }
+ await grouperPar(m, '');
+ expect(groupes(m)).toHaveLength(0);
+ expect(lignes(m)).toHaveLength(5);
+ await attendreSeance(m);
+ expect(empreinte(m)).toEqual(avant);
+ });
+
+ test('replier masque exactement les lignes du groupe, déplier les rend ; Tout replier, Tout déplier ; l’état se lit en texte et dans aria-expanded ; rien ne s’écrit, en écriture comme en lecture', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ for (const mode of ['ecriture', 'lecture']) {
+ if (mode === 'lecture') {
+ await m.seance.passerEnLecture();
+ await rendu();
+ }
+ const avant = empreinte(m);
+ await grouperPar(m, 'appartenance');
+ const tous = idsAffiches(m);
+ expect(tous, mode).toHaveLength(5);
+ const merles = idsDuGroupe(m, MERLES);
+ expect(merles, mode).toHaveLength(2);
+ const texteOuvert = bascule(m, MERLES).querySelector('[data-groupe-etat]').textContent;
+
+ await cliquer(bascule(m, MERLES));
+ expect(bascule(m, MERLES).getAttribute('aria-expanded'), mode).toBe('false');
+ expect(idsDuGroupe(m, MERLES), mode).toEqual([]);
+ expect(idsAffiches(m), mode).toEqual(tous.filter((id) => !merles.includes(id)));
+ expect(entete(m, MERLES).compte, mode).toBe(t('participants.groupe.compte', { compte: 2 }));
+ // Replié et ouvert se distinguent par le texte, pas seulement par la couleur.
+ expect(bascule(m, MERLES).querySelector('[data-groupe-etat]').textContent, mode).not.toBe(texteOuvert);
+ expect(bascule(m, PINSONS).getAttribute('aria-expanded'), mode).toBe('true');
+
+ await cliquer(bascule(m, MERLES));
+ expect(bascule(m, MERLES).getAttribute('aria-expanded'), mode).toBe('true');
+ expect(idsAffiches(m), mode).toEqual(tous);
+
+ // Au clavier : Entrée sur le bouton replie.
+ bascule(m, PINSONS).focus();
+ await touche('Enter');
+ await rendu();
+ expect(bascule(m, PINSONS).getAttribute('aria-expanded'), mode).toBe('false');
+ expect(idsDuGroupe(m, PINSONS), mode).toEqual([]);
+ await touche(' ');
+ await rendu();
+ expect(bascule(m, PINSONS).getAttribute('aria-expanded'), mode).toBe('true');
+
+ await cliquer(section(m).querySelector('[data-commande="toutReplier"]'));
+ expect(lignes(m), mode).toHaveLength(0);
+ expect(groupes(m).map((g) => bascule(m, g.dataset.groupe).getAttribute('aria-expanded')), mode).toEqual(['false', 'false', 'false']);
+ await cliquer(section(m).querySelector('[data-commande="toutDeplier"]'));
+ expect(idsAffiches(m), mode).toEqual(tous);
+ expect(groupes(m).map((g) => bascule(m, g.dataset.groupe).getAttribute('aria-expanded')), mode).toEqual(['true', 'true', 'true']);
+
+ await attendreSeance(m);
+ expect(empreinte(m), mode).toEqual(avant);
+ }
+ });
+
+ test('le filtre passe d’abord : un groupe sans ligne disparaît, chaque compte est celui des lignes montrées ; trié par la colonne groupée, les groupes suivent le sens', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ await grouperPar(m, 'appartenance');
+ await saisir(section(m).querySelector('[data-champ="filtre"]'), 'pinsons');
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS]);
+ expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 2 }));
+ await saisir(section(m).querySelector('[data-champ="filtre"]'), 'ondine');
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(['vide']);
+ expect(entete(m, 'vide').compte).toBe(t('participants.groupe.compte', { compte: 1 }));
+ await saisir(section(m).querySelector('[data-champ="filtre"]'), 'iris');
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([MERLES]);
+ expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 1 }));
+ await saisir(section(m).querySelector('[data-champ="filtre"]'), '');
+
+ await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="appartenance"]'));
+ await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="appartenance"]'));
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([MERLES, PINSONS, 'vide']);
+ // Trié par nom décroissant : groupes croissants, lignes au tri dans chacun.
+ await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
+ await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']);
+ expect(idsDuGroupe(m, PINSONS)).toEqual([idDe(m, 'Grisaille'), idDe(m, 'Brindelle')]);
+ expect(idsDuGroupe(m, MERLES)).toEqual([idDe(m, 'Pervenche'), idDe(m, 'Ombrelle')]);
+ });
+
+ test('au clavier, les flèches et Tab passent d’une ligne visible à la suivante, par-dessus les en-têtes et les groupes repliés', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ await grouperPar(m, 'appartenance');
+ await cliquer(bascule(m, MERLES));
+ const [grisaille, brindelle, lacasse] = ['Grisaille', 'Brindelle', 'Lacasse'].map((nom) => idDe(m, nom));
+ expect(idsAffiches(m)).toEqual([grisaille, brindelle, lacasse]);
+
+ const premiere = grille(m).querySelector('td[tabindex="0"]');
+ expect(premiere).toBe(cellule(m, grisaille, COLONNES[0]));
+ premiere.focus();
+ await touche('ArrowDown');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, brindelle, COLONNES[0]));
+ await touche('ArrowDown');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, lacasse, COLONNES[0]));
+ await touche('ArrowDown');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, lacasse, COLONNES[0]));
+ await touche('ArrowUp');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, brindelle, COLONNES[0]));
+
+ // Tab depuis la dernière cellule de Brindelle mène à la première de Lacasse.
+ cellule(m, brindelle, COLONNES.at(-1)).focus();
+ await touche('Tab');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, lacasse, COLONNES[0]));
+ // Une seule cellule de la grille dans l'ordre de tabulation.
+ expect(grille(m).querySelectorAll('td[tabindex="0"]')).toHaveLength(1);
+ });
+
+ test('une saisie qui change la valeur groupée mène la ligne à son nouveau groupe, la cellule garde le focus ; un groupe replié qui la reçoit se déplie ; une entrée par saisie', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ await grouperPar(m, 'appartenance');
+ const lacasse = idDe(m, 'Lacasse');
+ const brindelle = idDe(m, 'Brindelle');
+ const avant = entrees(m).length;
+
+ cellule(m, lacasse, 'appartenance').focus();
+ await touche('Enter');
+ await rendu();
+ await saisir(cellule(m, lacasse, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
+ await touche('Enter');
+ await attendreSeance(m);
+ expect(entrees(m)).toHaveLength(avant + 1);
+ expect(idsDuGroupe(m, PINSONS)).toContain(lacasse);
+ expect(groupe(m, 'vide')).toBeNull();
+ expect(document.activeElement).toBe(cellule(m, lacasse, 'appartenance'));
+ expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 3 }));
+
+ // Vers un groupe replié : il se déplie, et la cellule garde le focus.
+ await cliquer(bascule(m, MERLES));
+ expect(idsDuGroupe(m, MERLES)).toEqual([]);
+ cellule(m, brindelle, 'appartenance').focus();
+ await touche('Enter');
+ await rendu();
+ await saisir(cellule(m, brindelle, 'appartenance').querySelector('input'), CLUB);
+ await touche('Enter');
+ await attendreSeance(m);
+ expect(entrees(m)).toHaveLength(avant + 2);
+ expect(bascule(m, MERLES).getAttribute('aria-expanded')).toBe('true');
+ expect(idsDuGroupe(m, MERLES)).toContain(brindelle);
+ expect(document.activeElement).toBe(cellule(m, brindelle, 'appartenance'));
+ });
+
+ test('changer de groupement déplie tout : d’une colonne à une autre, ou à l’ajout d’un niveau', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ await grouperPar(m, 'appartenance');
+ await cliquer(bascule(m, 'vide'));
+ expect(bascule(m, 'vide').getAttribute('aria-expanded')).toBe('false');
+ expect(idsAffiches(m)).toHaveLength(4);
+
+ // Le prénom a lui aussi un groupe « vide ».
+ await grouperPar(m, 'prenom');
+ expect(groupe(m, 'vide')).not.toBeNull();
+ expect(ouverts(m).length).toBeGreaterThan(0);
+ expect(ouverts(m).every((etat) => etat === 'true')).toBe(true);
+ expect(idsAffiches(m)).toHaveLength(5);
+
+ await cliquer(bascule(m, 'vide'));
+ expect(idsAffiches(m)).toHaveLength(3);
+ await grouperPar(m, 'statut', 2);
+ expect(ouverts(m).every((etat) => etat === 'true')).toBe(true);
+ expect(idsAffiches(m)).toHaveLength(5);
+ });
+
+ test('validée par la sortie du champ, le focus posé ailleurs y reste ; le groupe replié qui reçoit la ligne le reste', async () => {
+ const m = await evenementOuvert({ personnes: CINQ });
+ await grouperPar(m, 'appartenance');
+ const lacasse = idDe(m, 'Lacasse');
+ const ombrelle = idDe(m, 'Ombrelle');
+ const filtre = section(m).querySelector('[data-champ="filtre"]');
+ const avant = entrees(m).length;
+
+ cellule(m, lacasse, 'appartenance').focus();
+ await touche('Enter');
+ await rendu();
+ await saisir(cellule(m, lacasse, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
+ await cliquer(filtre);
+ await attendreSeance(m);
+ expect(entrees(m)).toHaveLength(avant + 1);
+ expect(idsDuGroupe(m, PINSONS)).toContain(lacasse);
+ expect(document.activeElement).toBe(filtre);
+
+ // Le groupe qui reçoit la ligne, replié avant la saisie, le reste.
+ await cliquer(bascule(m, PINSONS));
+ cellule(m, ombrelle, 'appartenance').focus();
+ await touche('Enter');
+ await rendu();
+ await saisir(cellule(m, ombrelle, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
+ await cliquer(filtre);
+ await attendreSeance(m);
+ expect(entrees(m)).toHaveLength(avant + 2);
+ expect(bascule(m, PINSONS).getAttribute('aria-expanded')).toBe('false');
+ expect(entete(m, PINSONS).compte).toBe(t('participants.groupe.compte', { compte: 4 }));
+ expect(idsAffiches(m)).not.toContain(ombrelle);
+ expect(document.activeElement).toBe(filtre);
+ });
+
+ test('appartenance puis statut : en-têtes imbriqués, comptes, repli du sous-groupe seul ou du parent entier ; rien ne s’écrit', async () => {
+ const m = await appartenancePuisStatut();
+ const avant = empreinte(m);
+ const [ombrelle, pervenche] = ['Ombrelle', 'Pervenche'].map((nom) => idDe(m, nom));
+ const attendus = groupesGrille(lignesGrille(participants(m), { tri: null, filtre: '' }, cleNormalisee), ['appartenance', 'statut'], {
+ tri: null,
+ cleNormalisee,
+ statuts: statuts(m.seance.etat().charge).parId,
+ });
+ const tous = aplatir(attendus);
+ expect(tous.some(({ niveau }) => niveau === 2)).toBe(true);
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(tous.map(({ cle }) => cle));
+ expect(groupes(m).map((g) => Number(g.dataset.niveau))).toEqual(tous.map(({ niveau }) => niveau));
+ for (const attendu of tous) {
+ expect(entete(m, attendu.cle), attendu.cle).toEqual(enteteAttendu(attendu));
+ expect(idsDuGroupe(m, attendu.cle), attendu.cle).toEqual(lignesVisibles([attendu], new Set()).map(({ id }) => id));
+ }
+ expect(idsAffiches(m)).toEqual(lignesVisibles(attendus, new Set()).map(({ id }) => id));
+ expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 2 }));
+ expect(idsDuGroupe(m, `${MERLES}/statut:ancre`)).toEqual([ombrelle]);
+ expect(idsDuGroupe(m, `${MERLES}/statut:mobile`)).toEqual([pervenche]);
+ // Un en-tête de sous-groupe est en retrait de celui de son parent.
+ expect(bascule(m, `${MERLES}/statut:ancre`).getBoundingClientRect().left).toBeGreaterThan(bascule(m, MERLES).getBoundingClientRect().left);
+
+ await cliquer(bascule(m, `${MERLES}/statut:mobile`));
+ expect(bascule(m, `${MERLES}/statut:mobile`).getAttribute('aria-expanded')).toBe('false');
+ expect(idsDuGroupe(m, MERLES)).toEqual([ombrelle]);
+ expect(entete(m, MERLES).compte).toBe(t('participants.groupe.compte', { compte: 2 }));
+ expect(entete(m, `${MERLES}/statut:mobile`).compte).toBe(t('participants.groupe.compte', { compte: 1 }));
+ expect(bascule(m, MERLES).getAttribute('aria-expanded')).toBe('true');
+
+ await cliquer(bascule(m, MERLES));
+ expect(groupe(m, `${MERLES}/statut:ancre`)).toBeNull();
+ expect(groupe(m, `${MERLES}/statut:mobile`)).toBeNull();
+ expect(idsDuGroupe(m, MERLES)).toEqual([]);
+ expect(idsAffiches(m)).toEqual(lignesVisibles(attendus, new Set([MERLES])).map(({ id }) => id));
+ // Déplié, le parent rend ses sous-groupes dans l'état où il les a laissés.
+ await cliquer(bascule(m, MERLES));
+ expect(bascule(m, `${MERLES}/statut:mobile`).getAttribute('aria-expanded')).toBe('false');
+ expect(bascule(m, `${MERLES}/statut:ancre`).getAttribute('aria-expanded')).toBe('true');
+
+ // Tout replier replie le premier niveau ; Tout déplier déplie tous les niveaux.
+ await cliquer(section(m).querySelector('[data-commande="toutReplier"]'));
+ expect(lignes(m)).toHaveLength(0);
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(attendus.map(({ cle }) => cle));
+ expect(ouverts(m).every((etat) => etat === 'false')).toBe(true);
+ // Un parent déplié après Tout replier montre ses sous-groupes dépliés.
+ await cliquer(bascule(m, MERLES));
+ expect(ouverts(m).filter((etat) => etat === 'true')).toHaveLength(3);
+ expect(idsDuGroupe(m, MERLES)).toEqual([ombrelle, pervenche]);
+ await cliquer(section(m).querySelector('[data-commande="toutDeplier"]'));
+ expect(groupes(m).map((g) => g.dataset.groupe)).toEqual(tous.map(({ cle }) => cle));
+ expect(ouverts(m).every((etat) => etat === 'true')).toBe(true);
+ expect(idsAffiches(m)).toEqual(lignesVisibles(attendus, new Set()).map(({ id }) => id));
+
+ await attendreSeance(m);
+ expect(empreinte(m)).toEqual(avant);
+ });
+
+ test('imbriqués : les flèches sautent tout en-tête ; une ligne menée sous un parent et un sous-groupe repliés les déplie', async () => {
+ const m = await appartenancePuisStatut();
+ const [ombrelle, pervenche, lacasse, grisaille, brindelle] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Brindelle'].map((nom) => idDe(m, nom));
+ await cliquer(bascule(m, `${MERLES}/statut:ancre`));
+ expect(idsAffiches(m)).toEqual([brindelle, grisaille, pervenche, lacasse]);
+ cellule(m, grisaille, COLONNES[0]).focus();
+ await touche('ArrowDown');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, pervenche, COLONNES[0]));
+ await touche('ArrowUp');
+ await rendu();
+ expect(document.activeElement).toBe(cellule(m, grisaille, COLONNES[0]));
+ await cliquer(section(m).querySelector('[data-commande="toutDeplier"]'));
+ expect(idsAffiches(m)).toContain(ombrelle);
+
+ // Lacasse rejoint les Merles, mobile comme Pervenche : le sous-groupe
+ // et son parent, tous deux repliés, se déplient.
+ const destination = `${MERLES}/statut:mobile`;
+ expect(cheminDe(groupesGrille(lignesGrille(participants(m), { tri: null, filtre: '' }, cleNormalisee), ['appartenance', 'statut'], {
+ tri: null,
+ cleNormalisee,
+ statuts: statuts(m.seance.etat().charge).parId,
+ }), pervenche)).toEqual([MERLES, destination]);
+ await cliquer(bascule(m, destination));
+ await cliquer(bascule(m, MERLES));
+ const avant = entrees(m).length;
+ cellule(m, lacasse, 'appartenance').focus();
+ await touche('Enter');
+ await rendu();
+ await saisir(cellule(m, lacasse, 'appartenance').querySelector('input'), CLUB);
+ await touche('Enter');
+ await attendreSeance(m);
+ expect(entrees(m)).toHaveLength(avant + 1);
+ expect(bascule(m, MERLES).getAttribute('aria-expanded')).toBe('true');
+ expect(bascule(m, destination).getAttribute('aria-expanded')).toBe('true');
+ expect(idsDuGroupe(m, destination)).toEqual([pervenche, lacasse]);
+ expect(document.activeElement).toBe(cellule(m, lacasse, 'appartenance'));
+ });
+});
diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte
index c325107..c0c09ed 100644
--- a/src/interface/participants/SectionParticipants.svelte
+++ b/src/interface/participants/SectionParticipants.svelte
@@ -9,6 +9,19 @@
// section : regarder, trier et filtrer n'écrivent rien, en écriture comme
// en lecture. La fiche ouverte est celle de l'état de navigation, ui.fiche.
//
+ // « Grouper par » groupe les lignes par une ou plusieurs colonnes de
+ // GROUPEMENTS, la plus large d'abord, en groupes imbriqués de
+ // groupesGrille, sur les lignes déjà filtrées et triées. Un sélecteur par
+ // niveau de niveauxAffiches : un par colonne choisie, puis celui du
+ // niveau suivant tant qu'une colonne reste libre ; chacun offre
+ // colonnesOffertes, et son choix passe par choisirGroupement — la valeur
+ // vide retire son niveau et ceux d'après. Le statut se lit dans statuts
+ // (src/application/places.js), calculé seulement quand il groupe.
+ // « Tout replier » replie les groupes du premier niveau, « Tout déplier »
+ // les groupes de tout niveau. Le groupement et les groupes repliés vivent
+ // dans la section, le temps qu'elle est ouverte : changer de groupement
+ // déplie tout, et rien ne s'écrit.
+ //
// Supprimer une personne qui porte des réservations ouvre la fenêtre à
// confirmer, qui les nomme ; sans réservation, ou quand la séance refuse
// déjà tout geste modifiant, la commande part et la séance répond. L'export
@@ -22,7 +35,17 @@
import Fiche from './Fiche.svelte';
import Formulaire from './Formulaire.svelte';
import Grille from './Grille.svelte';
- import { lignesGrille, nomExport, reservationsDe } from './grille.js';
+ import { statuts } from '../../application/places.js';
+ import {
+ choisirGroupement,
+ cleDeGroupement,
+ colonnesOffertes,
+ groupesGrille,
+ lignesGrille,
+ niveauxAffiches,
+ nomExport,
+ reservationsDe,
+ } from './grille.js';
const t = getContext('t');
const seance = getContext('seance');
@@ -32,14 +55,26 @@
let tri = $state.raw(null);
let filtre = $state('');
+ // Les colonnes du groupement, la plus large d'abord, vide sans
+ // groupement, et les clés des groupes repliés.
+ let groupement = $state.raw([]);
+ let replies = $state.raw(new Set());
// La suppression qui attend sa confirmation : {participant, reservations}.
let aSupprimer = $state.raw(null);
const charge = $derived($seance.charge);
const participants = $derived(charge?.participants ?? []);
const lignes = $derived(lignesGrille(participants, { tri, filtre }, cleNormalisee));
+ const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null);
+ const groupes = $derived(groupement.length === 0 ? null : groupesGrille(lignes, groupement, { tri, cleNormalisee, statuts: parStatut }));
+ const niveaux = $derived(niveauxAffiches(groupement));
const personneFiche = $derived($ui.fiche === null ? null : (participants.find(({ id }) => id === $ui.fiche) ?? null));
+ function choisirNiveau(rang, colonne) {
+ groupement = choisirGroupement(groupement, rang, colonne);
+ replies = new Set();
+ }
+
async function exporter() {
const { evenement, charge: courante } = seance.etat();
await facade.enregistrerSous(nomExport(evenement.base), exporterParticipants(courante.participants));
@@ -70,13 +105,40 @@
+ {#each niveaux as rang (rang)}
+
+
+
+
+ {/each}
+ {#if groupes !== null}
+
+
+ {/if}
-
+
{#if participants.length === 0}
{t('participants.grille.vide')}
{:else if lignes.length === 0}
@@ -124,7 +186,8 @@
gap: var(--espace-1);
}
- input {
+ input,
+ select {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
@@ -134,7 +197,20 @@
font-size: var(--taille-texte);
}
- input:focus-visible {
+ .outils > button {
+ padding: var(--espace-1) var(--espace-3);
+ border: 1px solid var(--couleur-page-filet);
+ border-radius: var(--rayon-1);
+ background-color: var(--couleur-page-fond);
+ color: var(--couleur-page-texte);
+ font: inherit;
+ font-size: var(--taille-texte);
+ cursor: pointer;
+ }
+
+ input:focus-visible,
+ select:focus-visible,
+ .outils > button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
diff --git a/src/interface/participants/grille.js b/src/interface/participants/grille.js
index bbb64d1..901976f 100644
--- a/src/interface/participants/grille.js
+++ b/src/interface/participants/grille.js
@@ -4,8 +4,10 @@
// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2), que
// câblent SectionParticipants, Grille, Formulaire et Fiche : les colonnes de
// la grille, ses lignes triées et filtrées, la cellule qu'atteint une
-// touche, l'orthographe d'appartenance à reprendre, l'itinéraire d'une
-// personne dans le retenu et ses réservations. Aucune fonction ne modifie ce
+// touche, les groupes, imbriqués, de la liste groupée par une colonne ou
+// plusieurs et les lignes que laissent voir les groupes repliés,
+// l'orthographe d'appartenance à reprendre, l'itinéraire d'une personne
+// dans le retenu et ses réservations. Aucune fonction ne modifie ce
// qu'elle reçoit ; la clé normalisée vient de l'appelant, qui la tient de
// src/application/csv.js.
@@ -115,6 +117,252 @@ export function lignesGrille(participants, { tri, filtre }, cleNormalisee) {
return retenues.sort(comparer);
}
+/** Les colonnes par lesquelles la liste se groupe : celles de la grille,
+ * dans leur ordre, puis le statut calculé (§ 4.2), qui n'est pas une
+ * colonne. */
+export const GROUPEMENTS = Object.freeze([...COLONNES, 'statut']);
+
+/**
+ * La clé du libellé d'une colonne de GROUPEMENTS : celle de sa colonne de
+ * la grille, ou celle du statut.
+ *
+ * @param {string} colonne
+ * @returns {string}
+ * @throws {RangeError} hors de GROUPEMENTS
+ */
+export function cleDeGroupement(colonne) {
+ return colonne === 'statut' ? 'participants.groupe.statut' : cleDeColonne(colonne);
+}
+
+/**
+ * Le groupement après le choix de colonne au niveau rang, compté depuis 0 :
+ * la liste des colonnes de GROUPEMENTS, la plus large d'abord. La valeur
+ * vide retire ce niveau et tous ceux d'après ; une colonne remplace celle
+ * du niveau, ou l'ajoute au premier niveau libre, et les niveaux suivants
+ * restent, hors celui qui la portait déjà. Rend une nouvelle liste.
+ *
+ * @param {Array} groupement
+ * @param {number} rang de 0 au nombre de niveaux
+ * @param {string} colonne '' ou une colonne de colonnesOffertes(groupement, rang)
+ * @returns {Array}
+ * @throws {RangeError} un rang au-delà du premier niveau libre, une colonne
+ * hors de celles offertes à ce rang
+ */
+export function choisirGroupement(groupement, rang, colonne) {
+ if (!Number.isInteger(rang) || rang < 0 || rang > groupement.length) throw new RangeError(`niveau inconnu : ${rang}`);
+ const plusLarges = groupement.slice(0, rang);
+ if (colonne === '') return plusLarges;
+ if (!colonnesOffertes(groupement, rang).includes(colonne)) throw new RangeError(`groupement inconnu : ${JSON.stringify(colonne)}`);
+ return [...plusLarges, colonne, ...groupement.slice(rang + 1).filter((suivante) => suivante !== colonne)];
+}
+
+/**
+ * Les rangs, comptés depuis 0, des niveaux dont le groupement montre le
+ * sélecteur : un par colonne choisie, puis celui du niveau suivant tant
+ * qu'une colonne de GROUPEMENTS reste libre.
+ *
+ * @param {Array} groupement
+ * @returns {Array}
+ */
+export function niveauxAffiches(groupement) {
+ const nombre = groupement.length < GROUPEMENTS.length ? groupement.length + 1 : groupement.length;
+ return Array.from({ length: nombre }, (_, rang) => rang);
+}
+
+/**
+ * Les colonnes que le niveau rang du groupement peut prendre : celles de
+ * GROUPEMENTS, dans leur ordre, hors celles des niveaux plus larges.
+ *
+ * @param {Array} groupement
+ * @param {number} rang compté depuis 0
+ * @returns {Array}
+ */
+export function colonnesOffertes(groupement, rang) {
+ const prises = groupement.slice(0, rang);
+ return GROUPEMENTS.filter((colonne) => !prises.includes(colonne));
+}
+
+// Les groupes d'un groupement par statut, dans leur ordre, et la clé du
+// libellé de chacun : les trois statuts calculés, une personne exclue, qui
+// n'en a aucun, puis une personne présente que le calcul ne nomme pas —
+// celui d'une configuration incohérente.
+const GROUPES_STATUT = new Map([
+ ['ancre', 'participants.statut.ancre'],
+ ['partiel', 'participants.statut.partiel'],
+ ['mobile', 'participants.statut.mobile'],
+ ['exclue', 'participants.grille.exclue'],
+ ['inconnu', 'participants.groupe.statutInconnu'],
+]);
+const RANGS_STATUT = new Map([...GROUPES_STATUT.keys()].map((statut, rang) => [statut, rang]));
+
+// Un segment de la clé d'un groupe : « % » et « / » s'y échappent, si
+// bien que « / » ne sépare jamais que deux niveaux.
+const segment = (texte) => texte.replaceAll('%', '%25').replaceAll('/', '%2F');
+
+/**
+ * Les groupes de la liste groupée par une ou plusieurs colonnes, imbriqués
+ * dans l'ordre de colonnes : un groupe par valeur distincte de la première
+ * parmi les lignes reçues, qui sont celles de lignesGrille — le filtre est
+ * déjà passé, et un groupe n'existe que par ses lignes —, puis, dans chaque
+ * groupe, un sous-groupe par valeur distincte de la colonne suivante, et
+ * ainsi de suite. Chaque groupe garde ses lignes dans l'ordre reçu, celui
+ * du tri.
+ *
+ * Une colonne de texte groupe par la clé normalisée, comme le tri la lit :
+ * le groupe se nomme par l'orthographe du plus petit identifiant qu'il
+ * compte, que ni le tri ni l'ordre reçu ne changent. Un champ null forme le
+ * groupe vide, après toute valeur dans les deux sens. L'exclusion groupe
+ * les présentes avant les exclues. Le statut groupe par statuts.get(id),
+ * dans l'ordre de GROUPES_STATUT : une exclue, absente des statuts
+ * calculés, dans son groupe ; une présente qui y manque, dans le groupe non
+ * calculé.
+ *
+ * Les groupes d'un niveau s'ordonnent sur leur valeur dans le sens du tri
+ * quand le tri porte sur la colonne de ce niveau, croissants sinon ; l'ordre
+ * est total, donc le même quel que soit l'ordre de découverte des valeurs.
+ *
+ * Un groupe rend sa clé, unique parmi tous les groupes de tous les niveaux
+ * et stable tant que les valeurs qui le désignent ne changent pas : celle
+ * de son parent, « / », puis le segment de sa valeur — 'texte:', où « % » et « / » s'échappent en %25 et %2F, 'vide',
+ * 'exclu:non', 'exclu:oui', 'statut:' ; au premier niveau, le seul
+ * segment. Il rend aussi son niveau, de 1 au nombre de colonnes, sa
+ * colonne, son libellé, qui est soit une donnée (libelle), soit la clé d'un
+ * texte de la table de traduction (cleLibelle), l'autre valant null, le
+ * nombre de ses lignes, ses lignes, et ses sous-groupes : null au dernier
+ * niveau, la liste des groupes de la colonne suivante sinon.
+ *
+ * @param {Array