feat(use-keyboard-shortcuts): shared React keyboard shortcuts hook

This commit is contained in:
saravanakumardb1 2026-03-29 12:40:43 -07:00
parent 0f5dc91648
commit bfa55998a2
6 changed files with 308 additions and 0 deletions

View File

@ -93,6 +93,7 @@ export default [
Element: 'readonly',
Event: 'readonly',
KeyboardEvent: 'readonly',
KeyboardEventInit: 'readonly',
MouseEvent: 'readonly',
FocusEvent: 'readonly',
CustomEvent: 'readonly',

View File

@ -0,0 +1,35 @@
{
"name": "@bytelyst/use-keyboard-shortcuts",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run --pool forks"
},
"peerDependencies": {
"react": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"happy-dom": "^18.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vitest": "^3.0.0"
},
"publishConfig": {
"registry": "http://localhost:3300/api/packages/bytelyst/npm/"
}
}

View File

@ -0,0 +1,200 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useKeyboardShortcuts } from '../index.js';
import type { ShortcutDef } from '../index.js';
function fireKey(key: string, opts: Partial<KeyboardEventInit> = {}) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...opts,
});
window.dispatchEvent(event);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('useKeyboardShortcuts', () => {
it('fires handler on matching key combo', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'k', meta: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
fireKey('k', { metaKey: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('does not fire without matching meta modifier', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'k', meta: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
fireKey('k'); // no meta
expect(handler).not.toHaveBeenCalled();
});
it('respects shift modifier', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 's', meta: true, shift: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
// Without shift — should NOT fire
fireKey('s', { metaKey: true });
expect(handler).not.toHaveBeenCalled();
// With shift — should fire
fireKey('s', { metaKey: true, shiftKey: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('respects alt modifier', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'p', alt: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
fireKey('p'); // no alt
expect(handler).not.toHaveBeenCalled();
fireKey('p', { altKey: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('skips handler when focus is in input (allowInInput: false)', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'n', meta: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
// Simulate focus in an input element
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
const event = new KeyboardEvent('keydown', {
key: 'n',
metaKey: true,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);
expect(handler).not.toHaveBeenCalled();
document.body.removeChild(input);
});
it('fires handler in input when allowInInput: true', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'k', meta: true, handler, allowInInput: true }];
renderHook(() => useKeyboardShortcuts(shortcuts));
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
const event = new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);
expect(handler).toHaveBeenCalledTimes(1);
document.body.removeChild(input);
});
it('skips handler when focus is in textarea', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'n', meta: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.focus();
const event = new KeyboardEvent('keydown', {
key: 'n',
metaKey: true,
bubbles: true,
cancelable: true,
});
textarea.dispatchEvent(event);
expect(handler).not.toHaveBeenCalled();
document.body.removeChild(textarea);
});
it('handles multiple shortcuts simultaneously', () => {
const searchHandler = vi.fn();
const newHandler = vi.fn();
const escHandler = vi.fn();
const shortcuts: ShortcutDef[] = [
{ key: 'k', meta: true, handler: searchHandler },
{ key: 'n', meta: true, handler: newHandler },
{ key: 'Escape', handler: escHandler },
];
renderHook(() => useKeyboardShortcuts(shortcuts));
fireKey('k', { metaKey: true });
expect(searchHandler).toHaveBeenCalledTimes(1);
expect(newHandler).not.toHaveBeenCalled();
fireKey('n', { metaKey: true });
expect(newHandler).toHaveBeenCalledTimes(1);
fireKey('Escape');
expect(escHandler).toHaveBeenCalledTimes(1);
});
it('cleans up event listener on unmount', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'k', meta: true, handler }];
const { unmount } = renderHook(() => useKeyboardShortcuts(shortcuts));
unmount();
fireKey('k', { metaKey: true });
expect(handler).not.toHaveBeenCalled();
});
it('handles empty shortcuts array without errors', () => {
expect(() => {
renderHook(() => useKeyboardShortcuts([]));
}).not.toThrow();
});
it('matches keys case-insensitively', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'K', meta: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
fireKey('k', { metaKey: true });
expect(handler).toHaveBeenCalledTimes(1);
});
it('fires on ctrlKey as well as metaKey when meta: true', () => {
const handler = vi.fn();
const shortcuts: ShortcutDef[] = [{ key: 'k', meta: true, handler }];
renderHook(() => useKeyboardShortcuts(shortcuts));
fireKey('k', { ctrlKey: true });
expect(handler).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,2 @@
export { useKeyboardShortcuts } from './use-keyboard-shortcuts.js';
export type { ShortcutDef } from './use-keyboard-shortcuts.js';

View File

@ -0,0 +1,59 @@
import { useEffect } from 'react';
export interface ShortcutDef {
/** Key to match (e.g. 'k', 'n', 'Escape', '/') */
key: string;
/** Require Cmd (Mac) or Ctrl (Windows/Linux) — default: false */
meta?: boolean;
/** Require Shift — default: false */
shift?: boolean;
/** Require Alt/Option — default: false */
alt?: boolean;
/** Handler to invoke when shortcut fires */
handler: () => void;
/** Allow firing when focus is in input/textarea — default: false */
allowInInput?: boolean;
/** Human-readable description for help overlay / accessibility */
description?: string;
}
function isInputElement(el: EventTarget | null): boolean {
if (!el || !(el instanceof HTMLElement)) {
return false;
}
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}
export function useKeyboardShortcuts(shortcuts: ShortcutDef[]): void {
useEffect(() => {
if (shortcuts.length === 0) {
return;
}
function handleKeyDown(e: KeyboardEvent) {
const inInput = isInputElement(e.target);
for (const shortcut of shortcuts) {
const metaMatch = shortcut.meta ? e.metaKey || e.ctrlKey : true;
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey || !shortcut.meta; // only enforce no-shift when meta is set
const altMatch = shortcut.alt ? e.altKey : true;
// Normalize key comparison (case-insensitive for letters)
const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase();
if (keyMatch && metaMatch && shiftMatch && altMatch) {
if (inInput && !shortcut.allowInInput) {
continue;
}
e.preventDefault();
shortcut.handler();
return;
}
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts]);
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}