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();