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} - - -
+
+ +
+ {#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} -
+