From 59f847a3fe4c193a06fe3f5db01a711f2c8b170c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 11 Oct 2026 19:04:09 -0400 Subject: [PATCH] [IMP] screens a11y: table-named controls, focus back after a remedy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controls repeated under every table (places, goals, rule, remove, the remedies of their refusals) all read the same to a screen reader, and a remedy that cleared its own refusal dropped focus on the page body. Each such control now carries an accessible name with its table, through a declared prop on Champ and Commande; every refusal is linked to its control by id, and after a remedy focus returns to that control, nested refusals and the priority editor included. Visible text is unchanged. Checked: node 4590, navigateur 954, walkthrough green in verify mode. --- FR --- [IMP] écrans, accessibilité : noms par table, focus rendu après remède Les commandes répétées sous chaque table (places, objectifs, règle, retrait, remèdes de leurs refus) se lisaient pareil au lecteur d'écran, et un remède qui retirait son propre refus laissait le focus sur le corps de la page. Chacune porte désormais un nom accessible qui nomme sa table, par une prop déclarée de Champ et Commande ; chaque refus est relié à sa commande par id, et après un remède le focus y revient, refus imbriqués et éditeur des priorités compris. Le texte visible ne change pas. Vérifié : node 4590, navigateur 954, parcours vert en vérification. Assisted-by: Claude Opus 5.5 --- src/interface/generation/Priorites.svelte | 24 +-- .../generation/Reglages.navigateur.test.js | 12 +- src/interface/libelles/communs.js | 8 + src/interface/libelles/tables.js | 18 +- src/interface/primitives/Champ.svelte | 10 +- src/interface/primitives/Commande.svelte | 25 ++- src/interface/primitives/Refus.svelte | 54 ++++- .../primitives/primitives.navigateur.test.js | 200 ++++++++++++++++++ src/interface/tables/ChoixRegle.svelte | 9 +- src/interface/tables/ListeTables.svelte | 42 +++- .../tables/Tables.navigateur.test.js | 121 +++++++++++ 11 files changed, 494 insertions(+), 29 deletions(-) diff --git a/src/interface/generation/Priorites.svelte b/src/interface/generation/Priorites.svelte index 1c40578..ef0ca31 100644 --- a/src/interface/generation/Priorites.svelte +++ b/src/interface/generation/Priorites.svelte @@ -19,20 +19,20 @@ // ne nomme une touche, aucun raccourci ne s'y ajoute : Tab les atteint, // Entrée ou Espace les active. Chaque libellé nomme le critère, ce qui // donne à chaque bouton un nom accessible à lui. Monter et descendre - // déplacent le palier du critère, et disent « le palier » quand il en - // porte plusieurs. Un geste est - // une commande changerPriorites {paliers} — une entrée du journal — ; abouti, - // la région role="status" annonce la place du critère, et le focus le suit : - // le même bouton quand le geste reste offert, sinon son premier bouton, - // sinon son nom. « Rétablir l'ordre par défaut » est retablirPriorites, - // sans fenêtre à confirmer (D19). + // déplacent le palier du critère, et disent « le palier » quand il en porte + // plusieurs. Un geste est une commande changerPriorites {paliers} — une + // entrée du journal — ; abouti, la région role="status" annonce la place du + // critère, et le focus le suit : le même bouton quand le geste reste + // offert, sinon son premier bouton, sinon son nom. « Rétablir l'ordre par + // défaut » est retablirPriorites, sans fenêtre à confirmer (D19). // // Le refus d'un geste — lecture, plan bloqué, support rompu — se pose // contre la liste, avec son remède, et décrit le groupe de l'éditeur - // (aria-describedby) ; rien ne s'écrit. Le poids d'un critère d'un palier - // partagé se saisit dans un Champ entier (poids.), écrit à la sortie - // du champ par changerPriorites ; son refus, un poids hors de son domaine - // compris, se pose contre le champ. + // (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.), + // écrit à la sortie du champ par changerPriorites ; son refus, un poids + // hors de son domaine compris, se pose contre le champ. import { getContext, tick } from 'svelte'; import { lireEntier } from '../../application/capacite.js'; import Champ from '../primitives/Champ.svelte'; @@ -109,7 +109,7 @@ const ecrirePoids = (critere) => (poids) => seance.executer('changerPriorites', { paliers: avecPoids(liste, critere, poids) }); -
+
{t(entree.etiquette)}

