[IMP] proposals, generation, badges, preview: cards and grouped headers
The proposal table had a dozen measure columns with no grouping. Five group headers now gather neighbouring measures without changing any column text; numbers align right in tabular figures, and the kept placement row wears the Retenu tint and a « retenue » pill. Generation settings and progress, badges and the paper choice become cards, each paper a distinct choice; the preview toolbar groups its commands. The printed paper is unchanged. Checked: node 172, browser 162. --- FR --- [IMP] propositions, génération, badges, aperçu : cartes, intitulés Le tableau des propositions alignait une douzaine de mesures sans groupe. Cinq intitulés de groupe réunissent les mesures voisines sans changer le texte d'aucune colonne ; les nombres s'alignent à droite en chiffres tabulaires, la ligne du retenu porte la teinte Retenu et une pastille « retenue ». Réglages et avancement, badges et papier deviennent des cartes, chaque papier un choix distinct ; la barre de l'aperçu groupe ses commandes. Le papier imprimé ne change pas. Vérifié : node 172, navigateur 162. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
901095a1ef
commit
c6082c3af2
25 changed files with 1332 additions and 317 deletions
122
src/interface/APropos.navigateur.test.js
Normal file
122
src/interface/APropos.navigateur.test.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le panneau « À propos » dans un vrai moteur de rendu (§ 18.6) : monté
|
||||
// seul, sur un presse-papiers d'épreuve qui relève ce qu'on lui remet ou
|
||||
// refuse. Le bloc copié est celui qu'on lui passe ; l'ouverture depuis le
|
||||
// bandeau et la composition du bloc s'éprouvent dans App.navigateur.test.js.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import './jetons.css';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { creerHorlogePilotee } from '../application/horloge.js';
|
||||
import { TABLES_APPLICATION } from '../application/libelles.js';
|
||||
import { creerTraduction } from '../application/traduction.js';
|
||||
import { couleurDeJeton } from '../../test/navigateur/captures.js';
|
||||
import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../test/navigateur/rendu.js';
|
||||
import APropos from './APropos.svelte';
|
||||
import { TABLES_INTERFACE } from './libelles.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
// Un bloc inventé, de la forme d'une ligne d'« À propos ».
|
||||
const BLOC = 'Logiciel témoin \u{00B7} forme d\u{2019}épreuve \u{00B7} plateforme simulée';
|
||||
|
||||
const montes = [];
|
||||
afterEach(() => {
|
||||
for (const { composant, conteneur } of montes.splice(0)) {
|
||||
unmount(composant);
|
||||
conteneur.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Un presse-papiers d'épreuve : relève chaque copie, ou lève refus.
|
||||
function pressePapiersDEpreuve({ refus = null } = {}) {
|
||||
const copies = [];
|
||||
return {
|
||||
copies,
|
||||
async copier(texte) {
|
||||
if (refus !== null) throw refus;
|
||||
copies.push(texte);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function monter(props) {
|
||||
const conteneur = document.createElement('div');
|
||||
document.body.prepend(conteneur);
|
||||
const contexte = new Map([
|
||||
['t', t],
|
||||
['horloge', creerHorlogePilotee()],
|
||||
]);
|
||||
const composant = mount(APropos, { target: conteneur, props, context: contexte });
|
||||
flushSync();
|
||||
montes.push({ composant, conteneur });
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
// La carte qui porte un élément : le plus proche ancêtre peint du fond de la
|
||||
// carte, au rayon de 12 px ; null sans elle.
|
||||
function carteDe(element) {
|
||||
const fond = couleurDeJeton('--couleur-carte-fond').join();
|
||||
for (let n = element.parentElement; n !== null; n = n.parentElement) {
|
||||
const style = getComputedStyle(n);
|
||||
if (composantes(style.backgroundColor).join() === fond && style.borderTopLeftRadius === '12px') return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('« À propos » : l’habillage (§ 18.6)', () => {
|
||||
test('le titre au-dessus d’une carte qui porte le bloc, sur le fond sourd, et les commandes ; « Copier » est l’action principale, dans les deux thèmes', async () => {
|
||||
const conteneur = monter({ bloc: BLOC, pressePapiers: pressePapiersDEpreuve(), fermer: () => {} });
|
||||
const ecran = conteneur.querySelector('[data-ecran="apropos"]');
|
||||
const titre = ecran.querySelector('h1');
|
||||
expect(titre.textContent).toBe(t('coquille.apropos.titre'));
|
||||
const bloc = ecran.querySelector('[data-bloc]');
|
||||
expect(bloc.textContent).toBe(BLOC);
|
||||
const carte = carteDe(bloc);
|
||||
expect(carte, 'carte du bloc').not.toBeNull();
|
||||
expect(ecran.contains(carte)).toBe(true);
|
||||
expect(carte.contains(titre)).toBe(false);
|
||||
expect(carte.contains(ecran.querySelector('[data-commande="copier"]'))).toBe(true);
|
||||
expect(carte.contains(ecran.querySelector('[data-commande="fermerApropos"]'))).toBe(true);
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
const [fond] = fondPeintSous(bloc);
|
||||
expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-carte-fond-sourd').join());
|
||||
expect(contraste(couleurTexte(bloc), fond), theme).toBeGreaterThanOrEqual(4.5);
|
||||
const copier = getComputedStyle(ecran.querySelector('[data-commande="copier"]'));
|
||||
expect(composantes(copier.backgroundColor).join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('« Copier » remet exactement le bloc affiché et le dit ; refusée, la copie le dit dans l’encre du refus', async () => {
|
||||
const presse = pressePapiersDEpreuve();
|
||||
const conteneur = monter({ bloc: BLOC, pressePapiers: presse, fermer: () => {} });
|
||||
conteneur.querySelector('[data-commande="copier"]').click();
|
||||
await vi.waitFor(() => expect(presse.copies).toEqual([BLOC]));
|
||||
await vi.waitFor(() => {
|
||||
flushSync();
|
||||
expect(conteneur.querySelector('[role="status"]')?.textContent).toBe(t('coquille.apropos.copie'));
|
||||
});
|
||||
|
||||
const refuse = monter({ bloc: BLOC, pressePapiers: pressePapiersDEpreuve({ refus: new Error('refusé') }), fermer: () => {} });
|
||||
refuse.querySelector('[data-commande="copier"]').click();
|
||||
await vi.waitFor(() => {
|
||||
flushSync();
|
||||
expect(refuse.querySelector('[data-refus="copier"]')?.textContent).toBe(t('coquille.apropos.copieRefusee'));
|
||||
});
|
||||
const message = refuse.querySelector('[data-refus="copier"]');
|
||||
expect(couleurTexte(message).join()).toBe(couleurDeJeton('--couleur-refus').join());
|
||||
});
|
||||
|
||||
test('« Fermer » appelle fermer une fois', () => {
|
||||
const fermer = vi.fn();
|
||||
const conteneur = monter({ bloc: BLOC, pressePapiers: pressePapiersDEpreuve(), fermer });
|
||||
conteneur.querySelector('[data-commande="fermerApropos"]').click();
|
||||
expect(fermer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,14 @@
|
|||
// copie dans un courriel, tel que blocAPropos l'a composé, et sa copie dans
|
||||
// le presse-papiers de la plateforme. « Copier » remet exactement le texte
|
||||
// affiché ; l'issue de la copie se lit sous les commandes.
|
||||
//
|
||||
// Habillage : le titre de l'écran, puis une carte qui porte le bloc, sur
|
||||
// le fond sourd de la carte, et les commandes — « Copier », l'action
|
||||
// principale, et « Fermer ». Une copie refusée se dit dans l'encre du
|
||||
// refus.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from './primitives/Carte.svelte';
|
||||
import Commande from './primitives/Commande.svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
|
||||
|
|
@ -27,23 +34,51 @@
|
|||
|
||||
<section class="apropos" data-ecran="apropos">
|
||||
<h1>{t('coquille.apropos.titre')}</h1>
|
||||
<p class="bloc" data-bloc>{bloc}</p>
|
||||
<div class="commandes">
|
||||
<button type="button" data-commande="copier" onclick={copier}>{t('coquille.apropos.copier')}</button>
|
||||
<button type="button" data-commande="fermerApropos" onclick={fermer}>{t('coquille.apropos.fermer')}</button>
|
||||
</div>
|
||||
{#if issue === 'faite'}
|
||||
<p role="status">{t('coquille.apropos.copie')}</p>
|
||||
{:else if issue === 'refusee'}
|
||||
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
|
||||
{/if}
|
||||
<Carte>
|
||||
<div class="contenu">
|
||||
<p class="bloc" data-bloc>{bloc}</p>
|
||||
<div class="commandes">
|
||||
<Commande cle="coquille.apropos.copier" nom="copier" variante="principale" executer={copier} />
|
||||
<Commande cle="coquille.apropos.fermer" nom="fermerApropos" executer={fermer} />
|
||||
</div>
|
||||
{#if issue === 'faite'}
|
||||
<p role="status">{t('coquille.apropos.copie')}</p>
|
||||
{:else if issue === 'refusee'}
|
||||
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Carte>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.apropos {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.contenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Le bloc se sélectionne d'un clic, entier, et se coupe où il faut
|
||||
plutôt que de déborder. */
|
||||
.bloc {
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
|
@ -51,24 +86,10 @@
|
|||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
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);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.refus {
|
||||
color: var(--couleur-refus);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ import { MARQUE_TEMOIN, creerTraduction } from '../../application/traduction.js'
|
|||
import { PAPIERS } from '../../geometrie/papiers.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import { composantes, contraste } from '../../../test/navigateur/rendu.js';
|
||||
import { BASE, attendre, chargeBloquee, chargePrincipale, cliquer, creerContexte, fichierReglages, ouvrirEvenement } from '../../../test/navigateur/ecrans.js';
|
||||
import { FORME_TEMOIN, chargeTemoin } from '../../../test/fixtures/badges/temoin.js';
|
||||
import { clesPapier } from '../impression/modele.js';
|
||||
|
|
@ -470,3 +472,46 @@ describe('la langue témoin (§ 14.6)', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- L'habillage : cartes, action principale --------------------------------------
|
||||
|
||||
// La carte qui porte un élément : le plus proche ancêtre peint du fond de la
|
||||
// carte, au rayon de 12 px ; null sans elle.
|
||||
function carteDe(element) {
|
||||
const fond = couleurDeJeton('--couleur-carte-fond').join();
|
||||
for (let n = element.parentElement; n !== null; n = n.parentElement) {
|
||||
const style = getComputedStyle(n);
|
||||
if (composantes(style.backgroundColor).join() === fond && style.borderTopLeftRadius === '12px') return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('l’habillage de la section des badges', () => {
|
||||
test('les réglages, le papier, les commandes et l’annonce de la planche dans une carte ; la réédition dans une autre', async () => {
|
||||
const m = await ouvrirBadges(chargeRetenue());
|
||||
dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
|
||||
await attendre('l’annonce', () => annonce(m), IMAGES);
|
||||
const planche = carteDe(dans(m, '[data-section="badges"] [data-champ="imposition"]'));
|
||||
expect(planche, 'carte de la planche').not.toBeNull();
|
||||
expect(section(m).contains(planche)).toBe(true);
|
||||
for (const selecteur of ['[data-champ="ordre"]', '[data-papier="choisi"]', '[data-commande="preparerPlanche"]', '[data-commande="produirePlanche"]', '[data-annonce-planche]']) {
|
||||
expect(planche.contains(dans(m, `[data-section="badges"] ${selecteur}`)), selecteur).toBe(true);
|
||||
}
|
||||
const reedition = carteDe(dans(m, '[data-section="badges"] [data-champ="reedition"]'));
|
||||
expect(reedition, 'carte de la réédition').not.toBeNull();
|
||||
expect(reedition).not.toBe(planche);
|
||||
expect(planche.contains(reedition)).toBe(false);
|
||||
});
|
||||
|
||||
test('l’annonce sur le fond sourd de la carte ; « Produire la planche » est l’action principale, son texte à 4,5:1', async () => {
|
||||
const m = await ouvrirBadges(chargeRetenue());
|
||||
dans(m, '[data-section="badges"] [data-commande="preparerPlanche"]').click();
|
||||
await attendre('l’annonce', () => annonce(m), IMAGES);
|
||||
expect(composantes(getComputedStyle(annonce(m)).backgroundColor).join()).toBe(couleurDeJeton('--couleur-carte-fond-sourd').join());
|
||||
const style = getComputedStyle(produire(m));
|
||||
const fond = composantes(style.backgroundColor);
|
||||
expect(fond.join()).toBe(couleurDeJeton('--couleur-action-fond').join());
|
||||
expect(contraste(composantes(style.color), fond)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(produire(m).getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
// sélection — dans l'ordre de la liste — à executer, la production de la
|
||||
// section, qui annonce puis produit. Un refus s'affiche à côté de la
|
||||
// commande, avec son remède, que gestes exécute quand l'écran le fournit.
|
||||
//
|
||||
// Habillage : le groupe est une carte, titrée de son intitulé ; la liste
|
||||
// défile dans un cadre au filet de la carte, l'appartenance d'une personne
|
||||
// en encre secondaire.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { filtrerPersonnes, personnesReeditables, selectionRetenue } from './modele.js';
|
||||
|
|
@ -65,16 +69,23 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
/* Une carte : son fond, son filet, son rayon, son ombre. */
|
||||
.reedition {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
gap: var(--espace-3);
|
||||
min-width: 0;
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-3);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
box-shadow: var(--ombre-carte);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
font-size: var(--taille-titre-carte);
|
||||
}
|
||||
|
||||
p {
|
||||
|
|
@ -84,15 +95,15 @@
|
|||
.filtre {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
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;
|
||||
}
|
||||
|
|
@ -108,12 +119,14 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
box-sizing: border-box;
|
||||
width: min(36rem, 100%);
|
||||
max-height: 16rem;
|
||||
margin: 0;
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,14 @@
|
|||
//
|
||||
// La réédition (Reedition.svelte) passe par la même production, sa
|
||||
// sélection dans la demande.
|
||||
//
|
||||
// Habillage : les réglages, le papier, les commandes, l'annonce et la
|
||||
// planche produite vivent dans la carte de la planche ; « Produire la
|
||||
// planche » en est l'action principale. L'annonce se pose sur le fond
|
||||
// sourd de la carte, la planche produite sur le fond de l'information. La
|
||||
// réédition est une carte à elle.
|
||||
import { getContext } from 'svelte';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import Papier from '../sorties/Papier.svelte';
|
||||
|
|
@ -161,83 +168,87 @@
|
|||
<p>{t('badges.introduction')}</p>
|
||||
|
||||
{#if charge !== null}
|
||||
<div class="reglages">
|
||||
{#if offertes.length > 1}
|
||||
<label class="reglage">
|
||||
<span>{t('badges.source.titre')}</span>
|
||||
<select data-champ="source" value={cleSource(source)} onchange={(evenement) => (cleChoisie = evenement.currentTarget.value)}>
|
||||
{#each offertes as offerte (cleSource(offerte))}
|
||||
<option value={cleSource(offerte)}>{t(libelleSource(offerte).cle, libelleSource(offerte).details)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="reglage">
|
||||
<span>{t('badges.imposition.titre')}</span>
|
||||
<select data-champ="imposition" value={imposition} onchange={(evenement) => (imposition = evenement.currentTarget.value)}>
|
||||
{#each IMPOSITIONS_OFFERTES as valeur (valeur)}
|
||||
<option value={valeur}>{t(cleImposition(valeur))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="reglage">
|
||||
<span>{t('badges.ordre.titre')}</span>
|
||||
<select data-champ="ordre" value={ordre} onchange={(evenement) => (ordre = evenement.currentTarget.value)}>
|
||||
{#each ORDRES_OFFERTS as valeur (valeur)}
|
||||
<option value={valeur}>{t(cleOrdre(valeur))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if $seance.papier !== null || attente !== null}
|
||||
<Papier onChoisi={papierChoisi} />
|
||||
{/if}
|
||||
|
||||
<div class="commandes">
|
||||
<Commande cle="badges.commande.preparer" nom="preparerPlanche" executer={() => preparer(null)} {gestes} />
|
||||
{#if bloque}
|
||||
<button type="button" data-commande="produirePlanche" aria-disabled="true" aria-describedby={`${uid}-bloque`}
|
||||
>{t('badges.commande.produire')}</button
|
||||
>
|
||||
<p class="explication" id={`${uid}-bloque`}>{t('badges.produire.bloque')}</p>
|
||||
{:else}
|
||||
<Commande cle="badges.commande.produire" nom="produirePlanche" executer={() => produire(null)} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if refusRepris !== null}
|
||||
<Refus refus={refusRepris} lever={() => (refusRepris = null)} {gestes} />
|
||||
{/if}
|
||||
|
||||
{#if montree !== null}
|
||||
<div class="annonce" data-annonce-planche role="group" aria-labelledby={`${uid}-annonce`}>
|
||||
<h3 id={`${uid}-annonce`}>{t('badges.annonce.titre')}</h3>
|
||||
<ul>
|
||||
{#each phrases as phrase, rang (rang)}
|
||||
<li>{t(phrase.cle, phrase.details)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if montree.annonce.refus !== null}
|
||||
<Refus refus={montree.annonce.refus} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if produite !== null}
|
||||
<div class="produite">
|
||||
{#if produite.chemin !== null}
|
||||
<p>
|
||||
<span>{t('badges.produite.chemin')}</span>
|
||||
<span class="chemin" data-chemin-planche>{produite.chemin}</span>
|
||||
</p>
|
||||
{#if explorateur.disponible && !produite.ailleurs}
|
||||
<Commande cle="badges.commande.ouvrirDossier" nom="ouvrirDossier" executer={() => facade.ouvrirExplorateur()} />
|
||||
<Carte titre={t('badges.planche.titre')} niveau={3}>
|
||||
<div class="planche">
|
||||
<div class="reglages">
|
||||
{#if offertes.length > 1}
|
||||
<label class="reglage">
|
||||
<span>{t('badges.source.titre')}</span>
|
||||
<select data-champ="source" value={cleSource(source)} onchange={(evenement) => (cleChoisie = evenement.currentTarget.value)}>
|
||||
{#each offertes as offerte (cleSource(offerte))}
|
||||
<option value={cleSource(offerte)}>{t(libelleSource(offerte).cle, libelleSource(offerte).details)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{:else}
|
||||
<p data-chemin-planche>{t('badges.produite.telechargee', { nom: produite.nom })}</p>
|
||||
<label class="reglage">
|
||||
<span>{t('badges.imposition.titre')}</span>
|
||||
<select data-champ="imposition" value={imposition} onchange={(evenement) => (imposition = evenement.currentTarget.value)}>
|
||||
{#each IMPOSITIONS_OFFERTES as valeur (valeur)}
|
||||
<option value={valeur}>{t(cleImposition(valeur))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="reglage">
|
||||
<span>{t('badges.ordre.titre')}</span>
|
||||
<select data-champ="ordre" value={ordre} onchange={(evenement) => (ordre = evenement.currentTarget.value)}>
|
||||
{#each ORDRES_OFFERTS as valeur (valeur)}
|
||||
<option value={valeur}>{t(cleOrdre(valeur))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if $seance.papier !== null || attente !== null}
|
||||
<Papier onChoisi={papierChoisi} />
|
||||
{/if}
|
||||
|
||||
<div class="commandes">
|
||||
<Commande cle="badges.commande.preparer" nom="preparerPlanche" executer={() => preparer(null)} {gestes} />
|
||||
{#if bloque}
|
||||
<button type="button" data-commande="produirePlanche" aria-disabled="true" aria-describedby={`${uid}-bloque`}
|
||||
>{t('badges.commande.produire')}</button
|
||||
>
|
||||
<p class="explication" id={`${uid}-bloque`}>{t('badges.produire.bloque')}</p>
|
||||
{:else}
|
||||
<Commande cle="badges.commande.produire" nom="produirePlanche" variante="principale" executer={() => produire(null)} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if refusRepris !== null}
|
||||
<Refus refus={refusRepris} lever={() => (refusRepris = null)} {gestes} />
|
||||
{/if}
|
||||
|
||||
{#if montree !== null}
|
||||
<div class="annonce" data-annonce-planche role="group" aria-labelledby={`${uid}-annonce`}>
|
||||
<h3 id={`${uid}-annonce`}>{t('badges.annonce.titre')}</h3>
|
||||
<ul>
|
||||
{#each phrases as phrase, rang (rang)}
|
||||
<li>{t(phrase.cle, phrase.details)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if montree.annonce.refus !== null}
|
||||
<Refus refus={montree.annonce.refus} {gestes} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if produite !== null}
|
||||
<div class="produite">
|
||||
{#if produite.chemin !== null}
|
||||
<p>
|
||||
<span>{t('badges.produite.chemin')}</span>
|
||||
<span class="chemin" data-chemin-planche>{produite.chemin}</span>
|
||||
</p>
|
||||
{#if explorateur.disponible && !produite.ailleurs}
|
||||
<Commande cle="badges.commande.ouvrirDossier" nom="ouvrirDossier" executer={() => facade.ouvrirExplorateur()} />
|
||||
{/if}
|
||||
{:else}
|
||||
<p data-chemin-planche>{t('badges.produite.telechargee', { nom: produite.nom })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Carte>
|
||||
|
||||
<Reedition {charge} executer={produire} {gestes} />
|
||||
{/if}
|
||||
|
|
@ -247,7 +258,7 @@
|
|||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
gap: var(--espace-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
|
@ -265,40 +276,67 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.reglages,
|
||||
.commandes {
|
||||
.planche {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
/* Les réglages : chaque libellé, en petites capitales d'encre secondaire,
|
||||
au-dessus de son sélecteur. */
|
||||
.reglages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-3);
|
||||
align-items: flex-end;
|
||||
gap: var(--espace-3) var(--espace-5);
|
||||
}
|
||||
|
||||
.reglage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.reglage > span {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2);
|
||||
align-items: center;
|
||||
gap: var(--espace-3);
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-ligne-filet);
|
||||
}
|
||||
|
||||
select {
|
||||
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;
|
||||
}
|
||||
|
||||
/* « Produire » désactivé tant qu'un refus tient : son filet tireté et son
|
||||
texte secondaire le distinguent, et son explication le suit. */
|
||||
/* « Produire » désactivé tant qu'un refus tient : à la hauteur de l'action
|
||||
principale, son filet tireté et son texte secondaire le distinguent, et
|
||||
son explication le suit. */
|
||||
button {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
min-height: var(--hauteur-cible);
|
||||
padding: 0 var(--espace-4);
|
||||
border: 1px dashed 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-secondaire);
|
||||
font: inherit;
|
||||
font-size: var(--taille-texte);
|
||||
font-weight: bold;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
|
@ -317,9 +355,10 @@
|
|||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
ul {
|
||||
|
|
@ -333,8 +372,12 @@
|
|||
.produite {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-information-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-information-fond);
|
||||
}
|
||||
|
||||
/* Un chemin long se coupe où il faut, plutôt que de déborder. */
|
||||
|
|
|
|||
77
src/interface/generation/Avancement.navigateur.test.js
Normal file
77
src/interface/generation/Avancement.navigateur.test.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'avancement d'une génération dans un vrai moteur de rendu (§ 5.10) : la
|
||||
// barre de progression se voit. Le composant est monté seul, dans un
|
||||
// conteneur de largeur fixée ; la barre se lit par son rôle, son
|
||||
// remplissage par son premier enfant, les couleurs par leurs jetons, dans
|
||||
// les deux thèmes.
|
||||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import '../jetons.css';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { creerHorlogePilotee } from '../../application/horloge.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Avancement from './Avancement.svelte';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
||||
const montes = [];
|
||||
afterEach(() => {
|
||||
for (const { composant, conteneur } of montes.splice(0)) {
|
||||
unmount(composant);
|
||||
conteneur.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Monte Avancement dans un conteneur de 600 px.
|
||||
function monter(props) {
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.style.width = '600px';
|
||||
document.body.prepend(conteneur);
|
||||
const contexte = new Map([
|
||||
['t', t],
|
||||
['horloge', creerHorlogePilotee()],
|
||||
]);
|
||||
const composant = mount(Avancement, { target: conteneur, props, context: contexte });
|
||||
flushSync();
|
||||
montes.push({ composant, conteneur });
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
describe('l’avancement se voit (§ 5.10)', () => {
|
||||
test('la barre occupe la largeur de son conteneur ; son remplissage, au prorata, se peint de la teinte de l’action à 3:1 sur la piste, dans les deux thèmes', async () => {
|
||||
const conteneur = monter({ fait: 250, total: 1000, annuler: () => {} });
|
||||
const barre = conteneur.querySelector('[role="progressbar"]');
|
||||
expect(barre.getAttribute('aria-valuenow')).toBe('250');
|
||||
expect(barre.getBoundingClientRect().width).toBeGreaterThanOrEqual(0.9 * conteneur.getBoundingClientRect().width);
|
||||
const plein = barre.firstElementChild;
|
||||
expect(plein.getBoundingClientRect().width / barre.clientWidth).toBeCloseTo(0.25, 2);
|
||||
expect(barre.getBoundingClientRect().height).toBeGreaterThanOrEqual(8);
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
const remplissage = composantes(getComputedStyle(plein).backgroundColor);
|
||||
const piste = composantes(getComputedStyle(barre).backgroundColor);
|
||||
expect(remplissage.join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join());
|
||||
expect(piste.join(), theme).toBe(couleurDeJeton('--couleur-carte-fond-sourd').join());
|
||||
expect(contraste(remplissage, piste), theme).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('le compte se lit en mots au-dessus de la barre, et « Annuler la génération » la suit', () => {
|
||||
const conteneur = monter({ fait: 250, total: 1000, annuler: () => {} });
|
||||
const barre = conteneur.querySelector('[role="progressbar"]');
|
||||
const texte = [...conteneur.querySelectorAll('p')].find((p) => p.textContent === t('generation.avancement.texte', { fait: 250, total: 1000 }));
|
||||
expect(texte).toBeDefined();
|
||||
expect(texte.getBoundingClientRect().bottom).toBeLessThanOrEqual(barre.getBoundingClientRect().top);
|
||||
const annuler = conteneur.querySelector('[data-commande="annuler"]');
|
||||
expect(annuler.getBoundingClientRect().top).toBeGreaterThanOrEqual(barre.getBoundingClientRect().bottom);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,10 @@
|
|||
// génération », qui appelle annuler. fait et total viennent du contrôleur
|
||||
// de génération ; total est le produit du nombre de propositions et du
|
||||
// compte d'arrêt, jamais nul pendant un calcul.
|
||||
//
|
||||
// La barre occupe la largeur de son bloc : sa piste sur le fond sourd de la
|
||||
// carte, son remplissage de la teinte de l'action principale, qui s'y
|
||||
// détache à 3:1 (SIGNES, actionPrincipale).
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
|
||||
|
|
@ -39,23 +43,28 @@
|
|||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.barre {
|
||||
box-sizing: border-box;
|
||||
width: min(30rem, 100%);
|
||||
height: var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
width: 100%;
|
||||
height: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-pilule);
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
.plein {
|
||||
height: 100%;
|
||||
background-color: var(--couleur-page-texte);
|
||||
background-color: var(--couleur-action-fond);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ import { configurationDepuisCharge } from '../../stockage/document.js';
|
|||
import { lireJournal } from '../../stockage/journal.js';
|
||||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Travailleur from './travailleur.js?worker&inline';
|
||||
|
||||
|
|
@ -942,3 +944,47 @@ describe('la durée du calcul (§ 5.10, § 19.4)', () => {
|
|||
await attendre(() => expect(enCours(m)).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
// --- L'habillage : réglages en carte, action principale -------------------------
|
||||
|
||||
// La carte qui porte un élément : le plus proche ancêtre peint du fond de la
|
||||
// carte, au rayon de 12 px ; null sans elle.
|
||||
function carteDe(element) {
|
||||
const fond = couleurDeJeton('--couleur-carte-fond').join();
|
||||
for (let n = element.parentElement; n !== null; n = n.parentElement) {
|
||||
const style = getComputedStyle(n);
|
||||
if (composantes(style.backgroundColor).join() === fond && style.borderTopLeftRadius === '12px') return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('l’habillage de la génération', () => {
|
||||
test('les réglages et « Générer » dans une même carte de la section ; la meilleure proposition dans la sienne, au-dessus', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
const reglages = section(m).querySelector('[data-reglages]');
|
||||
const carte = carteDe(reglages);
|
||||
expect(carte, 'carte des réglages').not.toBeNull();
|
||||
expect(section(m).contains(carte)).toBe(true);
|
||||
expect(carte.contains(generer(m))).toBe(true);
|
||||
const meilleure = section(m).querySelector('[data-meilleure]');
|
||||
const style = getComputedStyle(meilleure);
|
||||
expect(composantes(style.backgroundColor).join()).toBe(couleurDeJeton('--couleur-carte-fond').join());
|
||||
expect(style.borderTopLeftRadius).toBe('12px');
|
||||
expect(carte.contains(meilleure)).toBe(false);
|
||||
expect(meilleure.getBoundingClientRect().bottom).toBeLessThanOrEqual(carte.getBoundingClientRect().top);
|
||||
});
|
||||
|
||||
test('« Générer » est l’action principale : pleine de la teinte de l’action, son texte à 4,5:1, haute de 44 px, dans les deux thèmes', async () => {
|
||||
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
flushSync();
|
||||
const style = getComputedStyle(generer(m));
|
||||
const fond = composantes(style.backgroundColor);
|
||||
expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join());
|
||||
expect(contraste(composantes(style.color), fond), theme).toBeGreaterThanOrEqual(4.5);
|
||||
expect(generer(m).getBoundingClientRect().height, theme).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { creerSeance } from '../../application/seance.js';
|
|||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { composantes, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Reglages from './Reglages.svelte';
|
||||
import { REGLAGES_GENERATION, reglagesDeSection } from './reglages.js';
|
||||
|
|
@ -162,3 +164,23 @@ describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
|
|||
expect(designations(cible)).toEqual(['graine', 'generation.arret', 'nouvelleTable', 'coeursAutomatiques']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’habillage des réglages', () => {
|
||||
test('chaque champ de saisie sur le fond de la carte, au filet de contrôle, dans les deux thèmes ; chaque groupe sous un intertitre en petites capitales', async () => {
|
||||
const cible = await monter();
|
||||
const champs = [...cible.querySelectorAll('input[type="text"]')];
|
||||
expect(champs.length).toBeGreaterThan(0);
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
for (const champ of champs) {
|
||||
const style = getComputedStyle(champ);
|
||||
expect(composantes(style.backgroundColor).join(), `${theme} ${champ.dataset.champ}`).toBe(couleurDeJeton('--couleur-carte-fond').join());
|
||||
expect(composantes(style.borderTopColor).join(), `${theme} ${champ.dataset.champ}`).toBe(couleurDeJeton('--couleur-page-filet').join());
|
||||
}
|
||||
});
|
||||
}
|
||||
const legendes = [...cible.querySelectorAll('legend')];
|
||||
expect(legendes.length).toBe(2);
|
||||
for (const legende of legendes) expect(getComputedStyle(legende).textTransform).toBe('uppercase');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@
|
|||
// posé, « Générer » ne part pas. Échap rend le dernier nombre accepté ; les
|
||||
// deux touches sont celles du contexte 'champ' de la table des raccourcis.
|
||||
// La valeur automatique du poste s'affiche à côté, dans les deux états.
|
||||
//
|
||||
// Habillage : la graine et les cœurs se saisissent comme un Champ, sur le
|
||||
// fond de la carte au filet de contrôle ; les contraintes et les cœurs sont
|
||||
// deux groupes sous un intertitre en petites capitales.
|
||||
import { getContext, untrack } from 'svelte';
|
||||
import Champ from '../primitives/Champ.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
|
|
@ -224,10 +228,10 @@
|
|||
<p>{t('generation.coeurs.note')}</p>
|
||||
</fieldset>
|
||||
{#if mesure === null}
|
||||
<p data-duree>{t('generation.duree.inconnue')}</p>
|
||||
<p class="note" data-duree>{t('generation.duree.inconnue')}</p>
|
||||
{:else}
|
||||
<p data-duree>{t('generation.duree.observee', mesure)}</p>
|
||||
<p data-estimation>{t('generation.duree.estimee', estimation)}</p>
|
||||
<p class="note" data-duree>{t('generation.duree.observee', mesure)}</p>
|
||||
<p class="note" data-estimation>{t('generation.duree.estimee', estimation)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -235,7 +239,7 @@
|
|||
.reglages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
.champs {
|
||||
|
|
@ -255,8 +259,8 @@
|
|||
input {
|
||||
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);
|
||||
|
|
@ -271,30 +275,37 @@
|
|||
border-color: var(--couleur-refus);
|
||||
}
|
||||
|
||||
/* Les deux groupes : un filet de carte, l'intertitre en petites capitales
|
||||
d'encre secondaire. */
|
||||
.coeurs,
|
||||
.bascules {
|
||||
margin: 0;
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
}
|
||||
|
||||
.coeurs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
margin: 0;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
.bascules {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2) var(--espace-4);
|
||||
margin: 0;
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
gap: var(--espace-2) var(--espace-5);
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 var(--espace-1);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
font-weight: bold;
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bascule {
|
||||
|
|
@ -311,4 +322,8 @@
|
|||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -42,9 +42,15 @@
|
|||
// colonne du tableau ; son survol ou son focus ouvre l'infobulle de la
|
||||
// section, une seule, sur l'explication de cette colonne, et le quitter la
|
||||
// referme ; le terme expliqué porte aria-describedby vers elle.
|
||||
//
|
||||
// Habillage : la meilleure proposition est une carte à elle, chaque valeur
|
||||
// sous son intitulé ; les réglages, « Générer » — l'action principale de
|
||||
// l'écran —, la question, l'avancement et l'issue vivent dans la carte des
|
||||
// réglages, sous elle.
|
||||
import { getContext, onDestroy, untrack } from 'svelte';
|
||||
import { ErreurCommande } from '../../application/erreurs.js';
|
||||
import { ErreurAnnulee } from '../../application/generation.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Infobulle from '../primitives/Infobulle.svelte';
|
||||
import { explicationDeColonne, texteCellule } from '../propositions/modele.js';
|
||||
|
|
@ -190,28 +196,32 @@
|
|||
{/if}
|
||||
</section>
|
||||
<Infobulle id={idExplication} ouverte={expliquee} fermer={taire} />
|
||||
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} bind:refusCoeurs />
|
||||
<div class="lancer">
|
||||
<Commande cle="generation.commande.generer" nom="generer" executer={() => lancer(false)} />
|
||||
</div>
|
||||
{#if question !== null}
|
||||
<div class="question" data-question="longue" role="status">
|
||||
<p>{t('generation.longue.question', question)}</p>
|
||||
<div class="issues">
|
||||
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
|
||||
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
|
||||
<Carte titre={t('generation.reglages.titre')} niveau={3}>
|
||||
<div class="reglages">
|
||||
<Reglages {charge} {mesure} {estimation} bind:graine {refusGraine} {saisirGraine} bind:refusCoeurs />
|
||||
<div class="lancer">
|
||||
<Commande cle="generation.commande.generer" nom="generer" variante="principale" executer={() => lancer(false)} />
|
||||
</div>
|
||||
{#if question !== null}
|
||||
<div class="question" data-question="longue" role="status">
|
||||
<p>{t('generation.longue.question', question)}</p>
|
||||
<div class="issues">
|
||||
<Commande cle="generation.longue.confirmer" nom="confirmerGeneration" executer={() => lancer(true)} />
|
||||
<Commande cle="generation.longue.renoncer" nom="renoncerGeneration" executer={() => (question = null)} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $generation.enCours}
|
||||
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
|
||||
{:else if issue !== null}
|
||||
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
|
||||
{:else if $generation.annulee === 'commande'}
|
||||
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
|
||||
{:else if $generation.annulee === 'seance'}
|
||||
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $generation.enCours}
|
||||
<Avancement fait={$generation.fait} total={$generation.total} annuler={() => generation.annuler()} />
|
||||
{:else if issue !== null}
|
||||
<p data-issue role="status">{t('generation.issue.ajoutees', issue)}</p>
|
||||
{:else if $generation.annulee === 'commande'}
|
||||
<p data-issue role="status">{t('generation.issue.annuleeCommande')}</p>
|
||||
{:else if $generation.annulee === 'seance'}
|
||||
<p data-issue role="status">{t('generation.issue.annuleeSeance')}</p>
|
||||
{/if}
|
||||
</Carte>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
@ -220,6 +230,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
|
@ -227,37 +238,47 @@
|
|||
font-size: var(--taille-titre);
|
||||
}
|
||||
|
||||
/* La meilleure proposition : un bloc d'information encadré d'un filet,
|
||||
sur le fond de la page ; chaque libellé suivi de sa valeur, en gras. */
|
||||
/* La meilleure proposition : une carte, son titre en tête, puis chaque
|
||||
valeur en grand, sous son intitulé en petites capitales. */
|
||||
.meilleure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
gap: var(--espace-3);
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-3);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
box-shadow: var(--ombre-carte);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
font-size: var(--taille-titre-carte);
|
||||
}
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2) var(--espace-5);
|
||||
gap: var(--espace-3) var(--espace-6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl > div {
|
||||
display: flex;
|
||||
gap: var(--espace-2);
|
||||
align-items: baseline;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
@ -274,13 +295,28 @@
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.reglages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
.lancer {
|
||||
display: flex;
|
||||
padding-top: var(--espace-3);
|
||||
border-top: 1px solid var(--couleur-ligne-filet);
|
||||
}
|
||||
|
||||
.question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--espace-2);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border-left: 3px solid var(--couleur-avis);
|
||||
padding: var(--espace-3) var(--espace-4);
|
||||
border: 1px solid var(--couleur-avis-filet);
|
||||
border-left: 3px solid var(--couleur-avis-marque);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-avis-fond);
|
||||
}
|
||||
|
||||
.issues {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { analyser } from '../../stockage/document.js';
|
|||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { FICHIER_REGLAGES } from '../../stockage/reglages_locaux.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { fixerCadre } from '../../../test/navigateur/entrees.js';
|
||||
import {
|
||||
attendre,
|
||||
|
|
@ -43,7 +44,7 @@ import {
|
|||
ouvrirApercu,
|
||||
ouvrirEvenement,
|
||||
} from '../../../test/navigateur/ecrans.js';
|
||||
import { attenuations, composantes, contraste } from '../../../test/navigateur/rendu.js';
|
||||
import { attenuations, composantes, contraste, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { creerImprimanteSimulee } from '../../../test/imprimante_simulee.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { MARQUES } from '../marques.js';
|
||||
|
|
@ -525,3 +526,101 @@ describe('la bande mesurée (HAUTEUR_BANDE_IMPRESSION, § 14.1)', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- L'habillage de l'écran : la barre d'outils groupée ---------------------------
|
||||
|
||||
describe('la barre d’outils de l’aperçu', () => {
|
||||
const barre = (m) => dans(m, `[data-ecran="apercu"] [role="group"][aria-label="${t('impression.barre')}"]`);
|
||||
|
||||
// La carte qui porte un élément : le plus proche ancêtre peint du fond de
|
||||
// la carte, au rayon de 12 px ; null sans elle.
|
||||
function carteDe(element) {
|
||||
const fond = couleurDeJeton('--couleur-carte-fond').join();
|
||||
for (let n = element.parentElement; n !== null; n = n.parentElement) {
|
||||
const style = getComputedStyle(n);
|
||||
if (composantes(style.backgroundColor).join() === fond && style.borderTopLeftRadius === '12px') return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('sans papier, la question se pose dans une carte sous la barre, dans les deux thèmes ; « Fermer l’aperçu » se range au bord droit de la barre', async () => {
|
||||
const m = await ouvrirApercu(contexte, chargePrincipale(), { papier: null });
|
||||
const question = dans(m, '[data-ecran="apercu"] [data-papier="question"]');
|
||||
expect(question, 'la question du papier').not.toBeNull();
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
const carte = carteDe(question);
|
||||
expect(carte, `${theme} : carte de la question`).not.toBeNull();
|
||||
expect(carte.contains(barre(m)), theme).toBe(false);
|
||||
expect(barre(m).contains(carte), theme).toBe(false);
|
||||
expect(carte.getBoundingClientRect().top, theme).toBeGreaterThanOrEqual(barre(m).getBoundingClientRect().bottom);
|
||||
});
|
||||
}
|
||||
// Le bord droit de la barre, moins son filet et sa marge intérieure :
|
||||
// la fermeture s'y range, à un pixel près, quelle que soit la place que
|
||||
// les groupes laissent libre.
|
||||
const style = getComputedStyle(barre(m));
|
||||
const bord = barre(m).getBoundingClientRect().right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight);
|
||||
const fermer = dans(m, '[data-ecran="apercu"] [data-commande="fermerApercu"]').getBoundingClientRect();
|
||||
expect(fermer.right).toBeGreaterThanOrEqual(bord - 1);
|
||||
expect(fermer.right).toBeLessThanOrEqual(bord + 1);
|
||||
});
|
||||
|
||||
test('une carte, ses commandes en groupes nommés côte à côte : vue, réglages de la vue, papier et impression, fermeture', async () => {
|
||||
const m = await ouvrirApercu(contexte, chargePrincipale());
|
||||
const style = getComputedStyle(barre(m));
|
||||
expect(composantes(style.backgroundColor).join()).toBe(couleurDeJeton('--couleur-carte-fond').join());
|
||||
expect(style.borderTopLeftRadius).toBe('12px');
|
||||
const groupes = [...barre(m).querySelectorAll('[role="group"]')];
|
||||
expect(groupes.map((groupe) => groupe.getAttribute('aria-label'))).toEqual([
|
||||
t('impression.vue.titre'),
|
||||
t('impression.groupe.reglages'),
|
||||
t('impression.groupe.sortie'),
|
||||
]);
|
||||
const boites = groupes.map((groupe) => groupe.getBoundingClientRect());
|
||||
for (const [rang, a] of boites.entries()) {
|
||||
expect(a.width * a.height, groupes[rang].getAttribute('aria-label')).toBeGreaterThan(0);
|
||||
for (const b of boites.slice(rang + 1)) {
|
||||
const chevauchent = a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
expect(chevauchent).toBe(false);
|
||||
}
|
||||
}
|
||||
const [, reglages, sortie] = groupes;
|
||||
expect(reglages.querySelector('[data-champ="tour"]')).not.toBeNull();
|
||||
expect(sortie.querySelector('[data-papier="choisi"]')).not.toBeNull();
|
||||
expect(sortie.querySelector('[data-commande="imprimer"]')).not.toBeNull();
|
||||
expect(groupes.some((groupe) => groupe.contains(dans(m, '[data-commande="fermerApercu"]')))).toBe(false);
|
||||
});
|
||||
|
||||
test('la vue retenue se lit enfoncée — teinte de l’action, gras, texte à 4,5:1 — et « Imprimer » est l’action principale, dans les deux thèmes', async () => {
|
||||
const m = await ouvrirApercu(contexte, chargePrincipale());
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
const vues = tous(m, '[data-ecran="apercu"] [data-commande="vue"]');
|
||||
const enfoncee = vues.find((vue) => vue.getAttribute('aria-pressed') === 'true');
|
||||
const style = getComputedStyle(enfoncee);
|
||||
const fond = composantes(style.backgroundColor);
|
||||
expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-action-teinte').join());
|
||||
expect(style.fontWeight, theme).toBe('700');
|
||||
expect(contraste(composantes(style.color), fond), theme).toBeGreaterThanOrEqual(4.5);
|
||||
for (const vue of vues.filter((autre) => autre !== enfoncee)) {
|
||||
expect(composantes(getComputedStyle(vue).backgroundColor).join(), theme).not.toBe(fond.join());
|
||||
}
|
||||
const imprimer = getComputedStyle(dans(m, '[data-ecran="apercu"] [data-commande="imprimer"]'));
|
||||
expect(composantes(imprimer.backgroundColor).join(), theme).toBe(couleurDeJeton('--couleur-action-fond').join());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('à 1 024 px de large, la barre tient dans la fenêtre sans défilement interne', async () => {
|
||||
await fixerCadre(1024, 700);
|
||||
const m = await ouvrirApercu(contexte, chargePrincipale());
|
||||
const b = barre(m).getBoundingClientRect();
|
||||
expect(b.left).toBeGreaterThanOrEqual(0);
|
||||
expect(b.right).toBeLessThanOrEqual(document.documentElement.clientWidth);
|
||||
expect(barre(m).scrollWidth).toBeLessThanOrEqual(barre(m).clientWidth);
|
||||
for (const enfant of barre(m).querySelectorAll('button, select')) {
|
||||
expect(enfant.getBoundingClientRect().right, enfant.textContent).toBeLessThanOrEqual(b.right);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@
|
|||
// la commande, une annulation ne dit rien. Rien de l'événement ne
|
||||
// s'écrit : l'aperçu ne reçoit de la séance que son état, et le papier
|
||||
// passe par choisirPapier.
|
||||
//
|
||||
// Habillage de l'écran : la barre est une carte, ses commandes en groupes
|
||||
// nommés, côte à côte — la vue imprimée, en commandes enfoncées ; les
|
||||
// réglages de la vue, quand elle en a ; le papier et « Imprimer », l'action
|
||||
// principale — puis « Fermer l'aperçu », à part. Le document imprimé n'en
|
||||
// prend rien.
|
||||
import '../impression.css';
|
||||
import { getContext } from 'svelte';
|
||||
import { estBrouillon, piedDePage, refusImpression } from '../../application/sorties.js';
|
||||
|
|
@ -105,47 +111,57 @@
|
|||
|
||||
<div class="apercu" data-ecran="apercu">
|
||||
<div class="barre" role="group" aria-label={t('impression.barre')}>
|
||||
<h2>{t('impression.titre')}</h2>
|
||||
{#if cadre !== null}
|
||||
<span class="mode" data-mode-libelle="cadre">{t(cadre.cle)}</span>
|
||||
{/if}
|
||||
<div class="vues" role="group" aria-label={t('impression.vue.titre')}>
|
||||
<div class="tete">
|
||||
<h2>{t('impression.titre')}</h2>
|
||||
{#if cadre !== null}
|
||||
<span class="mode" data-mode-libelle="cadre">{t(cadre.cle)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="groupe vues" role="group" aria-label={t('impression.vue.titre')}>
|
||||
{#each VUES_IMPRESSION as vue (vue)}
|
||||
<button type="button" data-commande="vue" data-cible={vue} aria-pressed={options.vue === vue} onclick={() => choisirVue(vue)}
|
||||
>{t(cleVue(vue))}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#if options.vue === 'plan'}
|
||||
<label class="reglage">
|
||||
<span>{t('impression.tour.titre')}</span>
|
||||
<select data-champ="tour" value={options.tour} onchange={(evenement) => ui.reglerApercu({ tour: Number(evenement.currentTarget.value) })}>
|
||||
{#each tours as tour (tour)}
|
||||
<option value={tour}>{t('impression.tour.option', { tour })}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" data-commande="listes" aria-pressed={options.listes} onclick={() => ui.reglerApercu({ listes: !options.listes })}
|
||||
>{t('impression.listes')}</button
|
||||
>
|
||||
<label class="reglage">
|
||||
<span>{t('impression.decoupe.titre')}</span>
|
||||
<select data-champ="decoupe" value={options.decoupe} onchange={(evenement) => ui.reglerApercu({ decoupe: evenement.currentTarget.value })}>
|
||||
{#each DECOUPES as decoupe (decoupe)}
|
||||
<option value={decoupe}>{t(cleDecoupe(decoupe))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else if options.vue === 'tables'}
|
||||
<button type="button" data-commande="parTour" aria-pressed={options.parTour} onclick={() => ui.reglerApercu({ parTour: !options.parTour })}
|
||||
>{t('impression.parTour')}</button
|
||||
>
|
||||
{#if options.vue === 'plan' || options.vue === 'tables'}
|
||||
<div class="groupe" role="group" aria-label={t('impression.groupe.reglages')}>
|
||||
{#if options.vue === 'plan'}
|
||||
<label class="reglage">
|
||||
<span>{t('impression.tour.titre')}</span>
|
||||
<select data-champ="tour" value={options.tour} onchange={(evenement) => ui.reglerApercu({ tour: Number(evenement.currentTarget.value) })}>
|
||||
{#each tours as tour (tour)}
|
||||
<option value={tour}>{t('impression.tour.option', { tour })}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" data-commande="listes" aria-pressed={options.listes} onclick={() => ui.reglerApercu({ listes: !options.listes })}
|
||||
>{t('impression.listes')}</button
|
||||
>
|
||||
<label class="reglage">
|
||||
<span>{t('impression.decoupe.titre')}</span>
|
||||
<select data-champ="decoupe" value={options.decoupe} onchange={(evenement) => ui.reglerApercu({ decoupe: evenement.currentTarget.value })}>
|
||||
{#each DECOUPES as decoupe (decoupe)}
|
||||
<option value={decoupe}>{t(cleDecoupe(decoupe))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{:else}
|
||||
<button type="button" data-commande="parTour" aria-pressed={options.parTour} onclick={() => ui.reglerApercu({ parTour: !options.parTour })}
|
||||
>{t('impression.parTour')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !options.question}
|
||||
<Papier />
|
||||
<Commande cle="impression.commande.imprimer" nom="imprimer" executer={imprimer} />
|
||||
<div class="groupe" role="group" aria-label={t('impression.groupe.sortie')}>
|
||||
<Papier />
|
||||
<Commande cle="impression.commande.imprimer" nom="imprimer" variante="principale" executer={imprimer} />
|
||||
</div>
|
||||
{/if}
|
||||
<Commande cle="impression.commande.fermer" nom="fermerApercu" executer={fermer} />
|
||||
<div class="fermer">
|
||||
<Commande cle="impression.commande.fermer" nom="fermerApercu" executer={fermer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if options.question}
|
||||
|
|
@ -173,28 +189,66 @@
|
|||
gap: var(--espace-4);
|
||||
}
|
||||
|
||||
/* La barre : une carte, ses groupes côte à côte, chacun ouvert par un
|
||||
filet ; elle se replie à la ligne sous une largeur étroite. */
|
||||
.barre {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2) var(--espace-4);
|
||||
padding-bottom: var(--espace-3);
|
||||
border-bottom: 1px solid var(--couleur-page-filet);
|
||||
gap: var(--espace-3) var(--espace-4);
|
||||
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);
|
||||
}
|
||||
|
||||
.tete {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-titre);
|
||||
font-size: var(--taille-titre-carte);
|
||||
}
|
||||
|
||||
.mode {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vues {
|
||||
.groupe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
align-items: center;
|
||||
gap: var(--espace-2) var(--espace-3);
|
||||
padding-left: var(--espace-4);
|
||||
border-left: 1px solid var(--couleur-carte-filet);
|
||||
}
|
||||
|
||||
/* Les vues se touchent, en une rangée de commandes enfoncées. */
|
||||
.vues {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.vues button + button {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.vues button:not(:first-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.vues button:not(:last-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.fermer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reglage {
|
||||
|
|
@ -203,13 +257,18 @@
|
|||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.reglage > span {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -217,19 +276,34 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Une vue ou un réglage enfoncé se distingue par sa forme : gras et
|
||||
souligné. */
|
||||
button:hover {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
}
|
||||
|
||||
/* Une vue ou un réglage enfoncé : la teinte de l'action, le texte en gras
|
||||
de l'encre de la page ; le gras le distingue sans la couleur. */
|
||||
button[aria-pressed='true'] {
|
||||
position: relative;
|
||||
background-color: var(--couleur-action-teinte);
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible {
|
||||
position: relative;
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* La question du papier, sans document : une carte sous la barre. */
|
||||
.question {
|
||||
padding: var(--espace-4) var(--espace-5);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-3);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
box-shadow: var(--ombre-carte);
|
||||
}
|
||||
|
||||
/* La feuille gabarit se rend hors de la vue : elle se mesure, elle ne se
|
||||
voit ni ne s'atteint. */
|
||||
.gabarit {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const TABLE = new Map([
|
|||
['badges.introduction', "Un badge A6 par personne présente, imposé sur le papier de l'imprimante, précédé d'une feuille de contrôle."],
|
||||
|
||||
// --- Les réglages de la planche (§ 11.6, Q3) ------------------------------
|
||||
['badges.planche.titre', 'Planche de badges'],
|
||||
['badges.source.titre', 'Placement imprimé'],
|
||||
['badges.source.retenu', 'Le placement retenu'],
|
||||
['badges.source.proposition', ({ id }) => `La proposition n° ${id}`],
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const TABLE = new Map([
|
|||
],
|
||||
|
||||
// --- Les réglages (§ 5.7, § 5.10) ---------------------------------------------
|
||||
['generation.reglages.titre', 'Réglages de la génération'],
|
||||
['generation.reglage.graine', 'Graine (nombre entier, de 0 à 4\u{202F}294\u{202F}967\u{202F}295)'],
|
||||
['generation.reglage.nombre', 'Propositions par génération (nombre de propositions)'],
|
||||
['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export const TABLE = new Map([
|
|||
['impression.commande.ouvrir', 'Imprimer…'],
|
||||
['impression.commande.imprimer', 'Imprimer'],
|
||||
['impression.commande.fermer', "Fermer l'aperçu"],
|
||||
['impression.groupe.reglages', 'Réglages de la vue'],
|
||||
['impression.groupe.sortie', 'Papier et impression'],
|
||||
|
||||
// --- Ce que l'aperçu imprime ----------------------------------------------
|
||||
['impression.vue.titre', 'Vue imprimée'],
|
||||
|
|
|
|||
|
|
@ -64,6 +64,15 @@ export const TABLE = new Map([
|
|||
['propositions.colonne.imposes', 'Imposé par les réservations'],
|
||||
['propositions.colonne.commandes', 'Commandes'],
|
||||
|
||||
// --- Les groupes d'en-têtes, au-dessus des colonnes -------------------------
|
||||
// Chaque groupe réunit des colonnes voisines ; les en-têtes de colonne
|
||||
// gardent chacun son unité.
|
||||
['propositions.groupe.rencontres', 'Personnes rencontrées'],
|
||||
['propositions.groupe.collisions', "Collisions d'appartenance"],
|
||||
['propositions.groupe.repetitions', 'Rencontres répétées et retours'],
|
||||
['propositions.groupe.diversite', "Diversité d'appartenance"],
|
||||
['propositions.groupe.manque', 'Manque et plafond a priori'],
|
||||
|
||||
// --- Les explications, une par colonne (§ 5.4, § 5.5, § 5.7, § 12.10.4) --------
|
||||
// Le texte de l'infobulle d'un en-tête, et celui du nom de critère qui
|
||||
// désigne la colonne dans la phrase de l'ordre : un critère et sa colonne
|
||||
|
|
|
|||
|
|
@ -131,12 +131,14 @@ export const MARQUES = Object.freeze([
|
|||
// La désignation se pose sur la page, le sol, le plateau et une chaise
|
||||
// vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond.
|
||||
marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
marque('selection', 'selection', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
// La sélection marque aussi une colonne du tableau des propositions, sur la
|
||||
// carte, son fond sourd et la ligne du retenu.
|
||||
marque('selection', 'selection', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
// Le cadre de mode (§ 8.4) entoure le contenu de l'événement, sur la page.
|
||||
marque('cadreLecture', 'cadre-lecture', ['page-fond']),
|
||||
marque('cadreEcriture', 'cadre-ecriture', ['page-fond']),
|
||||
marque('cadreBloque', 'cadre-bloque', ['page-fond']),
|
||||
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'carte-fond', 'carte-fond-sourd', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'action-teinte', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
// Le mobilier sur le sol : le trait de la table, celui de la chaise, qui
|
||||
// touche aussi le plateau.
|
||||
marque('contourTable', 'table-trait', ['sol']),
|
||||
|
|
|
|||
|
|
@ -34,11 +34,13 @@ import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../.
|
|||
import { SUFFIXES } from '../../stockage/noms.js';
|
||||
import { planDepuisFichier } from '../../stockage/placements.js';
|
||||
import { VERSION } from '../../version.genere.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { centre } from '../../../test/navigateur/ecrans.js';
|
||||
import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js';
|
||||
import { monterApp } from '../../../test/navigateur/montage.js';
|
||||
import { composantes, contraste, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import { evaluation, tableau } from './modele.js';
|
||||
import { GROUPES_COLONNES, evaluation, tableau } from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
const format = creerFormat(t);
|
||||
|
|
@ -1005,3 +1007,116 @@ describe('la cible de la somme de redondance : un champ de la section', () => {
|
|||
expect(revision(ecriture)).toBe(revisionEcriture);
|
||||
});
|
||||
});
|
||||
|
||||
// --- L'habillage : carte, en-têtes groupés, ligne du retenu (§ 5.7, § 12.7) -------
|
||||
|
||||
// La carte qui porte un élément : le plus proche ancêtre peint du fond de la
|
||||
// carte, au rayon de 12 px ; null sans elle.
|
||||
function carteDe(element) {
|
||||
const fond = couleurDeJeton('--couleur-carte-fond').join();
|
||||
for (let n = element.parentElement; n !== null; n = n.parentElement) {
|
||||
const style = getComputedStyle(n);
|
||||
if (composantes(style.backgroundColor).join() === fond && style.borderTopLeftRadius === '12px') return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Les colonnes de nombres : le rang et chaque mesure, sauf le certificat.
|
||||
const COLONNES_NOMBRES = NOMS_DES_COLONNES.filter((nom) => !['proposition', 'certificat', 'imposes', 'commandes'].includes(nom));
|
||||
|
||||
describe('l’habillage du tableau des propositions', () => {
|
||||
const chargeRetenue = () => executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 3 });
|
||||
|
||||
test('le tableau vit dans une carte de la section, et son conteneur défilant reste dans la carte', async () => {
|
||||
const m = await ouvrir(chargeRetenue());
|
||||
const table = section(m).querySelector('table');
|
||||
const carte = carteDe(table);
|
||||
expect(carte, 'carte du tableau').not.toBeNull();
|
||||
expect(section(m).contains(carte)).toBe(true);
|
||||
const defilement = table.parentElement;
|
||||
const b = carte.getBoundingClientRect();
|
||||
const d = defilement.getBoundingClientRect();
|
||||
expect(d.left).toBeGreaterThanOrEqual(b.left);
|
||||
expect(d.right).toBeLessThanOrEqual(b.right);
|
||||
});
|
||||
|
||||
test('deux rangées d’en-têtes : chaque groupe, scope colgroup, couvre exactement ses colonnes ; les en-têtes de colonne restent dans la seconde', async () => {
|
||||
const m = await ouvrir(chargeRetenue());
|
||||
const thead = section(m).querySelector('thead');
|
||||
expect(thead.rows.length).toBe(2);
|
||||
const groupes = [...thead.rows[0].querySelectorAll('th')];
|
||||
const attendus = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
|
||||
expect(groupes.map(texteDe)).toEqual(attendus.map(({ cle }) => t(cle)));
|
||||
expect(thead.rows[0].querySelectorAll('[data-colonne]').length).toBe(0);
|
||||
expect([...thead.rows[1].querySelectorAll('th[data-colonne]')].map((th) => th.dataset.colonne)).toEqual(NOMS_DES_COLONNES);
|
||||
for (const [rang, groupe] of groupes.entries()) {
|
||||
const { colonnes } = attendus[rang];
|
||||
expect(groupe.getAttribute('scope')).toBe('colgroup');
|
||||
const premiere = thead.querySelector(`th[data-colonne="${colonnes[0]}"]`).getBoundingClientRect();
|
||||
const derniere = thead.querySelector(`th[data-colonne="${colonnes.at(-1)}"]`).getBoundingClientRect();
|
||||
const g = groupe.getBoundingClientRect();
|
||||
expect(Math.abs(g.left - premiere.left), texteDe(groupe)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(g.right - derniere.right), texteDe(groupe)).toBeLessThanOrEqual(1);
|
||||
expect(g.bottom).toBeLessThanOrEqual(premiere.top + 1);
|
||||
}
|
||||
const colgroups = [...section(m).querySelectorAll('table > colgroup')];
|
||||
expect(colgroups.map((colgroup) => colgroup.span)).toEqual(GROUPES_COLONNES.map(({ etendue }) => etendue));
|
||||
});
|
||||
|
||||
test('les nombres, en-têtes compris, s’alignent à droite en chiffres tabulaires', async () => {
|
||||
const m = await ouvrir(chargeRetenue());
|
||||
for (const nom of COLONNES_NOMBRES) {
|
||||
const cellulesLues = [...section(m).querySelectorAll(`tbody[data-classement] td[data-colonne="${nom}"], thead th[data-colonne="${nom}"]`)];
|
||||
expect(cellulesLues.length, nom).toBe(4);
|
||||
for (const cellule of cellulesLues) {
|
||||
const style = getComputedStyle(cellule);
|
||||
expect(style.textAlign, nom).toBe('right');
|
||||
expect(style.fontVariantNumeric, nom).toContain('tabular-nums');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('la ligne du retenu sur la teinte de Retenu, son texte à 4,5:1, dans les deux thèmes ; les autres lignes sur la carte', async () => {
|
||||
const m = await ouvrir(chargeRetenue());
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
flushSync();
|
||||
const teinte = couleurDeJeton('--couleur-etat-retenu-fond');
|
||||
const carte = couleurDeJeton('--couleur-carte-fond');
|
||||
for (const cellule of ligneRetenu(m).children) {
|
||||
cellule.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const [fond] = fondPeintSous(cellule);
|
||||
expect(fond.join(), `${theme} ${cellule.dataset.colonne}`).toBe(teinte.join());
|
||||
}
|
||||
const titre = ligneRetenu(m).querySelector('[data-colonne="rang"]');
|
||||
expect(contraste(couleurTexte(titre), teinte), theme).toBeGreaterThanOrEqual(4.5);
|
||||
for (const cellule of ligne(m, 1).querySelectorAll('td[data-colonne]')) {
|
||||
cellule.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const [fond] = fondPeintSous(cellule);
|
||||
expect(fond.join(), `${theme} ligne 1 ${cellule.dataset.colonne}`).toBe(carte.join());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('la proposition retenue porte la pastille « retenue » : fond de Retenu, signe à 3:1, texte de l’encre de la page', async () => {
|
||||
const m = await ouvrir(chargeRetenue());
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
flushSync();
|
||||
const cellule = ligne(m, 3).querySelector('td[data-colonne="proposition"]');
|
||||
const libelle = [...cellule.querySelectorAll('span')].find((span) => texteDe(span) === t('propositions.marque.retenue') && span.children.length === 0);
|
||||
expect(libelle, theme).toBeDefined();
|
||||
libelle.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const [fond] = fondPeintSous(libelle);
|
||||
expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-etat-retenu-fond').join());
|
||||
expect(couleurTexte(libelle).join(), theme).toBe(couleurDeJeton('--couleur-page-texte').join());
|
||||
const signe = libelle.parentElement.querySelector('svg');
|
||||
expect(signe, theme).not.toBeNull();
|
||||
expect(composantes(getComputedStyle(signe).color).join(), theme).toBe(couleurDeJeton('--couleur-etat-retenu-marque').join());
|
||||
expect(contraste(composantes(getComputedStyle(signe).color), fond), theme).toBeGreaterThanOrEqual(3);
|
||||
for (const autre of [1, 2]) expect(texteDe(ligne(m, autre))).not.toContain(t('propositions.marque.retenue'));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@
|
|||
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
|
||||
// aria-describedby vers elle, et lui seul.
|
||||
//
|
||||
// Habillage : le tableau et la phrase de l'ordre vivent dans une carte
|
||||
// titrée, « Effacer les propositions » à droite de son en-tête ; le message
|
||||
// d'une liste vide et le champ de la cible, chacun dans sa carte.
|
||||
//
|
||||
// Le champ de la cible de la somme de redondance montre la cible de
|
||||
// l'événement (cibleRedondance), et exécute changerCibleRedondance à la
|
||||
// sortie du champ — une entrée ; la saisie se lit par lireEntier, et un
|
||||
|
|
@ -47,6 +51,7 @@
|
|||
import { lireEntier } from '../../application/capacite.js';
|
||||
import { cibleRedondance } from '../../application/evaluation.js';
|
||||
import { REGLAGES_GENERATION, reglagesDeSection } from '../generation/reglages.js';
|
||||
import Carte from '../primitives/Carte.svelte';
|
||||
import Champ from '../primitives/Champ.svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Confirmation from '../primitives/Confirmation.svelte';
|
||||
|
|
@ -137,11 +142,17 @@
|
|||
const nommer = (id) => nomDe(charge, id);
|
||||
</script>
|
||||
|
||||
{#snippet effacerListe()}
|
||||
<Commande cle="propositions.commande.effacer" executer={effacer} />
|
||||
{/snippet}
|
||||
|
||||
<section data-section="propositions">
|
||||
<h2>{t('coquille.section.propositions')}</h2>
|
||||
|
||||
{#if charge !== null && charge.propositions.length === 0}
|
||||
<p>{t('propositions.aucune')}</p>
|
||||
<Carte>
|
||||
<p>{t('propositions.aucune')}</p>
|
||||
</Carte>
|
||||
{/if}
|
||||
|
||||
{#if evaluee.refus !== null}
|
||||
|
|
@ -149,55 +160,55 @@
|
|||
{/if}
|
||||
|
||||
{#if vue !== null}
|
||||
<div class="ordre" data-ordre>
|
||||
{#each vue.ordre as morceaux, rang (rang)}
|
||||
<p>
|
||||
{#each morceaux as morceau, place (place)}
|
||||
{#if morceau.colonne === null}
|
||||
{t(morceau.cle, morceau.details)}
|
||||
{:else}
|
||||
<!-- Le nom est un terme, non une commande : aucun rôle n'annonce
|
||||
d'action, et le focus ne sert qu'à montrer au clavier la
|
||||
colonne que le pointeur montre au survol. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="critere"
|
||||
class:actif={surlignee === morceau.colonne}
|
||||
data-critere={morceau.critere}
|
||||
tabindex="0"
|
||||
aria-describedby={decrit(`critere:${morceau.critere}`)}
|
||||
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
|
||||
onmouseleave={() => sortir(morceau, false)}
|
||||
onfocus={(evenement) => entrer(evenement, morceau, true)}
|
||||
onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
|
||||
<Carte titre={t('propositions.tableau.titre')} niveau={3} plein actions={charge.propositions.length > 0 ? effacerListe : null}>
|
||||
<div class="ordre" data-ordre>
|
||||
{#each vue.ordre as morceaux, rang (rang)}
|
||||
<p>
|
||||
{#each morceaux as morceau, place (place)}
|
||||
{#if morceau.colonne === null}
|
||||
{t(morceau.cle, morceau.details)}
|
||||
{:else}
|
||||
<!-- Le nom est un terme, non une commande : aucun rôle n'annonce
|
||||
d'action, et le focus ne sert qu'à montrer au clavier la
|
||||
colonne que le pointeur montre au survol. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="critere"
|
||||
class:actif={surlignee === morceau.colonne}
|
||||
data-critere={morceau.critere}
|
||||
tabindex="0"
|
||||
aria-describedby={decrit(`critere:${morceau.critere}`)}
|
||||
onmouseenter={(evenement) => entrer(evenement, morceau, false)}
|
||||
onmouseleave={() => sortir(morceau, false)}
|
||||
onfocus={(evenement) => entrer(evenement, morceau, true)}
|
||||
onblur={() => sortir(morceau, true)}>{t(morceau.cle, morceau.details)}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
|
||||
</Carte>
|
||||
{:else if charge !== null && charge.propositions.length > 0}
|
||||
<div class="effacer">{@render effacerListe()}</div>
|
||||
{/if}
|
||||
|
||||
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
|
||||
|
||||
{#if charge !== null}
|
||||
<div class="cible" data-cible-redondance>
|
||||
<Champ
|
||||
cle={entreeCible.cle}
|
||||
etiquette={entreeCible.etiquette}
|
||||
valeur={cibleRedondance(charge)}
|
||||
lire={lireEntier}
|
||||
executer={changerCible}
|
||||
/>
|
||||
<p>{t('propositions.cible.aide')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if charge !== null && charge.propositions.length > 0}
|
||||
<div class="effacer">
|
||||
<Commande cle="propositions.commande.effacer" executer={effacer} />
|
||||
</div>
|
||||
<Carte>
|
||||
<div class="cible" data-cible-redondance>
|
||||
<Champ
|
||||
cle={entreeCible.cle}
|
||||
etiquette={entreeCible.etiquette}
|
||||
valeur={cibleRedondance(charge)}
|
||||
lire={lireEntier}
|
||||
executer={changerCible}
|
||||
/>
|
||||
<p class="aide">{t('propositions.cible.aide')}</p>
|
||||
</div>
|
||||
</Carte>
|
||||
{/if}
|
||||
|
||||
{#if aConfirmer !== null}
|
||||
|
|
@ -228,10 +239,13 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* La phrase de l'ordre, en tête de la carte, au retrait du contenu d'une
|
||||
carte ; le tableau la suit, collé aux bords. */
|
||||
.ordre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-1);
|
||||
padding: var(--espace-3) var(--espace-5);
|
||||
}
|
||||
|
||||
/* Un nom qui désigne une colonne se signale par un soulignement pointillé ;
|
||||
|
|
@ -263,4 +277,9 @@
|
|||
align-items: flex-start;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
.aide {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-texte-petit);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@
|
|||
// decrit('colonne:<nom>') rend l'identifiant de l'infobulle quand elle
|
||||
// explique cet en-tête, pour son aria-describedby, undefined sinon.
|
||||
//
|
||||
// Deux rangées d'en-têtes : celle des groupes (GROUPES_COLONNES, modele.js),
|
||||
// chacun en scope colgroup au-dessus de ses colonnes, une cellule vide au-
|
||||
// dessus d'une colonne hors groupe ; puis celle des colonnes, seule à
|
||||
// porter data-colonne et les explications. Les colonnes de nombres
|
||||
// s'alignent à droite en chiffres tabulaires, en-tête compris. La ligne du
|
||||
// retenu se peint de la teinte de l'état Retenu, sous l'encre de la page ;
|
||||
// la proposition retenue porte la pastille « retenue » (Pastille,
|
||||
// primitive). Le tableau se nomme par aria-label : la carte de la section
|
||||
// en porte le titre visible.
|
||||
//
|
||||
// Désignations : data-retenu sur la ligne du retenu, data-proposition sur
|
||||
// chaque autre ligne, data-colonne sur chaque cellule et chaque en-tête ;
|
||||
// data-classement et data-hors-classement sur les deux groupes de lignes.
|
||||
|
|
@ -36,7 +46,9 @@
|
|||
// 3:1 contre le fond, dans les deux thèmes. Les autres n'en portent pas.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import { texteCellule } from './modele.js';
|
||||
import Icone from '../primitives/Icone.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import { GROUPES_COLONNES, texteCellule } from './modele.js';
|
||||
|
||||
let { vue, surlignee = null, nommer, voir, retenir, expliquer, taire, decrit } = $props();
|
||||
|
||||
|
|
@ -47,6 +59,9 @@
|
|||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
const outils = { t, format };
|
||||
|
||||
// Les noms des colonnes de nombres.
|
||||
const nombres = $derived(new Set(vue.colonnes.filter((colonne) => colonne.nombre).map((colonne) => colonne.nom)));
|
||||
</script>
|
||||
|
||||
{#snippet raisons(ligne)}
|
||||
|
|
@ -65,7 +80,7 @@
|
|||
{#snippet mesures(ligne)}
|
||||
{#if ligne.comparable}
|
||||
{#each ligne.cellules as cellule (cellule.colonne)}
|
||||
<td data-colonne={cellule.colonne} data-marque={marque(cellule.colonne)} class="nombre" class:surlignee={cellule.colonne === surlignee}>{texteCellule(cellule, outils)}</td>
|
||||
<td data-colonne={cellule.colonne} data-marque={marque(cellule.colonne)} class:nombre={nombres.has(cellule.colonne)} class:surlignee={cellule.colonne === surlignee}>{texteCellule(cellule, outils)}</td>
|
||||
{/each}
|
||||
<td data-colonne="imposes">
|
||||
{#each ligne.imposes as phrase, rang (rang)}
|
||||
|
|
@ -89,7 +104,7 @@
|
|||
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
|
||||
<span class="identifiant">{format.nombre(ligne.id)}</span>
|
||||
{#if ligne.retenue}
|
||||
<span class="marque">{t('propositions.marque.retenue')}</span>
|
||||
<Pastille sorte="statut" valeur="retenu" icone="coche" libelle={t('propositions.marque.retenue')} />
|
||||
{/if}
|
||||
{@render violations(ligne)}
|
||||
</td>
|
||||
|
|
@ -105,9 +120,20 @@
|
|||
{/snippet}
|
||||
|
||||
<div class="defilement">
|
||||
<table>
|
||||
<caption>{t('propositions.tableau.titre')}</caption>
|
||||
<table aria-label={t('propositions.tableau.titre')}>
|
||||
{#each GROUPES_COLONNES as segment, rang (rang)}
|
||||
<colgroup span={segment.etendue}></colgroup>
|
||||
{/each}
|
||||
<thead>
|
||||
<tr class="groupes">
|
||||
{#each GROUPES_COLONNES as segment, rang (rang)}
|
||||
{#if segment.cle === null}
|
||||
<td></td>
|
||||
{:else}
|
||||
<th scope="colgroup" colspan={segment.etendue}>{t(segment.cle)}</th>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
<tr>
|
||||
{#each vue.colonnes as colonne (colonne.nom)}
|
||||
{@const terme = `colonne:${colonne.nom}`}
|
||||
|
|
@ -118,6 +144,7 @@
|
|||
data-colonne={colonne.nom}
|
||||
data-marque={marque(colonne.nom)}
|
||||
class:surlignee={colonne.nom === surlignee}
|
||||
class:nombre={colonne.nombre}
|
||||
tabindex="0"
|
||||
aria-describedby={decrit(terme)}
|
||||
onmouseenter={(evenement) => expliquer(evenement, terme, colonne.nom)}
|
||||
|
|
@ -131,7 +158,9 @@
|
|||
{#if vue.retenu !== null}
|
||||
<tbody>
|
||||
<tr data-retenu={vue.retenu.id} class="retenu">
|
||||
<th scope="row" data-colonne="rang">{t('propositions.retenu.titre')}</th>
|
||||
<th scope="row" data-colonne="rang"
|
||||
><span class="titre-retenu"><span class="signe-retenu"><Icone nom="coche" /></span>{t('propositions.retenu.titre')}</span></th
|
||||
>
|
||||
<td data-colonne="proposition" data-marque={marque('proposition')} class:surlignee={surlignee === 'proposition'}>
|
||||
<span class="identifiant">{format.nombre(vue.retenu.id)}</span>
|
||||
<p>{t(vue.retenu.libelle.cle, vue.retenu.libelle.details)}</p>
|
||||
|
|
@ -181,27 +210,55 @@
|
|||
}
|
||||
|
||||
table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-bottom: var(--espace-2);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Les cellules : un filet léger entre deux lignes, aucun filet vertical. */
|
||||
th,
|
||||
td {
|
||||
padding: var(--espace-1) var(--espace-2);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
padding: var(--espace-2) var(--espace-3);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--couleur-ligne-filet);
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* Les en-têtes de colonne : sur le fond sourd de la carte, en gras et en
|
||||
petit corps, sous un filet de carte. */
|
||||
thead th,
|
||||
thead td {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
border-bottom-color: var(--couleur-carte-filet);
|
||||
}
|
||||
|
||||
thead th {
|
||||
min-width: 6rem;
|
||||
font-size: var(--taille-texte-petit);
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* La rangée des groupes : des intertitres en petites capitales d'encre
|
||||
secondaire, chacun soulignant l'étendue de ses colonnes. */
|
||||
.groupes th {
|
||||
padding-top: var(--espace-2);
|
||||
padding-bottom: var(--espace-1);
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
font-size: var(--taille-intertitre);
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupes th,
|
||||
.groupes td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.groupes th {
|
||||
border-bottom: 2px solid var(--couleur-carte-filet);
|
||||
}
|
||||
|
||||
.nombre {
|
||||
|
|
@ -231,23 +288,55 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* La ligne du retenu et la proposition retenue se distinguent par leur
|
||||
forme : texte gras, filet épais à gauche, et le mot « retenue ». */
|
||||
.retenu,
|
||||
.retenue {
|
||||
font-weight: bold;
|
||||
/* L'en-tête du groupe hors classement : un intertitre sur le fond sourd. */
|
||||
tbody[data-hors-classement] th[scope='rowgroup'] {
|
||||
background-color: var(--couleur-carte-fond-sourd);
|
||||
font-size: var(--taille-intertitre);
|
||||
letter-spacing: var(--interlettre-capitales);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* La ligne du retenu : chaque cellule sur la teinte de l'état Retenu,
|
||||
l'encre de la page dessus ; son titre en gras, précédé de la coche. */
|
||||
.retenu > * {
|
||||
background-color: var(--couleur-etat-retenu-fond);
|
||||
}
|
||||
|
||||
.titre-retenu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signe-retenu {
|
||||
display: inline-flex;
|
||||
color: var(--couleur-etat-retenu-marque);
|
||||
}
|
||||
|
||||
/* La ligne du retenu et la proposition retenue ouvrent sur un trait de la
|
||||
marque de Retenu ; la ligne du retenu se nomme en mots, la proposition
|
||||
retenue par sa pastille. */
|
||||
.retenu > :first-child,
|
||||
.retenue > :first-child {
|
||||
border-left: 4px solid var(--couleur-page-texte);
|
||||
border-left: 3px solid var(--couleur-etat-retenu-marque);
|
||||
}
|
||||
|
||||
td[data-colonne='proposition'] {
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
.identifiant {
|
||||
margin-right: var(--espace-2);
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* La colonne que désigne la phrase de l'ordre : un filet de 2 px de la
|
||||
couleur de sélection de chaque côté de son en-tête et de ses cellules,
|
||||
plus épais que le filet ordinaire, et l'en-tête souligné. Ni le fond ni
|
||||
la couleur du texte ne changent : les contrastes restent ceux du
|
||||
tableau. */
|
||||
et l'en-tête souligné. Ni le fond ni la couleur du texte ne changent :
|
||||
les contrastes restent ceux du tableau. */
|
||||
th.surlignee,
|
||||
td.surlignee {
|
||||
border-left: 2px solid var(--couleur-selection);
|
||||
|
|
@ -267,13 +356,6 @@
|
|||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.marque {
|
||||
margin-left: var(--espace-1);
|
||||
padding: 0 var(--espace-1);
|
||||
border: 1px solid var(--couleur-page-texte);
|
||||
border-radius: var(--rayon-1);
|
||||
}
|
||||
|
||||
.commandes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -62,17 +62,64 @@ const SELON_A_PRIORI = new Set(['ecartItineraireMax', 'ecartAuPlafondAPriori']);
|
|||
// sont des comptes.
|
||||
const DECIMALES = new Map([['diversite', 2]]);
|
||||
|
||||
// Les colonnes dont les cellules sont du texte : toutes les autres portent
|
||||
// un nombre, qui s'aligne à droite en chiffres tabulaires.
|
||||
const TEXTUELLES = new Set(['proposition', 'certificat', 'imposes', 'commandes']);
|
||||
|
||||
/** Les colonnes du tableau, dans l'ordre d'affichage : le rang et
|
||||
* l'identifiant, puis les mesures — les trois minimums des rencontres en
|
||||
* tête —, le certificat, les imposés et les commandes. Chacune porte son nom,
|
||||
* la clé de son en-tête, qui nomme son unité, et celle de son explication,
|
||||
* le texte de l'infobulle de son en-tête et du critère qui la désigne. */
|
||||
* la clé de son en-tête, qui nomme son unité, celle de son explication,
|
||||
* le texte de l'infobulle de son en-tête et du critère qui la désigne, et
|
||||
* nombre, vrai quand ses cellules portent un nombre. */
|
||||
export const COLONNES = Object.freeze(
|
||||
['rang', 'proposition', ...LECTURES.keys(), 'imposes', 'commandes'].map((nom) =>
|
||||
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}` }),
|
||||
Object.freeze({ nom, cle: `propositions.colonne.${nom}`, explication: `propositions.explication.${nom}`, nombre: !TEXTUELLES.has(nom) }),
|
||||
),
|
||||
);
|
||||
|
||||
// Les groupes d'en-têtes : chacun nomme des colonnes de mesure voisines dans
|
||||
// COLONNES, sous la clé propositions.groupe.<nom>.
|
||||
const GROUPES = [
|
||||
['rencontres', ['rencontresTous', 'rencontresMobiles', 'rencontresAncres']],
|
||||
['collisions', ['collisionsCumulees', 'pairesDistinctes', 'excedentCollisions']],
|
||||
['repetitions', ['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis']],
|
||||
['diversite', ['appartenancesVues', 'totalRedondance', 'diversite']],
|
||||
['manque', ['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori']],
|
||||
];
|
||||
|
||||
// Les segments de la rangée des groupes, dans l'ordre de COLONNES : un
|
||||
// groupe couvre ses colonnes d'un seul segment, une colonne hors groupe
|
||||
// forme le sien, sans clé. Un groupe dont les colonnes ne se suivent pas
|
||||
// dans COLONNES lève au chargement du module.
|
||||
function segmenter() {
|
||||
const noms = COLONNES.map(({ nom }) => nom);
|
||||
const segments = [];
|
||||
for (let rang = 0; rang < noms.length; ) {
|
||||
const groupe = GROUPES.find(([, colonnes]) => colonnes[0] === noms[rang]);
|
||||
if (groupe === undefined) {
|
||||
if (GROUPES.some(([, colonnes]) => colonnes.includes(noms[rang]))) throw new Error(`colonne groupée hors de son groupe : ${noms[rang]}`);
|
||||
segments.push({ cle: null, colonnes: [noms[rang]] });
|
||||
rang += 1;
|
||||
continue;
|
||||
}
|
||||
const [nom, colonnes] = groupe;
|
||||
colonnes.forEach((colonne, decalage) => {
|
||||
if (noms[rang + decalage] !== colonne) throw new Error(`groupe ${nom} : colonnes non voisines`);
|
||||
});
|
||||
segments.push({ cle: `propositions.groupe.${nom}`, colonnes });
|
||||
rang += colonnes.length;
|
||||
}
|
||||
return segments.map(({ cle, colonnes }) => Object.freeze({ cle, etendue: colonnes.length, colonnes: Object.freeze([...colonnes]) }));
|
||||
}
|
||||
|
||||
/** La rangée des groupes d'en-têtes, au-dessus de celle des colonnes :
|
||||
* { cle, etendue, colonnes } par segment, dans l'ordre de COLONNES — cle,
|
||||
* la clé du libellé du groupe, null pour une colonne hors groupe ;
|
||||
* etendue, le nombre de colonnes couvertes ; colonnes, leurs noms. Les
|
||||
* segments couvrent chaque colonne une fois. */
|
||||
export const GROUPES_COLONNES = Object.freeze(segmenter());
|
||||
|
||||
/**
|
||||
* La clé de l'explication d'une colonne : le texte de l'infobulle de son
|
||||
* en-tête, et celui du nom de critère qui la désigne dans la phrase de
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { VERSION } from '../../version.genere.js';
|
|||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import {
|
||||
COLONNES,
|
||||
GROUPES_COLONNES,
|
||||
colonneDuCritere,
|
||||
demandeConfirmation,
|
||||
evaluation,
|
||||
|
|
@ -769,3 +770,53 @@ describe('surlignee : la colonne surlignée, du survol et du focus (§ 5.7)', ()
|
|||
assert.equal(surlignee(null, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GROUPES_COLONNES : les en-têtes groupés du tableau', () => {
|
||||
const noms = COLONNES.map(({ nom }) => nom);
|
||||
|
||||
test('les segments couvrent chaque colonne une fois, dans l’ordre de COLONNES ; chaque étendue est le compte de ses colonnes', () => {
|
||||
assert.ok(GROUPES_COLONNES.length > 0);
|
||||
assert.deepEqual(GROUPES_COLONNES.flatMap(({ colonnes }) => colonnes), noms);
|
||||
for (const segment of GROUPES_COLONNES) assert.equal(segment.etendue, segment.colonnes.length, JSON.stringify(segment));
|
||||
assert.ok(Object.isFrozen(GROUPES_COLONNES));
|
||||
for (const segment of GROUPES_COLONNES) {
|
||||
assert.ok(Object.isFrozen(segment));
|
||||
assert.ok(Object.isFrozen(segment.colonnes));
|
||||
}
|
||||
});
|
||||
|
||||
test('le rang, la proposition, le certificat, les imposés et les commandes restent hors groupe, une colonne par segment sans clé', () => {
|
||||
const seules = GROUPES_COLONNES.filter(({ cle }) => cle === null);
|
||||
assert.deepEqual(seules.map(({ colonnes }) => colonnes), [['rang'], ['proposition'], ['certificat'], ['imposes'], ['commandes']]);
|
||||
});
|
||||
|
||||
test('cinq groupes de mesures voisines, chacun sous une clé « propositions.groupe. » qui a son texte, distinct des en-têtes de colonne', () => {
|
||||
const groupes = GROUPES_COLONNES.filter(({ cle }) => cle !== null);
|
||||
assert.deepEqual(
|
||||
groupes.map(({ colonnes }) => colonnes),
|
||||
[
|
||||
['rencontresTous', 'rencontresMobiles', 'rencontresAncres'],
|
||||
['collisionsCumulees', 'pairesDistinctes', 'excedentCollisions'],
|
||||
['rencontresRepetees', 'maxRencontresPaire', 'retoursChoisis'],
|
||||
['appartenancesVues', 'totalRedondance', 'diversite'],
|
||||
['manqueMax', 'effectifManque', 'ecartItineraireMax', 'ecartAuPlafondAPriori'],
|
||||
],
|
||||
);
|
||||
const enTetes = new Set(COLONNES.map(({ cle }) => t(cle)));
|
||||
for (const { cle } of groupes) {
|
||||
assert.match(cle, /^propositions\.groupe\.[a-z]+$/u);
|
||||
const texte = t(cle);
|
||||
assert.ok(texte.length > 0, cle);
|
||||
assert.ok(!enTetes.has(texte), cle);
|
||||
assert.doesNotMatch(texte, /moyenne/i, cle);
|
||||
}
|
||||
assert.equal(new Set(groupes.map(({ cle }) => cle)).size, groupes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COLONNES : les colonnes de nombres', () => {
|
||||
test('nombre est vrai pour le rang et chaque mesure chiffrée ; faux pour la proposition, le certificat en mots, les imposés et les commandes', () => {
|
||||
const textes = new Set(['proposition', 'certificat', 'imposes', 'commandes']);
|
||||
for (const colonne of COLONNES) assert.equal(colonne.nombre, !textes.has(colonne.nom), colonne.nom);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,13 +13,16 @@
|
|||
//
|
||||
// Le projet navigateur emploie expect, et test et describe de Vitest
|
||||
// (§ 14.8).
|
||||
import '../jetons.css';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { creerHorlogePilotee } from '../../application/horloge.js';
|
||||
import { TABLES_APPLICATION } from '../../application/libelles.js';
|
||||
import { creerTraduction } from '../../application/traduction.js';
|
||||
import { PAPIERS } from '../../geometrie/papiers.js';
|
||||
import { couleurDeJeton } from '../../../test/navigateur/captures.js';
|
||||
import { attendre } from '../../../test/navigateur/ecrans.js';
|
||||
import { composantes, couleurTexte, fondPeintSous, sousTheme } from '../../../test/navigateur/rendu.js';
|
||||
import { TABLES_INTERFACE } from '../libelles.js';
|
||||
import Papier from './Papier.svelte';
|
||||
|
||||
|
|
@ -135,3 +138,39 @@ describe('la question du papier (§ 8.5, § 11.2)', () => {
|
|||
expect(seance.demandes).toEqual(['A4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l’habillage de la question du papier', () => {
|
||||
test('chaque papier est un choix distinct : une tuile à son filet, qui ne chevauche pas sa voisine, et son bouton la remplit sur 44 px de haut au moins', () => {
|
||||
const conteneur = monter(seanceDEpreuve(null));
|
||||
const tuiles = [...conteneur.querySelectorAll('[data-papier="question"] [data-cible]')];
|
||||
expect(tuiles.map((tuile) => tuile.dataset.cible)).toEqual(Object.keys(PAPIERS));
|
||||
const filet = couleurDeJeton('--couleur-carte-filet').join();
|
||||
for (const tuile of tuiles) {
|
||||
const style = getComputedStyle(tuile);
|
||||
expect(style.borderTopWidth, tuile.dataset.cible).toBe('1px');
|
||||
expect(composantes(style.borderTopColor).join(), tuile.dataset.cible).toBe(filet);
|
||||
expect(Number.parseFloat(style.borderTopLeftRadius), tuile.dataset.cible).toBeGreaterThanOrEqual(8);
|
||||
const bouton = tuile.querySelector('[data-commande="choisirPapier"]');
|
||||
const b = bouton.getBoundingClientRect();
|
||||
expect(b.height, tuile.dataset.cible).toBeGreaterThanOrEqual(44);
|
||||
expect(b.width, tuile.dataset.cible).toBeGreaterThanOrEqual(tuile.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight) - 1);
|
||||
}
|
||||
const [a, b] = tuiles.map((tuile) => tuile.getBoundingClientRect());
|
||||
const chevauchent = a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
expect(chevauchent).toBe(false);
|
||||
});
|
||||
|
||||
test('le papier choisi se montre dans une pastille neutre, son nom de l’encre de la page, dans les deux thèmes', async () => {
|
||||
const conteneur = monter(seanceDEpreuve('A4'));
|
||||
const choisi = conteneur.querySelector('[data-papier="choisi"]');
|
||||
const nom = [...choisi.querySelectorAll('span')].find((span) => texte(span) === t('impression.papier.nom.a4') && span.children.length === 0);
|
||||
expect(nom).toBeDefined();
|
||||
for (const theme of ['clair', 'sombre']) {
|
||||
await sousTheme(theme, async () => {
|
||||
const [fond] = fondPeintSous(nom);
|
||||
expect(fond.join(), theme).toBe(couleurDeJeton('--couleur-neutre-fond').join());
|
||||
expect(couleurTexte(nom).join(), theme).toBe(couleurDeJeton('--couleur-page-texte').join());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,8 +18,13 @@
|
|||
// la commande, et la question reste. Le choix fait, onChoisi, quand il est
|
||||
// donné, reçoit le papier : la section qui demandait une production la
|
||||
// poursuit.
|
||||
//
|
||||
// Habillage : la question montre chaque papier comme un choix distinct,
|
||||
// une tuile à son filet que son bouton remplit, haut d'une cible
|
||||
// principale ; le papier choisi se montre dans une pastille neutre.
|
||||
import { getContext } from 'svelte';
|
||||
import Commande from '../primitives/Commande.svelte';
|
||||
import Pastille from '../primitives/Pastille.svelte';
|
||||
import { PAPIERS_OFFERTS, clesPapier } from '../impression/modele.js';
|
||||
|
||||
let { onChoisi = null } = $props();
|
||||
|
|
@ -45,7 +50,7 @@
|
|||
<p class="explication">{t('impression.papier.explication')}</p>
|
||||
<div class="choix">
|
||||
{#each PAPIERS_OFFERTS as choix (choix)}
|
||||
<div data-cible={choix}>
|
||||
<div class="tuile" data-cible={choix}>
|
||||
<Commande cle={clesPapier(choix).choix} nom="choisirPapier" executer={() => choisir(choix)} />
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -53,8 +58,8 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="papier choisi" data-papier="choisi">
|
||||
<span>{t('impression.papier.titre')}</span>
|
||||
<span class="donnee">{t(clesPapier(papier).nom)}</span>
|
||||
<span class="titre">{t('impression.papier.titre')}</span>
|
||||
<Pastille sorte="statut" valeur="neutre" icone="imprimer" libelle={t(clesPapier(papier).nom)} />
|
||||
<Commande cle="impression.papier.changer" nom="changerPapier" executer={() => (changer = true)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -63,13 +68,13 @@
|
|||
.papier {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.question {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
p {
|
||||
|
|
@ -80,17 +85,37 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.explication {
|
||||
.explication,
|
||||
.titre {
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
/* Les choix : une tuile par papier, côte à côte tant que la largeur le
|
||||
permet, l'une sous l'autre sinon. */
|
||||
.choix {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--espace-2);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: var(--espace-3);
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.donnee {
|
||||
.tuile {
|
||||
padding: var(--espace-2);
|
||||
border: 1px solid var(--couleur-carte-filet);
|
||||
border-radius: var(--rayon-2);
|
||||
background-color: var(--couleur-carte-fond);
|
||||
}
|
||||
|
||||
/* Le bouton de la Commande remplit sa tuile, haut d'une cible principale ;
|
||||
son refus se pose dessous. */
|
||||
.tuile :global(.commande) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tuile :global(button) {
|
||||
flex: 1 1 100%;
|
||||
min-height: var(--hauteur-cible);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue