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.
text-foreground sur bg-background : ratio 12.6:1text-muted-foreground sur bg-background : ratio 5.3:1text-primary-foreground sur bg-primary : ratio 7.8:1// 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 activerChecklist a11y
SemantiqueUtiliser les landmarks HTML (header, main, nav, footer, section)HeadingsHierarchie de titres logique (h1 > h2 > h3), un seul h1 par pageLabelsTous les inputs ont un label (visible ou sr-only)FocusRing de focus visible sur tous les elements interactifsClavierNavigation 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 suffisantMotionRespecter prefers-reduced-motion pour les animations