Version : 1.0 — Juin 2026 Scope : Tout projet frontend Gentle Designs (web Next.js + desktop Tauri/React) Objectif : Garantir une navigation clavier complète et accessible sur chaque projet, sans avoir à la respécifier — sert autant à auditer/upgrader l'existant qu'à démarrer un nouveau projet
0. Philosophie — Pourquoi ce document existe
De projet en projet, la navigation clavier n'était pas implémentée de façon systématique : elle dépendait du fait qu'on pense à la demander. Pourtant, nos deux projets les plus aboutis sur le sujet — EVQuest (React + Vite + Tauri) et ayari.racing (React + Vite SPA) — ont, indépendamment et sans aucune librairie d'accessibilité externe (pas de Radix, Headless UI ni react-aria), réinventé exactement les mêmes patterns. Cette convergence est le signal qu'une norme durable doit être figée : ce document.
Principe directeur : toute interaction réalisable à la souris doit avoir un équivalent clavier. Un panneau qu'on ouvre au clic se ferme au clavier ; un item qu'on sélectionne au clic se sélectionne au clavier.
Deux règles socle, non négociables :
- Élément interactif = élément natif. Toujours
<button>,<a>,<input>,<select>— jamais<div onClick>. Les éléments natifs sont focusables, activables au clavier (Enter/Space) et annoncés correctement par les lecteurs d'écran, gratuitement. Un<div onClick>ne reçoit ni focus ni activation clavier sans qu'on les recâble à la main. - Tout listener clavier global est nettoyé. Un
document.addEventListener("keydown", …)posé dans unuseEffectDOIT être retiré dans lereturn. Sinon les handlers s'empilent à chaque montage et plusieurs modals se mettent à réagir à un même Escape.
Convention de balisage de ce document
Chaque règle est marquée :
- ✅ Vécu — pattern prouvé en production, avec le projet et le fichier source. Copiable tel quel.
- 🎯 Cible — bonne pratique actuelle absente de nos deux projets, à appliquer désormais. La raison est donnée. C'est là que ce document fait monter le niveau plutôt que de simplement décrire l'existant.
1. Hooks réutilisables — le socle
Trois hooks couvrent l'essentiel. Ils sont volontairement sans dépendance.
useModalEscape — fermer au clavier, protéger les saisies
✅ Vécu — ayari.racing src/lib/hooks/useModalEscape.ts
type UseModalEscapeOptions = {
isOpen: boolean;
onClose: () => void;
hasUnsavedChanges: boolean;
confirmMessage?: string;
};
export function useModalEscape({
isOpen,
onClose,
hasUnsavedChanges,
confirmMessage = 'Des modifications non sauvegardées seront perdues. Fermer quand même ?',
}: UseModalEscapeOptions) {
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
e.preventDefault();
if (hasUnsavedChanges) {
if (window.confirm(confirmMessage)) onClose();
} else {
onClose();
}
}
}, [isOpen, hasUnsavedChanges, onClose, confirmMessage]);
useEffect(() => {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [handleEscape]);
}
Détail vécu : la garde hasUnsavedChanges évite qu'un Escape réflexe fasse perdre une saisie en cours. Pour les modals à brouillon auto-sauvegardé (ex. SaisieModal), on passe hasUnsavedChanges={false} et Escape ferme directement.
useKeyboardNav — raccourcis globaux d'écran
✅ Vécu — EVQuest frontend/src/hooks/useKeyboardNav.ts (navigation du replayer de mains)
export function useKeyboardNav(actions: KeyboardNavActions) {
const actionsRef = useRef(actions);
actionsRef.current = actions; // garde les callbacks frais...
const bufferRef = useRef("");
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const a = actionsRef.current;
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return; // ne pas voler la frappe
// Typeahead chiffré : aller à l'élément n°N
if (/^[0-9]$/.test(e.key)) {
if (tag === "SELECT") (e.target as HTMLElement).blur();
/* …accumule dans bufferRef, reset après 1 s via timerRef… */
return;
}
if (tag === "SELECT") (e.target as HTMLElement).blur(); // reprend les flèches
switch (e.key) {
case "ArrowLeft": a.onPrevStep(); break;
case "ArrowRight": a.onNextStep(); break;
case " ": e.preventDefault(); a.onTogglePlay(); break;
case "Home": e.preventDefault(); a.onGoToStart(); break;
case "End": e.preventDefault(); a.onGoToEnd(); break;
case "PageUp": e.preventDefault(); a.onPrevSession?.(); break;
case "PageDown": e.preventDefault(); a.onNextSession?.(); break;
case "ArrowUp": e.preventDefault(); a.onPrevHand(); break;
case "ArrowDown": e.preventDefault(); a.onNextHand(); break;
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []); // ...attaché UNE seule fois
}
Deux subtilités vécues à ne pas « simplifier » par erreur :
actionsRef.current = actionsà chaque render +useEffect([], …): le listener est attaché une seule fois (pas de ré-attachement à chaque changement d'état), mais lit toujours des callbacks frais via la ref. Si on mettait[actions]en dépendance, on réattacherait le listener en boucle.(e.target).blur()sur un<select>focalisé : un<select>natif capte les flèches pour son propre usage. On lui retire le focus pour récupérer les flèches au profit de la navigation d'écran. Friction qu'on ne devine qu'en l'ayant vécue.if (tag === "INPUT" || "TEXTAREA") return: un raccourci global ne doit jamais voler une touche pendant que l'utilisateur tape dans un champ.
useReducedMotion — lire la préférence système
✅ Vécu — ayari.racing src/lib/hooks/useReducedMotion.ts
export function useReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const onChange = () => setPrefersReducedMotion(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return prefersReducedMotion;
}
2. Overlays — modals, popovers, panneaux, menus contextuels
Un overlay piège l'attention : il doit donc piéger le clavier de façon cohérente.
-
✅ Escape ferme — systématiquement. EVQuest
components/achievements/BadgeDetailModal.tsx,components/shared/ContextMenu.tsx; ayari viauseModalEscape. Aucune exception : tout panneau ouvrable doit se fermer au Escape. -
✅ Click-outside ferme. Listener
mousedownsurdocumentqui teste!ref.current.contains(e.target). Pattern présent dans 8+ composants EVQuest (HandNavigator,QuickThemeBar,Sidebar,ContextMenu, panneau HUD…).useEffect(() => { if (!open) return; function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); -
✅ Verrou du scroll de fond. ayari
src/components/ui/Modal.tsx:document.body.style.overflow = 'hidden'à l'ouverture, restauré au démontage. Évite que la page derrière défile sous le modal. -
🎯 Focus trap (Tab confiné). Absent des deux projets. Aujourd'hui, Tab peut sortir d'un modal ouvert et atteindre des éléments du fond — Escape ferme, mais le focus n'est pas contenu. Désormais : confiner le Tab dans l'overlay (du dernier élément focusable, Tab renvoie au premier ; et inversement avec Shift+Tab). Implémenter un petit
useFocusTrap(ref), ou — si le projet utilise déjà shadcn/Radix — préférer leDialogde Radix qui le gère nativement. Raison : sans piège, l'utilisateur au clavier « perd » le modal et tabule dans une page invisible. -
🎯 Restauration du focus. Absent des deux projets. À l'ouverture, mémoriser
document.activeElement; à la fermeture, lui redonner le focus (previouslyFocused.focus()). Raison : sans ça, après fermeture le focus retombe en haut du<body>et l'utilisateur clavier doit re-tabuler depuis le début.
3. Composants custom avec interaction clavier ARIA
Dès qu'on construit un widget non-natif (dropdown, sélecteur, galerie), il faut recréer à la main le clavier ET les attributs ARIA que le natif aurait fournis.
Dropdown / listbox
✅ Vécu — EVQuest components/replayer/HandNavigator.tsx
Clavier complet sur un sélecteur de main custom :
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true); // ouvre
}
return;
}
if (e.key === "Escape") { e.preventDefault(); setOpen(false); return; }
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const dir = e.key === "ArrowDown" ? 1 : -1;
const nextIdx = Math.max(0, Math.min(hands.length - 1, currentIndex + dir));
if (hands[nextIdx]?.hand_id) onSelect(hands[nextIdx].hand_id); // navigue
}
if (e.key === "Enter") { e.preventDefault(); setOpen(false); } // valide
}, [open, hands, currentIndex, onSelect]);
<button aria-expanded={open} aria-haspopup="listbox"> … </button>
{open && <div role="listbox"> … </div>}
À retenir : Enter / Space / ArrowDown ouvrent ; ArrowUp/Down parcourent ; Enter valide ; Escape ferme. Plus aria-expanded, aria-haspopup="listbox", role="listbox", et l'auto-scroll de l'item actif à l'ouverture (activeEl.scrollIntoView({ block: "center" })).
Radiogroup (segmented control)
✅ Vécu — ayari.racing src/components/ui/LanguageSwitcher.tsx
<div role="radiogroup" aria-label="Langue">
{LOCALES.map((loc) => (
<button
key={loc.value}
role="radio"
aria-checked={locale === loc.value}
onClick={() => setLocale(loc.value)}
>
{loc.label}
</button>
))}
</div>
role="radiogroup" + role="radio" + aria-checked : le lecteur d'écran annonce « groupe radio, 2 éléments, X sélectionné ».
Navigation par flèches + tabIndex conditionnel
✅ Vécu — ayari.racing src/components/ProductImageGallery.tsx
<div
tabIndex={hasMultiple ? 0 : -1} // focusable seulement s'il y a qqch à parcourir
onKeyDown={handleKeyDown} // ArrowLeft / ArrowRight
className="… focus:outline-none focus:ring-2 focus:ring-ayari-blue"
>
tabIndex={0} rend l'élément atteignable au Tab ; -1 le retire de l'ordre de tabulation quand il n'y a rien à faire (image unique). Les vignettes portent aria-current={index === activeIndex} et les boutons icône un aria-label (« Previous image »).
🎯 Roving tabindex (listes/menus/toolbars longs)
Absent des deux projets. Pour une longue liste ou une barre d'outils, ne PAS rendre chaque item tabbable (sinon Tab traverse 50 éléments). Pattern cible : un seul item a tabIndex={0} (l'actif), tous les autres tabIndex={-1} ; les flèches déplacent le 0 d'item en item et y posent le focus programmatiquement. Raison : garde l'ordre de tabulation de la page court et prévisible.
4. Formulaires
-
✅ Enter soumet le champ. EVQuest
components/settings/ProfileGroupsSection.tsx, ayaricomponents/admin/PhotoGalleryManager.tsx:<input onKeyDown={(e) => { if (e.key === "Enter" && value.trim()) submit(); }} />Dans un vrai
<form>, préférer<form onSubmit={…}>avece.preventDefault()(ayariContactForm.tsx) : Enter y soumet nativement. -
✅
autoFocussur les champs de recherche. EVQuestcomponents/opponents/OpponentFilterBar.tsx: le champ prend le focus à l'ouverture pour taper immédiatement. À réserver aux contextes où la saisie est l'action évidente (recherche, modal de création) — pas sur une page de contenu. -
✅ Éléments natifs.
<input>,<select>,<form>natifs : tabulation, activation et soumission clavier gratuites.
5. Focus visible
-
✅ Anneau de focus tokenisé plutôt qu'
outlinenatif. Les deux projets :focus:outline-none focus:ring-2 focus:ring-<accent>(ayarifocus:ring-ayari-blue, EVQuest bordureaccent-cyan). On remplace l'outline système par un anneau cohérent avec le design token du projet. -
Règle absolue : ne JAMAIS faire
focus:outline-nonesans fournir un substitut visible. Unoutline-noneorphelin rend l'app inutilisable au clavier (on ne voit plus où on est) — c'est une régression d'accessibilité fréquente. -
🎯 Préférer
:focus-visibleà:focus. Raison ::focus-visiblen'affiche l'anneau que lors d'une navigation clavier, pas après un clic souris — on obtient un repère clavier net sans « halo » intempestif au clic. Tailwind : utiliserfocus-visible:ring-2plutôt quefocus:ring-2.
6. Skip links & landmarks — 🎯 Cible
Absents des deux projets.
-
Skip link : un lien « Aller au contenu » en tout premier élément focusable de la page, visuellement masqué jusqu'au focus, qui pointe vers
#main. Raison : sans lui, l'utilisateur clavier doit tabuler à travers toute la navigation à chaque page.<a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:z-50 …"> Aller au contenu </a> … <main id="main"> … </main> -
Landmarks : structurer la page avec
<header>,<nav>,<main id="main">,<footer>. Les lecteurs d'écran les utilisent pour sauter de région en région.
7. prefers-reduced-motion
Pris en charge aux trois niveaux dans nos projets :
-
✅ CSS global — ayari
src/index.css: neutralise animations et transitions globalement.@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } -
✅ Framer Motion — EVQuest
App.tsx:<MotionConfig reducedMotion="user">désactive déclarativement les animations Motion selon la préférence OS. -
✅ Hook —
useReducedMotion(§1) pour les branchements conditionnels en JS.
Lien : voir aussi
frontend-anti-slop-playbook.md§4 (principes d'animation) et le breakthroughghost-dom-travelling-transition.md(fallback sans animation).
8. Documenter les raccourcis pour l'utilisateur
✅ Vécu — EVQuest components/settings/KeyboardShortcutsSection.tsx
Un écran de réglages liste tous les raccourcis (groupés : Navigation, Lecture, Général). Principe : un raccourci non documenté n'existe pas pour l'utilisateur. Dès qu'un projet expose des raccourcis globaux (via useKeyboardNav), il doit fournir cette page de référence.
9. Checklist d'application
Auditer un projet existant
- Chaque action réalisable à la souris a-t-elle un équivalent clavier ?
- Tous les overlays (modals, popovers, menus) ferment-ils au Escape ?
- Le focus est-il visible partout (aucun
outline-noneorphelin) ? - Les widgets custom (dropdown, tabs, sélecteurs) ont-ils flèches + Enter + Escape + ARIA ?
- Les éléments interactifs sont-ils des
<button>/<a>natifs (pas des<div onClick>) ? -
prefers-reduced-motionest-il respecté ? - Manques cible à combler : focus trap sur modals, restauration du focus, skip link, roving tabindex sur listes longues,
:focus-visible.
Démarrer un nouveau projet
- Installer
useModalEscape,useReducedMotion(+useKeyboardNavsi écrans à raccourcis) dès le départ. - Mettre en place dès le layout : skip link + landmarks (
<main id="main">). - Adopter
focus-visible:ring-2 ring-<accent>comme style de focus par défaut dans les tokens. - Si shadcn/Radix est dans la stack : utiliser ses
Dialog/Dropdown(focus trap + clavier gérés) plutôt que de tout recoder. - Prévoir l'écran « Raccourcis clavier » si l'app expose des raccourcis globaux.
10. Installation dans gentlewits
Emplacement
gentlewits/knowledge/references/keyboard-navigation.md
Référencement dans le CLAUDE.md d'un projet
Ajouter dans la section « Documentation transversale » (le mot IMPORTANT garantit que Claude lit effectivement le fichier) :
## Documentation transversale (docs/gentlewits/)
IMPORTANT : Vérifie si un fichier ci-dessous est pertinent et lis-le.
- `knowledge/references/keyboard-navigation.md` — Standard navigation clavier & accessibilité
Quand Claude Code le lit
Via progressive disclosure : pas au démarrage de session, mais dès qu'une tâche frontend touche à un composant interactif (modal, dropdown, formulaire, navigation), grâce au mot « IMPORTANT » dans l'instruction du CLAUDE.md projet. Complémentaire de frontend-anti-slop-playbook.md (qualité visuelle) : ce document-ci traite l'interaction et l'accessibilité.
Dernière mise à jour : juin 2026