12k
All articles

Cómo añadir atajos de teclado a una aplicación web

Cómo añadir atajos de teclado a una web app con un listener keydown global, filtros al escribir, modificadores de Mac y Windows, secuencias y limpieza en React.

OpenReplay Team
OpenReplay Team
Cómo añadir atajos de teclado a una aplicación web

Para añadir atajos de teclado a una aplicación web, adjunta un único listener de keydown a document, compara event.key junto con los booleanos de los modificadores, omite el evento cuando su target sea un elemento editable y elimina el listener usando la misma referencia de función cuando el componente propietario se desmonte.

El primer atajo suele escribirse rápido. Los problemas tienden a llegar después: alguien escribe “k” en un cuadro de búsqueda y se abre la paleta de comandos, o un colega en Mac descubre que el atajo no hace absolutamente nada.

Este artículo parte de ese listener ingenuo y corrige cada fallo por turnos: activaciones mientras se escribe, modificadores en Mac frente a Windows, secuencias de dos teclas, fugas de listeners en React y las reglas de accesibilidad que se aplican específicamente a los atajos. La mayoría de las soluciones son unas pocas líneas de TypeScript que puedes incorporar a un handler existente.

Puntos clave

  • Un listener de keydown en document recibe cada pulsación de tecla de la página, así que el handler debe retornar de inmediato cuando event.target sea un input, textarea, select o cualquier elemento cuyo isContentEditable sea true.
  • Un atajo que solo comprueba event.ctrlKey nunca se dispara en un Mac, porque la tecla Command establece event.metaKey; comprueba event.metaKey || event.ctrlKey para que un solo binding cubra ambas plataformas.
  • La comparación no necesita detección de plataforma; resuelve la plataforma únicamente para la visualización, que es el único uso que MDN documenta para navigator.platform.
  • Una secuencia como g seguida de i necesita un búfer, un timeout de expiración, un reinicio ante cualquier tecla que no sea prefijo y una nueva comprobación de esa tecla como inicio de una nueva secuencia.
  • En React, registra el listener en useEffect, elimina la misma referencia en la función de limpieza y memoriza el handler con useCallback si lee props o estado.

El listener ingenuo de atajos de teclado en JavaScript

El atajo funcional más simple es un listener de keydown que compara event.key con un carácter y llama a preventDefault() cuando coincide. Usa event.key, nunca el obsoleto keyCode.

document.addEventListener('keydown', (event) => {
  if (event.ctrlKey && event.key.toLowerCase() === 'k') {
    event.preventDefault();
    openCommandPalette();
  }
});

Convertir event.key a minúsculas hace que la comparación sobreviva a Bloq Mayús y a Shift. Todo lo demás en este listener es un bug esperando a un usuario.

¿Cómo evitar que los atajos se disparen mientras el usuario escribe?

Un handler de atajos debe comprobar event.target antes de hacer nada, porque un listener a nivel de documento también recibe las pulsaciones que un usuario escribe en un campo de búsqueda. El filtro habitual comprueba tres nombres de etiqueta, y ahí está el agujero de ese modelo mental: una región contenteditable conserva su propia etiqueta (normalmente div), así que un editor de texto enriquecido pasa la comprobación y el atajo se dispara a mitad de frase. Las repeticiones de sesión de aplicaciones con atajos globales muestran exactamente esto: un usuario escribiendo en un campo y la página navegando a otro sitio al pulsar la letra que resultaba estar asignada.

Usa isContentEditable en su lugar. Es true para cualquier elemento que el usuario pueda editar, incluido uno que herede la edición de un ancestro:

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
});

Retorna al principio del handler para que nada aguas abajo, incluido el búfer de secuencias de una sección posterior, llegue a ver una pulsación escrita.

¿Cómo gestionar las teclas modificadoras de Mac y Windows?

Un atajo que solo comprueba event.ctrlKey está muerto en un Mac, porque la tecla Command establece event.metaKey. Acepta cualquiera de los dos modificadores al comparar:

const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }

Esto sobrecoincide ligeramente (Ctrl+K también funciona en un Mac), lo cual es inocuo. Lo que evita es la detección de plataforma en la ruta de comparación. navigator.platform está documentado como poco fiable para detección, y el único uso que MDN respalda es elegir entre ⌘ y Ctrl al mostrar un atajo al usuario. Déjalo ahí:

Tecla físicaPropiedad del eventoVisualización
Command (macOS)metaKey
Control (Windows/Linux)ctrlKeyCtrl
Tecla WindowsmetaKeyNo asignar
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
  keys.map((k) => (k === 'mod' ? (isMac ? '' : 'Ctrl') : k)).join(isMac ? '' : '+');

¿Cómo dar soporte a secuencias de teclas como g y luego i?

Una secuencia de dos teclas necesita un búfer, un timeout que lo vacíe, un reinicio cuando el búfer deje de ser un prefijo válido y una nueva comprobación de la tecla infractora como inicio de una nueva secuencia. Descartar esa tecla obliga al usuario a pulsarla dos veces. Dos reglas más: ignora los keydown donde event.repeat sea true, para que una tecla mantenida no inunde el búfer, e ignora los keydown de solo modificador (Shift, Control, Meta, Alt, AltGraph), o pulsar Shift antes de un acorde cancelará cualquier secuencia en curso.

