From bfa55998a2a8992ad763d891e6368f9af7c72faf Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 29 Mar 2026 12:40:43 -0700 Subject: [PATCH] feat(use-keyboard-shortcuts): shared React keyboard shortcuts hook --- eslint.config.js | 1 + packages/use-keyboard-shortcuts/package.json | 35 +++ .../__tests__/use-keyboard-shortcuts.test.ts | 200 ++++++++++++++++++ packages/use-keyboard-shortcuts/src/index.ts | 2 + .../src/use-keyboard-shortcuts.ts | 59 ++++++ packages/use-keyboard-shortcuts/tsconfig.json | 11 + 6 files changed, 308 insertions(+) create mode 100644 packages/use-keyboard-shortcuts/package.json create mode 100644 packages/use-keyboard-shortcuts/src/__tests__/use-keyboard-shortcuts.test.ts create mode 100644 packages/use-keyboard-shortcuts/src/index.ts create mode 100644 packages/use-keyboard-shortcuts/src/use-keyboard-shortcuts.ts create mode 100644 packages/use-keyboard-shortcuts/tsconfig.json diff --git a/eslint.config.js b/eslint.config.js index fb27b412..5b5cd859 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -93,6 +93,7 @@ export default [ Element: 'readonly', Event: 'readonly', KeyboardEvent: 'readonly', + KeyboardEventInit: 'readonly', MouseEvent: 'readonly', FocusEvent: 'readonly', CustomEvent: 'readonly', diff --git a/packages/use-keyboard-shortcuts/package.json b/packages/use-keyboard-shortcuts/package.json new file mode 100644 index 00000000..a9b7f8bc --- /dev/null +++ b/packages/use-keyboard-shortcuts/package.json @@ -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/" + } +} diff --git a/packages/use-keyboard-shortcuts/src/__tests__/use-keyboard-shortcuts.test.ts b/packages/use-keyboard-shortcuts/src/__tests__/use-keyboard-shortcuts.test.ts new file mode 100644 index 00000000..063f32bc --- /dev/null +++ b/packages/use-keyboard-shortcuts/src/__tests__/use-keyboard-shortcuts.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/packages/use-keyboard-shortcuts/src/index.ts b/packages/use-keyboard-shortcuts/src/index.ts new file mode 100644 index 00000000..ccbfa667 --- /dev/null +++ b/packages/use-keyboard-shortcuts/src/index.ts @@ -0,0 +1,2 @@ +export { useKeyboardShortcuts } from './use-keyboard-shortcuts.js'; +export type { ShortcutDef } from './use-keyboard-shortcuts.js'; diff --git a/packages/use-keyboard-shortcuts/src/use-keyboard-shortcuts.ts b/packages/use-keyboard-shortcuts/src/use-keyboard-shortcuts.ts new file mode 100644 index 00000000..5307c300 --- /dev/null +++ b/packages/use-keyboard-shortcuts/src/use-keyboard-shortcuts.ts @@ -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]); +} diff --git a/packages/use-keyboard-shortcuts/tsconfig.json b/packages/use-keyboard-shortcuts/tsconfig.json new file mode 100644 index 00000000..4447784f --- /dev/null +++ b/packages/use-keyboard-shortcuts/tsconfig.json @@ -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"] +}