From c4130d3b5eec69046eb65ce1509b23b958276961 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 11 Oct 2026 00:58:07 -0400 Subject: [PATCH] [IMP] participants, tables, import: counts, membership pills, messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long participant lists were hard to scan by organisation. The list now opens on counts and a legend of memberships, each with its headcount; the grid shows a membership as a pill of its tint, the expected title and the exclusion as icon pills, and row commands wrap without breaking their labels at 1024 px. The import preview and report class each message as refus, avis or information. Editing and shortcuts are as before. Checked: node 144, browser 117, 9 mutations caught. --- FR --- [IMP] participants, tables, import : comptes, pastilles, messages Une longue liste se parcourait mal par organisation. Elle s'ouvre sur des comptes et une légende des appartenances avec leur effectif ; la grille montre l'appartenance en pastille de sa teinte, le titre pressenti et l'exclusion en pastilles à icône, et les commandes de ligne passent à la ligne sans casser leur libellé à 1024 px. L'aperçu et le rapport d'import classent chaque message en refus, avis ou information. L'édition et les raccourcis ne changent pas. Vérifié : node 144, navigateur 117, 9 mutations prises. Assisted-by: Claude Opus 5.5 --- src/interface/import/Apercu.svelte | 141 +++++++---- .../import/Import.navigateur.test.js | 90 ++++++- src/interface/import/Message.svelte | 76 ++++++ src/interface/import/Rapport.svelte | 80 +++++-- src/interface/import/SectionImport.svelte | 153 ++++++------ src/interface/import/modele.js | 43 ++++ src/interface/import/modele.test.js | 36 +++ src/interface/libelles/import.js | 5 + src/interface/libelles/participants.js | 23 +- src/interface/libelles/tables.js | 6 + src/interface/participants/Fiche.svelte | 49 +++- src/interface/participants/Formulaire.svelte | 162 ++++++++++--- src/interface/participants/Grille.svelte | 125 ++++++++-- .../Participants.navigateur.test.js | 226 +++++++++++++++++- .../participants/SectionParticipants.svelte | 190 +++++++++------ src/interface/participants/entete.js | 51 ++++ src/interface/participants/entete.test.js | 107 +++++++++ src/interface/tables/Defaut.svelte | 71 +++++- src/interface/tables/ListeTables.svelte | 213 +++++++++++------ src/interface/tables/SectionTables.svelte | 38 +-- .../tables/Tables.navigateur.test.js | 64 ++++- src/interface/tables/modele.js | 25 ++ src/interface/tables/modele.test.js | 25 ++ 23 files changed, 1607 insertions(+), 392 deletions(-) create mode 100644 src/interface/import/Message.svelte create mode 100644 src/interface/participants/entete.js create mode 100644 src/interface/participants/entete.test.js diff --git a/src/interface/import/Apercu.svelte b/src/interface/import/Apercu.svelte index 46d4220..ead324f 100644 --- a/src/interface/import/Apercu.svelte +++ b/src/interface/import/Apercu.svelte @@ -17,8 +17,16 @@ // choisir(champ, rang) : tranche une ambiguïté, null pour un aperçu figé. // Chaque donnée — nom de fichier, en-tête, cellule, rang, compte — occupe // son propre nœud de texte. + // + // Chaque message est classé (Message, messageDuBloc) : le refus global et + // les lignes refusées en refus, les doublons, les homonymes et les + // cellules sur plusieurs lignes en avis, les orthographes fondues en + // information. Les tableaux prennent le langage des cartes : en-tête sur + // le fond sourd, lignes séparées d'un filet léger. import { getContext } from 'svelte'; - import { COLONNES_DES_LIGNES, motifLigne, nommer } from './modele.js'; + import Pastille from '../primitives/Pastille.svelte'; + import Message from './Message.svelte'; + import { COLONNES_DES_LIGNES, messageDuBloc, motifLigne, nommer } from './modele.js'; let { modele, source, participants = [], refusees = null, choisir = null } = $props(); @@ -28,6 +36,7 @@ const refuseesMontrees = $derived(refusees ?? modele.refusees); const global = $derived(modele.global); + const refusGlobal = messageDuBloc('global');
@@ -51,6 +60,7 @@ {#if global !== null}
+

{t(global.cle, global.details)}

{#if !global.remede.dansLaCause}

{t(global.remede.cle, global.remede.details)}

@@ -125,39 +135,40 @@ {/if} {#if refuseesMontrees.length > 0} - - - - - - - - - - {#each refuseesMontrees as { ligne, code, details } (ligne)} - - - + +
{t('import.apercu.refusees')}
{t('import.apercu.ligne')}{t('import.apercu.motif')}
{format.nombre(ligne)} - {motif(code, details)} - {#if details.participants !== undefined} - {t('import.rapport.designees')} - {#each nommer(participants, details.participants) as personne (personne.id)} - - {#if personne.prenom !== null}{personne.prenom}{/if} - {personne.nom} - - {/each} - {/if} -
+ + + + + - {/each} - -
{t('import.apercu.refusees')}
{t('import.apercu.ligne')}{t('import.apercu.motif')}
+ + + {#each refuseesMontrees as { ligne, code, details } (ligne)} + + {format.nombre(ligne)} + + {motif(code, details)} + {#if details.participants !== undefined} + {t('import.rapport.designees')} + {#each nommer(participants, details.participants) as personne (personne.id)} + + {#if personne.prenom !== null}{personne.prenom}{/if} + {personne.nom} + + {/each} + {/if} + + + {/each} + + + {/if} {#if modele.fusions.length > 0} -
-

{t('import.apercu.fusions')}

+
    {#each modele.fusions as fusion, rang (rang)}
  • @@ -175,12 +186,11 @@
  • {/each}
-
+ {/if} {#if modele.doublons.length > 0} -
-

{t('import.apercu.doublons')}

+
    {#each modele.doublons as doublon, rang (rang)}
  • @@ -200,12 +210,11 @@
  • {/each}
-
+ {/if} {#if modele.homonymes.length > 0} -
-

{t('import.apercu.homonymes')}

+
    {#each modele.homonymes as homonyme (homonyme.ligne)}
  • @@ -220,12 +229,11 @@
  • {/each}
-
+ {/if} {#if modele.multilignes.length > 0} -
-

{t('import.apercu.multilignes')}

+
    {#each modele.multilignes as multiligne (multiligne.ligne)}
  • @@ -234,7 +242,7 @@
  • {/each}
-
+ {/if}
@@ -253,20 +261,31 @@ } dt { + color: var(--couleur-page-texte-secondaire); + font-size: var(--taille-intertitre); font-weight: bold; + letter-spacing: var(--interlettre-capitales); + text-transform: uppercase; + align-self: center; } dd { margin: 0; } + /* Le refus global : la pastille du refus, puis la cause et le remède, de + l'encre de la page, sur le fond du refus. */ .refus { display: flex; flex-direction: column; + align-items: flex-start; gap: var(--espace-1); - padding: var(--espace-1) var(--espace-2); - border-left: 3px solid var(--couleur-refus); - color: var(--couleur-refus); + padding: var(--espace-2) var(--espace-3); + border: 1px solid var(--couleur-refus-filet); + border-left: 3px solid var(--couleur-refus-marque); + border-radius: var(--rayon-2); + background-color: var(--couleur-carte-fond); + color: var(--couleur-page-texte); } .refus p { @@ -274,7 +293,8 @@ } table { - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; } caption { @@ -286,30 +306,47 @@ th, td { padding: var(--espace-1) var(--espace-2); - border: 1px solid var(--couleur-page-filet); + border-bottom: 1px solid var(--couleur-ligne-filet); text-align: left; vertical-align: top; white-space: pre-wrap; } + thead th { + border-top: 1px solid var(--couleur-carte-filet); + border-bottom-color: var(--couleur-page-filet); + background-color: var(--couleur-carte-fond-sourd); + white-space: nowrap; + } + + tbody th { + font-variant-numeric: tabular-nums; + text-align: right; + } + .defile { max-height: 24rem; overflow: auto; + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-2); + } + + .defile thead th { + position: sticky; + top: 0; + border-top: none; } .ambigu { display: flex; flex-wrap: wrap; gap: var(--espace-2); - border: 1px solid var(--couleur-page-filet); - border-radius: var(--rayon-1); + border: 1px solid var(--couleur-avis-filet); + border-radius: var(--rayon-2); + background-color: var(--couleur-avis-fond); } - .bloc h4 { - margin: 0 0 var(--espace-1); - } - - .bloc ul { + ul { margin: 0; padding-left: var(--espace-5); } diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js index 8ddc082..c90972a 100644 --- a/src/interface/import/Import.navigateur.test.js +++ b/src/interface/import/Import.navigateur.test.js @@ -36,7 +36,7 @@ import { SUFFIXES } from '../../stockage/noms.js'; import { VERSION } from '../../version.genere.js'; import { pointer } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; -import { visible } from '../../../test/navigateur/rendu.js'; +import { composantes, visible } from '../../../test/navigateur/rendu.js'; import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url'; import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url'; import urlGrande from '../../../exemples/participants_demo_grande.csv?url'; @@ -892,3 +892,91 @@ describe('les cas du § 10.3', () => { expect(parVariante[1].participants).toEqual(parVariante[0].participants); }); }); + +describe('l’habillage : cartes et messages classés en refus, avis ou information', () => { + // 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 fondDe = (element) => composantes(getComputedStyle(element).backgroundColor); + const nomDe = (region) => texteDe(document.getElementById(region.getAttribute('aria-labelledby'))); + + // La classe d'un message, lue dans son bloc : la pastille dont le texte + // nomme la sorte, sa teinte et son signe. + function exigerSorte(bloc, sorte, lieu) { + expect(bloc, lieu).not.toBeNull(); + const libelle = t(`import.sorte.${sorte}`); + const pastille = [...bloc.querySelectorAll('*')].find((e) => fondDe(e)[3] > 0 && texteDe(e) === libelle); + expect(pastille, `${lieu} : pastille « ${libelle} »`).toBeDefined(); + expect(fondDe(pastille), lieu).toEqual(jeton(`--couleur-${sorte}-fond`)); + expect(pastille.querySelector('svg'), lieu).not.toBeNull(); + expect(composantes(getComputedStyle(pastille).color), lieu).toEqual(jeton('--couleur-page-texte')); + } + + // Chaque bloc de messages d'un conteneur, et la sorte qu'il doit porter. + function exigerBlocs(conteneur, lieu) { + const bloc = (selecteur) => conteneur.querySelector(selecteur)?.closest('section') ?? null; + exigerSorte(bloc('[data-refusees]'), 'refus', `${lieu}, lignes refusées`); + exigerSorte(bloc('[data-doublons]'), 'avis', `${lieu}, doublons`); + exigerSorte(bloc('[data-fusions]'), 'information', `${lieu}, orthographes fondues`); + } + + test('les cas limites : refusées en refus, doublons en avis, fusions en information, dans l’aperçu puis le rapport', async () => { + const e = await ouvrir(); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + exigerBlocs(dans(e, '[data-apercu]'), 'aperçu'); + await importer(e); + exigerBlocs(dans(e, '[data-rapport]'), 'rapport'); + }); + + test('une mise à jour : homonymes et cellule sur deux lignes qui porte le séparateur en avis, dans l’aperçu puis le rapport', async () => { + const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris')] }); + await choisir(e, 'homonymes.csv', enUtf8('nom;prenom;notes\r\nOmbrelle;Aurèle;\r\nPervenche;Théo;"végétarien;\r\nsans noix"\r\n')); + expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour'); + for (const lieu of ['aperçu', 'rapport']) { + const conteneur = dans(e, lieu === 'aperçu' ? '[data-apercu]' : '[data-rapport]'); + exigerSorte(conteneur.querySelector('[data-homonyme="2"]')?.closest('section') ?? null, 'avis', `${lieu}, homonymes`); + exigerSorte(conteneur.querySelector('[data-multiligne="3"]')?.closest('section') ?? null, 'avis', `${lieu}, cellules sur plusieurs lignes`); + if (lieu === 'aperçu') await importer(e); + } + }); + + // Un import n'écrit rien quand la charge ne change pas et qu'il refuse + // des lignes : ici, une personne déjà là, et une ligne sans nom. + test('un import qui n’a rien écrit le dit en information', async () => { + const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris')] }); + await choisir(e, 'inchange.csv', enUtf8('nom;prenom\r\nOmbrelle;Iris\r\n;Théo\r\n')); + expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour'); + const { rendu } = await importer(e); + expect(rendu.libelle).toBeNull(); + const rapport = dans(e, '[data-rapport]'); + const rien = [...rapport.querySelectorAll('section')].find((bloc) => texteDe(bloc).includes(t('import.rapport.rienEcrit'))) ?? null; + exigerSorte(rien, 'information', 'rapport, rien d’écrit'); + }); + + test('un refus global porte la pastille du refus, sa cause et son remède', async () => { + const e = await ouvrir(); + await coller(e, 'Ombrelle\tIris\nPervenche\tThéo'); + const pose = dans(e, '[data-apercu] [data-refus="AUCUN_ENTETE"]'); + exigerSorte(pose, 'refus', 'refus global'); + expect(pose.textContent).toContain(t('import.remede.aucunEnteteCollage')); + }); + + test('la section, l’aperçu et le rapport sont des cartes titrées ; « Choisir un fichier » est l’action principale de la section', async () => { + const e = await ouvrir(); + const regions = () => tous(e, 'section[aria-labelledby]').map(nomDe); + expect(regions()).toEqual([t('coquille.section.import')]); + const choisirUn = commande(e, 'choisirFichier'); + expect(fondDe(choisirUn)).toEqual(jeton('--couleur-action-fond')); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + expect(regions().slice(0, 2)).toEqual([t('coquille.section.import'), t('import.apercu.titre')]); + await importer(e); + expect(regions()).toContain(t('import.rapport.titre')); + expect(regions()).not.toContain(t('import.apercu.titre')); + }); +}); diff --git a/src/interface/import/Message.svelte b/src/interface/import/Message.svelte new file mode 100644 index 0000000..41b92f6 --- /dev/null +++ b/src/interface/import/Message.svelte @@ -0,0 +1,76 @@ + + + + +
+
+ + {#if titre !== null} +

{titre}

+ {/if} +
+ {@render children?.()} +
+ + diff --git a/src/interface/import/Rapport.svelte b/src/interface/import/Rapport.svelte index 9c16e88..d7295f2 100644 --- a/src/interface/import/Rapport.svelte +++ b/src/interface/import/Rapport.svelte @@ -9,13 +9,20 @@ // mise à jour, chacune à son rang, avec les personnes qu'elle désigne. Un // import qui n'a rien écrit le dit. // + // Le rapport est une carte titrée ; ses comptes, des chiffres tabulaires + // en tête de chaque libellé ; un import qui n'a rien écrit le dit en + // information (Message). Ses commandes, celles de la section, viennent + // en pied de carte (commandes, un snippet). + // // rapport : { modele, source, resume, libelle } ; participants : ceux de - // la charge écrite. Les commandes du rapport sont celles de la section. + // la charge écrite. import { getContext } from 'svelte'; + import Carte from '../primitives/Carte.svelte'; import Apercu from './Apercu.svelte'; + import Message from './Message.svelte'; import { refuseesDe } from './modele.js'; - let { rapport, participants } = $props(); + let { rapport, participants, commandes = null } = $props(); const t = getContext('t'); const format = getContext('format'); @@ -32,42 +39,71 @@ const refusees = $derived(refuseesDe(rapport.resume.refusees)); -
-

{t('import.rapport.titre')}

- {#if rapport.libelle === null} -

{t('import.rapport.rienEcrit')}

- {/if} -
    - {#each COMPTES as [nom, cle] (nom)} -
  • - {format.nombre(rapport.resume[nom])} - {t(cle, { n: rapport.resume[nom] })} -
  • - {/each} -
- -
+
+ +
+ {#if rapport.libelle === null} +

{t('import.rapport.rienEcrit')}

+ {/if} +
    + {#each COMPTES as [nom, cle] (nom)} +
  • + {format.nombre(rapport.resume[nom])} + {t(cle, { n: rapport.resume[nom] })} +
  • + {/each} +
+ + {#if commandes !== null} +
{@render commandes()}
+ {/if} +
+
+
diff --git a/src/interface/import/SectionImport.svelte b/src/interface/import/SectionImport.svelte index d18ef4d..ce5546f 100644 --- a/src/interface/import/SectionImport.svelte +++ b/src/interface/import/SectionImport.svelte @@ -15,6 +15,10 @@ // et ne l'ouvre que sur un import que la commande accepterait. // Le rapport lit le résumé que la séance rend. // + // La section est une carte titrée, dont l'en-tête porte la consigne et + // « Choisir un fichier CSV… », son action principale ; l'aperçu, puis le + // rapport, sont des cartes sous elle. + // // Le CSV des refus — de l'aperçu, ou du résumé dans le rapport — vient // d'exporterRefus de src/application/csv.js, dont le motif est celui de // libelle ; la façade l'enregistre sous ‹base›-refus.csv. La détection @@ -23,6 +27,7 @@ import { getContext, untrack } from 'svelte'; import { appliquerCommande } from '../../application/commandes.js'; import { apercevoir, bilanRemplacement, decoder, exporterRefus } from '../../application/csv.js'; + import Carte from '../primitives/Carte.svelte'; import Commande from '../primitives/Commande.svelte'; import Confirmation from '../primitives/Confirmation.svelte'; import Apercu from './Apercu.svelte'; @@ -184,69 +189,75 @@ -
-

{t('coquille.section.import')}

-

{t('import.consigne')}

-
- -
+
+ + {#snippet actions()} + + {/snippet} + {#if source !== null} {#key source} -
-

{t('import.apercu.titre')}

- {#if modele.global === null} -
- {t('import.mode.titre')} - {#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)} - - {/each} -
- {/if} - -
- {#if grille !== null} - - {/if} - {#if modele.global === null} - {#key apercu} - - {/key} - {/if} - {#if modele.refusees.length > 0} - enregistrerRefus(exporterRefus(apercu))} - /> - {/if} - -
- {#if enregistre !== null} -

{t('import.refusEnregistres')} {enregistre}

- {/if} -
+
+ +
+ {#if modele.global === null} +
+ {t('import.mode.titre')} + {#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)} + + {/each} +
+ {/if} + +
+ {#if grille !== null} + + {/if} + {#if modele.global === null} + {#key apercu} + + {/key} + {/if} + {#if modele.refusees.length > 0} + enregistrerRefus(exporterRefus(apercu))} + /> + {/if} + +
+ {#if enregistre !== null} +

{t('import.refusEnregistres')} {enregistre}

+ {/if} +
+
+
{/key} {/if} {#if rapport !== null} - -
- {#if rapport.resume.refusees.length > 0} - enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))} - /> - {/if} - (rapport = null)} /> -
- {#if enregistre !== null} -

{t('import.refusEnregistres')} {enregistre}

- {/if} + + {#snippet commandes()} +
+ {#if rapport.resume.refusees.length > 0} + enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))} + /> + {/if} + (rapport = null)} /> +
+ {#if enregistre !== null} +

{t('import.refusEnregistres')} {enregistre}

+ {/if} + {/snippet} +
{/if} {#if confirmation !== null} @@ -257,27 +268,23 @@ surAnnuler={() => (confirmation = null)} /> {/if} -
+
diff --git a/src/interface/import/modele.js b/src/interface/import/modele.js index 72c2c18..6c6efd6 100644 --- a/src/interface/import/modele.js +++ b/src/interface/import/modele.js @@ -58,6 +58,49 @@ export const MODES_D_IMPORT = Object.freeze( ].map(([mode, cle]) => Object.freeze({ mode, cle })), ); +/** Les trois sortes d'un message de l'aperçu et du rapport, chacune un ton + * de pastille (Pastille, sorte 'statut'). */ +export const SORTES_DE_MESSAGE = Object.freeze(['refus', 'avis', 'information']); + +// L'icône de chaque sorte, distincte des deux autres : la sorte se lit au +// libellé de sa pastille, et l'icône la redit sans la couleur. +const ICONES_DE_SORTE = new Map([ + ['refus', 'interdit'], + ['avis', 'triangle'], + ['information', 'information'], +]); + +// La sorte de chaque bloc de messages. Un refus écarte des lignes, ou tout +// l'import ; un avis demande à l'opérateur de vérifier ou de trancher ce +// que le logiciel ne fusionne jamais seul — doublons, homonymes d'une mise +// à jour —, ou une cellule sur plusieurs lignes qui porte le séparateur, +// peut-être un guillemet mal fermé ; une information dit ce qui a été fait +// ou lu sans rien écarter. +const SORTES_DES_BLOCS = new Map([ + ['global', 'refus'], + ['refusees', 'refus'], + ['doublons', 'avis'], + ['homonymes', 'avis'], + ['multilignes', 'avis'], + ['fusions', 'information'], + ['rienEcrit', 'information'], +]); + +/** + * La classe d'un bloc de messages de l'aperçu ou du rapport. + * + * @param {string} bloc 'global', 'refusees', 'doublons', 'homonymes', + * 'fusions', 'multilignes' ou 'rienEcrit' + * @returns {{sorte: string, cle: string, icone: string}} la sorte, la clé + * de son libellé et l'icône de sa pastille + * @throws {RangeError} un bloc inconnu + */ +export function messageDuBloc(bloc) { + const sorte = SORTES_DES_BLOCS.get(bloc); + if (sorte === undefined) throw new RangeError(`bloc de messages inconnu : ${JSON.stringify(bloc)}`); + return { sorte, cle: `import.sorte.${sorte}`, icone: ICONES_DE_SORTE.get(sorte) }; +} + /** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de * CHAMPS, et la clé de son nom. */ export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) }))); diff --git a/src/interface/import/modele.test.js b/src/interface/import/modele.test.js index 0e49482..1f9ee35 100644 --- a/src/interface/import/modele.test.js +++ b/src/interface/import/modele.test.js @@ -19,7 +19,9 @@ import { ENCODAGES, MODES_D_IMPORT, SEPARATEURS, + SORTES_DE_MESSAGE, avecEntetesDeLaGrille, + messageDuBloc, modeleApercu, modePropose, motifLigne, @@ -406,3 +408,37 @@ describe('les titres que « Remplacer » annonce (§ 10.1, § 4.1)', () => { }); } }); + +describe('messageDuBloc : chaque message de l’aperçu et du rapport classé en refus, avis ou information', () => { + test('les refus : le refus global et les lignes refusées', () => { + for (const bloc of ['global', 'refusees']) { + assert.deepEqual(messageDuBloc(bloc), { sorte: 'refus', cle: 'import.sorte.refus', icone: 'interdit' }, bloc); + } + }); + + test('les avis : les doublons et les homonymes, que rien ne fusionne seul, et les cellules sur plusieurs lignes qui portent le séparateur, à vérifier', () => { + for (const bloc of ['doublons', 'homonymes', 'multilignes']) { + assert.deepEqual(messageDuBloc(bloc), { sorte: 'avis', cle: 'import.sorte.avis', icone: 'triangle' }, bloc); + } + }); + + test('les informations : les orthographes fondues, un import qui n’a rien écrit', () => { + for (const bloc of ['fusions', 'rienEcrit']) { + assert.deepEqual(messageDuBloc(bloc), { sorte: 'information', cle: 'import.sorte.information', icone: 'information' }, bloc); + } + }); + + test('un bloc inconnu lève RangeError, en le nommant', () => { + assert.throws(() => messageDuBloc('fusion'), { name: 'RangeError', message: /"fusion"/ }); + }); + + test('trois sortes, chacune un ton de pastille, un libellé traduit et une icône distincte', () => { + assert.deepEqual(SORTES_DE_MESSAGE, ['refus', 'avis', 'information']); + const blocs = ['global', 'refusees', 'doublons', 'homonymes', 'fusions', 'multilignes', 'rienEcrit'].map(messageDuBloc); + assert.equal(new Set(blocs.map(({ icone }) => icone)).size, 3); + assert.deepEqual( + SORTES_DE_MESSAGE.map((sorte) => t(`import.sorte.${sorte}`)), + ['Refus', 'Avis', 'Information'], + ); + }); +}); diff --git a/src/interface/libelles/import.js b/src/interface/libelles/import.js index 0335d2f..5a7ea9e 100644 --- a/src/interface/libelles/import.js +++ b/src/interface/libelles/import.js @@ -24,6 +24,11 @@ export const TABLE = new Map([ ['import.commande.fermerRapport', 'Fermer le rapport'], ['import.refusEnregistres', 'Lignes refusées enregistrées :'], + // --- La sorte d'un message de l'aperçu et du rapport (messageDuBloc) ------ + ['import.sorte.refus', 'Refus'], + ['import.sorte.avis', 'Avis'], + ['import.sorte.information', 'Information'], + // --- Le mode de l'import (§ 10.1) ------------------------------------------- ['import.mode.titre', "Mode de l'import"], ['import.mode.ajouter', 'Ajouter'], diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js index 98e0a09..99f9547 100644 --- a/src/interface/libelles/participants.js +++ b/src/interface/libelles/participants.js @@ -1,8 +1,13 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Les libellés de la liste des participants : grille, formulaire, fiche (§ -// 14.6). Chaque clé commence par « participants. ». +// Les libellés de la liste des participants : en-tête, grille, formulaire, +// fiche (§ 14.6). Chaque clé commence par « participants. ». +// +// participants.entete.comptes compose la ligne de comptes de la carte +// (comptesEntete, entete.js), ses parties séparées d'un point médian ; +// participants.legende.* nomme la légende des appartenances et chacune de +// ses pastilles, le nom puis le compte. // // participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans // l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle @@ -51,6 +56,20 @@ export const TABLE = new Map([ ['participants.colonne.exclu', 'Exclusion'], ['participants.colonne.notes', 'Notes'], + // --- L'en-tête de la carte : la ligne de comptes et la légende ----------- + [ + 'participants.entete.comptes', + ({ personnes, aPlacer, exclues, appartenances }) => + [ + compte(personnes, 'personne', 'personnes'), + `${aPlacer} à placer`, + compte(exclues, 'exclue', 'exclues'), + compte(appartenances, 'appartenance', 'appartenances'), + ].join(' \u{00B7} '), + ], + ['participants.legende.titre', 'Appartenances'], + ['participants.legende.element', ({ nom, compte: n }) => `${nom} \u{00B7} ${n}`], + // --- Le formulaire d'ajout (§ 10.2) --------------------------------------- ['participants.formulaire.titre', 'Ajouter une personne'], ['participants.formulaire.ajouter', 'Ajouter'], diff --git a/src/interface/libelles/tables.js b/src/interface/libelles/tables.js index 152db9e..05ec5d7 100644 --- a/src/interface/libelles/tables.js +++ b/src/interface/libelles/tables.js @@ -80,6 +80,12 @@ export const TABLE = new Map([ // --- La liste des tables (§ 4, § 6.1, § 6.2) ---------------------------------- ['tables.liste.titre', 'Tables'], + // L'en-tête de la liste : le nombre de tables et le total de leurs sièges + // (comptesTables), séparés d'un point médian. + [ + 'tables.liste.comptes', + ({ tables, sieges }) => `${compte(tables, 'table', 'tables')} \u{00B7} ${compte(sieges, 'siège', 'sièges')}`, + ], ['tables.liste.aucune', 'Aucune table : ajoutez-en une, ronde ou carrée.'], ['tables.liste.table', 'Table'], ['tables.liste.places', 'Places'], diff --git a/src/interface/participants/Fiche.svelte b/src/interface/participants/Fiche.svelte index f8050c7..7b8b4b0 100644 --- a/src/interface/participants/Fiche.svelte +++ b/src/interface/participants/Fiche.svelte @@ -15,6 +15,10 @@ // tour par tour dans etatPlaces : la fiche ne recalcule ni l'un ni // l'autre, et ne porte aucun contrôle de saisie. // + // La fiche est une carte : fond et filet de la carte, rayon de 12 px, + // ombre légère ; ses étiquettes et légendes de tableau en petites + // capitales d'encre secondaire. + // // Ouverte, ou passée à une autre personne, la fiche se fait défiler // jusque dans la fenêtre : dans une fenêtre étroite, elle se pose sous la // grille. @@ -136,10 +140,11 @@ box-sizing: border-box; width: 24rem; max-width: 100%; - padding: var(--espace-3); - border: 1px solid var(--couleur-page-filet); - border-radius: var(--rayon-2); - background-color: var(--couleur-page-fond); + padding: var(--espace-4) var(--espace-5); + 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-size: var(--taille-texte); line-height: 1.4; @@ -156,24 +161,32 @@ align-items: flex-start; justify-content: space-between; gap: var(--espace-2); + padding-bottom: var(--espace-3); + border-bottom: 1px solid var(--couleur-carte-filet); } h3 { flex: 1; min-width: 0; margin: 0; - font-size: var(--taille-titre); + font-size: var(--taille-titre-carte); } dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); - gap: var(--espace-1) var(--espace-3); + align-items: baseline; + gap: var(--espace-2) var(--espace-3); margin: var(--espace-3) 0; } - dt { + dt, + caption { + color: var(--couleur-page-texte-secondaire); + font-size: var(--taille-intertitre); font-weight: bold; + letter-spacing: var(--interlettre-capitales); + text-transform: uppercase; } dd { @@ -184,37 +197,47 @@ table { width: 100%; - margin: var(--espace-2) 0; + margin: var(--espace-3) 0; border-collapse: collapse; table-layout: fixed; } caption { - font-weight: bold; + padding-bottom: var(--espace-1); text-align: left; } th, td { - padding: var(--espace-1); - border-bottom: 1px solid var(--couleur-page-filet); + padding: var(--espace-1) var(--espace-2); + border-bottom: 1px solid var(--couleur-ligne-filet); text-align: left; } + th { + border-bottom-color: var(--couleur-carte-filet); + background-color: var(--couleur-carte-fond-sourd); + } + p { margin: var(--espace-2) 0; + color: var(--couleur-page-texte-secondaire); } 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; cursor: pointer; } + button:hover { + background-color: var(--couleur-carte-fond-sourd); + } + button:focus-visible { outline: 2px solid var(--couleur-focus); outline-offset: 2px; diff --git a/src/interface/participants/Formulaire.svelte b/src/interface/participants/Formulaire.svelte index 4be701c..784dcca 100644 --- a/src/interface/participants/Formulaire.svelte +++ b/src/interface/participants/Formulaire.svelte @@ -17,6 +17,11 @@ // contre son champ, avec l'orthographe existante à reprendre d'un clic ; // rien ne fusionne seul. // + // Le signal et chaque refus, apparus, se font défiler jusque dans la + // fenêtre (montrer) : sous la bande de tête d'une carte, dans une fenêtre + // étroite, ils tomberaient sinon sous le bord, loin du champ ou de la + // commande qu'ils commentent. + // // Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est // un geste ouvert (geste.js), qui suspend le compte d'inactivité. // @@ -27,12 +32,18 @@ // que l'opérateur tape pendant l'écriture, la personne suivante, reste. // Le focus revient au nom quand rien n'a été tapé entre-temps. // + // Le formulaire est la bande de tête de la carte des participants : fond + // sourd, champs en grille, « Ajouter » en action principale dans la + // dernière case de la grille, au pied des champs ; sur une fenêtre large, + // la bande tient en une rangée. + // // Les notes sont une zone de texte, qui garde les sauts de ligne : // Maj+Entrée y insère un saut de ligne, Entrée seule envoie // (entreeValide, grille.js). import { getContext, onDestroy, tick, untrack } from 'svelte'; import { cleNormalisee } from '../../application/csv.js'; import { creerGeste } from '../primitives/geste.js'; + import Icone from '../primitives/Icone.svelte'; import Refus from '../primitives/Refus.svelte'; import { effetDe } from '../raccourcis.js'; import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js'; @@ -119,6 +130,11 @@ formulaire.requestSubmit(); } + // Fait défiler l'élément monté jusque dans la fenêtre, au plus court. + function montrer(element) { + element.scrollIntoView({ block: 'nearest' }); + } + function reprendre() { valeurs.appartenance = orthographe; } @@ -130,7 +146,7 @@

{t('participants.formulaire.titre')}

{#each CHAMPS as cle (cle)} -
+
{#if cle === 'nom'} {#if refusNom !== null} - (refusNom = null)} /> +
+ (refusNom = null)} /> +
{/if} {:else if cle === 'appartenance'} {#if orthographe !== null} -
+
+

{t('participants.formulaire.orthographe')} {orthographe}

{/each} -
-
- - {#if refusAjout !== null} - (refusAjout = null)} /> - {/if} +
+ + {#if refusAjout !== null} +
+ (refusAjout = null)} /> +
+ {/if} +
diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte index 5e90c0a..3acb7a6 100644 --- a/src/interface/participants/Grille.svelte +++ b/src/interface/participants/Grille.svelte @@ -47,6 +47,13 @@ // la cellule d'exclusion suit sa ligne de même : la personne exclue ou // réintégrée passe dans son nouveau groupe, et sa cellule garde le focus. // + // L'appartenance s'affiche en pastille de la teinte que teintes lui donne + // (teintesAppartenances, calculée par la section sur la liste entière), + // neutre au-delà de la palette ; le titre pressenti en pastille + // d'information à étoile ; « exclue » en pastille neutre à croix, + // « présente » en texte secondaire. Chaque pastille porte la valeur ou le + // libellé en texte, et garde les blancs de la donnée comme la cellule. + // // Les conflits du placement montré (conflits, clé d'un groupe → ses // lignes de conflit ; enConflit, identifiant → ses règles), que la section // ne donne que groupée : l'en-tête d'un groupe en conflit, de tout niveau, @@ -59,6 +66,7 @@ import { getContext, onDestroy, tick } from 'svelte'; import { creerMachineChamp } from '../primitives/champ.js'; import Commande from '../primitives/Commande.svelte'; + import Pastille from '../primitives/Pastille.svelte'; import { creerGeste } from '../primitives/geste.js'; import Refus from '../primitives/Refus.svelte'; import { effetDe } from '../raccourcis.js'; @@ -68,6 +76,7 @@ let { lignes, groupes = null, + teintes = new Map(), replies = $bindable(new Set()), tri = $bindable(null), supprimer, @@ -399,8 +408,18 @@ onblur={surSortie} /> {:else if colonne === 'exclu'} - - {t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')} + {#if personne.exclu} + + {:else} + {t('participants.grille.presente')} + {/if} + {:else if colonne === 'appartenance' && personne.appartenance !== null} + + + + {:else if colonne === 'titrePressenti' && personne.titrePressenti !== null} + + {:else} {personne[colonne] ?? ''} @@ -538,17 +557,44 @@ overflow-x: auto; } + /* Le tableau d'une carte : en-tête sur le fond sourd, lignes séparées + d'un filet léger, sans filet vertical. */ + /* Au-dessous de 59rem, le tableau défile dans son conteneur plutôt que de + replier ses cellules caractère par caractère sous des actions d'une + rangée. Le plancher tient dans la carte pleine largeur d'une fenêtre de + 1024 px ; la liste qui la porte (SectionParticipants) réclame un peu + plus, pour que la fiche passe dessous plutôt que de le rogner. */ table { - border-collapse: collapse; + width: 100%; + min-width: 59rem; + border-collapse: separate; + border-spacing: 0; font-size: var(--taille-texte); } th, td { - padding: var(--espace-1) var(--espace-2); - border: 1px solid var(--couleur-page-filet); + padding: var(--espace-2) var(--espace-3); + border-bottom: 1px solid var(--couleur-ligne-filet); text-align: left; - vertical-align: top; + vertical-align: middle; + } + + th:first-child, + td:first-child { + padding-left: var(--espace-5); + } + + th:last-child, + td:last-child { + padding-right: var(--espace-5); + } + + thead th { + border-top: 1px solid var(--couleur-carte-filet); + border-bottom-color: var(--couleur-page-filet); + background-color: var(--couleur-carte-fond-sourd); + white-space: nowrap; } td { @@ -556,13 +602,32 @@ overflow-wrap: anywhere; } + td[data-champ='nom'] { + font-weight: bold; + } + + td[data-champ='notes'] { + color: var(--couleur-page-texte-secondaire); + } + .valeur { white-space: pre-wrap; } - /* L'exclusion se lit en texte ; l'italique la double, sans couleur. */ - .exclue { - font-style: italic; + /* La pastille d'une donnée garde ses blancs, comme la cellule de texte : + deux orthographes qui ne diffèrent que par eux restent distinctes. */ + .donnee-pastille { + display: flex; + min-width: 0; + } + + .donnee-pastille :global(.libelle) { + white-space: pre-wrap; + } + + .presente { + color: var(--couleur-page-texte-secondaire); + font-size: var(--taille-texte-petit); } th button { @@ -581,8 +646,12 @@ font-weight: normal; } - /* L'en-tête d'un groupe : un bouton qui porte son état en texte, le - libellé du groupe en gras et son compte. */ + /* L'en-tête d'un groupe : une ligne sur le fond sourd, un bouton qui porte + son état en texte, le libellé du groupe en gras et son compte. */ + .groupe th { + background-color: var(--couleur-carte-fond-sourd); + } + .groupe button { display: inline-flex; flex-wrap: wrap; @@ -590,8 +659,8 @@ gap: var(--espace-2); padding: var(--espace-1) var(--espace-2); border: 1px solid var(--couleur-page-filet); - border-radius: var(--rayon-1); - background-color: var(--couleur-page-fond); + border-radius: var(--rayon-2); + background-color: var(--couleur-carte-fond); color: var(--couleur-page-texte); font: inherit; text-align: left; @@ -633,6 +702,10 @@ overflow-wrap: anywhere; } + .compte { + color: var(--couleur-page-texte-secondaire); + } + /* La marque d'un conflit : un cadre plein, de l'encre du texte, autour d'un texte qui nomme le conflit ; la forme ne dépend d'aucune couleur. */ .marque-conflit { @@ -640,7 +713,7 @@ padding: 0 var(--espace-1); border: 2px solid var(--couleur-page-texte); border-radius: var(--rayon-1); - background-color: var(--couleur-page-fond); + background-color: var(--couleur-carte-fond); color: var(--couleur-page-texte); font-weight: bold; } @@ -650,6 +723,13 @@ padding-left: var(--espace-5); } + /* Les actions d'une ligne vont sur une rangée, qui passe à la ligne + quand la colonne est étroite ; chaque action garde son libellé sur une + ligne : un mot ne se coupe jamais entre deux lettres. */ + td:last-child { + max-width: none; + } + .commandes { display: flex; flex-wrap: wrap; @@ -657,24 +737,33 @@ gap: var(--espace-1); } + .commandes > :global(button) { + white-space: nowrap; + } + + /* Les actions de ligne : des boutons secondaires, comme Commande. */ .commandes > 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; cursor: pointer; } + .commandes > button:hover { + background-color: var(--couleur-carte-fond-sourd); + } + input, textarea { width: 100%; box-sizing: border-box; padding: var(--espace-1); 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; } diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js index 6edb346..61b28bc 100644 --- a/src/interface/participants/Participants.navigateur.test.js +++ b/src/interface/participants/Participants.navigateur.test.js @@ -31,10 +31,12 @@ import { SUFFIXES } from '../../stockage/noms.js'; import { formeNommee } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; import { monterApp } from '../../../test/navigateur/montage.js'; -import { pointer, touche } from '../../../test/navigateur/entrees.js'; -import { visible } from '../../../test/navigateur/rendu.js'; +import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js'; +import { composantes, sousTheme, visible } from '../../../test/navigateur/rendu.js'; import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url'; +import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js'; import { TABLES_INTERFACE } from '../libelles.js'; +import { comptesEntete } from './entete.js'; import { COLONNES, GROUPEMENTS, cheminDe, cleDeGroupement, cleDeStatut, groupesGrille, lignesGrille, lignesVisibles } from './grille.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -1704,3 +1706,223 @@ describe('la liste groupée par une colonne ou plusieurs', () => { }); }); }); + +// --- L'habillage : carte, bandes, légende et pastilles ---------------------- + +// 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 fondDe = (element) => composantes(getComputedStyle(element).backgroundColor); +const opaque = (element) => fondDe(element)[3] > 0; +const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); + +// La pastille d'un conteneur : le premier élément qu'il contient dont le +// fond est peint, ou null. +const pastilleDans = (conteneur) => [...conteneur.querySelectorAll('*')].find(opaque) ?? null; + +// La région titrée de la section : la carte des participants. +const carte = (m) => section(m).querySelector('section[aria-labelledby]'); +const legende = (m) => section(m).querySelector(`[aria-label="${t('participants.legende.titre')}"]`); + +// Sept appartenances — une de plus que la palette —, une exclue, une +// personne sans appartenance et une au titre pressenti. +const SEPT = [ + { nom: 'Quenouillet', appartenance: 'Atelier Pivoine' }, + { nom: 'Brindelle', appartenance: 'Banc des Loutres', exclu: true }, + { nom: 'Calvaille', appartenance: 'Cercle des Grives', titre: 'hôtesse' }, + { nom: 'Doucemer', appartenance: 'Cercle des Grives' }, + { nom: 'Esquirol', appartenance: 'Dune des Hérons' }, + { nom: 'Faverolle', appartenance: 'Étang des Sarcelles' }, + { nom: 'Glanduc', appartenance: 'Forge des Martres' }, + { nom: 'Houblonnet', appartenance: 'Grange des Mulots' }, + { nom: 'Isardin' }, +]; + +describe('l’habillage de la liste : carte, bandes, légende, pastilles', () => { + test('la carte « Participants » : sa ligne de comptes suit la liste, le formulaire d’ajout en est la bande de tête, sur le fond sourd, avant la grille', async () => { + const m = await evenementOuvert({ personnes: SEPT }); + const region = carte(m); + expect(region).not.toBeNull(); + expect(texteDe(document.getElementById(region.getAttribute('aria-labelledby')))).toBe(t('coquille.section.participants')); + const ligneDeComptes = () => [...region.querySelectorAll('p')].map((p) => p.textContent); + expect(comptesEntete(participants(m))).toEqual({ personnes: 9, aPlacer: 8, exclues: 1, appartenances: 7 }); + expect(ligneDeComptes()).toContain(t('participants.entete.comptes', comptesEntete(participants(m)))); + + await m.seance.executer('exclureParticipant', { id: idDe(m, 'Isardin') }); + await attendreSeance(m); + expect(ligneDeComptes()).toContain(t('participants.entete.comptes', { personnes: 9, aPlacer: 7, exclues: 2, appartenances: 7 })); + + expect(region.contains(formulaire(m))).toBe(true); + expect(fondDe(formulaire(m))).toEqual(jeton('--couleur-carte-fond-sourd')); + expect(formulaire(m).compareDocumentPosition(grille(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // « Ajouter » est l'action principale de la carte : pleine, haute d'une cible. + const ajout = formulaire(m).querySelector('[data-commande="ajouter"]'); + expect(fondDe(ajout)).toEqual(jeton('--couleur-action-fond')); + expect(composantes(getComputedStyle(ajout).color)).toEqual(jeton('--couleur-action-texte')); + expect(ajout.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + // « Exporter la liste » est dans l'en-tête de la carte, avant le formulaire. + const exporter = region.querySelector('[data-commande="exporter"]'); + expect(exporter.compareDocumentPosition(formulaire(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + test('la légende : une pastille par appartenance, dans l’ordre des teintes, avec son compte, la teinte de son rang ; un filtre ne la change pas', async () => { + const m = await evenementOuvert({ personnes: SEPT }); + const teintes = teintesAppartenances(participants(m)); + const attendue = [...teintes].map(([nom, rang]) => ({ + texte: t('participants.legende.element', { nom, compte: participants(m).filter((p) => p.appartenance === nom).length }), + rang, + })); + expect(attendue.map(({ rang }) => rang)).toEqual([1, 2, 3, 4, 5, 6, null]); + const lue = () => + [...legende(m).querySelectorAll('li')].map((li) => ({ texte: texteDe(li), fond: fondDe(pastilleDans(li)) })); + const peinte = attendue.map(({ texte, rang }) => ({ + texte, + fond: jeton(rang === null ? '--couleur-neutre-fond' : `--couleur-appartenance-${rang}-fond`), + })); + expect(lue()).toEqual(peinte); + expect(lue()[2].texte).toBe('Cercle des Grives \u{00B7} 2'); + + await saisir(section(m).querySelector('[data-champ="filtre"]'), 'Grives'); + await rendu(); + expect(idsAffiches(m)).toHaveLength(2); + expect(lue()).toEqual(peinte); + }); + + test('la grille : appartenance en pastille de sa teinte, stable sous le filtre ; « exclue » et le titre pressenti en pastilles à icône ; encre de la page', async () => { + const m = await evenementOuvert({ personnes: SEPT }); + const teintes = teintesAppartenances(participants(m)); + const encre = jeton('--couleur-page-texte'); + for (const p of participants(m)) { + const pastille = pastilleDans(cellule(m, p.id, 'appartenance')); + if (p.appartenance === null) { + expect(pastille, p.nom).toBeNull(); + continue; + } + const rang = teintes.get(p.appartenance); + expect(texteDe(pastille), p.nom).toBe(p.appartenance); + expect(fondDe(pastille), p.nom).toEqual(jeton(rang === null ? '--couleur-neutre-fond' : `--couleur-appartenance-${rang}-fond`)); + expect(composantes(getComputedStyle(pastille).color), p.nom).toEqual(encre); + } + const loutres = idDe(m, 'Brindelle'); + const exclue = pastilleDans(cellule(m, loutres, 'exclu')); + expect(texteDe(exclue)).toBe(t('participants.grille.exclue')); + expect(fondDe(exclue)).toEqual(jeton('--couleur-neutre-fond')); + expect(exclue.querySelector('svg')).not.toBeNull(); + const presente = cellule(m, idDe(m, 'Doucemer'), 'exclu'); + expect(texteDe(presente)).toBe(t('participants.grille.presente')); + expect(pastilleDans(presente)).toBeNull(); + const titre = pastilleDans(cellule(m, idDe(m, 'Calvaille'), 'titrePressenti')); + expect(texteDe(titre)).toBe('hôtesse'); + expect(fondDe(titre)).toEqual(jeton('--couleur-information-fond')); + expect(titre.querySelector('svg')).not.toBeNull(); + expect(pastilleDans(cellule(m, idDe(m, 'Doucemer'), 'titrePressenti'))).toBeNull(); + + // Le filtre ne repeint rien : la teinte se lit sur la liste entière. + const grives = idDe(m, 'Doucemer'); + const avant = fondDe(pastilleDans(cellule(m, grives, 'appartenance'))); + await saisir(section(m).querySelector('[data-champ="filtre"]'), 'Doucemer'); + await rendu(); + expect(idsAffiches(m)).toEqual([grives]); + expect(fondDe(pastilleDans(cellule(m, grives, 'appartenance')))).toEqual(avant); + + // En sombre, la pastille prend la teinte sombre du même rang. + await sousTheme('sombre', async () => { + expect(fondDe(pastilleDans(cellule(m, grives, 'appartenance')))).toEqual(jeton(`--couleur-appartenance-${teintes.get('Cercle des Grives')}-fond`)); + }); + expect(RANGS_APPARTENANCE).toBe(6); + }); + + test('les actions de ligne sont des boutons secondaires : fond de la carte, filet de la page, rayon de 8 px', async () => { + const m = await evenementOuvert({ personnes: SEPT.slice(0, 2) }); + const id = idDe(m, 'Quenouillet'); + for (const nom of ['fiche', 'voirSurLePlan', 'exclureParticipant', 'supprimerParticipant']) { + const bouton = commandeDeLigne(m, id, nom); + const style = getComputedStyle(bouton); + expect(fondDe(bouton), nom).toEqual(jeton('--couleur-carte-fond')); + expect(composantes(style.borderTopColor), nom).toEqual(jeton('--couleur-page-filet')); + expect(style.borderTopLeftRadius, nom).toBe('8px'); + } + }); + + test('la fiche est une carte : fond de la carte, filet de la carte, rayon de 12 px', async () => { + const m = await evenementOuvert({ personnes: SEPT.slice(0, 2) }); + m.ui.ouvrirFiche(idDe(m, 'Quenouillet')); + await rendu(); + const style = getComputedStyle(fiche(m)); + expect(fondDe(fiche(m))).toEqual(jeton('--couleur-carte-fond')); + expect(composantes(style.borderTopColor)).toEqual(jeton('--couleur-carte-filet')); + expect(style.borderTopLeftRadius).toBe('12px'); + }); +}); + +describe('l’habillage dans le cadre : rien ne déborde, rien ne se cache sous le bord', () => { + // Pose le cadre largeur × hauteur le temps de mesure, puis rend celui d'avant. + async function dansLeCadre(largeur, hauteur, mesure) { + const avant = { largeur: innerWidth, hauteur: innerHeight }; + await fixerCadre(largeur, hauteur); + try { + await mesure(); + } finally { + await fixerCadre(avant.largeur, avant.hauteur); + } + } + + for (const largeur of [1366, 1024]) { + test(`à ${largeur} × 700 : la page ne défile pas en largeur, la carte tient dans la fenêtre, la grille dans la carte`, async () => { + await dansLeCadre(largeur, 700, async () => { + const m = await evenementOuvert({ personnes: SEPT }); + m.ui.ouvrirFiche(idDe(m, 'Calvaille')); + await rendu(); + expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth); + const region = carte(m).getBoundingClientRect(); + expect(region.right).toBeLessThanOrEqual(innerWidth); + expect(fiche(m).getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth); + // Le conteneur de défilement tient dans la carte, et le tableau dans + // le conteneur : aucune colonne, celle des actions de ligne comprise, + // ne se cache au-delà de son bord. + const defilement = grille(m).parentElement; + expect(defilement.getBoundingClientRect().right).toBeLessThanOrEqual(region.right); + expect(defilement.scrollWidth, 'le tableau défile en largeur dans son conteneur').toBeLessThanOrEqual(defilement.clientWidth); + for (const li of legende(m).querySelectorAll('li')) expect(li.getBoundingClientRect().right).toBeLessThanOrEqual(region.right); + // Chaque action de ligne tient sur une ligne : les boîtes de son + // texte partagent un même haut. + for (const rangee of lignes(m)) { + for (const bouton of rangee.lastElementChild.querySelectorAll('button')) { + const etendue = document.createRange(); + etendue.selectNodeContents(bouton); + const hauts = new Set([...etendue.getClientRects()].filter((r) => r.width > 0).map((r) => Math.round(r.top))); + expect(hauts.size, `${rangee.dataset.participant} : ${bouton.textContent.trim()}`).toBe(1); + } + } + }); + }); + } + + test('dans une fenêtre basse, le signal d’orthographe et le refus du nom viennent dans la fenêtre, à côté de leur champ', async () => { + await dansLeCadre(1024, 420, async () => { + const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] }); + await saisir(champ(m, 'appartenance'), 'club des merles'); + const signal = formulaire(m).querySelector('[data-avertissement]'); + expect(visible(signal)).toBe(true); + await cliquer(signal.querySelector('[data-commande="reprendreOrthographe"]')); + // Le champ du nom au bord bas de la fenêtre : Entrée y pose le refus + // dessous, que le défilement amène dans la fenêtre. + await saisir(champ(m, 'nom'), ' '); + champ(m, 'nom').scrollIntoView({ block: 'end' }); + champ(m, 'nom').focus({ preventScroll: true }); + await touche('Enter'); + await vi.waitFor(() => expect(champ(m, 'nom').getAttribute('aria-describedby')).not.toBeNull()); + await rendu(); + const message = document.getElementById(champ(m, 'nom').getAttribute('aria-describedby')); + expect(visible(message)).toBe(true); + expect(participants(m)).toHaveLength(1); + }); + }); +}); diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte index 4555efd..770e843 100644 --- a/src/interface/participants/SectionParticipants.svelte +++ b/src/interface/participants/SectionParticipants.svelte @@ -2,10 +2,13 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('coquille.section.participants')}

- - - -
-
- - -
- {#each niveaux as rang (rang)} -
- - -
- {/each} - {#if groupes !== null} - - - {/if} - {t('participants.grille.compte', { total: participants.length, affiches: lignes.length })} - -
- +
- - {#if participants.length === 0} -

{t('participants.grille.vide')}

- {:else if lignes.length === 0} -

{t('participants.grille.aucunResultat')}

- {/if} + + {#snippet actions()} + + {/snippet} + + + +
+
+ + +
+ {#each niveaux as rang (rang)} +
+ + +
+ {/each} + {#if groupes !== null} + + + {/if} + {t('participants.grille.compte', { total: participants.length, affiches: lignes.length })} + {#if legende.length > 0} +
    + {#each legende as { nom, rang, compte } (nom)} +
  • + {/each} +
+ {/if} +
+ + + {#if participants.length === 0} +

{t('participants.grille.vide')}

+ {:else if lignes.length === 0} +

{t('participants.grille.aucunResultat')}

+ {/if} +
{#if personneFiche !== null} @@ -185,55 +210,63 @@ surAnnuler={() => (aSupprimer = null)} /> {/if} -
+
diff --git a/src/interface/participants/entete.js b/src/interface/participants/entete.js new file mode 100644 index 0000000..b671e0a --- /dev/null +++ b/src/interface/participants/entete.js @@ -0,0 +1,51 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'en-tête de la liste des participants, pur : la ligne de comptes de la +// carte et la légende des appartenances. Les deux se lisent sur la liste +// entière, exclues comprises — la population de l'attribution des teintes +// (teintesAppartenances, appartenances.js) — : un filtre ne change ni un +// compte ni une teinte. Aucune fonction ne modifie ce qu'elle reçoit. + +/** + * La ligne de comptes de la carte des participants. + * + * @param {Array<{appartenance: string|null, exclu: boolean}>} participants + * tous ceux de l'événement + * @returns {{personnes: number, aPlacer: number, exclues: number, appartenances: number}} + * les personnes, celles que le placement assoit — les non exclues —, les + * exclues, et les appartenances distinctes par égalité de chaîne, comme + * le moteur les distingue + */ +export function comptesEntete(participants) { + const appartenances = new Set(); + let exclues = 0; + for (const { appartenance, exclu } of participants) { + if (exclu) exclues += 1; + if (appartenance !== null) appartenances.add(appartenance); + } + return { personnes: participants.length, aPlacer: participants.length - exclues, exclues, appartenances: appartenances.size }; +} + +/** + * La légende des appartenances : une entrée par appartenance de teintes, + * dans l'ordre de la Map, avec le nombre de personnes de participants qui + * la portent. Une appartenance que personne ne porte dans participants n'y + * figure pas. + * + * @param {Array<{appartenance: string|null}>} participants + * @param {Map} teintes celles de teintesAppartenances, + * calculées sur la liste entière + * @returns {Array<{nom: string, rang: number|null, compte: number}>} + */ +export function legendeAppartenances(participants, teintes) { + const comptes = new Map(); + for (const { appartenance } of participants) { + if (appartenance !== null) comptes.set(appartenance, (comptes.get(appartenance) ?? 0) + 1); + } + const legende = []; + for (const [nom, rang] of teintes) { + if (comptes.has(nom)) legende.push({ nom, rang, compte: comptes.get(nom) }); + } + return legende; +} diff --git a/src/interface/participants/entete.test.js b/src/interface/participants/entete.test.js new file mode 100644 index 0000000..bb38808 --- /dev/null +++ b/src/interface/participants/entete.test.js @@ -0,0 +1,107 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'en-tête de la liste des participants : la ligne de comptes de la carte +// et la légende des appartenances, chacune lue sur la liste entière, exclues +// comprises, et le texte que les libellés en composent. Les noms d'épreuve +// sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { RANGS_APPARTENANCE, teintesAppartenances } from '../appartenances.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { comptesEntete, legendeAppartenances } from './entete.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const personne = (id, appartenance, exclu = false) => ({ + id, + nom: `Brumaille${id}`, + prenom: null, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu, +}); + +// Trois appartenances, dont une portée par une exclue seule, et une +// personne sans appartenance. +const LISTE = [ + personne(1, 'Cercle des Grives'), + personne(2, 'Atelier Pivoine'), + personne(3, 'Cercle des Grives', true), + personne(4, null), + personne(5, 'Cercle des Grives'), + personne(6, 'Banc des Loutres', true), +]; + +describe('comptesEntete : la ligne de comptes de la carte des participants', () => { + test('personnes, à placer, exclues et appartenances distinctes, exclues comprises', () => { + assert.deepEqual(comptesEntete(LISTE), { personnes: 6, aPlacer: 4, exclues: 2, appartenances: 3 }); + }); + + test('une liste vide : quatre zéros', () => { + assert.deepEqual(comptesEntete([]), { personnes: 0, aPlacer: 0, exclues: 0, appartenances: 0 }); + }); + + test('deux orthographes sont deux appartenances, comme pour le moteur et les teintes', () => { + const liste = [personne(1, 'Cercle des Grives'), personne(2, 'cercle des grives')]; + assert.equal(comptesEntete(liste).appartenances, 2); + assert.equal(comptesEntete(liste).appartenances, teintesAppartenances(liste).size); + }); + + test('le libellé compose la ligne, accordée au nombre', () => { + assert.equal( + t('participants.entete.comptes', { personnes: 13, aPlacer: 12, exclues: 1, appartenances: 3 }), + '13 personnes \u{00B7} 12 à placer \u{00B7} 1 exclue \u{00B7} 3 appartenances', + ); + assert.equal( + t('participants.entete.comptes', { personnes: 1, aPlacer: 0, exclues: 2, appartenances: 1 }), + '1 personne \u{00B7} 0 à placer \u{00B7} 2 exclues \u{00B7} 1 appartenance', + ); + assert.equal( + t('participants.entete.comptes', { personnes: 0, aPlacer: 0, exclues: 0, appartenances: 0 }), + '0 personne \u{00B7} 0 à placer \u{00B7} 0 exclue \u{00B7} 0 appartenance', + ); + }); +}); + +describe('legendeAppartenances : la légende en pastilles, avec leur compte', () => { + test('dans l’ordre des teintes, chaque appartenance avec son rang et le nombre de personnes qui la portent, exclues comprises', () => { + assert.deepEqual(legendeAppartenances(LISTE, teintesAppartenances(LISTE)), [ + { nom: 'Atelier Pivoine', rang: 1, compte: 1 }, + { nom: 'Banc des Loutres', rang: 2, compte: 1 }, + { nom: 'Cercle des Grives', rang: 3, compte: 3 }, + ]); + }); + + test('au-delà des rangs de la palette, le rang est null : la pastille est neutre, le compte reste', () => { + const liste = Array.from({ length: RANGS_APPARTENANCE + 2 }, (_, i) => personne(i + 1, `Guilde ${String.fromCodePoint(65 + i)}`)); + liste.push(personne(99, 'Guilde A')); + const legende = legendeAppartenances(liste, teintesAppartenances(liste)); + assert.equal(legende.length, RANGS_APPARTENANCE + 2); + assert.deepEqual(legende[0], { nom: 'Guilde A', rang: 1, compte: 2 }); + assert.deepEqual( + legende.map(({ rang }) => rang), + [...Array.from({ length: RANGS_APPARTENANCE }, (_, i) => i + 1), null, null], + ); + }); + + test('sans appartenance portée, une légende vide ; une personne sans appartenance n’y entre pas', () => { + const liste = [personne(1, null), personne(2, null, true)]; + assert.deepEqual(legendeAppartenances(liste, teintesAppartenances(liste)), []); + }); + + test('les rangs sont ceux des teintes reçues, jamais recalculés sur une liste filtrée', () => { + const teintes = teintesAppartenances(LISTE); + const filtree = LISTE.filter(({ appartenance }) => appartenance === 'Cercle des Grives'); + assert.deepEqual(legendeAppartenances(filtree, teintes), [{ nom: 'Cercle des Grives', rang: 3, compte: 3 }]); + }); + + test('le libellé d’un élément : le nom, puis le compte', () => { + assert.equal(t('participants.legende.element', { nom: 'Atelier Pivoine', compte: 4 }), 'Atelier Pivoine \u{00B7} 4'); + assert.equal(t('participants.legende.titre'), 'Appartenances'); + }); +}); diff --git a/src/interface/tables/Defaut.svelte b/src/interface/tables/Defaut.svelte index 0eaeb95..643e00f 100644 --- a/src/interface/tables/Defaut.svelte +++ b/src/interface/tables/Defaut.svelte @@ -16,6 +16,10 @@ // champ, qui rend la valeur enregistrée. Un refus de l'événement entier — // moins de 2 places — se pose sur le champ (refusDuChampDefaut). // + // L'énoncé se pose sur le fond sourd de la carte ; le refus d'une table + // en refus, un avertissement en avis, chacun son signe et un texte de + // l'encre de la page. + // // La demande tombe quand le défaut l'atteint ; appliquée, elle tombe, et // les avertissements du geste s'affichent tant que la révision qui les a // produits est la courante. @@ -26,6 +30,7 @@ import Champ from '../primitives/Champ.svelte'; import Commande from '../primitives/Commande.svelte'; import Confirmation from '../primitives/Confirmation.svelte'; + import Icone from '../primitives/Icone.svelte'; import { actionRemede } from '../primitives/commande.js'; import { avertissementsNommes, enonceDefaut, refusDuChampDefaut, surchargesPerdues } from './modele.js'; @@ -112,6 +117,7 @@ {#snippet refusDeTable(entree)} {@const action = actionRemede(entree.details.refus.details.remede ?? null, { seance, ui })}
+

{t(entree.cle, entree.details)}

{#if action !== null} + {#if avertissement.numero !== null}

{t('tables.liste.table')} {avertissement.numero}

{/if} @@ -221,23 +228,37 @@ display: flex; flex-direction: column; gap: var(--espace-2); - padding: var(--espace-2) var(--espace-3); - border: 1px solid var(--couleur-page-filet); - border-radius: var(--rayon-1); + padding: var(--espace-3); + border: 1px solid var(--couleur-carte-filet); + border-radius: var(--rayon-2); + background-color: var(--couleur-carte-fond-sourd); } .enonce p { margin: 0; } + /* Le refus d'une table : le signe à gauche, la phrase et le remède + dessous l'un de l'autre, à sa droite. */ .refus { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--espace-1); - padding-left: var(--espace-2); - border-left: 3px solid var(--couleur-refus); - color: var(--couleur-refus); + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: var(--espace-1) var(--espace-2); + padding: var(--espace-2) var(--espace-3); + border: 1px solid var(--couleur-refus-filet); + border-left: 3px solid var(--couleur-refus-marque); + border-radius: var(--rayon-2); + background-color: var(--couleur-refus-fond); + color: var(--couleur-page-texte); + } + + .refus > :not(.signe) { + grid-column: 2; + } + + .refus .signe { + color: var(--couleur-refus-marque); } .realignement { @@ -257,13 +278,39 @@ } .avertissement { - padding: var(--espace-1) var(--espace-2); - border-left: 3px solid var(--couleur-avis); + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: var(--espace-1) var(--espace-2); + padding: var(--espace-2) var(--espace-3); + border: 1px solid var(--couleur-avis-filet); + border-left: 3px solid var(--couleur-avis-marque); + border-radius: var(--rayon-2); + background-color: var(--couleur-avis-fond); + color: var(--couleur-page-texte); font-size: var(--taille-texte); } + .avertissement > :not(.signe) { + grid-column: 2; + } + + .avertissement .signe { + color: var(--couleur-avis-marque); + } + + .signe { + display: inline-flex; + grid-row: 1 / span 3; + padding-top: 0.125rem; + } + .avertissement p, .avertissement ul { margin: 0; } + + .refus p { + margin: 0; + } diff --git a/src/interface/tables/ListeTables.svelte b/src/interface/tables/ListeTables.svelte index e01e930..af42b37 100644 --- a/src/interface/tables/ListeTables.svelte +++ b/src/interface/tables/ListeTables.svelte @@ -2,10 +2,12 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('tables.liste.titre')}

-
+ + {#snippet actions()} seance.executer('creerTable', { forme: 'ronde' })} /> seance.executer('creerTable', { forme: 'carree' })} /> -
+ {/snippet} {#if tables.length === 0} -

{t('tables.liste.aucune')}

+

{t('tables.liste.aucune')}

{:else}
    {#each tables as table (table.id)} @@ -136,8 +145,8 @@
  • {t('tables.liste.table')} {table.numero} - {t(cleForme(table.forme))} - {t(etat.cle)} + {t(cleForme(table.forme))} +
    executerSurTable(table.id, 'changerPlacesTable', { id: table.id, sieges, origine: 'champ' })} />
    - {#if complement !== null && complement.retires.length > 0} -
      - {#each complement.retires as retire, rang (rang)} -
    • {t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}{retire.nom}{/if}
    • - {/each} -
    - {/if} - {#if complement !== null} - {#each complement.avertissements as avertissement, rang (rang)} -
    -

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

    - {#if avertissement.noms.length > 0} -
      - {#each avertissement.noms as nom, rangNom (rangNom)} -
    • {nom}
    • - {/each} -
    - {/if} -
    - {/each} - {/if}
    {#if etat.realignable} supprimer(table)} />
    + {#if complement !== null && complement.retires.length > 0} +
    + +
      + {#each complement.retires as retire, rang (rang)} +
    • {t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}{retire.nom}{/if}
    • + {/each} +
    +
    + {/if} + {#if complement !== null} + {#each complement.avertissements as avertissement, rang (rang)} +
    + +
    +

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

    + {#if avertissement.noms.length > 0} +
      + {#each avertissement.noms as nom, rangNom (rangNom)} +
    • {nom}
    • + {/each} +
    + {/if} +
    +
    + {/each} + {/if}
  • {/each}
{/if} + - {#if suppression !== null} - (suppression = null)} - /> - {/if} -
+{#if suppression !== null} + (suppression = null)} + /> +{/if} diff --git a/src/interface/tables/SectionTables.svelte b/src/interface/tables/SectionTables.svelte index 7021175..cee9ade 100644 --- a/src/interface/tables/SectionTables.svelte +++ b/src/interface/tables/SectionTables.svelte @@ -2,12 +2,14 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('coquille.section.tables')}

- {#if $seance.charge !== null} -
- - -
+
+ {#if $seance.charge === null} + + {:else} + +
+ + +
+
{/if} -
+