DS

Accessibilite

Patterns d'accessibilite utilises dans Speedcube Master : HTML semantique, attributs ARIA, gestion du focus, cibles tactiles, contraste et navigation clavier.

HTML semantique

Utiliser les elements HTML semantiques natifs plutot que des <div> generiques. Les landmarks (<header>, <main>, <nav>, <footer>) permettent aux lecteurs d'ecran de naviguer rapidement dans la page.

// Structure de page avec landmarks semantiques
export default function PageLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <header role="banner" className="border-b border-border">
        <nav aria-label="Navigation principale" className="max-w-6xl mx-auto px-4">
          {/* Liens de navigation */}
        </nav>
      </header>

      <main role="main" className="max-w-6xl mx-auto px-4 py-6">
        {children}
      </main>

      <footer role="contentinfo" className="border-t border-border">
        {/* Contenu du footer */}
      </footer>
    </>
  );
}

// Hierarchie des headings (ne jamais sauter de niveau)
<h1>Titre de la page</h1>        {/* Un seul h1 par page */}
  <h2>Section principale</h2>
    <h3>Sous-section</h3>
    <h3>Sous-section</h3>
  <h2>Autre section</h2>
    <h3>Sous-section</h3>

// Listes semantiques
<ul aria-label="Resultats recents">
  <li>Solve 1 - 12.34s</li>
  <li>Solve 2 - 14.67s</li>
</ul>

// Sections avec titres
<section aria-labelledby="stats-heading">
  <h2 id="stats-heading">Statistiques</h2>
  {/* Contenu des stats */}
</section>

Attributs ARIA

Les attributs ARIA completent le HTML semantique pour les composants interactifs personnalises. La regle d'or : preferer le HTML natif, et n'utiliser ARIA que quand c'est necessaire.

// Bouton avec icone seule (label obligatoire)
<Button variant="ghost" size="icon" aria-label="Supprimer la solve">
  <Trash className="h-4 w-4" />
</Button>

// Zone de statut en temps reel
<div
  role="status"
  aria-live="polite"
  aria-atomic="true"
  className="text-2xl font-mono"
>
  {formattedTime}
</div>

// Dialog / Modal
<Dialog>
  <DialogContent
    role="dialog"
    aria-modal="true"
    aria-labelledby="dialog-title"
    aria-describedby="dialog-description"
  >
    <DialogHeader>
      <DialogTitle id="dialog-title">Confirmer la suppression</DialogTitle>
      <DialogDescription id="dialog-description">
        Cette action est irreversible.
      </DialogDescription>
    </DialogHeader>
    {/* Contenu */}
  </DialogContent>
</Dialog>

// Toggle avec etat
<Button
  variant="outline"
  aria-pressed={isActive}
  aria-label={isActive ? "Desactiver le mode inspection" : "Activer le mode inspection"}
  onClick={() => setIsActive(!isActive)}
>
  <Eye className="h-4 w-4" />
  Inspection
</Button>

// Indicateur de chargement
<div aria-busy="true" aria-label="Chargement des solves">
  <Skeleton className="h-8 w-full" />
</div>

// Lien externe
<a
  href="https://worldcubeassociation.org"
  target="_blank"
  rel="noopener noreferrer"
  aria-label="World Cube Association (ouvre dans un nouvel onglet)"
>
  WCA <ExternalLink className="h-3 w-3 inline" />
</a>

Gestion du focus

Le ring de focus visible est essentiel pour la navigation clavier. Il utilise la pseudo-classe focus-visible pour n'apparaitre que lors de la navigation clavier (pas au clic souris).

// Ring de focus standard (applique a tous les elements interactifs)
<button className="focus-visible:outline-none
  focus-visible:ring-2
  focus-visible:ring-ring
  focus-visible:ring-offset-2
  focus-visible:ring-offset-background
  rounded-lg">
  Action
</button>

// Focus visible sur les cartes cliquables
<Card
  tabIndex={0}
  role="button"
  className="cursor-pointer
    focus-visible:outline-none
    focus-visible:ring-2
    focus-visible:ring-ring
    focus-visible:ring-offset-2
    focus-visible:ring-offset-background"
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      handleClick();
    }
  }}
>
  {/* Contenu */}
</Card>

// Focus trap dans les modales (gere par Radix Dialog)
// Le focus est automatiquement piege dans la modale
// et restaure a l'element precedent a la fermeture

// Skip link (premier element de la page)
<a
  href="#main-content"
  className="sr-only focus:not-sr-only
    focus:absolute focus:top-4 focus:left-4
    focus:z-50 focus:px-4 focus:py-2
    focus:bg-primary focus:text-primary-foreground
    focus:rounded-lg"
>
  Aller au contenu principal
</a>

// Gestion programmatique du focus
import { useRef, useEffect } from "react";

function SearchDialog() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // Focus l'input a l'ouverture
    inputRef.current?.focus();
  }, []);

  return <Input ref={inputRef} placeholder="Rechercher..." />;
}

Cibles tactiles

Tous les elements interactifs doivent avoir une zone tactile minimale de 48x48px (recommandation WCAG 2.5.8). Pour les elements visuellement plus petits, on augmente la zone cliquable avec du padding ou un pseudo-element.

// Taille minimale directe (48px)
<Button className="min-h-[48px] min-w-[48px]">
  Action
</Button>

// Inputs touch-friendly
<Input className="min-h-[44px]" />           {/* 44px minimum (Apple HIG) */}
<SelectTrigger className="min-h-[44px]" />
<Textarea className="min-h-[100px]" />