BúferResultado tras añadir la teclaAcción
cualquieraigual a un bindingejecutarlo, vaciar el búfer
cualquieraprefijo de un bindingconservar el búfer, reiniciar el timeout
longitud > 1no coincide con nadavaciar el búfer, reintroducir la tecla sola
longitud 1no coincide con nadavaciar el búfer
cualquierase dispara el timeoutvaciar el búfer
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 ventana de 800 ms es una elección, no una medición; unos pocos cientos de milisegundos es lo habitual.

¿Cómo registrar y limpiar un listener de atajos en React?

En React, añade el listener dentro de useEffect y elimina la misma referencia de función en su función de limpieza; si el handler lee props o estado, memorízalo con useCallback e inclúyelo en el array de dependencias del efecto. Sin la limpieza, cada nuevo montaje apila otro listener y una sola pulsación ejecuta la acción dos veces.

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]);
}

Presta atención a la dependencia bindings en ese hook. Si quien lo llama pasa un array literal en línea, será un array distinto en cada render, así que handleKeyDown cambia de identidad y el efecto elimina y vuelve a añadir el listener cada vez. Nada se rompe, pero ese trasiego es trabajo desperdiciado. Declara el array a nivel de módulo, o envuélvelo en useMemo en el componente que lo invoca.

Desde React 18, el Modo Estricto somete cada Efecto a una ronda adicional de configuración y desmontaje en desarrollo, de modo que una limpieza que elimine una referencia distinta de la que añadió se manifiesta de inmediato como un handler duplicado. Fuera de React la regla es idéntica: un addEventListener, un removeEventListener que le corresponda, la misma función.

Mantén los atajos accesibles y descubribles

Tres reglas se aplican específicamente a los atajos. No asignes combinaciones que el navegador reserva, incluidas Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+T y Tab, y no llames a preventDefault() sobre acordes de edición nativos como Cmd/Ctrl+C. Nunca hagas de un atajo la única vía de acceso a una función; debe existir un elemento de menú o un botón para la misma acción. Y para asignaciones de un solo carácter, el criterio WCAG 2.1 SC 2.1.4 Atajos de teclado de caracteres (Nivel A) exige una de estas tres cosas: una forma de que las personas desactiven el atajo, una forma de reasignarlo para que incluya una tecla como Ctrl o Alt, o un alcance lo bastante restringido como para que solo se dispare mientras su propio componente tenga el foco.

Para la descubribilidad, asigna ? a un diálogo de ayuda que renderice el mismo array bindings que usa el comparador. Compara con event.key === '?' en lugar de Shift más la tecla de la barra, para que funcione en distribuciones donde ? se encuentra en una tecla física distinta.

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() en un <dialog> nativo te da el manejo de Escape gratis; para la gestión del foco dentro del diálogo, consulta la guía sobre problemas comunes de accesibilidad con modales.

¿Cuándo conviene recurrir a una librería de atajos?

Cuando tienes más de un par de asignaciones, merece la pena delegar el scoping, la detección de conflictos y el manejo de secuencias. TanStack Hotkeys es una opción: una tecla Mod en un binding se resuelve como Command en un Mac y como Control en el resto de plataformas, y las pulsaciones dirigidas a elementos de entrada enfocados se omiten automáticamente. Su página de resumen todavía etiqueta la librería como alpha y advierte de que la API puede cambiar, así que fija la versión y cuenta con cierta inestabilidad.

Conclusión

Los atajos fallan en lugares predecibles: el target del evento, la tecla modificadora, el búfer de secuencias y el ciclo de vida del listener. Empieza con la guarda isTyping y la comparación metaKey || ctrlKey en el handler que ya tienes, y luego traslada tus asignaciones a un único array para que el comparador y el diálogo de ayuda de ? lean de la misma fuente.

Preguntas frecuentes

¿Cuál es la diferencia entre event.key y event.code para los atajos de teclado?

event.key te da el carácter que produce una tecla una vez tenidos en cuenta la distribución del teclado y cualquier modificador mantenido, mientras que event.code nombra la posición física de la tecla y permanece igual sea cual sea la distribución. Compara los atajos con event.key para que una asignación a 'k' signifique la letra impresa en la tecla, en cualquier teclado. Reserva event.code para entradas basadas en posición, como WASD en juegos. TanStack Hotkeys recurre a event.code solo para las teclas de letras y dígitos, y únicamente cuando event.key devuelve en su lugar un carácter especial, como ocurre con Option más una letra en macOS.

¿Debo usar keydown, keyup o keypress para los atajos de teclado?

Usa keydown. MDN marca keypress como obsoleto, y solo se dispara para teclas que producen un carácter, así que nunca informa de Escape, las teclas de flecha o un modificador pulsado en solitario. keydown se dispara para todas las teclas, expone event.key y los booleanos de los modificadores, y es el evento en el que preventDefault detiene la acción propia del navegador. keyup llega después de que el navegador ya haya actuado sobre el keydown, de modo que no puede suprimir un atajo nativo ni un carácter insertado.

¿Se disparan los atajos de teclado mientras un usuario escribe con un IME, como la entrada de japonés o chino?

Sí. Un listener de keydown a nivel de documento sigue recibiendo pulsaciones mientras un IME está componiendo, así que retorna de inmediato cuando event.isComposing sea true. La bandera permanece en true para cada evento de teclado entre el momento en que el IME abre una sesión de composición y el momento en que la cierra, que es justo la ventana en la que tus atajos deben mantenerse al margen. La guarda isTyping cubre la mayoría de los casos porque la composición ocurre en un elemento editable, pero isComposing añade una segunda comprobación para superficies de texto personalizadas.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.