gestion_table_tournante_libre/src/interface/propositions/SectionPropositions.svelte
Mathieu Benoit c6082c3af2 [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
2026-10-11 00:58:07 -04:00

285 lines
11 KiB
Svelte

<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des propositions (§ 2.1 étape 10, § 5.7, § 9) : la phrase de
// l'ordre du classement, le tableau, et la commande qui efface la liste en
// épargnant le retenu. Regarder n'écrit jamais (§ 8.4) : le tableau se lit
// en lecture et sur un plan bloqué, où la séance refuse elle-même
// « Retenir » et « Effacer », le refus posé à côté du bouton.
//
// La table d'évaluation se lit dans la charge de l'état observable, par
// evaluation (modele.js) : une seule mesure, celle d'evaluer (evaluation.js), refaite
// à chaque charge écrite ; les indicateurs du retenu suivent donc
// l'ajustement (§ 2.1 étape 11).
//
// « Voir sur le plan » demande la proposition au plan par l'état de
// navigation, sans écriture, et ouvre la section du plan ; voir(null) y
// montre le retenu. « Retenir » exécute retenirProposition, puis remet la
// demande à null : le plan montre le retenu. Quand le retenu est ajusté et
// que la séance accepterait le geste, retenir détruit ces ajustements : la
// fenêtre à confirmer le dit d'abord, nommant le retenu ajusté et la
// proposition qui le remplace (§ 2.1). Un geste que la séance refuserait
// s'exécute sans fenêtre, et son refus s'affiche. « Effacer » ne détruit
// aucune saisie — chaque proposition se régénère de sa graine, et le geste
// se défait — : il s'exécute sans fenêtre.
//
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) ;
// chaque nom de critère, et l'identifiant croissant, est un élément
// focalisable qui désigne sa colonne. Le survoler ou le focaliser surligne
// cette colonne dans le tableau. Le survol et le focus se tiennent à part,
// et surlignee (modele.js) choisit : le nom survolé, sinon celui qui a le
// focus. Quitter un nom — pointeur sorti, focus perdu — n'efface que l'état
// qu'il tenait, et laisse l'autre.
//
// Chaque nom de critère et chaque en-tête de colonne s'expliquent dans
// l'infobulle de la section, une seule (Infobulle, primitive) : le survol
// ou le focus l'ouvre sur le terme, le quitter la referme quand elle est
// encore la sienne, Échap aussi. Un critère et sa colonne partagent leur
// 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
// refus se pose contre le champ (Champ).
import { getContext } from 'svelte';
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';
import Infobulle from '../primitives/Infobulle.svelte';
import Refus from '../primitives/Refus.svelte';
import TableauPropositions from './TableauPropositions.svelte';
import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, surlignee as colonneSurlignee, tableau } from './modele.js';
const t = getContext('t');
// Le champ de la cible lit l'entrée que lit aussi le tableau des réglages
// de la documentation (§ 19.8).
const [entreeCible] = reglagesDeSection(REGLAGES_GENERATION, 'propositions');
const seance = getContext('seance');
const ui = getContext('ui');
const charge = $derived($seance.charge);
const evaluee = $derived(charge === null ? { table: null, refus: null } : evaluation(charge));
const ajuste = $derived(charge !== null && retenuAjuste(charge));
const vue = $derived(evaluee.table === null ? null : tableau(evaluee.table, { ajuste }));
// La retenue en attente de confirmation, telle que demandeConfirmation la
// rend : la proposition demandée, le titre et les éléments de la fenêtre ;
// null sans fenêtre.
let aConfirmer = $state(null);
function voir(id) {
ui.montrerProposition(id);
ui.allerA('plan');
}
async function retenirSansQuestion(id) {
await seance.executer('retenirProposition', { id });
ui.montrerProposition(null);
}
async function retenir(id) {
const demande = demandeConfirmation(seance.etat(), id);
if (demande !== null) {
aConfirmer = demande;
return;
}
await retenirSansQuestion(id);
}
async function confirmer() {
await retenirSansQuestion(aConfirmer.id);
aConfirmer = null;
}
// Les colonnes du nom survolé et du nom focalisé, null sans lui ; la
// colonne surlignée s'en déduit.
let survolee = $state(null);
let focalisee = $state(null);
const surlignee = $derived(colonneSurlignee(survolee, focalisee));
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation, 'critere:<critère>' pour un nom de la phrase ou
// 'colonne:<nom>' pour un en-tête, et la clé de son explication — ; null
// sans infobulle. Ouvrir sur un terme remplace l'explication d'un autre ;
// quitter un terme ne ferme que la sienne.
const uid = $props.id();
const idExplication = `${uid}-explication`;
let expliquee = $state(null);
function expliquer(evenement, terme, colonne) {
expliquee = { ancre: evenement.currentTarget, terme, cle: explicationDeColonne(colonne), details: {} };
}
function taire(terme) {
if (expliquee?.terme === terme) expliquee = null;
}
const fermerExplication = () => (expliquee = null);
// L'aria-describedby d'un terme : l'infobulle quand elle l'explique.
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
// L'entrée sur un nom, par le pointeur ou par le focus, et sa sortie.
function entrer(evenement, { critere, colonne }, parFocus) {
if (parFocus) focalisee = colonne;
else survolee = colonne;
expliquer(evenement, `critere:${critere}`, colonne);
}
function sortir({ critere }, parFocus) {
if (parFocus) focalisee = null;
else survolee = null;
taire(`critere:${critere}`);
}
const effacer = () => seance.executer('effacerPropositions', {});
const changerCible = (cible) => seance.executer('changerCibleRedondance', { cible });
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}
<Carte>
<p>{t('propositions.aucune')}</p>
</Carte>
{/if}
{#if evaluee.refus !== null}
<Refus refus={evaluee.refus} />
{/if}
{#if vue !== null}
<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}
<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}
<Confirmation
titre={aConfirmer.titre.cle}
details={aConfirmer.titre.details}
elements={aConfirmer.elements.map((element) => t(element.cle, element.details))}
surConfirmer={confirmer}
surAnnuler={() => (aConfirmer = null)}
/>
{/if}
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: var(--espace-3);
min-width: 0;
}
h2 {
margin: 0;
font-size: var(--taille-titre);
}
p {
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é ;
survolé ou focalisé, par un soulignement plein. Il ne se coupe pas d'une
ligne à l'autre : sa boîte, que le pointeur survole, reste d'un tenant. */
.critere {
white-space: nowrap;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
}
.critere.actif {
text-decoration-style: solid;
text-decoration-thickness: 2px;
}
.critere:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.effacer {
display: flex;
}
.cible {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
.aide {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
</style>