[IMP] event list: summary, state filter tiles, table grouped by state

The list gave every event the same weight, so a manager could not see at
a glance what was still in draft or already printed. A header now sums
events and participants; one tile per plan state, in the § 9 order,
filters the table and holds only for the session; rows group by state or
not at all, under grouped column headers. Each « Ouvrir » is named after
its event, and messages take the avis, refus or information tone. The
cards sit under the table: a sidebar does not fit beside nine columns.
Checked: accueil node and browser tests, 8 mutations caught.

--- FR ---

[IMP] liste des événements : résumé, tuiles par état, groupes par état

La liste donnait à chaque événement le même poids : on ne voyait pas
d'un coup d'œil ce qui restait en brouillon ou déjà imprimé. Un en-tête
compte événements et participants ; une tuile par état du plan, dans
l'ordre du § 9, filtre le tableau le temps de la séance ; les lignes se
groupent par état ou pas, sous des en-têtes groupés. Chaque « Ouvrir »
porte le nom de son événement, les messages prennent le ton avis, refus
ou information. Les cartes passent sous le tableau, faute de place.
Vérifié : épreuves node et navigateur de l'accueil, 8 mutations prises.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 00:58:07 -04:00
parent 16e328ab12
commit 229a2d4067
16 changed files with 1958 additions and 526 deletions

View file

