Commit 9fc11b6f authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Quatre barres d'onglets où la couleur portait seule l'information

Un « div » et des « button » stylés selon l'onglet actif, écrits quatre fois à
la main. À l'écran cela se voit ; pour le reste, non. Aucun « tablist », aucun
« aria-selected » : un lecteur d'écran annonçait des boutons ordinaires sans
dire qu'ils forment un jeu, ni lequel est ouvert. Et le dossier de préparation
demandait quinze appuis sur « Tab » pour être traversé.

── Ma liste des « trois » était fausse dans les deux sens

Le tableau des actions n'est pas une barre d'onglets : ce sont deux bascules
indépendantes, « Par cellule » et « Voir les abandonnées ». Et il en existait
deux que je n'avais pas citées — la console d'instance et le dossier de
préparation, celui-là même qui souffrait le plus du défaut avec ses quinze
onglets.

Cinquième fois dans cette session qu'une énumération à la main se révèle fausse.
J'ai donc balayé la forme d'abord : un état nommé « onglet », des boutons
stylés par comparaison à cet état, un panneau conditionnel en dessous. Quatre
écrans répondent à cette description — la conduite, la veille externe, la
console, le dossier de préparation.

── Un composant, pas quatre copies

« coque/Onglets.tsx ». Le motif ARIA tient en trois pièces, plus une quatrième
qu'on oublie toujours : la barre porte « tablist » et son nom, chaque onglet
porte « tab » et « aria-selected », le panneau porte « tabpanel » et le nom de
l'onglet qui l'a ouvert — et le clavier fait de la barre un seul arrêt de
tabulation, les flèches circulant entre les onglets. C'est cette quatrième pièce
qui justifie le composant : une gestion clavier ne se recopie pas quatre fois,
et une barre à moitié posée est pire qu'une barre absente. Annoncer « onglet »
sans donner les flèches, c'est promettre un comportement qui n'existe pas.

Le panneau ne reçoit pas de « tabIndex » : les quatre contiennent des éléments
focalisables, et les pratiques ARIA ne demandent l'arrêt supplémentaire que pour
un panneau qui n'en a aucun.

Un détail qui compte pour la veille externe : son bouton « Collecter
maintenant » vivait dans la même rangée que les onglets. La barre s'y niche
désormais, le bouton reste son frère — un « tablist » n'accueille que des
onglets. Vérifié : zéro enfant non-onglet dans la barre.

── Deux garanties, parce qu'une moitié de motif est pire que rien

L'une interdit d'écrire une barre à la main — la comparaison « onglet === … ? »
hors du composant. L'autre exige le panneau qui va avec : sans lui,
« aria-controls » désignerait le vide.

── Vérifié à l'écran, pas au source

Dossier de préparation : quinze onglets, un seul sélectionné, un seul arrêt de
tabulation, tous les « aria-controls » résolvent vers le panneau, et le panneau
porte le nom de l'onglet actif. Flèche droite deux fois depuis « Préparation »
mène à « Étiquettes » — sélection, focus et nom du panneau suivent ensemble.
« Fin » mène à « Historique », et flèche droite y revient au début.

Les trois autres de même. La conduite garde ses grands boutons — « petit » est
une propriété, la mise en page ne change pas d'un pixel.

1181 garanties au vert, et quatre clés d'anglais pour les noms des barres : le
compteur de couverture les a réclamées avant que j'y pense, ce qui est
exactement son travail.

── Ce que je n'ai pas touché

Les bascules du tableau des actions et le « Composer » du cockpit n'ont pas
d'« aria-pressed » : rien ne dit si elles sont enfoncées. Le choix de registre
de la clôture est un jeu de boutons sans nom qui n'ouvre aucun panneau — un
groupe, pas des onglets. Le périmètre du cockpit — « Toute la crise » et les
cellules — est un filtre logé dans une rangée qui porte aussi le « Composer » :
en faire une barre d'onglets demanderait de séparer la rangée. Trois écarts
distincts, à traiter comme tels.

Co-Authored-By: Claude (RCA)
parent df48c296
Loading
Loading
Loading
Loading
Loading
+30 −0
Changes for apps/api/src/verification/interface.ts: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -489,6 +489,36 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  r.verifie("EX-19", "aucun jeu de boutons n'ignore l'intitulé qui le coiffe",
    anonymes.length === 0, anonymes.slice(0, 4).join(", "));

  // ── Une barre d'onglets passe par le composant, pas par la main (EX-19) ──
  //
  // Quatre écrans écrivaient la leur : un « div » et des « button » stylés selon
  // l'onglet actif. La couleur portait seule l'information — aucun « tablist »,
  // aucun « aria-selected », et quinze arrêts de tabulation pour traverser le
  // dossier de préparation. Le motif ARIA demande aussi un clavier, ce qui ne se
  // recopie pas quatre fois : il vit dans « coque/Onglets.tsx ».
  //
  // Deux contrôles, parce qu'une barre à moitié posée est pire que pas de barre.
  // Le premier interdit d'en écrire une à la main ; le second exige le panneau
  // qui va avec, sans quoi « aria-controls » désignerait le vide.
  const maison: string[] = [];
  const sansPanneau: string[] = [];
  for (const f of fichiers) {
    if (!f.endsWith(".tsx")) continue;
    const court = f.slice(web.length);
    const texte = await readFile(f, "utf8");
    if (court !== "coque/Onglets.tsx" && /onglet === \w+ \?/.test(texte)) {
      maison.push(court);
    }
    if (texte.includes("<Onglets")
      && !(texte.includes('role="tabpanel"') && texte.includes("aria-labelledby={idOnglet("))) {
      sansPanneau.push(court);
    }
  }
  r.verifie("EX-19", "aucune barre d'onglets n'est écrite à la main",
    maison.length === 0, maison.join(", "));
  r.verifie("EX-19", "toute barre d'onglets commande un panneau qui porte son nom",
    sansPanneau.length === 0, sansPanneau.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",
+85 −0
Changes for apps/web/src/coque/Onglets.tsx: 85 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useRef, type CSSProperties, type KeyboardEvent } from "react";