{t('generation.priorites.note')}

{t(regle ? 'generation.priorites.regle' : 'generation.priorites.parDefaut')}

diff --git a/src/interface/generation/Reglages.navigateur.test.js b/src/interface/generation/Reglages.navigateur.test.js index 5389bf5..1949710 100644 --- a/src/interface/generation/Reglages.navigateur.test.js +++ b/src/interface/generation/Reglages.navigateur.test.js @@ -21,7 +21,7 @@ // // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). -import { afterEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import '../jetons.css'; import { creerControleurGeneration, creerExecuteurDirect } from '../../application/generation.js'; @@ -427,6 +427,16 @@ describe('l’ordre des priorités : l’éditeur (§ 5.7, A11, A12)', () => { expect(annonce(m)).toBe(t('generation.priorites.annonceRetablie')); }); + test('le remède « Modifier » d’un geste refusé rend le focus au groupe de l’éditeur, jamais au corps de la page', async () => { + const m = await demonstration({ ecriture: false }); + await cliquer(m, bouton(m, REDONDANCE, 'joindrePalier')); + const remede = editeur(m).querySelector(':scope > [data-refus="MODE_LECTURE"] [data-commande="modifier"]'); + remede.focus(); + await cliquer(m, remede); + await vi.waitFor(() => expect(editeur(m).querySelector('[data-refus="MODE_LECTURE"]')).toBeNull()); + await vi.waitFor(() => expect(document.activeElement).toBe(editeur(m))); + }); + test('en lecture et sur un plan bloqué : le geste refusé contre la liste, avec son remède ; rien d’écrit', async () => { const m = await demonstration({ ecriture: false }); const ecritures = m.trace.length; diff --git a/src/interface/libelles/communs.js b/src/interface/libelles/communs.js index c981f71..1e94595 100644 --- a/src/interface/libelles/communs.js +++ b/src/interface/libelles/communs.js @@ -11,6 +11,8 @@ // geste du contrat, suivi de . quand ses arguments changent son // libellé (primitives/commande.js, libelleRemede). Un remède qui fait ce que // fait une commande de mode en reprend le texte, par texte. +// communs.nom.remede est le nom accessible d'un remède posé contre un +// contrôle qui porte un nom distinct (Refus, objet). // Le libellé d'une autre clé de cette table. const meme = (cle) => (details, texte) => texte(cle); @@ -31,6 +33,12 @@ export const TABLE = new Map([ ['communs.confirmation.annuler', 'Annuler'], ['communs.confirmation.enCours', 'Opération en cours\u{2026}'], + // --- Le nom accessible d'un remède (§ 14.5) --------------------------------- + // Le remède d'un refus posé contre un contrôle qui porte un nom distinct + // se nomme par son texte, sous ses détails, puis par ce nom, objet : deux + // remèdes de même texte, sous deux tables, ne se confondent pas. + ['communs.nom.remede', ({ cle, details = {}, objet }, texte) => `${texte(cle, details)} (${objet})`], + // --- Les remèdes des gestes du contrat ------------------------------------ ['communs.remede.modifier', meme('communs.commande.modifier')], ['communs.remede.debloquer', meme('communs.commande.debloquer')], diff --git a/src/interface/libelles/tables.js b/src/interface/libelles/tables.js index cac2849..1ace620 100644 --- a/src/interface/libelles/tables.js +++ b/src/interface/libelles/tables.js @@ -28,6 +28,9 @@ const etiquettesCitees = (etiquettes) => etiquettes.map(cite).join(JOINTURE_ETIQ // Ce qu'une règle exige des étiquettes, en mots (§ 4.5). const exigence = (regle) => (regle === 'et' ? 'toutes' : 'au moins une'); +// La portée d'un objectif en mots : la soirée, ou le tour désigné. +const porteeObjectif = (tour) => (tour === null ? 'de la soirée' : `du tour ${tour}`); + export const TABLE = new Map([ // --- Le défaut (§ 6.1) ------------------------------------------------------ ['tables.defaut.etiquette', 'Places par défaut'], @@ -129,7 +132,7 @@ export const TABLE = new Map([ ['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${siege}`], [ 'tables.suppression.objectif', - ({ tour, etiquettes }) => `Objectif ${tour === null ? 'de la soirée' : `du tour ${tour}`} : ${etiquettesCitees(etiquettes)}`, + ({ tour, etiquettes }) => `Objectif ${porteeObjectif(tour)} : ${etiquettesCitees(etiquettes)}`, ], // --- Les tours (§ 2.1 étape 5, § 4.3) ---------------------------------------- @@ -167,6 +170,19 @@ export const TABLE = new Map([ ['tables.objectifs.parTour', ({ nombre }) => (nombre === 0 ? 'Objectifs par tour' : `Objectifs par tour (${nombre})`)], ['tables.objectifs.exporter', 'Exporter les objectifs'], + // --- Les noms accessibles des commandes de chaque table (§ 14.5) ------------- + // Une commande que chaque table répète se nomme par son texte visible, + // composé depuis sa propre clé, puis par sa table : le nom commence + // toujours par ce que l'œil lit, et deux tables ne se confondent pas. + // tables.nom.table : le texte de la clé cle, sous ses détails, puis la + // table. tables.nom.objectif : le texte de la clé cle, puis la portée de + // l'objectif, puis la table — pour ce que l'objectif ne nomme pas déjà. + ['tables.nom.table', ({ cle, details = {}, numero }, texte) => `${texte(cle, details)}, table ${numero}`], + [ + 'tables.nom.objectif', + ({ cle, tour, numero }, texte) => `${texte(cle)} ${porteeObjectif(tour)}, table ${numero}`, + ], + // Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne // correspond plus à la configuration. ['tables.touchee.proposition', ({ id }) => `Proposition n° ${id}, qui ne correspondra plus à la configuration`], diff --git a/src/interface/primitives/Champ.svelte b/src/interface/primitives/Champ.svelte index f66d5cf..8d2db3d 100644 --- a/src/interface/primitives/Champ.svelte +++ b/src/interface/primitives/Champ.svelte @@ -31,13 +31,18 @@ // aria-invalid et aria-describedby vers le message. La sortie du champ ne // retire pas un refus : son remède, qu'un clic atteint, resterait sinon // hors de portée. + // + // nom, déjà traduit, est le nom accessible du champ, en aria-label, quand + // 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. 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 = {}, gestes = {} } = $props(); + let { cle, etiquette, valeur, lire, executer, details = {}, nom = null, gestes = {} } = $props(); const t = getContext('t'); const seance = getContext('seance') ?? null; @@ -190,6 +195,7 @@ type="text" data-champ={cle} value={saisie} + aria-label={nom ?? undefined} aria-invalid={refus === null ? undefined : 'true'} aria-describedby={refus === null ? undefined : idRefus} oninput={surSaisie} @@ -197,7 +203,7 @@ onblur={valider} /> {#if refus !== null} - + {/if} diff --git a/src/interface/primitives/Commande.svelte b/src/interface/primitives/Commande.svelte index bbc394b..b292dab 100644 --- a/src/interface/primitives/Commande.svelte +++ b/src/interface/primitives/Commande.svelte @@ -21,9 +21,15 @@ // // Un refus — une erreur qui porte un code — s'affiche à côté du bouton, // avec son remède, et le bouton reste actif : jamais un bouton grisé muet. - // Une erreur sans code n'est pas un refus et remonte. + // Une erreur sans code n'est pas un refus et remonte. Le bouton désigne + // le refus affiché par aria-describedby, sur l'id que porte le refus : le + // remède qui le retire rend le focus au bouton (Refus). // // data-commande porte nom, ou à défaut le dernier segment de la clé. + // nomAccessible, déjà traduit, nomme le bouton quand son libellé seul ne + // le distingue pas d'un homonyme ; null, le libellé le nomme. Armée, la + // commande se nomme par son libellé de confirmation, celui qu'elle + // montre. Le refus reçoit nomAccessible pour nommer son remède. // confirmer se lit au montage : une commande qui change de confirmation se // remonte. // @@ -37,10 +43,21 @@ import { DELAI_CONFIRMATION_MS, creerMachineCommande, exigerVariante } from './commande.js'; import Refus from './Refus.svelte'; - let { cle, executer, confirmer = null, details = {}, nom = null, gestes = {}, variante = 'secondaire' } = $props(); + let { + cle, + executer, + confirmer = null, + details = {}, + nom = null, + nomAccessible = null, + gestes = {}, + variante = 'secondaire', + } = $props(); const t = getContext('t'); const horloge = getContext('horloge'); + const uid = $props.id(); + const idRefus = `${uid}-refus`; const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) }); let armee = $state(false); @@ -104,11 +121,13 @@ class:discrete={forme === 'discrete'} aria-busy={enCours ? 'true' : undefined} aria-disabled={enCours ? 'true' : undefined} + aria-label={armee ? undefined : (nomAccessible ?? undefined)} + aria-describedby={refus === null ? undefined : idRefus} onkeydown={surTouche} onclick={cliquer}>{libelle} {#if refus !== null} - (refus = null)} {gestes} /> + (refus = null)} objet={nomAccessible} {gestes} /> {/if} diff --git a/src/interface/primitives/Refus.svelte b/src/interface/primitives/Refus.svelte index b62ac47..684d182 100644 --- a/src/interface/primitives/Refus.svelte +++ b/src/interface/primitives/Refus.svelte @@ -17,16 +17,32 @@ // qu'il levait : le porteur ne retire que lui, même remplacé entre-temps. // Un refus de details.autres rend à lever le refus qui le porte. // + // id est l'identifiant du refus, que le contrôle qu'il décrit nomme dans + // son aria-describedby. Un refus de details.autres n'a pas d'id : il + // reçoit idPorteur, celui du refus qui le porte, et décrit le même + // contrôle. objet, déjà traduit, est le nom distinct de ce contrôle, null + // quand son étiquette suffit : le remède se nomme alors par son texte puis + // par objet (communs.nom.remede), et deux remèdes de même texte posés + // contre deux homonymes ne se confondent pas. + // + // Le focus ne tombe pas sur le corps de la page : un remède lancé depuis + // le refus — le focus en lui à son départ — qui retire ce refus retire + // aussi le bouton qui avait le focus. Après le rendu, un focus tombé sur + // le corps revient au contrôle décrit : l'élément dont aria-describedby + // nomme id, ou idPorteur. Un refus sans l'un ni l'autre ne décrit aucun + // contrôle, et ne rend pas le focus. Un remède qui a porté le focus + // ailleurs l'y laisse ; un contrôle sorti du document ne le reprend pas. + // // Le refus se peint sur son propre fond, --couleur-refus-fond, bordé de son // filet et d'un trait de gauche de la teinte du refus, une icône devant son // texte : il se lit de même sur la page, une carte ou une fenêtre. - import { getContext } from 'svelte'; + import { getContext, tick } from 'svelte'; import { actionRemede } from './commande.js'; import Commande from './Commande.svelte'; import Icone from './Icone.svelte'; import Refus from './Refus.svelte'; - let { refus, lever = null, gestes = {}, id = undefined } = $props(); + let { refus, lever = null, gestes = {}, id = undefined, idPorteur = undefined, objet = null } = $props(); const t = getContext('t'); const seance = getContext('seance'); @@ -36,10 +52,39 @@ const action = $derived(actionRemede(refus.details?.remede ?? null, { seance, ui, gestes })); const autres = $derived(refus.details?.autres ?? []); + // L'élément du refus : il dit si le focus est en lui. + let racine = $state.raw(null); + + // L'id que nomme le contrôle décrit : celui du refus qui porte ce refus + // de details.autres, sinon le sien ; undefined s'il n'en a aucun. + const idDecrit = $derived(idPorteur ?? id); + + // Le nom du remède : son texte puis objet, ou null, son texte seul. + const nomRemede = $derived( + action === null || objet === null ? null : t('communs.nom.remede', { cle: action.cle, details: action.details, objet }), + ); + + // Le contrôle décrit : l'élément dont aria-describedby nomme idDecrit, + // parmi les noms qu'il énumère ; null s'il n'y en a aucun. + function controleDecrit() { + if (idDecrit === undefined) return null; + return document.querySelector(`[aria-describedby~="${CSS.escape(idDecrit)}"]`); + } + + // Rend le focus au contrôle, après le rendu, s'il est tombé sur le corps + // de la page et que le contrôle est encore dans le document. + async function rendreFocus(controle) { + await tick(); + const actif = document.activeElement; + if ((actif === null || actif === document.body) && controle.isConnected) controle.focus(); + } + async function executerRemede() { const affiche = refus; + const controle = racine?.contains(document.activeElement) ? controleDecrit() : null; const rendu = await action.executer(); if (rendu !== null) lever?.(affiche); + if (controle !== null) await rendreFocus(controle); return rendu; } @@ -47,7 +92,7 @@ const leverPorteur = $derived(lever === null ? null : () => lever(refus)); -
+

