Retour à la Knowledge Base
Référence

keyboard-navigation

Juin 2026·Version : 1.0 — Juin 2026·Tout projet frontend Gentle Designs (web Next.js + desktop Tauri/React)

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 :

  1. É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.
  2. Tout listener clavier global est nettoyé. Un document.addEventListener("keydown", …) posé dans un useEffect DOIT être retiré dans le return. 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 via useModalEscape. Aucune exception : tout panneau ouvrable doit se fermer au Escape.

  • ✅ Click-outside ferme. Listener mousedown sur document qui 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 le Dialog de 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, ayari components/admin/PhotoGalleryManager.tsx :

    <input onKeyDown={(e) => { if (e.key === "Enter" && value.trim()) submit(); }} />

    Dans un vrai <form>, préférer <form onSubmit={…}> avec e.preventDefault() (ayari ContactForm.tsx) : Enter y soumet nativement.

  • ✅ autoFocus sur les champs de recherche. EVQuest components/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'outline natif. Les deux projets : focus:outline-none focus:ring-2 focus:ring-<accent> (ayari focus:ring-ayari-blue, EVQuest bordure accent-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-none sans fournir un substitut visible. Un outline-none orphelin 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-visible n'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 : utiliser focus-visible:ring-2 plutôt que focus: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 breakthrough ghost-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-none orphelin) ?
  • 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-motion est-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 (+ useKeyboardNav si é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