/**
 * Une barre d'onglets, au clavier comme à l'oreille (EX-19).
 *
 * Quatre écrans en portaient une, écrite quatre fois à la main : un « div » et
 * des « button » stylés selon l'onglet actif. À l'écran, cela se voit ; pour le
 * reste, non. Un lecteur d'écran annonçait des boutons ordinaires sans dire
 * qu'ils forment un jeu, ni lequel est actif — la couleur portait seule
 * l'information. Et le dossier de préparation demandait quinze appuis sur
 * « Tab » pour être traversé.
 *
 * Le motif est celui des pratiques ARIA, et il tient en trois pièces : la barre
 * porte « tablist » et son nom, chaque onglet porte « tab » et « aria-selected »,
 * le panneau porte « tabpanel » et le nom de l'onglet qui l'a ouvert. La
 * quatrième pièce est le clavier : la barre est un seul arrêt de tabulation, et
 * les flèches circulent entre les onglets.
 *
 * Le panneau ne reçoit pas de « tabIndex » : les quatre en contiennent des
 * éléments focalisables, et les pratiques ARIA ne demandent l'arrêt
 * supplémentaire que pour un panneau qui n'en a aucun. En ajouter un poserait
 * un contour de focus autour de toute la zone de contenu, sans rien gagner.
 *
 * Ce composant existe parce que la gestion clavier ne se recopie pas quatre
 * fois. Ce qui reste au ressort de l'écran — l'identifiant, le nom de la barre,
 * le panneau à marquer — se passe en propriétés, parce que lui seul le sait.
 */

export const idOnglet = (nom: string, cle: string) => `onglet-${nom}-${cle}`;
export const idPanneau = (nom: string) => `panneau-${nom}`;

export function Onglets<C extends string>({
  nom, intitule, liste, actif, definir, petit = true, style,
}: {
  /** Préfixe des identifiants : unique par écran. */
  nom: string;
  /** Ce que la barre s'appelle pour un lecteur d'écran. */
  intitule: string;
  liste: readonly (readonly [C, string])[];
  actif: C;
  definir: (cle: C) => void;
  petit?: boolean;
  style?: CSSProperties;
}) {
  const barre = useRef<HTMLDivElement>(null);

  function auClavier(e: KeyboardEvent<HTMLDivElement>) {
    let i: number;
    if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
      const pas = e.key === "ArrowRight" ? 1 : -1;
      const depuis = liste.findIndex(([c]) => c === actif);
      // On boucle : au bout de la barre, la flèche revient au début. C'est ce
      // que fait le motif de référence, et cela évite de buter sans retour.
      i = (depuis + pas + liste.length) % liste.length;
    } else if (e.key === "Home") {
      i = 0;
    } else if (e.key === "End") {
      i = liste.length - 1;
    } else {
      return;
    }
    const cible = liste[i];
    if (cible === undefined) return;
    e.preventDefault();
    definir(cible[0]);
    // Activation automatique : le panneau suit la flèche. C'est le bon choix
    // quand ouvrir un panneau ne coûte rien — ici, un changement d'état local.
    barre.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[i]?.focus();
  }

  return (
    <div ref={barre} role="tablist" aria-label={intitule} className="ligne" style={style}
      onKeyDown={auClavier}>
      {liste.map(([cle, libelle]) => (
        <button key={cle} type="button" role="tab" id={idOnglet(nom, cle)}
          aria-selected={cle === actif} aria-controls={idPanneau(nom)}
          // Tabulation tournante : la barre entière est un seul arrêt, et l'on
          // y entre sur l'onglet actif.
          tabIndex={cle === actif ? 0 : -1}
          className={`${cle === actif ? "accent" : ""}${petit ? " petit" : ""}`.trim()}
          onClick={() => definir(cle)}>{libelle}</button>
      ))}
    </div>
  );
}
+8 −8
Changes for apps/web/src/ecrans/Conduite.tsx: 8 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import { LIBELLES_STATUT, STATUTS_ACTION } from "@kastell/shared";
import { Historique } from "../coque/Historique.js";
import { ActionDetail } from "./ActionDetail.js";
import { useT } from "../langues/index.js";
import { Onglets, idOnglet, idPanneau } from "../coque/Onglets.js";

