From 229a2d4067b48c6f66adc1f47d9e2adfe7ddd12d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 11 Oct 2026 00:58:07 -0400 Subject: [PATCH] [IMP] event list: summary, state filter tiles, table grouped by state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../accueil/Accueil.navigateur.test.js | 478 ++++++++++++-- src/interface/accueil/Accueil.svelte | 254 +++++--- src/interface/accueil/Corbeille.svelte | 62 +- src/interface/accueil/Demonstrations.svelte | 34 +- src/interface/accueil/DossierTravail.svelte | 133 +--- src/interface/accueil/Filtres.svelte | 93 +++ src/interface/accueil/ListeEvenements.svelte | 609 ++++++++++++------ src/interface/accueil/Message.svelte | 100 +++ src/interface/accueil/MessagesDossier.svelte | 107 +++ src/interface/accueil/Operations.svelte | 52 +- src/interface/accueil/Recuperation.svelte | 74 ++- src/interface/accueil/modele.js | 207 +++++- src/interface/accueil/modele.test.js | 218 ++++++- src/interface/libelles/accueil.js | 53 +- src/interface/marques.js | 5 +- test/navigateur/ecrans.js | 5 +- 16 files changed, 1958 insertions(+), 526 deletions(-) create mode 100644 src/interface/accueil/Filtres.svelte create mode 100644 src/interface/accueil/Message.svelte create mode 100644 src/interface/accueil/MessagesDossier.svelte diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index bfc5ed7..27ef9de 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -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); + }); +}); diff --git a/src/interface/accueil/Accueil.svelte b/src/interface/accueil/Accueil.svelte index 409a3ce..382f152 100644 --- a/src/interface/accueil/Accueil.svelte +++ b/src/interface/accueil/Accueil.svelte @@ -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 = null)} /> {:else}
-

{t('coquille.liste')}

-

- {t('coquille.version.libelle')} - {versionVisible(provenance, version, t)} -

+
+
+

{t('coquille.liste')}

