From 13bf16b5be2c518f1446fbce005617674e3ec8da Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 17:25:03 -0400 Subject: [PATCH] [REF] interface: read every key through the shortcut table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/interface/participants/Formulaire.svelte | 3 +- src/interface/participants/Grille.svelte | 40 +++-- .../Participants.navigateur.test.js | 43 +++++ src/interface/participants/grille.js | 34 ++-- src/interface/plan/ListesTravail.svelte | 3 +- src/interface/plan/PanneauPlace.svelte | 3 +- src/interface/plan/PanneauTable.svelte | 3 +- .../plan/Panneaux.navigateur.test.js | 13 ++ src/interface/plan/Plan.svelte | 5 +- src/interface/plan/Reserve.svelte | 3 +- src/interface/primitives/Champ.svelte | 6 +- src/interface/primitives/Commande.svelte | 3 +- src/interface/primitives/Infobulle.svelte | 3 +- src/interface/raccourcis.js | 8 +- src/interface/tables/ListeTables.svelte | 3 +- .../tables/Tables.navigateur.test.js | 22 +++ test/sources_interface.test.js | 148 +----------------- 17 files changed, 159 insertions(+), 184 deletions(-) diff --git a/src/interface/participants/Formulaire.svelte b/src/interface/participants/Formulaire.svelte index bb387be..4be701c 100644 --- a/src/interface/participants/Formulaire.svelte +++ b/src/interface/participants/Formulaire.svelte @@ -34,6 +34,7 @@ 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(); @@ -113,7 +114,7 @@ // 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 (evenement.key !== 'Enter' || !entreeValide({ maj: evenement.shiftKey, multiligne: true })) return; + if (effetDe('champ', evenement.key) !== 'valider' || !entreeValide({ maj: evenement.shiftKey, multiligne: true })) return; evenement.preventDefault(); formulaire.requestSubmit(); } diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte index b22f5cb..5e90c0a 100644 --- a/src/interface/participants/Grille.svelte +++ b/src/interface/participants/Grille.svelte @@ -61,6 +61,7 @@ import Commande from '../primitives/Commande.svelte'; import { creerGeste } from '../primitives/geste.js'; import Refus from '../primitives/Refus.svelte'; + import { effetDe } from '../raccourcis.js'; import { cleDeRaison } from './conflits.js'; import { COLONNES, basculerGroupe, celluleSuivante, cheminDe, cleDeColonne, deplierChemin, entreeValide, lignesVisibles, ligneVoisine } from './grille.js'; @@ -249,25 +250,41 @@ geste.tenir(machine.enCours); } + // Les effets de la grille qui mènent à une autre cellule (celluleSuivante). + const DEPLACEMENTS = new Set(['haut', 'bas', 'gauche', 'droite', 'suivante', 'precedente']); + + // La touche d'un appui telle que RACCOURCIS la nomme dans la grille : + // « Maj+ » devant la touche quand la table donne à la majuscule son effet + // propre (Maj+Tab), la touche seule sinon — Maj+flèche se lit comme la + // flèche. + function toucheDeGrille(evenement) { + const majuscule = `Maj+${evenement.key}`; + return evenement.shiftKey && effetDe('grille', majuscule) !== null ? majuscule : evenement.key; + } + + // Dans une cellule en édition, les effets du contexte 'champ' — valider, + // rendre — passent d'abord ; seuls suivante et precedente de la grille + // s'y ajoutent. async function surToucheEntree(evenement, rang) { - const touche = evenement.key; - if (touche === 'Escape') { + const touche = toucheDeGrille(evenement); + const saisie = effetDe('champ', evenement.key); + const tabulation = effetDe('grille', touche); + if (saisie === 'rendre') { evenement.preventDefault(); abandonner(); - } else if (touche === 'Enter') { + } else if (saisie === 'valider') { if (!entreeValide({ maj: evenement.shiftKey, multiligne: edition.colonne === 'notes' })) return; evenement.preventDefault(); valider(); - } else if (touche === 'Tab') { + } else if (tabulation === 'suivante' || tabulation === 'precedente') { // Une saisie refusée garde l'édition et le focus : Tab ne quitte pas // le champ, dont la sortie validerait une seconde fois. evenement.preventDefault(); const { id } = edition; const colonne = COLONNES.indexOf(edition.colonne); - const sens = evenement.shiftKey ? 'Maj+Tab' : 'Tab'; if (!(await valider())) return; await tick(); - tabulerDepuis(id, rang, colonne, sens); + tabulerDepuis(id, rang, colonne, touche); } } @@ -281,7 +298,7 @@ function tabulerDepuis(id, rangAvant, colonne, touche) { const rang = rangs.get(id) ?? -1; if (rang >= 0) return deplacer(rang, colonne, touche); - const suivante = touche === 'Tab' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 }; + const suivante = effetDe('grille', touche) === 'suivante' ? { ligne: rangAvant, colonne: 0 } : { ligne: rangAvant - 1, colonne: COLONNES.length - 1 }; if (suivante.ligne < 0 || suivante.ligne >= visibles.length) return false; activer(suivante.ligne, suivante.colonne); return true; @@ -318,14 +335,15 @@ 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)) { + const touche = toucheDeGrille(evenement); + const effet = effetDe('grille', touche); + if (DEPLACEMENTS.has(effet)) { if (deplacer(rang, colonne, touche)) evenement.preventDefault(); - } else if (touche === 'Enter' || touche === 'F2') { + } else if (effet === 'editer') { evenement.preventDefault(); if (COLONNES[colonne] === 'exclu') basculerExclusion(personne); else ouvrirEdition(personne, COLONNES[colonne]); - } else if (touche === 'Escape') { + } else if (effet === 'effacer') { refusCellule = null; } } diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js index ff47fe8..6edb346 100644 --- a/src/interface/participants/Participants.navigateur.test.js +++ b/src/interface/participants/Participants.navigateur.test.js @@ -443,6 +443,18 @@ describe('la grille (§ 10.2, § 19.3)', () => { expect(m.trace.length).toBe(trace); }); + test('Flèche gauche sur une cellule hors édition mène à la cellule précédente de la ligne, et s’arrête à la première', async () => { + const m = await evenementOuvert({ personnes: TROIS }); + const premier = idsAffiches(m)[0]; + cellule(m, premier, COLONNES[1]).focus(); + await touche('ArrowLeft'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, premier, COLONNES[0])); + await touche('ArrowLeft'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, premier, COLONNES[0])); + }); + test('Tab après une saisie qui déplace la ligne au tri mène à la cellule suivante de la même personne', async () => { const m = await evenementOuvert({ personnes: TROIS }); await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]')); @@ -532,6 +544,37 @@ describe('la grille (§ 10.2, § 19.3)', () => { expect(document.activeElement).toBe(cellule(m, premier, COLONNES.at(-1))); }); + test('hors édition, Maj+Tab mène de la première cellule d’une ligne à la dernière de la ligne d’avant, Tab l’y ramène', async () => { + const m = await evenementOuvert({ personnes: TROIS }); + const [premier, second] = idsAffiches(m); + cellule(m, second, COLONNES[0]).focus(); + await rendu(); + await majTab(); + await rendu(); + expect(document.activeElement).toBe(cellule(m, premier, COLONNES.at(-1))); + expect(cellule(m, premier, COLONNES.at(-1)).querySelector('input, textarea')).toBeNull(); + await touche('Tab'); + await rendu(); + expect(document.activeElement).toBe(cellule(m, second, COLONNES[0])); + }); + + test('hors édition, Échap efface le refus de la cellule : Entrée en lecture le pose sans ouvrir de champ, la cellule garde le focus', async () => { + const m = await evenementOuvert({ personnes: TROIS }); + await m.seance.passerEnLecture(); + await rendu(); + const cible = cellule(m, idsAffiches(m)[0], 'nom'); + cible.focus(); + await touche('Enter'); + await rendu(); + expect(cible.querySelector('input')).toBeNull(); + expect(cible.querySelector('[data-refus]')).not.toBeNull(); + expect(document.activeElement).toBe(cible); + await touche('Escape'); + await rendu(); + expect(cible.querySelector('[data-refus]')).toBeNull(); + expect(cible.hasAttribute('aria-describedby')).toBe(false); + }); + test('une note de deux lignes, retouchée dans la grille, garde son saut de ligne', async () => { const m = await evenementOuvert(); await m.seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine', notes: 'arrive tard\nrepart tôt' } }); diff --git a/src/interface/participants/grille.js b/src/interface/participants/grille.js index 21ba952..13a3de5 100644 --- a/src/interface/participants/grille.js +++ b/src/interface/participants/grille.js @@ -11,6 +11,8 @@ // qu'elle reçoit ; la clé normalisée vient de l'appelant, qui la tient de // src/application/csv.js. +import { effetDe } from '../raccourcis.js'; + /** Les colonnes de la grille, propriétés d'un participant, dans l'ordre des * colonnes de l'export : « l'ordre de la grille » que relit l'import. */ export const COLONNES = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'exclu', 'notes']); @@ -420,16 +422,18 @@ export function cheminDe(groupes, id) { /** * La cellule qu'atteint une touche depuis la cellule position, dans une - * grille de lignes × colonnes. Les flèches bougent d'une cellule et restent - * sur place au bord ; Tab avance dans la ligne puis passe au début de la - * suivante, Maj+Tab recule de même. Tab après la dernière cellule, Maj+Tab - * avant la première, rendent null : le focus quitte la grille, et le - * navigateur le mène à l'élément suivant. Une autre touche ne bouge rien. - * Une grille sans ligne n'a pas de cellule : null. + * grille de lignes × colonnes. La touche se lit par effetDe dans le + * contexte 'grille' de RACCOURCIS. Les flèches — effets haut, bas, gauche, + * droite — bougent d'une cellule et restent sur place au bord ; Tab (effet + * suivante) avance dans la ligne puis passe au début de la suivante, + * Maj+Tab (effet precedente) recule de même. Tab après la dernière cellule, + * Maj+Tab avant la première, rendent null : le focus quitte la grille, et + * le navigateur le mène à l'élément suivant. Une autre touche ne bouge + * rien. Une grille sans ligne n'a pas de cellule : null. * * @param {{ligne: number, colonne: number}} position - * @param {string} touche 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - * 'Tab' ou 'Maj+Tab' + * @param {string} touche la touche telle que RACCOURCIS la nomme : + * KeyboardEvent.key, « Maj+ » devant quand la majuscule change l'effet * @param {{lignes: number, colonnes: number}} taille * @returns {{ligne: number, colonne: number}|null} */ @@ -437,18 +441,18 @@ export function celluleSuivante({ ligne, colonne }, touche, { lignes, colonnes } if (lignes === 0 || colonnes === 0) return null; const borner = (valeur, max) => (valeur < 0 ? 0 : valeur > max ? max : valeur); const rang = ligne * colonnes + colonne; - switch (touche) { - case 'ArrowUp': + switch (effetDe('grille', touche)) { + case 'haut': return { ligne: borner(ligne - 1, lignes - 1), colonne }; - case 'ArrowDown': + case 'bas': return { ligne: borner(ligne + 1, lignes - 1), colonne }; - case 'ArrowLeft': + case 'gauche': return { ligne, colonne: borner(colonne - 1, colonnes - 1) }; - case 'ArrowRight': + case 'droite': return { ligne, colonne: borner(colonne + 1, colonnes - 1) }; - case 'Tab': + case 'suivante': return rang + 1 >= lignes * colonnes ? null : { ligne: Math.floor((rang + 1) / colonnes), colonne: (rang + 1) % colonnes }; - case 'Maj+Tab': + case 'precedente': return rang === 0 ? null : { ligne: Math.floor((rang - 1) / colonnes), colonne: (rang - 1) % colonnes }; default: return { ligne, colonne }; diff --git a/src/interface/plan/ListesTravail.svelte b/src/interface/plan/ListesTravail.svelte index 16f8af9..36752ab 100644 --- a/src/interface/plan/ListesTravail.svelte +++ b/src/interface/plan/ListesTravail.svelte @@ -26,6 +26,7 @@ import { getContext, tick } from 'svelte'; import { conversionsPossibles, pressentisNonReserves, titresNonPourvus } from '../../application/listes_travail.js'; import Commande from '../primitives/Commande.svelte'; + import { effetDe } from '../raccourcis.js'; import { nomDeCharge } from './vue_plan.js'; const t = getContext('t'); @@ -63,7 +64,7 @@ } function surTouche(evenement) { - if (evenement.key !== 'Escape' || choix === null) return; + if (effetDe('plan', evenement.key) !== 'remonter' || choix === null) return; evenement.preventDefault(); ui.choisir(null); } diff --git a/src/interface/plan/PanneauPlace.svelte b/src/interface/plan/PanneauPlace.svelte index 7ce97c4..fa4b396 100644 --- a/src/interface/plan/PanneauPlace.svelte +++ b/src/interface/plan/PanneauPlace.svelte @@ -27,6 +27,7 @@ import { getContext, untrack } from 'svelte'; import { libellePropose } from '../../application/places.js'; import Commande from '../primitives/Commande.svelte'; + import { effetDe } from '../raccourcis.js'; import { commandeDeDepot } from './depot.js'; import { libellePlace } from './gestes.js'; import MenuPersonne from './MenuPersonne.svelte'; @@ -154,7 +155,7 @@ // Échap referme le panneau et rend le focus à la racine du plan. function surTouche(evenement) { - if (evenement.key !== 'Escape') return; + if (effetDe('fenetre', evenement.key) !== 'fermer') return; evenement.preventDefault(); const racine = evenement.currentTarget.closest('[data-section]')?.querySelector('[data-plan]') ?? null; ui.selectionner(null); diff --git a/src/interface/plan/PanneauTable.svelte b/src/interface/plan/PanneauTable.svelte index 173b3a2..751bf93 100644 --- a/src/interface/plan/PanneauTable.svelte +++ b/src/interface/plan/PanneauTable.svelte @@ -28,6 +28,7 @@ import { comptesTitres } from '../../application/listes_travail.js'; import { libellePropose } from '../../application/places.js'; import Commande from '../primitives/Commande.svelte'; + import { effetDe } from '../raccourcis.js'; import { libellePlace } from './gestes.js'; import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js'; import { nomDeCharge } from './vue_plan.js'; @@ -103,7 +104,7 @@ } function surTouche(evenement) { - if (evenement.key !== 'Escape') return; + if (effetDe('fenetre', evenement.key) !== 'fermer') return; evenement.preventDefault(); const racine = evenement.currentTarget.closest('[data-section]')?.querySelector('[data-plan]') ?? null; ui.selectionner(null); diff --git a/src/interface/plan/Panneaux.navigateur.test.js b/src/interface/plan/Panneaux.navigateur.test.js index bbf28f0..5de3afd 100644 --- a/src/interface/plan/Panneaux.navigateur.test.js +++ b/src/interface/plan/Panneaux.navigateur.test.js @@ -872,6 +872,19 @@ describe('Échap oublie le choix en attente (§ 7.1, § 7.3)', () => { await touche('Escape'); await attendre(() => expect(m.ui.etat().choix).toBeNull()); }); + + test('choisie d’un vrai clic dans la réserve : Échap sur le plan sans élément actif l’oublie', async () => { + const m = await ouvrir(sansProposition()); + // Le vrai pointeur ne rend pas le focus suivant focus-visible : la + // racine du plan, focalisée ensuite, n'a aucun élément actif. + await presserBouton(dans(m, `[data-reserve] [data-participant="${THEO}"]`)); + await attendre(() => expect(m.ui.etat().choix?.participant).toBe(THEO)); + racinePlan(m).focus(); + flushSync(); + expect(actif(m)).toBeNull(); + await touche('Escape'); + await attendre(() => expect(m.ui.etat().choix).toBeNull()); + }); }); describe('un clic ne déplace pas la vue (§ 7.3, § 8.4)', () => { diff --git a/src/interface/plan/Plan.svelte b/src/interface/plan/Plan.svelte index a003c65..f48c06e 100644 --- a/src/interface/plan/Plan.svelte +++ b/src/interface/plan/Plan.svelte @@ -94,6 +94,7 @@ import { controlerCapacite } from '../../application/capacite.js'; import { constante } from '../../geometrie/constantes.js'; import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; + import { effetDe } from '../raccourcis.js'; import BandeauPlan from './BandeauPlan.svelte'; import BarreVue from './BarreVue.svelte'; import { toucheDuPlan, vueQuiMontre } from './clavier.js'; @@ -544,7 +545,7 @@ }; function surTouche(evenement) { - if (evenement.key !== 'Escape' || etatGeste.phase === 'repos') return; + if (effetDe('geste', evenement.key) !== 'annuler' || etatGeste.phase === 'repos') return; evenement.stopPropagation(); evenement.preventDefault(); annulerGeste('echap'); @@ -671,7 +672,7 @@ if (etatGeste.phase !== 'repos' || modele === null || evenement.altKey || evenement.ctrlKey || evenement.metaKey) return; auClavier = true; const rendu = toucheDuPlan(actif, evenement.key, contexteClavier()); - if (evenement.key === 'Escape' && (rendu === null || rendu.effet !== null)) ui.choisir(null); + if (effetDe('plan', evenement.key) === 'remonter' && (rendu === null || rendu.effet !== null)) ui.choisir(null); if (rendu === null) return; evenement.preventDefault(); actif = rendu.actif; diff --git a/src/interface/plan/Reserve.svelte b/src/interface/plan/Reserve.svelte index 8ae9a7c..2bf3968 100644 --- a/src/interface/plan/Reserve.svelte +++ b/src/interface/plan/Reserve.svelte @@ -14,6 +14,7 @@ // Le panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des // places du tour affiché, que SectionPlan calcule, ou null sans événement. import { getContext } from 'svelte'; + import { effetDe } from '../raccourcis.js'; import { nomDeCharge } from './vue_plan.js'; let { etatTour } = $props(); @@ -35,7 +36,7 @@ } function surTouche(evenement) { - if (evenement.key !== 'Escape' || choix === null) return; + if (effetDe('plan', evenement.key) !== 'remonter' || choix === null) return; evenement.preventDefault(); ui.choisir(null); } diff --git a/src/interface/primitives/Champ.svelte b/src/interface/primitives/Champ.svelte index 321f9c1..5602372 100644 --- a/src/interface/primitives/Champ.svelte +++ b/src/interface/primitives/Champ.svelte @@ -32,6 +32,7 @@ // retire pas un refus : son remède, qu'un clic atteint, resterait sinon // hors de portée. 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'; @@ -168,8 +169,9 @@ } function surTouche(evenement) { - if (evenement.key === 'Escape') abandonner(evenement.currentTarget); - else if (evenement.key === 'Enter') { + const effet = effetDe('champ', evenement.key); + if (effet === 'rendre') abandonner(evenement.currentTarget); + else if (effet === 'valider') { evenement.preventDefault(); valider(); } diff --git a/src/interface/primitives/Commande.svelte b/src/interface/primitives/Commande.svelte index 8847c76..57f7a9a 100644 --- a/src/interface/primitives/Commande.svelte +++ b/src/interface/primitives/Commande.svelte @@ -27,6 +27,7 @@ // confirmer se lit au montage : une commande qui change de confirmation se // remonte. import { getContext, onDestroy, untrack } from 'svelte'; + import { effetDe } from '../raccourcis.js'; import { DELAI_CONFIRMATION_MS, creerMachineCommande } from './commande.js'; import Refus from './Refus.svelte'; @@ -82,7 +83,7 @@ } function surTouche(evenement) { - if (evenement.repeat && (evenement.key === 'Enter' || evenement.key === ' ')) evenement.preventDefault(); + if (evenement.repeat && effetDe('commande', evenement.key) === 'activer') evenement.preventDefault(); } onDestroy(desarmer); diff --git a/src/interface/primitives/Infobulle.svelte b/src/interface/primitives/Infobulle.svelte index 4ff7074..46cadaa 100644 --- a/src/interface/primitives/Infobulle.svelte +++ b/src/interface/primitives/Infobulle.svelte @@ -23,6 +23,7 @@ // n'est pas placée, elle reste invisible. Une ancre retirée du document la // referme. Elle ne capte aucun pointeur : le terme reste survolé. import { getContext } from 'svelte'; + import { effetDe } from '../raccourcis.js'; import { placerInfobulle } from './infobulle.js'; let { id, ouverte = null, fermer } = $props(); @@ -60,7 +61,7 @@ }); function surTouche(evenement) { - if (ouverte !== null && evenement.key === 'Escape') fermer(); + if (ouverte !== null && effetDe('fenetre', evenement.key) === 'fermer') fermer(); } diff --git a/src/interface/raccourcis.js b/src/interface/raccourcis.js index 3901b76..5ec8d49 100644 --- a/src/interface/raccourcis.js +++ b/src/interface/raccourcis.js @@ -3,10 +3,10 @@ // La table des raccourcis clavier de l'interface (§ 7.3, § 13.5, § 19.8) : // la seule source du tableau des raccourcis de la documentation. Le code qui -// répond à une touche la lit par effetDe ; les fichiers que la garde de -// test/sources_interface.test.js admet encore nomment leurs touches eux-mêmes, -// et chacune de ces touches doit avoir son entrée ici, sans quoi la garde la -// relève comme absente du tableau. +// répond à une touche la lit par effetDe, et la garde de +// test/sources_interface.test.js relève toute touche nommée hors d'ici : +// l'interface ne répond qu'aux touches de la table, et le tableau décrit +// chacune de ses réponses. // // Une entrée est { touche, contexte, effet, cle } : // touche la valeur de KeyboardEvent.key, précédée de « Maj+ » quand la diff --git a/src/interface/tables/ListeTables.svelte b/src/interface/tables/ListeTables.svelte index 89e949d..e01e930 100644 --- a/src/interface/tables/ListeTables.svelte +++ b/src/interface/tables/ListeTables.svelte @@ -27,6 +27,7 @@ import Champ from '../primitives/Champ.svelte'; import Commande from '../primitives/Commande.svelte'; import Confirmation from '../primitives/Confirmation.svelte'; + import { effetDe } from '../raccourcis.js'; import { annonceSuppression, avertissementsNommes, @@ -109,7 +110,7 @@ const arrets = [ on(element, 'input', () => oublier(id)), on(element, 'keydown', (evenement) => { - if (evenement.key === 'Escape') oublier(id); + if (effetDe('champ', evenement.key) === 'rendre') oublier(id); }), ]; return () => { diff --git a/src/interface/tables/Tables.navigateur.test.js b/src/interface/tables/Tables.navigateur.test.js index 73cc0c1..f996056 100644 --- a/src/interface/tables/Tables.navigateur.test.js +++ b/src/interface/tables/Tables.navigateur.test.js @@ -494,6 +494,28 @@ describe('la capacité d’une table, saisie au champ (§ 6.2)', () => { expect(ecritures(m)).toBe(avant); }); + test('sièges retirés puis Échap dans le champ : le refus et la liste des sièges retirés partent ensemble ; rien d’écrit', async () => { + const m = await preparer( + chargeDe({ + tables: [{ id: 1 }], + n: 4, + attribuerSieges: true, + reservations: [resa(1, 1, 7)], + titres: [{ table: 1, siege: 8, libelle: 'animateur' }], + }), + ); + const avant = ecritures(m); + await saisir(m, champTable(m, 1), '6'); + expect(ligne(m, 1).querySelector('[data-refus="SIEGES_RETIRES"]')).not.toBeNull(); + expect(ligne(m, 1).querySelector('[data-sieges-retires]')).not.toBeNull(); + expect(document.activeElement).toBe(champTable(m, 1)); + await touche('Escape'); + await attendre(m); + expect(ligne(m, 1).querySelector('[data-refus="SIEGES_RETIRES"]')).toBeNull(); + expect(ligne(m, 1).querySelector('[data-sieges-retires]')).toBeNull(); + expect(ecritures(m)).toBe(avant); + }); + test('surnuméraires et lisibilité : un retenu assied huit personnes à la table 3 au tour 2 ; 6 accepté, l’avertissement nomme les deux personnes versées en réserve ; S + 1 avertit de S ; S, non', async () => { const donnees = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 }); placer( diff --git a/test/sources_interface.test.js b/test/sources_interface.test.js index 24add4f..baac44d 100644 --- a/test/sources_interface.test.js +++ b/test/sources_interface.test.js @@ -33,7 +33,6 @@ import { TABLES_APPLICATION } from '../src/application/libelles.js'; import { creerTraduction } from '../src/application/traduction.js'; import { TABLES_INTERFACE } from '../src/interface/libelles.js'; import { JETONS_CONFLIT } from '../src/interface/marques.js'; -import { CONTEXTES_RACCOURCIS, RACCOURCIS } from '../src/interface/raccourcis.js'; import { DESIGNATIONS } from './navigateur/designations.js'; import { describe, test } from './lanceur.js'; @@ -1079,98 +1078,21 @@ function valeurDeChaine(noeud) { return null; } -// Les fichiers de src/interface qui nomment encore leurs touches, chacun avec -// les contextes de RACCOURCIS où il agit et le relevé de ses touches, ligne -// omise, trié : la garde les admet tels quels. Un écart — une touche de -// plus, une de moins — relève les touches du fichier et demande de revoir -// son entrée ; une entrée dont le fichier ne nomme plus rien est sans objet. -// Faire lire RACCOURCIS à l'un d'eux retire son entrée. Tant qu'il ne la lit -// pas, chacune de ses touches a son entrée dans RACCOURCIS sous l'un de ses -// contextes : le tableau engendré décrit ce que le fichier fait, et une -// entrée retirée ou renommée dans la table se relève ici. -const enAttente = (contextes, touches) => Object.freeze({ contextes: Object.freeze(contextes), touches: Object.freeze(touches) }); -const TOUCHES_EN_ATTENTE = new Map([ - ['src/interface/participants/Formulaire.svelte', enAttente(['champ'], ['touche "Enter"'])], - [ - 'src/interface/participants/Grille.svelte', - enAttente( - ['grille', 'champ'], - [ - 'touche "ArrowDown"', - 'touche "ArrowLeft"', - 'touche "ArrowRight"', - 'touche "ArrowUp"', - 'touche "Enter"', - 'touche "Enter"', - 'touche "Escape"', - 'touche "Escape"', - 'touche "F2"', - 'touche "Maj+Tab"', - 'touche "Maj+Tab"', - 'touche "Maj+Tab"', - 'touche "Tab"', - 'touche "Tab"', - 'touche "Tab"', - 'touche "Tab"', - 'touche "Tab"', - ], - ), - ], - [ - 'src/interface/participants/grille.js', - enAttente( - ['grille'], - ['touche "ArrowDown"', 'touche "ArrowLeft"', 'touche "ArrowRight"', 'touche "ArrowUp"', 'touche "Maj+Tab"', 'touche "Tab"'], - ), - ], - ['src/interface/plan/ListesTravail.svelte', enAttente(['plan'], ['touche "Escape"'])], - ['src/interface/plan/PanneauPlace.svelte', enAttente(['fenetre'], ['touche "Escape"'])], - ['src/interface/plan/PanneauTable.svelte', enAttente(['fenetre'], ['touche "Escape"'])], - ['src/interface/plan/Plan.svelte', enAttente(['plan', 'geste'], ['touche "Escape"', 'touche "Escape"'])], - ['src/interface/plan/Reserve.svelte', enAttente(['plan'], ['touche "Escape"'])], - ['src/interface/primitives/Champ.svelte', enAttente(['champ'], ['touche "Enter"', 'touche "Escape"'])], - ['src/interface/primitives/Commande.svelte', enAttente(['commande'], ['touche " "', 'touche "Enter"'])], - ['src/interface/primitives/Infobulle.svelte', enAttente(['fenetre'], ['touche "Escape"'])], - ['src/interface/tables/ListeTables.svelte', enAttente(['champ'], ['touche "Escape"'])], -]); - -// Les touches des fichiers en attente qu'aucune entrée de raccourcis ne -// décrit sous un des contextes du fichier : « chemin touche "…" hors de -// RACCOURCIS (contextes) », trié. Un contexte qui n'est pas dans -// contextesConnus se relève aussi. -function touchesSansEntree(raccourcis, contextesConnus, enAttenteDe = TOUCHES_EN_ATTENTE) { - const decrites = new Set(raccourcis.map(({ contexte, touche }) => `${contexte}:${touche}`)); - const releves = []; - for (const [chemin, { contextes, touches }] of enAttenteDe) { - for (const contexte of contextes) { - if (!contextesConnus.includes(contexte)) releves.push(`${chemin} contexte ${JSON.stringify(contexte)} inconnu`); - } - for (const motif of new Set(touches)) { - const touche = JSON.parse(motif.slice('touche '.length)); - if (!contextes.some((contexte) => decrites.has(`${contexte}:${touche}`))) { - releves.push(`${chemin} ${motif} hors de RACCOURCIS (${contextes.join(', ')})`); - } - } - } - return releves.sort(); -} - // Relevé des touches nommées dans les composants et les modules de // src/interface, hors de la table des raccourcis : une chaîne qui nomme une // touche (TOUCHE_NOMMEE), où qu'elle soit dans le code, et toute chaîne — // l'espace, une lettre — comparée à une lecture de key ou cas d'un switch // sur elle. Une touche se relève une fois, sous la forme « touche "…" ». -// Puis les fichiers en attente : admis quand leur relevé est le leur. -function releverTouches(racine, enAttenteDe = TOUCHES_EN_ATTENTE) { +// Aucun fichier n'est admis : le code répond à une touche par effetDe. +function releverTouches(racine) { const chemins = exigerNonVide( sources(racine, ['src/interface'], ['.svelte', '.js']).filter((chemin) => chemin !== TABLE_RACCOURCIS), 'touches nommées', ); - const parFichier = new Map(); + const releves = []; for (const chemin of chemins) { const composant = chemin.endsWith('.svelte'); const { texte, ast } = composant ? analyserComposant(racine, chemin) : analyserModule(racine, chemin); - const releves = []; const vus = new Set(); const relever = (noeud) => { const valeur = valeurDeChaine(noeud); @@ -1188,19 +1110,8 @@ function releverTouches(racine, enAttenteDe = TOUCHES_EN_ATTENTE) { relever(noeud); } }); - if (releves.length > 0) parFichier.set(chemin, releves); } - const releves = []; - const aRevoir = []; - for (const [chemin, deFichier] of parFichier) { - const admis = enAttenteDe.get(chemin)?.touches; - const motifs = deFichier.map(({ texte }) => texte).sort(); - if (admis !== undefined && JSON.stringify(motifs) === JSON.stringify([...admis].sort())) continue; - releves.push(...deFichier); - if (admis !== undefined) aRevoir.push(`${chemin} : en attente, entrée à revoir`); - } - const sansObjet = [...enAttenteDe.keys()].filter((chemin) => !parFichier.has(chemin)).map((chemin) => `${chemin} : en attente sans objet`); - return [...trier(releves), ...[...aRevoir, ...sansObjet].sort()]; + return trier(releves); } // --- Arbres d'épreuve --------------------------------------------------------- @@ -1842,7 +1753,7 @@ describe('sources de l’interface : les désignations stables (contrainte 16)', }); describe('sources de l’interface : les touches nommées (§ 7.3, § 19.8)', () => { - test('aucune touche nommée sous src/interface hors de raccourcis.js, sauf les fichiers en attente, tels quels', () => { + test('aucune touche nommée sous src/interface hors de raccourcis.js : chaque fichier lit ses touches par effetDe', () => { exigerVide(releverTouches(RACINE), 'touches nommées'); }); @@ -1867,7 +1778,7 @@ describe('sources de l’interface : les touches nommées (§ 7.3, § 19.8)', () 'src/interface/A.test.js': "if (e.key === 'Escape') f();\n", 'src/application/c.js': "export const k = (e) => e.key === 'Escape';\n", }; - assert.deepEqual(avecArbre(fichiers, (racine) => releverTouches(racine, new Map())), [ + assert.deepEqual(avecArbre(fichiers, releverTouches), [ 'src/interface/A.svelte:2 touche "Enter"', 'src/interface/A.svelte:4 touche "Tab"', 'src/interface/plan/sous/b.js:2 touche "Escape"', @@ -1880,53 +1791,6 @@ describe('sources de l’interface : les touches nommées (§ 7.3, § 19.8)', () ]); }); - test('un fichier en attente n’est admis qu’avec son relevé exact ; un écart relève ses touches, une entrée que rien ne sert est sans objet', () => { - const fichiers = { - 'src/interface/x.js': "export const f = (e) => e.key === 'Escape' || e.key === 'Enter';\n", - 'src/interface/y.js': "export const g = (e) => e.key === 'Escape';\n", - 'src/interface/z.js': 'export const h = 1;\n', - }; - const attente = new Map([ - ['src/interface/x.js', enAttente(['champ'], ['touche "Escape"', 'touche "Enter"'])], - ['src/interface/y.js', enAttente(['champ'], ['touche "Escape"', 'touche "Tab"'])], - ['src/interface/z.js', enAttente(['champ'], ['touche "Escape"'])], - ['src/interface/w.js', enAttente(['champ'], ['touche "Escape"'])], - ]); - assert.deepEqual(avecArbre(fichiers, (racine) => releverTouches(racine, attente)), [ - 'src/interface/y.js:1 touche "Escape"', - 'src/interface/w.js : en attente sans objet', - 'src/interface/y.js : en attente, entrée à revoir', - 'src/interface/z.js : en attente sans objet', - ]); - }); - - test('chaque touche d’un fichier en attente a son entrée dans RACCOURCIS sous un des contextes du fichier : le tableau engendré décrit ce qu’il fait', () => { - assert.ok(TOUCHES_EN_ATTENTE.size > 0 || RACCOURCIS.length > 0); - exigerVide(touchesSansEntree(RACCOURCIS, CONTEXTES_RACCOURCIS), 'touches en attente sans entrée'); - }); - - test('une touche en attente que la table ne décrit plus sous les contextes du fichier se relève, comme un contexte inconnu', () => { - const raccourcis = [ - { touche: 'Escape', contexte: 'fenetre', effet: 'fermer' }, - { touche: 'Enter', contexte: 'champ', effet: 'valider' }, - { touche: 'q', contexte: 'geste', effet: 'annuler' }, - ]; - const attente = new Map([ - ['src/interface/a.svelte', enAttente(['fenetre'], ['touche "Escape"', 'touche "Escape"'])], - ['src/interface/b.svelte', enAttente(['geste', 'champ'], ['touche "Enter"', 'touche "Escape"'])], - ['src/interface/c.svelte', enAttente(['fenetre', 'tiroir'], ['touche "Escape"'])], - ]); - assert.deepEqual(touchesSansEntree(raccourcis, ['champ', 'geste', 'fenetre'], attente), [ - 'src/interface/b.svelte touche "Escape" hors de RACCOURCIS (geste, champ)', - 'src/interface/c.svelte contexte "tiroir" inconnu', - ]); - // La table où Échap de la fenêtre devient « q » : le fichier qui ferme sur Échap se relève. - const deplacee = raccourcis.map((entree) => (entree.contexte === 'fenetre' ? { ...entree, touche: 'q' } : entree)); - assert.deepEqual(touchesSansEntree(deplacee, ['champ', 'geste', 'fenetre'], new Map([...attente].slice(0, 1))), [ - 'src/interface/a.svelte touche "Escape" hors de RACCOURCIS (fenetre)', - ]); - }); - test('un arbre sans source d’interface, ou réduit à la table des raccourcis, fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverTouches), /touches nommées : aucun fichier examiné/); assert.throws(