Commit c87a1392 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Quatorze étiquettes qui ne nommaient rien

Un « label » sans « htmlFor » et qui n'enveloppe aucun contrôle ne porte aucune
association. Le lecteur d'écran lit son texte comme une phrase en l'air, puis
annonce les commandes qui suivent sans dire de quoi elles relèvent : « Gravité »,
puis quatre boutons anonymes.

Le relevé en annonçait onze. En les cherchant une par une, il y en avait
quatorze — treize noms de groupe et une étiquette de champ.

── Treize noms de groupe

Un « label » ne peut nommer ni un jeu de boutons, ni un tableau : l'élément
n'accepte qu'un contrôle de formulaire. Le nom vit désormais dans un « span » et
le conteneur porte « role=group » avec « aria-label ».

Pas d'« aria-labelledby » ici, à dessein : il aurait fallu un identifiant unique
par groupe, donc un « useId » dans dix composants, pour un résultat identique à
l'oreille. « aria-label » reçoit le même appel de traduction que le texte
visible, sur la ligne d'à côté — les deux ne peuvent pas diverger sans qu'on le
voie.

Déclenchement (gravité, cellules à ouvrir), Membres (par quel canal), Tableaux
(modèle), Continuité (criticité), Recensement (à qui), Destinataires (étiquettes,
deux fois), Annuaire, Fiches réflexes (types de crise), Mobilisations prêtes
(qui est mobilisé, réponses proposées), Modèles (ciblage habituel).

── Une étiquette de champ posée à côté du champ

Celle de l'import de destinataires coiffait un « textarea » sans le nommer.
Rattachée par « htmlFor ». Vérifié dans le DOM : « label.control » désigne bien
le champ.

── Le style ne change pas d'un pixel

La règle de « label » accueille « .nom-groupe » : mêmes taille, graisse et
couleur. Vérifié à l'écran sur trois écrans — « Gravité » se lit exactement
comme « Type » au-dessus.

── La garantie

Elle interdit ce que la campagne a trouvé : un « label » sans « htmlFor » et
sans « input », « select » ou « textarea » avant sa fermeture. Elle balaie les
sources de l'interface, pas une liste d'écrans — c'est ce qui manquait aux
contrôles de titre de page, qui portaient sur six fichiers nommés à la main.

1177 garanties au vert.

── Ce que la vérification a fait apparaître, hors de ce lot

Le sélecteur de gravité du déclenchement affiche encore les codes bruts :
« vigilance », « mineure », « majeure », « critique », en minuscules, alors que
tout autre écran dit « Majeure ». Il boucle sur un tableau littéral avec « g »
pour variable, et la garantie posée au lot précédent ne cherche que « {gravite} »
et « {statut} » : elle ne le voit pas. C'est un écart, non corrigé ici — il
n'était pas dans la demande.