interface Decision {
  id: string; objet: string; options: string[]; retenue: string | null;
@@ -105,16 +106,15 @@ export function Conduite({
        {/* Titre de page : l'écran n'en avait aucun, et c'est bien ce
            chapeau qui le porte visuellement. */}
        <h1 className="etiq">{t("Conduite")}</h1>
        <div className="ligne" style={{ gap: 6, marginTop: 6 }}>
          {([["decisions", t("Décisions")], ["actions", "Actions"],
            ["point", t("Point de situation")]] as const).map(([cle, libelle]) => (
            <button key={cle} className={onglet === cle ? "accent" : ""}
              onClick={() => definirOnglet(cle)}>{libelle}</button>
          ))}
        </div>
        <Onglets nom="conduite" intitule={t("Sections de la conduite")}
          petit={false} style={{ gap: 6, marginTop: 6 }}
          liste={[["decisions", t("Décisions")], ["actions", "Actions"],
            ["point", t("Point de situation")]] as const}
          actif={onglet} definir={definirOnglet} />
      </header>

      <div className="contenu">
      <div className="contenu" role="tabpanel" id={idPanneau("conduite")}
        aria-labelledby={idOnglet("conduite", onglet)}>
        {erreur && <div className="erreur">{erreur}</div>}

        {onglet === "decisions" && (
+7 −5
Changes for apps/web/src/ecrans/VeilleExterne.tsx: 7 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { dateHeure } from "../etat.js";
import { useT } from "../langues/index.js";
import { Onglets, idOnglet, idPanneau } from "../coque/Onglets.js";

interface Source {
  id: string; genre: string; libelle: string; adresse: string; actif: boolean;
@@ -109,17 +110,18 @@ export function VeilleExterne({ organisationId, criseId }: {
        <div className="etiq">{t("Veille externe")}</div>
        <h1>{t("Ce qui se dit dehors")}</h1>
        <div className="ligne" style={{ gap: 6, marginTop: 8 }}>
          {([["flux", "Flux"], ["sources", t("Sources et termes")]] as const).map(([c, l]) => (
            <button key={c} className={onglet === c ? "accent petit" : "petit"}
              onClick={() => definirOnglet(c)}>{l}</button>
          ))}
          <Onglets nom="veille-externe" intitule={t("Sections de la veille externe")}
            style={{ gap: 6 }}
            liste={[["flux", "Flux"], ["sources", t("Sources et termes")]] as const}
            actif={onglet} definir={definirOnglet} />
          <button className="petit pousse" disabled={occupe} onClick={() => void passer()}>
            {occupe ? "Collecte…" : t("Collecter maintenant")}
          </button>
        </div>
      </header>

      <div className="contenu">
      <div className="contenu" role="tabpanel" id={idPanneau("veille-externe")}
        aria-labelledby={idOnglet("veille-externe", onglet)}>
        {erreur && <div className="erreur">{erreur}</div>}
        {message && <div className="succes">{message}</div>}

+11 −11
Changes for apps/web/src/ecrans/administration/Administration.tsx: 11 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import { Diagnostic } from "./Diagnostic.js";
import { Comptes } from "./Comptes.js";
import { Organisations } from "./Organisations.js";
import { Roots } from "./Roots.js";
import { Onglets, idOnglet, idPanneau } from "../../coque/Onglets.js";

interface Etat {
  version: string;
@@ -153,20 +154,19 @@ export function Administration() {
        <h1 style={{ fontSize: 20 }}>
          {t("Instance Kastell {version}", { version: d?.version ?? "" })}
        </h1>
        <div className="ligne" style={{ gap: 5, marginTop: 8 }}>
          {([["etat", "État"], ["diagnostic", "Diagnostic"],
            ["configuration", "Configuration"],
            ["organisations", "Organisations"],
            ["comptes", "Comptes"],
        <Onglets nom="console" intitule={t("Sections de la console")}
          style={{ gap: 5, marginTop: 8 }}
          liste={[["etat", t("État")], ["diagnostic", t("Diagnostic")],
            ["configuration", t("Configuration")],
            ["organisations", t("Organisations")],
            ["comptes", t("Comptes")],
            ["roots", t("Droit root")],
            ["acces", t("Accès exceptionnels")]] as const).map(([cle, libelle]) => (
            <button key={cle} className={onglet === cle ? "accent petit" : "petit"}
              onClick={() => definirOnglet(cle)}>{t(libelle)}</button>
          ))}
        </div>
            ["acces", t("Accès exceptionnels")]] as const}
          actif={onglet} definir={definirOnglet} />
      </header>

      <div className="contenu">
      <div className="contenu" role="tabpanel" id={idPanneau("console")}
        aria-labelledby={idOnglet("console", onglet)}>
        {erreur && <div className="erreur">{erreur}</div>}
        {integrite && <div className="succes">{integrite}</div>}

Loading