gestion_table_tournante_libre/src/interface/primitives/champ.js
Mathieu Benoit 876ea0f2ce [ADD] interface: primitives — field, refusal, confirmation, command
Every screen of the plan edits, refuses and confirms the same way, so
these behaviours live once. A field validates on leaving it and shows a
refused value as refused, restoring the stored one when a session refusal
clears. A refusal is shown where the gesture happened, with its remedy as
an executable command. The confirmation window only opens for a gesture
that destroys data, and stays busy — no key closes it — until the
confirmed action ends.
Checked: 2152 node and 163 browser tests.

--- FR ---

[ADD] interface : primitives — champ, refus, confirmation, commande

Chaque écran du plan saisit, refuse et confirme de la même façon : ces
comportements n'existent qu'une fois. Un champ se valide à sa sortie et
montre une valeur refusée comme refusée, rendant la valeur enregistrée
quand un refus de séance s'efface. Un refus se pose là où le geste a eu
lieu, son remède en commande exécutable. La fenêtre à confirmer ne s'ouvre
que pour un geste qui détruit des données, et reste occupée — aucune touche
ne la ferme — jusqu'à la fin de l'action confirmée.
Vérifié : 2152 node et 163 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 07:21:20 -04:00

145 lines
5.4 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La machine d'un champ de saisie (§ 2, § 8.2), pure : Champ.svelte la
// câble. Elle tient le texte de la valeur enregistrée (stockee), le texte
// saisi (saisie), le drapeau d'une saisie non validée (enCours) et le refus
// de la dernière validation (refus). Le texte saisi ne devient une valeur
// qu'à la validation, que la sortie du champ déclenche : jamais pendant la
// frappe, jamais par un minuteur.
//
// lire(texte) rend { valeur } ou { refus: {code, details} } ; la machine ne
// connaît ni la séance ni la commande, qu'exécute le composant.
/**
* Une machine de champ.
*
* @param {{stockee: string, lire: (texte: string) => ({valeur: unknown}|{refus: {code: string, details: Object}})}} options
* @returns {{
* readonly stockee: string, readonly saisie: string, readonly enCours: boolean,
* readonly refus: null|{code: string, details: Object},
* saisir: (texte: string) => void,
* valider: () => ({action: 'aucune'}|{action: 'refus', refus: Object}|{action: 'executer', valeur: unknown}),
* abandonner: () => void, echec: (refus: Object) => void, recevoir: (texte: string) => void,
* }}
* valider rend 'aucune' quand rien n'est saisi ou que la saisie se lit
* comme la valeur enregistrée — la saisie revient alors à son texte ;
* 'refus' quand lire refuse, la saisie gardée et toujours en cours ;
* 'executer' sinon : la saisie devient le texte enregistré, la validation
* suivante ne relance rien, et echec(refus) défait ce passage quand
* l'exécution est refusée. abandonner (Échap) rend la valeur enregistrée.
* recevoir pose une valeur enregistrée nouvelle, venue d'ailleurs : la
* saisie la suit hors d'une saisie en cours.
*/
export function creerMachineChamp({ stockee, lire }) {
let enregistree = stockee;
let saisie = stockee;
let enCours = false;
let refus = null;
// Le texte enregistré d'avant la dernière exécution, qu'echec restitue.
let avant = stockee;
// La valeur que lire donne d'un texte, ou undefined quand il la refuse.
const valeurDe = (texte) => {
const lu = lire(texte);
return Object.hasOwn(lu, 'refus') ? undefined : lu.valeur;
};
function terminer() {
saisie = enregistree;
enCours = false;
refus = null;
}
return {
get stockee() {
return enregistree;
},
get saisie() {
return saisie;
},
get enCours() {
return enCours;
},
get refus() {
return refus;
},
saisir(texte) {
saisie = texte;
enCours = true;
refus = null;
},
valider() {
if (!enCours || saisie === enregistree) {
terminer();
return { action: 'aucune' };
}
const lu = lire(saisie);
if (Object.hasOwn(lu, 'refus')) {
refus = lu.refus;
return { action: 'refus', refus };
}
const ancienne = valeurDe(enregistree);
if (ancienne !== undefined && Object.is(ancienne, lu.valeur)) {
terminer();
return { action: 'aucune' };
}
avant = enregistree;
enregistree = saisie;
enCours = false;
refus = null;
return { action: 'executer', valeur: lu.valeur };
},
abandonner() {
terminer();
},
echec(refusRecu) {
enregistree = avant;
enCours = true;
refus = refusRecu;
},
recevoir(texte) {
enregistree = texte;
avant = texte;
if (!enCours) saisie = texte;
},
};
}
// Les codes d'un refus de la séance : ceux de son état, qu'exigerEcriture
// calcule — LECTURE_SEULE, MODE_LECTURE, SUPPORT_ROMPU —, et VERROU_PRIS, le
// passage en écriture refusé. Les raisons de LECTURE_SEULE qu'etat.refus
// porte ; les autres, ENCODAGE et FORMAT_PLUS_RECENT, tiennent le temps de
// la lecture.
const CODES_SEANCE = new Set(['LECTURE_SEULE', 'MODE_LECTURE', 'SUPPORT_ROMPU', 'VERROU_PRIS']);
const RAISONS_ETAT = new Set(['BLOQUE', 'FORMAT']);
/** Vrai pour un refus qui nomme un état de la séance et non la saisie. */
export const estRefusSeance = (refus) => CODES_SEANCE.has(refus.code);
/** Deux refus de même code et de mêmes détails sont le même. */
export const memeRefus = (a, b) => a.code === b.code && JSON.stringify(a.details) === JSON.stringify(b.details);
/**
* Le refus de la séance qu'un champ affiche, confronté à l'état courant
* (EtatSeance) : le refus lui-même, le même objet, tant que l'état le
* tient ; null quand l'état ne le tient plus ; la cause courante quand une
* autre l'a remplacé. VERROU_PRIS tient tant qu'etat.verrouAutre est
* relevé ; LECTURE_SEULE d'une raison qu'etat.refus ne porte pas, tant que
* la séance est en lecture ; les autres suivent etat.refus. Un refus local
* passe tel quel.
*
* @param {{code: string, details: Object}} refus
* @param {{mode: string, refus: null|{code: string, details: Object}, verrouAutre: Object|null}} etat
* @returns {null|{code: string, details: Object}}
*/
export function refusSeanceCourant(refus, etat) {
if (!estRefusSeance(refus)) return refus;
if (refus.code === 'VERROU_PRIS') return etat.verrouAutre === null ? null : refus;
if (refus.code === 'LECTURE_SEULE' && !RAISONS_ETAT.has(refus.details?.raison)) {
return etat.mode === 'lecture' ? refus : null;
}
const courant = etat.refus;
if (courant === null) return null;
return memeRefus(courant, refus) ? refus : courant;
}