[IMP] screens a11y: table-named controls, focus back after a remedy

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
This commit is contained in:
Mathieu Benoit 2026-10-11 19:04:09 -04:00
parent 91879ea4a5
commit 59f847a3fe
11 changed files with 494 additions and 29 deletions

View file

@ -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.<nom>), é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.<nom>),
// é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) });
</script>
<fieldset class="priorites" aria-describedby={refus === null ? undefined : idRefus}>
<fieldset class="priorites" tabindex="-1" aria-describedby={refus === null ? undefined : idRefus}>
<legend>{t(entree.etiquette)}</legend>
<p class="note">{t('generation.priorites.note')}</p>
<p class="note">{t(regle ? 'generation.priorites.regle' : 'generation.priorites.parDefaut')}</p>

View file

@ -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;

View file

@ -11,6 +11,8 @@
// geste du contrat, suivi de .<variante> 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')],

View file

@ -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`],

View file

@ -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}
<Refus id={idRefus} {refus} {lever} {gestes} />
<Refus id={idRefus} {refus} {lever} objet={nom} {gestes} />
{/if}
</div>

View file

@ -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}</button
>
{#if refus !== null}
<Refus {refus} lever={() => (refus = null)} {gestes} />
<Refus id={idRefus} {refus} lever={() => (refus = null)} objet={nomAccessible} {gestes} />
{/if}
</div>

View file

@ -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));
</script>
<div class="refus" role="status" data-refus={refus.code} {id}>
<div class="refus" role="status" data-refus={refus.code} {id} bind:this={racine}>
<p><span class="signe"><Icone nom="interdit" /></span>{message}</p>
{#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)}
<Refus refus={autre} lever={leverPorteur} {gestes} />
<Refus refus={autre} lever={leverPorteur} idPorteur={idDecrit} {objet} {gestes} />
{/each}
</div>

View file

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

View file

@ -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();
</script>
<select
{id}
data-champ={champ}
value={valeur}
aria-label={nom ?? undefined}
aria-invalid={idRefus === null ? undefined : 'true'}
aria-describedby={idRefus ?? undefined}
onchange={surChoix}

View file

@ -45,6 +45,16 @@
// aria-describedby et montre alors la règle de la charge, ou le choix
// tenu d'un objectif neuf.
//
// Les commandes que chaque table répète — ses places, « Réaligner »,
// « Supprimer », le bouton qui déplie, et pour chaque objectif son champ,
// le choix de sa règle et « Retirer l'objectif » — portent un nom
// accessible qui nomme leur table, et l'objectif pour ce qui ne le dit
// pas déjà (tables.nom.table, tables.nom.objectif) : leur texte visible,
// puis ce qui les distingue, par la prop nom de Champ et de ChoixRegle,
// nomAccessible de Commande. Le remède d'un refus posé contre l'une
// d'elles se nomme par son texte puis par ce nom (Refus, objet). Le texte
// affiché ne change pas.
//
// « Exporter les objectifs » (§ 10.4) remet les octets d'exporterObjectifs
// à facade.enregistrerSous, sous le nom ‹base›-objectifs.csv
// (nomExportObjectifs), comme l'export des participants : sortir n'est pas
@ -241,6 +251,12 @@
// la met en forme et la refuse.
const lireTexte = (texte) => ({ valeur: texte });
// Les noms d'une commande de la table numero : son texte, sous ses
// détails, puis la table ; et pour un objectif, son texte, la portée de
// vue, puis la table.
const nomDeTable = (numero, cle, details = {}) => t('tables.nom.table', { cle, details, numero });
const nomDObjectif = (numero, cle, vue) => t('tables.nom.objectif', { cle, tour: vue.tour, numero });
// Une frappe ou Échap dans le champ de la table id retire le complément
// de son refus : le champ retire le refus au même instant.
const suivreSaisie = (id) => (element) => {
@ -281,6 +297,7 @@
cle={`objectif-${vue.cle}`}
etiquette={vue.tour === null ? 'tables.objectif.soiree' : 'tables.objectif.tour'}
details={{ tour: vue.tour }}
nom={nomDeTable(table.numero, vue.tour === null ? 'tables.objectif.soiree' : 'tables.objectif.tour', { tour: vue.tour })}
valeur={vue.texte}
lire={lireTexte}
executer={(texte) => poser(table, vue, texte)}
@ -294,16 +311,27 @@
valeur={valeurRegle(vue)}
{options}
idRefus={refusDuChoix === null ? null : idRefusRegle}
nom={nomDObjectif(table.numero, 'tables.objectif.regle', vue)}
surChoix={(evenement) => choisirRegle(evenement, table, vue)}
/>
</div>
{#if vue.existe}
<span class="etat-regle"><Pastille sorte="statut" valeur={vue.etat.ton} libelle={t(vue.etat.cle)} /></span>
<Commande cle="tables.objectif.retirer" nom="retirerObjectif" executer={() => retirer(table, vue)} />
<Commande
cle="tables.objectif.retirer"
nom="retirerObjectif"
nomAccessible={nomDObjectif(table.numero, 'tables.objectif.retirer', vue)}
executer={() => retirer(table, vue)}
/>
{/if}
</div>
{#if refusDuChoix !== null}
<Refus id={idRefusRegle} refus={refusDuChoix} lever={() => delete refusRegle[vue.cle]} />
<Refus
id={idRefusRegle}
refus={refusDuChoix}
lever={() => delete refusRegle[vue.cle]}
objet={nomDObjectif(table.numero, 'tables.objectif.regle', vue)}
/>
{/if}
{#each [...retenuesDe(vue.cle), ...vue.avis] as avis, rang (rang)}
<div class="message avis" data-avertissement={avis.code} role="status">
@ -346,6 +374,7 @@
<Champ
cle={`sieges-${table.id}`}
etiquette="tables.liste.places"
nom={nomDeTable(table.numero, 'tables.liste.places')}
valeur={etat.capacite}
lire={lireEntier}
executer={(sieges) => executerSurTable(table.id, 'changerPlacesTable', { id: table.id, sieges, origine: 'champ' })}
@ -356,10 +385,16 @@
<Commande
cle="tables.liste.realigner"
nom="realignerTable"
nomAccessible={nomDeTable(table.numero, 'tables.liste.realigner')}
executer={() => executerSurTable(table.id, 'realignerTable', { id: table.id })}
/>
{/if}
<Commande cle="tables.liste.supprimer" nom="supprimerTable" executer={() => supprimer(table)} />
<Commande
cle="tables.liste.supprimer"
nom="supprimerTable"
nomAccessible={nomDeTable(table.numero, 'tables.liste.supprimer')}
executer={() => supprimer(table)}
/>
</div>
{#if complement !== null && complement.retires.length > 0}
<div class="message refus">
@ -394,6 +429,7 @@
type="button"
class="deplier"
data-commande="basculerObjectifsTours"
aria-label={nomDeTable(table.numero, 'tables.objectifs.parTour', { nombre: vues.parTour })}
aria-expanded={ouverte ? 'true' : 'false'}
aria-controls={ouverte ? idTours : undefined}
onclick={() => (ouvertes = basculerTable(ouvertes, table.id))}

View file

@ -1103,3 +1103,124 @@ describe('les objectifs en lecture (§ 8.4)', () => {
expect(ecritures(m)).toBe(avant);
});
});
// --- Les noms accessibles et le focus (§ 14.5) ------------------------------------
// Le nom accessible d'un contrôle, calculé comme le font les technologies
// d'assistance pour les formes que la section emploie : aria-labelledby,
// puis aria-label, puis l'étiquette du champ, puis le texte du contrôle.
// Chaque nom relevé est confronté à toHaveAccessibleName, qui suit
// l'algorithme complet : le calcul ne s'écarte jamais de lui.
function nomAccessible(element) {
const texte = (noeud) => noeud.textContent.replace(/\s+/g, ' ').trim();
const designes = element.getAttribute('aria-labelledby');
if (designes !== null) return designes.split(/\s+/).map((id) => texte(document.getElementById(id))).join(' ');
const etiquette = element.getAttribute('aria-label');
if (etiquette !== null) return etiquette.trim();
if (element.labels !== undefined && element.labels.length > 0) return [...element.labels].map(texte).join(' ');
return texte(element);
}
// Le texte que l'œil lit pour un contrôle : son étiquette, ou son texte.
const texteVu = (element) =>
(element.labels !== undefined && element.labels.length > 0 ? element.labels[0] : element).textContent.replace(/\s+/g, ' ').trim();
describe('les commandes répétées sous chaque table se distinguent (§ 14.5)', () => {
test('deux tables dépliées, en lecture, chacune avec cinq refus : chaque contrôle, remèdes compris, nomme sa table après son texte visible, et les noms sont deux à deux distincts', async () => {
const donnees = chargeObjectifs({ tables: [{ id: 1, sieges: 6 }, { id: 2, sieges: 6 }] });
for (const table of donnees.tables) {
table.objectifs = [
{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] },
{ portee: 'tour', tour: 2, regle: 'et', etiquettes: ['cuisine'] },
];
}
const m = await preparer(donnees);
for (const id of [1, 2]) await cliquer(m, commande(ligne(m, id), 'basculerObjectifsTours'));
// En lecture, chaque table refuse ses places, le champ et la règle de
// son objectif de la soirée, « Supprimer » et « Retirer l'objectif » :
// cinq refus par table, chacun avec son remède « Modifier ».
await m.seance.passerEnLecture();
await attendre(m);
for (const id of [1, 2]) {
await saisir(m, champTable(m, id), '7');
await saisir(m, champObjectif(m, String(id)), 'x');
await choisirAuClavier(m, choixRegle(m, String(id)), 'et');
await cliquer(m, commande(ligne(m, id), 'supprimerTable'));
await cliquer(m, commande(zoneObjectif(m, String(id)), 'retirerObjectif'));
}
const remedes = [1, 2].flatMap((id) => [...ligne(m, id).querySelectorAll('[data-refus="MODE_LECTURE"] button')]);
expect(remedes).toHaveLength(2 * 5);
expect(new Set(remedes.map(texteVu))).toEqual(new Set([t('communs.remede.modifier')]));
const controles = [1, 2].flatMap((id) => [...ligne(m, id).querySelectorAll('input, select, button')]);
// Par table : places, réaligner, supprimer, le bouton qui déplie, et,
// pour la soirée et les trois tours, un champ et un choix de règle,
// plus « Retirer l'objectif » pour les deux objectifs posés ; puis les
// cinq remèdes.
expect(controles).toHaveLength(2 * (4 + 4 * 2 + 2 + 5));
const noms = controles.map(nomAccessible);
for (const [rang, element] of controles.entries()) {
expect(element).toHaveAccessibleName(noms[rang]);
expect(noms[rang].startsWith(texteVu(element)), `« ${noms[rang]} » commence par « ${texteVu(element)} »`).toBe(true);
}
const doublons = noms.filter((nom, rang) => noms.indexOf(nom) !== rang);
expect(doublons).toEqual([]);
// Le texte visible ne change pas ; le nom ajoute la portée et la table.
const regle = choixRegle(m, '2-2');
expect(texteVu(regle)).toBe(t('tables.objectif.regle'));
expect(nomAccessible(regle)).toBe(t('tables.nom.objectif', { cle: 'tables.objectif.regle', tour: 2, numero: 2 }));
expect(nomAccessible(champObjectif(m, '1'))).toBe(t('tables.nom.table', { cle: 'tables.objectif.soiree', numero: 1 }));
expect(nomAccessible(commande(ligne(m, 2), 'supprimerTable'))).toBe(t('tables.nom.table', { cle: 'tables.liste.supprimer', numero: 2 }));
// Le remède nomme, après son texte, le contrôle dont il lève le refus.
const remedePlaces = ligne(m, 1).querySelector('.saisie [data-refus] button');
expect(nomAccessible(remedePlaces)).toBe(
t('communs.nom.remede', { cle: 'communs.remede.modifier', objet: t('tables.nom.table', { cle: 'tables.liste.places', numero: 1 }) }),
);
});
});
describe('le focus après un remède qui retire son refus (§ 14.5)', () => {
test('vider l’objectif posé, puis son remède « Retirer l’objectif » : le refus part, le focus revient au champ, jamais au corps de la page', async () => {
const donnees = chargeObjectifs();
donnees.tables[1].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['cuisine'] }];
const m = await preparer(donnees);
await saisir(m, champObjectif(m, '2'), '');
const refus = zoneObjectif(m, '2').querySelector('[data-refus="OBJECTIF_SANS_ETIQUETTE"]');
expect(refus).not.toBeNull();
await cliquer(m, commande(refus, 'retirerObjectif'));
expect(objectifs(m, 2)).toBeUndefined();
await vi.waitFor(() => expect(zoneObjectif(m, '2').querySelector('[data-refus]')).toBeNull());
await vi.waitFor(() => expect(document.activeElement).toBe(champObjectif(m, '2')));
});
test('en lecture, le choix d’une règle refusé, puis « Modifier » : le focus revient au choix qui portait le refus, celui de l’objectif comme celui du défaut', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
for (const choix of [(m) => choixRegle(m, '1'), (m) => regleDefaut(m)]) {
const m = await preparer(donnees, { mode: 'lecture' });
await choisirAuClavier(m, choix(m), 'et');
const refus = document.getElementById(choix(m).getAttribute('aria-describedby'));
expect(refus.dataset.refus).toBe('MODE_LECTURE');
await cliquer(m, refus.querySelector('button'));
await vi.waitFor(() => expect(m.seance.etat().mode).toBe('ecriture'));
await vi.waitFor(() => expect(choix(m).getAttribute('aria-describedby')).toBeNull());
await vi.waitFor(() => expect(document.activeElement).toBe(choix(m)));
}
});
test('en lecture, une commande de table refusée — « Supprimer », « Retirer l’objectif » —, puis « Modifier » : le refus part, le focus revient au bouton de la commande', async () => {
const donnees = chargeObjectifs();
donnees.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
for (const bouton of [(m) => commande(ligne(m, 1), 'supprimerTable'), (m) => commande(zoneObjectif(m, '1'), 'retirerObjectif')]) {
const m = await preparer(donnees, { mode: 'lecture' });
await cliquer(m, bouton(m));
const refus = bouton(m).parentElement.querySelector('[data-refus="MODE_LECTURE"]');
expect(refus).not.toBeNull();
await cliquer(m, refus.querySelector('button'));
await vi.waitFor(() => expect(m.seance.etat().mode).toBe('ecriture'));
await vi.waitFor(() => expect(bouton(m).parentElement.querySelector('[data-refus]')).toBeNull());
await vi.waitFor(() => expect(document.activeElement).toBe(bouton(m)));
}
});
});