feat(use-keyboard-shortcuts): shared React keyboard shortcuts hook
This commit is contained in:
parent
0f5dc91648
commit
bfa55998a2
@ -93,6 +93,7 @@ export default [
|
||||
Element: 'readonly',
|
||||
Event: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
KeyboardEventInit: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
FocusEvent: 'readonly',
|
||||
CustomEvent: 'readonly',
|
||||
|
||||
35
packages/use-keyboard-shortcuts/package.json
Normal file
35
packages/use-keyboard-shortcuts/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
2
packages/use-keyboard-shortcuts/src/index.ts
Normal file
2
packages/use-keyboard-shortcuts/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { useKeyboardShortcuts } from './use-keyboard-shortcuts.js';
|
||||
export type { ShortcutDef } from './use-keyboard-shortcuts.js';
|
||||
@ -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]);
|
||||
}
|
||||
11
packages/use-keyboard-shortcuts/tsconfig.json
Normal file
11
packages/use-keyboard-shortcuts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user