{message}

{#if action !== null} {#key action} @@ -55,6 +100,7 @@ cle={action.cle} details={action.details} nom={action.nom} + nomAccessible={nomRemede} confirmer={action.confirmer} executer={executerRemede} {gestes} @@ -62,7 +108,7 @@ {/key} {/if} {#each autres as autre, rang (rang)} - + {/each}
diff --git a/src/interface/primitives/primitives.navigateur.test.js b/src/interface/primitives/primitives.navigateur.test.js index db6860e..4aff6bb 100644 --- a/src/interface/primitives/primitives.navigateur.test.js +++ b/src/interface/primitives/primitives.navigateur.test.js @@ -484,6 +484,152 @@ describe('champ : le refus là où il a lieu (§ 2.1, § 5.9)', () => { expect(entree.value).toBe(`${NOM}x`); }); + test('le remède qui retire son refus rend le focus au champ qui le portait, jamais au corps de la page', async () => { + const montage = await preparer({ mode: 'lecture' }); + const { cible, entree, message } = await refusAuPremierCaractere(montage, 'Modifier'); + await cliquer(message.querySelector('button')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull()); + await vi.waitFor(() => expect(document.activeElement).toBe(entree)); + }); + + test('un remède qui porte lui-même le focus ailleurs l’y laisse : le refus parti, le champ ne le reprend pas', async () => { + const montage = await preparer(); + const ailleurs = document.createElement('button'); + ailleurs.type = 'button'; + document.body.append(ailleurs); + try { + // Un seul objet de refus : la sortie du champ, au clic du remède, + // relit la saisie, et le refus relu reste celui que le remède lève. + const refusLu = { code: 'NOM_REQUIS', details: { remede: { geste: 'afficherTour', tour: 2 } } }; + const props = { + cle: 'nom', + etiquette: 'communs.commande.modifier', + valeur: NOM, + lire: () => ({ refus: refusLu }), + executer: () => undefined, + gestes: { + afficherTour: () => { + ailleurs.focus(); + return true; + }, + }, + }; + const cible = monter(Champ, props, montage.contexte); + const entree = cible.querySelector('input[data-champ="nom"]'); + await cliquer(entree); + await taper('x'); + await touche('Enter'); + await tick(); + const message = cible.querySelector('[data-refus="NOM_REQUIS"]'); + expect(message).not.toBeNull(); + await cliquer(message.querySelector('button')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull()); + await tick(); + await tick(); + expect(document.activeElement).toBe(ailleurs); + } finally { + ailleurs.remove(); + } + }); + + test('le remède d’un refus de details.autres retire le refus qui le porte : le focus revient au champ que celui-ci décrit', async () => { + const montage = await preparer(); + // Un seul objet de refus, que la sortie du champ relit à l'identique. + const refusLu = { + code: 'NOM_REQUIS', + details: { remede: null, autres: [{ code: 'NOM_REQUIS', details: { remede: { geste: 'afficherTour', tour: 2 } } }] }, + }; + const props = { + cle: 'nom', + etiquette: 'communs.commande.modifier', + valeur: NOM, + lire: () => ({ refus: refusLu }), + executer: () => undefined, + gestes: { afficherTour: () => true }, + }; + const cible = monter(Champ, props, montage.contexte); + const entree = cible.querySelector('input[data-champ="nom"]'); + await cliquer(entree); + await taper('x'); + await touche('Enter'); + await tick(); + const porteur = cible.querySelector('[data-refus="NOM_REQUIS"]'); + expect(entree.getAttribute('aria-describedby')).toBe(porteur.id); + const interne = porteur.querySelector('[data-refus="NOM_REQUIS"]'); + expect(interne).not.toBeNull(); + expect(interne.id).toBe(''); + await cliquer(interne.querySelector('button')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull()); + await vi.waitFor(() => expect(document.activeElement).toBe(entree)); + }); + + test('le remède d’une commande de remède : le refus qu’elle pose la désigne, et son remède rend le focus à cette commande, non au champ', async () => { + const montage = await preparer(); + // Un seul objet de refus, que la sortie du champ relit à l'identique ; + // son remède échoue sur un refus qui offre le sien. + const refusLu = { code: 'NOM_REQUIS', details: { remede: { geste: 'afficherTour', tour: 2 } } }; + const props = { + cle: 'nom', + etiquette: 'communs.commande.modifier', + valeur: NOM, + lire: () => ({ refus: refusLu }), + executer: () => undefined, + gestes: { + afficherTour: () => { + throw Object.assign(new Error('NOM_REQUIS'), { code: 'NOM_REQUIS', details: { remede: { geste: 'choisirPlace' } } }); + }, + choisirPlace: () => true, + }, + }; + const cible = monter(Champ, props, montage.contexte); + const entree = cible.querySelector('input[data-champ="nom"]'); + await cliquer(entree); + await taper('x'); + await touche('Enter'); + await tick(); + const porteur = cible.querySelector('[data-refus="NOM_REQUIS"]'); + const remede = porteur.querySelector('button[data-commande="afficherTour"]'); + await cliquer(remede); + await vi.waitFor(() => expect(porteur.querySelector('[data-refus]')).not.toBeNull()); + const interne = porteur.querySelector('[data-refus]'); + expect(interne.id).not.toBe(''); + expect(remede.getAttribute('aria-describedby')).toBe(interne.id); + await cliquer(interne.querySelector('button[data-commande="choisirPlace"]')); + await vi.waitFor(() => expect(porteur.querySelector('[data-refus]')).toBeNull()); + await vi.waitFor(() => expect(document.activeElement).toBe(remede)); + expect(remede.getAttribute('aria-describedby')).toBeNull(); + expect(porteur.isConnected).toBe(true); + }); + + test('nom : le nom accessible du champ, l’étiquette visible inchangée ; le remède de son refus se nomme d’après lui, et sans nom il garde son texte', async () => { + const montage = await preparer({ mode: 'lecture' }); + const nom = 'Nom de famille, fiche 3'; + const props = (cle) => ({ + cle, + etiquette: 'communs.commande.modifier', + valeur: NOM, + lire: (texte) => ({ valeur: texte }), + executer: () => undefined, + }); + const avec = monter(Champ, { ...props('nom'), nom }, montage.contexte); + const sans = monter(Champ, props('autre'), montage.contexte); + const entree = avec.querySelector('input'); + expect(entree).toHaveAccessibleName(nom); + expect(avec.querySelector('label').textContent).toBe(t('communs.commande.modifier')); + expect(sans.querySelector('input')).toHaveAccessibleName(t('communs.commande.modifier')); + expect(sans.querySelector('input').getAttribute('aria-label')).toBeNull(); + for (const cible of [avec, sans]) { + await cliquer(cible.querySelector('input')); + await taper('x'); + } + const remedeAvec = avec.querySelector('[data-refus="MODE_LECTURE"] button'); + const remedeSans = sans.querySelector('[data-refus="MODE_LECTURE"] button'); + expect(remedeAvec.textContent.trim()).toBe(t('communs.remede.modifier')); + expect(remedeAvec).toHaveAccessibleName(t('communs.nom.remede', { cle: 'communs.remede.modifier', objet: nom })); + expect(remedeSans).toHaveAccessibleName(t('communs.remede.modifier')); + expect(remedeSans.getAttribute('aria-label')).toBeNull(); + }); + test('le refus de la séance suit la séance : passée en écriture par un autre chemin que son remède, le champ ne garde ni le refus ni aria-invalid', async () => { const montage = await preparer({ mode: 'lecture' }); const { cible, entree } = await refusAuPremierCaractere(montage, 'Modifier'); @@ -1092,6 +1238,60 @@ describe('commande (§ 2.1)', () => { expect(bouton.getAttribute('aria-busy')).toBeNull(); }); + test('refusée, son bouton désigne le refus par aria-describedby ; le remède qui le retire rend le focus au bouton', async () => { + const montage = await preparer({ mode: 'lecture' }); + const cible = monter( + Commande, + { + cle: 'communs.remede.ajouterParticipant', + executer: () => montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), + }, + montage.contexte, + ); + const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]'); + expect(bouton.getAttribute('aria-describedby')).toBeNull(); + await cliquer(bouton); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).not.toBeNull()); + const message = cible.querySelector('[data-refus]'); + expect(message.id).not.toBe(''); + expect(bouton.getAttribute('aria-describedby')).toBe(message.id); + await cliquer(message.querySelector('button[data-commande="modifier"]')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).toBeNull()); + expect(bouton.getAttribute('aria-describedby')).toBeNull(); + await vi.waitFor(() => expect(document.activeElement).toBe(bouton)); + }); + + test('nomAccessible : le nom du bouton, son texte inchangé ; armée, le bouton se nomme par sa confirmation ; le remède de son refus se nomme d’après lui', async () => { + const montage = await preparer({ mode: 'lecture' }); + const nom = 'Débloquer, plan 4'; + const armable = monter( + Commande, + { cle: 'communs.commande.debloquer', confirmer: 'communs.commande.confirmerDeblocage', executer: () => undefined, nomAccessible: nom }, + montage.contexte, + ).querySelector('button'); + expect(armable.textContent.trim()).toBe(t('communs.commande.debloquer')); + expect(armable).toHaveAccessibleName(nom); + await cliquer(armable); + expect(armable.textContent.trim()).toBe(t('communs.commande.confirmerDeblocage')); + expect(armable).toHaveAccessibleName(t('communs.commande.confirmerDeblocage')); + + const objet = 'Ajouter la personne, liste 2'; + const cible = monter( + Commande, + { + cle: 'communs.remede.ajouterParticipant', + executer: () => montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), + nomAccessible: objet, + }, + montage.contexte, + ); + await cliquer(cible.querySelector('button[data-commande="ajouterParticipant"]')); + await vi.waitFor(() => expect(cible.querySelector('[data-refus]')).not.toBeNull()); + const remede = cible.querySelector('[data-refus] button'); + expect(remede.textContent.trim()).toBe(t('communs.remede.modifier')); + expect(remede).toHaveAccessibleName(t('communs.nom.remede', { cle: 'communs.remede.modifier', objet })); + }); + test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => { const montage = await preparer(); const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte); diff --git a/src/interface/tables/ChoixRegle.svelte b/src/interface/tables/ChoixRegle.svelte index 0de0b8e..7c10cdc 100644 --- a/src/interface/tables/ChoixRegle.svelte +++ b/src/interface/tables/ChoixRegle.svelte @@ -8,15 +8,18 @@ // le refus. options est [{valeur, libelle}], le libellé déjà traduit ; // champ nomme sa désignation data-champ. idRefus est l'identifiant du // refus affiché, null sans refus : il pose aria-invalid et le lien - // aria-describedby vers le message. surChoix reçoit l'événement change, - // dont currentTarget est le sélecteur. - let { id, champ, valeur, options, idRefus = null, surChoix } = $props(); + // aria-describedby vers le message. nom, déjà traduit, est le nom + // accessible du sélecteur quand son étiquette seule ne le distingue pas + // d'un autre ; null, l'étiquette le nomme. surChoix reçoit l'événement + // change, dont currentTarget est le sélecteur. + let { id, champ, valeur, options, idRefus = null, nom = null, surChoix } = $props();