The working screens of an evening. The home screen lists the events of the working folder, asks its question, loads demos, copies, renames, imports and deletes, and shows what the trash holds. An open event frames its mode, offers Modifier, Débloquer and a confirmed lock takeover, and shows its warnings. Participants get a grid, a form and a file; an import goes through a preview and a report; tables, the default and tours have their screens; the plan draws tables, chairs, empty seats and names. Checked: 2342 node, 356 browser, 60 node-long; the Electron start check. --- FR --- [ADD] interface : liste, événement, participants, import, tables, plan Les écrans de travail d'une soirée. L'accueil liste les événements du dossier de travail, pose sa question, charge les démonstrations, copie, renomme, importe et supprime, et montre ce que tient la corbeille. Un événement ouvert encadre son mode, offre Modifier, Débloquer et une reprise de verrou confirmée, et montre ses avertissements. Les participants ont une grille, un formulaire et une fiche ; un import passe par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ; le plan dessine tables, chaises, sièges vides et noms. Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron. Assisted-by: Claude Opus 5.5
443 lines
16 KiB
Svelte
443 lines
16 KiB
Svelte
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
|
|
|
<script>
|
|
// La grille éditable des participants (§ 10.2) : une ligne par personne
|
|
// de lignes, dans l'ordre que lui donne SectionParticipants, une cellule
|
|
// par colonne de COLONNES (grille.js), puis les commandes de la ligne.
|
|
//
|
|
// Au clavier, une seule cellule est dans l'ordre de tabulation, la cellule
|
|
// active ; flèches, Tab et Maj+Tab la déplacent (celluleSuivante), et Tab
|
|
// après la dernière, Maj+Tab avant la première, rendent le focus au
|
|
// navigateur. Entrée ou F2 ouvre l'édition d'une cellule de texte, que la
|
|
// machine de champ.js tient : Entrée, Tab ou la sortie du champ valident,
|
|
// Échap rend la valeur enregistrée. Les notes s'éditent dans une zone de
|
|
// texte, qui garde leurs sauts de ligne ; un champ d'une ligne les
|
|
// retirerait de toute note retouchée. Une validation qui change la valeur
|
|
// exécute modifierParticipant par la séance, sur ce seul champ ; une
|
|
// saisie qui se lit comme la valeur enregistrée — en NFC, sans blancs de
|
|
// bord — n'exécute rien. Entrée sur la cellule d'exclusion exclut ou
|
|
// réintègre la personne. Un refus se pose dans la cellule où le geste a eu
|
|
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant
|
|
// d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
|
|
//
|
|
// L'en-tête trie : un clic sur une colonne la trie croissante, un second
|
|
// décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur
|
|
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
|
|
// 10.2) : le texte part à l'état de navigation, intact, et rien ne
|
|
// s'écrit ici.
|
|
import { getContext, onDestroy, tick } from 'svelte';
|
|
import { creerMachineChamp } from '../primitives/champ.js';
|
|
import Commande from '../primitives/Commande.svelte';
|
|
import { creerGeste } from '../primitives/geste.js';
|
|
import Refus from '../primitives/Refus.svelte';
|
|
import { COLONNES, celluleSuivante, cleDeColonne } from './grille.js';
|
|
|
|
let { lignes, tri = $bindable(null), supprimer } = $props();
|
|
|
|
const t = getContext('t');
|
|
const seance = getContext('seance');
|
|
const ui = getContext('ui');
|
|
const uid = $props.id();
|
|
const geste = creerGeste(seance);
|
|
|
|
// La cellule active : l'identifiant de sa ligne et le rang de sa colonne.
|
|
let actif = $state({ id: null, colonne: 0 });
|
|
// La cellule en édition, {id, colonne}, et le texte saisi.
|
|
let edition = $state.raw(null);
|
|
let saisie = $state('');
|
|
// Le refus posé sur une cellule : {id, colonne, refus}.
|
|
let refusCellule = $state.raw(null);
|
|
let machine = null;
|
|
let tableau;
|
|
let entree = $state(null);
|
|
|
|
// Le rang de la ligne active ; la première quand l'active n'est plus affichée.
|
|
const rangActif = $derived.by(() => {
|
|
const rang = lignes.findIndex(({ id }) => id === actif.id);
|
|
return rang < 0 ? 0 : rang;
|
|
});
|
|
|
|
const idRefus = (id, colonne) => `${uid}-refus-${id}-${colonne}`;
|
|
const enEdition = (id, colonne) => edition !== null && edition.id === id && edition.colonne === colonne;
|
|
const refusDe = (id, colonne) =>
|
|
refusCellule !== null && refusCellule.id === id && refusCellule.colonne === colonne ? refusCellule.refus : null;
|
|
|
|
// Le texte d'une valeur enregistrée, vide pour null ; et la valeur que
|
|
// l'enregistrement en lirait, pour reconnaître une saisie sans changement.
|
|
const texteDe = (valeur) => (valeur === null ? '' : valeur);
|
|
const lire = (texte) => ({ valeur: texte.normalize('NFC').trim() });
|
|
|
|
function trier(colonne) {
|
|
tri = tri !== null && tri.colonne === colonne && tri.sens === 'croissant' ? { colonne, sens: 'decroissant' } : { colonne, sens: 'croissant' };
|
|
}
|
|
|
|
const ariaSort = (colonne) => {
|
|
if (tri === null || tri.colonne !== colonne) return 'none';
|
|
return tri.sens === 'croissant' ? 'ascending' : 'descending';
|
|
};
|
|
|
|
function cellule(id, colonne) {
|
|
return tableau?.querySelector(`[data-participant="${id}"] [data-champ="${colonne}"]`) ?? null;
|
|
}
|
|
|
|
async function activer(rang, colonne) {
|
|
const ligne = lignes[rang];
|
|
actif = { id: ligne.id, colonne };
|
|
await tick();
|
|
cellule(ligne.id, COLONNES[colonne])?.focus();
|
|
}
|
|
|
|
function refuser(id, colonne, erreur) {
|
|
if (typeof erreur?.code !== 'string') throw erreur;
|
|
refusCellule = { id, colonne, refus: { code: erreur.code, details: erreur.details ?? {} } };
|
|
}
|
|
|
|
async function ouvrirEdition(personne, colonne) {
|
|
const courant = seance.etat().refus;
|
|
if (courant !== null) {
|
|
refusCellule = { id: personne.id, colonne, refus: courant };
|
|
return;
|
|
}
|
|
refusCellule = null;
|
|
machine = creerMachineChamp({ stockee: texteDe(personne[colonne]), lire });
|
|
saisie = machine.saisie;
|
|
edition = { id: personne.id, colonne };
|
|
await tick();
|
|
entree?.focus();
|
|
entree?.setSelectionRange(saisie.length, saisie.length);
|
|
}
|
|
|
|
// Ferme l'édition et rend le focus à sa cellule quand il était dans le
|
|
// champ.
|
|
async function fermerEdition() {
|
|
const fermee = edition;
|
|
const avaitLeFocus = entree !== null && document.activeElement === entree;
|
|
edition = null;
|
|
machine = null;
|
|
geste.tenir(false);
|
|
if (fermee === null || !avaitLeFocus) return;
|
|
await tick();
|
|
cellule(fermee.id, fermee.colonne)?.focus();
|
|
}
|
|
|
|
// Valide la saisie de la cellule en édition : rien quand elle se lit comme
|
|
// la valeur enregistrée, la commande sinon. Refusée, l'édition reste
|
|
// ouverte sur la saisie, le refus contre elle.
|
|
function valider() {
|
|
if (edition === null) return null;
|
|
const { id, colonne } = edition;
|
|
const rendu = machine.valider();
|
|
saisie = machine.saisie;
|
|
if (rendu.action !== 'executer') return fermerEdition();
|
|
const enCours = machine;
|
|
let promesse;
|
|
try {
|
|
promesse = seance.executer('modifierParticipant', { id, champs: { [colonne]: rendu.valeur } });
|
|
} catch (erreur) {
|
|
return echouer(enCours, id, colonne, erreur);
|
|
}
|
|
fermerEdition();
|
|
return Promise.resolve(promesse).catch((erreur) => echouer(enCours, id, colonne, erreur));
|
|
}
|
|
|
|
async function echouer(enCours, id, colonne, erreur) {
|
|
refuser(id, colonne, erreur);
|
|
enCours.echec(refusCellule.refus);
|
|
machine = enCours;
|
|
saisie = enCours.saisie;
|
|
edition = { id, colonne };
|
|
geste.tenir(enCours.enCours);
|
|
await tick();
|
|
entree?.focus();
|
|
}
|
|
|
|
function abandonner() {
|
|
machine?.abandonner();
|
|
refusCellule = null;
|
|
return fermerEdition();
|
|
}
|
|
|
|
function surSaisie(evenement) {
|
|
machine.saisir(evenement.currentTarget.value);
|
|
saisie = machine.saisie;
|
|
refusCellule = null;
|
|
geste.tenir(machine.enCours);
|
|
}
|
|
|
|
async function surToucheEntree(evenement, rang) {
|
|
const touche = evenement.key;
|
|
if (touche === 'Escape') {
|
|
evenement.preventDefault();
|
|
abandonner();
|
|
} else if (touche === 'Enter') {
|
|
evenement.preventDefault();
|
|
valider();
|
|
} else if (touche === 'Tab') {
|
|
evenement.preventDefault();
|
|
const { id } = edition;
|
|
const colonne = COLONNES.indexOf(edition.colonne);
|
|
const sens = evenement.shiftKey ? 'Maj+Tab' : 'Tab';
|
|
await valider();
|
|
await tick();
|
|
tabulerDepuis(id, rang, colonne, sens);
|
|
}
|
|
}
|
|
|
|
// Tab ou Maj+Tab depuis la cellule validée de la personne id, au rang
|
|
// rangAvant avant la validation. La ligne se relit par son identifiant :
|
|
// la valeur validée peut l'avoir déplacée au tri, et le rang d'avant
|
|
// désignerait une autre personne. Une ligne que le filtre ne montre plus a
|
|
// laissé son rang à celle qui la suivait, les autres gardant leur ordre :
|
|
// Tab mène à la première cellule de celle-ci, Maj+Tab à la dernière de la
|
|
// ligne d'avant ; hors de la grille, le focus reste où il est.
|
|
function tabulerDepuis(id, rangAvant, colonne, touche) {
|
|
const rang = lignes.findIndex((personne) => personne.id === id);
|
|
if (rang >= 0) return deplacer(rang, colonne, touche);
|
|
const suivante = touche === 'Tab' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 };
|
|
if (suivante.ligne < 0 || suivante.ligne >= lignes.length) return false;
|
|
activer(suivante.ligne, suivante.colonne);
|
|
return true;
|
|
}
|
|
|
|
// La sortie du champ valide, sauf quand l'édition s'est déjà close.
|
|
function surSortie() {
|
|
if (edition !== null) valider();
|
|
}
|
|
|
|
function deplacer(rang, colonne, touche) {
|
|
const suivante = celluleSuivante({ ligne: rang, colonne }, touche, { lignes: lignes.length, colonnes: COLONNES.length });
|
|
if (suivante === null) return false;
|
|
activer(suivante.ligne, suivante.colonne);
|
|
return true;
|
|
}
|
|
|
|
function basculerExclusion(personne) {
|
|
const nom = personne.exclu ? 'reintegrerParticipant' : 'exclureParticipant';
|
|
refusCellule = null;
|
|
try {
|
|
Promise.resolve(seance.executer(nom, { id: personne.id })).catch((erreur) => refuser(personne.id, 'exclu', erreur));
|
|
} catch (erreur) {
|
|
refuser(personne.id, 'exclu', erreur);
|
|
}
|
|
}
|
|
|
|
function surToucheCellule(evenement, personne, rang, colonne) {
|
|
if (evenement.target !== evenement.currentTarget) return;
|
|
const touche = evenement.key === 'Tab' && evenement.shiftKey ? 'Maj+Tab' : evenement.key;
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab', 'Maj+Tab'].includes(touche)) {
|
|
if (deplacer(rang, colonne, touche)) evenement.preventDefault();
|
|
} else if (touche === 'Enter' || touche === 'F2') {
|
|
evenement.preventDefault();
|
|
if (COLONNES[colonne] === 'exclu') basculerExclusion(personne);
|
|
else ouvrirEdition(personne, COLONNES[colonne]);
|
|
} else if (touche === 'Escape') {
|
|
refusCellule = null;
|
|
}
|
|
}
|
|
|
|
function surCollage(evenement) {
|
|
if (evenement.target instanceof HTMLInputElement || evenement.target instanceof HTMLTextAreaElement) return;
|
|
const texte = evenement.clipboardData?.getData('text/plain') ?? '';
|
|
if (texte === '') return;
|
|
evenement.preventDefault();
|
|
ui.ouvrirImport({ texte, origine: 'collage' });
|
|
ui.allerA('import');
|
|
}
|
|
|
|
onDestroy(() => geste.liberer());
|
|
</script>
|
|
|
|
<div class="defilement">
|
|
<table
|
|
role="grid"
|
|
aria-label={t('participants.grille.titre')}
|
|
tabindex={lignes.length === 0 ? 0 : undefined}
|
|
bind:this={tableau}
|
|
onpaste={surCollage}
|
|
>
|
|
<thead>
|
|
<tr>
|
|
{#each COLONNES as colonne (colonne)}
|
|
<th scope="col" aria-sort={ariaSort(colonne)}>
|
|
<button type="button" data-commande="trier" data-cible={colonne} onclick={() => trier(colonne)}>
|
|
<span>{t(cleDeColonne(colonne))}</span>
|
|
{#if tri !== null && tri.colonne === colonne}
|
|
<span class="sens">{t(tri.sens === 'croissant' ? 'participants.grille.croissant' : 'participants.grille.decroissant')}</span>
|
|
{/if}
|
|
</button>
|
|
</th>
|
|
{/each}
|
|
<th scope="col">{t('participants.grille.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each lignes as personne, rang (personne.id)}
|
|
<tr data-participant={personne.id}>
|
|
{#each COLONNES as colonne, rangColonne (colonne)}
|
|
{@const refus = refusDe(personne.id, colonne)}
|
|
<td
|
|
role="gridcell"
|
|
data-champ={colonne}
|
|
tabindex={rang === rangActif && rangColonne === actif.colonne ? 0 : -1}
|
|
aria-describedby={refus === null || enEdition(personne.id, colonne) ? undefined : idRefus(personne.id, colonne)}
|
|
onfocus={(evenement) => {
|
|
if (evenement.target === evenement.currentTarget) actif = { id: personne.id, colonne: rangColonne };
|
|
}}
|
|
onkeydown={(evenement) => surToucheCellule(evenement, personne, rang, rangColonne)}
|
|
>
|
|
{#if enEdition(personne.id, colonne) && colonne === 'notes'}
|
|
<textarea
|
|
rows="3"
|
|
aria-label={t(cleDeColonne(colonne))}
|
|
value={saisie}
|
|
bind:this={entree}
|
|
aria-invalid={refus === null ? undefined : 'true'}
|
|
aria-describedby={refus === null ? undefined : idRefus(personne.id, colonne)}
|
|
oninput={surSaisie}
|
|
onkeydown={(evenement) => surToucheEntree(evenement, rang)}
|
|
onblur={surSortie}
|
|
></textarea>
|
|
{:else if enEdition(personne.id, colonne)}
|
|
<input
|
|
type="text"
|
|
aria-label={t(cleDeColonne(colonne))}
|
|
value={saisie}
|
|
bind:this={entree}
|
|
aria-invalid={refus === null ? undefined : 'true'}
|
|
aria-describedby={refus === null ? undefined : idRefus(personne.id, colonne)}
|
|
oninput={surSaisie}
|
|
onkeydown={(evenement) => surToucheEntree(evenement, rang)}
|
|
onblur={surSortie}
|
|
/>
|
|
{:else if colonne === 'exclu'}
|
|
<span class:exclue={personne.exclu}>
|
|
{t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')}
|
|
</span>
|
|
{:else}
|
|
<span class="valeur">{personne[colonne] ?? ''}</span>
|
|
{/if}
|
|
{#if refus !== null}
|
|
<Refus id={idRefus(personne.id, colonne)} {refus} lever={() => (refusCellule = null)} />
|
|
{/if}
|
|
</td>
|
|
{/each}
|
|
<td role="gridcell">
|
|
<div class="commandes">
|
|
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
|
|
{t('participants.commande.fiche')}
|
|
</button>
|
|
{#if personne.exclu}
|
|
<Commande
|
|
cle="participants.commande.reintegrer"
|
|
nom="reintegrerParticipant"
|
|
executer={() => seance.executer('reintegrerParticipant', { id: personne.id })}
|
|
/>
|
|
{:else}
|
|
<Commande
|
|
cle="participants.commande.exclure"
|
|
nom="exclureParticipant"
|
|
executer={() => seance.executer('exclureParticipant', { id: personne.id })}
|
|
/>
|
|
{/if}
|
|
<Commande cle="participants.commande.supprimer" nom="supprimerParticipant" executer={() => supprimer(personne)} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<style>
|
|
.defilement {
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
border-collapse: collapse;
|
|
font-size: var(--taille-texte);
|
|
}
|
|
|
|
th,
|
|
td {
|
|
padding: var(--espace-1) var(--espace-2);
|
|
border: 1px solid var(--couleur-page-filet);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
td {
|
|
max-width: 18rem;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.valeur {
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
/* L'exclusion se lit en texte ; l'italique la double, sans couleur. */
|
|
.exclue {
|
|
font-style: italic;
|
|
}
|
|
|
|
th button {
|
|
display: inline-flex;
|
|
gap: var(--espace-1);
|
|
padding: 0;
|
|
border: none;
|
|
background: none;
|
|
color: var(--couleur-page-texte);
|
|
font: inherit;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sens {
|
|
font-weight: normal;
|
|
}
|
|
|
|
.commandes {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
gap: var(--espace-1);
|
|
}
|
|
|
|
.commandes > button {
|
|
padding: var(--espace-1) var(--espace-3);
|
|
border: 1px solid var(--couleur-page-filet);
|
|
border-radius: var(--rayon-1);
|
|
background-color: var(--couleur-page-fond);
|
|
color: var(--couleur-page-texte);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input,
|
|
textarea {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: var(--espace-1);
|
|
border: 1px solid var(--couleur-page-filet);
|
|
border-radius: var(--rayon-1);
|
|
background-color: var(--couleur-page-fond);
|
|
color: var(--couleur-page-texte);
|
|
font: inherit;
|
|
}
|
|
|
|
input[aria-invalid='true'],
|
|
textarea[aria-invalid='true'] {
|
|
border-color: var(--couleur-refus);
|
|
}
|
|
|
|
td:focus-visible,
|
|
button:focus-visible,
|
|
input:focus-visible,
|
|
textarea:focus-visible,
|
|
table:focus-visible {
|
|
outline: 2px solid var(--couleur-focus);
|
|
outline-offset: -2px;
|
|
}
|
|
</style>
|