+ {#if lignes.length > 0} +

{t('accueil.entete.resume', resumeDeLaListe(lignes))}

+ {/if} +

+ {t('coquille.version.libelle')} + {versionVisible(provenance, version, t)} +

+
+
+ + +
+
+ {#if refusLecture !== null} -

{t(refusLecture.code, refusLecture.details)}

+ +

{t(refusLecture.code, refusLecture.details)}

+
{/if} {#if issue !== null} - {/if} - + {#if lignes.length > 0} + + {/if} -
-

{t('accueil.creation.titre')}

-
-
- - - {#if champRefuse === 'nom'} - - {/if} -
-
- - - {#if champRefuse === 'date'} - - {/if} -
-
- - {#if refusCreation !== null && champRefuse === null} - - {/if} -
-
- -
+ - +
+ +
+
+ + + {#if champRefuse === 'nom'} + + {/if} +
+
+ + + {#if champRefuse === 'date'} + + {/if} +
+
+ + {#if refusCreation !== null && champRefuse === null} + + {/if} +
+
+
- + {#if issue !== null} + + {/if} + + + + +
{/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; } diff --git a/src/interface/accueil/Corbeille.svelte b/src/interface/accueil/Corbeille.svelte index fa25a7e..21dd968 100644 --- a/src/interface/accueil/Corbeille.svelte +++ b/src/interface/accueil/Corbeille.svelte @@ -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(); {#if contenu.length > 0} -
-

{t('accueil.corbeille.titre')}

-

{t('accueil.corbeille.explication')}

-
    - {#each contenu as { dossier, chemin, fichiers } (dossier)} -
  • -

    {chemin}

    -
      - {#each fichiers as nom (nom)} -
    • {nom}
    • - {/each} -
    -
  • - {/each} -
-
+
+ +
    + {#each contenu as { dossier, chemin, fichiers } (dossier)} +
  • +

    {chemin}

    +
      + {#each fichiers as nom (nom)} +
    • {nom}
    • + {/each} +
    +
  • + {/each} +
+
+
{/if} diff --git a/src/interface/accueil/Demonstrations.svelte b/src/interface/accueil/Demonstrations.svelte index 98ef1db..0b6bb01 100644 --- a/src/interface/accueil/Demonstrations.svelte +++ b/src/interface/accueil/Demonstrations.svelte @@ -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 @@ } -
-

{t('accueil.demonstrations.titre')}

-

{t('accueil.demonstrations.explication')}

+
    {#each demonstrations as { cle, nom } (cle)}
  • @@ -72,31 +74,21 @@
  • {/each}
-
+ diff --git a/src/interface/accueil/Filtres.svelte b/src/interface/accueil/Filtres.svelte new file mode 100644 index 0000000..fb3631d --- /dev/null +++ b/src/interface/accueil/Filtres.svelte @@ -0,0 +1,93 @@ + + + + +
+ {#each tuiles as { filtre, cle, aide, compte, presse, pastille } (filtre)} + + {/each} +
+ + diff --git a/src/interface/accueil/ListeEvenements.svelte b/src/interface/accueil/ListeEvenements.svelte index 679dd01..8256f08 100644 --- a/src/interface/accueil/ListeEvenements.svelte +++ b/src/interface/accueil/ListeEvenements.svelte @@ -2,57 +2,83 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> - {#snippet resume(affichee, valeur)} {#if affichee.enAttente}{:else}{valeur}{/if} {/snippet} -{#snippet termeExplique(base, nom, cle, explication, id)} - {@const terme = `${base}:${nom}`} + +{#snippet termeExplique(nom, cle, explication)} expliquer(evenement, terme, explication)} - onmouseleave={() => taire(terme)} - onfocus={(evenement) => expliquer(evenement, terme, explication)} - onblur={() => taire(terme)}>{t(cle)} expliquer(evenement, nom, explication)} + onmouseleave={() => taire(nom)} + onfocus={(evenement) => expliquer(evenement, nom, explication)} + onblur={() => taire(nom)}>{t(cle)} {/snippet} + +{#snippet choixDuGroupement()} + +{/snippet} + {#if lignes.length > 0} -
    - {#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}`} -
  • - -
    -
    -
    {t('accueil.colonne.date')}
    -
    {affichee.date}
    -
    -
    -
    {t('accueil.colonne.etat')}
    -
    {t(affichee.etat)}
    -
    -
    -
    {t('accueil.colonne.participants')}
    -
    {affichee.participants}
    -
    -
    -
    {t('accueil.colonne.modification')}
    -
    {affichee.modification}
    -
    - {#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)} -
    -
    {@render termeExplique(ligne.base, colonne, cle, explication, undefined)}
    -
    {@render resume(affichee, affichee[colonne])}
    -
    + + {#if groupes.length === 0} +

    {t('accueil.filtre.vide')}

    + {:else} +
    + + + + + + + + + + + + + + + + + + {#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)} + + {/each} + + + + + {#each groupes as groupe, rangGroupe (groupe.groupe ?? '')} + + {#if groupe.cle !== null} + + + + {/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} + + + + + + {#each COLONNES_DU_RETENU as { colonne } (colonne)} + + {/each} + + + + + {/each} + {/each} -
    -
    {t('accueil.colonne.chemin')}
    -
    {affichee.chemin}
    -
    - -
    - {@render termeExplique(ligne.base, 'meilleure', MEILLEURE.titre, MEILLEURE.explication, idMeilleure)} -
    -
    -
    {t(MEILLEURE.proposition)}
    -
    {@render resume(affichee, affichee.meilleure.id)}
    -
    -
    -
    - {@render termeExplique( - ligne.base, - 'meilleureCollisions', - MEILLEURE.collisions, - MEILLEURE.explicationCollisions, - undefined, - )} -
    -
    {@render resume(affichee, affichee.meilleure.collisions)}
    -
    -
    -
    - {#each affichee.signalements as { cle, details, noms }, rang (rang)} -
    -

    {t(cle, details)}

    - {#if noms.length > 0} -
      - {#each noms as nom (nom)} -
    • {nom}
    • - {/each} -
    - {/if} -
    - {/each} - {#if refusOuverture !== null && refusOuverture.base === ligne.base} - - {/if} - - - {/each} - -

    {#if enChargement}{t('accueil.chargement')}{/if}

    +
    {t('accueil.entete.evenement')}{t('accueil.entete.retenu')}{@render termeExplique( + 'meilleure', + MEILLEURE.titre, + MEILLEURE.explication, + )}{t('accueil.colonne.actions')}
    {t('accueil.colonne.nom')}{t('accueil.colonne.etat')}{t('accueil.colonne.participants')}{t('accueil.colonne.modification')}{@render termeExplique(colonne, cle, explication)}{t(MEILLEURE.proposition)}{@render termeExplique('meilleureCollisions', MEILLEURE.collisions, MEILLEURE.explicationCollisions)}
    {t('accueil.groupe.compte', groupe.details)}
    + {affichee.nom} + {t('accueil.colonne.date')}{affichee.date}{t('accueil.colonne.chemin')}{affichee.chemin} + {#each affichee.signalements as { cle, details, noms }, rang (rang)} +
    +

    {t(cle, details)}

    + {#if noms.length > 0} +
      + {#each noms as nom (nom)} +
    • {nom}
    • + {/each} +
    + {/if} +
    + {/each} +
    {#if ligne.etat !== null}{:else}{t( + affichee.etat, + )}{/if}{affichee.participants}{affichee.modification}{@render resume(affichee, affichee[colonne])}{@render resume(affichee, affichee.meilleure.id)}{@render resume(affichee, affichee.meilleure.collisions)} +
    + + +
    + {#if refusOuverture !== null && refusOuverture.base === ligne.base} + + {/if} +
    +
    + {/if} +

    {#if enChargement}{t('accueil.chargement')}{/if}

    +
    {/if} diff --git a/src/interface/accueil/Message.svelte b/src/interface/accueil/Message.svelte new file mode 100644 index 0000000..45a50f7 --- /dev/null +++ b/src/interface/accueil/Message.svelte @@ -0,0 +1,100 @@ + + + + +
    + +
    {@render children()}
    +
    + + diff --git a/src/interface/accueil/MessagesDossier.svelte b/src/interface/accueil/MessagesDossier.svelte new file mode 100644 index 0000000..119bd32 --- /dev/null +++ b/src/interface/accueil/MessagesDossier.svelte @@ -0,0 +1,107 @@ + + + + +{#if question !== null || (autre !== null && !lignesPresentes) || importAutre !== null || avis.length > 0} +
    + {#if question !== null} + +
    +

    {t(question.code, question)}

    + {#if !reponseEnCours} +
    + {#each reponses as { choix, cle } (choix)} + repondre(choix)} /> + {/each} +
    + {/if} +
    +
    + {/if} + + {#if autre !== null && !lignesPresentes} + +
    +

    {t('accueil.autre.proposition', { evenements: autre.evenements })}

    +

    {autre.racine.chemin}

    +

    {t('accueil.autre.originaux')}

    + +
    +
    + {/if} + {#if importAutre !== null} + +

    {t('accueil.autre.resultat', importAutre)}

    +
    + {/if} + + {#each avis as { code, details }, rang (rang)} + +

    {t(code, details)}

    +
    + {/each} +
    +{/if} + + diff --git a/src/interface/accueil/Operations.svelte b/src/interface/accueil/Operations.svelte index 8146f11..d394173 100644 --- a/src/interface/accueil/Operations.svelte +++ b/src/interface/accueil/Operations.svelte @@ -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 @@
    {#if saisie === null} - ouvrirSaisie('copier')} /> - ouvrirSaisie('renommer')} /> - + ouvrirSaisie('copier')} /> + ouvrirSaisie('renommer')} /> + {:else}
    @@ -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); + } diff --git a/src/interface/accueil/Recuperation.svelte b/src/interface/accueil/Recuperation.svelte index ad7d45c..1abcf2f 100644 --- a/src/interface/accueil/Recuperation.svelte +++ b/src/interface/accueil/Recuperation.svelte @@ -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 @@

    {t('accueil.recuperation.titre')}

    -

    - {t('accueil.recuperation.fichier')} - {chemin} -

    - - {#if offerts.length > 0} -

    {t('accueil.recuperation.offerte')}

    -
      - {#each offerts as { source, cle } (source)} -
    • - surLaListe(() => seance.ouvrirSecours(base, source))} /> -
    • - {/each} -
    - {:else} -

    {t('accueil.recuperation.aucunSecours')}

    - {/if} - + +
    +

    + {t('accueil.recuperation.fichier')} + {chemin} +

    + + {#if offerts.length > 0} +

    {t('accueil.recuperation.offerte')}

    +
      + {#each offerts as { source, cle }, rang (source)} +
    • + surLaListe(() => seance.ouvrirSecours(base, source))} + /> +
    • + {/each} +
    + {:else} +

    {t('accueil.recuperation.aucunSecours')}

    + {/if} +
    + +
    +
    +
    diff --git a/src/interface/accueil/modele.js b/src/interface/accueil/modele.js index 52d767c..f7d9878 100644 --- a/src/interface/accueil/modele.js +++ b/src/interface/accueil/modele.js @@ -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} 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 })); +} diff --git a/src/interface/accueil/modele.test.js b/src/interface/accueil/modele.test.js index adaadf6..952c21f 100644 --- a/src/interface/accueil/modele.test.js +++ b/src/interface/accueil/modele.test.js @@ -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' }); + }); +}); diff --git a/src/interface/libelles/accueil.js b/src/interface/libelles/accueil.js index d1e448c..a954c87 100644 --- a/src/interface/libelles/accueil.js +++ b/src/interface/libelles/accueil.js @@ -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'], diff --git a/src/interface/marques.js b/src/interface/marques.js index e113ffc..5ac151e 100644 --- a/src/interface/marques.js +++ b/src/interface/marques.js @@ -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. diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index 09a5968..6233fc6 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -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"]');