@ -8,8 +8,10 @@
// l'ouverture en lecture, les réglages illisibles, le dossier de travail, ses
// avis et ses questions, l'explorateur, l'autre emplacement, copier,
// renommer, importer, supprimer, les démonstrations, la racine neutre, la
// liste qui défile sous le bandeau, et le chargement des résumés de la liste,
// dont l'horloge pilotée du montage tient chaque pas. Chaque épreuve monte
// liste qui défile sous le bandeau, le chargement des résumés de la liste,
// dont l'horloge pilotée du montage tient chaque pas, et la liste en
// tableau : l'en-tête et son résumé, les tuiles des filtres par état du
// plan, les groupes, les en-têtes groupés, les cartes et les messages. Chaque épreuve monte
// App par monterApp (test/navigateur/montage.js), sur le système de fichiers
// simulé, et lit le relevé de ses écritures.
//
@ -139,8 +141,24 @@ const accueil = (m) => dans(m, '[data-ecran="accueil"]');
const lignes = (m) => tous(m, '[data-evenement]');
const ligneDe = (m, base) => dans(m, `[data-evenement="${CSS.escape(base)}"]`);
const colonne = (ligne, nom) => ligne.querySelector(`[data-colonne="${nom}"]`)?.textContent;
// Le nom de l'état d'une ligne : le seul texte de sa cellule, que la
// pastille porte à côté de son signe.
const etatDe = (ligne) => noeudsDeTexte(ligne.querySelector('[data-colonne="etat"]')).join('');
const ecritures = (m) => m.trace.length;
const sondes = (m) => m.trace.filter(([primitive]) => primitive === 'sonder').length;
// Le terme expliqué nom, sous racine : un en-tête de colonne du tableau.
const terme = (racine, nom) => racine.querySelector(`[data-terme="${nom}"]`);
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
function jeton(nom) {
const sonde = document.createElement('span');
sonde.style.color = `var(${nom})`;
document.body.append(sonde);
const couleur = composantes(getComputedStyle(sonde).color);
sonde.remove();
return couleur;
}
const fond = (element) => composantes(getComputedStyle(element).backgroundColor);
// Visible une fois ramené dans la fenêtre : la liste défile sous le bandeau.
function visible(element) {
@ -275,7 +293,7 @@ describe('le premier écran (§ 8.1, § 18.6)', () => {
expect(colonne(gala, 'nom')).toBe(lue.charge.evenement.nom);
expect(colonne(gala, 'nom')).toBe('Gala du printemps');
expect(colonne(gala, 'date')).toBe(format.date('2031-05-17'));
expect(colonne(gala, 'etat')).toBe(t('accueil.etat.brouillon'));
expect(etatDe(gala)).toBe(t('accueil.etat.brouillon'));
expect(colonne(gala, 'participants')).toBe(format.nombre(lue.entete.comptes.participants));
expect(colonne(gala, 'modification')).toBe(format.horodatage(HORODATAGE_DEPOT));
expect(colonne(gala, 'chemin')).toBe(`${DOCUMENTS}\\soiree-a${SUFFIXES.etat}`);
@ -288,7 +306,9 @@ describe('le premier écran (§ 8.1, § 18.6)', () => {
expect(Number(lu.entete.comptes.participants)).toBeGreaterThan(0);
expect(colonne(ligneClub, 'modification')).toBe('\u{2014}');
expect(colonne(ligneClub, 'chemin')).toBe(`${DOCUMENTS}\\club${SUFFIXES.etat}`);
for (const ligne of lignes(m)) expect(visible(ligne)).toBe(true);
// Le nom de chaque ligne se voit ; sous la largeur où le tableau tient,
// le reste de la ligne défile dans le conteneur du tableau.
for (const ligne of lignes(m)) expect(visible(ligne.querySelector('[data-colonne="nom"]'))).toBe(true);
});
test('répertoire vide : « version » et la version, la création, une entrée par démonstration du catalogue, atteinte ; aucun « aucun événement »', async () => {
@ -759,7 +779,7 @@ describe('les opérations de fichiers (§ 8.7)', () => {
const original = await photographier(m, 'documents');
const avant = ecritures(m);
const ligne = ligneDe(m, 'Bal des Grisailles');
expect(colonne(ligne, 'etat')).toBe(t('accueil.etat.bloque'));
expect(etatDe(ligne)).toBe(t('accueil.etat.bloque'));
await cliquer(ligne.querySelector('[data-commande="copier"]'));
const champ = ligne.querySelector('[data-champ="nomCopie"]');
expect(visible(champ)).toBe(true);
@ -777,7 +797,7 @@ describe('les opérations de fichiers (§ 8.7)', () => {
const journal = lireJournal(m.fs.contenu('documents', `${copie.base}${SUFFIXES.journal}`));
expect(journal.entrees.map(({ libelle: texte }) => texte)).toEqual([attendu]);
expect(analyser(m.fs.contenu('documents', `${copie.base}${SUFFIXES.etat}`)).charge.evenement.etat).not.toBe('bloque');
expect(colonne(ligneDe(m, copie.base), 'etat')).not.toBe(t('accueil.etat.bloque'));
expect(etatDe(ligneDe(m, copie.base))).not.toBe(t('accueil.etat.bloque'));
const apres = await photographier(m, 'documents');
for (const [chemin, texte] of Object.entries(original)) expect(apres[chemin]).toBe(texte);
});
@ -1168,7 +1188,6 @@ function chargeRetenue() {
const etat = (charge) => serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
const TIRET = '\u{2014}';
const terme = (ligne, nom) => ligne.querySelector(`[data-terme="${nom}"]`);
const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')];
// L'infobulle unique, ouverte sur le terme : visible, au texte de la clé,
@ -1188,13 +1207,14 @@ function exigerInfobulle(ancre, cle) {
expect(b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom, `${cle} sur son terme`).toBe(false);
}
// Chaque terme de la ligne, survolé puis focalisé, ouvre l'infobulle sur sa
// clé ; la sortie du pointeur et Échap la ferment, le focus reste au terme.
async function eprouverTermes(ligne, cas) {
// Chaque terme de la liste, un en-tête de colonne, survolé puis focalisé,
// ouvre l'infobulle sur sa clé ; la sortie du pointeur et Échap la ferment,
// le focus reste au terme.
async function eprouverTermes(racine, cas) {
expect(cas.length).toBeGreaterThan(0);
await pointer('bouger', 0, 0);
for (const [nom, cle] of cas) {
const ancre = terme(ligne, nom);
const ancre = terme(racine, nom);
ancre.scrollIntoView({ block: 'center' });
const { x, y } = await centre(ancre);
await pointer('bouger', x, y);
@ -1234,8 +1254,16 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
const club = ligneDe(m, 'club');
expect(colonne(club, 'collisionsCumulees')).toBe(TIRET);
expect(colonne(club, 'diffRedondance')).toBe(TIRET);
expect(terme(retenue, 'collisionsCumulees').textContent).toBe(t('accueil.colonne.collisionsCumulees'));
expect(terme(retenue, 'diffRedondance').textContent).toBe(t('accueil.colonne.diffRedondance'));
// Chaque intitulé est un en-tête de colonne, une seule fois pour toute la
// liste.
for (const [nom, cle] of [
['collisionsCumulees', 'accueil.colonne.collisionsCumulees'],
['diffRedondance', 'accueil.colonne.diffRedondance'],
]) {
expect(tous(m, `[data-terme="${nom}"]`).length, nom).toBe(1);
expect(terme(accueil(m), nom).closest('thead'), nom).not.toBeNull();
expect(terme(accueil(m), nom).textContent).toBe(t(cle));
}
expect(visible(retenue.querySelector('[data-colonne="diffRedondance"]'))).toBe(true);
expect(ecritures(m)).toBe(avant);
});
@ -1243,8 +1271,7 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
test('chaque intitulé s’explique : le survol puis le focus ouvrent son infobulle, Échap et la sortie la ferment', async () => {
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
const retenue = ligneDe(m, 'retenue');
await eprouverTermes(retenue, [
await eprouverTermes(accueil(m), [
['collisionsCumulees', 'accueil.explication.collisionsCumulees'],
['diffRedondance', 'accueil.explication.diffRedondance'],
]);
@ -1296,12 +1323,23 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
return appliquerCommande('retenirProposition', charge, { id: autre.id }).charge;
}
const blocDe = (ligne) => ligne.querySelector('[data-meilleure]');
// Ce que le bloc d'une ligne, ou celui de la génération, écrit.
// Ce que la ligne, ou le bloc de la génération, écrit de la meilleure
// proposition.
const lu = (bloc) => ({
id: bloc.querySelector('[data-meilleure-id]')?.textContent ?? null,
collisions: bloc.querySelector('[data-meilleure-collisions]')?.textContent ?? null,
});
// L'en-tête du groupe de colonnes de la meilleure proposition, et celui de
// sa colonne d'identifiant.
const groupeMeilleure = (m) => terme(accueil(m), 'meilleure').closest('th');
const enteteProposition = (m) =>
tous(m, 'thead th[scope="col"]').find((th) => th.textContent.trim() === t('generation.meilleure.proposition'));
// Vrai quand la boîte de cellule tient, en largeur, dans celle de l'en-tête.
const sous = (cellule, entete) => {
const c = cellule.getBoundingClientRect();
const e = entete.getBoundingClientRect();
return c.left >= e.left - 0.5 && c.right <= e.right + 0.5 && c.top >= e.bottom - 0.5;
};
const blocGeneration = (m) => dans(m, '[data-section="generation"] [data-meilleure]');
test('un événement classé : le rang 1, son identifiant et ses collisions cumulées, à part du retenu ; sans proposition, « — » ; rien d’écrit', async () => {
@ -1317,26 +1355,32 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
await chargerResumes(m);
const avant = ecritures(m);
const classee = ligneDe(m, 'classee');
const bloc = blocDe(classee);
expect(lu(bloc)).toEqual({ id: format.nombre(premiere.id), collisions: format.nombre(premiere.mesures.collisionsCumulees) });
expect(lu(classee)).toEqual({ id: format.nombre(premiere.id), collisions: format.nombre(premiere.mesures.collisionsCumulees) });
expect(colonne(classee, 'collisionsCumulees')).toBe(format.nombre(table.retenu.mesures.collisionsCumulees));
expect(terme(classee, 'meilleure').textContent).toBe(t('generation.meilleure.titre'));
expect(bloc.querySelector('dt').textContent).toBe(t('generation.meilleure.proposition'));
expect(terme(classee, 'meilleureCollisions').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
expect(bloc.getAttribute('role')).toBe('group');
expect(document.getElementById(bloc.getAttribute('aria-labelledby'))).toBe(terme(classee, 'meilleure'));
// Les intitulés sont ceux du bloc de la génération, en en-têtes de
// colonnes : le titre sur le groupe, l'identifiant et les collisions sur
// ses deux colonnes ; chaque valeur se tient sous son groupe.
expect(terme(accueil(m), 'meilleure').textContent).toBe(t('generation.meilleure.titre'));
expect(groupeMeilleure(m).getAttribute('scope')).toBe('colgroup');
expect(groupeMeilleure(m).colSpan).toBe(2);
expect(enteteProposition(m)).toBeDefined();
expect(terme(accueil(m), 'meilleureCollisions').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
for (const valeur of ['[data-meilleure-id]', '[data-meilleure-collisions]']) {
expect(visible(bloc.querySelector(valeur)), valeur).toBe(true);
expect(atteint(bloc.querySelector(valeur)), valeur).toBe(true);
const cellule = classee.querySelector(valeur);
expect(visible(cellule), valeur).toBe(true);
expect(atteint(cellule), valeur).toBe(true);
expect(sous(cellule, groupeMeilleure(m)), valeur).toBe(true);
}
expect(lu(blocDe(ligneDe(m, 'club')))).toEqual({ id: TIRET, collisions: TIRET });
expect(sous(classee.querySelector('[data-meilleure-id]'), enteteProposition(m))).toBe(true);
expect(sous(classee.querySelector('[data-meilleure-collisions]'), terme(accueil(m), 'meilleureCollisions').closest('th'))).toBe(true);
expect(lu(ligneDe(m, 'club'))).toEqual({ id: TIRET, collisions: TIRET });
expect(ecritures(m)).toBe(avant);
});
test('le titre et l’intitulé des collisions s’expliquent : le survol puis le focus ouvrent l’infobulle, Échap et la sortie la ferment', async () => {
const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
await eprouverTermes(ligneDe(m, 'classee'), [
await eprouverTermes(accueil(m), [
['meilleure', 'accueil.explication.meilleure'],
['meilleureCollisions', 'propositions.explication.collisionsCumulees'],
]);
@ -1346,11 +1390,10 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
await chargerResumes(m);
const ligne = ligneDe(m, 'classee');
const deLaListe = lu(blocDe(ligne));
const deLaListe = lu(ligneDe(m, 'classee'));
expect(deLaListe.id).toMatch(/\d/);
const libelles = [terme(ligne, 'meilleure'), blocDe(ligne).querySelector('dt'), terme(ligne, 'meilleureCollisions')].map(
(element) => element.textContent,
const libelles = [terme(accueil(m), 'meilleure'), enteteProposition(m), terme(accueil(m), 'meilleureCollisions')].map(
(element) => element.textContent.trim(),
);
await ouvrirLigne(m, 'classee');
await attendreEvenement(m);
@ -1368,7 +1411,7 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
const resumeRecu = suivreResumes(m);
await revenirALaListe(m, 1);
await resumeRecu('classee');
expect(lu(blocDe(ligneDe(m, 'classee')))).toEqual(deLaListe);
expect(lu(ligneDe(m, 'classee'))).toEqual(deLaListe);
});
test('des propositions toutes hors du classement : « — » à la liste, là où le bloc de la génération le dit en mots', async () => {
@ -1380,33 +1423,40 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
const m = await monter({ fichiers: [fichier(`horsclassement${SUFFIXES.etat}`, etat(charge))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
await chargerResumes(m);
expect(lu(blocDe(ligneDe(m, 'horsclassement')))).toEqual({ id: TIRET, collisions: TIRET });
expect(lu(ligneDe(m, 'horsclassement'))).toEqual({ id: TIRET, collisions: TIRET });
await ouvrirLigne(m, 'horsclassement');
await attendreEvenement(m);
m.ui.allerA('generation');
await vi.waitFor(() => expect(blocGeneration(m)?.querySelector('[data-meilleure-vide]')).not.toBeNull());
});
test('à 1366 × 700, six événements classés : la page ne défile pas en largeur, chaque bloc tient dans la sienne', async () => {
const avant = { largeur: innerWidth, hauteur: innerHeight };
await fixerCadre(1366, 700);
try {
const bases = ['un', 'deux', 'trois', 'quatre', 'cinq', 'six'];
const m = await monter({ fichiers: bases.map((base) => fichier(`${base}${SUFFIXES.etat}`, etat(chargeClassee({ id: `evt-${base}` })))) });
await vi.waitFor(() => expect(lignes(m).length).toBe(bases.length));
await chargerResumes(m);
const blocs = lignes(m).map(blocDe);
expect(blocs.length).toBe(bases.length);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
for (const bloc of blocs) {
expect(lu(bloc).id).toMatch(/\d/);
expect(bloc.scrollWidth).toBeLessThanOrEqual(bloc.clientWidth);
expect(bloc.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
for (const largeur of [1366, 1024]) {
test(`à ${largeur} × 700, six événements classés : la page ne défile pas en largeur, le tableau tient dans sa carte, chaque valeur dans sa cellule`, async () => {
const avant = { largeur: innerWidth, hauteur: innerHeight };
await fixerCadre(largeur, 700);
try {
const bases = ['un', 'deux', 'trois', 'quatre', 'cinq', 'six'];
const m = await monter({ fichiers: bases.map((base) => fichier(`${base}${SUFFIXES.etat}`, etat(chargeClassee({ id: `evt-${base}` })))) });
await vi.waitFor(() => expect(lignes(m).length).toBe(bases.length));
await chargerResumes(m);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
const tableau = dans(m, '[data-ecran="accueil"] table');
const carte = tableau.closest('section');
expect(tableau.parentElement.scrollWidth).toBeLessThanOrEqual(tableau.parentElement.clientWidth);
expect(tableau.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right);
expect(carte.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
for (const ligne of lignes(m)) {
expect(lu(ligne).id).toMatch(/\d/);
for (const cellule of ligne.cells) {
expect(cellule.scrollWidth, cellule.textContent).toBeLessThanOrEqual(cellule.clientWidth);
expect(cellule.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
}
}
} finally {
await fixerCadre(avant.largeur, avant.hauteur);
}
} finally {
await fixerCadre(avant.largeur, avant.hauteur);
}
});
});
}
});
// --- Le chargement des résumés (§ 8.1, § 14.5, § 19.2) ------------------------------
@ -1455,7 +1505,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
const regions = tous(m, '[data-chargement-liste]');
expect(regions.length).toBe(1);
expect(regions[0].getAttribute('role')).toBe('status');
expect(tous(m, '.liste [role="status"], .liste [role="img"]')).toEqual([]);
expect(tous(m, 'table [role="status"], table [role="img"]')).toEqual([]);
return regions[0];
}
const resolue = (ligne, valeurs) => {
@ -1579,3 +1629,323 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
}
});
});
// --- La liste en tableau : en-tête, tuiles, groupes, cartes (§ 8.1, § 9) ------------
describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, § 9)', () => {
// Le fichier d'état de la petite démonstration, sous un identifiant, un
// nom et un état à soi : aucun doublon entre deux fichiers.
function texteDe(id, nom, etatDuPlan) {
const { charge } = analyser(PETITE);
charge.evenement.id = id;
charge.evenement.nom = nom;
charge.evenement.etat = etatDuPlan;
return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
}
// Cinq événements de quatre états : [base, nom, état].
const CINQ = [
['bal', 'Bal des Grisailles', 'bloque'],
['club', 'Club des Merles', 'brouillon'],
['gala', 'Gala des Ombrelles', 'retenu'],
['nuit', 'Nuit des Pervenches', 'brouillon'],
['souper', 'Souper des Hiboux', 'propose'],
];
const fichiersDe = (evenements) =>
evenements.map(([base, nom, etatDuPlan]) => fichier(`${base}${SUFFIXES.etat}`, texteDe(`evt-${base}`, nom, etatDuPlan)));
async function monterCinq(options = {}) {
const m = await monter({ fichiers: fichiersDe(CINQ), ...options });
await vi.waitFor(() => expect(lignes(m).length).toBe(CINQ.length));
return m;
}
const bases = (m) => lignes(m).map((ligne) => ligne.dataset.evenement);
// Les tuiles des filtres, dans l'ordre du rendu, et celle d'un filtre.
const groupeDesTuiles = (m) => dans(m, `[data-ecran="accueil"] [role="group"][aria-label="${t('accueil.filtre.groupe')}"]`);
const tuiles = (m) => [...groupeDesTuiles(m).querySelectorAll('button[data-commande="filtrer"]')];
const tuile = (m, filtre) => tuiles(m).find((bouton) => bouton.dataset.cible === filtre);
const presses = (m) => tuiles(m).map((bouton) => bouton.getAttribute('aria-pressed'));
// Le texte d'un élément, espaces réduits.
const texte = (element) => element.textContent.replace(/\s+/g, ' ').trim();
// Les groupes du tableau : chacun le texte de son intitulé, ou null, et
// ses bases.
const groupes = (m) =>
tous(m, '[data-ecran="accueil"] table tbody').map((corps) => {
const intitule = corps.querySelector('th[scope="rowgroup"]');
return [intitule === null ? null : noeudsDeTexte(intitule), [...corps.querySelectorAll('[data-evenement]')].map((l) => l.dataset.evenement)];
});
// Choisit un groupement dans le sélecteur, comme une personne.
function grouper(m, valeur) {
const choix = dans(m, '[data-champ="groupement"]');
choix.value = valeur;
choix.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
// Vrai quand la boîte de l'élément est entière dans la fenêtre, sans défilement.
function dansLaPremiereVue(element) {
const boite = element.getBoundingClientRect();
return boite.top >= 0 && boite.bottom <= innerHeight && boite.left >= 0 && boite.right <= innerWidth;
}
test('en-tête : le titre, le résumé des lignes listées, « Créer un événement » principale qui mène au champ du nom, « Importer… » secondaire à côté ; tout dans la première vue à 1366 × 700', async () => {
const avant = { largeur: innerWidth, hauteur: innerHeight };
await fixerCadre(1366, 700);
try {
const m = await monterCinq();
window.scrollTo(0, 0);
const ecrites = ecritures(m);
expect(texte(accueil(m).querySelector('h1'))).toBe(t('coquille.liste'));
const participants = CINQ.length * analyser(PETITE).charge.participants.length;
expect(participants).toBeGreaterThan(CINQ.length);
const attendu = t('accueil.entete.resume', { evenements: CINQ.length, participants });
const resume = tous(m, '[data-ecran="accueil"] p').find((p) => p.textContent === attendu);
expect(resume, attendu).toBeDefined();
const creer = dans(m, '[data-ecran="accueil"] [data-commande="allerCreation"]');
const importer = dans(m, '[data-ecran="accueil"] [data-commande="importer"]');
expect(creer.textContent.trim()).toBe(t('accueil.entete.creer'));
expect(importer.textContent.trim()).toBe(t('accueil.operation.importer'));
for (const element of [resume, creer, importer, dans(m, '[data-version]')]) expect(dansLaPremiereVue(element), texte(element)).toBe(true);
// Les deux rangs de la maquette : la principale pleine de l'action, à
// hauteur de cible ; l'import à filet, sur le fond de la carte.
expect(fond(creer)).toEqual(jeton('--couleur-action-fond'));
expect(creer.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
expect(fond(importer)).toEqual(jeton('--couleur-carte-fond'));
// « Créer un événement » porte le focus sur le champ du nom de la carte
// de création, et n'écrit rien.
await cliquer(creer);
const nom = dans(m, '[data-champ="nom"]');
expect(document.activeElement).toBe(nom);
expect(visibleDansLaFenetre(nom)).toBe(true);
expect(nom.closest('section').getAttribute('aria-labelledby')).not.toBeNull();
expect(texte(document.getElementById(nom.closest('section').getAttribute('aria-labelledby')))).toBe(t('accueil.creation.titre'));
expect(ecritures(m)).toBe(ecrites);
} finally {
await fixerCadre(avant.largeur, avant.hauteur);
}
});
test('tuiles : Tous puis les quatre états dans l’ordre du § 9, chacune son compte, sa pastille et son aide ; un clic filtre, aria-pressed suit, rien ne s’écrit', async () => {
const m = await monterCinq();
const ecrites = ecritures(m);
expect(tuiles(m).map((bouton) => bouton.dataset.cible)).toEqual(['tous', 'brouillon', 'propose', 'retenu', 'bloque']);
expect(presses(m)).toEqual(['true', 'false', 'false', 'false', 'false']);
const attendues = [
['tous', 'accueil.filtre.tous', 5, 'accueil.filtre.aide.tous'],
['brouillon', 'accueil.etat.brouillon', 2, 'accueil.filtre.aide.brouillon'],
['propose', 'accueil.etat.propose', 1, 'accueil.filtre.aide.propose'],
['retenu', 'accueil.etat.retenu', 1, 'accueil.filtre.aide.retenu'],
['bloque', 'accueil.etat.bloque', 1, 'accueil.filtre.aide.bloque'],
];
for (const [filtre, cle, compte, aide] of attendues) {
const bouton = tuile(m, filtre);
expect(noeudsDeTexte(bouton).map((t0) => t0.trim()), filtre).toEqual([t(cle), format.nombre(compte), t(aide)]);
// La pastille : un signe dessiné, caché aux technologies d'assistance.
const signe = bouton.querySelector('svg');
expect(signe, filtre).not.toBeNull();
expect(signe.getAttribute('aria-hidden'), filtre).toBe('true');
expect(visible(bouton), filtre).toBe(true);
expect(atteint(bouton), filtre).toBe(true);
}
await cliquer(tuile(m, 'brouillon'));
expect(bases(m)).toEqual(['club', 'nuit']);
expect(presses(m)).toEqual(['false', 'true', 'false', 'false', 'false']);
await cliquer(tuile(m, 'bloque'));
expect(bases(m)).toEqual(['bal']);
// Les comptes ne suivent pas le filtre : ils comptent toute la liste.
expect(noeudsDeTexte(tuile(m, 'tous'))[1].trim()).toBe(format.nombre(5));
await cliquer(tuile(m, 'tous'));
expect(bases(m).length).toBe(5);
expect(ecritures(m)).toBe(ecrites);
});
test('le filtre est un état de la séance : il survit à un retour à la liste, une autre séance part de Tous, et le fichier ne le porte pas', async () => {
const m = await monterCinq();
const ecrites = ecritures(m);
await cliquer(tuile(m, 'bloque'));
grouper(m, 'aucun');
expect(bases(m)).toEqual(['bal']);
await ouvrirLigne(m, 'bal');
await attendreEvenement(m);
await revenirALaListe(m, 1);
expect(bases(m)).toEqual(['bal']);
expect(presses(m)).toEqual(['false', 'false', 'false', 'false', 'true']);
expect(dans(m, '[data-champ="groupement"]').value).toBe('aucun');
expect(ecritures(m)).toBe(ecrites);
for (const [base] of CINQ) expect(m.fs.contenu('documents', `${base}${SUFFIXES.etat}`)).not.toMatch(/filtre|groupement/);
const autre = await monter({ systeme: { disque: m.disque } });
await vi.waitFor(() => expect(lignes(autre).length).toBe(CINQ.length));
expect(presses(autre)).toEqual(['true', 'false', 'false', 'false', 'false']);
expect(dans(autre, '[data-champ="groupement"]').value).toBe('etat');
});
test('groupé par état : un groupe par état présent, dans l’ordre du § 9, son intitulé porte la pastille et « n événement(s) » ; sans groupement, un seul groupe dans l’ordre de la façade', async () => {
const m = await monterCinq();
const intitule = (cle, n) => [t(cle), t('accueil.groupe.compte', { evenements: n })];
expect(groupes(m)).toEqual([
[intitule('accueil.etat.brouillon', 2), ['club', 'nuit']],
[intitule('accueil.etat.propose', 1), ['souper']],
[intitule('accueil.etat.retenu', 1), ['gala']],
[intitule('accueil.etat.bloque', 1), ['bal']],
]);
for (const th of tous(m, 'table th[scope="rowgroup"]')) {
expect(th.colSpan).toBe(tous(m, 'table thead tr')[1].cells.length + 1);
expect(th.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
}
const choix = dans(m, '[data-champ="groupement"]');
expect(texte(choix.labels[0])).toContain(t('accueil.groupement.libelle'));
expect([...choix.options].map((option) => option.textContent)).toEqual([t('accueil.groupement.etat'), t('accueil.groupement.aucun')]);
expect(choix.value).toBe('etat');
grouper(m, 'aucun');
const ordre = (await m.facade.lister()).map(({ base }) => base);
expect(groupes(m)).toEqual([[null, ordre]]);
// Un filtre s'applique aussi sans groupement.
await cliquer(tuile(m, 'brouillon'));
expect(groupes(m)).toEqual([[null, ['club', 'nuit']]]);
grouper(m, 'etat');
expect(groupes(m)).toEqual([[intitule('accueil.etat.brouillon', 2), ['club', 'nuit']]]);
});
test('un filtre sans ligne : le tableau le dit, sans aucune ligne ; « Tous les événements » les rend', async () => {
const m = await monter({ fichiers: fichiersDe(CINQ.filter(([, , etatDuPlan]) => etatDuPlan === 'brouillon')) });
await vi.waitFor(() => expect(lignes(m).length).toBe(2));
await cliquer(tuile(m, 'retenu'));
expect(lignes(m).length).toBe(0);
const message = tous(m, '[data-ecran="accueil"] p').find((p) => p.textContent === t('accueil.filtre.vide'));
expect(message).toBeDefined();
expect(visible(message)).toBe(true);
await cliquer(tuile(m, 'tous'));
expect(lignes(m).length).toBe(2);
expect(tous(m, '[data-ecran="accueil"] p').some((p) => p.textContent === t('accueil.filtre.vide'))).toBe(false);
});
test('en-têtes groupés : Événement, Placement retenu à la teinte de Retenu, Meilleure proposition à celle de Proposé ; chaque valeur sous son groupe ; le nom en tête de ligne, la date et le chemin en petite ligne dessous', async () => {
const m = await monterCinq();
await chargerResumes(m);
const entetes = tous(m, '[data-ecran="accueil"] thead th[scope="colgroup"]');
expect(entetes.map(texte)).toEqual([t('accueil.entete.evenement'), t('accueil.entete.retenu'), t('generation.meilleure.titre')]);
expect(entetes.map((th) => th.colSpan)).toEqual([4, 2, 2]);
expect(fond(entetes[1])).toEqual(jeton('--couleur-etat-retenu-fond'));
expect(fond(entetes[2])).toEqual(jeton('--couleur-etat-propose-fond'));
// Le groupe porte le signe de son état, de la marque de l'état.
expect(composantes(getComputedStyle(entetes[1].querySelector('svg')).stroke)).toEqual(jeton('--couleur-etat-retenu-marque'));
expect(composantes(getComputedStyle(entetes[2].querySelector('svg')).stroke)).toEqual(jeton('--couleur-etat-propose-marque'));
const dans2 = (cellule, entete) => {
const c = cellule.getBoundingClientRect();
const e = entete.getBoundingClientRect();
return c.left >= e.left - 0.5 && c.right <= e.right + 0.5;
};
const ligne = ligneDe(m, 'gala');
for (const nom of ['nom', 'etat', 'participants', 'modification']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[0]), nom).toBe(true);
for (const nom of ['collisionsCumulees', 'diffRedondance']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[1]), nom).toBe(true);
for (const valeur of ['[data-meilleure-id]', '[data-meilleure-collisions]']) expect(dans2(ligne.querySelector(valeur), entetes[2]), valeur).toBe(true);
// Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent,
// dessous, en plus petit.
const nom = ligne.querySelector('[data-colonne="nom"]');
const enTete = nom.closest('th');
expect(enTete.getAttribute('scope')).toBe('row');
for (const colonneDessous of ['date', 'chemin']) {
const element = ligne.querySelector(`[data-colonne="${colonneDessous}"]`);
expect(enTete.contains(element), colonneDessous).toBe(true);
expect(element.getBoundingClientRect().top, colonneDessous).toBeGreaterThanOrEqual(nom.getBoundingClientRect().bottom - 0.5);
expect(parseFloat(getComputedStyle(element).fontSize), colonneDessous).toBeLessThan(parseFloat(getComputedStyle(nom).fontSize));
}
// L'état est une pastille : son signe, et le nom de l'état seul pour texte.
const etatDeLigne = ligne.querySelector('[data-colonne="etat"]');
expect(etatDeLigne.textContent.trim()).toBe(t('accueil.etat.retenu'));
expect(etatDeLigne.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
// « Ouvrir » ouvre la ligne.
const ouvrir = ligne.querySelector('[data-commande="ouvrir"]');
expect(ouvrir.textContent.trim()).toBe(t('accueil.ouvrir'));
await cliquer(ouvrir);
await attendreEvenement(m, ({ base }) => base === 'gala');
});
test('« Ouvrir », la commande principale de la ligne, a une cible d’au moins 44 px de haut, à chaque ligne', async () => {
const m = await monterCinq();
for (const [base] of CINQ) {
const ouvrir = ligneDe(m, base).querySelector('[data-commande="ouvrir"]');
expect(ouvrir.getBoundingClientRect().height, base).toBeGreaterThanOrEqual(44);
}
});
test('chaque « Ouvrir » se nomme « Ouvrir » suivi du nom de sa ligne, et se distingue ainsi des autres', async () => {
const m = await monterCinq();
// Le nom accessible d'un bouton nommé par aria-labelledby : le texte de
// chaque élément désigné, dans l'ordre, séparés d'une espace.
const nomAccessible = (element) =>
element
.getAttribute('aria-labelledby')
.split(' ')
.map((id) => texte(document.getElementById(id)))
.join(' ');
const noms = [];
for (const [base, nom] of CINQ) {
const ouvrir = ligneDe(m, base).querySelector('[data-commande="ouvrir"]');
expect(ouvrir.getAttribute('aria-labelledby'), base).not.toBeNull();
expect(nomAccessible(ouvrir), base).toBe(`${t('accueil.ouvrir')} ${nom}`);
noms.push(nomAccessible(ouvrir));
}
expect(new Set(noms).size).toBe(CINQ.length);
});
test('la date et le chemin, sous le nom, portent chacun son intitulé, lu par les technologies d’assistance et caché à l’œil', async () => {
const m = await monterCinq();
const enTete = ligneDe(m, 'gala').querySelector('[data-colonne="nom"]').closest('th');
for (const [colonne, cle] of [
['date', 'accueil.colonne.date'],
['chemin', 'accueil.colonne.chemin'],
]) {
const valeur = enTete.querySelector(`[data-colonne="${colonne}"]`);
// L'intitulé : le nœud sans enfant de l'en-tête de ligne qui porte son texte.
const intitule = [...enTete.querySelectorAll('span')].find((element) => element.children.length === 0 && texte(element) === t(cle)) ?? null;
expect(intitule, colonne).not.toBeNull();
expect(texte(intitule), colonne).toBe(t(cle));
// L'intitulé précède sa valeur dans l'ordre de lecture.
expect(intitule.compareDocumentPosition(valeur) & Node.DOCUMENT_POSITION_FOLLOWING, colonne).toBeTruthy();
expect(intitule.getAttribute('aria-hidden'), colonne).toBeNull();
const boite = intitule.getBoundingClientRect();
expect(boite.width * boite.height, colonne).toBeLessThanOrEqual(1);
}
});
test('l’avis d’un dossier de l’exécutable inconnu est un message « avis » : son fond, son signe ; le texte garde l’encre de la page', async () => {
const m = await monterCinq();
const avis = dans(m, '[data-avertissement="NON_PUBLIE"]');
expect(visible(avis)).toBe(true);
expect(fondPeintSous(avis)).toEqual([jeton('--couleur-avis-fond')]);
expect(composantes(getComputedStyle(avis).color)).toEqual(jeton('--couleur-page-texte'));
let message = avis;
while (message.querySelector('svg') === null) message = message.parentElement;
const signe = message.querySelector('svg');
expect(signe.getAttribute('aria-hidden')).toBe('true');
expect(composantes(getComputedStyle(signe).stroke)).toEqual(jeton('--couleur-avis-marque'));
// Le message précède les tuiles et le tableau.
expect(avis.compareDocumentPosition(groupeDesTuiles(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test('les cartes : Créer un événement, Dossier de travail, Démonstrations, chacune une région nommée par son titre, sur le fond de la carte, après le tableau', async () => {
const m = await monterCinq();
const titres = [t('accueil.creation.titre'), t('accueil.dossier.titre'), t('accueil.demonstrations.titre')];
const regions = tous(m, '[data-ecran="accueil"] section[aria-labelledby]');
const nommees = new Map(regions.map((region) => [texte(document.getElementById(region.getAttribute('aria-labelledby'))), region]));
const tableau = dans(m, '[data-ecran="accueil"] table');
for (const titre of titres) {
const region = nommees.get(titre);
expect(region, titre).toBeDefined();
expect(fond(region), titre).toEqual(jeton('--couleur-carte-fond'));
expect(tableau.compareDocumentPosition(region) & Node.DOCUMENT_POSITION_FOLLOWING, titre).toBeTruthy();
}
expect(nommees.get(titres[0]).contains(dans(m, '[data-commande="creer"]'))).toBe(true);
expect(nommees.get(titres[1]).contains(dans(m, '[data-dossier-travail]'))).toBe(true);
expect(nommees.get(titres[2]).contains(dans(m, '[data-demonstration]'))).toBe(true);
expect(nommees.get(t('accueil.liste.titre'))?.contains(tableau)).toBe(true);
});
test('répertoire vide : ni tuiles, ni tableau, ni résumé ; la création et les démonstrations', async () => {
const m = await monter();
expect(groupeDesTuiles(m)).toBeNull();
expect(dans(m, '[data-ecran="accueil"] table')).toBeNull();
expect(tous(m, '[data-ecran="accueil"] p').some((p) => p.textContent.includes(t('accueil.entete.resume', { evenements: 0, participants: 0 })))).toBe(false);
expect(atteint(dans(m, '[data-commande="creer"]'))).toBe(true);
expect(atteint(dans(m, '[data-demonstration]'))).toBe(true);
});
});

View file

@ -9,6 +9,16 @@
// en lecture par la séance ; ses autres gestes passent par la façade (A3),
// et il ne lit ni n'écrit rien lui-même.
//
// De haut en bas : l'en-tête — le titre, le résumé de la liste
// (resumeDeLaListe), la version, puis « Importer… » et « Créer un
// événement », l'action principale, qui porte le focus sur le champ du nom
// de la carte de création — ; le refus de la lecture et les messages du
// dossier de travail ; les tuiles des filtres par état du plan et le
// tableau, tant que la liste a des lignes ; enfin les cartes — la création,
// le dossier de travail, les démonstrations, la corbeille. Le filtre et le
// groupement forment la vue de la liste, que la séance retient
// (vueDeLaListe) et que rien n'écrit : un retour à la liste la retrouve.
//
// Les lignes viennent d'App, qui lit la liste par la façade une fois la
// détermination du dossier de travail réglée, puis leurs résumés, qu'App
// reçoit un événement après l'autre (resumes, par base) ; refusLecture est
@ -28,16 +38,30 @@
// ignore une seconde demande et se dit occupée.
import { getContext, onDestroy, untrack } from 'svelte';
import { versionVisible } from '../apropos.js';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import Corbeille from './Corbeille.svelte';
import Demonstrations from './Demonstrations.svelte';
import DossierTravail from './DossierTravail.svelte';
import Filtres from './Filtres.svelte';
import ListeEvenements from './ListeEvenements.svelte';
import Message from './Message.svelte';
import MessagesDossier from './MessagesDossier.svelte';
import Recuperation from './Recuperation.svelte';
import { champDuRefus, premiereAnnonce, refusDeLaListe, surLaListe } from './modele.js';
import {
champDuRefus,
premiereAnnonce,
refusDeLaListe,
resumeDeLaListe,
retenirVue,
surLaListe,
tuilesDeLaListe,
vueDeLaListe,
} from './modele.js';
const t = getContext('t');
const format = getContext('format');
const facade = getContext('facade');
const seance = getContext('seance');
const relireListe = getContext('relireListe');
@ -70,6 +94,19 @@
const question = $derived(issue?.dossier.question ?? null);
const avis = $derived(annoncer ? (issue?.avis ?? []) : []);
// La vue de la liste, { filtre, groupement }, celle que la séance retient.
let vue = $state.raw(vueDeLaListe(facade));
function changerVue(changement) {
vue = { ...vue, ...changement };
retenirVue(facade, vue);
}
const filtrer = (filtre) => changerVue({ filtre });
const grouper = (groupement) => changerVue({ groupement });
// Le champ du nom de la création, où mène « Créer un événement ».
let champNom = $state(null);
const allerALaCreation = () => champNom?.focus();
let detruit = false;
const desabonner = seance.subscribe((etat) => {
racine = etat.racine;
@ -207,21 +244,33 @@
<Recuperation {...recuperation} retour={() => (recuperation = null)} />
{:else}
<section class="accueil" data-ecran="accueil">
<h1>{t('coquille.liste')}</h1>
<p class="version" data-version>
<span>{t('coquille.version.libelle')}</span>
<span>{versionVisible(provenance, version, t)}</span>
</p>
<header class="entete">
<div class="titres">
<h1>{t('coquille.liste')}</h1>
{#if lignes.length > 0}
<p class="resume">{t('accueil.entete.resume', resumeDeLaListe(lignes))}</p>
{/if}
<p class="version" data-version>
<span>{t('coquille.version.libelle')}</span>
<span>{versionVisible(provenance, version, t)}</span>
</p>
</div>
<div class="commandes-entete">
<Commande cle="accueil.operation.importer" executer={importerFichier} />
<Commande cle="accueil.entete.creer" nom="allerCreation" variante="principale" executer={allerALaCreation} />
</div>
</header>
{#if refusLecture !== null}
<p class="refus" data-refus="accueil">{t(refusLecture.code, refusLecture.details)}</p>
<Message ton="refus">
<p data-refus="accueil">{t(refusLecture.code, refusLecture.details)}</p>
</Message>
{/if}
{#if issue !== null}
<DossierTravail
<MessagesDossier
{issue}
{racine}
{avis}
explorateur={facade.explorateur()}
{repondre}
{reponseEnCours}
{importerDepuisAutre}
@ -230,57 +279,66 @@
/>
{/if}
<ListeEvenements {lignes} {resumes} {ouvrir} {refusOuverture} {apresOperation} />
{#if lignes.length > 0}
<Filtres tuiles={tuilesDeLaListe(lignes, vue.filtre, format)} {filtrer} />
{/if}
<section class="creation" aria-labelledby={`${uid}-creation`}>
<h2 id={`${uid}-creation`}>{t('accueil.creation.titre')}</h2>
<form novalidate onsubmit={creer}>
<div class="champ">
<label for={`${uid}-nom`}>{t('accueil.creation.nom')}</label>
<input
id={`${uid}-nom`}
type="text"
data-champ="nom"
bind:value={nom}
aria-invalid={champRefuse === 'nom' ? 'true' : undefined}
aria-describedby={champRefuse === 'nom' ? `${uid}-refus` : undefined}
/>
{#if champRefuse === 'nom'}
<Refus id={`${uid}-refus`} refus={refusCreation} />
{/if}
</div>
<div class="champ">
<label for={`${uid}-date`}>{t('accueil.creation.date')}</label>
<input
id={`${uid}-date`}
type="text"
data-champ="date"
bind:value={date}
aria-invalid={champRefuse === 'date' ? 'true' : undefined}
aria-describedby={champRefuse === 'date' ? `${uid}-refus` : undefined}
/>
{#if champRefuse === 'date'}
<Refus id={`${uid}-refus`} refus={refusCreation} />
{/if}
</div>
<div class="commandes">
<button
type="submit"
data-commande="creer"
aria-busy={creationEnCours ? 'true' : undefined}
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
>
{#if refusCreation !== null && champRefuse === null}
<Refus refus={refusCreation} />
{/if}
</div>
</form>
<Commande cle="accueil.operation.importer" executer={importerFichier} />
</section>
<ListeEvenements {lignes} {resumes} {vue} {grouper} {ouvrir} {refusOuverture} {apresOperation} />
<Demonstrations {demonstrations} {charger} refus={refusDemonstration} />
<div class="cartes">
<Carte titre={t('accueil.creation.titre')} icone="ajouter">
<form novalidate onsubmit={creer}>
<div class="champ">
<label for={`${uid}-nom`}>{t('accueil.creation.nom')}</label>
<input
id={`${uid}-nom`}
type="text"
data-champ="nom"
bind:this={champNom}
bind:value={nom}
aria-invalid={champRefuse === 'nom' ? 'true' : undefined}
aria-describedby={champRefuse === 'nom' ? `${uid}-refus` : undefined}
/>
{#if champRefuse === 'nom'}
<Refus id={`${uid}-refus`} refus={refusCreation} />
{/if}
</div>
<div class="champ">
<label for={`${uid}-date`}>{t('accueil.creation.date')}</label>
<input
id={`${uid}-date`}
type="text"
data-champ="date"
bind:value={date}
aria-invalid={champRefuse === 'date' ? 'true' : undefined}
aria-describedby={champRefuse === 'date' ? `${uid}-refus` : undefined}
/>
{#if champRefuse === 'date'}
<Refus id={`${uid}-refus`} refus={refusCreation} />
{/if}
</div>
<div class="commandes">
<button
type="submit"
data-commande="creer"
aria-busy={creationEnCours ? 'true' : undefined}
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
>
{#if refusCreation !== null && champRefuse === null}
<Refus refus={refusCreation} />
{/if}
</div>
</form>
</Carte>
<Corbeille contenu={corbeille} />
{#if issue !== null}
<DossierTravail {question} {racine} explorateur={facade.explorateur()} />
{/if}
<Demonstrations {demonstrations} {charger} refus={refusDemonstration} />
<Corbeille contenu={corbeille} />
</div>
</section>
{/if}
@ -288,43 +346,81 @@
.accueil {
display: flex;
flex-direction: column;
gap: var(--espace-4);
gap: var(--espace-5);
}
h1,
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
/* L'en-tête : les titres à gauche, les deux commandes à droite ; elles
passent dessous quand la largeur manque. */
.entete {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--espace-3) var(--espace-5);
}
.titres {
display: flex;
flex-direction: column;
gap: var(--espace-1);
min-width: 0;
}
h1 {
line-height: 1.2;
}
.resume {
color: var(--couleur-page-texte-secondaire);
font-variant-numeric: tabular-nums;
}
.version {
display: flex;
gap: var(--espace-1);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.refus {
color: var(--couleur-refus);
.commandes-entete {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2) var(--espace-3);
}
/* Les cartes qui suivent la liste : autant de colonnes que la largeur en
admet, chacune de 19 rem au moins. */
.cartes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
align-items: start;
gap: var(--espace-4);
}
.creation,
form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
align-items: stretch;
gap: var(--espace-3);
}
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: stretch;
gap: var(--espace-1);
}
label {
font-weight: bold;
}
.commandes {
display: flex;
flex-wrap: wrap;
@ -333,10 +429,12 @@
}
input {
min-height: 2.5rem;
box-sizing: border-box;
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);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
@ -352,20 +450,24 @@
outline-offset: 2px;
}
/* « Créer l'événement » : l'action de la carte, pleine de la teinte de
l'action, à hauteur de cible. */
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);
min-height: var(--hauteur-cible);
padding: var(--espace-2) var(--espace-4);
border: 1px solid var(--couleur-action-fond);
border-radius: var(--rayon-2);
background-color: var(--couleur-action-fond);
color: var(--couleur-action-texte);
font: inherit;
font-size: var(--taille-texte);
font-weight: bold;
cursor: pointer;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
border-color: var(--couleur-action-texte);
cursor: progress;
}
</style>

View file

@ -9,57 +9,57 @@
// vide ne montre rien.
//
// contenu : [{ dossier, chemin, fichiers }] de facade.corbeille().
//
// La corbeille est une carte titrée, l'explication en sous-titre ; elle
// n'offre aucune commande.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
let { contenu } = $props();
const t = getContext('t');
const uid = $props.id();
</script>
{#if contenu.length > 0}
<section class="corbeille" data-corbeille aria-labelledby={`${uid}-titre`}>
<h2 id={`${uid}-titre`}>{t('accueil.corbeille.titre')}</h2>
<p>{t('accueil.corbeille.explication')}</p>
<ul class="dossiers">
{#each contenu as { dossier, chemin, fichiers } (dossier)}
<li data-corbeille-dossier={dossier}>
<p class="chemin">{chemin}</p>
<ul class="fichiers">
{#each fichiers as nom (nom)}
<li>{nom}</li>
{/each}
</ul>
</li>
{/each}
</ul>
</section>
<div class="corbeille" data-corbeille>
<Carte titre={t('accueil.corbeille.titre')} sousTitre={t('accueil.corbeille.explication')} icone="dossier">
<ul class="dossiers">
{#each contenu as { dossier, chemin, fichiers } (dossier)}
<li data-corbeille-dossier={dossier}>
<p class="chemin">{chemin}</p>
<ul class="fichiers">
{#each fichiers as nom (nom)}
<li>{nom}</li>
{/each}
</ul>
</li>
{/each}
</ul>
</Carte>
</div>
{/if}
<style>
.corbeille,
.corbeille {
min-width: 0;
}
.dossiers {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
.dossiers {
margin: 0;
padding: 0;
list-style: none;
}
p {
margin: 0;
}
.chemin {
font-size: var(--taille-texte-petit);
font-weight: bold;
overflow-wrap: anywhere;
}
@ -67,5 +67,7 @@
margin: 0;
padding-left: var(--espace-4);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
overflow-wrap: anywhere;
}
</style>

View file

@ -17,7 +17,11 @@
// se dit : sous elle, un message [data-attente-demonstration] nomme celle
// qui se charge, et le bouton le désigne par aria-describedby. Le message
// part avec le chargement.
//
// Les démonstrations sont une carte titrée, l'explication en sous-titre ;
// chacune un bouton secondaire, sur une rangée qui passe à la ligne.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Refus from '../primitives/Refus.svelte';
let { demonstrations, charger, refus } = $props();
@ -47,9 +51,7 @@
}
</script>
<section class="demonstrations" aria-labelledby={`${uid}-titre`}>
<h2 id={`${uid}-titre`}>{t('accueil.demonstrations.titre')}</h2>
<p>{t('accueil.demonstrations.explication')}</p>
<Carte titre={t('accueil.demonstrations.titre')} sousTitre={t('accueil.demonstrations.explication')} icone="demarrer">
<ul>
{#each demonstrations as { cle, nom } (cle)}
<li>
@ -72,31 +74,21 @@
</li>
{/each}
</ul>
</section>
</Carte>
<style>
.demonstrations,
ul {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
/* Les démonstrations se rangent en ligne, et passent à la ligne quand la
largeur manque : en colonne, quatre entrées repoussent la dernière
au-dessous d'une fenêtre de 700 px de haut. */
ul {
display: flex;
flex-flow: row wrap;
align-items: flex-start;
gap: var(--espace-2);
margin: 0;
padding: 0;
list-style: none;
@ -112,8 +104,8 @@
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);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
@ -121,6 +113,10 @@
cursor: pointer;
}
button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;

View file

@ -2,139 +2,60 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le dossier de travail sur la liste (§ 8.6, § 8.8), dans cet ordre : la
// question qui attend sa réponse — elle vient avant toute liste, et nomme
// le dossier, la cause et ce qu'il porte ; la proposition d'importer
// l'autre emplacement, quand le dossier retenu est neuf et vide — son
// compte, puis son chemin dans son propre nœud (data-autre-chemin) —, avec
// la phrase sur les originaux ; les avis de la détermination, non modaux ;
// puis le chemin du dossier, en entier, et la commande qui l'ouvre dans
// l'explorateur, ou, sous web, la phrase qui dit pourquoi elle manque.
// La carte du dossier de travail (§ 8.6) : son chemin, en entier, dans son
// propre nœud (data-dossier-travail), et la commande qui l'ouvre dans
// l'explorateur, ou, sous web, la phrase qui dit pourquoi elle manque, en
// message « avis ». Tant qu'une question attend, aucun dossier n'est
// retenu : la carte n'existe pas, la question est en tête de la liste
// (MessagesDossier).
//
// issue : la détermination { dossier, avis } de la façade ; racine : le
// dossier de travail de la séance, null tant qu'une question attend ;
// avis : ceux à dire, vides hors du premier accueil de la séance ;
// explorateur : { disponible, raison } de la façade ; repondre(choix) et
// importerDepuisAutre(), les gestes de l'accueil ; reponseEnCours : vrai
// pendant une réponse, qui retire les réponses offertes ; importAutre : les
// comptes du dernier import, ou null ; lignesPresentes : le dossier retenu
// porte déjà des événements, et l'import de l'autre emplacement ne se
// propose plus.
// question : celle qui attend sa réponse, ou null ; racine : le dossier de
// travail de la séance, null tant qu'une question attend ; explorateur :
// { disponible, raison } de la façade.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import { reponsesDe, surLaListe } from './modele.js';
import Message from './Message.svelte';
import { surLaListe } from './modele.js';
let {
issue,
racine,
avis,
explorateur,
repondre,
reponseEnCours,
importerDepuisAutre,
importAutre,
lignesPresentes,
} = $props();
let { question, racine, explorateur } = $props();
const t = getContext('t');
const facade = getContext('facade');
const uid = $props.id();
const question = $derived(issue.dossier.question);
const autre = $derived(issue.dossier.autre);
const reponses = $derived(question === null ? [] : reponsesDe(question));
</script>
<section class="dossier" data-dossier>
{#if question !== null}
<div class="question" role="group" data-question={question.code} aria-labelledby={`${uid}-question`}>
<p id={`${uid}-question`}>{t(question.code, question)}</p>
{#if !reponseEnCours}
<div class="reponses">
{#each reponses as { choix, cle } (choix)}
<Commande {cle} executer={() => repondre(choix)} />
{/each}
</div>
{/if}
</div>
{/if}
{#if autre !== null && !lignesPresentes}
<div class="autre" data-autre>
<p>{t('accueil.autre.proposition', { evenements: autre.evenements })}</p>
<p class="donnee" data-autre-chemin>{autre.racine.chemin}</p>
<p>{t('accueil.autre.originaux')}</p>
<Commande cle="accueil.autre.importer" nom="importerAutre" executer={importerDepuisAutre} />
</div>
{/if}
{#if importAutre !== null}
<p class="avis" role="status" data-avertissement="accueil.autre.resultat">{t('accueil.autre.resultat', importAutre)}</p>
{/if}
{#each avis as { code, details }, rang (rang)}
<p class="avis" role="status" data-avertissement={code}>{t(code, details)}</p>
{/each}
{#if question === null && racine !== null}
<div class="chemin">
<h2 id={`${uid}-titre`}>{t('accueil.dossier.titre')}</h2>
<p data-dossier-travail>{racine.chemin}</p>
{#if question === null && racine !== null}
<Carte titre={t('accueil.dossier.titre')} icone="dossier">
<div class="dossier" data-dossier>
<p class="chemin" data-dossier-travail>{racine.chemin}</p>
{#if explorateur.disponible}
<Commande cle="accueil.dossier.explorateur" executer={() => surLaListe(() => facade.ouvrirExplorateur())} />
{:else}
<p class="avis" data-avertissement={explorateur.raison}>{t(explorateur.raison)}</p>
<Message ton="avis">
<p data-avertissement={explorateur.raison}>{t(explorateur.raison)}</p>
</Message>
{/if}
</div>
{/if}
</section>
</Carte>
{/if}
<style>
.dossier {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-3);
}
h2,
p {
margin: 0;
}
h2 {
font-size: var(--taille-titre);
}
.question,
.autre {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
padding: var(--espace-3);
border: 2px solid var(--couleur-avis);
border-radius: var(--rayon-2);
}
.reponses {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.avis {
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
}
.chemin {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2) var(--espace-3);
}
[data-dossier-travail],
.donnee {
padding: var(--espace-2) var(--espace-3);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond-sourd);
font-size: var(--taille-texte-petit);
overflow-wrap: anywhere;
}
</style>

View file

@ -0,0 +1,93 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les tuiles des filtres de la liste par état du plan (§ 8.1, § 9) : un
// groupe nommé de boutons-filtres, un par tuile de tuilesDeLaListe, dans
// son ordre — tous les événements, puis chaque état dans l'ordre du § 9.
// Chaque tuile porte la pastille de son filtre, le nombre de lignes qu'il
// retient et son aide ; data-commande="filtrer" et data-cible, son filtre.
// La tuile du filtre courant est pressée (aria-pressed), et se distingue
// par un filet double de la teinte de l'action, non par la seule couleur.
//
// tuiles : celles de tuilesDeLaListe (modele.js) ; filtrer(filtre) : le
// geste de l'accueil, qui ne lit ni n'écrit rien.
import { getContext } from 'svelte';
import Pastille from '../primitives/Pastille.svelte';
let { tuiles, filtrer } = $props();
const t = getContext('t');
</script>
<div class="tuiles" role="group" aria-label={t('accueil.filtre.groupe')}>
{#each tuiles as { filtre, cle, aide, compte, presse, pastille } (filtre)}
<button
type="button"
class="tuile"
data-commande="filtrer"
data-cible={filtre}
aria-pressed={presse ? 'true' : 'false'}
onclick={() => filtrer(filtre)}
><Pastille sorte={pastille.sorte} valeur={pastille.valeur} icone={pastille.icone} libelle={t(cle)} /><span class="compte"
>{compte}</span
><span class="aide">{t(aide)}</span></button
>
{/each}
</div>
<style>
/* Cinq tuiles de même largeur sur une rangée ; elles passent à la ligne
quand la largeur manque. */
.tuiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10.5rem), 1fr));
gap: var(--espace-3);
}
/* Une tuile : une carte qui se presse. Le filet passe de 1 à 2 px quand
elle est pressée ; le retrait perd 1 px, de sorte que rien ne bouge. */
.tuile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
min-width: 0;
min-height: var(--hauteur-cible);
padding: var(--espace-3) var(--espace-4);
border: 1px solid var(--couleur-carte-filet);
border-radius: var(--rayon-3);
background-color: var(--couleur-carte-fond);
box-shadow: var(--ombre-carte);
color: var(--couleur-page-texte);
font: inherit;
text-align: start;
cursor: pointer;
}
.tuile:hover {
background-color: var(--couleur-carte-fond-sourd);
}
.tuile[aria-pressed='true'] {
padding: calc(var(--espace-3) - 1px) calc(var(--espace-4) - 1px);
border: 2px solid var(--couleur-action-fond);
}
.tuile:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.compte {
font-size: var(--taille-titre-grand);
font-weight: bold;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.aide {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
</style>

View file

@ -2,57 +2,83 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La liste des événements (§ 8.1) : une ligne par événement du dossier de
// travail, dans l'ordre de la façade, que désigne data-evenement. Chaque
// ligne porte la commande qui l'ouvre en lecture — son nom —, ses cinq
// autres informations et les deux mesures de son retenu (COLONNES_DU_RETENU)
// sous leur intitulé, chacune dans son propre nœud (data-colonne), la
// meilleure proposition de son classement (MEILLEURE) — un groupe
// data-meilleure sous le titre et les libellés du bloc de la génération,
// l'identifiant dans data-meilleure-id, les collisions cumulées dans
// data-meilleure-collisions, « — » l'un et l'autre sans proposition
// classée —, ses signalements traduits — les bases qu'un doublon
// nomme suivent son texte, chacune dans son propre nœud (data-doublon) —,
// le refus de son ouverture, et ses opérations. Un dossier sans événement
// n'affiche pas de liste vide : la création et les démonstrations de
// l'accueil en tiennent lieu.
// La liste des événements (§ 8.1), en tableau dans une carte titrée : une
// ligne par événement du dossier de travail que le filtre retient, que
// désigne data-evenement, rangée dans les groupes de groupesDeLaListe —
// un corps de tableau par groupe, son intitulé (la pastille de l'état et
// « n événement(s) ») en tête, dans l'ordre du § 9 ; un seul corps, sans
// intitulé, sans groupement. Le choix du groupement est dans l'en-tête de
// la carte. Un filtre qui ne retient aucune ligne le dit. Un dossier sans
// événement n'affiche ni carte ni tableau : la création et les
// démonstrations de l'accueil en tiennent lieu.
//
// Les en-têtes de colonnes sont groupés : l'événement — nom, état,
// participants, dernière modification —, le placement retenu, à la teinte
// de Retenu — ses deux mesures (COLONNES_DU_RETENU) —, la meilleure
// proposition du classement, à la teinte de Proposé, sous le titre et les
// libellés du bloc de la génération (MEILLEURE) — son identifiant et ses
// collisions cumulées —, puis les actions. Chaque valeur est dans son
// propre nœud : data-colonne, data-meilleure-id, data-meilleure-collisions,
// « — » sans valeur. Le nom est l'en-tête de sa ligne ; la date et le
// chemin le suivent en petite ligne, chacun précédé de son intitulé, que
// lisent les technologies d'assistance et que l'œil ne voit pas, puis ses
// signalements traduits — les bases qu'un doublon nomme suivent son
// texte, chacune dans son propre nœud (data-doublon). L'état est une
// pastille. La cellule des actions porte « Ouvrir », qui ouvre la ligne
// en lecture, ses opérations, et le refus de son ouverture. « Ouvrir » se
// nomme par aria-labelledby de son propre texte puis du nom de sa ligne :
// chaque ligne a son « Ouvrir Bal des Grisailles », distinct des autres.
// Les identifiants viennent du rang du groupe et du rang de la ligne :
// une base est un nom de fichier, qui n'est pas un identifiant.
//
// lignes : LigneListe[] de la façade ; resumes : les résumés reçus de
// facade.resumer, par base — une ligne sans le sien attend, et chacune de
// ses quatre valeurs de résumé porte le signe du chargement (Chargement),
// décoratif, dans une cellule qui se dit occupée (aria-busy), jusqu'à ce
// qu'il arrive. La région d'état qui suit la liste (data-chargement-liste)
// existe tant que la liste a des lignes, et écrit « accueil.chargement »
// tant qu'une ligne attend : le chargement s'annonce une fois pour la
// liste, et sa fin la vide. ouvrir(ligne) : le geste de l'accueil ;
// refusOuverture : { base, refus } du dernier échec, ou null ;
// apresOperation() : à appeler quand une opération a changé le dossier.
// qu'il arrive. La région d'état qui suit le tableau
// (data-chargement-liste) existe tant que la liste a des lignes, et écrit
// « accueil.chargement » tant qu'une ligne attend : le chargement
// s'annonce une fois pour la liste, et sa fin la vide. vue : { filtre,
// groupement } de l'accueil ; grouper(groupement) : le geste du choix ;
// ouvrir(ligne) : le geste de l'accueil ; refusOuverture : { base, refus }
// du dernier échec, ou null ; apresOperation() : à appeler quand une
// opération a changé le dossier.
//
// L'intitulé de chaque mesure du retenu, le titre de la meilleure
// proposition et l'intitulé de ses collisions s'expliquent dans
// l'infobulle de la liste, une seule (Infobulle, primitive) : chaque terme
// (data-terme, que pose le fragment termeExplique) se
// focalise au clavier, son survol ou son focus ouvre l'explication, le
// (data-terme, que pose le fragment termeExplique), un en-tête de colonne,
// se focalise au clavier, son survol ou son focus ouvre l'explication, le
// quitter la referme quand elle est encore la sienne, Échap aussi. Le
// terme expliqué porte aria-describedby vers elle, et lui seul.
//
// Le tableau tient dans sa carte à 1024 px de large ; plus étroit, il
// défile dans son propre conteneur, et la page ne défile pas en largeur.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Icone from '../primitives/Icone.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
import Pastille from '../primitives/Pastille.svelte';
import Refus from '../primitives/Refus.svelte';
import Chargement from './Chargement.svelte';
import Operations from './Operations.svelte';
import { COLONNES_DU_RETENU, MEILLEURE, ligneAffichee } from './modele.js';
import { COLONNES_DU_RETENU, MEILLEURE, choixDeGroupement, groupesDeLaListe, ligneAffichee } from './modele.js';
let { lignes, resumes, ouvrir, refusOuverture, apresOperation } = $props();
let { lignes, resumes, vue, grouper, ouvrir, refusOuverture, apresOperation } = $props();
const t = getContext('t');
const format = getContext('format');
// Une ligne au moins attend son résumé.
const enChargement = $derived(lignes.some((ligne) => ligneAffichee(ligne, format, resumes.get(ligne.base)).enAttente));
const groupes = $derived(groupesDeLaListe(lignes, vue.filtre, vue.groupement));
// Le nombre de colonnes du tableau, que l'intitulé d'un groupe couvre :
// les quatre de l'événement, les deux du retenu, les deux de la meilleure
// proposition, les actions.
const COLONNES = 9;
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation '<base>:<colonne>' et la clé de son explication — ; null
// sans infobulle.
// Le terme expliqué : {ancre, terme, cle, details} — son élément, son nom
// et la clé de son explication — ; null sans infobulle.
const uid = $props.id();
const idExplication = `${uid}-explication`;
let expliquee = $state(null);
@ -67,214 +93,394 @@
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
</script>
<!-- Un terme expliqué : nom est son data-terme, cle son intitulé,
explication la clé que l'infobulle montre ; id, celui qu'un groupe
nomme par aria-labelledby, ou undefined. -->
<!-- Une valeur de résumé : le signe du chargement tant que la ligne
attend, sa valeur ensuite. -->
{#snippet resume(affichee, valeur)}
{#if affichee.enAttente}<Chargement />{:else}{valeur}{/if}
{/snippet}
{#snippet termeExplique(base, nom, cle, explication, id)}
{@const terme = `${base}:${nom}`}
<!-- Un terme expliqué : nom est son data-terme, cle son intitulé,
explication la clé que l'infobulle montre. -->
{#snippet termeExplique(nom, cle, explication)}
<!-- L'intitulé est un terme, non une commande : le focus ne sert qu'à
montrer au clavier l'explication que le pointeur montre au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<span
{id}
class="terme"
data-terme={nom}
tabindex="0"
aria-describedby={decrit(terme)}
onmouseenter={(evenement) => expliquer(evenement, terme, explication)}
onmouseleave={() => taire(terme)}
onfocus={(evenement) => expliquer(evenement, terme, explication)}
onblur={() => taire(terme)}>{t(cle)}</span
aria-describedby={decrit(nom)}
onmouseenter={(evenement) => expliquer(evenement, nom, explication)}
onmouseleave={() => taire(nom)}
onfocus={(evenement) => expliquer(evenement, nom, explication)}
onblur={() => taire(nom)}>{t(cle)}</span
>
{/snippet}
<!-- Le choix du groupement, dans l'en-tête de la carte. -->
{#snippet choixDuGroupement()}
<label class="groupement">
<span>{t('accueil.groupement.libelle')}</span>
<select data-champ="groupement" value={vue.groupement} onchange={(evenement) => grouper(evenement.currentTarget.value)}>
{#each choixDeGroupement() as { groupement, cle } (groupement)}
<option value={groupement}>{t(cle)}</option>
{/each}
</select>
</label>
{/snippet}
{#if lignes.length > 0}
<ul class="liste">
{#each lignes as ligne, position (ligne.base)}
{@const affichee = ligneAffichee(ligne, format, resumes.get(ligne.base))}
{@const occupee = affichee.enAttente ? 'true' : undefined}
{@const idMeilleure = `${uid}-meilleure-${position}`}
<li class="ligne" data-evenement={ligne.base}>
<button type="button" class="ouvrir" data-commande="ouvrir" onclick={() => ouvrir(ligne)}
><span data-colonne="nom">{affichee.nom}</span></button
>
<dl class="informations">
<div>
<dt>{t('accueil.colonne.date')}</dt>
<dd data-colonne="date">{affichee.date}</dd>
</div>
<div>
<dt>{t('accueil.colonne.etat')}</dt>
<dd data-colonne="etat">{t(affichee.etat)}</dd>
</div>
<div>
<dt>{t('accueil.colonne.participants')}</dt>
<dd data-colonne="participants">{affichee.participants}</dd>
</div>
<div>
<dt>{t('accueil.colonne.modification')}</dt>
<dd data-colonne="modification">{affichee.modification}</dd>
</div>
{#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)}
<div>
<dt>{@render termeExplique(ligne.base, colonne, cle, explication, undefined)}</dt>
<dd data-colonne={colonne} aria-busy={occupee}>{@render resume(affichee, affichee[colonne])}</dd>
</div>
<Carte titre={t('accueil.liste.titre')} plein actions={choixDuGroupement}>
{#if groupes.length === 0}
<p class="vide">{t('accueil.filtre.vide')}</p>
{:else}
<div class="defilement">
<table>
<colgroup span="4"></colgroup>
<colgroup span="2"></colgroup>
<colgroup span="2"></colgroup>
<colgroup></colgroup>
<thead>
<tr>
<th scope="colgroup" colspan="4" class="groupe">{t('accueil.entete.evenement')}</th>
<th scope="colgroup" colspan="2" class="groupe retenu"
><span class="entete-groupe"
><span class="signe"><Icone nom="coche" taille={14} /></span><span>{t('accueil.entete.retenu')}</span></span
></th
>
<th scope="colgroup" colspan="2" class="groupe propose"
><span class="entete-groupe"
><span class="signe"><Icone nom="listePoints" taille={14} /></span>{@render termeExplique(
'meilleure',
MEILLEURE.titre,
MEILLEURE.explication,
)}</span
></th
>
<th scope="col" rowspan="2" class="actions">{t('accueil.colonne.actions')}</th>
</tr>
<tr>
<th scope="col">{t('accueil.colonne.nom')}</th>
<th scope="col">{t('accueil.colonne.etat')}</th>
<th scope="col" class="nombre">{t('accueil.colonne.participants')}</th>
<th scope="col">{t('accueil.colonne.modification')}</th>
{#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)}
<th scope="col" class="nombre">{@render termeExplique(colonne, cle, explication)}</th>
{/each}
<th scope="col" class="nombre">{t(MEILLEURE.proposition)}</th>
<th scope="col" class="nombre"
>{@render termeExplique('meilleureCollisions', MEILLEURE.collisions, MEILLEURE.explicationCollisions)}</th
>
</tr>
</thead>
{#each groupes as groupe, rangGroupe (groupe.groupe ?? '')}
<tbody>
{#if groupe.cle !== null}
<tr class="intertitre">
<th scope="rowgroup" colspan={COLONNES}
><span class="intitule"
><Pastille
sorte={groupe.pastille.sorte}
valeur={groupe.pastille.valeur}
icone={groupe.pastille.icone}
libelle={t(groupe.cle)}
/><span class="compte">{t('accueil.groupe.compte', groupe.details)}</span></span
></th
>
</tr>
{/if}
{#each groupe.lignes as ligne, rangLigne (ligne.base)}
{@const affichee = ligneAffichee(ligne, format, resumes.get(ligne.base))}
{@const idLigne = `${uid}-ligne-${rangGroupe}-${rangLigne}`}
{@const occupee = affichee.enAttente ? 'true' : undefined}
<tr data-evenement={ligne.base}>
<th scope="row" class="evenement">
<span class="nom" id="{idLigne}-nom" data-colonne="nom">{affichee.nom}</span>
<span class="sous-ligne"
><span class="date"
><span class="cache">{t('accueil.colonne.date')}</span><span class="signe-date"
><Icone nom="calendrier" taille={13} /></span
><span data-colonne="date">{affichee.date}</span></span
><span class="chemin"
><span class="cache">{t('accueil.colonne.chemin')}</span><span data-colonne="chemin"
>{affichee.chemin}</span
></span
></span
>
{#each affichee.signalements as { cle, details, noms }, rang (rang)}
<div class="signalement" data-signalement={cle}>
<p>{t(cle, details)}</p>
{#if noms.length > 0}
<ul class="noms">
{#each noms as nom (nom)}
<li data-doublon={nom}>{nom}</li>
{/each}
</ul>
{/if}
</div>
{/each}
</th>
<td data-colonne="etat"
>{#if ligne.etat !== null}<Pastille sorte="etat" valeur={ligne.etat} libelle={t(affichee.etat)} />{:else}{t(
affichee.etat,
)}{/if}</td
>
<td class="nombre" data-colonne="participants">{affichee.participants}</td>
<td data-colonne="modification">{affichee.modification}</td>
{#each COLONNES_DU_RETENU as { colonne } (colonne)}
<td class="nombre" data-colonne={colonne} aria-busy={occupee}>{@render resume(affichee, affichee[colonne])}</td>
{/each}
<td class="nombre" data-meilleure-id aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.id)}</td>
<td class="nombre" data-meilleure-collisions aria-busy={occupee}
>{@render resume(affichee, affichee.meilleure.collisions)}</td
>
<td class="actions">
<div class="commandes-ligne">
<button
type="button"
class="ouvrir"
id="{idLigne}-ouvrir"
aria-labelledby="{idLigne}-ouvrir {idLigne}-nom"
data-commande="ouvrir"
onclick={() => ouvrir(ligne)}>{t('accueil.ouvrir')}</button
>
<Operations {ligne} {apresOperation} />
</div>
{#if refusOuverture !== null && refusOuverture.base === ligne.base}
<Refus refus={refusOuverture.refus} />
{/if}
</td>
</tr>
{/each}
</tbody>
{/each}
<div class="chemin">
<dt>{t('accueil.colonne.chemin')}</dt>
<dd data-colonne="chemin">{affichee.chemin}</dd>
</div>
</dl>
<div class="meilleure" role="group" data-meilleure aria-labelledby={idMeilleure}>
<span class="titre-meilleure"
>{@render termeExplique(ligne.base, 'meilleure', MEILLEURE.titre, MEILLEURE.explication, idMeilleure)}</span
>
<dl>
<div>
<dt>{t(MEILLEURE.proposition)}</dt>
<dd data-meilleure-id aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.id)}</dd>
</div>
<div>
<dt>
{@render termeExplique(
ligne.base,
'meilleureCollisions',
MEILLEURE.collisions,
MEILLEURE.explicationCollisions,
undefined,
)}
</dt>
<dd data-meilleure-collisions aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.collisions)}</dd>
</div>
</dl>
</div>
{#each affichee.signalements as { cle, details, noms }, rang (rang)}
<div class="signalement" data-signalement={cle}>
<p>{t(cle, details)}</p>
{#if noms.length > 0}
<ul class="noms">
{#each noms as nom (nom)}
<li data-doublon={nom}>{nom}</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if refusOuverture !== null && refusOuverture.base === ligne.base}
<Refus refus={refusOuverture.refus} />
{/if}
<Operations {ligne} {apresOperation} />
</li>
{/each}
</ul>
<p class="chargement" role="status" data-chargement-liste>{#if enChargement}{t('accueil.chargement')}{/if}</p>
</table>
</div>
{/if}
<p class="chargement" class:annonce={enChargement} role="status" data-chargement-liste
>{#if enChargement}{t('accueil.chargement')}{/if}</p
>
</Carte>
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
{/if}
<style>
.liste {
display: flex;
flex-direction: column;
gap: var(--espace-3);
p {
margin: 0;
padding: 0;
list-style: none;
}
.ligne {
.groupement {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
gap: var(--espace-2);
padding: var(--espace-3);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
select {
min-height: 2.25rem;
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
}
select:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.vide {
padding: var(--espace-4) var(--espace-5);
color: var(--couleur-page-texte-secondaire);
}
/* Le conteneur défilant du tableau : un tableau plus large que la carte
y défile, la page ne défile pas. */
.defilement {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--taille-texte-petit);
}
th,
td {
padding: var(--espace-2);
border-bottom: 1px solid var(--couleur-ligne-filet);
text-align: start;
vertical-align: top;
}
th:first-child,
td:first-child {
padding-left: var(--espace-3);
}
th:last-child,
td:last-child {
padding-right: var(--espace-3);
}
thead th {
border-bottom-color: var(--couleur-carte-filet);
background-color: var(--couleur-carte-fond-sourd);
font-weight: bold;
vertical-align: bottom;
}
/* Les en-têtes de groupes de colonnes : en petites capitales, sur la
teinte de l'état qu'ils résument, le signe de l'état devant. */
thead .groupe {
font-size: var(--taille-intertitre);
letter-spacing: var(--interlettre-capitales);
text-transform: uppercase;
}
thead .retenu {
background-color: var(--couleur-etat-retenu-fond);
}
thead .retenu .signe {
color: var(--couleur-etat-retenu-marque);
}
thead .propose {
background-color: var(--couleur-etat-propose-fond);
}
thead .propose .signe {
color: var(--couleur-etat-propose-marque);
}
.entete-groupe {
display: inline-flex;
align-items: center;
gap: var(--espace-1);
}
.signe {
display: inline-flex;
flex: none;
}
.nombre {
text-align: end;
font-variant-numeric: tabular-nums;
}
/* L'intitulé d'un groupe de lignes, sur le fond sourd de la carte. */
.intertitre th {
padding-top: var(--espace-2);
padding-bottom: var(--espace-2);
background-color: var(--couleur-carte-fond-sourd);
font-weight: normal;
}
.intitule {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2);
}
.compte {
color: var(--couleur-page-texte-secondaire);
}
/* L'en-tête d'une ligne : le nom en gras, au corps du texte, puis la
petite ligne de la date et du chemin, et les signalements. */
.evenement {
min-width: 9rem;
font-weight: normal;
overflow-wrap: anywhere;
}
.nom {
display: block;
font-size: var(--taille-texte);
font-weight: bold;
}
.sous-ligne {
display: flex;
flex-wrap: wrap;
gap: 0 var(--espace-3);
margin-top: var(--espace-1);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-intertitre);
}
.date {
display: inline-flex;
align-items: center;
gap: var(--espace-1);
white-space: nowrap;
}
.signe-date {
display: inline-flex;
}
.chemin {
min-width: 0;
}
/* Un intitulé lu par les technologies d'assistance, hors de la vue : une
boîte d'un pixel, rognée, qui ne pousse rien. */
.cache {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Les actions : « Ouvrir », puis les opérations, sur une rangée qui
passe à la ligne quand la colonne est étroite. « Ouvrir » est la
commande principale de la ligne : sa cible a la hauteur des cibles
principales. */
.actions {
min-width: 8rem;
}
.commandes-ligne {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1);
}
.ouvrir {
min-height: var(--hauteur-cible);
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);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-titre);
font-size: var(--taille-texte);
font-weight: bold;
text-align: start;
cursor: pointer;
}
.ouvrir:hover {
background-color: var(--couleur-carte-fond-sourd);
}
.ouvrir:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.informations {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-4);
margin: 0;
}
.informations div {
display: flex;
flex-direction: column;
}
dt {
color: var(--couleur-page-texte-secondaire);
}
dd {
margin: 0;
}
.chemin dd {
overflow-wrap: anywhere;
}
/* La meilleure proposition du classement : un groupe encadré d'un filet,
son titre en gras, puis chaque libellé suivi de sa valeur, en gras,
comme dans le bloc de la génération. */
.meilleure {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-1) var(--espace-4);
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
.titre-meilleure {
font-weight: bold;
}
.meilleure dl {
display: flex;
flex-wrap: wrap;
gap: var(--espace-1) var(--espace-4);
margin: 0;
}
.meilleure dl > div {
display: flex;
gap: var(--espace-2);
align-items: baseline;
}
.meilleure dd {
font-weight: bold;
font-variant-numeric: tabular-nums;
}
/* Un intitulé qui s'explique se signale par un soulignement pointillé ;
focalisé, par le contour du focus. */
.terme {
@ -291,22 +497,25 @@
display: flex;
flex-direction: column;
gap: var(--espace-1);
margin-top: var(--espace-2);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
}
.signalement p {
margin: 0;
}
.chargement {
margin: 0;
color: var(--couleur-page-texte-secondaire);
background-color: var(--couleur-avis-fond);
font-size: var(--taille-texte-petit);
}
.noms {
margin: 0;
padding-left: var(--espace-4);
overflow-wrap: anywhere;
}
.chargement {
padding: 0 var(--espace-5);
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
.chargement.annonce {
padding: var(--espace-2) var(--espace-5);
}
</style>

View file

@ -0,0 +1,100 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Un message de l'accueil (§ 8.6, § 8.8, § 12.7) : un avis, une
// information ou un refus, en bande sur le fond teinté de son ton, filet
// de son ton et trait de gauche de son encre, son signe — une icône de la
// marque du ton, cachée aux technologies d'assistance — devant le texte.
// Le texte garde l'encre de la page : le signe et le fond redisent le ton,
// ils ne le portent pas seuls. Le contenu (children) apporte ses propres
// désignations, son rôle d'état et ses commandes.
//
// ton : 'avis', 'information' ou 'refus' ; un autre lève RangeError au
// montage.
import Icone from '../primitives/Icone.svelte';
let { ton, children } = $props();
// L'icône de chaque ton.
const ICONES = new Map([
['avis', 'triangle'],
['information', 'information'],
['refus', 'interdit'],
]);
const icone = $derived.by(() => {
if (!ICONES.has(ton)) throw new RangeError(`message : ton inconnu ${JSON.stringify(ton)}`);
return ICONES.get(ton);
});
</script>
<div class="message" class:avis={ton === 'avis'} class:information={ton === 'information'} class:refus={ton === 'refus'}>
<span class="signe"><Icone nom={icone} taille={18} /></span>
<div class="texte">{@render children()}</div>
</div>
<style>
.message {
display: flex;
align-items: flex-start;
gap: var(--espace-2);
min-width: 0;
padding: var(--espace-2) var(--espace-3);
border: 1px solid;
border-left-width: 3px;
border-radius: var(--rayon-2);
color: var(--couleur-page-texte);
}
.avis {
border-color: var(--couleur-avis-filet);
border-left-color: var(--couleur-avis);
background-color: var(--couleur-avis-fond);
}
.avis .signe {
color: var(--couleur-avis-marque);
}
.information {
border-color: var(--couleur-information-filet);
border-left-color: var(--couleur-information);
background-color: var(--couleur-information-fond);
}
.information .signe {
color: var(--couleur-information-marque);
}
.refus {
border-color: var(--couleur-refus-filet);
border-left-color: var(--couleur-refus);
background-color: var(--couleur-refus-fond);
}
.refus .signe {
color: var(--couleur-refus-marque);
}
/* Le signe se cale sur la première ligne du texte. */
.signe {
display: inline-flex;
flex: none;
align-items: center;
height: calc(1em * var(--police-interligne));
}
.texte {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
min-width: 0;
overflow-wrap: anywhere;
}
.texte :global(p) {
margin: 0;
}
</style>

View file

@ -0,0 +1,107 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Les messages du dossier de travail en tête de la liste (§ 8.6, § 8.8),
// dans cet ordre : la question qui attend sa réponse — un avis, avant toute
// liste, qui nomme le dossier, la cause et ce qu'il porte, et offre ses
// réponses ; la proposition d'importer l'autre emplacement, quand le
// dossier retenu est neuf et vide — une information, son compte, puis son
// chemin dans son propre nœud (data-autre-chemin), la phrase sur les
// originaux et sa commande ; le compte du dernier import ; puis les avis de
// la détermination, non modaux, chacun un message « avis ». Le chemin du
// dossier retenu est dans sa carte (DossierTravail).
//
// issue : la détermination { dossier, avis } de la façade ; avis : ceux à
// dire, vides hors du premier accueil de la séance ; repondre(choix) et
// importerDepuisAutre(), les gestes de l'accueil ; reponseEnCours : vrai
// pendant une réponse, qui retire les réponses offertes ; importAutre : les
// comptes du dernier import, ou null ; lignesPresentes : le dossier retenu
// porte déjà des événements, et l'import de l'autre emplacement ne se
// propose plus.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Message from './Message.svelte';
import { reponsesDe } from './modele.js';
let { issue, avis, repondre, reponseEnCours, importerDepuisAutre, importAutre, lignesPresentes } = $props();
const t = getContext('t');
const uid = $props.id();
const question = $derived(issue.dossier.question);
const autre = $derived(issue.dossier.autre);
const reponses = $derived(question === null ? [] : reponsesDe(question));
</script>
{#if question !== null || (autre !== null && !lignesPresentes) || importAutre !== null || avis.length > 0}
<div class="messages">
{#if question !== null}
<Message ton="avis">
<div class="question" role="group" data-question={question.code} aria-labelledby={`${uid}-question`}>
<p id={`${uid}-question`}>{t(question.code, question)}</p>
{#if !reponseEnCours}
<div class="reponses">
{#each reponses as { choix, cle } (choix)}
<Commande {cle} executer={() => repondre(choix)} />
{/each}
</div>
{/if}
</div>
</Message>
{/if}
{#if autre !== null && !lignesPresentes}
<Message ton="information">
<div class="autre" data-autre>
<p>{t('accueil.autre.proposition', { evenements: autre.evenements })}</p>
<p class="donnee" data-autre-chemin>{autre.racine.chemin}</p>
<p>{t('accueil.autre.originaux')}</p>
<Commande cle="accueil.autre.importer" nom="importerAutre" executer={importerDepuisAutre} />
</div>
</Message>
{/if}
{#if importAutre !== null}
<Message ton="information">
<p role="status" data-avertissement="accueil.autre.resultat">{t('accueil.autre.resultat', importAutre)}</p>
</Message>
{/if}
{#each avis as { code, details }, rang (rang)}
<Message ton="avis">
<p role="status" data-avertissement={code}>{t(code, details)}</p>
</Message>
{/each}
</div>
{/if}
<style>
.messages {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
p {
margin: 0;
}
.question,
.autre {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-2);
}
.reponses {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.donnee {
font-weight: bold;
overflow-wrap: anywhere;
}
</style>

View file

@ -13,7 +13,8 @@
// opération réussie. Une validation en cours ne se relance pas : une
// seconde soumission pendant l'écriture ne fait rien, et le bouton de
// validation se dit occupé ; supprimer passe par la Commande, qui a la
// même garde.
// même garde. Dans la cellule des actions du tableau, les trois commandes
// sont discrètes, à côté de « Ouvrir » ; la saisie s'ouvre à leur place.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
@ -76,9 +77,14 @@
<div class="operations">
{#if saisie === null}
<Commande cle="accueil.operation.copier" executer={() => ouvrirSaisie('copier')} />
<Commande cle="accueil.operation.renommer" executer={() => ouvrirSaisie('renommer')} />
<Commande cle="accueil.operation.supprimer" confirmer="accueil.operation.confirmerSuppression" executer={supprimer} />
<Commande cle="accueil.operation.copier" variante="discrete" executer={() => ouvrirSaisie('copier')} />
<Commande cle="accueil.operation.renommer" variante="discrete" executer={() => ouvrirSaisie('renommer')} />
<Commande
cle="accueil.operation.supprimer"
variante="discrete"
confirmer="accueil.operation.confirmerSuppression"
executer={supprimer}
/>
{:else}
<form novalidate onsubmit={valider}>
<div class="champ">
@ -117,14 +123,14 @@
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2);
gap: var(--espace-1);
}
form,
.champ {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: stretch;
gap: var(--espace-2);
}
@ -132,12 +138,18 @@
gap: var(--espace-1);
}
label {
font-weight: bold;
}
input {
min-width: 20em;
width: 14rem;
max-width: 100%;
box-sizing: border-box;
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);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
@ -150,14 +162,27 @@
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);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
cursor: pointer;
}
button:hover {
background-color: var(--couleur-carte-fond-sourd);
}
/* La validation : l'action de la saisie, pleine de la teinte de
l'action. */
button[type='submit'] {
border-color: var(--couleur-action-fond);
background-color: var(--couleur-action-fond);
color: var(--couleur-action-texte);
font-weight: bold;
}
input:focus-visible,
button:focus-visible {
outline: 2px solid var(--couleur-focus);
@ -166,7 +191,10 @@
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
button[type='submit'][aria-disabled='true'] {
border-color: var(--couleur-action-texte);
}
</style>

View file

@ -12,7 +12,11 @@
//
// base, chemin : ceux de la ligne ; refus : { code, details } de
// l'ouverture refusée ; retour() : rend la liste.
//
// Sous le titre de l'écran, une carte : le fichier, la raison, les secours
// — le premier offert est l'action principale —, puis le retour.
import { getContext } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
import { secoursOfferts, surLaListe } from './modele.js';
@ -28,28 +32,44 @@
<section class="recuperation" data-ecran="recuperation" aria-labelledby={`${uid}-titre`}>
<h1 id={`${uid}-titre`}>{t('accueil.recuperation.titre')}</h1>
<p class="fichier">
<span>{t('accueil.recuperation.fichier')}</span>
<span class="chemin">{chemin}</span>
</p>
<Refus {refus} />
{#if offerts.length > 0}
<p>{t('accueil.recuperation.offerte')}</p>
<ul class="secours">
{#each offerts as { source, cle } (source)}
<li data-secours={source}>
<Commande {cle} executer={() => surLaListe(() => seance.ouvrirSecours(base, source))} />
</li>
{/each}
</ul>
{:else}
<p>{t('accueil.recuperation.aucunSecours')}</p>
{/if}
<Commande cle="accueil.recuperation.retour" executer={retour} />
<Carte>
<div class="contenu">
<p class="fichier">
<span class="intitule">{t('accueil.recuperation.fichier')}</span>
<span class="chemin">{chemin}</span>
</p>
<Refus {refus} />
{#if offerts.length > 0}
<p>{t('accueil.recuperation.offerte')}</p>
<ul class="secours">
{#each offerts as { source, cle }, rang (source)}
<li data-secours={source}>
<Commande
{cle}
variante={rang === 0 ? 'principale' : 'secondaire'}
executer={() => surLaListe(() => seance.ouvrirSecours(base, source))}
/>
</li>
{/each}
</ul>
{:else}
<p>{t('accueil.recuperation.aucunSecours')}</p>
{/if}
<div class="retour">
<Commande cle="accueil.recuperation.retour" executer={retour} />
</div>
</div>
</Carte>
</section>
<style>
.recuperation,
.recuperation {
display: flex;
flex-direction: column;
gap: var(--espace-5);
}
.contenu,
.secours {
display: flex;
flex-direction: column;
@ -62,20 +82,36 @@
margin: 0;
}
h1 {
line-height: 1.2;
}
.fichier {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
.intitule {
font-weight: bold;
}
.chemin {
overflow-wrap: anywhere;
}
.secours {
flex-flow: row wrap;
align-items: center;
gap: var(--espace-2);
margin: 0;
padding: 0;
list-style: none;
}
.retour {
padding-top: var(--espace-3);
border-top: 1px solid var(--couleur-carte-filet);
align-self: stretch;
}
</style>

View file

@ -6,10 +6,11 @@
// ligne de la liste (LigneListe de la façade, evenements.js) devient ses six
// informations et ses signalements, mis en forme, puis, son résumé reçu, les
// deux mesures de son retenu et la meilleure proposition de son
// classement ; une question du dossier de travail, ses réponses ; un état
// illisible, ses secours ; un refus de la façade, le refus affiché sur la
// liste. Chaque clé rendue est écrite ici en entier : aucun composant ne
// compose une clé.
// classement ; les lignes de la liste, les tuiles de ses filtres par état
// du plan, le résumé de son en-tête et les groupes de son tableau ; une
// question du dossier de travail, ses réponses ; un état illisible, ses
// secours ; un refus de la façade, le refus affiché sur la liste. Chaque clé
// rendue est écrite ici en entier : aucun composant ne compose une clé.
import { COLONNE_ANNONCEE } from '../generation/meilleure.js';
import { COLONNES } from '../propositions/modele.js';
@ -328,3 +329,201 @@ export function premiereAnnonce(seance) {
ANNONCEES.add(seance);
return true;
}
// --- La vue de la liste : filtres, résumé, groupes (§ 8.1, § 9) ------------------
/**
* Les filtres de la liste, dans l'ordre de leurs tuiles : tous les
* événements, puis chaque état du plan, dans l'ordre du § 9.
*/
export const FILTRES = Object.freeze(['tous', ...ETATS]);
// Le groupe des lignes dont l'état ne se lit pas : il suit les quatre états,
// et aucun filtre d'état ne le retient.
const ILLISIBLE = 'illisible';
// La clé du nom et celle de l'aide de chaque tuile ; le nom d'un état est
// celui de la colonne État, l'aide redit sa ligne du tableau du § 9.
const CLES_FILTRES = new Map([
['tous', { cle: 'accueil.filtre.tous', aide: 'accueil.filtre.aide.tous' }],
['brouillon', { cle: 'accueil.etat.brouillon', aide: 'accueil.filtre.aide.brouillon' }],
['propose', { cle: 'accueil.etat.propose', aide: 'accueil.filtre.aide.propose' }],
['retenu', { cle: 'accueil.etat.retenu', aide: 'accueil.filtre.aide.retenu' }],
['bloque', { cle: 'accueil.etat.bloque', aide: 'accueil.filtre.aide.bloque' }],
]);
// La clé de l'intitulé de chaque groupe par état : le nom de l'état, ou
// celui des états illisibles.
const CLES_GROUPES = new Map([...CLES_ETATS, [ILLISIBLE, 'accueil.groupe.illisible']]);
// La pastille de chaque tuile et de chaque groupe, telle que Pastille la
// reçoit : un état porte la sienne ; tous les événements, un statut
// d'information à l'icône de la liste ; les états illisibles, un avis à
// l'icône du triangle.
const PASTILLES = new Map([
['tous', { sorte: 'statut', valeur: 'information', icone: 'liste' }],
['brouillon', { sorte: 'etat', valeur: 'brouillon', icone: null }],
['propose', { sorte: 'etat', valeur: 'propose', icone: null }],
['retenu', { sorte: 'etat', valeur: 'retenu', icone: null }],
['bloque', { sorte: 'etat', valeur: 'bloque', icone: null }],
[ILLISIBLE, { sorte: 'statut', valeur: 'avis', icone: 'triangle' }],
]);
// Les groupements de la liste, dans l'ordre de leur choix, et la clé du
// libellé de chacun.
const CLES_GROUPEMENTS = new Map([
['etat', 'accueil.groupement.etat'],
['aucun', 'accueil.groupement.aucun'],
]);
/** Les groupements de la liste : par état du plan, ou aucun. */
export const GROUPEMENTS = Object.freeze([...CLES_GROUPEMENTS.keys()]);
function exigerFiltre(filtre) {
if (!FILTRES.includes(filtre)) throw new RangeError(`filtre de la liste inconnu : ${JSON.stringify(filtre)}`);
}
function exigerGroupement(groupement) {
if (!CLES_GROUPEMENTS.has(groupement)) throw new RangeError(`groupement de la liste inconnu : ${JSON.stringify(groupement)}`);
}
// Le groupe d'une ligne : son état, ILLISIBLE sans état lu ; un état hors
// du § 9 lève, plutôt que de laisser la ligne hors de tout groupe.
function groupeDe(ligne) {
if (ligne.etat === null) return ILLISIBLE;
cleDEtat(ligne.etat);
return ligne.etat;
}
// Vrai quand le filtre retient la ligne : tous les événements, ou ceux de
// son état. L'état se vérifie sous tout filtre.
function retient(filtre, ligne) {
const groupe = groupeDe(ligne);
return filtre === 'tous' || groupe === filtre;
}
/**
* Les tuiles des filtres de la liste (§ 8.1, § 9), dans l'ordre de FILTRES :
* chacune { filtre, cle, aide, compte, presse, pastille } — le filtre
* qu'elle pose, la clé de son nom et celle de son aide, le nombre de lignes
* qu'il retient, mis en forme, vrai pour la tuile du filtre courant, et sa
* pastille ({ sorte, valeur, icone }, une copie). Une ligne illisible ne
* compte que sous « tous ».
*
* @param {Object[]} lignes LigneListe[] (evenements.js)
* @param {string} filtre le filtre courant, de FILTRES
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
* @returns {Array<{filtre: string, cle: string, aide: string, compte: string, presse: boolean,
* pastille: {sorte: string, valeur: string, icone: string|null}}>}
* @throws {RangeError} un filtre hors de FILTRES
*/
export function tuilesDeLaListe(lignes, filtre, format) {
exigerFiltre(filtre);
return FILTRES.map((tuile) => ({
filtre: tuile,
...CLES_FILTRES.get(tuile),
compte: format.nombre(lignes.filter((ligne) => retient(tuile, ligne)).length),
presse: tuile === filtre,
pastille: { ...PASTILLES.get(tuile) },
}));
}
/**
* Le résumé de l'en-tête de la liste (§ 8.1), les détails de la clé
* accueil.entete.resume : le nombre d'événements listés, et la somme des
* participants qu'ils portent déjà — une ligne illisible, sans compte, n'en
* apporte aucun. Rien ne se relit : la somme est celle des lignes reçues.
*
* @param {Object[]} lignes LigneListe[] (evenements.js)
* @returns {{evenements: number, participants: number}}
*/
export function resumeDeLaListe(lignes) {
let participants = 0;
for (const ligne of lignes) if (ligne.participants !== null) participants += ligne.participants;
return { evenements: lignes.length, participants };
}
/**
* Les groupes du tableau de la liste (§ 8.1, § 9), les lignes que le filtre
* retient, chacune dans l'ordre de la façade : chacun { groupe, cle,
* pastille, details, lignes }. Groupées par état, un groupe par état du
* plan dans l'ordre du § 9, puis celui des états illisibles ; un groupe sans
* ligne n'existe pas ; cle est la clé de son intitulé, details les détails
* de accueil.groupe.compte. Sans groupement, un seul groupe, sans
* intitulé : groupe, cle et pastille valent null. Sans ligne retenue, aucun
* groupe : le composant dit que le filtre ne retient rien.
*
* @param {Object[]} lignes LigneListe[] (evenements.js)
* @param {string} filtre de FILTRES
* @param {string} groupement de GROUPEMENTS
* @returns {Array<{groupe: string|null, cle: string|null, pastille: Object|null,
* details: {evenements: number}, lignes: Object[]}>}
* @throws {RangeError} un filtre, un groupement, ou l'état d'une ligne inconnus
*/
export function groupesDeLaListe(lignes, filtre, groupement) {
exigerFiltre(filtre);
exigerGroupement(groupement);
const retenues = lignes.filter((ligne) => retient(filtre, ligne));
if (retenues.length === 0) return [];
if (groupement === 'aucun') {
return [{ groupe: null, cle: null, pastille: null, details: { evenements: retenues.length }, lignes: retenues }];
}
const groupes = [];
for (const [groupe, cle] of CLES_GROUPES) {
const siennes = retenues.filter((ligne) => groupeDe(ligne) === groupe);
if (siennes.length === 0) continue;
groupes.push({ groupe, cle, pastille: { ...PASTILLES.get(groupe) }, details: { evenements: siennes.length }, lignes: siennes });
}
return groupes;
}
/**
* Les choix du groupement, dans l'ordre de GROUPEMENTS : chacun
* { groupement, cle }, une copie.
*
* @returns {Array<{groupement: string, cle: string}>}
*/
export function choixDeGroupement() {
return GROUPEMENTS.map((groupement) => ({ groupement, cle: CLES_GROUPEMENTS.get(groupement) }));
}
// La vue de la liste que chaque séance retient, et celle d'une séance qui
// n'en a retenu aucune.
const VUES = new WeakMap();
const VUE_PAR_DEFAUT = Object.freeze({ filtre: 'tous', groupement: 'etat' });
function exigerSeance(seance, quoi) {
if (seance === null || typeof seance !== 'object') throw new TypeError(`${quoi} : la séance est un objet`);
}
/**
* La vue de la liste que la séance retient (§ 8.1) : { filtre, groupement },
* une copie ; tous les événements groupés par état tant qu'elle n'en a
* retenu aucune. C'est un état d'interface : il survit à un retour à la
* liste, jamais à la séance, et rien ne l'écrit. La séance est la façade
* d'App, comme pour premiereAnnonce.
*
* @param {object} seance
* @returns {{filtre: string, groupement: string}}
* @throws {TypeError} une séance qui n'est pas un objet
*/
export function vueDeLaListe(seance) {
exigerSeance(seance, 'vueDeLaListe');
return { ...(VUES.get(seance) ?? VUE_PAR_DEFAUT) };
}
/**
* Retient la vue de la liste pour la séance ; une copie, que vueDeLaListe
* rend ensuite.
*
* @param {object} seance
* @param {{filtre: string, groupement: string}} vue
* @throws {TypeError} une séance qui n'est pas un objet
* @throws {RangeError} un filtre ou un groupement inconnus ; rien n'est retenu
*/
export function retenirVue(seance, { filtre, groupement }) {
exigerSeance(seance, 'retenirVue');
exigerFiltre(filtre);
exigerGroupement(groupement);
VUES.set(seance, Object.freeze({ filtre, groupement }));
}

View file

@ -5,7 +5,8 @@
// ligne de la liste, mises en forme, et ses signalements ; les réponses
// d'une question du dossier de travail ; les secours d'un état illisible ;
// le refus d'un geste de la liste, sans remède ; l'annonce du dossier de
// travail, une fois par séance. Chaque clé que le modèle rend existe dans la
// travail, une fois par séance ; les tuiles par état du plan, le résumé de
// la liste, ses groupes et la vue que la séance retient. Chaque clé que le modèle rend existe dans la
// traduction. Les noms d'épreuve sont inventés.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
@ -24,12 +25,20 @@ import { COLONNES, texteCellule } from '../propositions/modele.js';
import {
COLONNES_DU_RETENU,
ETATS,
FILTRES,
GROUPEMENTS,
MEILLEURE,
SAISIES,
champDuRefus,
choixDeGroupement,
cleDEtat,
groupesDeLaListe,
ligneAffichee,
premiereAnnonce,
resumeDeLaListe,
retenirVue,
tuilesDeLaListe,
vueDeLaListe,
refusDeLaListe,
reponsesDe,
saisieDe,
@ -441,3 +450,210 @@ describe('ligneAffichee : la meilleure proposition du classement (§ 5.4, § 5.7
assert.equal(classees, 2);
});
});
// --- La vue de la liste : tuiles, résumé, groupes (§ 8.1, § 9) -------------------
// Une liste de sept lignes, dans l'ordre de la façade : deux brouillons, un
// proposé, un retenu, deux bloqués et un état illisible, entremêlés.
function liste() {
const lisible = (base, etat, participants) => ligne({ base, id: `evt-${base}`, nom: base, etat, participants });
return [
lisible('Nuit des Pervenches', 'bloque', 40),
lisible('Bal des Grisailles', 'brouillon', 12),
ligne({ base: 'Soirée vide', id: null, nom: null, etat: null, participants: null, illisible: { raison: 'VIDE' } }),
lisible('Gala des Ombrelles', 'retenu', 1200),
lisible('Club des Merles', 'brouillon', 0),
lisible('Souper des Hiboux', 'propose', 7),
lisible('Fête des Lucioles', 'bloque', 3),
];
}
const bases = (lignes) => lignes.map(({ base }) => base);
describe('tuilesDeLaListe : une tuile par filtre (§ 8.1, § 9)', () => {
test('Tous, puis les quatre états dans l’ordre du § 9 ; chaque compte, mis en forme ; seule la tuile du filtre est pressée', () => {
assert.deepEqual(FILTRES, ['tous', 'brouillon', 'propose', 'retenu', 'bloque']);
// Un format qui marque ses nombres : sous 1 000, format.nombre(n) vaut
// String(n), et un compte resté brut passerait pour mis en forme.
const marque = { nombre: (n) => `#${n}` };
const tuiles = tuilesDeLaListe(liste(), 'propose', marque);
assert.deepEqual(
tuiles.map(({ filtre, compte, presse }) => [filtre, compte, presse]),
[
['tous', '#7', false],
['brouillon', '#2', false],
['propose', '#1', true],
['retenu', '#1', false],
['bloque', '#2', false],
],
);
});
test('le nom de chaque état est celui de la colonne État ; Tous et chaque aide ont leur clé, qui existe, et l’aide redit le § 9', () => {
const tuiles = tuilesDeLaListe(liste(), 'tous', format);
for (const { filtre, cle, aide } of tuiles) {
if (filtre !== 'tous') assert.equal(cle, cleDEtat(filtre));
assert.ok(t(cle).length > 0, cle);
assert.ok(t(aide).length > 0, aide);
}
assert.equal(t(tuiles[0].cle), 'Tous les événements');
assert.deepEqual(
tuiles.slice(1).map(({ aide }) => t(aide)),
['on prépare', 'des placements existent, aucun n\u{2019}est retenu', 'un placement est en vigueur', 'rien ne peut plus changer'],
);
});
test('la pastille de chaque tuile : celle de son état ; Tous, un statut d’information à l’icône de la liste', () => {
assert.deepEqual(
tuilesDeLaListe(liste(), 'tous', format).map(({ pastille }) => pastille),
[
{ sorte: 'statut', valeur: 'information', icone: 'liste' },
...ETATS.map((etat) => ({ sorte: 'etat', valeur: etat, icone: null })),
],
);
});
test('une liste vide compte zéro partout ; un filtre inconnu lève', () => {
assert.deepEqual(
tuilesDeLaListe([], 'tous', format).map(({ compte }) => compte),
FILTRES.map(() => format.nombre(0)),
);
assert.throws(() => tuilesDeLaListe(liste(), 'illisible', format), RangeError);
assert.throws(() => tuilesDeLaListe(liste(), undefined, format), RangeError);
});
});
describe('resumeDeLaListe : le résumé de l’en-tête (§ 8.1)', () => {
test('le nombre d’événements, et la somme des participants déjà listés : un état illisible n’en apporte aucun', () => {
assert.deepEqual(resumeDeLaListe(liste()), { evenements: 7, participants: 1262 });
assert.deepEqual(resumeDeLaListe([]), { evenements: 0, participants: 0 });
});
test('le texte du résumé accorde chaque compte, et met les nombres en forme', () => {
assert.equal(t('accueil.entete.resume', resumeDeLaListe(liste())), `7 événements \u{00B7} ${format.nombre(1262)} participants au total`);
assert.equal(t('accueil.entete.resume', { evenements: 1, participants: 1 }), '1 événement \u{00B7} 1 participant au total');
assert.equal(t('accueil.entete.resume', { evenements: 1, participants: 0 }), '1 événement \u{00B7} 0 participant au total');
// Le compte des événements passe lui aussi par la mise en forme des nombres.
assert.notEqual(format.nombre(1200), '1200');
assert.equal(t('accueil.entete.resume', { evenements: 1200, participants: 1 }), `${format.nombre(1200)} événements \u{00B7} 1 participant au total`);
});
});
describe('groupesDeLaListe : les groupes du tableau (§ 8.1, § 9)', () => {
test('par état : les groupes dans l’ordre du § 9, puis les états illisibles ; dans chaque groupe, l’ordre de la façade ; un état sans ligne n’a pas de groupe', () => {
const lignes = liste().filter(({ etat }) => etat !== 'propose');
const groupes = groupesDeLaListe(lignes, 'tous', 'etat');
assert.deepEqual(
groupes.map(({ groupe, cle, details }) => [groupe, cle, details]),
[
['brouillon', 'accueil.etat.brouillon', { evenements: 2 }],
['retenu', 'accueil.etat.retenu', { evenements: 1 }],
['bloque', 'accueil.etat.bloque', { evenements: 2 }],
['illisible', 'accueil.groupe.illisible', { evenements: 1 }],
],
);
assert.deepEqual(bases(groupes[0].lignes), ['Bal des Grisailles', 'Club des Merles']);
assert.deepEqual(bases(groupes[2].lignes), ['Nuit des Pervenches', 'Fête des Lucioles']);
assert.deepEqual(bases(groupes[3].lignes), ['Soirée vide']);
assert.equal(groupes[0].lignes[0], lignes[1]);
});
test('la pastille de chaque groupe : celle de son état ; les états illisibles, un avis à l’icône du triangle ; chaque clé existe', () => {
const groupes = groupesDeLaListe(liste(), 'tous', 'etat');
assert.deepEqual(
groupes.map(({ pastille }) => pastille),
[
...ETATS.map((etat) => ({ sorte: 'etat', valeur: etat, icone: null })),
{ sorte: 'statut', valeur: 'avis', icone: 'triangle' },
],
);
for (const { cle, details } of groupes) {
assert.ok(t(cle).length > 0, cle);
assert.ok(t('accueil.groupe.compte', details).length > 0);
}
assert.equal(t('accueil.groupe.compte', { evenements: 1 }), '1 événement');
assert.equal(t('accueil.groupe.compte', { evenements: 2 }), '2 événements');
// Au-delà de 999, le compte prend la mise en forme des nombres.
assert.notEqual(format.nombre(1200), '1200');
assert.equal(t('accueil.groupe.compte', { evenements: 1200 }), `${format.nombre(1200)} événements`);
});
test('un filtre ne retient que son état, illisibles écartés ; sans ligne retenue, aucun groupe', () => {
const groupes = groupesDeLaListe(liste(), 'bloque', 'etat');
assert.deepEqual(
groupes.map(({ groupe }) => groupe),
['bloque'],
);
assert.deepEqual(bases(groupes[0].lignes), ['Nuit des Pervenches', 'Fête des Lucioles']);
assert.deepEqual(groupesDeLaListe(liste().filter(({ etat }) => etat !== 'retenu'), 'retenu', 'etat'), []);
assert.deepEqual(groupesDeLaListe([], 'tous', 'etat'), []);
assert.deepEqual(groupesDeLaListe([], 'tous', 'aucun'), []);
});
test('sans groupement : un seul groupe sans intitulé, dans l’ordre de la façade, filtré comme les autres', () => {
const lignes = liste();
const [seul, ...autres] = groupesDeLaListe(lignes, 'tous', 'aucun');
assert.deepEqual(autres, []);
assert.equal(seul.groupe, null);
assert.equal(seul.cle, null);
assert.equal(seul.pastille, null);
assert.deepEqual(seul.details, { evenements: 7 });
assert.deepEqual(bases(seul.lignes), bases(lignes));
assert.deepEqual(
bases(groupesDeLaListe(lignes, 'brouillon', 'aucun')[0].lignes),
['Bal des Grisailles', 'Club des Merles'],
);
});
test('un filtre, un groupement ou un état de ligne inconnus lèvent : aucune ligne ne disparaît en silence', () => {
assert.throws(() => groupesDeLaListe(liste(), 'illisible', 'etat'), RangeError);
assert.throws(() => groupesDeLaListe(liste(), 'tous', 'date'), RangeError);
assert.throws(() => groupesDeLaListe([ligne({ etat: 'archive' })], 'tous', 'etat'), RangeError);
assert.throws(() => groupesDeLaListe([ligne({ etat: 'archive' })], 'tous', 'aucun'), RangeError);
});
test('choixDeGroupement : par état du plan, puis aucun, chacun avec sa clé, qui existe', () => {
assert.deepEqual(GROUPEMENTS, ['etat', 'aucun']);
const choix = choixDeGroupement();
assert.deepEqual(
choix.map(({ groupement }) => groupement),
GROUPEMENTS,
);
assert.deepEqual(
choix.map(({ cle }) => t(cle)),
['État du plan', 'Aucun groupement'],
);
choix[0].cle = 'autre';
assert.equal(choixDeGroupement()[0].cle, 'accueil.groupement.etat');
});
});
describe('vueDeLaListe : le filtre et le groupement, retenus par la séance, jamais écrits (§ 8.1)', () => {
test('par défaut, tous les événements groupés par état ; une vue retenue se relit pour la même séance, et pour elle seule', () => {
const seance = {};
const autre = {};
assert.deepEqual(vueDeLaListe(seance), { filtre: 'tous', groupement: 'etat' });
retenirVue(seance, { filtre: 'retenu', groupement: 'aucun' });
assert.deepEqual(vueDeLaListe(seance), { filtre: 'retenu', groupement: 'aucun' });
assert.deepEqual(vueDeLaListe(autre), { filtre: 'tous', groupement: 'etat' });
});
test('la vue rendue est une copie : la changer ne change pas ce que la séance retient', () => {
const seance = {};
const vue = vueDeLaListe(seance);
vue.filtre = 'bloque';
assert.deepEqual(vueDeLaListe(seance), { filtre: 'tous', groupement: 'etat' });
const retenue = { filtre: 'propose', groupement: 'etat' };
retenirVue(seance, retenue);
retenue.filtre = 'bloque';
assert.equal(vueDeLaListe(seance).filtre, 'propose');
});
test('une séance absente, un filtre ou un groupement inconnus lèvent, et rien n’est retenu', () => {
const seance = {};
assert.throws(() => vueDeLaListe(null), TypeError);
assert.throws(() => retenirVue('séance', { filtre: 'tous', groupement: 'etat' }), TypeError);
assert.throws(() => retenirVue(seance, { filtre: 'illisible', groupement: 'etat' }), RangeError);
assert.throws(() => retenirVue(seance, { filtre: 'tous', groupement: 'date' }), RangeError);
assert.deepEqual(vueDeLaListe(seance), { filtre: 'tous', groupement: 'etat' });
});
});

View file

@ -11,12 +11,63 @@
// le composant la rend dans son propre nœud, à côté du texte traduit
// (§ 14.6). Un compte s'accorde dans la phrase qui le porte.
import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
// Un compte mis en forme comme les nombres de la liste — espace fine entre
// les milliers —, accordé à sa valeur.
const compteEnForme = (n, singulier, pluriel, texte) => `${creerFormat(texte).nombre(n)} ${n <= 1 ? singulier : pluriel}`;
export const TABLE = new Map([
// --- L'en-tête de la liste (§ 8.1) ----------------------------------------
// evenements, participants : le nombre de lignes et la somme des
// participants qu'elles portent déjà (resumeDeLaListe, modele.js).
[
'accueil.entete.resume',
({ evenements, participants }, texte) =>
`${compteEnForme(evenements, 'événement', 'événements', texte)} \u{00B7} ` +
`${compteEnForme(participants, 'participant', 'participants', texte)} au total`,
],
// La commande de l'en-tête qui mène au champ du nom de la création.
['accueil.entete.creer', 'Créer un événement'],
// --- Les tuiles des filtres par état du plan (§ 8.1, § 9) ------------------
// Le nom du groupe des tuiles ; celui de « tous » ; l'aide de chacune, qui
// redit pour un état sa ligne du tableau du § 9.
['accueil.filtre.groupe', 'Filtrer par état du plan'],
['accueil.filtre.tous', 'Tous les événements'],
['accueil.filtre.aide.tous', 'tous les états'],
['accueil.filtre.aide.brouillon', 'on prépare'],
['accueil.filtre.aide.propose', 'des placements existent, aucun n\u{2019}est retenu'],
['accueil.filtre.aide.retenu', 'un placement est en vigueur'],
['accueil.filtre.aide.bloque', 'rien ne peut plus changer'],
// Le tableau quand le filtre ne retient aucune ligne.
['accueil.filtre.vide', 'Aucune ligne ne porte cet état : choisir « Tous les événements » les montre toutes.'],
// --- Le tableau des événements (§ 8.1) ---------------------------------------
['accueil.liste.titre', 'Événements'],
// Le choix du groupement, et ses deux valeurs.
['accueil.groupement.libelle', 'Grouper par'],
['accueil.groupement.etat', 'État du plan'],
['accueil.groupement.aucun', 'Aucun groupement'],
// L'intitulé d'un groupe : le nom de son état, ou celui des états qui ne
// se lisent pas, puis son compte (evenements).
['accueil.groupe.illisible', 'État illisible'],
['accueil.groupe.compte', ({ evenements }, texte) => compteEnForme(evenements, 'événement', 'événements', texte)],
// Les en-têtes de groupes de colonnes ; la meilleure proposition porte le
// titre du bloc de la génération.
['accueil.entete.evenement', 'Événement'],
['accueil.entete.retenu', 'Placement retenu'],
['accueil.colonne.nom', 'Nom'],
['accueil.colonne.actions', 'Actions'],
// La commande qui ouvre l'événement de la ligne, en lecture.
['accueil.ouvrir', 'Ouvrir'],
// --- La liste (§ 8.1) ----------------------------------------------------
// Les intitulés des informations d'une ligne ; le nom est la commande qui
// Les intitulés des informations d'une ligne : en-têtes des colonnes, et,
// pour la date et le chemin, l'intitulé caché à l'œil qui les précède sous
// le nom, en tête de ligne. Le nom est l'en-tête de la ligne ; « Ouvrir »
// ouvre l'événement.
['accueil.colonne.date', 'Date'],
['accueil.colonne.etat', 'État'],

View file

@ -147,8 +147,9 @@ export const MARQUES = Object.freeze([
marque('echelle', 'echelle', ['sol', 'page-fond']),
marque('fantome', 'fantome', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// Le signe du chargement d'une valeur de la liste des événements (§ 8.1) :
// un arc tracé de l'encre secondaire, sur la page.
marque('chargement', 'page-texte-secondaire', ['page-fond']),
// un arc tracé de l'encre secondaire, dans une cellule du tableau, sur la
// carte.
marque('chargement', 'page-texte-secondaire', ['carte-fond']),
// Le filigrane BROUILLON (§ 11.7) : un texte au trait, par-dessus le
// contenu d'une feuille imprimée, mesuré sur le papier, clair dans les
// deux thèmes.

View file

@ -435,7 +435,8 @@ export const ECRANS = Object.freeze([
async atteindre(contexte) {
// La principale, sa proposition retenue : la ligne montre les deux
// mesures du retenu et la meilleure proposition du classement, et le
// pointeur réel sur l'intitulé de la différence ouvre son infobulle.
// pointeur réel sur l'intitulé de la différence — un en-tête de
// colonne du tableau, une fois pour la liste — ouvre son infobulle.
const principale = chargePrincipale();
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
@ -448,7 +449,7 @@ export const ECRANS = Object.freeze([
'[data-meilleure-collisions]',
].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')),
);
const { x, y } = await centre(await element(m, `${ligne} [data-terme="diffRedondance"]`));
const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="diffRedondance"]'));
await pointer('bouger', x, y);
await attendre("l'infobulle de la différence", () => {
const bulle = m.racine.querySelector('[data-ecran="accueil"] [role="tooltip"]');