Co-Authored-By: Claude (RCA)
parent d1d2b8ff
Loading
Loading
Loading
Loading
+23 −0
Changes for apps/api/src/verification/interface.ts: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -393,6 +393,29 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-21", "aucune énumération n'est affichée sans passer par ses libellés",
    brutes.length === 0, brutes.slice(0, 3).join(" ; "));

  // ── Une étiquette nomme un champ, ou elle ne nomme rien (EX-19) ──────────
  //
  // Treize « label » coiffaient un jeu de boutons, un tableau, ou un champ posé
  // à côté d'eux. Sans « htmlFor » et sans contrôle enveloppé, l'élément ne
  // porte aucune association : le lecteur d'écran lisait le texte comme une
  // phrase en l'air, puis annonçait des commandes anonymes. Le nom d'un jeu de
  // commandes se dit avec « role=group » et « aria-label » ; celui d'un champ,
  // avec « htmlFor » ou en l'enveloppant.
  const orphelines: string[] = [];
  for (const f of fichiers) {
    if (!f.endsWith(".tsx")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/<label(?![\w-])([^>]*)>/g)) {
      if (m[1]!.includes("htmlFor")) continue;
      const fin = texte.indexOf("</label>", m.index!);
      const dedans = fin === -1 ? "" : texte.slice(m.index! + m[0]!.length, fin);
      if (/<(?:input|select|textarea)\b/.test(dedans)) continue;
      orphelines.push(`${f.slice(web.length)}:${texte.slice(0, m.index!).split("\n").length}`);
    }
  }
  r.verifie("EX-19", "aucune étiquette n'est posée sans le champ qu'elle nomme",
    orphelines.length === 0, orphelines.slice(0, 4).join(", "));

  r.verifie("EX-21", "une clé absente retombe sur le français, pas sur un identifiant",
    noyau.includes("EN[fr] ?? fr"));
  r.verifie("EX-21", "la langue du document suit la langue choisie",
+2 −2
Changes for apps/web/src/ecrans/Continuite.tsx: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -167,8 +167,8 @@ export function Continuite({ criseId, lectureSeule }: {
            </div>

            <div className="champ">
              <label>{t("Criticité")}</label>
              <div className="ligne" style={{ gap: 6 }}>
              <span className="nom-groupe">{t("Criticité")}</span>
              <div role="group" aria-label={t("Criticité")} className="ligne" style={{ gap: 6 }}>
                {(["vitale", "importante", "secondaire"] as const).map((c) => (
                  <button key={c}
                    className={(fiche.criticite ?? "importante") === c ? "accent petit" : "petit"}
+4 −4
Changes for apps/web/src/ecrans/Declenchement.tsx: 4 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -125,8 +125,8 @@ export function Declenchement() {
          </div>

          <div className="champ">
            <label>{t("Gravité")}</label>
            <div className="ligne" style={{ flexWrap: "wrap" }}>
            <span className="nom-groupe">{t("Gravité")}</span>
            <div role="group" aria-label={t("Gravité")} className="ligne" style={{ flexWrap: "wrap" }}>
              {["vigilance", "mineure", "majeure", "critique"].map((g) => (
                <button
                  key={g} type="button"
@@ -141,8 +141,8 @@ export function Declenchement() {
          </div>

          <div className="champ">
            <label>{t("Cellules à ouvrir")}</label>
            <div className="ligne" style={{ flexWrap: "wrap" }}>
            <span className="nom-groupe">{t("Cellules à ouvrir")}</span>
            <div role="group" aria-label={t("Cellules à ouvrir")} className="ligne" style={{ flexWrap: "wrap" }}>
              {cellulesDuDossier.map((c) => (
                <button
                  key={c.code} type="button"
+2 −2
Changes for apps/web/src/ecrans/Membres.tsx: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -260,8 +260,8 @@ export function Membres(
                  l'accepter : Kastell sert le jour où le SI du client est
                  tombé, et convier dépendait du relais de ce même SI. */}
              <div className="champ" style={{ marginTop: 12, marginBottom: 0 }}>
                <label>{t("Par quel canal")}</label>
                <div className="ligne" style={{ gap: 14, flexWrap: "wrap" }}>
                <span className="nom-groupe">{t("Par quel canal")}</span>
                <div role="group" aria-label={t("Par quel canal")} className="ligne" style={{ gap: 14, flexWrap: "wrap" }}>
                  <label className="ligne" style={{ gap: 6, cursor: "pointer" }}>
                    <input type="checkbox" checked={parCourriel} style={{ width: "auto" }}
                      onChange={(e) => definirParCourriel(e.target.checked)} />
+2 −2
Changes for apps/web/src/ecrans/Recensement.tsx: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -191,8 +191,8 @@ export function Recensement({ criseId, organisationId }: {
                         + "sécurité.")}
            onChange={(ev) => definirMessage(ev.target.value)} />

          <label style={{ marginTop: 8 }}>{t("À qui")}</label>
          <div className="ligne" style={{ flexWrap: "wrap", gap: 6 }}>
          <span className="nom-groupe" style={{ marginTop: 8 }}>{t("À qui")}</span>
          <div role="group" aria-label={t("À qui")} className="ligne" style={{ flexWrap: "wrap", gap: 6 }}>
            {(vocabulaire.data?.etiquettes ?? []).map((x) => (
              <button key={x.nom} className={et.includes(x.nom) ? "accent petit" : "petit"}
                onClick={() => definirEt(et.includes(x.nom)
Loading