Comment ajouter des raccourcis clavier à une application web
Comment ajouter des raccourcis clavier à une web app avec un écouteur keydown global, des garde-fous pour la saisie, les modificateurs Mac et Windows, les séquences et le cleanup React.
Pour ajouter des raccourcis clavier à une application web, attachez un seul écouteur keydown à document, effectuez la correspondance sur event.key ainsi que sur les booléens de modificateurs, ignorez l’événement lorsque sa cible est un élément éditable, et retirez l’écouteur avec la même référence de fonction lorsque le composant propriétaire est démonté.
Le premier raccourci est généralement rapide à écrire. Les ennuis arrivent plutôt plus tard : quelqu’un tape « k » dans un champ de recherche et la palette de commandes s’ouvre, ou un collègue sur Mac constate que le raccourci ne fait absolument rien.
Cet article part de cet écouteur naïf et corrige chaque défaillance l’une après l’autre : déclenchement pendant la saisie, modificateurs Mac contre Windows, séquences à deux touches, fuites d’écouteurs dans React, et les règles d’accessibilité qui s’appliquent spécifiquement aux raccourcis. La plupart des correctifs tiennent en quelques lignes de TypeScript que vous pouvez intégrer à un gestionnaire existant.
Points clés à retenir
- Un écouteur
keydownsurdocumentreçoit chaque frappe de la page ; le gestionnaire doit donc sortir immédiatement lorsqueevent.targetest uninput, untextarea, unselect, ou tout élément dontisContentEditablevaut true. - Un raccourci qui teste uniquement
event.ctrlKeyne se déclenche jamais sur un Mac, car la touche Command positionneevent.metaKey; testezevent.metaKey || event.ctrlKeypour qu’une seule liaison couvre les deux plateformes. - La correspondance n’a pas besoin de détection de plateforme ; ne résolvez la plateforme que pour l’affichage, le seul usage de
navigator.platformdocumenté par MDN. - Une séquence comme
gpuisinécessite un tampon, un délai d’expiration, une réinitialisation sur toute touche qui n’est pas un préfixe, et une nouvelle vérification de cette touche en tant que début d’une nouvelle séquence. - Dans React, enregistrez l’écouteur dans
useEffect, retirez la même référence dans la fonction de nettoyage, et mémoïsez le gestionnaire avecuseCallbacks’il lit des props ou du state.
L’écouteur naïf de raccourcis clavier en JavaScript
Le raccourci fonctionnel le plus simple est un écouteur keydown qui compare event.key à un caractère et appelle preventDefault() en cas de correspondance. Utilisez event.key, jamais l’obsolète keyCode.
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
openCommandPalette();
}
});
Passer event.key en minuscules permet à la correspondance de résister à Verr. Maj et à Maj. Tout le reste dans cet écouteur est un bug qui attend son utilisateur.
Comment empêcher les raccourcis de se déclencher pendant que l’utilisateur saisit du texte ?
Un gestionnaire de raccourci doit vérifier event.target avant toute autre chose, car un écouteur au niveau du document reçoit aussi les frappes qu’un utilisateur saisit dans un champ de recherche. Le filtre habituel vérifie trois noms de balises, et c’est précisément là que le modèle mental est défaillant : une zone contenteditable conserve sa propre balise (généralement div), donc un éditeur de texte enrichi passe la vérification et le raccourci se déclenche au milieu d’une phrase. Les rejeux de session d’applications dotées de raccourcis globaux révèlent exactement cela : un utilisateur en train de saisir dans un champ, et la page qui navigue ailleurs sur la lettre qui se trouvait justement liée à une action.
Utilisez plutôt isContentEditable. Il vaut true pour tout élément que l’utilisateur peut éditer, y compris un élément qui hérite de l’édition d’un ancêtre :
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|| target.isContentEditable;
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target)) return;
// matching below
});
Sortez dès le début du gestionnaire afin qu’aucun traitement en aval, y compris le tampon de séquence présenté dans une section ultérieure, ne voie jamais une frappe de saisie.
Comment gérer les touches de modification sur Mac et Windows ?
Un raccourci qui teste uniquement event.ctrlKey est inopérant sur un Mac, car la touche Command positionne event.metaKey. Acceptez l’un ou l’autre modificateur lors de la correspondance :
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }
Cela élargit légèrement la correspondance (Ctrl+K fonctionne aussi sur un Mac), ce qui est inoffensif. Ce que l’on évite, c’est la détection de plateforme dans le chemin de correspondance. navigator.platform est documenté comme non fiable pour la détection, et le seul usage que MDN approuve est le choix entre ⌘ et Ctrl lorsqu’on affiche un raccourci à l’utilisateur. Cantonnez-le à cet usage :
| Touche physique | Propriété de l’événement | Affichage |
|---|---|---|
| Command (macOS) | metaKey | ⌘ |
| Control (Windows/Linux) | ctrlKey | Ctrl |
| Touche Windows | metaKey | Ne pas lier |
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
keys.map((k) => (k === 'mod' ? (isMac ? '⌘' : 'Ctrl') : k)).join(isMac ? '' : '+');
Comment prendre en charge des séquences de touches comme g puis i ?
Une séquence à deux touches nécessite un tampon, un délai qui le vide, une réinitialisation lorsque le tampon cesse d’être un préfixe valide, et une nouvelle vérification de la touche fautive en tant que début d’une nouvelle séquence. Abandonner cette touche oblige l’utilisateur à l’appuyer deux fois. Deux règles supplémentaires : ignorez les keydown dont event.repeat vaut true, afin qu’une touche maintenue n’inonde pas le tampon, et ignorez les keydown de modificateurs seuls (Shift, Control, Meta, Alt, AltGraph), sans quoi appuyer sur Maj avant un accord annule toute séquence en cours.
| Tampon | Résultat après ajout de la touche | Action |
|---|---|---|
| quelconque | correspond à une liaison | l’exécuter, vider le tampon |
| quelconque | préfixe d’une liaison | conserver le tampon, relancer le délai |
| longueur > 1 | ne correspond à rien | vider le tampon, réinjecter la touche seule |
| longueur 1 | ne correspond à rien | vider le tampon |
| quelconque | le délai expire | vider le tampon |
type Binding = { keys: string[]; description: string; run: () => void };
const bindings: Binding[] = [
{ keys: ['g', 'i'], description: 'Go to inbox', run: () => navigate('/inbox') },
{ keys: ['g', 'p'], description: 'Go to projects', run: () => navigate('/projects') },
];
const MODIFIERS = new Set(['Control', 'Meta', 'Shift', 'Alt', 'AltGraph']);
let buffer: string[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;
function reset() { buffer = []; clearTimeout(timer); }
function feed(key: string) {
buffer.push(key);
const exact = bindings.find(
(b) => b.keys.length === buffer.length && b.keys.every((k, i) => k === buffer[i]),
);
if (exact) { exact.run(); reset(); return; }
if (bindings.some((b) => buffer.every((k, i) => b.keys[i] === k))) {
clearTimeout(timer);
timer = setTimeout(reset, 800);
return;
}
const retry = buffer.length > 1;
reset();
if (retry) feed(key);
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target) || event.repeat || MODIFIERS.has(event.key)) return;
if (event.metaKey || event.ctrlKey || event.altKey) return; // chords go elsewhere
feed(event.key.toLowerCase());
});
La fenêtre de 800 ms est un choix, pas une mesure ; quelques centaines de millisecondes constituent la norme.
Comment enregistrer et nettoyer un écouteur de raccourci dans React ?
Dans React, ajoutez l’écouteur à l’intérieur de useEffect et retirez la même référence de fonction dans sa fonction de nettoyage ; si le gestionnaire lit des props ou du state, mémoïsez-le avec useCallback et listez-le dans le tableau de dépendances de l’effet. Sans le nettoyage, chaque remontage empile un écouteur supplémentaire et une seule frappe exécute l’action deux fois.
function useShortcuts(bindings: Binding[]) {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isTyping(event.target)) return;
// match against bindings here
}, [bindings]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
Surveillez la dépendance bindings dans ce hook. Si un appelant transmet un littéral de tableau en ligne, il s’agit d’un tableau différent à chaque rendu ; handleKeyDown change donc d’identité et l’effet retire puis rajoute l’écouteur à chaque fois. Rien ne casse, mais ce va-et-vient est du travail inutile. Déclarez le tableau au niveau du module, ou encapsulez-le dans useMemo au sein du composant appelant.
Depuis React 18, le Strict Mode fait passer chaque Effect par un cycle supplémentaire de mise en place et de démontage en développement ; un nettoyage qui retire une référence différente de celle qui a été ajoutée se manifeste donc immédiatement par un gestionnaire dédoublé. En dehors de React, la règle est identique : un addEventListener, un removeEventListener correspondant, la même fonction.
Gardez les raccourcis accessibles et repérables
Trois règles s’appliquent spécifiquement aux raccourcis. Ne liez pas les combinaisons réservées par le navigateur, notamment Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+T et Tab, et n’appelez pas preventDefault() sur les accords d’édition natifs tels que Cmd/Ctrl+C. Ne faites jamais d’un raccourci la seule voie d’accès à une fonctionnalité ; un élément de menu ou un bouton doit exister pour la même action. Et pour les liaisons à caractère unique, le critère WCAG 2.1 SC 2.1.4 Raccourcis clavier utilisant un caractère (niveau A) exige l’une de ces trois choses : un moyen pour les utilisateurs de désactiver le raccourci, un moyen de le réassigner afin qu’il inclue une touche telle que Ctrl ou Alt, ou une portée suffisamment restreinte pour qu’il ne se déclenche que lorsque son propre composant a le focus.
Pour la découvrabilité, liez ? à une boîte de dialogue d’aide qui affiche le même tableau bindings que celui utilisé par le mécanisme de correspondance. Effectuez la correspondance sur event.key === '?' plutôt que sur Maj plus la touche slash, afin que cela fonctionne sur les dispositions où ? se trouve sur une autre touche physique.
if (event.key === '?' && !isTyping(event.target)) {
event.preventDefault();
helpDialog.showModal();
}
// inside the dialog
{bindings.map((b) => (
<li key={b.keys.join(' ')}><kbd>{formatKeys(b.keys)}</kbd> {b.description}</li>
))}
showModal() sur un <dialog> natif vous offre gratuitement la gestion de la touche Échap ; pour la gestion du focus à l’intérieur, consultez le guide sur les problèmes d’accessibilité courants des modales.
Quand faut-il recourir à une bibliothèque de raccourcis ?
Dès que vous dépassez quelques liaisons, la gestion des portées, la détection de conflits et le traitement des séquences méritent d’être délégués. TanStack Hotkeys est une option : une touche Mod dans une liaison se résout en Command sur un Mac et en Control partout ailleurs, et les frappes destinées aux champs de saisie ayant le focus sont ignorées pour vous. Sa page de présentation qualifie encore la bibliothèque d’alpha et avertit que l’API peut changer ; épinglez donc votre version et attendez-vous à des évolutions.
Conclusion
Les raccourcis se cassent à des endroits prévisibles : la cible de l’événement, la touche de modification, le tampon de séquence et le cycle de vie de l’écouteur. Commencez par le garde-fou isTyping et la correspondance metaKey || ctrlKey dans le gestionnaire que vous avez déjà, puis regroupez vos liaisons dans un unique tableau afin que le mécanisme de correspondance et la boîte de dialogue d’aide ? lisent la même source.
FAQ
Quelle est la différence entre event.key et event.code pour les raccourcis clavier ?
event.key vous donne le caractère qu'une touche produit une fois pris en compte la disposition du clavier et les modificateurs maintenus, tandis que event.code nomme la position physique de la touche et reste identique quelle que soit la disposition. Effectuez la correspondance des raccourcis sur event.key afin qu'une liaison sur « k » désigne la lettre imprimée sur le capuchon, sur tous les claviers. Réservez event.code aux saisies fondées sur la position, comme WASD dans les jeux. TanStack Hotkeys se replie sur event.code uniquement pour les touches de lettres et de chiffres, et seulement lorsque event.key renvoie un caractère spécial à la place, comme le fait Option plus une lettre sur macOS.
Faut-il utiliser keydown, keyup ou keypress pour les raccourcis clavier ?
Utilisez keydown. MDN marque keypress comme déprécié, et il ne se déclenche que pour les touches qui produisent un caractère : il ne signale donc jamais Échap, les touches fléchées, ni un modificateur pressé seul. keydown se déclenche pour chaque touche, expose event.key et les booléens de modificateurs, et c'est l'événement où preventDefault empêche l'action propre du navigateur. keyup arrive après que le navigateur a déjà agi sur le keydown : il ne peut donc pas supprimer un raccourci natif ni un caractère inséré.
Les raccourcis clavier se déclenchent-ils pendant qu'un utilisateur saisit du texte avec un IME, par exemple pour le japonais ou le chinois ?
Oui. Un écouteur keydown au niveau du document reçoit toujours les frappes pendant qu'un IME compose ; sortez donc immédiatement lorsque event.isComposing vaut true. L'indicateur reste à true pour chaque événement clavier entre le moment où l'IME ouvre une session de composition et celui où il la clôt, soit exactement la fenêtre pendant laquelle vos raccourcis doivent s'effacer. Le garde-fou isTyping intercepte la plupart des cas, car la composition se produit dans un élément éditable, mais isComposing ajoute une seconde vérification pour les surfaces de texte personnalisées.