diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 6f2ef155b..bd0868376 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -74,6 +74,12 @@ export { type ExportCSVOptions, type ImportJSONOptions, } from './data-export'; +export { + createKeyboardShortcuts, + keyboardShortcuts, + type ShortcutBinding, + type KeyboardShortcutRegistry, +} from './keyboard-shortcuts'; export { createGuestMode, diff --git a/packages/shared-stores/src/keyboard-shortcuts.test.ts b/packages/shared-stores/src/keyboard-shortcuts.test.ts new file mode 100644 index 000000000..5c26e87cf --- /dev/null +++ b/packages/shared-stores/src/keyboard-shortcuts.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createKeyboardShortcuts } from './keyboard-shortcuts'; + +describe('createKeyboardShortcuts', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = createKeyboardShortcuts(); + registry.start(); + }); + + afterEach(() => { + registry.stop(); + }); + + function fireKey(key: string, options: Partial = {}) { + 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(); + }); +}); diff --git a/packages/shared-stores/src/keyboard-shortcuts.ts b/packages/shared-stores/src/keyboard-shortcuts.ts new file mode 100644 index 000000000..7c0d0baff --- /dev/null +++ b/packages/shared-stores/src/keyboard-shortcuts.ts @@ -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 = 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();