[IMP] card and priority editor: red circled cross, number weight field

A tag's « Retirer » text button read like plain text, and the weight of
a criterion took a whole column for a number from 1 to 100. The card
now removes a tag with a red circled cross, shown in write mode only,
named « Retirer « tag » » for screen readers, with a 24 px target; the
red has its own token, exempted from the conflict-hue rule and limited
to card surfaces. The weight is a narrow number field, still read and
refused as before.
Checked: node 4597, navigateur 965; captures 04a and 10a republished.

--- FR ---

[IMP] fiche et priorités : croix rouge cerclée, poids en champ nombre

Le bouton texte « Retirer » d'une étiquette se lisait comme du texte, et
le poids d'un critère prenait toute une colonne pour un nombre de 1 à
100. La fiche retire désormais une étiquette par une croix rouge cerclée,
en mode écriture seulement, nommée « Retirer « étiquette » » pour les
lecteurs d'écran, sur une cible de 24 px ; le rouge a son jeton, exempté
de la règle des teintes de conflit et borné aux surfaces de la fiche. Le
poids est un champ nombre étroit, lu et refusé comme avant.
Vérifié : node 4597, navigateur 965 ; captures 04a et 10a republiées.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-11 23:34:28 -04:00
parent 883dcb2519
commit 426eb911c9
26 changed files with 344 additions and 96 deletions

View file

@ -148,8 +148,8 @@ nulle part.
![La fiche d’une personne : ses étiquettes, le champ d’ajout, une orthographe reçue sous celle de l’événement](doc/captures/04a-etiquettes.png)
*Chaque étiquette est une pastille grise, suivie de sa commande « Retirer ».
Tapez une ou plusieurs étiquettes séparées par une virgule, puis « Ajouter »
*Chaque étiquette est une pastille grise ; en écriture, une petite croix
rouge cerclée la suit, qui la retire. Tapez une ou plusieurs étiquettes séparées par une virgule, puis « Ajouter »
ou Entrée. Ici, « FINANCE » s’enregistre sous « Finance », l’orthographe que
l’événement porte déjà, et le logiciel le dit sous le champ : une même
étiquette ne s’écrit jamais de deux façons. La légende, au-dessus de la

View file

@ -103,6 +103,7 @@ sur les captures.
| `--couleur-pastille-texte` | texte | 4,5 | — | — | 14,51 | 19-ajustement |
| `--couleur-poignee` | marque | 3 | 7,44 | 07-plan-tables | 11,32 | 07-plan-tables |
| `--couleur-refus` | texte | 4,5 | 8,34 | 01b-passer-en-ecriture | 8,14 | 01b-passer-en-ecriture |
| `--couleur-retrait` | marque | 3 | 5,62 | 04a-etiquettes | 5,96 | 04a-etiquettes |
| `--couleur-selection` | marque | 3 | 4,57 | 08-animateurs-designes | 4,18 | 08-animateurs-designes |
| `--couleur-table-trait` | marque | 3 | 7,44 | 07-plan-tables | 5,59 | 07-plan-tables |
| `rgb(24, 33, 47)` | texte | 4,5 | — | — | 16,19 | 22-plan-brouillon |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

File diff suppressed because one or more lines are too long

15
spec.md
View file

@ -2751,9 +2751,10 @@ l'appartenance s'affiche en pastille de sa teinte (§ 12.7), le titre pressenti
et l'exclusion en pastilles à icône.
La fiche d'une personne porte ses étiquettes, en pastilles **neutres** — la
teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7) —, chacune avec sa
commande « Retirer », et un champ qui en ajoute une ou plusieurs, séparées comme
dans une cellule CSV ; la saisie passe par la même lecture que l'import (§ 4.5,
teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7) —, chacune suivie,
en mode écriture seulement, d'une petite croix cerclée rouge qui la retire,
nommée « Retirer « étiquette » » pour les technologies d'assistance, et un
champ qui en ajoute une ou plusieurs, séparées comme dans une cellule CSV ; la saisie passe par la même lecture que l'import (§ 4.5,
§ 10.1). Quand l'événement porte des étiquettes, une légende des étiquettes suit
celle des appartenances, chacune avec le nombre de personnes qui la portent,
exclues comprises.
@ -3622,6 +3623,9 @@ Une page de statistiques se casse par ses bords, et chacun se produira.
le plan de salle, et ne servent à rien d'autre ; chacune ne peint que les
marques de sa règle. Elles se distinguent entre elles et des autres marques
du plan **une fois les trois dichromaties simulées**.
- **Le rouge du retrait** (`--couleur-retrait`) ne peint que la croix cerclée
d'une commande qui retire, sur une carte, jamais sur le plan : c'est le seul
jeton chromatique admis à moins de 30° d'une teinte de conflit.
- **Les couleurs d'appartenance ne vont que sur les listes**, jamais sur le
plan, dont le canal chromatique appartient aux conflits : au plus six teintes
catégorielles, chacune à 30° au moins des teintes de conflit dans chaque
@ -4023,7 +4027,7 @@ des fonctions pures du module de géométrie.
| tenir hors ligne | se compile en JavaScript ordinaire ; Vite est une dépendance de développement qui ne survit pas à la construction |
| rester rapide | compile des mises à jour fines, sans arbre virtuel ni mémoïsation à poser à la main — à une exception près : la table d'évaluation des propositions, gardée dans le modèle pur sous l'identité de la charge figée de la séance, qu'une écriture remplace |
| se tester sans navigateur | n'impose d'écrire aucun calcul dans un composant ; le composant ne contient que le câblage |
| être joli sans y passer des semaines | un fichier de jetons — couleurs, rayons, ombres, échelle typographique, cible principale, largeur de page — décliné en deux thèmes ; ses familles de couleur : la page, le bandeau, la carte, l'action principale, les quatre états du plan, le neutre, les appartenances, la bande de mode, les messages (avis, refus, information), le plan, le papier |
| être joli sans y passer des semaines | un fichier de jetons — couleurs, rayons, ombres, échelle typographique, cible principale, largeur de page — décliné en deux thèmes ; ses familles de couleur : la page, le bandeau, la carte, l'action principale, les quatre états du plan, le neutre, les appartenances, la bande de mode, les messages (avis, refus, information), le retrait, le plan, le papier |
**Les candidates écartées.** Vue 3 est le second choix défendable, écarté de peu
sur le poids et sur la discipline qu'exige sa réactivité. React est écarté sur la
@ -4243,7 +4247,8 @@ l'étage intermédiaire et laisse partir les défauts de coordonnées.
### 14.5 Accessibilité et thèmes
Le texte tient **4,5:1** de contraste, les marques porteuses d'information
**3:1**. Chaque couleur est accompagnée du ratio **mesuré sur la couleur relue du
**3:1**. Une commande sans texte, à icône seule, offre une cible de 24 × 24 px
au moins et porte son libellé en nom accessible. Chaque couleur est accompagnée du ratio **mesuré sur la couleur relue du
rendu**, non sur le jeton source. Les deux thèmes sont éprouvés **par rendu**,
jamais seulement par calcul, et le tableau des ratios est **engendré pour les
deux** à partir des styles relevés au pilotage (§ 19.5), jamais recopié. Le