// Bouton icone avec zone tactile elargie
<button className="relative p-2 -m-2
  min-h-[44px] min-w-[44px]
  flex items-center justify-center">
  <Trash className="h-4 w-4" />
</button>

// Zone tactile elargie via pseudo-element
<button className="relative">
  <span className="absolute -inset-2" aria-hidden="true" />
  <Trash className="h-4 w-4" />
</button>

// Espacement suffisant entre les cibles tactiles
<div className="space-y-3">  {/* Minimum 8px entre les elements */}
  <Button className="w-full min-h-[44px]">Option 1</Button>
  <Button className="w-full min-h-[44px]">Option 2</Button>
  <Button className="w-full min-h-[44px]">Option 3</Button>
</div>

Contraste des couleurs

Les ratios de contraste WCAG AA doivent etre respectes : 4.5:1 pour le texte normal, 3:1 pour le grand texte (18px+ bold ou 24px+). Les tokens du design system sont calibres pour respecter ces ratios.

AAtext-foreground sur bg-background : ratio 12.6:1
AAtext-muted-foreground sur bg-background : ratio 5.3:1
AAtext-primary-foreground sur bg-primary : ratio 7.8:1
AttentionNe pas utiliser la couleur seule pour transmettre une information (ajouter icone ou texte)
// Ne pas se fier uniquement a la couleur
// Mauvais : seule la couleur indique l'erreur
<input className="border-destructive" />

// Bon : couleur + icone + texte d'erreur
<div>
  <input className="border-destructive" aria-invalid="true" aria-describedby="email-error" />
  <p id="email-error" className="text-sm text-destructive flex items-center gap-1 mt-1">
    <AlertCircle className="h-3.5 w-3.5" />
    Adresse email invalide
  </p>
</div>

// Icones decoratives vs informatives
// Decorative : masquee du lecteur d'ecran
<Timer className="h-5 w-5" aria-hidden="true" />

// Informative : texte alternatif
<CheckCircle className="h-5 w-5 text-accent" aria-label="Succes" role="img" />

Texte pour lecteurs d'ecran

La classe sr-only masque visuellement le texte tout en le rendant accessible aux lecteurs d'ecran. Elle est essentielle pour les boutons icones et les informations contextuelles.

// Texte visible uniquement par les lecteurs d'ecran
<Button variant="ghost" size="icon">
  <Trash className="h-4 w-4" />
  <span className="sr-only">Supprimer la solve</span>
</Button>

// Contexte supplementaire pour les stats
<Card>
  <CardContent className="pt-6 text-center">
    <p className="text-2xl font-bold font-mono">12.34</p>
    <p className="text-xs text-muted-foreground">Ao5</p>
    <span className="sr-only">
      Moyenne des 5 derniers temps : 12 secondes et 34 centiemes
    </span>
  </CardContent>
</Card>

// Label invisible pour un champ de recherche
<div className="relative">
  <label htmlFor="search" className="sr-only">
    Rechercher un algorithme
  </label>
  <Input
    id="search"
    placeholder="Rechercher..."
    className="pl-10"
  />
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
</div>

// Annonces dynamiques (resultats de recherche)
<div aria-live="polite" className="sr-only">
  {results.length} resultats trouves pour "{query}"
</div>

Navigation clavier

Tous les elements interactifs doivent etre accessibles au clavier. Les composants Radix (shadcn/ui) gerent cela nativement. Pour les composants personnalises, il faut implementer les raccourcis clavier.

// Raccourcis clavier globaux (timer)
useEffect(() => {
  function handleKeyDown(e: KeyboardEvent) {
    // Espace pour demarrer/arreter le timer
    if (e.code === "Space") {
      e.preventDefault();
      toggleTimer();
    }
    // Echap pour annuler l'inspection
    if (e.key === "Escape") {
      cancelInspection();
    }
  }

  document.addEventListener("keydown", handleKeyDown);
  return () => document.removeEventListener("keydown", handleKeyDown);
}, []);

// Navigation dans une liste avec fleches
function useArrowNavigation(itemCount: number) {
  const [focusedIndex, setFocusedIndex] = useState(0);

  function handleKeyDown(e: React.KeyboardEvent) {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setFocusedIndex((i) => Math.min(i + 1, itemCount - 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        setFocusedIndex((i) => Math.max(i - 1, 0));
        break;
      case "Home":
        e.preventDefault();
        setFocusedIndex(0);
        break;
      case "End":
        e.preventDefault();
        setFocusedIndex(itemCount - 1);
        break;
    }
  }

  return { focusedIndex, handleKeyDown };
}

// Composants Radix : navigation clavier integree
// - Dialog : Tab/Shift+Tab pour naviguer, Echap pour fermer
// - Select : Fleches pour naviguer, Entree pour selectionner
// - Tabs : Fleches pour changer d'onglet
// - DropdownMenu : Fleches pour naviguer, Entree pour activer

Checklist a11y

SemantiqueUtiliser les landmarks HTML (header, main, nav, footer, section)
HeadingsHierarchie de titres logique (h1 > h2 > h3), un seul h1 par page
LabelsTous les inputs ont un label (visible ou sr-only)
FocusRing de focus visible sur tous les elements interactifs
ClavierNavigation complete au clavier (Tab, Entree, Echap, Fleches)
ContrasteRatio minimum 4.5:1 (texte normal) ou 3:1 (grand texte)
TactileCibles tactiles de 48x48px minimum avec espacement suffisant
MotionRespecter prefers-reduced-motion pour les animations