[IMP] event list: summary, state filter tiles, table grouped by state
The list gave every event the same weight, so a manager could not see at a glance what was still in draft or already printed. A header now sums events and participants; one tile per plan state, in the § 9 order, filters the table and holds only for the session; rows group by state or not at all, under grouped column headers. Each « Ouvrir » is named after its event, and messages take the avis, refus or information tone. The cards sit under the table: a sidebar does not fit beside nine columns. Checked: accueil node and browser tests, 8 mutations caught. --- FR --- [IMP] liste des événements : résumé, tuiles par état, groupes par état La liste donnait à chaque événement le même poids : on ne voyait pas d'un coup d'œil ce qui restait en brouillon ou déjà imprimé. Un en-tête compte événements et participants ; une tuile par état du plan, dans l'ordre du § 9, filtre le tableau le temps de la séance ; les lignes se groupent par état ou pas, sous des en-têtes groupés. Chaque « Ouvrir » porte le nom de son événement, les messages prennent le ton avis, refus ou information. Les cartes passent sous le tableau, faute de place. Vérifié : épreuves node et navigateur de l'accueil, 8 mutations prises. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
16e328ab12
commit
229a2d4067
16 changed files with 1958 additions and 526 deletions
|
|
@ -8,8 +8,10 @@
|
|||
// l'ouverture en lecture, les réglages illisibles, le dossier de travail, ses
|
||||
// avis et ses questions, l'explorateur, l'autre emplacement, copier,
|
||||
// renommer, importer, supprimer, les démonstrations, la racine neutre, la
|
||||
// liste qui défile sous le bandeau, et le chargement des résumés de la liste,
|
||||
// dont l'horloge pilotée du montage tient chaque pas. Chaque épreuve monte
|
||||
// liste qui défile sous le bandeau, le chargement des résumés de la liste,
|
||||
// dont l'horloge pilotée du montage tient chaque pas, et la liste en
|
||||
// tableau : l'en-tête et son résumé, les tuiles des filtres par état du
|
||||
// plan, les groupes, les en-têtes groupés, les cartes et les messages. Chaque épreuve monte
|
||||
// App par monterApp (test/navigateur/montage.js), sur le système de fichiers
|
||||
// simulé, et lit le relevé de ses écritures.
|
||||
//
|
||||
|
|
@ -139,8 +141,24 @@ const accueil = (m) => dans(m, '[data-ecran="accueil"]');
|
|||
const lignes = (m) => tous(m, '[data-evenement]');
|
||||
const ligneDe = (m, base) => dans(m, `[data-evenement="${CSS.escape(base)}"]`);
|
||||
const colonne = (ligne, nom) => ligne.querySelector(`[data-colonne="${nom}"]`)?.textContent;
|
||||
// Le nom de l'état d'une ligne : le seul texte de sa cellule, que la
|
||||
// pastille porte à côté de son signe.
|
||||
const etatDe = (ligne) => noeudsDeTexte(ligne.querySelector('[data-colonne="etat"]')).join('');
|
||||
const ecritures = (m) => m.trace.length;
|
||||
const sondes = (m) => m.trace.filter(([primitive]) => primitive === 'sonder').length;
|
||||
// Le terme expliqué nom, sous racine : un en-tête de colonne du tableau.
|
||||
const terme = (racine, nom) => racine.querySelector(`[data-terme="${nom}"]`);
|
||||
|
||||
// La couleur qu'un jeton donne dans le thème courant, relue sur une sonde.
|
||||
function jeton(nom) {
|
||||
const sonde = document.createElement('span');
|
||||
sonde.style.color = `var(${nom})`;
|
||||
document.body.append(sonde);
|
||||
const couleur = composantes(getComputedStyle(sonde).color);
|
||||
sonde.remove();
|
||||
return couleur;
|
||||
}
|
||||
const fond = (element) => composantes(getComputedStyle(element).backgroundColor);
|
||||
|
||||
// Visible une fois ramené dans la fenêtre : la liste défile sous le bandeau.
|
||||
function visible(element) {
|
||||
|
|
@ -275,7 +293,7 @@ describe('le premier écran (§ 8.1, § 18.6)', () => {
|
|||
expect(colonne(gala, 'nom')).toBe(lue.charge.evenement.nom);
|
||||
expect(colonne(gala, 'nom')).toBe('Gala du printemps');
|
||||
expect(colonne(gala, 'date')).toBe(format.date('2031-05-17'));
|
||||
expect(colonne(gala, 'etat')).toBe(t('accueil.etat.brouillon'));
|
||||
expect(etatDe(gala)).toBe(t('accueil.etat.brouillon'));
|
||||
expect(colonne(gala, 'participants')).toBe(format.nombre(lue.entete.comptes.participants));
|
||||
expect(colonne(gala, 'modification')).toBe(format.horodatage(HORODATAGE_DEPOT));
|
||||
expect(colonne(gala, 'chemin')).toBe(`${DOCUMENTS}\\soiree-a${SUFFIXES.etat}`);
|
||||
|
|
@ -288,7 +306,9 @@ describe('le premier écran (§ 8.1, § 18.6)', () => {
|
|||
expect(Number(lu.entete.comptes.participants)).toBeGreaterThan(0);
|
||||
expect(colonne(ligneClub, 'modification')).toBe('\u{2014}');
|
||||
expect(colonne(ligneClub, 'chemin')).toBe(`${DOCUMENTS}\\club${SUFFIXES.etat}`);
|
||||
for (const ligne of lignes(m)) expect(visible(ligne)).toBe(true);
|
||||
// Le nom de chaque ligne se voit ; sous la largeur où le tableau tient,
|
||||
// le reste de la ligne défile dans le conteneur du tableau.
|
||||
for (const ligne of lignes(m)) expect(visible(ligne.querySelector('[data-colonne="nom"]'))).toBe(true);
|
||||
});
|
||||
|
||||
test('répertoire vide : « version » et la version, la création, une entrée par démonstration du catalogue, atteinte ; aucun « aucun événement »', async () => {
|
||||
|
|
@ -759,7 +779,7 @@ describe('les opérations de fichiers (§ 8.7)', () => {
|
|||
const original = await photographier(m, 'documents');
|
||||
const avant = ecritures(m);
|
||||
const ligne = ligneDe(m, 'Bal des Grisailles');
|
||||
expect(colonne(ligne, 'etat')).toBe(t('accueil.etat.bloque'));
|
||||
expect(etatDe(ligne)).toBe(t('accueil.etat.bloque'));
|
||||
await cliquer(ligne.querySelector('[data-commande="copier"]'));
|
||||
const champ = ligne.querySelector('[data-champ="nomCopie"]');
|
||||
expect(visible(champ)).toBe(true);
|
||||
|
|
@ -777,7 +797,7 @@ describe('les opérations de fichiers (§ 8.7)', () => {
|
|||
const journal = lireJournal(m.fs.contenu('documents', `${copie.base}${SUFFIXES.journal}`));
|
||||
expect(journal.entrees.map(({ libelle: texte }) => texte)).toEqual([attendu]);
|
||||
expect(analyser(m.fs.contenu('documents', `${copie.base}${SUFFIXES.etat}`)).charge.evenement.etat).not.toBe('bloque');
|
||||
expect(colonne(ligneDe(m, copie.base), 'etat')).not.toBe(t('accueil.etat.bloque'));
|
||||
expect(etatDe(ligneDe(m, copie.base))).not.toBe(t('accueil.etat.bloque'));
|
||||
const apres = await photographier(m, 'documents');
|
||||
for (const [chemin, texte] of Object.entries(original)) expect(apres[chemin]).toBe(texte);
|
||||
});
|
||||
|
|
@ -1168,7 +1188,6 @@ function chargeRetenue() {
|
|||
|
||||
const etat = (charge) => serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
|
||||
const TIRET = '\u{2014}';
|
||||
const terme = (ligne, nom) => ligne.querySelector(`[data-terme="${nom}"]`);
|
||||
const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')];
|
||||
|
||||
// L'infobulle unique, ouverte sur le terme : visible, au texte de la clé,
|
||||
|
|
@ -1188,13 +1207,14 @@ function exigerInfobulle(ancre, cle) {
|
|||
expect(b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom, `${cle} sur son terme`).toBe(false);
|
||||
}
|
||||
|
||||
// Chaque terme de la ligne, survolé puis focalisé, ouvre l'infobulle sur sa
|
||||
// clé ; la sortie du pointeur et Échap la ferment, le focus reste au terme.
|
||||
async function eprouverTermes(ligne, cas) {
|
||||
// Chaque terme de la liste, un en-tête de colonne, survolé puis focalisé,
|
||||
// ouvre l'infobulle sur sa clé ; la sortie du pointeur et Échap la ferment,
|
||||
// le focus reste au terme.
|
||||
async function eprouverTermes(racine, cas) {
|
||||
expect(cas.length).toBeGreaterThan(0);
|
||||
await pointer('bouger', 0, 0);
|
||||
for (const [nom, cle] of cas) {
|
||||
const ancre = terme(ligne, nom);
|
||||
const ancre = terme(racine, nom);
|
||||
ancre.scrollIntoView({ block: 'center' });
|
||||
const { x, y } = await centre(ancre);
|
||||
await pointer('bouger', x, y);
|
||||
|
|
@ -1234,8 +1254,16 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
|
|||
const club = ligneDe(m, 'club');
|
||||
expect(colonne(club, 'collisionsCumulees')).toBe(TIRET);
|
||||
expect(colonne(club, 'diffRedondance')).toBe(TIRET);
|
||||
expect(terme(retenue, 'collisionsCumulees').textContent).toBe(t('accueil.colonne.collisionsCumulees'));
|
||||
expect(terme(retenue, 'diffRedondance').textContent).toBe(t('accueil.colonne.diffRedondance'));
|
||||
// Chaque intitulé est un en-tête de colonne, une seule fois pour toute la
|
||||
// liste.
|
||||
for (const [nom, cle] of [
|
||||
['collisionsCumulees', 'accueil.colonne.collisionsCumulees'],
|
||||
['diffRedondance', 'accueil.colonne.diffRedondance'],
|
||||
]) {
|
||||
expect(tous(m, `[data-terme="${nom}"]`).length, nom).toBe(1);
|
||||
expect(terme(accueil(m), nom).closest('thead'), nom).not.toBeNull();
|
||||
expect(terme(accueil(m), nom).textContent).toBe(t(cle));
|
||||
}
|
||||
expect(visible(retenue.querySelector('[data-colonne="diffRedondance"]'))).toBe(true);
|
||||
expect(ecritures(m)).toBe(avant);
|
||||
});
|
||||
|
|
@ -1243,8 +1271,7 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
|
|||
test('chaque intitulé s’explique : le survol puis le focus ouvrent son infobulle, Échap et la sortie la ferment', async () => {
|
||||
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
|
||||
const retenue = ligneDe(m, 'retenue');
|
||||
await eprouverTermes(retenue, [
|
||||
await eprouverTermes(accueil(m), [
|
||||
['collisionsCumulees', 'accueil.explication.collisionsCumulees'],
|
||||
['diffRedondance', 'accueil.explication.diffRedondance'],
|
||||
]);
|
||||
|
|
@ -1296,12 +1323,23 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
|
|||
return appliquerCommande('retenirProposition', charge, { id: autre.id }).charge;
|
||||
}
|
||||
|
||||
const blocDe = (ligne) => ligne.querySelector('[data-meilleure]');
|
||||
// Ce que le bloc d'une ligne, ou celui de la génération, écrit.
|
||||
// Ce que la ligne, ou le bloc de la génération, écrit de la meilleure
|
||||
// proposition.
|
||||
const lu = (bloc) => ({
|
||||
id: bloc.querySelector('[data-meilleure-id]')?.textContent ?? null,
|
||||
collisions: bloc.querySelector('[data-meilleure-collisions]')?.textContent ?? null,
|
||||
});
|
||||
// L'en-tête du groupe de colonnes de la meilleure proposition, et celui de
|
||||
// sa colonne d'identifiant.
|
||||
const groupeMeilleure = (m) => terme(accueil(m), 'meilleure').closest('th');
|
||||
const enteteProposition = (m) =>
|
||||
tous(m, 'thead th[scope="col"]').find((th) => th.textContent.trim() === t('generation.meilleure.proposition'));
|
||||
// Vrai quand la boîte de cellule tient, en largeur, dans celle de l'en-tête.
|
||||
const sous = (cellule, entete) => {
|
||||
const c = cellule.getBoundingClientRect();
|
||||
const e = entete.getBoundingClientRect();
|
||||
return c.left >= e.left - 0.5 && c.right <= e.right + 0.5 && c.top >= e.bottom - 0.5;
|
||||
};
|
||||
const blocGeneration = (m) => dans(m, '[data-section="generation"] [data-meilleure]');
|
||||
|
||||
test('un événement classé : le rang 1, son identifiant et ses collisions cumulées, à part du retenu ; sans proposition, « — » ; rien d’écrit', async () => {
|
||||
|
|
@ -1317,26 +1355,32 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
|
|||
await chargerResumes(m);
|
||||
const avant = ecritures(m);
|
||||
const classee = ligneDe(m, 'classee');
|
||||
const bloc = blocDe(classee);
|
||||
expect(lu(bloc)).toEqual({ id: format.nombre(premiere.id), collisions: format.nombre(premiere.mesures.collisionsCumulees) });
|
||||
expect(lu(classee)).toEqual({ id: format.nombre(premiere.id), collisions: format.nombre(premiere.mesures.collisionsCumulees) });
|
||||
expect(colonne(classee, 'collisionsCumulees')).toBe(format.nombre(table.retenu.mesures.collisionsCumulees));
|
||||
expect(terme(classee, 'meilleure').textContent).toBe(t('generation.meilleure.titre'));
|
||||
expect(bloc.querySelector('dt').textContent).toBe(t('generation.meilleure.proposition'));
|
||||
expect(terme(classee, 'meilleureCollisions').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
|
||||
expect(bloc.getAttribute('role')).toBe('group');
|
||||
expect(document.getElementById(bloc.getAttribute('aria-labelledby'))).toBe(terme(classee, 'meilleure'));
|
||||
// Les intitulés sont ceux du bloc de la génération, en en-têtes de
|
||||
// colonnes : le titre sur le groupe, l'identifiant et les collisions sur
|
||||
// ses deux colonnes ; chaque valeur se tient sous son groupe.
|
||||
expect(terme(accueil(m), 'meilleure').textContent).toBe(t('generation.meilleure.titre'));
|
||||
expect(groupeMeilleure(m).getAttribute('scope')).toBe('colgroup');
|
||||
expect(groupeMeilleure(m).colSpan).toBe(2);
|
||||
expect(enteteProposition(m)).toBeDefined();
|
||||
expect(terme(accueil(m), 'meilleureCollisions').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
|
||||
for (const valeur of ['[data-meilleure-id]', '[data-meilleure-collisions]']) {
|
||||
expect(visible(bloc.querySelector(valeur)), valeur).toBe(true);
|
||||
expect(atteint(bloc.querySelector(valeur)), valeur).toBe(true);
|
||||
const cellule = classee.querySelector(valeur);
|
||||
expect(visible(cellule), valeur).toBe(true);
|
||||
expect(atteint(cellule), valeur).toBe(true);
|
||||
expect(sous(cellule, groupeMeilleure(m)), valeur).toBe(true);
|
||||
}
|
||||
expect(lu(blocDe(ligneDe(m, 'club')))).toEqual({ id: TIRET, collisions: TIRET });
|
||||
expect(sous(classee.querySelector('[data-meilleure-id]'), enteteProposition(m))).toBe(true);
|
||||
expect(sous(classee.querySelector('[data-meilleure-collisions]'), terme(accueil(m), 'meilleureCollisions').closest('th'))).toBe(true);
|
||||
expect(lu(ligneDe(m, 'club'))).toEqual({ id: TIRET, collisions: TIRET });
|
||||
expect(ecritures(m)).toBe(avant);
|
||||
});
|
||||
|
||||
test('le titre et l’intitulé des collisions s’expliquent : le survol puis le focus ouvrent l’infobulle, Échap et la sortie la ferment', async () => {
|
||||
const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
|
||||
await eprouverTermes(ligneDe(m, 'classee'), [
|
||||
await eprouverTermes(accueil(m), [
|
||||
['meilleure', 'accueil.explication.meilleure'],
|
||||
['meilleureCollisions', 'propositions.explication.collisionsCumulees'],
|
||||
]);
|
||||
|
|
@ -1346,11 +1390,10 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
|
|||
const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
|
||||
await chargerResumes(m);
|
||||
const ligne = ligneDe(m, 'classee');
|
||||
const deLaListe = lu(blocDe(ligne));
|
||||
const deLaListe = lu(ligneDe(m, 'classee'));
|
||||
expect(deLaListe.id).toMatch(/\d/);
|
||||
const libelles = [terme(ligne, 'meilleure'), blocDe(ligne).querySelector('dt'), terme(ligne, 'meilleureCollisions')].map(
|
||||
(element) => element.textContent,
|
||||
const libelles = [terme(accueil(m), 'meilleure'), enteteProposition(m), terme(accueil(m), 'meilleureCollisions')].map(
|
||||
(element) => element.textContent.trim(),
|
||||
);
|
||||
await ouvrirLigne(m, 'classee');
|
||||
await attendreEvenement(m);
|
||||
|
|
@ -1368,7 +1411,7 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
|
|||
const resumeRecu = suivreResumes(m);
|
||||
await revenirALaListe(m, 1);
|
||||
await resumeRecu('classee');
|
||||
expect(lu(blocDe(ligneDe(m, 'classee')))).toEqual(deLaListe);
|
||||
expect(lu(ligneDe(m, 'classee'))).toEqual(deLaListe);
|
||||
});
|
||||
|
||||
test('des propositions toutes hors du classement : « — » à la liste, là où le bloc de la génération le dit en mots', async () => {
|
||||
|
|
@ -1380,33 +1423,40 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7,
|
|||
const m = await monter({ fichiers: [fichier(`horsclassement${SUFFIXES.etat}`, etat(charge))] });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
|
||||
await chargerResumes(m);
|
||||
expect(lu(blocDe(ligneDe(m, 'horsclassement')))).toEqual({ id: TIRET, collisions: TIRET });
|
||||
expect(lu(ligneDe(m, 'horsclassement'))).toEqual({ id: TIRET, collisions: TIRET });
|
||||
await ouvrirLigne(m, 'horsclassement');
|
||||
await attendreEvenement(m);
|
||||
m.ui.allerA('generation');
|
||||
await vi.waitFor(() => expect(blocGeneration(m)?.querySelector('[data-meilleure-vide]')).not.toBeNull());
|
||||
});
|
||||
|
||||
test('à 1366 × 700, six événements classés : la page ne défile pas en largeur, chaque bloc tient dans la sienne', async () => {
|
||||
const avant = { largeur: innerWidth, hauteur: innerHeight };
|
||||
await fixerCadre(1366, 700);
|
||||
try {
|
||||
const bases = ['un', 'deux', 'trois', 'quatre', 'cinq', 'six'];
|
||||
const m = await monter({ fichiers: bases.map((base) => fichier(`${base}${SUFFIXES.etat}`, etat(chargeClassee({ id: `evt-${base}` })))) });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(bases.length));
|
||||
await chargerResumes(m);
|
||||
const blocs = lignes(m).map(blocDe);
|
||||
expect(blocs.length).toBe(bases.length);
|
||||
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
|
||||
for (const bloc of blocs) {
|
||||
expect(lu(bloc).id).toMatch(/\d/);
|
||||
expect(bloc.scrollWidth).toBeLessThanOrEqual(bloc.clientWidth);
|
||||
expect(bloc.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
|
||||
for (const largeur of [1366, 1024]) {
|
||||
test(`à ${largeur} × 700, six événements classés : la page ne défile pas en largeur, le tableau tient dans sa carte, chaque valeur dans sa cellule`, async () => {
|
||||
const avant = { largeur: innerWidth, hauteur: innerHeight };
|
||||
await fixerCadre(largeur, 700);
|
||||
try {
|
||||
const bases = ['un', 'deux', 'trois', 'quatre', 'cinq', 'six'];
|
||||
const m = await monter({ fichiers: bases.map((base) => fichier(`${base}${SUFFIXES.etat}`, etat(chargeClassee({ id: `evt-${base}` })))) });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(bases.length));
|
||||
await chargerResumes(m);
|
||||
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth);
|
||||
const tableau = dans(m, '[data-ecran="accueil"] table');
|
||||
const carte = tableau.closest('section');
|
||||
expect(tableau.parentElement.scrollWidth).toBeLessThanOrEqual(tableau.parentElement.clientWidth);
|
||||
expect(tableau.getBoundingClientRect().right).toBeLessThanOrEqual(carte.getBoundingClientRect().right);
|
||||
expect(carte.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
|
||||
for (const ligne of lignes(m)) {
|
||||
expect(lu(ligne).id).toMatch(/\d/);
|
||||
for (const cellule of ligne.cells) {
|
||||
expect(cellule.scrollWidth, cellule.textContent).toBeLessThanOrEqual(cellule.clientWidth);
|
||||
expect(cellule.getBoundingClientRect().right).toBeLessThanOrEqual(innerWidth);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fixerCadre(avant.largeur, avant.hauteur);
|
||||
}
|
||||
} finally {
|
||||
await fixerCadre(avant.largeur, avant.hauteur);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Le chargement des résumés (§ 8.1, § 14.5, § 19.2) ------------------------------
|
||||
|
|
@ -1455,7 +1505,7 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
|
|||
const regions = tous(m, '[data-chargement-liste]');
|
||||
expect(regions.length).toBe(1);
|
||||
expect(regions[0].getAttribute('role')).toBe('status');
|
||||
expect(tous(m, '.liste [role="status"], .liste [role="img"]')).toEqual([]);
|
||||
expect(tous(m, 'table [role="status"], table [role="img"]')).toEqual([]);
|
||||
return regions[0];
|
||||
}
|
||||
const resolue = (ligne, valeurs) => {
|
||||
|
|
@ -1579,3 +1629,323 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- La liste en tableau : en-tête, tuiles, groupes, cartes (§ 8.1, § 9) ------------
|
||||
|
||||
describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, § 9)', () => {
|
||||
// Le fichier d'état de la petite démonstration, sous un identifiant, un
|
||||
// nom et un état à soi : aucun doublon entre deux fichiers.
|
||||
function texteDe(id, nom, etatDuPlan) {
|
||||
const { charge } = analyser(PETITE);
|
||||
charge.evenement.id = id;
|
||||
charge.evenement.nom = nom;
|
||||
charge.evenement.etat = etatDuPlan;
|
||||
return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
|
||||
}
|
||||
// Cinq événements de quatre états : [base, nom, état].
|
||||
const CINQ = [
|
||||
['bal', 'Bal des Grisailles', 'bloque'],
|
||||
['club', 'Club des Merles', 'brouillon'],
|
||||
['gala', 'Gala des Ombrelles', 'retenu'],
|
||||
['nuit', 'Nuit des Pervenches', 'brouillon'],
|
||||
['souper', 'Souper des Hiboux', 'propose'],
|
||||
];
|
||||
const fichiersDe = (evenements) =>
|
||||
evenements.map(([base, nom, etatDuPlan]) => fichier(`${base}${SUFFIXES.etat}`, texteDe(`evt-${base}`, nom, etatDuPlan)));
|
||||
async function monterCinq(options = {}) {
|
||||
const m = await monter({ fichiers: fichiersDe(CINQ), ...options });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(CINQ.length));
|
||||
return m;
|
||||
}
|
||||
|
||||
const bases = (m) => lignes(m).map((ligne) => ligne.dataset.evenement);
|
||||
// Les tuiles des filtres, dans l'ordre du rendu, et celle d'un filtre.
|
||||
const groupeDesTuiles = (m) => dans(m, `[data-ecran="accueil"] [role="group"][aria-label="${t('accueil.filtre.groupe')}"]`);
|
||||
const tuiles = (m) => [...groupeDesTuiles(m).querySelectorAll('button[data-commande="filtrer"]')];
|
||||
const tuile = (m, filtre) => tuiles(m).find((bouton) => bouton.dataset.cible === filtre);
|
||||
const presses = (m) => tuiles(m).map((bouton) => bouton.getAttribute('aria-pressed'));
|
||||
// Le texte d'un élément, espaces réduits.
|
||||
const texte = (element) => element.textContent.replace(/\s+/g, ' ').trim();
|
||||
// Les groupes du tableau : chacun le texte de son intitulé, ou null, et
|
||||
// ses bases.
|
||||
const groupes = (m) =>
|
||||
tous(m, '[data-ecran="accueil"] table tbody').map((corps) => {
|
||||
const intitule = corps.querySelector('th[scope="rowgroup"]');
|
||||
return [intitule === null ? null : noeudsDeTexte(intitule), [...corps.querySelectorAll('[data-evenement]')].map((l) => l.dataset.evenement)];
|
||||
});
|
||||
// Choisit un groupement dans le sélecteur, comme une personne.
|
||||
function grouper(m, valeur) {
|
||||
const choix = dans(m, '[data-champ="groupement"]');
|
||||
choix.value = valeur;
|
||||
choix.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
// Vrai quand la boîte de l'élément est entière dans la fenêtre, sans défilement.
|
||||
function dansLaPremiereVue(element) {
|
||||
const boite = element.getBoundingClientRect();
|
||||
return boite.top >= 0 && boite.bottom <= innerHeight && boite.left >= 0 && boite.right <= innerWidth;
|
||||
}
|
||||
|
||||
test('en-tête : le titre, le résumé des lignes listées, « Créer un événement » principale qui mène au champ du nom, « Importer… » secondaire à côté ; tout dans la première vue à 1366 × 700', async () => {
|
||||
const avant = { largeur: innerWidth, hauteur: innerHeight };
|
||||
await fixerCadre(1366, 700);
|
||||
try {
|
||||
const m = await monterCinq();
|
||||
window.scrollTo(0, 0);
|
||||
const ecrites = ecritures(m);
|
||||
expect(texte(accueil(m).querySelector('h1'))).toBe(t('coquille.liste'));
|
||||
const participants = CINQ.length * analyser(PETITE).charge.participants.length;
|
||||
expect(participants).toBeGreaterThan(CINQ.length);
|
||||
const attendu = t('accueil.entete.resume', { evenements: CINQ.length, participants });
|
||||
const resume = tous(m, '[data-ecran="accueil"] p').find((p) => p.textContent === attendu);
|
||||
expect(resume, attendu).toBeDefined();
|
||||
const creer = dans(m, '[data-ecran="accueil"] [data-commande="allerCreation"]');
|
||||
const importer = dans(m, '[data-ecran="accueil"] [data-commande="importer"]');
|
||||
expect(creer.textContent.trim()).toBe(t('accueil.entete.creer'));
|
||||
expect(importer.textContent.trim()).toBe(t('accueil.operation.importer'));
|
||||
for (const element of [resume, creer, importer, dans(m, '[data-version]')]) expect(dansLaPremiereVue(element), texte(element)).toBe(true);
|
||||
// Les deux rangs de la maquette : la principale pleine de l'action, à
|
||||
// hauteur de cible ; l'import à filet, sur le fond de la carte.
|
||||
expect(fond(creer)).toEqual(jeton('--couleur-action-fond'));
|
||||
expect(creer.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
expect(fond(importer)).toEqual(jeton('--couleur-carte-fond'));
|
||||
// « Créer un événement » porte le focus sur le champ du nom de la carte
|
||||
// de création, et n'écrit rien.
|
||||
await cliquer(creer);
|
||||
const nom = dans(m, '[data-champ="nom"]');
|
||||
expect(document.activeElement).toBe(nom);
|
||||
expect(visibleDansLaFenetre(nom)).toBe(true);
|
||||
expect(nom.closest('section').getAttribute('aria-labelledby')).not.toBeNull();
|
||||
expect(texte(document.getElementById(nom.closest('section').getAttribute('aria-labelledby')))).toBe(t('accueil.creation.titre'));
|
||||
expect(ecritures(m)).toBe(ecrites);
|
||||
} finally {
|
||||
await fixerCadre(avant.largeur, avant.hauteur);
|
||||
}
|
||||
});
|
||||
|
||||
test('tuiles : Tous puis les quatre états dans l’ordre du § 9, chacune son compte, sa pastille et son aide ; un clic filtre, aria-pressed suit, rien ne s’écrit', async () => {
|
||||
const m = await monterCinq();
|
||||
const ecrites = ecritures(m);
|
||||
expect(tuiles(m).map((bouton) => bouton.dataset.cible)).toEqual(['tous', 'brouillon', 'propose', 'retenu', 'bloque']);
|
||||
expect(presses(m)).toEqual(['true', 'false', 'false', 'false', 'false']);
|
||||
const attendues = [
|
||||
['tous', 'accueil.filtre.tous', 5, 'accueil.filtre.aide.tous'],
|
||||
['brouillon', 'accueil.etat.brouillon', 2, 'accueil.filtre.aide.brouillon'],
|
||||
['propose', 'accueil.etat.propose', 1, 'accueil.filtre.aide.propose'],
|
||||
['retenu', 'accueil.etat.retenu', 1, 'accueil.filtre.aide.retenu'],
|
||||
['bloque', 'accueil.etat.bloque', 1, 'accueil.filtre.aide.bloque'],
|
||||
];
|
||||
for (const [filtre, cle, compte, aide] of attendues) {
|
||||
const bouton = tuile(m, filtre);
|
||||
expect(noeudsDeTexte(bouton).map((t0) => t0.trim()), filtre).toEqual([t(cle), format.nombre(compte), t(aide)]);
|
||||
// La pastille : un signe dessiné, caché aux technologies d'assistance.
|
||||
const signe = bouton.querySelector('svg');
|
||||
expect(signe, filtre).not.toBeNull();
|
||||
expect(signe.getAttribute('aria-hidden'), filtre).toBe('true');
|
||||
expect(visible(bouton), filtre).toBe(true);
|
||||
expect(atteint(bouton), filtre).toBe(true);
|
||||
}
|
||||
await cliquer(tuile(m, 'brouillon'));
|
||||
expect(bases(m)).toEqual(['club', 'nuit']);
|
||||
expect(presses(m)).toEqual(['false', 'true', 'false', 'false', 'false']);
|
||||
await cliquer(tuile(m, 'bloque'));
|
||||
expect(bases(m)).toEqual(['bal']);
|
||||
// Les comptes ne suivent pas le filtre : ils comptent toute la liste.
|
||||
expect(noeudsDeTexte(tuile(m, 'tous'))[1].trim()).toBe(format.nombre(5));
|
||||
await cliquer(tuile(m, 'tous'));
|
||||
expect(bases(m).length).toBe(5);
|
||||
expect(ecritures(m)).toBe(ecrites);
|
||||
});
|
||||
|
||||
test('le filtre est un état de la séance : il survit à un retour à la liste, une autre séance part de Tous, et le fichier ne le porte pas', async () => {
|
||||
const m = await monterCinq();
|
||||
const ecrites = ecritures(m);
|
||||
await cliquer(tuile(m, 'bloque'));
|
||||
grouper(m, 'aucun');
|
||||
expect(bases(m)).toEqual(['bal']);
|
||||
await ouvrirLigne(m, 'bal');
|
||||
await attendreEvenement(m);
|
||||
await revenirALaListe(m, 1);
|
||||
expect(bases(m)).toEqual(['bal']);
|
||||
expect(presses(m)).toEqual(['false', 'false', 'false', 'false', 'true']);
|
||||
expect(dans(m, '[data-champ="groupement"]').value).toBe('aucun');
|
||||
expect(ecritures(m)).toBe(ecrites);
|
||||
for (const [base] of CINQ) expect(m.fs.contenu('documents', `${base}${SUFFIXES.etat}`)).not.toMatch(/filtre|groupement/);
|
||||
const autre = await monter({ systeme: { disque: m.disque } });
|
||||
await vi.waitFor(() => expect(lignes(autre).length).toBe(CINQ.length));
|
||||
expect(presses(autre)).toEqual(['true', 'false', 'false', 'false', 'false']);
|
||||
expect(dans(autre, '[data-champ="groupement"]').value).toBe('etat');
|
||||
});
|
||||
|
||||
test('groupé par état : un groupe par état présent, dans l’ordre du § 9, son intitulé porte la pastille et « n événement(s) » ; sans groupement, un seul groupe dans l’ordre de la façade', async () => {
|
||||
const m = await monterCinq();
|
||||
const intitule = (cle, n) => [t(cle), t('accueil.groupe.compte', { evenements: n })];
|
||||
expect(groupes(m)).toEqual([
|
||||
[intitule('accueil.etat.brouillon', 2), ['club', 'nuit']],
|
||||
[intitule('accueil.etat.propose', 1), ['souper']],
|
||||
[intitule('accueil.etat.retenu', 1), ['gala']],
|
||||
[intitule('accueil.etat.bloque', 1), ['bal']],
|
||||
]);
|
||||
for (const th of tous(m, 'table th[scope="rowgroup"]')) {
|
||||
expect(th.colSpan).toBe(tous(m, 'table thead tr')[1].cells.length + 1);
|
||||
expect(th.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
|
||||
}
|
||||
const choix = dans(m, '[data-champ="groupement"]');
|
||||
expect(texte(choix.labels[0])).toContain(t('accueil.groupement.libelle'));
|
||||
expect([...choix.options].map((option) => option.textContent)).toEqual([t('accueil.groupement.etat'), t('accueil.groupement.aucun')]);
|
||||
expect(choix.value).toBe('etat');
|
||||
grouper(m, 'aucun');
|
||||
const ordre = (await m.facade.lister()).map(({ base }) => base);
|
||||
expect(groupes(m)).toEqual([[null, ordre]]);
|
||||
// Un filtre s'applique aussi sans groupement.
|
||||
await cliquer(tuile(m, 'brouillon'));
|
||||
expect(groupes(m)).toEqual([[null, ['club', 'nuit']]]);
|
||||
grouper(m, 'etat');
|
||||
expect(groupes(m)).toEqual([[intitule('accueil.etat.brouillon', 2), ['club', 'nuit']]]);
|
||||
});
|
||||
|
||||
test('un filtre sans ligne : le tableau le dit, sans aucune ligne ; « Tous les événements » les rend', async () => {
|
||||
const m = await monter({ fichiers: fichiersDe(CINQ.filter(([, , etatDuPlan]) => etatDuPlan === 'brouillon')) });
|
||||
await vi.waitFor(() => expect(lignes(m).length).toBe(2));
|
||||
await cliquer(tuile(m, 'retenu'));
|
||||
expect(lignes(m).length).toBe(0);
|
||||
const message = tous(m, '[data-ecran="accueil"] p').find((p) => p.textContent === t('accueil.filtre.vide'));
|
||||
expect(message).toBeDefined();
|
||||
expect(visible(message)).toBe(true);
|
||||
await cliquer(tuile(m, 'tous'));
|
||||
expect(lignes(m).length).toBe(2);
|
||||
expect(tous(m, '[data-ecran="accueil"] p').some((p) => p.textContent === t('accueil.filtre.vide'))).toBe(false);
|
||||
});
|
||||
|
||||
test('en-têtes groupés : Événement, Placement retenu à la teinte de Retenu, Meilleure proposition à celle de Proposé ; chaque valeur sous son groupe ; le nom en tête de ligne, la date et le chemin en petite ligne dessous', async () => {
|
||||
const m = await monterCinq();
|
||||
await chargerResumes(m);
|
||||
const entetes = tous(m, '[data-ecran="accueil"] thead th[scope="colgroup"]');
|
||||
expect(entetes.map(texte)).toEqual([t('accueil.entete.evenement'), t('accueil.entete.retenu'), t('generation.meilleure.titre')]);
|
||||
expect(entetes.map((th) => th.colSpan)).toEqual([4, 2, 2]);
|
||||
expect(fond(entetes[1])).toEqual(jeton('--couleur-etat-retenu-fond'));
|
||||
expect(fond(entetes[2])).toEqual(jeton('--couleur-etat-propose-fond'));
|
||||
// Le groupe porte le signe de son état, de la marque de l'état.
|
||||
expect(composantes(getComputedStyle(entetes[1].querySelector('svg')).stroke)).toEqual(jeton('--couleur-etat-retenu-marque'));
|
||||
expect(composantes(getComputedStyle(entetes[2].querySelector('svg')).stroke)).toEqual(jeton('--couleur-etat-propose-marque'));
|
||||
const dans2 = (cellule, entete) => {
|
||||
const c = cellule.getBoundingClientRect();
|
||||
const e = entete.getBoundingClientRect();
|
||||
return c.left >= e.left - 0.5 && c.right <= e.right + 0.5;
|
||||
};
|
||||
const ligne = ligneDe(m, 'gala');
|
||||
for (const nom of ['nom', 'etat', 'participants', 'modification']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[0]), nom).toBe(true);
|
||||
for (const nom of ['collisionsCumulees', 'diffRedondance']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[1]), nom).toBe(true);
|
||||
for (const valeur of ['[data-meilleure-id]', '[data-meilleure-collisions]']) expect(dans2(ligne.querySelector(valeur), entetes[2]), valeur).toBe(true);
|
||||
// Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent,
|
||||
// dessous, en plus petit.
|
||||
const nom = ligne.querySelector('[data-colonne="nom"]');
|
||||
const enTete = nom.closest('th');
|
||||
expect(enTete.getAttribute('scope')).toBe('row');
|
||||
for (const colonneDessous of ['date', 'chemin']) {
|
||||
const element = ligne.querySelector(`[data-colonne="${colonneDessous}"]`);
|
||||
expect(enTete.contains(element), colonneDessous).toBe(true);
|
||||
expect(element.getBoundingClientRect().top, colonneDessous).toBeGreaterThanOrEqual(nom.getBoundingClientRect().bottom - 0.5);
|
||||
expect(parseFloat(getComputedStyle(element).fontSize), colonneDessous).toBeLessThan(parseFloat(getComputedStyle(nom).fontSize));
|
||||
}
|
||||
// L'état est une pastille : son signe, et le nom de l'état seul pour texte.
|
||||
const etatDeLigne = ligne.querySelector('[data-colonne="etat"]');
|
||||
expect(etatDeLigne.textContent.trim()).toBe(t('accueil.etat.retenu'));
|
||||
expect(etatDeLigne.querySelector('svg[aria-hidden="true"]')).not.toBeNull();
|
||||
// « Ouvrir » ouvre la ligne.
|
||||
const ouvrir = ligne.querySelector('[data-commande="ouvrir"]');
|
||||
expect(ouvrir.textContent.trim()).toBe(t('accueil.ouvrir'));
|
||||
await cliquer(ouvrir);
|
||||
await attendreEvenement(m, ({ base }) => base === 'gala');
|
||||
});
|
||||
|
||||
test('« Ouvrir », la commande principale de la ligne, a une cible d’au moins 44 px de haut, à chaque ligne', async () => {
|
||||
const m = await monterCinq();
|
||||
for (const [base] of CINQ) {
|
||||
const ouvrir = ligneDe(m, base).querySelector('[data-commande="ouvrir"]');
|
||||
expect(ouvrir.getBoundingClientRect().height, base).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque « Ouvrir » se nomme « Ouvrir » suivi du nom de sa ligne, et se distingue ainsi des autres', async () => {
|
||||
const m = await monterCinq();
|
||||
// Le nom accessible d'un bouton nommé par aria-labelledby : le texte de
|
||||
// chaque élément désigné, dans l'ordre, séparés d'une espace.
|
||||
const nomAccessible = (element) =>
|
||||
element
|
||||
.getAttribute('aria-labelledby')
|
||||
.split(' ')
|
||||
.map((id) => texte(document.getElementById(id)))
|
||||
.join(' ');
|
||||
const noms = [];
|
||||
for (const [base, nom] of CINQ) {
|
||||
const ouvrir = ligneDe(m, base).querySelector('[data-commande="ouvrir"]');
|
||||
expect(ouvrir.getAttribute('aria-labelledby'), base).not.toBeNull();
|
||||
expect(nomAccessible(ouvrir), base).toBe(`${t('accueil.ouvrir')} ${nom}`);
|
||||
noms.push(nomAccessible(ouvrir));
|
||||
}
|
||||
expect(new Set(noms).size).toBe(CINQ.length);
|
||||
});
|
||||
|
||||
test('la date et le chemin, sous le nom, portent chacun son intitulé, lu par les technologies d’assistance et caché à l’œil', async () => {
|
||||
const m = await monterCinq();
|
||||
const enTete = ligneDe(m, 'gala').querySelector('[data-colonne="nom"]').closest('th');
|
||||
for (const [colonne, cle] of [
|
||||
['date', 'accueil.colonne.date'],
|
||||
['chemin', 'accueil.colonne.chemin'],
|
||||
]) {
|
||||
const valeur = enTete.querySelector(`[data-colonne="${colonne}"]`);
|
||||
// L'intitulé : le nœud sans enfant de l'en-tête de ligne qui porte son texte.
|
||||
const intitule = [...enTete.querySelectorAll('span')].find((element) => element.children.length === 0 && texte(element) === t(cle)) ?? null;
|
||||
expect(intitule, colonne).not.toBeNull();
|
||||
expect(texte(intitule), colonne).toBe(t(cle));
|
||||
// L'intitulé précède sa valeur dans l'ordre de lecture.
|
||||
expect(intitule.compareDocumentPosition(valeur) & Node.DOCUMENT_POSITION_FOLLOWING, colonne).toBeTruthy();
|
||||
expect(intitule.getAttribute('aria-hidden'), colonne).toBeNull();
|
||||
const boite = intitule.getBoundingClientRect();
|
||||
expect(boite.width * boite.height, colonne).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('l’avis d’un dossier de l’exécutable inconnu est un message « avis » : son fond, son signe ; le texte garde l’encre de la page', async () => {
|
||||
const m = await monterCinq();
|
||||
const avis = dans(m, '[data-avertissement="NON_PUBLIE"]');
|
||||
expect(visible(avis)).toBe(true);
|
||||
expect(fondPeintSous(avis)).toEqual([jeton('--couleur-avis-fond')]);
|
||||
expect(composantes(getComputedStyle(avis).color)).toEqual(jeton('--couleur-page-texte'));
|
||||
let message = avis;
|
||||
while (message.querySelector('svg') === null) message = message.parentElement;
|
||||
const signe = message.querySelector('svg');
|
||||
expect(signe.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(composantes(getComputedStyle(signe).stroke)).toEqual(jeton('--couleur-avis-marque'));
|
||||
// Le message précède les tuiles et le tableau.
|
||||
expect(avis.compareDocumentPosition(groupeDesTuiles(m)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test('les cartes : Créer un événement, Dossier de travail, Démonstrations, chacune une région nommée par son titre, sur le fond de la carte, après le tableau', async () => {
|
||||
const m = await monterCinq();
|
||||
const titres = [t('accueil.creation.titre'), t('accueil.dossier.titre'), t('accueil.demonstrations.titre')];
|
||||
const regions = tous(m, '[data-ecran="accueil"] section[aria-labelledby]');
|
||||
const nommees = new Map(regions.map((region) => [texte(document.getElementById(region.getAttribute('aria-labelledby'))), region]));
|
||||
const tableau = dans(m, '[data-ecran="accueil"] table');
|
||||
for (const titre of titres) {
|
||||
const region = nommees.get(titre);
|
||||
expect(region, titre).toBeDefined();
|
||||
expect(fond(region), titre).toEqual(jeton('--couleur-carte-fond'));
|
||||
expect(tableau.compareDocumentPosition(region) & Node.DOCUMENT_POSITION_FOLLOWING, titre).toBeTruthy();
|
||||
}
|
||||
expect(nommees.get(titres[0]).contains(dans(m, '[data-commande="creer"]'))).toBe(true);
|
||||
expect(nommees.get(titres[1]).contains(dans(m, '[data-dossier-travail]'))).toBe(true);
|
||||
expect(nommees.get(titres[2]).contains(dans(m, '[data-demonstration]'))).toBe(true);
|
||||
expect(nommees.get(t('accueil.liste.titre'))?.contains(tableau)).toBe(true);
|
||||
});
|
||||
|
||||
test('répertoire vide : ni tuiles, ni tableau, ni résumé ; la création et les démonstrations', async () => {
|
||||
const m = await monter();
|
||||
expect(groupeDesTuiles(m)).toBeNull();
|
||||
expect(dans(m, '[data-ecran="accueil"] table')).toBeNull();
|
||||
expect(tous(m, '[data-ecran="accueil"] p').some((p) => p.textContent.includes(t('accueil.entete.resume', { evenements: 0, participants: 0 })))).toBe(false);
|
||||
expect(atteint(dans(m, '[data-commande="creer"]'))).toBe(true);
|
||||
expect(atteint(dans(m, '[data-demonstration]'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,16 @@
|
|||
// en lecture par la séance ; ses autres gestes passent par la façade (A3),
|
||||
// et il ne lit ni n'écrit rien lui-même.
|
||||
//
|
||||
// De haut en bas : l'en-tête — le titre, le résumé de la liste
|
||||
// (resumeDeLaListe), la version, puis « Importer… » et « Créer un
|
||||
// événement », l'action principale, qui porte le focus sur le champ du nom
|
||||
// de la carte de création — ; le refus de la lecture et les messages du
|
||||
// dossier de travail ; les tuiles des filtres par état du plan et le
|
||||
// tableau, tant que la liste a des lignes ; enfin les cartes — la création,
|
||||
// le dossier de travail, les démonstrations, la corbeille. Le filtre et le
|
||||
// groupement forment la vue de la liste, que la séance retient
|
||||
// (vueDeLaListe) et que rien n'écrit : un retour à la liste la retrouve.
|
||||
//
|
||||
// Les lignes viennent d'App, qui lit la liste par la façade une fois la
|
||||
// détermination du dossier de travail réglée, puis leurs résumés, qu'App
|
||||
// reçoit un événement après l'autre (resumes, par base) ; refusLecture est
|
||||
|
|
@ -28,16 +38,30 @@
|
|||
// ignore une seconde demande et se dit occupée.
|
||||
import { getContext, onDestroy, untrack } from 'svelte';
|
||||
import { versionVisible } from '../apropos.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import Corbeille from './Corbeille.svelte';
|
||||
import Demonstrations from './Demonstrations.svelte';
|
||||
import DossierTravail from './DossierTravail.svelte';
|
||||
import Filtres from './Filtres.svelte';
|
||||
import ListeEvenements from './ListeEvenements.svelte';
|
||||
import Message from './Message.svelte';
|
||||
import MessagesDossier from './MessagesDossier.svelte';
|
||||
import Recuperation from './Recuperation.svelte';
|
||||
import { champDuRefus, premiereAnnonce, refusDeLaListe, surLaListe } from './modele.js';
|
||||
import {
|
||||
champDuRefus,
|
||||
premiereAnnonce,
|
||||
refusDeLaListe,
|
||||
resumeDeLaListe,
|
||||
retenirVue,
|
||||
surLaListe,
|
||||
tuilesDeLaListe,
|
||||
vueDeLaListe,
|
||||
} from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const facade = getContext('facade');
|
||||
const seance = getContext('seance');
|
||||
const relireListe = getContext('relireListe');
|
||||
|
|
@ -70,6 +94,19 @@
|
|||
const question = $derived(issue?.dossier.question ?? null);
|
||||
const avis = $derived(annoncer ? (issue?.avis ?? []) : []);
|
||||
|
||||
// La vue de la liste, { filtre, groupement }, celle que la séance retient.
|
||||
let vue = $state.raw(vueDeLaListe(facade));
|
||||
function changerVue(changement) {
|
||||
vue = { ...vue, ...changement };
|
||||
retenirVue(facade, vue);
|
||||
}
|
||||
const filtrer = (filtre) => changerVue({ filtre });
|
||||
const grouper = (groupement) => changerVue({ groupement });
|
||||
|
||||
// Le champ du nom de la création, où mène « Créer un événement ».
|
||||
let champNom = $state(null);
|
||||
const allerALaCreation = () => champNom?.focus();
|
||||
|
||||
let detruit = false;
|
||||
const desabonner = seance.subscribe((etat) => {
|
||||
racine = etat.racine;
|
||||
|
|
@ -207,21 +244,33 @@
|
|||
<Recuperation {...recuperation} retour={() => (recuperation = null)} />
|
||||
{:else}
|
||||
<section class="accueil" data-ecran="accueil">
|
||||
<h1>{t('coquille.liste')}</h1>
|
||||
<p class="version" data-version>
|
||||
<span>{t('coquille.version.libelle')}</span>
|
||||
<span>{versionVisible(provenance, version, t)}</span>
|
||||
</p>
|
||||
<header class="entete">
|
||||
<div class="titres">
|
||||
<h1>{t('coquille.liste')}</h1>
|
||||
{#if lignes.length > 0}
|
||||
<p class="resume">{t('accueil.entete.resume', resumeDeLaListe(lignes))}</p>
|
||||
{/if}
|
||||
<p class="version" data-version>
|
||||
<span>{t('coquille.version.libelle')}</span>
|
||||
<span>{versionVisible(provenance, version, t)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="commandes-entete">
|
||||
<Commande cle="accueil.operation.importer" executer={importerFichier} />
|
||||
<Commande cle="accueil.entete.creer" nom="allerCreation" variante="principale" executer={allerALaCreation} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if refusLecture !== null}
|
||||
<p class="refus" data-refus="accueil">{t(refusLecture.code, refusLecture.details)}</p>
|
||||
<Message ton="refus">
|
||||
<p data-refus="accueil">{t(refusLecture.code, refusLecture.details)}</p>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if issue !== null}
|
||||
<DossierTravail
|
||||
<MessagesDossier
|
||||
{issue}
|
||||
{racine}
|
||||
{avis}
|
||||
explorateur={facade.explorateur()}
|
||||
{repondre}
|
||||
{reponseEnCours}
|
||||
{importerDepuisAutre}
|
||||
|
|
@ -230,57 +279,66 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
<ListeEvenements {lignes} {resumes} {ouvrir} {refusOuverture} {apresOperation} />
|
||||
{#if lignes.length > 0}
|
||||
<Filtres tuiles={tuilesDeLaListe(lignes, vue.filtre, format)} {filtrer} />
|
||||
{/if}
|
||||
|
||||
<section class="creation" aria-labelledby={`${uid}-creation`}>
|
||||
<h2 id={`${uid}-creation`}>{t('accueil.creation.titre')}</h2>
|
||||
<form novalidate onsubmit={creer}>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-nom`}>{t('accueil.creation.nom')}</label>
|
||||
<input
|
||||
id={`${uid}-nom`}
|
||||
type="text"
|
||||
data-champ="nom"
|
||||
bind:value={nom}
|
||||
aria-invalid={champRefuse === 'nom' ? 'true' : undefined}
|
||||
aria-describedby={champRefuse === 'nom' ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if champRefuse === 'nom'}
|
||||
<Refus id={`${uid}-refus`} refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-date`}>{t('accueil.creation.date')}</label>
|
||||
<input
|
||||
id={`${uid}-date`}
|
||||
type="text"
|
||||
data-champ="date"
|
||||
bind:value={date}
|
||||
aria-invalid={champRefuse === 'date' ? 'true' : undefined}
|
||||
aria-describedby={champRefuse === 'date' ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if champRefuse === 'date'}
|
||||
<Refus id={`${uid}-refus`} refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="creer"
|
||||
aria-busy={creationEnCours ? 'true' : undefined}
|
||||
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
|
||||
>
|
||||
{#if refusCreation !== null && champRefuse === null}
|
||||
<Refus refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
<Commande cle="accueil.operation.importer" executer={importerFichier} />
|
||||
</section>
|
||||
<ListeEvenements {lignes} {resumes} {vue} {grouper} {ouvrir} {refusOuverture} {apresOperation} />
|
||||
|
||||
<Demonstrations {demonstrations} {charger} refus={refusDemonstration} />
|
||||
<div class="cartes">
|
||||
<Carte titre={t('accueil.creation.titre')} icone="ajouter">
|
||||
<form novalidate onsubmit={creer}>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-nom`}>{t('accueil.creation.nom')}</label>
|
||||
<input
|
||||
id={`${uid}-nom`}
|
||||
type="text"
|
||||
data-champ="nom"
|
||||
bind:this={champNom}
|
||||
bind:value={nom}
|
||||
aria-invalid={champRefuse === 'nom' ? 'true' : undefined}
|
||||
aria-describedby={champRefuse === 'nom' ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if champRefuse === 'nom'}
|
||||
<Refus id={`${uid}-refus`} refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="champ">
|
||||
<label for={`${uid}-date`}>{t('accueil.creation.date')}</label>
|
||||
<input
|
||||
id={`${uid}-date`}
|
||||
type="text"
|
||||
data-champ="date"
|
||||
bind:value={date}
|
||||
aria-invalid={champRefuse === 'date' ? 'true' : undefined}
|
||||
aria-describedby={champRefuse === 'date' ? `${uid}-refus` : undefined}
|
||||
/>
|
||||
{#if champRefuse === 'date'}
|
||||
<Refus id={`${uid}-refus`} refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="commandes">
|
||||
<button
|
||||
type="submit"
|
||||
data-commande="creer"
|
||||
aria-busy={creationEnCours ? 'true' : undefined}
|
||||
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
|
||||
>
|
||||
{#if refusCreation !== null && champRefuse === null}
|
||||
<Refus refus={refusCreation} />
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</Carte>
|
||||
|
||||
<Corbeille contenu={corbeille} />
|
||||
{#if issue !== null}
|
||||
<DossierTravail {question} {racine} explorateur={facade.explorateur()} />
|
||||
{/if}
|
||||
|
||||
<Demonstrations {demonstrations} {charger} refus={refusDemonstration} />
|
||||
|
||||
<Corbeille contenu={corbeille} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
|
@ -288,43 +346,81 @@
|
|||
.accueil {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
gap: var(--espace-5);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
/* L'en-tête : les titres à gauche, les deux commandes à droite ; elles
|
||||
passent dessous quand la largeur manque. */
|
||||
.entete {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--espace-3) var(--espace-5);
|
||||
}
|
||||
|
||||
.titres {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.resume {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.version {
|
||||
display: flex;
|
||||
gap: var(--espace-1);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.refus {
|
||||
color: var(--couleur-refus);
|
||||
.commandes-entete {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
/* Les cartes qui suivent la liste : autant de colonnes que la largeur en
|
||||
admet, chacune de 19 rem au moins. */
|
||||
.cartes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
align-items: start;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
.creation,
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
align-items: stretch;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -333,10 +429,12 @@
|
|||
}
|
||||
|
||||
input {
|
||||
min-height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
|
|
@ -352,20 +450,24 @@
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* « Créer l'événement » : l'action de la carte, pleine de la teinte de
|
||||
l'action, à hauteur de cible. */
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
min-height: var(--hauteur-cible);
|
||||
padding: var(--espace-2) var(--espace-4);
|
||||
border: 1px solid var(--couleur-action-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-action-fond);
|
||||
color: var(--couleur-action-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
border-color: var(--couleur-action-texte);
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -9,57 +9,57 @@
|
|||
// vide ne montre rien.
|
||||
//
|
||||
// contenu : [{ dossier, chemin, fichiers }] de facade.corbeille().
|
||||
//
|
||||
// La corbeille est une carte titrée, l'explication en sous-titre ; elle
|
||||
// n'offre aucune commande.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
|
||||
let { contenu } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
</script>
|
||||
|
||||
{#if contenu.length > 0}
|
||||
<section class="corbeille" data-corbeille aria-labelledby={`${uid}-titre`}>
|
||||
<h2 id={`${uid}-titre`}>{t('accueil.corbeille.titre')}</h2>
|
||||
<p>{t('accueil.corbeille.explication')}</p>
|
||||
<ul class="dossiers">
|
||||
{#each contenu as { dossier, chemin, fichiers } (dossier)}
|
||||
<li data-corbeille-dossier={dossier}>
|
||||
<p class="chemin">{chemin}</p>
|
||||
<ul class="fichiers">
|
||||
{#each fichiers as nom (nom)}
|
||||
<li>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<div class="corbeille" data-corbeille>
|
||||
<Carte titre={t('accueil.corbeille.titre')} sousTitre={t('accueil.corbeille.explication')} icone="dossier">
|
||||
<ul class="dossiers">
|
||||
{#each contenu as { dossier, chemin, fichiers } (dossier)}
|
||||
<li data-corbeille-dossier={dossier}>
|
||||
<p class="chemin">{chemin}</p>
|
||||
<ul class="fichiers">
|
||||
{#each fichiers as nom (nom)}
|
||||
<li>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Carte>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.corbeille,
|
||||
.corbeille {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dossiers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.dossiers {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chemin {
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
|
@ -67,5 +67,7 @@
|
|||
margin: 0;
|
||||
padding-left: var(--espace-4);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@
|
|||
// se dit : sous elle, un message [data-attente-demonstration] nomme celle
|
||||
// qui se charge, et le bouton le désigne par aria-describedby. Le message
|
||||
// part avec le chargement.
|
||||
//
|
||||
// Les démonstrations sont une carte titrée, l'explication en sous-titre ;
|
||||
// chacune un bouton secondaire, sur une rangée qui passe à la ligne.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
||||
let { demonstrations, charger, refus } = $props();
|
||||
|
|
@ -47,9 +51,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<section class="demonstrations" aria-labelledby={`${uid}-titre`}>
|
||||
<h2 id={`${uid}-titre`}>{t('accueil.demonstrations.titre')}</h2>
|
||||
<p>{t('accueil.demonstrations.explication')}</p>
|
||||
<Carte titre={t('accueil.demonstrations.titre')} sousTitre={t('accueil.demonstrations.explication')} icone="demarrer">
|
||||
<ul>
|
||||
{#each demonstrations as { cle, nom } (cle)}
|
||||
<li>
|
||||
|
|
@ -72,31 +74,21 @@
|
|||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Carte>
|
||||
|
||||
<style>
|
||||
.demonstrations,
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
/* Les démonstrations se rangent en ligne, et passent à la ligne quand la
|
||||
largeur manque : en colonne, quatre entrées repoussent la dernière
|
||||
au-dessous d'une fenêtre de 700 px de haut. */
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
|
@ -112,8 +104,8 @@
|
|||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
|
|
@ -121,6 +113,10 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
|
|
|
|||
|
|
@ -2,139 +2,60 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le dossier de travail sur la liste (§ 8.6, § 8.8), dans cet ordre : la
|
||||
// question qui attend sa réponse — elle vient avant toute liste, et nomme
|
||||
// le dossier, la cause et ce qu'il porte ; la proposition d'importer
|
||||
// l'autre emplacement, quand le dossier retenu est neuf et vide — son
|
||||
// compte, puis son chemin dans son propre nœud (data-autre-chemin) —, avec
|
||||
// la phrase sur les originaux ; les avis de la détermination, non modaux ;
|
||||
// puis le chemin du dossier, en entier, et la commande qui l'ouvre dans
|
||||
// l'explorateur, ou, sous web, la phrase qui dit pourquoi elle manque.
|
||||
// La carte du dossier de travail (§ 8.6) : son chemin, en entier, dans son
|
||||
// propre nœud (data-dossier-travail), et la commande qui l'ouvre dans
|
||||
// l'explorateur, ou, sous web, la phrase qui dit pourquoi elle manque, en
|
||||
// message « avis ». Tant qu'une question attend, aucun dossier n'est
|
||||
// retenu : la carte n'existe pas, la question est en tête de la liste
|
||||
// (MessagesDossier).
|
||||
//
|
||||
// issue : la détermination { dossier, avis } de la façade ; racine : le
|
||||
// dossier de travail de la séance, null tant qu'une question attend ;
|
||||
// avis : ceux à dire, vides hors du premier accueil de la séance ;
|
||||
// explorateur : { disponible, raison } de la façade ; repondre(choix) et
|
||||
// importerDepuisAutre(), les gestes de l'accueil ; reponseEnCours : vrai
|
||||
// pendant une réponse, qui retire les réponses offertes ; importAutre : les
|
||||
// comptes du dernier import, ou null ; lignesPresentes : le dossier retenu
|
||||
// porte déjà des événements, et l'import de l'autre emplacement ne se
|
||||
// propose plus.
|
||||
// question : celle qui attend sa réponse, ou null ; racine : le dossier de
|
||||
// travail de la séance, null tant qu'une question attend ; explorateur :
|
||||
// { disponible, raison } de la façade.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { reponsesDe, surLaListe } from './modele.js';
|
||||
import Message from './Message.svelte';
|
||||
import { surLaListe } from './modele.js';
|
||||
|
||||
let {
|
||||
issue,
|
||||
racine,
|
||||
avis,
|
||||
explorateur,
|
||||
repondre,
|
||||
reponseEnCours,
|
||||
importerDepuisAutre,
|
||||
importAutre,
|
||||
lignesPresentes,
|
||||
} = $props();
|
||||
let { question, racine, explorateur } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const facade = getContext('facade');
|
||||
const uid = $props.id();
|
||||
|
||||
const question = $derived(issue.dossier.question);
|
||||
const autre = $derived(issue.dossier.autre);
|
||||
const reponses = $derived(question === null ? [] : reponsesDe(question));
|
||||
</script>
|
||||
|
||||
<section class="dossier" data-dossier>
|
||||
{#if question !== null}
|
||||
<div class="question" role="group" data-question={question.code} aria-labelledby={`${uid}-question`}>
|
||||
<p id={`${uid}-question`}>{t(question.code, question)}</p>
|
||||
{#if !reponseEnCours}
|
||||
<div class="reponses">
|
||||
{#each reponses as { choix, cle } (choix)}
|
||||
<Commande {cle} executer={() => repondre(choix)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if autre !== null && !lignesPresentes}
|
||||
<div class="autre" data-autre>
|
||||
<p>{t('accueil.autre.proposition', { evenements: autre.evenements })}</p>
|
||||
<p class="donnee" data-autre-chemin>{autre.racine.chemin}</p>
|
||||
<p>{t('accueil.autre.originaux')}</p>
|
||||
<Commande cle="accueil.autre.importer" nom="importerAutre" executer={importerDepuisAutre} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if importAutre !== null}
|
||||
<p class="avis" role="status" data-avertissement="accueil.autre.resultat">{t('accueil.autre.resultat', importAutre)}</p>
|
||||
{/if}
|
||||
|
||||
{#each avis as { code, details }, rang (rang)}
|
||||
<p class="avis" role="status" data-avertissement={code}>{t(code, details)}</p>
|
||||
{/each}
|
||||
|
||||
{#if question === null && racine !== null}
|
||||
<div class="chemin">
|
||||
<h2 id={`${uid}-titre`}>{t('accueil.dossier.titre')}</h2>
|
||||
<p data-dossier-travail>{racine.chemin}</p>
|
||||
{#if question === null && racine !== null}
|
||||
<Carte titre={t('accueil.dossier.titre')} icone="dossier">
|
||||
<div class="dossier" data-dossier>
|
||||
<p class="chemin" data-dossier-travail>{racine.chemin}</p>
|
||||
{#if explorateur.disponible}
|
||||
<Commande cle="accueil.dossier.explorateur" executer={() => surLaListe(() => facade.ouvrirExplorateur())} />
|
||||
{:else}
|
||||
<p class="avis" data-avertissement={explorateur.raison}>{t(explorateur.raison)}</p>
|
||||
<Message ton="avis">
|
||||
<p data-avertissement={explorateur.raison}>{t(explorateur.raison)}</p>
|
||||
</Message>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</Carte>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dossier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
.question,
|
||||
.autre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-3);
|
||||
border: 2px solid var(--couleur-avis);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
.reponses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.avis {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
}
|
||||
|
||||
.chemin {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
[data-dossier-travail],
|
||||
.donnee {
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
font-size: var(--taille-texte-petit);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
93
src/interface/accueil/Filtres.svelte
Normal file
93
src/interface/accueil/Filtres.svelte
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les tuiles des filtres de la liste par état du plan (§ 8.1, § 9) : un
|
||||
// groupe nommé de boutons-filtres, un par tuile de tuilesDeLaListe, dans
|
||||
// son ordre — tous les événements, puis chaque état dans l'ordre du § 9.
|
||||
// Chaque tuile porte la pastille de son filtre, le nombre de lignes qu'il
|
||||
// retient et son aide ; data-commande="filtrer" et data-cible, son filtre.
|
||||
// La tuile du filtre courant est pressée (aria-pressed), et se distingue
|
||||
// par un filet double de la teinte de l'action, non par la seule couleur.
|
||||
//
|
||||
// tuiles : celles de tuilesDeLaListe (modele.js) ; filtrer(filtre) : le
|
||||
// geste de l'accueil, qui ne lit ni n'écrit rien.
|
||||
import { getContext } from 'svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
|
||||
let { tuiles, filtrer } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<div class="tuiles" role="group" aria-label={t('accueil.filtre.groupe')}>
|
||||
{#each tuiles as { filtre, cle, aide, compte, presse, pastille } (filtre)}
|
||||
<button
|
||||
type="button"
|
||||
class="tuile"
|
||||
data-commande="filtrer"
|
||||
data-cible={filtre}
|
||||
aria-pressed={presse ? 'true' : 'false'}
|
||||
onclick={() => filtrer(filtre)}
|
||||
><Pastille sorte={pastille.sorte} valeur={pastille.valeur} icone={pastille.icone} libelle={t(cle)} /><span class="compte"
|
||||
>{compte}</span
|
||||
><span class="aide">{t(aide)}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Cinq tuiles de même largeur sur une rangée ; elles passent à la ligne
|
||||
quand la largeur manque. */
|
||||
.tuiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10.5rem), 1fr));
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
/* Une tuile : une carte qui se presse. Le filet passe de 1 à 2 px quand
|
||||
elle est pressée ; le retrait perd 1 px, de sorte que rien ne bouge. */
|
||||
.tuile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
min-width: 0;
|
||||
min-height: var(--hauteur-cible);
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-3);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
box-shadow: var(--ombre-carte);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tuile:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.tuile[aria-pressed='true'] {
|
||||
padding: calc(var(--espace-3) - 1px) calc(var(--espace-4) - 1px);
|
||||
border: 2px solid var(--couleur-action-fond);
|
||||
}
|
||||
|
||||
.tuile:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.compte {
|
||||
font-size: var(--taille-titre-grand);
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.aide {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,57 +2,83 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La liste des événements (§ 8.1) : une ligne par événement du dossier de
|
||||
// travail, dans l'ordre de la façade, que désigne data-evenement. Chaque
|
||||
// ligne porte la commande qui l'ouvre en lecture — son nom —, ses cinq
|
||||
// autres informations et les deux mesures de son retenu (COLONNES_DU_RETENU)
|
||||
// sous leur intitulé, chacune dans son propre nœud (data-colonne), la
|
||||
// meilleure proposition de son classement (MEILLEURE) — un groupe
|
||||
// data-meilleure sous le titre et les libellés du bloc de la génération,
|
||||
// l'identifiant dans data-meilleure-id, les collisions cumulées dans
|
||||
// data-meilleure-collisions, « — » l'un et l'autre sans proposition
|
||||
// classée —, ses signalements traduits — les bases qu'un doublon
|
||||
// nomme suivent son texte, chacune dans son propre nœud (data-doublon) —,
|
||||
// le refus de son ouverture, et ses opérations. Un dossier sans événement
|
||||
// n'affiche pas de liste vide : la création et les démonstrations de
|
||||
// l'accueil en tiennent lieu.
|
||||
// La liste des événements (§ 8.1), en tableau dans une carte titrée : une
|
||||
// ligne par événement du dossier de travail que le filtre retient, que
|
||||
// désigne data-evenement, rangée dans les groupes de groupesDeLaListe —
|
||||
// un corps de tableau par groupe, son intitulé (la pastille de l'état et
|
||||
// « n événement(s) ») en tête, dans l'ordre du § 9 ; un seul corps, sans
|
||||
// intitulé, sans groupement. Le choix du groupement est dans l'en-tête de
|
||||
// la carte. Un filtre qui ne retient aucune ligne le dit. Un dossier sans
|
||||
// événement n'affiche ni carte ni tableau : la création et les
|
||||
// démonstrations de l'accueil en tiennent lieu.
|
||||
//
|
||||
// Les en-têtes de colonnes sont groupés : l'événement — nom, état,
|
||||
// participants, dernière modification —, le placement retenu, à la teinte
|
||||
// de Retenu — ses deux mesures (COLONNES_DU_RETENU) —, la meilleure
|
||||
// proposition du classement, à la teinte de Proposé, sous le titre et les
|
||||
// libellés du bloc de la génération (MEILLEURE) — son identifiant et ses
|
||||
// collisions cumulées —, puis les actions. Chaque valeur est dans son
|
||||
// propre nœud : data-colonne, data-meilleure-id, data-meilleure-collisions,
|
||||
// « — » sans valeur. Le nom est l'en-tête de sa ligne ; la date et le
|
||||
// chemin le suivent en petite ligne, chacun précédé de son intitulé, que
|
||||
// lisent les technologies d'assistance et que l'œil ne voit pas, puis ses
|
||||
// signalements traduits — les bases qu'un doublon nomme suivent son
|
||||
// texte, chacune dans son propre nœud (data-doublon). L'état est une
|
||||
// pastille. La cellule des actions porte « Ouvrir », qui ouvre la ligne
|
||||
// en lecture, ses opérations, et le refus de son ouverture. « Ouvrir » se
|
||||
// nomme par aria-labelledby de son propre texte puis du nom de sa ligne :
|
||||
// chaque ligne a son « Ouvrir Bal des Grisailles », distinct des autres.
|
||||
// Les identifiants viennent du rang du groupe et du rang de la ligne :
|
||||
// une base est un nom de fichier, qui n'est pas un identifiant.
|
||||
//
|
||||
// lignes : LigneListe[] de la façade ; resumes : les résumés reçus de
|
||||
// facade.resumer, par base — une ligne sans le sien attend, et chacune de
|
||||
// ses quatre valeurs de résumé porte le signe du chargement (Chargement),
|
||||
// décoratif, dans une cellule qui se dit occupée (aria-busy), jusqu'à ce
|
||||
// qu'il arrive. La région d'état qui suit la liste (data-chargement-liste)
|
||||
// existe tant que la liste a des lignes, et écrit « accueil.chargement »
|
||||
// tant qu'une ligne attend : le chargement s'annonce une fois pour la
|
||||
// liste, et sa fin la vide. ouvrir(ligne) : le geste de l'accueil ;
|
||||
// refusOuverture : { base, refus } du dernier échec, ou null ;
|
||||
// apresOperation() : à appeler quand une opération a changé le dossier.
|
||||
// qu'il arrive. La région d'état qui suit le tableau
|
||||
// (data-chargement-liste) existe tant que la liste a des lignes, et écrit
|
||||
// « accueil.chargement » tant qu'une ligne attend : le chargement
|
||||
// s'annonce une fois pour la liste, et sa fin la vide. vue : { filtre,
|
||||
// groupement } de l'accueil ; grouper(groupement) : le geste du choix ;
|
||||
// ouvrir(ligne) : le geste de l'accueil ; refusOuverture : { base, refus }
|
||||
// du dernier échec, ou null ; apresOperation() : à appeler quand une
|
||||
// opération a changé le dossier.
|
||||
//
|
||||
// L'intitulé de chaque mesure du retenu, le titre de la meilleure
|
||||
// proposition et l'intitulé de ses collisions s'expliquent dans
|
||||
// l'infobulle de la liste, une seule (Infobulle, primitive) : chaque terme
|
||||
// (data-terme, que pose le fragment termeExplique) se
|
||||
// focalise au clavier, son survol ou son focus ouvre l'explication, le
|
||||
// (data-terme, que pose le fragment termeExplique), un en-tête de colonne,
|
||||
// se focalise au clavier, son survol ou son focus ouvre l'explication, le
|
||||
// quitter la referme quand elle est encore la sienne, Échap aussi. Le
|
||||
// terme expliqué porte aria-describedby vers elle, et lui seul.
|
||||
//
|
||||
// Le tableau tient dans sa carte à 1024 px de large ; plus étroit, il
|
||||
// défile dans son propre conteneur, et la page ne défile pas en largeur.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Infobulle from '../primitives/Infobulle.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import Chargement from './Chargement.svelte';
|
||||
import Operations from './Operations.svelte';
|
||||
import { COLONNES_DU_RETENU, MEILLEURE, ligneAffichee } from './modele.js';
|
||||
import { COLONNES_DU_RETENU, MEILLEURE, choixDeGroupement, groupesDeLaListe, ligneAffichee } from './modele.js';
|
||||
|
||||
let { lignes, resumes, ouvrir, refusOuverture, apresOperation } = $props();
|
||||
let { lignes, resumes, vue, grouper, ouvrir, refusOuverture, apresOperation } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
|
||||
// Une ligne au moins attend son résumé.
|
||||
const enChargement = $derived(lignes.some((ligne) => ligneAffichee(ligne, format, resumes.get(ligne.base)).enAttente));
|
||||
const groupes = $derived(groupesDeLaListe(lignes, vue.filtre, vue.groupement));
|
||||
// Le nombre de colonnes du tableau, que l'intitulé d'un groupe couvre :
|
||||
// les quatre de l'événement, les deux du retenu, les deux de la meilleure
|
||||
// proposition, les actions.
|
||||
const COLONNES = 9;
|
||||
|
||||
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
|
||||
// désignation '<base>:<colonne>' et la clé de son explication — ; null
|
||||
// sans infobulle.
|
||||
// Le terme expliqué : {ancre, terme, cle, details} — son élément, son nom
|
||||
// et la clé de son explication — ; null sans infobulle.
|
||||
const uid = $props.id();
|
||||
const idExplication = `${uid}-explication`;
|
||||
let expliquee = $state(null);
|
||||
|
|
@ -67,214 +93,394 @@
|
|||
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
|
||||
</script>
|
||||
|
||||
<!-- Un terme expliqué : nom est son data-terme, cle son intitulé,
|
||||
explication la clé que l'infobulle montre ; id, celui qu'un groupe
|
||||
nomme par aria-labelledby, ou undefined. -->
|
||||
<!-- Une valeur de résumé : le signe du chargement tant que la ligne
|
||||
attend, sa valeur ensuite. -->
|
||||
{#snippet resume(affichee, valeur)}
|
||||
{#if affichee.enAttente}<Chargement />{:else}{valeur}{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet termeExplique(base, nom, cle, explication, id)}
|
||||
{@const terme = `${base}:${nom}`}
|
||||
<!-- Un terme expliqué : nom est son data-terme, cle son intitulé,
|
||||
explication la clé que l'infobulle montre. -->
|
||||
{#snippet termeExplique(nom, cle, explication)}
|
||||
<!-- L'intitulé est un terme, non une commande : le focus ne sert qu'à
|
||||
montrer au clavier l'explication que le pointeur montre au survol. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
|
||||
<span
|
||||
{id}
|
||||
class="terme"
|
||||
data-terme={nom}
|
||||
tabindex="0"
|
||||
aria-describedby={decrit(terme)}
|
||||
onmouseenter={(evenement) => expliquer(evenement, terme, explication)}
|
||||
onmouseleave={() => taire(terme)}
|
||||
onfocus={(evenement) => expliquer(evenement, terme, explication)}
|
||||
onblur={() => taire(terme)}>{t(cle)}</span
|
||||
aria-describedby={decrit(nom)}
|
||||
onmouseenter={(evenement) => expliquer(evenement, nom, explication)}
|
||||
onmouseleave={() => taire(nom)}
|
||||
onfocus={(evenement) => expliquer(evenement, nom, explication)}
|
||||
onblur={() => taire(nom)}>{t(cle)}</span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<!-- Le choix du groupement, dans l'en-tête de la carte. -->
|
||||
{#snippet choixDuGroupement()}
|
||||
<label class="groupement">
|
||||
<span>{t('accueil.groupement.libelle')}</span>
|
||||
<select data-champ="groupement" value={vue.groupement} onchange={(evenement) => grouper(evenement.currentTarget.value)}>
|
||||
{#each choixDeGroupement() as { groupement, cle } (groupement)}
|
||||
<option value={groupement}>{t(cle)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/snippet}
|
||||
|
||||
{#if lignes.length > 0}
|
||||
<ul class="liste">
|
||||
{#each lignes as ligne, position (ligne.base)}
|
||||
{@const affichee = ligneAffichee(ligne, format, resumes.get(ligne.base))}
|
||||
{@const occupee = affichee.enAttente ? 'true' : undefined}
|
||||
{@const idMeilleure = `${uid}-meilleure-${position}`}
|
||||
<li class="ligne" data-evenement={ligne.base}>
|
||||
<button type="button" class="ouvrir" data-commande="ouvrir" onclick={() => ouvrir(ligne)}
|
||||
><span data-colonne="nom">{affichee.nom}</span></button
|
||||
>
|
||||
<dl class="informations">
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.date')}</dt>
|
||||
<dd data-colonne="date">{affichee.date}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.etat')}</dt>
|
||||
<dd data-colonne="etat">{t(affichee.etat)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.participants')}</dt>
|
||||
<dd data-colonne="participants">{affichee.participants}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('accueil.colonne.modification')}</dt>
|
||||
<dd data-colonne="modification">{affichee.modification}</dd>
|
||||
</div>
|
||||
{#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)}
|
||||
<div>
|
||||
<dt>{@render termeExplique(ligne.base, colonne, cle, explication, undefined)}</dt>
|
||||
<dd data-colonne={colonne} aria-busy={occupee}>{@render resume(affichee, affichee[colonne])}</dd>
|
||||
</div>
|
||||
<Carte titre={t('accueil.liste.titre')} plein actions={choixDuGroupement}>
|
||||
{#if groupes.length === 0}
|
||||
<p class="vide">{t('accueil.filtre.vide')}</p>
|
||||
{:else}
|
||||
<div class="defilement">
|
||||
<table>
|
||||
<colgroup span="4"></colgroup>
|
||||
<colgroup span="2"></colgroup>
|
||||
<colgroup span="2"></colgroup>
|
||||
<colgroup></colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="colgroup" colspan="4" class="groupe">{t('accueil.entete.evenement')}</th>
|
||||
<th scope="colgroup" colspan="2" class="groupe retenu"
|
||||
><span class="entete-groupe"
|
||||
><span class="signe"><Icone nom="coche" taille={14} /></span><span>{t('accueil.entete.retenu')}</span></span
|
||||
></th
|
||||
>
|
||||
<th scope="colgroup" colspan="2" class="groupe propose"
|
||||
><span class="entete-groupe"
|
||||
><span class="signe"><Icone nom="listePoints" taille={14} /></span>{@render termeExplique(
|
||||
'meilleure',
|
||||
MEILLEURE.titre,
|
||||
MEILLEURE.explication,
|
||||
)}</span
|
||||
></th
|
||||
>
|
||||
<th scope="col" rowspan="2" class="actions">{t('accueil.colonne.actions')}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="col">{t('accueil.colonne.nom')}</th>
|
||||
<th scope="col">{t('accueil.colonne.etat')}</th>
|
||||
<th scope="col" class="nombre">{t('accueil.colonne.participants')}</th>
|
||||
<th scope="col">{t('accueil.colonne.modification')}</th>
|
||||
{#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)}
|
||||
<th scope="col" class="nombre">{@render termeExplique(colonne, cle, explication)}</th>
|
||||
{/each}
|
||||
<th scope="col" class="nombre">{t(MEILLEURE.proposition)}</th>
|
||||
<th scope="col" class="nombre"
|
||||
>{@render termeExplique('meilleureCollisions', MEILLEURE.collisions, MEILLEURE.explicationCollisions)}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
{#each groupes as groupe, rangGroupe (groupe.groupe ?? '')}
|
||||
<tbody>
|
||||
{#if groupe.cle !== null}
|
||||
<tr class="intertitre">
|
||||
<th scope="rowgroup" colspan={COLONNES}
|
||||
><span class="intitule"
|
||||
><Pastille
|
||||
sorte={groupe.pastille.sorte}
|
||||
valeur={groupe.pastille.valeur}
|
||||
icone={groupe.pastille.icone}
|
||||
libelle={t(groupe.cle)}
|
||||
/><span class="compte">{t('accueil.groupe.compte', groupe.details)}</span></span
|
||||
></th
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each groupe.lignes as ligne, rangLigne (ligne.base)}
|
||||
{@const affichee = ligneAffichee(ligne, format, resumes.get(ligne.base))}
|
||||
{@const idLigne = `${uid}-ligne-${rangGroupe}-${rangLigne}`}
|
||||
{@const occupee = affichee.enAttente ? 'true' : undefined}
|
||||
<tr data-evenement={ligne.base}>
|
||||
<th scope="row" class="evenement">
|
||||
<span class="nom" id="{idLigne}-nom" data-colonne="nom">{affichee.nom}</span>
|
||||
<span class="sous-ligne"
|
||||
><span class="date"
|
||||
><span class="cache">{t('accueil.colonne.date')}</span><span class="signe-date"
|
||||
><Icone nom="calendrier" taille={13} /></span
|
||||
><span data-colonne="date">{affichee.date}</span></span
|
||||
><span class="chemin"
|
||||
><span class="cache">{t('accueil.colonne.chemin')}</span><span data-colonne="chemin"
|
||||
>{affichee.chemin}</span
|
||||
></span
|
||||
></span
|
||||
>
|
||||
{#each affichee.signalements as { cle, details, noms }, rang (rang)}
|
||||
<div class="signalement" data-signalement={cle}>
|
||||
<p>{t(cle, details)}</p>
|
||||
{#if noms.length > 0}
|
||||
<ul class="noms">
|
||||
{#each noms as nom (nom)}
|
||||
<li data-doublon={nom}>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</th>
|
||||
<td data-colonne="etat"
|
||||
>{#if ligne.etat !== null}<Pastille sorte="etat" valeur={ligne.etat} libelle={t(affichee.etat)} />{:else}{t(
|
||||
affichee.etat,
|
||||
)}{/if}</td
|
||||
>
|
||||
<td class="nombre" data-colonne="participants">{affichee.participants}</td>
|
||||
<td data-colonne="modification">{affichee.modification}</td>
|
||||
{#each COLONNES_DU_RETENU as { colonne } (colonne)}
|
||||
<td class="nombre" data-colonne={colonne} aria-busy={occupee}>{@render resume(affichee, affichee[colonne])}</td>
|
||||
{/each}
|
||||
<td class="nombre" data-meilleure-id aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.id)}</td>
|
||||
<td class="nombre" data-meilleure-collisions aria-busy={occupee}
|
||||
>{@render resume(affichee, affichee.meilleure.collisions)}</td
|
||||
>
|
||||
<td class="actions">
|
||||
<div class="commandes-ligne">
|
||||
<button
|
||||
type="button"
|
||||
class="ouvrir"
|
||||
id="{idLigne}-ouvrir"
|
||||
aria-labelledby="{idLigne}-ouvrir {idLigne}-nom"
|
||||
data-commande="ouvrir"
|
||||
onclick={() => ouvrir(ligne)}>{t('accueil.ouvrir')}</button
|
||||
>
|
||||
<Operations {ligne} {apresOperation} />
|
||||
</div>
|
||||
{#if refusOuverture !== null && refusOuverture.base === ligne.base}
|
||||
<Refus refus={refusOuverture.refus} />
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{/each}
|
||||
<div class="chemin">
|
||||
<dt>{t('accueil.colonne.chemin')}</dt>
|
||||
<dd data-colonne="chemin">{affichee.chemin}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="meilleure" role="group" data-meilleure aria-labelledby={idMeilleure}>
|
||||
<span class="titre-meilleure"
|
||||
>{@render termeExplique(ligne.base, 'meilleure', MEILLEURE.titre, MEILLEURE.explication, idMeilleure)}</span
|
||||
>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t(MEILLEURE.proposition)}</dt>
|
||||
<dd data-meilleure-id aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.id)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{@render termeExplique(
|
||||
ligne.base,
|
||||
'meilleureCollisions',
|
||||
MEILLEURE.collisions,
|
||||
MEILLEURE.explicationCollisions,
|
||||
undefined,
|
||||
)}
|
||||
</dt>
|
||||
<dd data-meilleure-collisions aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.collisions)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{#each affichee.signalements as { cle, details, noms }, rang (rang)}
|
||||
<div class="signalement" data-signalement={cle}>
|
||||
<p>{t(cle, details)}</p>
|
||||
{#if noms.length > 0}
|
||||
<ul class="noms">
|
||||
{#each noms as nom (nom)}
|
||||
<li data-doublon={nom}>{nom}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if refusOuverture !== null && refusOuverture.base === ligne.base}
|
||||
<Refus refus={refusOuverture.refus} />
|
||||
{/if}
|
||||
<Operations {ligne} {apresOperation} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="chargement" role="status" data-chargement-liste>{#if enChargement}{t('accueil.chargement')}{/if}</p>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="chargement" class:annonce={enChargement} role="status" data-chargement-liste
|
||||
>{#if enChargement}{t('accueil.chargement')}{/if}</p
|
||||
>
|
||||
</Carte>
|
||||
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ligne {
|
||||
.groupement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-3);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
select {
|
||||
min-height: 2.25rem;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.vide {
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* Le conteneur défilant du tableau : un tableau plus large que la carte
|
||||
y défile, la page ne défile pas. */
|
||||
.defilement {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-2);
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: var(--espace-3);
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: var(--espace-3);
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-bottom-color: var(--couleur-carte-filet);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* Les en-têtes de groupes de colonnes : en petites capitales, sur la
|
||||
teinte de l'état qu'ils résument, le signe de l'état devant. */
|
||||
thead .groupe {
|
||||
font-size: var(--taille-intertitre);
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
thead .retenu {
|
||||
background-color: var(--couleur-etat-retenu-fond);
|
||||
}
|
||||
|
||||
thead .retenu .signe {
|
||||
color: var(--couleur-etat-retenu-marque);
|
||||
}
|
||||
|
||||
thead .propose {
|
||||
background-color: var(--couleur-etat-propose-fond);
|
||||
}
|
||||
|
||||
thead .propose .signe {
|
||||
color: var(--couleur-etat-propose-marque);
|
||||
}
|
||||
|
||||
.entete-groupe {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.signe {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.nombre {
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* L'intitulé d'un groupe de lignes, sur le fond sourd de la carte. */
|
||||
.intertitre th {
|
||||
padding-top: var(--espace-2);
|
||||
padding-bottom: var(--espace-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.intitule {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.compte {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* L'en-tête d'une ligne : le nom en gras, au corps du texte, puis la
|
||||
petite ligne de la date et du chemin, et les signalements. */
|
||||
.evenement {
|
||||
min-width: 9rem;
|
||||
font-weight: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.nom {
|
||||
display: block;
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sous-ligne {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 var(--espace-3);
|
||||
margin-top: var(--espace-1);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
}
|
||||
|
||||
.date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signe-date {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chemin {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Un intitulé lu par les technologies d'assistance, hors de la vue : une
|
||||
boîte d'un pixel, rognée, qui ne pousse rien. */
|
||||
.cache {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Les actions : « Ouvrir », puis les opérations, sur une rangée qui
|
||||
passe à la ligne quand la colonne est étroite. « Ouvrir » est la
|
||||
commande principale de la ligne : sa cible a la hauteur des cibles
|
||||
principales. */
|
||||
.actions {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.commandes-ligne {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.ouvrir {
|
||||
min-height: var(--hauteur-cible);
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-titre);
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: bold;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ouvrir:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.ouvrir:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.informations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.informations div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chemin dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* La meilleure proposition du classement : un groupe encadré d'un filet,
|
||||
son titre en gras, puis chaque libellé suivi de sa valeur, en gras,
|
||||
comme dans le bloc de la génération. */
|
||||
.meilleure {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
.titre-meilleure {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.meilleure dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-1) var(--espace-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meilleure dl > div {
|
||||
display: flex;
|
||||
gap: var(--espace-2);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.meilleure dd {
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Un intitulé qui s'explique se signale par un soulignement pointillé ;
|
||||
focalisé, par le contour du focus. */
|
||||
.terme {
|
||||
|
|
@ -291,22 +497,25 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
margin-top: var(--espace-2);
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
}
|
||||
|
||||
.signalement p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chargement {
|
||||
margin: 0;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.noms {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-4);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chargement {
|
||||
padding: 0 var(--espace-5);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
|
||||
.chargement.annonce {
|
||||
padding: var(--espace-2) var(--espace-5);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
100
src/interface/accueil/Message.svelte
Normal file
100
src/interface/accueil/Message.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Un message de l'accueil (§ 8.6, § 8.8, § 12.7) : un avis, une
|
||||
// information ou un refus, en bande sur le fond teinté de son ton, filet
|
||||
// de son ton et trait de gauche de son encre, son signe — une icône de la
|
||||
// marque du ton, cachée aux technologies d'assistance — devant le texte.
|
||||
// Le texte garde l'encre de la page : le signe et le fond redisent le ton,
|
||||
// ils ne le portent pas seuls. Le contenu (children) apporte ses propres
|
||||
// désignations, son rôle d'état et ses commandes.
|
||||
//
|
||||
// ton : 'avis', 'information' ou 'refus' ; un autre lève RangeError au
|
||||
// montage.
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
|
||||
let { ton, children } = $props();
|
||||
|
||||
// L'icône de chaque ton.
|
||||
const ICONES = new Map([
|
||||
['avis', 'triangle'],
|
||||
['information', 'information'],
|
||||
['refus', 'interdit'],
|
||||
]);
|
||||
const icone = $derived.by(() => {
|
||||
if (!ICONES.has(ton)) throw new RangeError(`message : ton inconnu ${JSON.stringify(ton)}`);
|
||||
return ICONES.get(ton);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="message" class:avis={ton === 'avis'} class:information={ton === 'information'} class:refus={ton === 'refus'}>
|
||||
<span class="signe"><Icone nom={icone} taille={18} /></span>
|
||||
<div class="texte">{@render children()}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid;
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--rayon-2);
|
||||
color: var(--couleur-page-texte);
|
||||
}
|
||||
|
||||
.avis {
|
||||
border-color: var(--couleur-avis-filet);
|
||||
border-left-color: var(--couleur-avis);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
}
|
||||
|
||||
.avis .signe {
|
||||
color: var(--couleur-avis-marque);
|
||||
}
|
||||
|
||||
.information {
|
||||
border-color: var(--couleur-information-filet);
|
||||
border-left-color: var(--couleur-information);
|
||||
background-color: var(--couleur-information-fond);
|
||||
}
|
||||
|
||||
.information .signe {
|
||||
color: var(--couleur-information-marque);
|
||||
}
|
||||
|
||||
.refus {
|
||||
border-color: var(--couleur-refus-filet);
|
||||
border-left-color: var(--couleur-refus);
|
||||
background-color: var(--couleur-refus-fond);
|
||||
}
|
||||
|
||||
.refus .signe {
|
||||
color: var(--couleur-refus-marque);
|
||||
}
|
||||
|
||||
/* Le signe se cale sur la première ligne du texte. */
|
||||
.signe {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: calc(1em * var(--police-interligne));
|
||||
}
|
||||
|
||||
.texte {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.texte :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
107
src/interface/accueil/MessagesDossier.svelte
Normal file
107
src/interface/accueil/MessagesDossier.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les messages du dossier de travail en tête de la liste (§ 8.6, § 8.8),
|
||||
// dans cet ordre : la question qui attend sa réponse — un avis, avant toute
|
||||
// liste, qui nomme le dossier, la cause et ce qu'il porte, et offre ses
|
||||
// réponses ; la proposition d'importer l'autre emplacement, quand le
|
||||
// dossier retenu est neuf et vide — une information, son compte, puis son
|
||||
// chemin dans son propre nœud (data-autre-chemin), la phrase sur les
|
||||
// originaux et sa commande ; le compte du dernier import ; puis les avis de
|
||||
// la détermination, non modaux, chacun un message « avis ». Le chemin du
|
||||
// dossier retenu est dans sa carte (DossierTravail).
|
||||
//
|
||||
// issue : la détermination { dossier, avis } de la façade ; avis : ceux à
|
||||
// dire, vides hors du premier accueil de la séance ; repondre(choix) et
|
||||
// importerDepuisAutre(), les gestes de l'accueil ; reponseEnCours : vrai
|
||||
// pendant une réponse, qui retire les réponses offertes ; importAutre : les
|
||||
// comptes du dernier import, ou null ; lignesPresentes : le dossier retenu
|
||||
// porte déjà des événements, et l'import de l'autre emplacement ne se
|
||||
// propose plus.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Message from './Message.svelte';
|
||||
import { reponsesDe } from './modele.js';
|
||||
|
||||
let { issue, avis, repondre, reponseEnCours, importerDepuisAutre, importAutre, lignesPresentes } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const uid = $props.id();
|
||||
|
||||
const question = $derived(issue.dossier.question);
|
||||
const autre = $derived(issue.dossier.autre);
|
||||
const reponses = $derived(question === null ? [] : reponsesDe(question));
|
||||
</script>
|
||||
|
||||
{#if question !== null || (autre !== null && !lignesPresentes) || importAutre !== null || avis.length > 0}
|
||||
<div class="messages">
|
||||
{#if question !== null}
|
||||
<Message ton="avis">
|
||||
<div class="question" role="group" data-question={question.code} aria-labelledby={`${uid}-question`}>
|
||||
<p id={`${uid}-question`}>{t(question.code, question)}</p>
|
||||
{#if !reponseEnCours}
|
||||
<div class="reponses">
|
||||
{#each reponses as { choix, cle } (choix)}
|
||||
<Commande {cle} executer={() => repondre(choix)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#if autre !== null && !lignesPresentes}
|
||||
<Message ton="information">
|
||||
<div class="autre" data-autre>
|
||||
<p>{t('accueil.autre.proposition', { evenements: autre.evenements })}</p>
|
||||
<p class="donnee" data-autre-chemin>{autre.racine.chemin}</p>
|
||||
<p>{t('accueil.autre.originaux')}</p>
|
||||
<Commande cle="accueil.autre.importer" nom="importerAutre" executer={importerDepuisAutre} />
|
||||
</div>
|
||||
</Message>
|
||||
{/if}
|
||||
{#if importAutre !== null}
|
||||
<Message ton="information">
|
||||
<p role="status" data-avertissement="accueil.autre.resultat">{t('accueil.autre.resultat', importAutre)}</p>
|
||||
</Message>
|
||||
{/if}
|
||||
|
||||
{#each avis as { code, details }, rang (rang)}
|
||||
<Message ton="avis">
|
||||
<p role="status" data-avertissement={code}>{t(code, details)}</p>
|
||||
</Message>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.question,
|
||||
.autre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.reponses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.donnee {
|
||||
font-weight: bold;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -13,7 +13,8 @@
|
|||
// opération réussie. Une validation en cours ne se relance pas : une
|
||||
// seconde soumission pendant l'écriture ne fait rien, et le bouton de
|
||||
// validation se dit occupé ; supprimer passe par la Commande, qui a la
|
||||
// même garde.
|
||||
// même garde. Dans la cellule des actions du tableau, les trois commandes
|
||||
// sont discrètes, à côté de « Ouvrir » ; la saisie s'ouvre à leur place.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
|
@ -76,9 +77,14 @@
|
|||
|
||||
<div class="operations">
|
||||
{#if saisie === null}
|
||||
<Commande cle="accueil.operation.copier" executer={() => ouvrirSaisie('copier')} />
|
||||
<Commande cle="accueil.operation.renommer" executer={() => ouvrirSaisie('renommer')} />
|
||||
<Commande cle="accueil.operation.supprimer" confirmer="accueil.operation.confirmerSuppression" executer={supprimer} />
|
||||
<Commande cle="accueil.operation.copier" variante="discrete" executer={() => ouvrirSaisie('copier')} />
|
||||
<Commande cle="accueil.operation.renommer" variante="discrete" executer={() => ouvrirSaisie('renommer')} />
|
||||
<Commande
|
||||
cle="accueil.operation.supprimer"
|
||||
variante="discrete"
|
||||
confirmer="accueil.operation.confirmerSuppression"
|
||||
executer={supprimer}
|
||||
/>
|
||||
{:else}
|
||||
<form novalidate onsubmit={valider}>
|
||||
<div class="champ">
|
||||
|
|
@ -117,14 +123,14 @@
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
form,
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
|
|
@ -132,12 +138,18 @@
|
|||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 20em;
|
||||
width: 14rem;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
|
|
@ -150,14 +162,27 @@
|
|||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
/* La validation : l'action de la saisie, pleine de la teinte de
|
||||
l'action. */
|
||||
button[type='submit'] {
|
||||
border-color: var(--couleur-action-fond);
|
||||
background-color: var(--couleur-action-fond);
|
||||
color: var(--couleur-action-texte);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
|
|
@ -166,7 +191,10 @@
|
|||
|
||||
button[aria-disabled='true'] {
|
||||
border-style: dashed;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
button[type='submit'][aria-disabled='true'] {
|
||||
border-color: var(--couleur-action-texte);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
//
|
||||
// base, chemin : ceux de la ligne ; refus : { code, details } de
|
||||
// l'ouverture refusée ; retour() : rend la liste.
|
||||
//
|
||||
// Sous le titre de l'écran, une carte : le fichier, la raison, les secours
|
||||
// — le premier offert est l'action principale —, puis le retour.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import { secoursOfferts, surLaListe } from './modele.js';
|
||||
|
|
@ -28,28 +32,44 @@
|
|||
|
||||
<section class="recuperation" data-ecran="recuperation" aria-labelledby={`${uid}-titre`}>
|
||||
<h1 id={`${uid}-titre`}>{t('accueil.recuperation.titre')}</h1>
|
||||
<p class="fichier">
|
||||
<span>{t('accueil.recuperation.fichier')}</span>
|
||||
<span class="chemin">{chemin}</span>
|
||||
</p>
|
||||
<Refus {refus} />
|
||||
{#if offerts.length > 0}
|
||||
<p>{t('accueil.recuperation.offerte')}</p>
|
||||
<ul class="secours">
|
||||
{#each offerts as { source, cle } (source)}
|
||||
<li data-secours={source}>
|
||||
<Commande {cle} executer={() => surLaListe(() => seance.ouvrirSecours(base, source))} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p>{t('accueil.recuperation.aucunSecours')}</p>
|
||||
{/if}
|
||||
<Commande cle="accueil.recuperation.retour" executer={retour} />
|
||||
<Carte>
|
||||
<div class="contenu">
|
||||
<p class="fichier">
|
||||
<span class="intitule">{t('accueil.recuperation.fichier')}</span>
|
||||
<span class="chemin">{chemin}</span>
|
||||
</p>
|
||||
<Refus {refus} />
|
||||
{#if offerts.length > 0}
|
||||
<p>{t('accueil.recuperation.offerte')}</p>
|
||||
<ul class="secours">
|
||||
{#each offerts as { source, cle }, rang (source)}
|
||||
<li data-secours={source}>
|
||||
<Commande
|
||||
{cle}
|
||||
variante={rang === 0 ? 'principale' : 'secondaire'}
|
||||
executer={() => surLaListe(() => seance.ouvrirSecours(base, source))}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p>{t('accueil.recuperation.aucunSecours')}</p>
|
||||
{/if}
|
||||
<div class="retour">
|
||||
<Commande cle="accueil.recuperation.retour" executer={retour} />
|
||||
</div>
|
||||
</div>
|
||||
</Carte>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.recuperation,
|
||||
.recuperation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-5);
|
||||
}
|
||||
|
||||
.contenu,
|
||||
.secours {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -62,20 +82,36 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fichier {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.intitule {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chemin {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.secours {
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.retour {
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-carte-filet);
|
||||
align-self: stretch;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
// ligne de la liste (LigneListe de la façade, evenements.js) devient ses six
|
||||
// informations et ses signalements, mis en forme, puis, son résumé reçu, les
|
||||
// deux mesures de son retenu et la meilleure proposition de son
|
||||
// classement ; une question du dossier de travail, ses réponses ; un état
|
||||
// illisible, ses secours ; un refus de la façade, le refus affiché sur la
|
||||
// liste. Chaque clé rendue est écrite ici en entier : aucun composant ne
|
||||
// compose une clé.
|
||||
// classement ; les lignes de la liste, les tuiles de ses filtres par état
|
||||
// du plan, le résumé de son en-tête et les groupes de son tableau ; une
|
||||
// question du dossier de travail, ses réponses ; un état illisible, ses
|
||||
// secours ; un refus de la façade, le refus affiché sur la liste. Chaque clé
|
||||
// rendue est écrite ici en entier : aucun composant ne compose une clé.
|
||||
import { COLONNE_ANNONCEE } from '../generation/meilleure.js';
|
||||
import { COLONNES } from '../propositions/modele.js';
|
||||
|
||||
|
|
@ -328,3 +329,201 @@ export function premiereAnnonce(seance) {
|
|||
ANNONCEES.add(seance);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- La vue de la liste : filtres, résumé, groupes (§ 8.1, § 9) ------------------
|
||||
|
||||
/**
|
||||
* Les filtres de la liste, dans l'ordre de leurs tuiles : tous les
|
||||
* événements, puis chaque état du plan, dans l'ordre du § 9.
|
||||
*/
|
||||
export const FILTRES = Object.freeze(['tous', ...ETATS]);
|
||||
|
||||
// Le groupe des lignes dont l'état ne se lit pas : il suit les quatre états,
|
||||
// et aucun filtre d'état ne le retient.
|
||||
const ILLISIBLE = 'illisible';
|
||||
|
||||
// La clé du nom et celle de l'aide de chaque tuile ; le nom d'un état est
|
||||
// celui de la colonne État, l'aide redit sa ligne du tableau du § 9.
|
||||
const CLES_FILTRES = new Map([
|
||||
['tous', { cle: 'accueil.filtre.tous', aide: 'accueil.filtre.aide.tous' }],
|
||||
['brouillon', { cle: 'accueil.etat.brouillon', aide: 'accueil.filtre.aide.brouillon' }],
|
||||
['propose', { cle: 'accueil.etat.propose', aide: 'accueil.filtre.aide.propose' }],
|
||||
['retenu', { cle: 'accueil.etat.retenu', aide: 'accueil.filtre.aide.retenu' }],
|
||||
['bloque', { cle: 'accueil.etat.bloque', aide: 'accueil.filtre.aide.bloque' }],
|
||||
]);
|
||||
|
||||
// La clé de l'intitulé de chaque groupe par état : le nom de l'état, ou
|
||||
// celui des états illisibles.
|
||||
const CLES_GROUPES = new Map([...CLES_ETATS, [ILLISIBLE, 'accueil.groupe.illisible']]);
|
||||
|
||||
// La pastille de chaque tuile et de chaque groupe, telle que Pastille la
|
||||
// reçoit : un état porte la sienne ; tous les événements, un statut
|
||||
// d'information à l'icône de la liste ; les états illisibles, un avis à
|
||||
// l'icône du triangle.
|
||||
const PASTILLES = new Map([
|
||||
['tous', { sorte: 'statut', valeur: 'information', icone: 'liste' }],
|
||||
['brouillon', { sorte: 'etat', valeur: 'brouillon', icone: null }],
|
||||
['propose', { sorte: 'etat', valeur: 'propose', icone: null }],
|
||||
['retenu', { sorte: 'etat', valeur: 'retenu', icone: null }],
|
||||
['bloque', { sorte: 'etat', valeur: 'bloque', icone: null }],
|
||||
[ILLISIBLE, { sorte: 'statut', valeur: 'avis', icone: 'triangle' }],
|
||||
]);
|
||||
|
||||
// Les groupements de la liste, dans l'ordre de leur choix, et la clé du
|
||||
// libellé de chacun.
|
||||
const CLES_GROUPEMENTS = new Map([
|
||||
['etat', 'accueil.groupement.etat'],
|
||||
['aucun', 'accueil.groupement.aucun'],
|
||||
]);
|
||||
|
||||
/** Les groupements de la liste : par état du plan, ou aucun. */
|
||||
export const GROUPEMENTS = Object.freeze([...CLES_GROUPEMENTS.keys()]);
|
||||
|
||||
function exigerFiltre(filtre) {
|
||||
if (!FILTRES.includes(filtre)) throw new RangeError(`filtre de la liste inconnu : ${JSON.stringify(filtre)}`);
|
||||
}
|
||||
|
||||
function exigerGroupement(groupement) {
|
||||
if (!CLES_GROUPEMENTS.has(groupement)) throw new RangeError(`groupement de la liste inconnu : ${JSON.stringify(groupement)}`);
|
||||
}
|
||||
|
||||
// Le groupe d'une ligne : son état, ILLISIBLE sans état lu ; un état hors
|
||||
// du § 9 lève, plutôt que de laisser la ligne hors de tout groupe.
|
||||
function groupeDe(ligne) {
|
||||
if (ligne.etat === null) return ILLISIBLE;
|
||||
cleDEtat(ligne.etat);
|
||||
return ligne.etat;
|
||||
}
|
||||
|
||||
// Vrai quand le filtre retient la ligne : tous les événements, ou ceux de
|
||||
// son état. L'état se vérifie sous tout filtre.
|
||||
function retient(filtre, ligne) {
|
||||
const groupe = groupeDe(ligne);
|
||||
return filtre === 'tous' || groupe === filtre;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les tuiles des filtres de la liste (§ 8.1, § 9), dans l'ordre de FILTRES :
|
||||
* chacune { filtre, cle, aide, compte, presse, pastille } — le filtre
|
||||
* qu'elle pose, la clé de son nom et celle de son aide, le nombre de lignes
|
||||
* qu'il retient, mis en forme, vrai pour la tuile du filtre courant, et sa
|
||||
* pastille ({ sorte, valeur, icone }, une copie). Une ligne illisible ne
|
||||
* compte que sous « tous ».
|
||||
*
|
||||
* @param {Object[]} lignes LigneListe[] (evenements.js)
|
||||
* @param {string} filtre le filtre courant, de FILTRES
|
||||
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
|
||||
* @returns {Array<{filtre: string, cle: string, aide: string, compte: string, presse: boolean,
|
||||
* pastille: {sorte: string, valeur: string, icone: string|null}}>}
|
||||
* @throws {RangeError} un filtre hors de FILTRES
|
||||
*/
|
||||
export function tuilesDeLaListe(lignes, filtre, format) {
|
||||
exigerFiltre(filtre);
|
||||
return FILTRES.map((tuile) => ({
|
||||
filtre: tuile,
|
||||
...CLES_FILTRES.get(tuile),
|
||||
compte: format.nombre(lignes.filter((ligne) => retient(tuile, ligne)).length),
|
||||
presse: tuile === filtre,
|
||||
pastille: { ...PASTILLES.get(tuile) },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le résumé de l'en-tête de la liste (§ 8.1), les détails de la clé
|
||||
* accueil.entete.resume : le nombre d'événements listés, et la somme des
|
||||
* participants qu'ils portent déjà — une ligne illisible, sans compte, n'en
|
||||
* apporte aucun. Rien ne se relit : la somme est celle des lignes reçues.
|
||||
*
|
||||
* @param {Object[]} lignes LigneListe[] (evenements.js)
|
||||
* @returns {{evenements: number, participants: number}}
|
||||
*/
|
||||
export function resumeDeLaListe(lignes) {
|
||||
let participants = 0;
|
||||
for (const ligne of lignes) if (ligne.participants !== null) participants += ligne.participants;
|
||||
return { evenements: lignes.length, participants };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les groupes du tableau de la liste (§ 8.1, § 9), les lignes que le filtre
|
||||
* retient, chacune dans l'ordre de la façade : chacun { groupe, cle,
|
||||
* pastille, details, lignes }. Groupées par état, un groupe par état du
|
||||
* plan dans l'ordre du § 9, puis celui des états illisibles ; un groupe sans
|
||||
* ligne n'existe pas ; cle est la clé de son intitulé, details les détails
|
||||
* de accueil.groupe.compte. Sans groupement, un seul groupe, sans
|
||||
* intitulé : groupe, cle et pastille valent null. Sans ligne retenue, aucun
|
||||
* groupe : le composant dit que le filtre ne retient rien.
|
||||
*
|
||||
* @param {Object[]} lignes LigneListe[] (evenements.js)
|
||||
* @param {string} filtre de FILTRES
|
||||
* @param {string} groupement de GROUPEMENTS
|
||||
* @returns {Array<{groupe: string|null, cle: string|null, pastille: Object|null,
|
||||
* details: {evenements: number}, lignes: Object[]}>}
|
||||
* @throws {RangeError} un filtre, un groupement, ou l'état d'une ligne inconnus
|
||||
*/
|
||||
export function groupesDeLaListe(lignes, filtre, groupement) {
|
||||
exigerFiltre(filtre);
|
||||
exigerGroupement(groupement);
|
||||
const retenues = lignes.filter((ligne) => retient(filtre, ligne));
|
||||
if (retenues.length === 0) return [];
|
||||
if (groupement === 'aucun') {
|
||||
return [{ groupe: null, cle: null, pastille: null, details: { evenements: retenues.length }, lignes: retenues }];
|
||||
}
|
||||
const groupes = [];
|
||||
for (const [groupe, cle] of CLES_GROUPES) {
|
||||
const siennes = retenues.filter((ligne) => groupeDe(ligne) === groupe);
|
||||
if (siennes.length === 0) continue;
|
||||
groupes.push({ groupe, cle, pastille: { ...PASTILLES.get(groupe) }, details: { evenements: siennes.length }, lignes: siennes });
|
||||
}
|
||||
return groupes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les choix du groupement, dans l'ordre de GROUPEMENTS : chacun
|
||||
* { groupement, cle }, une copie.
|
||||
*
|
||||
* @returns {Array<{groupement: string, cle: string}>}
|
||||
*/
|
||||
export function choixDeGroupement() {
|
||||
return GROUPEMENTS.map((groupement) => ({ groupement, cle: CLES_GROUPEMENTS.get(groupement) }));
|
||||
}
|
||||
|
||||
// La vue de la liste que chaque séance retient, et celle d'une séance qui
|
||||
// n'en a retenu aucune.
|
||||
const VUES = new WeakMap();
|
||||
const VUE_PAR_DEFAUT = Object.freeze({ filtre: 'tous', groupement: 'etat' });
|
||||
|
||||
function exigerSeance(seance, quoi) {
|
||||
if (seance === null || typeof seance !== 'object') throw new TypeError(`${quoi} : la séance est un objet`);
|
||||
}
|
||||
|
||||
/**
|
||||
* La vue de la liste que la séance retient (§ 8.1) : { filtre, groupement },
|
||||
* une copie ; tous les événements groupés par état tant qu'elle n'en a
|
||||
* retenu aucune. C'est un état d'interface : il survit à un retour à la
|
||||
* liste, jamais à la séance, et rien ne l'écrit. La séance est la façade
|
||||
* d'App, comme pour premiereAnnonce.
|
||||
*
|
||||
* @param {object} seance
|
||||
* @returns {{filtre: string, groupement: string}}
|
||||
* @throws {TypeError} une séance qui n'est pas un objet
|
||||
*/
|
||||
export function vueDeLaListe(seance) {
|
||||
exigerSeance(seance, 'vueDeLaListe');
|
||||
return { ...(VUES.get(seance) ?? VUE_PAR_DEFAUT) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retient la vue de la liste pour la séance ; une copie, que vueDeLaListe
|
||||
* rend ensuite.
|
||||
*
|
||||
* @param {object} seance
|
||||
* @param {{filtre: string, groupement: string}} vue
|
||||
* @throws {TypeError} une séance qui n'est pas un objet
|
||||
* @throws {RangeError} un filtre ou un groupement inconnus ; rien n'est retenu
|
||||
*/
|
||||
export function retenirVue(seance, { filtre, groupement }) {
|
||||
exigerSeance(seance, 'retenirVue');
|
||||
exigerFiltre(filtre);
|
||||
exigerGroupement(groupement);
|
||||
VUES.set(seance, Object.freeze({ filtre, groupement }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
// ligne de la liste, mises en forme, et ses signalements ; les réponses
|
||||
// d'une question du dossier de travail ; les secours d'un état illisible ;
|
||||
// le refus d'un geste de la liste, sans remède ; l'annonce du dossier de
|
||||
// travail, une fois par séance. Chaque clé que le modèle rend existe dans la
|
||||
// travail, une fois par séance ; les tuiles par état du plan, le résumé de
|
||||
// la liste, ses groupes et la vue que la séance retient. Chaque clé que le modèle rend existe dans la
|
||||
// traduction. Les noms d'épreuve sont inventés.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
|
|
@ -24,12 +25,20 @@ import { COLONNES, texteCellule } from '../propositions/modele.js';
|
|||
import {
|
||||
COLONNES_DU_RETENU,
|
||||
ETATS,
|
||||
FILTRES,
|
||||
GROUPEMENTS,
|
||||
MEILLEURE,
|
||||
SAISIES,
|
||||
champDuRefus,
|
||||
choixDeGroupement,
|
||||
cleDEtat,
|
||||
groupesDeLaListe,
|
||||
ligneAffichee,
|
||||
premiereAnnonce,
|
||||
resumeDeLaListe,
|
||||
retenirVue,
|
||||
tuilesDeLaListe,
|
||||
vueDeLaListe,
|
||||
refusDeLaListe,
|
||||
reponsesDe,
|
||||
saisieDe,
|
||||
|
|
@ -441,3 +450,210 @@ describe('ligneAffichee : la meilleure proposition du classement (§ 5.4, § 5.7
|
|||
assert.equal(classees, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// --- La vue de la liste : tuiles, résumé, groupes (§ 8.1, § 9) -------------------
|
||||
|
||||
// Une liste de sept lignes, dans l'ordre de la façade : deux brouillons, un
|
||||
// proposé, un retenu, deux bloqués et un état illisible, entremêlés.
|
||||
function liste() {
|
||||
const lisible = (base, etat, participants) => ligne({ base, id: `evt-${base}`, nom: base, etat, participants });
|
||||
return [
|
||||
lisible('Nuit des Pervenches', 'bloque', 40),
|
||||
lisible('Bal des Grisailles', 'brouillon', 12),
|
||||
ligne({ base: 'Soirée vide', id: null, nom: null, etat: null, participants: null, illisible: { raison: 'VIDE' } }),
|
||||
lisible('Gala des Ombrelles', 'retenu', 1200),
|
||||
lisible('Club des Merles', 'brouillon', 0),
|
||||
lisible('Souper des Hiboux', 'propose', 7),
|
||||
lisible('Fête des Lucioles', 'bloque', 3),
|
||||
];
|
||||
}
|
||||
const bases = (lignes) => lignes.map(({ base }) => base);
|
||||
|
||||
describe('tuilesDeLaListe : une tuile par filtre (§ 8.1, § 9)', () => {
|
||||
test('Tous, puis les quatre états dans l’ordre du § 9 ; chaque compte, mis en forme ; seule la tuile du filtre est pressée', () => {
|
||||
assert.deepEqual(FILTRES, ['tous', 'brouillon', 'propose', 'retenu', 'bloque']);
|
||||
// Un format qui marque ses nombres : sous 1 000, format.nombre(n) vaut
|
||||
// String(n), et un compte resté brut passerait pour mis en forme.
|
||||
const marque = { nombre: (n) => `#${n}` };
|
||||
const tuiles = tuilesDeLaListe(liste(), 'propose', marque);
|
||||
assert.deepEqual(
|
||||
tuiles.map(({ filtre, compte, presse }) => [filtre, compte, presse]),
|
||||
[
|
||||
['tous', '#7', false],
|
||||
['brouillon', '#2', false],
|
||||
['propose', '#1', true],
|
||||
['retenu', '#1', false],
|
||||
['bloque', '#2', false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('le nom de chaque état est celui de la colonne État ; Tous et chaque aide ont leur clé, qui existe, et l’aide redit le § 9', () => {
|
||||
const tuiles = tuilesDeLaListe(liste(), 'tous', format);
|
||||
for (const { filtre, cle, aide } of tuiles) {
|
||||
if (filtre !== 'tous') assert.equal(cle, cleDEtat(filtre));
|
||||
assert.ok(t(cle).length > 0, cle);
|
||||
assert.ok(t(aide).length > 0, aide);
|
||||
}
|
||||
assert.equal(t(tuiles[0].cle), 'Tous les événements');
|
||||
assert.deepEqual(
|
||||
tuiles.slice(1).map(({ aide }) => t(aide)),
|
||||
['on prépare', 'des placements existent, aucun n\u{2019}est retenu', 'un placement est en vigueur', 'rien ne peut plus changer'],
|
||||
);
|
||||
});
|
||||
|
||||
test('la pastille de chaque tuile : celle de son état ; Tous, un statut d’information à l’icône de la liste', () => {
|
||||
assert.deepEqual(
|
||||
tuilesDeLaListe(liste(), 'tous', format).map(({ pastille }) => pastille),
|
||||
[
|
||||
{ sorte: 'statut', valeur: 'information', icone: 'liste' },
|
||||
...ETATS.map((etat) => ({ sorte: 'etat', valeur: etat, icone: null })),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('une liste vide compte zéro partout ; un filtre inconnu lève', () => {
|
||||
assert.deepEqual(
|
||||
tuilesDeLaListe([], 'tous', format).map(({ compte }) => compte),
|
||||
FILTRES.map(() => format.nombre(0)),
|
||||
);
|
||||
assert.throws(() => tuilesDeLaListe(liste(), 'illisible', format), RangeError);
|
||||
assert.throws(() => tuilesDeLaListe(liste(), undefined, format), RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeDeLaListe : le résumé de l’en-tête (§ 8.1)', () => {
|
||||
test('le nombre d’événements, et la somme des participants déjà listés : un état illisible n’en apporte aucun', () => {
|
||||
assert.deepEqual(resumeDeLaListe(liste()), { evenements: 7, participants: 1262 });
|
||||
assert.deepEqual(resumeDeLaListe([]), { evenements: 0, participants: 0 });
|
||||
});
|
||||
|
||||
test('le texte du résumé accorde chaque compte, et met les nombres en forme', () => {
|
||||
assert.equal(t('accueil.entete.resume', resumeDeLaListe(liste())), `7 événements \u{00B7} ${format.nombre(1262)} participants au total`);
|
||||
assert.equal(t('accueil.entete.resume', { evenements: 1, participants: 1 }), '1 événement \u{00B7} 1 participant au total');
|
||||
assert.equal(t('accueil.entete.resume', { evenements: 1, participants: 0 }), '1 événement \u{00B7} 0 participant au total');
|
||||
// Le compte des événements passe lui aussi par la mise en forme des nombres.
|
||||
assert.notEqual(format.nombre(1200), '1200');
|
||||
assert.equal(t('accueil.entete.resume', { evenements: 1200, participants: 1 }), `${format.nombre(1200)} événements \u{00B7} 1 participant au total`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupesDeLaListe : les groupes du tableau (§ 8.1, § 9)', () => {
|
||||
test('par état : les groupes dans l’ordre du § 9, puis les états illisibles ; dans chaque groupe, l’ordre de la façade ; un état sans ligne n’a pas de groupe', () => {
|
||||
const lignes = liste().filter(({ etat }) => etat !== 'propose');
|
||||
const groupes = groupesDeLaListe(lignes, 'tous', 'etat');
|
||||
assert.deepEqual(
|
||||
groupes.map(({ groupe, cle, details }) => [groupe, cle, details]),
|
||||
[
|
||||
['brouillon', 'accueil.etat.brouillon', { evenements: 2 }],
|
||||
['retenu', 'accueil.etat.retenu', { evenements: 1 }],
|
||||
['bloque', 'accueil.etat.bloque', { evenements: 2 }],
|
||||
['illisible', 'accueil.groupe.illisible', { evenements: 1 }],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(bases(groupes[0].lignes), ['Bal des Grisailles', 'Club des Merles']);
|
||||
assert.deepEqual(bases(groupes[2].lignes), ['Nuit des Pervenches', 'Fête des Lucioles']);
|
||||
assert.deepEqual(bases(groupes[3].lignes), ['Soirée vide']);
|
||||
assert.equal(groupes[0].lignes[0], lignes[1]);
|
||||
});
|
||||
|
||||
test('la pastille de chaque groupe : celle de son état ; les états illisibles, un avis à l’icône du triangle ; chaque clé existe', () => {
|
||||
const groupes = groupesDeLaListe(liste(), 'tous', 'etat');
|
||||
assert.deepEqual(
|
||||
groupes.map(({ pastille }) => pastille),
|
||||
[
|
||||
...ETATS.map((etat) => ({ sorte: 'etat', valeur: etat, icone: null })),
|
||||
{ sorte: 'statut', valeur: 'avis', icone: 'triangle' },
|
||||
],
|
||||
);
|
||||
for (const { cle, details } of groupes) {
|
||||
assert.ok(t(cle).length > 0, cle);
|
||||
assert.ok(t('accueil.groupe.compte', details).length > 0);
|
||||
}
|
||||
assert.equal(t('accueil.groupe.compte', { evenements: 1 }), '1 événement');
|
||||
assert.equal(t('accueil.groupe.compte', { evenements: 2 }), '2 événements');
|
||||
// Au-delà de 999, le compte prend la mise en forme des nombres.
|
||||
assert.notEqual(format.nombre(1200), '1200');
|
||||
assert.equal(t('accueil.groupe.compte', { evenements: 1200 }), `${format.nombre(1200)} événements`);
|
||||
});
|
||||
|
||||
test('un filtre ne retient que son état, illisibles écartés ; sans ligne retenue, aucun groupe', () => {
|
||||
const groupes = groupesDeLaListe(liste(), 'bloque', 'etat');
|
||||
assert.deepEqual(
|
||||
groupes.map(({ groupe }) => groupe),
|
||||
['bloque'],
|
||||
);
|
||||
assert.deepEqual(bases(groupes[0].lignes), ['Nuit des Pervenches', 'Fête des Lucioles']);
|
||||
assert.deepEqual(groupesDeLaListe(liste().filter(({ etat }) => etat !== 'retenu'), 'retenu', 'etat'), []);
|
||||
assert.deepEqual(groupesDeLaListe([], 'tous', 'etat'), []);
|
||||
assert.deepEqual(groupesDeLaListe([], 'tous', 'aucun'), []);
|
||||
});
|
||||
|
||||
test('sans groupement : un seul groupe sans intitulé, dans l’ordre de la façade, filtré comme les autres', () => {
|
||||
const lignes = liste();
|
||||
const [seul, ...autres] = groupesDeLaListe(lignes, 'tous', 'aucun');
|
||||
assert.deepEqual(autres, []);
|
||||
assert.equal(seul.groupe, null);
|
||||
assert.equal(seul.cle, null);
|
||||
assert.equal(seul.pastille, null);
|
||||
assert.deepEqual(seul.details, { evenements: 7 });
|
||||
assert.deepEqual(bases(seul.lignes), bases(lignes));
|
||||
assert.deepEqual(
|
||||
bases(groupesDeLaListe(lignes, 'brouillon', 'aucun')[0].lignes),
|
||||
['Bal des Grisailles', 'Club des Merles'],
|
||||
);
|
||||
});
|
||||
|
||||
test('un filtre, un groupement ou un état de ligne inconnus lèvent : aucune ligne ne disparaît en silence', () => {
|
||||
assert.throws(() => groupesDeLaListe(liste(), 'illisible', 'etat'), RangeError);
|
||||
assert.throws(() => groupesDeLaListe(liste(), 'tous', 'date'), RangeError);
|
||||
assert.throws(() => groupesDeLaListe([ligne({ etat: 'archive' })], 'tous', 'etat'), RangeError);
|
||||
assert.throws(() => groupesDeLaListe([ligne({ etat: 'archive' })], 'tous', 'aucun'), RangeError);
|
||||
});
|
||||
|
||||
test('choixDeGroupement : par état du plan, puis aucun, chacun avec sa clé, qui existe', () => {
|
||||
assert.deepEqual(GROUPEMENTS, ['etat', 'aucun']);
|
||||
const choix = choixDeGroupement();
|
||||
assert.deepEqual(
|
||||
choix.map(({ groupement }) => groupement),
|
||||
GROUPEMENTS,
|
||||
);
|
||||
assert.deepEqual(
|
||||
choix.map(({ cle }) => t(cle)),
|
||||
['État du plan', 'Aucun groupement'],
|
||||
);
|
||||
choix[0].cle = 'autre';
|
||||
assert.equal(choixDeGroupement()[0].cle, 'accueil.groupement.etat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vueDeLaListe : le filtre et le groupement, retenus par la séance, jamais écrits (§ 8.1)', () => {
|
||||
test('par défaut, tous les événements groupés par état ; une vue retenue se relit pour la même séance, et pour elle seule', () => {
|
||||
const seance = {};
|
||||
const autre = {};
|
||||
assert.deepEqual(vueDeLaListe(seance), { filtre: 'tous', groupement: 'etat' });
|
||||
retenirVue(seance, { filtre: 'retenu', groupement: 'aucun' });
|
||||
assert.deepEqual(vueDeLaListe(seance), { filtre: 'retenu', groupement: 'aucun' });
|
||||
assert.deepEqual(vueDeLaListe(autre), { filtre: 'tous', groupement: 'etat' });
|
||||
});
|
||||
|
||||
test('la vue rendue est une copie : la changer ne change pas ce que la séance retient', () => {
|
||||
const seance = {};
|
||||
const vue = vueDeLaListe(seance);
|
||||
vue.filtre = 'bloque';
|
||||
assert.deepEqual(vueDeLaListe(seance), { filtre: 'tous', groupement: 'etat' });
|
||||
const retenue = { filtre: 'propose', groupement: 'etat' };
|
||||
retenirVue(seance, retenue);
|
||||
retenue.filtre = 'bloque';
|
||||
assert.equal(vueDeLaListe(seance).filtre, 'propose');
|
||||
});
|
||||
|
||||
test('une séance absente, un filtre ou un groupement inconnus lèvent, et rien n’est retenu', () => {
|
||||
const seance = {};
|
||||
assert.throws(() => vueDeLaListe(null), TypeError);
|
||||
assert.throws(() => retenirVue('séance', { filtre: 'tous', groupement: 'etat' }), TypeError);
|
||||
assert.throws(() => retenirVue(seance, { filtre: 'illisible', groupement: 'etat' }), RangeError);
|
||||
assert.throws(() => retenirVue(seance, { filtre: 'tous', groupement: 'date' }), RangeError);
|
||||
assert.deepEqual(vueDeLaListe(seance), { filtre: 'tous', groupement: 'etat' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,12 +11,63 @@
|
|||
// le composant la rend dans son propre nœud, à côté du texte traduit
|
||||
// (§ 14.6). Un compte s'accorde dans la phrase qui le porte.
|
||||
import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js';
|
||||
import { creerFormat } from '../../application/format.js';
|
||||
|
||||
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
|
||||
|
||||
// Un compte mis en forme comme les nombres de la liste — espace fine entre
|
||||
// les milliers —, accordé à sa valeur.
|
||||
const compteEnForme = (n, singulier, pluriel, texte) => `${creerFormat(texte).nombre(n)} ${n <= 1 ? singulier : pluriel}`;
|
||||
|
||||
export const TABLE = new Map([
|
||||
// --- L'en-tête de la liste (§ 8.1) ----------------------------------------
|
||||
// evenements, participants : le nombre de lignes et la somme des
|
||||
// participants qu'elles portent déjà (resumeDeLaListe, modele.js).
|
||||
[
|
||||
'accueil.entete.resume',
|
||||
({ evenements, participants }, texte) =>
|
||||
`${compteEnForme(evenements, 'événement', 'événements', texte)} \u{00B7} ` +
|
||||
`${compteEnForme(participants, 'participant', 'participants', texte)} au total`,
|
||||
],
|
||||
// La commande de l'en-tête qui mène au champ du nom de la création.
|
||||
['accueil.entete.creer', 'Créer un événement'],
|
||||
|
||||
// --- Les tuiles des filtres par état du plan (§ 8.1, § 9) ------------------
|
||||
// Le nom du groupe des tuiles ; celui de « tous » ; l'aide de chacune, qui
|
||||
// redit pour un état sa ligne du tableau du § 9.
|
||||
['accueil.filtre.groupe', 'Filtrer par état du plan'],
|
||||
['accueil.filtre.tous', 'Tous les événements'],
|
||||
['accueil.filtre.aide.tous', 'tous les états'],
|
||||
['accueil.filtre.aide.brouillon', 'on prépare'],
|
||||
['accueil.filtre.aide.propose', 'des placements existent, aucun n\u{2019}est retenu'],
|
||||
['accueil.filtre.aide.retenu', 'un placement est en vigueur'],
|
||||
['accueil.filtre.aide.bloque', 'rien ne peut plus changer'],
|
||||
// Le tableau quand le filtre ne retient aucune ligne.
|
||||
['accueil.filtre.vide', 'Aucune ligne ne porte cet état : choisir « Tous les événements » les montre toutes.'],
|
||||
|
||||
// --- Le tableau des événements (§ 8.1) ---------------------------------------
|
||||
['accueil.liste.titre', 'Événements'],
|
||||
// Le choix du groupement, et ses deux valeurs.
|
||||
['accueil.groupement.libelle', 'Grouper par'],
|
||||
['accueil.groupement.etat', 'État du plan'],
|
||||
['accueil.groupement.aucun', 'Aucun groupement'],
|
||||
// L'intitulé d'un groupe : le nom de son état, ou celui des états qui ne
|
||||
// se lisent pas, puis son compte (evenements).
|
||||
['accueil.groupe.illisible', 'État illisible'],
|
||||
['accueil.groupe.compte', ({ evenements }, texte) => compteEnForme(evenements, 'événement', 'événements', texte)],
|
||||
// Les en-têtes de groupes de colonnes ; la meilleure proposition porte le
|
||||
// titre du bloc de la génération.
|
||||
['accueil.entete.evenement', 'Événement'],
|
||||
['accueil.entete.retenu', 'Placement retenu'],
|
||||
['accueil.colonne.nom', 'Nom'],
|
||||
['accueil.colonne.actions', 'Actions'],
|
||||
// La commande qui ouvre l'événement de la ligne, en lecture.
|
||||
['accueil.ouvrir', 'Ouvrir'],
|
||||
|
||||
// --- La liste (§ 8.1) ----------------------------------------------------
|
||||
// Les intitulés des informations d'une ligne ; le nom est la commande qui
|
||||
// Les intitulés des informations d'une ligne : en-têtes des colonnes, et,
|
||||
// pour la date et le chemin, l'intitulé caché à l'œil qui les précède sous
|
||||
// le nom, en tête de ligne. Le nom est l'en-tête de la ligne ; « Ouvrir »
|
||||
// ouvre l'événement.
|
||||
['accueil.colonne.date', 'Date'],
|
||||
['accueil.colonne.etat', 'État'],
|
||||
|
|
|
|||
|
|
@ -147,8 +147,9 @@ export const MARQUES = Object.freeze([
|
|||
marque('echelle', 'echelle', ['sol', 'page-fond']),
|
||||
marque('fantome', 'fantome', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
// Le signe du chargement d'une valeur de la liste des événements (§ 8.1) :
|
||||
// un arc tracé de l'encre secondaire, sur la page.
|
||||
marque('chargement', 'page-texte-secondaire', ['page-fond']),
|
||||
// un arc tracé de l'encre secondaire, dans une cellule du tableau, sur la
|
||||
// carte.
|
||||
marque('chargement', 'page-texte-secondaire', ['carte-fond']),
|
||||
// Le filigrane BROUILLON (§ 11.7) : un texte au trait, par-dessus le
|
||||
// contenu d'une feuille imprimée, mesuré sur le papier, clair dans les
|
||||
// deux thèmes.
|
||||
|
|
|
|||
|
|
@ -435,7 +435,8 @@ export const ECRANS = Object.freeze([
|
|||
async atteindre(contexte) {
|
||||
// La principale, sa proposition retenue : la ligne montre les deux
|
||||
// mesures du retenu et la meilleure proposition du classement, et le
|
||||
// pointeur réel sur l'intitulé de la différence ouvre son infobulle.
|
||||
// pointeur réel sur l'intitulé de la différence — un en-tête de
|
||||
// colonne du tableau, une fois pour la liste — ouvre son infobulle.
|
||||
const principale = chargePrincipale();
|
||||
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
|
||||
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
|
||||
|
|
@ -448,7 +449,7 @@ export const ECRANS = Object.freeze([
|
|||
'[data-meilleure-collisions]',
|
||||
].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')),
|
||||
);
|
||||
const { x, y } = await centre(await element(m, `${ligne} [data-terme="diffRedondance"]`));
|
||||
const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="diffRedondance"]'));
|
||||
await pointer('bouger', x, y);
|
||||
await attendre("l'infobulle de la différence", () => {
|
||||
const bulle = m.racine.querySelector('[data-ecran="accueil"] [role="tooltip"]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue