feat(shared-stores): add centralized keyboard shortcuts registry

createKeyboardShortcuts with register/unregister, Ctrl/Shift/Alt
modifiers, input-focus awareness, Mac Cmd support, case-insensitive
matching. Singleton keyboardShortcuts instance for app-wide use.
11 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 17:03:38 +02:00
parent f2d6573fa7
commit 8495a0d12f
3 changed files with 236 additions and 0 deletions

View file

@ -74,6 +74,12 @@ export {
type ExportCSVOptions,
type ImportJSONOptions,
} from './data-export';
export {
createKeyboardShortcuts,
keyboardShortcuts,
type ShortcutBinding,
type KeyboardShortcutRegistry,
} from './keyboard-shortcuts';
export {
createGuestMode,

View file

@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createKeyboardShortcuts } from './keyboard-shortcuts';
describe('createKeyboardShortcuts', () => {
let registry: ReturnType<typeof createKeyboardShortcuts>;
beforeEach(() => {
registry = createKeyboardShortcuts();
registry.start();
});
afterEach(() => {
registry.stop();
});
function fireKey(key: string, options: Partial<KeyboardEventInit> = {}) {
const event = new KeyboardEvent('keydown', { key, bubbles: true, ...options });
document.dispatchEvent(event);
}
it('fires handler on matching key', () => {
const handler = vi.fn();
registry.register([{ key: 'n', handler }]);
fireKey('n');
expect(handler).toHaveBeenCalledOnce();
});
it('does not fire for non-matching key', () => {
const handler = vi.fn();
registry.register([{ key: 'n', handler }]);
fireKey('m');
expect(handler).not.toHaveBeenCalled();
});
it('matches Ctrl modifier', () => {
const handler = vi.fn();
registry.register([{ key: 'n', ctrl: true, handler }]);
fireKey('n', { ctrlKey: true });
expect(handler).toHaveBeenCalledOnce();
});
it('does not fire Ctrl shortcut without Ctrl', () => {
const handler = vi.fn();
registry.register([{ key: 'n', ctrl: true, handler }]);
fireKey('n');
expect(handler).not.toHaveBeenCalled();
});
it('matches Shift modifier', () => {
const handler = vi.fn();
registry.register([{ key: '?', shift: true, handler }]);
fireKey('?', { shiftKey: true });
expect(handler).toHaveBeenCalledOnce();
});
it('unsubscribe removes bindings', () => {
const handler = vi.fn();
const unsub = registry.register([{ key: 'x', handler }]);
unsub();
fireKey('x');
expect(handler).not.toHaveBeenCalled();
});
it('ignores input-focused shortcuts by default', () => {
const handler = vi.fn();
registry.register([{ key: 'n', handler }]);
// Simulate input focus
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
fireKey('n');
expect(handler).not.toHaveBeenCalled();
document.body.removeChild(input);
});
it('fires in input when ignoreInputs=false', () => {
const handler = vi.fn();
registry.register([{ key: 'Escape', handler, ignoreInputs: false }]);
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
fireKey('Escape');
expect(handler).toHaveBeenCalledOnce();
document.body.removeChild(input);
});
it('getAll returns all registered shortcuts', () => {
registry.register([
{ key: 'a', handler: vi.fn() },
{ key: 'b', handler: vi.fn() },
]);
expect(registry.getAll()).toHaveLength(2);
});
it('stop prevents further event handling', () => {
const handler = vi.fn();
registry.register([{ key: 'n', handler }]);
registry.stop();
fireKey('n');
expect(handler).not.toHaveBeenCalled();
});
it('case-insensitive key matching', () => {
const handler = vi.fn();
registry.register([{ key: 'n', handler }]);
fireKey('N');
expect(handler).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,115 @@
/**
* Keyboard Shortcuts Registry
*
* Centralized keyboard shortcut handler. Modules register shortcuts,
* the registry listens for keydown events and dispatches to handlers.
*
* @example
* ```typescript
* import { keyboardShortcuts } from '@manacore/shared-stores';
*
* // Register shortcuts (typically in onMount)
* const unsubscribe = keyboardShortcuts.register([
* { key: 'n', ctrl: true, handler: () => createNew(), description: 'Neu erstellen' },
* { key: 'Escape', handler: () => closeModal(), description: 'Schließen' },
* { key: '/', handler: () => focusSearch(), description: 'Suchen' },
* ]);
*
* // Cleanup (in onDestroy)
* unsubscribe();
* ```
*/
export interface ShortcutBinding {
/** Key to match (e.g. 'n', 'Escape', '/') */
key: string;
/** Require Ctrl (or Cmd on Mac) */
ctrl?: boolean;
/** Require Shift */
shift?: boolean;
/** Require Alt */
alt?: boolean;
/** Handler function */
handler: () => void;
/** Description (for help modal) */
description?: string;
/** Only fire when no input/textarea is focused (default: true) */
ignoreInputs?: boolean;
}
export interface KeyboardShortcutRegistry {
/** Register shortcuts, returns unsubscribe function */
register(bindings: ShortcutBinding[]): () => void;
/** Start listening for keyboard events */
start(): void;
/** Stop listening */
stop(): void;
/** Get all registered shortcuts (for help display) */
getAll(): ShortcutBinding[];
}
export function createKeyboardShortcuts(): KeyboardShortcutRegistry {
const allBindings: Set<ShortcutBinding> = new Set();
let listening = false;
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
return (
tag === 'input' ||
tag === 'textarea' ||
tag === 'select' ||
(el as HTMLElement).isContentEditable
);
}
function handleKeyDown(e: KeyboardEvent) {
const isMac = navigator.platform.includes('Mac');
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
for (const binding of allBindings) {
if (binding.key.toLowerCase() !== e.key.toLowerCase()) continue;
if (binding.ctrl && !ctrlKey) continue;
if (binding.shift && !e.shiftKey) continue;
if (binding.alt && !e.altKey) continue;
if (!binding.ctrl && ctrlKey && binding.key.length === 1) continue; // Don't fire 'n' on Ctrl+N
if (binding.ignoreInputs !== false && isInputFocused()) continue;
e.preventDefault();
binding.handler();
return;
}
}
return {
register(bindings: ShortcutBinding[]): () => void {
for (const b of bindings) {
allBindings.add(b);
}
return () => {
for (const b of bindings) {
allBindings.delete(b);
}
};
},
start() {
if (listening) return;
document.addEventListener('keydown', handleKeyDown);
listening = true;
},
stop() {
document.removeEventListener('keydown', handleKeyDown);
listening = false;
},
getAll(): ShortcutBinding[] {
return [...allBindings];
},
};
}
/** Singleton instance for the app */
export const keyboardShortcuts = createKeyboardShortcuts();