gestion_table_tournante_libre/src/interface/participants/Formulaire.svelte
Mathieu Benoit 13bf16b5be [REF] interface: read every key through the shortcut table
Twelve components still compared key names themselves, so the shortcut
table could drift from what they actually did; a guard listed them as
pending. Each now reads its effect from RACCOURCIS through effetDe, the
pending list and its guard are removed, and the generated shortcut
table is unchanged. The keys the old guard pinned without a behaviour
test (Escape on the plan root with no active element, ArrowLeft in the
grid, and those of the participant and table lists) each gain one.
Checked: 3852 node, 766 browser, 101 long tests, from the index alone.

--- FR ---

[REF] interface : chaque touche lue par la table des raccourcis

Douze composants comparaient encore eux-mêmes les noms de touches : la
table des raccourcis pouvait s'écarter de ce qu'ils faisaient, et une
garde les tenait en attente. Chacun lit désormais son effet dans
RACCOURCIS par effetDe ; la liste d'attente et sa garde disparaissent,
la table engendrée ne change pas. Les touches que l'ancienne garde
figeait sans épreuve de comportement (Échap sur le plan sans élément
actif, flèche gauche dans la grille, listes des personnes et des
tables) en reçoivent chacune une.
Vérifié : 3852 node, 766 navigateur, 101 longues, depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-10 17:25:03 -04:00

276 lines
8.9 KiB
Svelte