View file

@ -543,6 +543,10 @@ function lirePaliers(paliers) {
});
}
/** Le plus grand poids d'un critère (§ 5.7), celui du moteur : l'écran en
* borne le champ du poids, que changerPriorites contrôle. */
export { POIDS_MAX };
// Vrai pour un poids du domaine : un entier de 1 à POIDS_MAX (§ 5.7).
const poidsAdmis = (poids) => Number.isInteger(poids) && poids >= 1 && poids <= POIDS_MAX;

View file

@ -30,11 +30,14 @@
// contre la liste, avec son remède, et décrit le groupe de l'éditeur
// (aria-describedby), focalisable par programme (tabindex -1) : après le
// remède, le focus revient au groupe ; rien ne s'écrit. Le poids d'un
// critère d'un palier partagé se saisit dans un Champ entier (poids.<nom>),
// écrit à la sortie du champ par changerPriorites ; son refus, un poids
// hors de son domaine compris, se pose contre le champ.
// critère d'un palier partagé se saisit dans un Champ nombre, étroit, de
// 1 à POIDS_MAX par pas de 1 (poids.<nom>), lu par lireEntier et écrit à
// la sortie du champ par changerPriorites ; son refus, un poids hors de
// son domaine compris, se pose contre le champ : les bornes de l'entrée
// guident ses flèches et ne refusent rien.
import { getContext, tick } from 'svelte';
import { lireEntier } from '../../application/capacite.js';
import { POIDS_MAX } from '../../application/commandes_mobilier.js';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
@ -134,6 +137,10 @@
valeur={poids}
lire={lireEntier}
executer={ecrirePoids(critere)}
type="number"
min={1}
max={POIDS_MAX}
pas={1}
/>
{/if}
<div class="gestes">

View file