<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le formulaire d'ajout d'une personne (§ 4, § 10.2) : un champ par
// colonne de texte, seul le nom requis. Entrée, ou « Ajouter », exécute
// ajouterParticipant par la séance ; la commande lit chaque saisie — NFC,
// blancs de bord retirés, vide valant null — et refuse un nom vide,
// NOM_REQUIS, que le formulaire pose contre le champ du nom. Un autre
// refus — le mode, l'état du plan — se pose contre « Ajouter ».
//
// L'appartenance est collante : après un ajout, chaque champ se vide sauf
// elle, et l'état de navigation la garde (garderAppartenance) : le
// formulaire remonté — retour d'une autre section, fermeture d'« À propos »
// — repart d'elle. Une appartenance qui ne diffère d'une existante que par la casse,
// les accents ou les blancs (orthographeExistante, grille.js) est signalée
// contre son champ, avec l'orthographe existante à reprendre d'un clic ;
// rien ne fusionne seul.
//
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
// un geste ouvert (geste.js), qui suspend le compte d'inactivité.
//
// L'envoi n'est pas ré-entrant : tant que l'ajout s'écrit, une seconde
// soumission ne fait rien, et « Ajouter » porte aria-busy et
// aria-disabled. Le formulaire envoie une copie des champs ; l'ajout
// abouti, il ne vide que ceux qui portent encore la valeur envoyée : ce
// que l'opérateur tape pendant l'écriture, la personne suivante, reste.
// Le focus revient au nom quand rien n'a été tapé entre-temps.
//
// Les notes sont une zone de texte, qui garde les sauts de ligne :
// Maj+Entrée y insère un saut de ligne, Entrée seule envoie
// (entreeValide, grille.js).
import { getContext, onDestroy, tick, untrack } from 'svelte';
import { cleNormalisee } from '../../application/csv.js';
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
import { effetDe } from '../raccourcis.js';
import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js';
let { participants } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const uid = $props.id();
const geste = creerGeste(seance);
// Les champs du formulaire, dans l'ordre des colonnes de la grille.
const CHAMPS = ['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes'];
// Ceux qu'un ajout vide : tous, l'appartenance exceptée.
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
let valeurs = $state({
nom: '',
prenom: '',
appartenance: untrack(() => ui.etat().appartenance),
courriel: '',
titrePressenti: '',
notes: '',
});
let refusNom = $state.raw(null);
let refusAjout = $state.raw(null);
let entreeNom = $state(null);
let formulaire;
// Vrai pendant l'écriture d'un ajout.
let envoiEnCours = $state(false);
const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee));
const idRefusNom = `${uid}-refus-nom`;
const idOrthographe = `${uid}-orthographe`;
$effect(() => {
geste.tenir(EPHEMERES.some((cle) => valeurs[cle] !== ''));
});
// Une frappe dans le nom retire le refus qu'il portait.
function surSaisieNom() {
refusNom = null;
}
async function ajouter(evenement) {
evenement.preventDefault();
if (envoiEnCours) return;
refusNom = null;
refusAjout = null;
const champs = {
nom: valeurs.nom,
prenom: valeurs.prenom,
appartenance: valeurs.appartenance,
courriel: valeurs.courriel,
titrePressenti: valeurs.titrePressenti,
notes: valeurs.notes,
};
envoiEnCours = true;
try {
await seance.executer('ajouterParticipant', { champs });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
const refus = { code: erreur.code, details: erreur.details ?? {} };
if (refus.code === 'NOM_REQUIS') refusNom = refus;
else refusAjout = refus;
return;
} finally {
envoiEnCours = false;
}
ui.garderAppartenance(champs.appartenance);
const intacts = EPHEMERES.filter((cle) => valeurs[cle] === champs[cle]);
for (const cle of intacts) valeurs[cle] = '';
if (intacts.length < EPHEMERES.length) return;
await tick();
entreeNom?.focus();
}
// Entrée dans les notes envoie le formulaire ; Maj+Entrée y garde le
// saut de ligne de la zone de texte.
function surToucheNotes(evenement) {
if (effetDe('champ', evenement.key) !== 'valider' || !entreeValide({ maj: evenement.shiftKey, multiligne: true })) return;
evenement.preventDefault();
formulaire.requestSubmit();
}
function reprendre() {
valeurs.appartenance = orthographe;
}
onDestroy(() => geste.liberer());
</script>
<form novalidate aria-labelledby={`${uid}-titre`} onsubmit={ajouter} bind:this={formulaire}>
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
<div class="champs">
{#each CHAMPS as cle (cle)}
<div class="champ">
<label for={`${uid}-${cle}`}>{t(cleDeColonne(cle))}</label>
{#if cle === 'nom'}
<input
id={`${uid}-${cle}`}
type="text"
data-champ={cle}
bind:value={valeurs.nom}
bind:this={entreeNom}
aria-invalid={refusNom === null ? undefined : 'true'}
aria-describedby={refusNom === null ? undefined : idRefusNom}
oninput={surSaisieNom}
/>
{#if refusNom !== null}
<Refus id={idRefusNom} refus={refusNom} lever={() => (refusNom = null)} />
{/if}
{:else if cle === 'appartenance'}
<input
id={`${uid}-${cle}`}
type="text"
data-champ={cle}
bind:value={valeurs.appartenance}
aria-describedby={orthographe === null ? undefined : idOrthographe}
/>
{#if orthographe !== null}
<div class="signal" id={idOrthographe} role="status" data-avertissement="orthographe">
<p><span>{t('participants.formulaire.orthographe')}</span> <span class="donnee">{orthographe}</span></p>
<button type="button" data-commande="reprendreOrthographe" onclick={reprendre}>
{t('participants.formulaire.reprendre')}
</button>
</div>
{/if}
{:else if cle === 'notes'}
<textarea id={`${uid}-${cle}`} rows="2" data-champ={cle} bind:value={valeurs.notes} onkeydown={surToucheNotes}></textarea>
{:else}
<input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} />
{/if}
</div>
{/each}
</div>
<div class="issue">
<button
type="submit"
data-commande="ajouter"
aria-busy={envoiEnCours ? 'true' : undefined}
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
>
{#if refusAjout !== null}
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
{/if}
</div>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
h3 {
margin: 0;
font-size: var(--taille-texte);
}
.champs {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-3);
}
.champ {
display: flex;
flex-direction: column;
gap: var(--espace-1);
max-width: 18rem;
}
input,
textarea {
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);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
}
input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
.signal {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
padding: var(--espace-1) var(--espace-2);
border-left: 3px solid var(--couleur-avis);
font-size: var(--taille-texte);
}
.signal p {
margin: 0;
overflow-wrap: anywhere;
}
.donnee {
font-weight: bold;
}
.issue {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
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;
font-size: var(--taille-texte);
cursor: pointer;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style>