@ -392,11 +392,22 @@ describe('l’ordre des priorités : l’éditeur (§ 5.7, A11, A12)', () => {
expect(annonce(m)).toBe(t('generation.priorites.annonce', { critere: CUMULEES, palier: 3, paliers: 7, avec: [] }));
});
test('le poids d’un critère d’un palier partagé : 0 refusé contre son champ, rien d’écrit ; 5 écrit, une entrée', async () => {
test('le poids d’un critère d’un palier partagé : un champ nombre étroit de 1 à 100 ; 0 refusé contre son champ, rien d’écrit ; 5 écrit, une entrée', async () => {
const m = await demonstration();
await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier'));
const champ = () => editeur(m).querySelector(`input[data-champ="poids.${REDONDANCE}"]`);
expect(editeur(m).querySelector(`label[for="${champ().id}"]`).textContent.trim()).toBe(t('generation.priorites.poids', { critere: REDONDANCE }));
expect([champ().type, champ().min, champ().max, champ().step]).toEqual(['number', '1', '100', '1']);
// Étroit : six caractères de large, le contenu de la boîte, flèches
// comprises.
const sonde = document.createElement('span');
sonde.style.cssText = 'position:absolute;font:inherit;visibility:hidden';
sonde.textContent = '0'.repeat(6);
champ().parentElement.append(sonde);
const sixCaracteres = sonde.getBoundingClientRect().width;
sonde.remove();
const style = getComputedStyle(champ());
expect(Number.parseFloat(style.width)).toBeCloseTo(sixCaracteres, 0);
const e0 = journal(m);
await cliquer(m, champ());
champ().select();

View file

@ -24,11 +24,18 @@
appartenances), --couleur-conflit-voisins (nouveaux voisins) et
--couleur-conflit-table (nouvelle table). Chacune ne peint que la trame de
sa règle ; tout autre jeton chromatique se tient à 30° au moins de chacune,
et aucun ne porte la valeur de l'une. Elles se distinguent entre elles et
des autres marques du plan sous les trois dichromaties (SEPARATIONS,
marques.js), et tiennent 3:1 sur le fond du plateau, qui borde leurs
traits. Le contour épaissi d'une table en conflit, commun aux trois, est
de l'encre forte du plan, --couleur-contour-fort.
sauf le rouge du retrait, et aucun ne porte la valeur de l'une. Elles se
distinguent entre elles et des autres marques du plan sous les trois
dichromaties (SEPARATIONS, marques.js), et tiennent 3:1 sur le fond du
plateau, qui borde leurs traits. Le contour épaissi d'une table en
conflit, commun aux trois, est de l'encre forte du plan,
--couleur-contour-fort.
Le rouge du retrait, --couleur-retrait, peint la croix cerclée d'une
commande qui retire (Commande, variante retrait), sur la carte et son
fond sourd, jamais sur le plan : c'est le seul jeton chromatique admis à
moins de 30° d'une teinte de conflit (JETON_RETRAIT, marques.js), le
canal chromatique que les conflits se réservent étant celui du plan.
La hachure d'une place à pourvoir peint des traits --couleur-hachure sur le
fond de la chaise, --couleur-chaise-fond : ce sont ses deux couleurs.
@ -72,7 +79,8 @@
le neutre et les appartenances, chacun un fond teinté et une marque, que
lisent les pastilles ; la bande de mode, un fond, un filet et une
teinte par mode, celle de son icône ; les messages — avis, refus,
information —, chacun son encre, un fond, un filet et une marque.
information —, chacun son encre, un fond, un filet et une marque ; le
retrait, la teinte de sa croix.
Le texte d'une pastille ou de la bande de mode garde l'encre de la page
(§ 12.7) : la teinte se porte par le fond et la marque. Les marques des
@ -134,9 +142,11 @@ textarea {
--rayon-3: 0.75rem;
--rayon-pilule: 62.5rem;
/* La hauteur d'une cible principale (44 px), et la largeur au-delà de
laquelle le contenu d'un écran se centre. */
/* La hauteur d'une cible principale (44 px), le côté minimal d'une cible
(24 px, § 14.5), et la largeur au-delà de laquelle le contenu d'un
écran se centre. */
--hauteur-cible: 2.75rem;
--cible-minimale: 1.5rem;
--largeur-page: 87rem;
/* Ombres : une couleur opaque par thème, --couleur-ombre ; celle d'une
@ -229,6 +239,9 @@ textarea {
--couleur-information-filet: #b7cef0;
--couleur-information-marque: var(--couleur-information);
/* Retrait : la croix cerclée d'une commande qui retire. */
--couleur-retrait: #c62828;
/* Désignation. Le focus, la sélection, la mise en évidence et le fantôme
passent sur une chaise vide comme sur une chaise pleine : leur clarté se
tient entre le fond et le plein de la chaise, à 3:1 de chacun. */
@ -334,6 +347,8 @@ textarea {
--couleur-information-filet: #34507a;
--couleur-information-marque: var(--couleur-information);
--couleur-retrait: #ff6b6b;
--couleur-focus: #8764e1;
--couleur-selection: #4070f0;
--couleur-mise-en-evidence: #00897b;

View file

@ -52,6 +52,14 @@ export const CONFLITS = Object.freeze(
/** Les jetons des teintes de conflit, dans l'ordre de CONFLITS. */
export const JETONS_CONFLIT = Object.freeze(CONFLITS.map(({ jeton }) => jeton));
/** Le jeton du rouge du retrait (§ 12.7) : la croix cerclée d'une commande
* qui retire (Commande, variante retrait), sur la carte et son fond sourd,
* jamais sur le plan. C'est le seul jeton chromatique admis à moins de 30°
* d'une teinte de conflit (verifierTeintesReservees, palette.js) : le
* canal chromatique que les conflits se réservent est celui du plan, où il
* ne se pose pas. */
export const JETON_RETRAIT = c('retrait');
/** Écarts perçus minimaux d'une teinte de conflit, en distance OKLab × 100
* mesurée en couleurs d'origine et sous chaque dichromatie (ecartPercu,
* palette.js) : entreConflits, d'une teinte de conflit à une autre — la
@ -152,6 +160,9 @@ export const MARQUES = Object.freeze([
// contenu d'une feuille imprimée, mesuré sur le papier, clair dans les
// deux thèmes.
marque('filigrane', 'filigrane', ['papier']),
// La croix cerclée d'un retrait, seul contenu de son bouton : sur la
// carte, et sur son fond sourd, celui du survol.
marque('retrait', 'retrait', ['carte-fond', 'carte-fond-sourd']),
]);
// Les fonds teintés d'une pastille : ceux de ses teintes, puis ceux des

View file

@ -18,7 +18,7 @@ const NOMS_MARQUES = [
'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein',
'contourConflit', 'trameAppartenance', 'trameVoisins', 'trameTable', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
'selection', 'focus',
'contourTable', 'contourChaise', 'echelle', 'fantome', 'chargement', 'filigrane',
'contourTable', 'contourChaise', 'echelle', 'fantome', 'chargement', 'filigrane', 'retrait',
];
// Les remplissages d'une chaise : fond, pleine, et les deux couleurs de la
@ -39,7 +39,7 @@ const FONDS_REQUIS = {
const parNom = (registre, nom) => registre.find((e) => e.nom === nom);
describe('registre des marques', () => {
test('les vingt et une marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
test('les vingt-deux marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);

View file

@ -273,14 +273,14 @@ export function teinte(couleur) {
/** § 12.7 : chaque teinte réservée — celles des conflits — est chromatique,
* et la teinte de chaque autre jeton chromatique se tient à ECART_TEINTE
* degrés au moins de chacune, dans chaque thème. Les teintes réservées ne
* se mesurent pas entre elles : verifierSeparation les distingue. Rend les
* fautes [{theme, reservee, jeton, ecart}], ecart en degrés sur le cercle,
* dans l'ordre thème, teinte réservée, jeton ; une teinte réservée
* achromatique est elle-même une faute, jeton égal à reservee et ecart
* null — aucune teinte n'est alors réservée. Un jeton réservé absent d'un
* thème lève. */
export function verifierTeintesReservees(texteCss, reservees) {
* degrés au moins de chacune, dans chaque thème, sauf celle d'un jeton
* d'admis, qui ne se mesure pas. Les teintes réservées ne se mesurent pas
* entre elles : verifierSeparation les distingue. Rend les fautes
* [{theme, reservee, jeton, ecart}], ecart en degrés sur le cercle, dans
* l'ordre thème, teinte réservée, jeton ; une teinte réservée achromatique
* est elle-même une faute, jeton égal à reservee et ecart null — aucune
* teinte n'est alors réservée. Un jeton réservé absent d'un thème lève. */
export function verifierTeintesReservees(texteCss, reservees, admis = []) {
const fautes = [];
for (const theme of THEMES) {
const jetons = lireJetons(texteCss, theme);
@ -292,7 +292,7 @@ export function verifierTeintesReservees(texteCss, reservees) {
continue;
}
for (const [nom, couleur] of jetons) {
if (reservees.includes(nom)) continue;
if (reservees.includes(nom) || admis.includes(nom)) continue;
const h = teinte(couleur);
if (h === null) continue;
const brut = Math.abs(h - reference);

View file

@ -11,7 +11,7 @@ import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../../test/lanceur.js';
import { JETONS_CONFLIT } from './marques.js';
import { JETONS_CONFLIT, JETON_RETRAIT, MARQUES } from './marques.js';
import {
SIMULATIONS,
contraste,
@ -320,6 +320,9 @@ describe('teinte et verifierTeintesReservees (§ 12.7)', () => {
assert.deepEqual(fautes.map((f) => `${f.theme}:${f.jeton}`), [
'clair:--couleur-b', 'clair:--couleur-d', 'sombre:--couleur-b', 'sombre:--couleur-d',
]);
// Un jeton admis ne se mesure pas ; les autres, si.
const admis = verifierTeintesReservees(css, ['--couleur-conflit'], ['--couleur-b']);
assert.deepEqual(admis.map((f) => `${f.theme}:${f.jeton}`), ['clair:--couleur-d', 'sombre:--couleur-d']);
});
});
@ -496,6 +499,21 @@ describe('jetons.css', () => {
// Aucun autre jeton ne porte le préfixe des teintes de conflit.
assert.deepEqual([...jetons.keys()].filter((n) => n.startsWith('--couleur-conflit')), JETONS_CONFLIT, theme);
}
assert.deepEqual(verifierTeintesReservees(JETONS, JETONS_CONFLIT), []);
assert.deepEqual(verifierTeintesReservees(JETONS, JETONS_CONFLIT, [JETON_RETRAIT]), []);
});
test('le rouge du retrait est le seul jeton admis près d’une teinte de conflit : chromatique, sa valeur à lui, peint sur la carte seulement, jamais sur le plan', () => {
assert.equal(JETON_RETRAIT, '--couleur-retrait');
// Sans l'admission, le retrait est la seule faute.
assert.deepEqual([...new Set(verifierTeintesReservees(JETONS, JETONS_CONFLIT).map((f) => f.jeton))], [JETON_RETRAIT]);
for (const theme of ['clair', 'sombre']) {
const jetons = lireJetons(JETONS, theme);
const couleur = jetons.get(JETON_RETRAIT);
assert.notEqual(teinte(couleur), null, theme);
const memeValeur = [...jetons].filter(([n, c]) => n !== JETON_RETRAIT && String(c) === String(couleur));
assert.deepEqual(memeValeur.map(([n]) => n), [], theme);
}
const peintes = MARQUES.filter((m) => m.jeton === JETON_RETRAIT || m.fonds.includes(JETON_RETRAIT));
assert.deepEqual(peintes.map((m) => [m.nom, [...m.fonds]]), [['retrait', ['--couleur-carte-fond', '--couleur-carte-fond-sourd']]]);
});
});

View file

@ -18,10 +18,11 @@
//
// Les étiquettes (etiquettes.js) : une pastille neutre à icône par
// étiquette, dans l'ordre de la charge — la teinte d'appartenance ne sert
// qu'à l'appartenance (§ 12.7) —, chacune suivie de sa commande
// « Retirer », nommée par elle ; puis un formulaire d'ajout, dont le
// champ reçoit une ou plusieurs étiquettes séparées comme dans une
// cellule CSV. Entrée, ou « Ajouter », envoie la saisie entière à
// qu'à l'appartenance (§ 12.7) —, chacune suivie, en écriture seulement,
// de sa commande de retrait : une croix cerclée rouge (Commande, variante
// retrait), nommée « Retirer « <étiquette> » » ; puis un formulaire
// d'ajout, dont le champ reçoit une ou plusieurs étiquettes séparées
// comme dans une cellule CSV. Entrée, ou « Ajouter », envoie la saisie entière à
// changerEtiquettes, qui la découpe, la met en forme et la réconcilie : la
// fiche ne lit rien elle-même. Un refus de l'ajout — étiquette mal formée,
// trop d'étiquettes, séance en lecture, plan bloqué — se pose contre le
@ -31,14 +32,15 @@
// (ORTHOGRAPHE_RETENUE), tant que la révision qui l'a produit est la
// courante et la fiche celle de la personne. Le refus d'un retrait se
// pose à côté de sa commande (Commande). En lecture comme sur un plan
// bloqué, pastilles et commandes restent : la séance refuse le geste, et
// bloqué (cleDeMode, mode.js), aucune commande de retrait ne se montre ;
// pastilles, champ et « Ajouter » restent : la séance refuse l'ajout, et
// le refus porte son remède. Une saisie non envoyée est un geste ouvert
// (geste.js), qui suspend le compte d'inactivité ; l'envoi n'est pas
// ré-entrant. Passer à une autre personne vide le champ et son refus ; le
// refus d'un envoi qui revient après ce passage n'est posé nulle part.
// Un retrait abouti rend le focus au « Retirer » qui prend la place du
// sien — le suivant, sinon le précédent (ligneVoisine) —, ou au champ
// quand il n'en reste aucun.
// Un retrait abouti rend le focus à la commande de retrait qui prend la
// place de la sienne — la suivante, sinon la précédente (ligneVoisine) —,
// ou au champ quand il n'en reste aucune.
//
// La fiche est une carte : fond et filet de la carte, rayon de 12 px,
// ombre légère ; ses étiquettes et légendes de tableau en petites
@ -50,6 +52,7 @@
import { getContext, onDestroy, tick, untrack } from 'svelte';
import { nomAffiche } from '../../application/personnes.js';
import { etatPlaces, statuts } from '../../application/places.js';
import { cleDeMode } from '../mode.js';
import Commande from '../primitives/Commande.svelte';
import Icone from '../primitives/Icone.svelte';
import Pastille from '../primitives/Pastille.svelte';
@ -79,7 +82,7 @@
const etapes = $derived(itineraire(participant.id, charge.evenement.tours, (tour) => etatPlaces(charge, { tour })));
// La fiche et son champ d'ajout, montés : le défilement vise la fiche,
// le focus d'après un retrait ses « Retirer » ou le champ.
// le focus d'après un retrait ses commandes de retrait ou le champ.
let element;
let champ;
$effect(() => {
@ -97,6 +100,9 @@
const idSignal = `${uid}-orthographe`;
const pastilles = $derived(etiquettesDeLaFiche(participant));
// Les commandes de retrait ne se montrent qu'en écriture, hors d'un plan
// bloqué.
const retraitOffert = $derived(cleDeMode($seance) === 'coquille.mode.ecriture');
const signaux = $derived(signauxOrthographe(dernier, { revision: $seance.evenement?.revision ?? null, participant: participant.id }));
const decrit = $derived(refusChamp !== null ? idRefus : signaux.length > 0 ? idSignal : undefined);
@ -142,8 +148,8 @@
}
// Retire motCle ; aboutie, quand le bouton détruit a laissé le focus au
// document, le porte au « Retirer » voisin ou au champ. Un refus remonte
// à Commande, qui le pose à côté du bouton resté en place.
// document, le porte à la commande de retrait voisine ou au champ. Un
// refus remonte à Commande, qui le pose à côté du bouton resté en place.
async function retirer(motCle) {
const id = participant.id;
const rang = pastilles.indexOf(motCle);
@ -190,13 +196,15 @@
{#each pastilles as motCle (motCle)}
<li>
<Pastille sorte="statut" valeur="neutre" icone="etiquettes" libelle={motCle} />
<Commande
cle="participants.etiquettes.retirer"
details={{ motCle }}
nom="retirerEtiquette"
variante="discrete"
executer={() => retirer(motCle)}
/>
{#if retraitOffert}
<Commande
cle="participants.etiquettes.retirer"
details={{ motCle }}
nom="retirerEtiquette"
variante="retrait"
executer={() => retirer(motCle)}
/>
{/if}
</li>
{/each}
</ul>
@ -399,8 +407,9 @@
}
/* Les étiquettes : l'intitulé en petites capitales, les pastilles en
rangée qui se replie, chacune suivie de son « Retirer » ; puis le champ
d'ajout et son bouton sur une ligne, le refus ou le signal dessous. */
rangée qui se replie, chacune suivie, en écriture, de sa croix de
retrait ; puis le champ d'ajout et son bouton sur une ligne, le refus
ou le signal dessous. */
.etiquettes {
display: flex;
flex-direction: column;

View file

@ -935,10 +935,11 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
const rubrique = (m) => fiche(m).querySelector('[data-etiquettes]');
const champEtiquettes = (m) => fiche(m).querySelector('[data-champ="etiquettes"]');
const ajoutEtiquettes = (m) => fiche(m).querySelector('[data-commande="ajouterEtiquettes"]');
// La commande de retrait d'une étiquette : une croix cerclée sans texte,
// nommée par son aria-label.
const retraits = (m) => [...fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')];
const retrait = (m, motCle) =>
[...fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')].find(
(bouton) => bouton.textContent.trim() === t('participants.etiquettes.retirer', { motCle }),
) ?? null;
retraits(m).find((bouton) => bouton.getAttribute('aria-label') === t('participants.etiquettes.retirer', { motCle })) ?? null;
const legendeEtiquettes = (m) => section(m).querySelector('[data-legende-etiquettes]');
const etiquettesDe = (m, nom) => participants(m).find((p) => p.nom === nom).etiquettes;
@ -964,7 +965,7 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
await attendreSeance(m);
}
test('la fiche montre chaque étiquette en pastille neutre à icône, sa commande « Retirer » nommée par elle ; sans étiquette, elle le dit', async () => {
test('la fiche montre chaque étiquette en pastille neutre à icône, sa croix de retrait rouge nommée par elle ; sans étiquette, elle le dit', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance, Montréal' });
await ouvrirFiche(m, 'Ombrelle');
@ -980,10 +981,33 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
// La teinte d'appartenance ne sert qu'à l'appartenance (§ 12.7).
const teintes = new Set([1, 2, 3, 4, 5, 6].map((rang) => JSON.stringify(jeton(`--couleur-appartenance-${rang}-fond`))));
for (const li of elements) expect(teintes.has(JSON.stringify(fondDe(pastilleDans(li))))).toBe(false);
for (const motCle of ['Montréal', 'finance']) expect(retrait(m, motCle), motCle).not.toBeNull();
// En sombre, la pastille prend le neutre sombre.
// La croix de retrait : une icône seule, sans texte, la croix cerclée
// de la teinte du retrait, marquée « retrait », dans une cible de
// 24 px de côté au moins, et un anneau de focus au clavier.
for (const motCle of ['Montréal', 'finance']) {
const bouton = retrait(m, motCle);
expect(bouton, motCle).not.toBeNull();
expect(bouton.textContent.trim(), motCle).toBe('');
const traces = [...bouton.querySelectorAll('svg path')];
expect(traces.length, motCle).toBeGreaterThan(0);
for (const trace of traces) {
expect(trace.dataset.marque).toBe('retrait');
expect(composantes(getComputedStyle(trace).stroke)).toEqual(jeton('--couleur-retrait'));
}
const { width, height } = bouton.getBoundingClientRect();
expect(Math.min(width, height), motCle).toBeGreaterThanOrEqual(24);
}
// Tab mène d'une croix à la suivante, qui porte l'anneau de focus.
retrait(m, 'Montréal').focus();
await touche('Tab');
expect(document.activeElement).toBe(retrait(m, 'finance'));
expect(retrait(m, 'finance').matches(':focus-visible')).toBe(true);
expect(getComputedStyle(retrait(m, 'finance')).outlineStyle).toBe('solid');
expect(composantes(getComputedStyle(retrait(m, 'finance')).outlineColor)).toEqual(jeton('--couleur-focus'));
// En sombre, la pastille prend le neutre sombre, la croix le rouge sombre.
await sousTheme('sombre', async () => {
for (const li of elements) expect(fondDe(pastilleDans(li))).toEqual(jeton('--couleur-neutre-fond'));
expect(composantes(getComputedStyle(retrait(m, 'finance').querySelector('path')).stroke)).toEqual(jeton('--couleur-retrait'));
});
await ouvrirFiche(m, 'Pervenche');
@ -992,7 +1016,7 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
expect(fiche(m).querySelectorAll('[data-commande="retirerEtiquette"]')).toHaveLength(0);
});
test('au clavier : une saisie de trois étiquettes, Entrée, une entrée au journal ; le champ se vide et garde le focus ; « Retirer » ôte la sienne et rend le focus à sa voisine', async () => {
test('au clavier : une saisie de trois étiquettes, Entrée, une entrée au journal ; le champ se vide et garde le focus ; la croix de retrait ôte la sienne et rend le focus à sa voisine', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await ouvrirFiche(m, 'Pervenche');
const champ = champEtiquettes(m);
@ -1017,8 +1041,8 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
expect(document.activeElement).toBe(champEtiquettes(m));
expect([...rubrique(m).querySelectorAll('li')].map((li) => texteDe(pastilleDans(li)))).toEqual(['bénévole', 'ressources humaines', 'voile']);
// « Retirer » activé par Entrée : le focus passe au « Retirer » suivant,
// sinon au précédent, sinon au champ ; jamais au document.
// La croix de retrait activée par Entrée : le focus passe à la croix
// suivante, sinon à la précédente, sinon au champ ; jamais au document.
const retirerAuClavier = async (motCle) => {
retrait(m, motCle).focus();
await touche('Enter');
@ -1197,29 +1221,31 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
expect(legendeEtiquettes(m)).toBeNull();
});
test('en lecture : les pastilles restent ; « Ajouter » et « Retirer » sont refusés, chacun avec son remède, et rien ne s’écrit', async () => {
test('en lecture : les pastilles restent, sans croix de retrait ; « Ajouter » est refusé avec son remède, rien ne s’écrit ; de retour en écriture, les croix reviennent', async () => {
const m = await evenementOuvert({ personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
await m.seance.passerEnLecture();
await ouvrirFiche(m, 'Ombrelle');
const avant = texteJournal(m);
expect(texteDe(pastilleDans(rubrique(m).querySelector('li')))).toBe('finance');
expect(retraits(m)).toHaveLength(0);
await ajouterParClavier(m, 'bénévole');
const message = document.getElementById(champEtiquettes(m).getAttribute('aria-describedby'));
expect(message.dataset.refus).toBe('MODE_LECTURE');
expect(message.querySelector('[data-commande]')).not.toBeNull();
await cliquer(retrait(m, 'finance'));
await attendreSeance(m);
const refusRetrait = retrait(m, 'finance').parentElement.querySelector('[data-refus]');
expect(refusRetrait.dataset.refus).toBe('MODE_LECTURE');
expect(refusRetrait.querySelector('[data-commande]')).not.toBeNull();
expect(etiquettesDe(m, 'Ombrelle')).toEqual(['finance']);
expect(texteJournal(m)).toBe(avant);
await m.seance.passerEnEcriture();
await rendu();
expect(retrait(m, 'finance')).not.toBeNull();
await m.seance.passerEnLecture();
await rendu();
expect(retraits(m)).toHaveLength(0);
});
test('plan bloqué, en écriture : « Ajouter » et « Retirer » sont refusés avec « Débloquer » pour remède', async () => {
test('plan bloqué : aucune croix de retrait ; « Ajouter » est refusé avec « Débloquer » pour remède', async () => {
const m = await evenementOuvert({ sieges: 4, tours: 2, tables: 1, personnes: TROIS_ETIQUETEES });
await m.seance.executer('changerEtiquettes', { id: idDe(m, 'Ombrelle'), ajouter: 'finance' });
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
@ -1229,13 +1255,12 @@ describe('les étiquettes : la fiche et la légende (§ 4.5, § 10.2, § 5.9)',
await ouvrirFiche(m, 'Ombrelle');
const avant = texteJournal(m);
expect(texteDe(pastilleDans(rubrique(m).querySelector('li')))).toBe('finance');
expect(retraits(m)).toHaveLength(0);
await ajouterParClavier(m, 'bénévole');
const message = document.getElementById(champEtiquettes(m).getAttribute('aria-describedby'));
expect(message.querySelector('[data-commande="debloquer"]')).not.toBeNull();
await cliquer(retrait(m, 'finance'));
await attendreSeance(m);
const refusRetrait = retrait(m, 'finance').parentElement.querySelector('[data-refus]');
expect(refusRetrait.querySelector('[data-commande="debloquer"]')).not.toBeNull();
expect(texteJournal(m)).toBe(avant);
});
});

View file

@ -36,13 +36,32 @@
// son étiquette seule ne le distingue pas d'un homonyme ; null,
// l'étiquette le nomme. L'étiquette visible ne change pas. Le refus reçoit
// nom pour nommer son remède.
//
// type est celui de l'entrée : 'text', le défaut, ou 'number', un champ
// étroit de six caractères, qui porte min, max et pas (step) quand ils
// sont donnés. La lecture reste celle de lire, et le domaine celui de
// l'exécution : le navigateur rend vide un texte qu'il ne lit pas comme
// un nombre, que lire refuse comme tout texte vide.
import { getContext, onDestroy, untrack } from 'svelte';
import { effetDe } from '../raccourcis.js';
import { creerMachineChamp, estRefusSeance, refusSeanceCourant } from './champ.js';
import { creerGeste } from './geste.js';
import Refus from './Refus.svelte';
let { cle, etiquette, valeur, lire, executer, details = {}, nom = null, gestes = {} } = $props();
let {
cle,
etiquette,
valeur,
lire,
executer,
details = {},
nom = null,
gestes = {},
type = 'text',
min = null,
max = null,
pas = null,
} = $props();
const t = getContext('t');
const seance = getContext('seance') ?? null;
@ -192,7 +211,10 @@
<label for={idEntree}>{t(etiquette, details)}</label>
<input
id={idEntree}
type="text"
{type}
min={min ?? undefined}
max={max ?? undefined}
step={pas ?? undefined}
data-champ={cle}
value={saisie}
aria-label={nom ?? undefined}
@ -225,6 +247,11 @@
font-size: var(--taille-texte);
}
/* Un nombre : six caractères de large, flèches comprises. */
input[type='number'] {
width: 6ch;
}
input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;

View file

@ -36,11 +36,17 @@
// variante : 'secondaire', la défaut, à filet sur le fond de la carte ;
// 'principale', l'action à faire sur l'écran, pleine de la teinte de
// l'action, haute d'une cible principale ; 'discrete', sans fond ni filet
// au repos. Elle ne change que l'apparence ; une variante hors de
// VARIANTES_COMMANDE lève RangeError au montage.
// au repos ; 'retrait', l'icône croixCerclee seule, de --couleur-retrait,
// sans fond ni filet au repos, dans une cible de --cible-minimale de côté
// au moins (§ 14.5) : son libellé, que rien ne montre, devient son nom
// accessible, à défaut de nomAccessible, et ses tracés portent la marque
// « retrait » du registre (marques.js). Elle ne change que l'apparence ;
// une variante hors de VARIANTES_COMMANDE, ou le retrait d'une commande à
// confirmer, lève RangeError au montage.
import { getContext, onDestroy, untrack } from 'svelte';
import { effetDe } from '../raccourcis.js';
import { DELAI_CONFIRMATION_MS, creerMachineCommande, exigerVariante } from './commande.js';
import Icone from './Icone.svelte';
import Refus from './Refus.svelte';
let {
@ -67,7 +73,11 @@
const libelle = $derived(armee ? t(confirmer) : t(cle, details));
const designation = $derived(nom ?? cle.slice(cle.lastIndexOf('.') + 1));
const forme = $derived(exigerVariante(variante));
const forme = $derived(exigerVariante(variante, confirmer));
// Le nom accessible : le libellé de confirmation, montré, quand elle est
// armée ; sinon nomAccessible, ou le libellé d'un retrait, qui ne le
// montre pas.
const nomDuBouton = $derived(armee ? undefined : (nomAccessible ?? (forme === 'retrait' ? libelle : undefined)));
function desarmer() {
if (minuterie === null) return;
@ -119,12 +129,14 @@
data-commande={designation}
class:principale={forme === 'principale'}
class:discrete={forme === 'discrete'}
class:retrait={forme === 'retrait'}
aria-busy={enCours ? 'true' : undefined}
aria-disabled={enCours ? 'true' : undefined}
aria-label={armee ? undefined : (nomAccessible ?? undefined)}
aria-label={nomDuBouton}
aria-describedby={refus === null ? undefined : idRefus}
onkeydown={surTouche}
onclick={cliquer}>{libelle}</button
onclick={cliquer}
>{#if forme === 'retrait'}<Icone nom="croixCerclee" taille={18} marque="retrait" />{:else}{libelle}{/if}</button
>
{#if refus !== null}
<Refus id={idRefus} {refus} lever={() => (refus = null)} objet={nomAccessible} {gestes} />
@ -177,6 +189,27 @@
background-color: var(--couleur-carte-fond-sourd);
}
/* Le retrait : l'icône seule, de la teinte du retrait, centrée dans une
cible ronde de --cible-minimale de côté au moins ; ni fond ni filet au
repos, le fond sourd de la carte au survol. */
button.retrait {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: var(--cible-minimale);
min-height: var(--cible-minimale);
padding: 0;
border-color: transparent;
border-radius: var(--rayon-pilule);
background-color: transparent;
color: var(--couleur-retrait);
}
button.retrait:hover {
background-color: var(--couleur-carte-fond-sourd);
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);

View file

@ -6,10 +6,13 @@
// sans remplissage, du trait de la couleur courante — le parent la règle
// par color, d'un jeton. Elle est cachée aux technologies d'assistance et
// ne prend jamais le focus : un libellé l'accompagne toujours. taille :
// le côté du carré, en px. Un nom inconnu lève RangeError au montage.
// le côté du carré, en px. marque, le nom d'une marque du registre
// (MARQUES, marques.js), se pose en data-marque sur chacun de ses tracés,
// que les épreuves de rendu mesurent ; null, aucun. Un nom inconnu lève
// RangeError au montage.
import { traceIcone } from './icones.js';
let { nom, taille = 16 } = $props();
let { nom, taille = 16, marque = null } = $props();
const traces = $derived(traceIcone(nom));
</script>
@ -28,7 +31,7 @@
focusable="false"
>
{#each traces as trace, rang (rang)}
<path d={trace} />
<path d={trace} data-marque={marque ?? undefined} />
{/each}
</svg>

View file

@ -39,19 +39,26 @@ import { COMMANDES } from '../../application/commandes.js';
/** Les variantes d'une commande, la secondaire en tête — celle d'une
* commande qui n'en donne pas : principale, l'action à faire sur l'écran,
* pleine de la teinte de l'action ; secondaire, à filet ; discrète, sans
* fond ni filet au repos. La variante ne change que l'apparence : le rôle,
* les attributs et la machine restent ceux de toute commande. */
export const VARIANTES_COMMANDE = Object.freeze(['secondaire', 'principale', 'discrete']);
* fond ni filet au repos ; retrait, une croix cerclée de la teinte du
* retrait, sans fond ni filet au repos, dont le libellé devient le nom
* accessible. La variante ne change que l'apparence : le rôle, la
* désignation et la machine restent ceux de toute commande. */
export const VARIANTES_COMMANDE = Object.freeze(['secondaire', 'principale', 'discrete', 'retrait']);
/**
* Une variante de commande, rendue telle quelle.
* Une variante de commande, rendue telle quelle. Le retrait ne montre
* aucun texte : son libellé armé n'aurait où s'afficher, et une commande à
* confirmer ne le prend pas.
*
* @param {string} variante
* @returns {'secondaire'|'principale'|'discrete'}
* @throws {RangeError} hors de VARIANTES_COMMANDE, en la nommant
* @param {string|null} [confirmer] la clé de confirmation de la commande
* @returns {'secondaire'|'principale'|'discrete'|'retrait'}
* @throws {RangeError} hors de VARIANTES_COMMANDE, en la nommant ; le
* retrait d'une commande à confirmer
*/
export function exigerVariante(variante) {
export function exigerVariante(variante, confirmer = null) {
if (!VARIANTES_COMMANDE.includes(variante)) throw new RangeError(`commande : variante inconnue ${JSON.stringify(variante) ?? 'undefined'}`);
if (variante === 'retrait' && confirmer !== null) throw new RangeError('commande : le retrait ne se confirme pas');
return variante;
}

View file

@ -352,8 +352,8 @@ describe('commande : l’action d’un remède', () => {
});
describe('commande : la variante', () => {
test('trois variantes, la secondaire en tête : celle d’une commande qui n’en donne pas', () => {
assert.deepEqual(VARIANTES_COMMANDE, ['secondaire', 'principale', 'discrete']);
test('quatre variantes, la secondaire en tête : celle d’une commande qui n’en donne pas', () => {
assert.deepEqual(VARIANTES_COMMANDE, ['secondaire', 'principale', 'discrete', 'retrait']);
assert.ok(Object.isFrozen(VARIANTES_COMMANDE));
});
@ -363,4 +363,10 @@ describe('commande : la variante', () => {
assert.throws(() => exigerVariante(autre), (e) => e instanceof RangeError && e.message.includes(JSON.stringify(autre) ?? 'undefined'), String(autre));
}
});
test('le retrait ne se confirme pas : avec une clé de confirmation, exigerVariante lève RangeError ; les autres variantes la prennent', () => {
assert.throws(() => exigerVariante('retrait', 'communs.commande.confirmer'), RangeError);
assert.equal(exigerVariante('retrait', null), 'retrait');
for (const variante of ['secondaire', 'principale', 'discrete']) assert.equal(exigerVariante(variante, 'communs.commande.confirmer'), variante);
});
});

View file

@ -415,6 +415,30 @@ describe('commande : la variante', () => {
expect(style(secondaire).fontSize).toBe('16px');
expect(() => commande({ variante: 'primaire' })).toThrow(RangeError);
});
test('retrait : la croix cerclée seule, de la teinte du retrait, sans texte ni fond, nommée par son libellé, dans une cible de 24 px au moins ; à confirmer, elle lève au montage', async () => {
const libelle = t('participants.etiquettes.retirer', { motCle: 'voile' });
const retrait = commande({ variante: 'retrait', cle: 'participants.etiquettes.retirer', details: { motCle: 'voile' }, nom: 'retirerEtiquette' });
expect(retrait.textContent.trim()).toBe('');
expect(retrait.getAttribute('aria-label')).toBe(libelle);
expect(retrait.getAttribute('data-commande')).toBe('retirerEtiquette');
expect([...retrait.querySelectorAll('svg path')].map((p) => p.getAttribute('d'))).toEqual([...ICONES.croixCerclee]);
const { width, height } = retrait.getBoundingClientRect();
expect(Math.min(width, height)).toBeGreaterThanOrEqual(24);
for (const theme of THEMES) {
await sousTheme(theme, () => {
expect(fond(retrait), theme).toEqual(TRANSPARENT);
expect(composantes(style(retrait).borderTopColor)[3], theme).toBe(0);
for (const trace of retrait.querySelectorAll('path')) {
expect(trace.dataset.marque, theme).toBe('retrait');
expect(composantes(getComputedStyle(trace).stroke), theme).toEqual(jeton('--couleur-retrait'));
}
});
}
// Un nom accessible donné passe devant le libellé.
expect(commande({ variante: 'retrait', nomAccessible: 'Ôter' }).getAttribute('aria-label')).toBe('Ôter');
expect(() => commande({ variante: 'retrait', confirmer: 'communs.commande.confirmerDeblocage' })).toThrow(RangeError);
});
});
describe('refus, infobulle, champ et fenêtre à confirmer : habillés des jetons', () => {

View file

@ -44,6 +44,8 @@ const DESSINS = [
['demarrer', ['M8 5l11 7-11 7z']],
// Une étiquette (§ 4.5) : l'étiquette à œillet, pointe en bas à droite.
['etiquettes', ['M3 3h8l10 10-8 8L3 11z', cercle(7.5, 7.5, 1.5)]],
// Un retrait (Commande, variante retrait) : une croix dans un cercle.
['croixCerclee', [cercle(12, 12, 9), 'M9 9l6 6M15 9l-6 6']],
];
/** Les noms des icônes, dans l'ordre du module. */

View file

@ -17,7 +17,7 @@ describe('icônes des primitives', () => {
assert.deepEqual(NOMS_ICONES, [
'crayon', 'listePoints', 'coche', 'cadenas', 'liste', 'importer', 'exporter', 'ajouter', 'chercher',
'calendrier', 'information', 'triangle', 'interdit', 'dossier', 'oeil', 'stylo', 'defaire', 'imprimer',
'etoile', 'croix', 'chevron', 'points', 'demarrer', 'etiquettes',
'etoile', 'croix', 'chevron', 'points', 'demarrer', 'etiquettes', 'croixCerclee',
]);
assert.ok(Object.isFrozen(ICONES) && Object.isFrozen(NOMS_ICONES));
assert.deepEqual(Object.keys(ICONES).sort(), [...NOMS_ICONES].sort());

View file

@ -251,6 +251,46 @@ describe('champ : la fin du geste (§ 2.1, § 8.2)', () => {
});
});
describe('champ : le type de l’entrée', () => {
test('texte par défaut, sans bornes ; nombre : min, max et pas posés, une lettre ne s’y inscrit pas, et lire refuse le vide qu’elle laisse ; des chiffres s’écrivent', async () => {
const montage = await preparer();
const { entree } = monterChamp(montage);
expect(entree.type).toBe('text');
for (const attribut of ['min', 'max', 'step']) expect(entree.hasAttribute(attribut), attribut).toBe(false);
const recus = [];
const cible = monter(
Champ,
{
cle: 'nombre',
etiquette: 'communs.commande.modifier',
valeur: 3,
type: 'number',
min: 1,
max: 100,
pas: 1,
lire: (texte) => (/^\d+$/.test(texte) ? { valeur: Number(texte) } : { refus: { code: 'NON_ENTIER', details: { saisie: texte, remede: null } } }),
executer: (valeur) => recus.push(valeur),
},
montage.contexte,
);
const nombre = cible.querySelector('input[data-champ="nombre"]');
expect([nombre.type, nombre.min, nombre.max, nombre.step, nombre.value]).toEqual(['number', '1', '100', '1', '3']);
await cliquer(nombre);
nombre.select();
await taper('x');
await cliquerAilleurs();
expect(nombre.value).toBe('');
expect(cible.querySelector('[data-refus="NON_ENTIER"]')).not.toBeNull();
expect(recus).toEqual([]);
await cliquer(nombre);
await taper('42');
await cliquerAilleurs();
expect(recus).toEqual([42]);
expect(cible.querySelector('[data-refus]')).toBeNull();
});
});
describe('champ : le refus de l’exécution (§ 2.1, § 5.9)', () => {
test('executer refusé avec un code : le refus se pose contre le champ, la saisie gardée, rien d’écrit ; le refus levé par son remède, le champ rend la valeur enregistrée, et une saisie nouvelle s’écrit', async () => {
const montage = await preparer();

View file

@ -659,7 +659,7 @@ export const ECRANS = Object.freeze([
async atteindre(contexte) {
// Deux personnes étiquetées, puis la fiche d'une troisième, où
// « FINANCE » tapé au clavier s'enregistre sous l'orthographe que
// l'événement porte : les pastilles neutres et leur « Retirer », le
// l'événement porte : les pastilles neutres et leur croix de retrait, le
// signal d'orthographe contre le champ, et la légende des étiquettes
// sous celle des appartenances.
let charge = chargePrincipale();