mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 04:39:41 +02:00
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:
parent
f2d6573fa7
commit
8495a0d12f
3 changed files with 236 additions and 0 deletions
|
|
@ -74,6 +74,12 @@ export {
|
|||
type ExportCSVOptions,
|
||||
type ImportJSONOptions,
|
||||
} from './data-export';
|
||||
export {
|
||||
createKeyboardShortcuts,
|
||||
keyboardShortcuts,
|
||||
type ShortcutBinding,
|
||||
type KeyboardShortcutRegistry,
|
||||
} from './keyboard-shortcuts';
|
||||
|
||||
export {
|
||||
createGuestMode,
|
||||
|
|
|
|||
115
packages/shared-stores/src/keyboard-shortcuts.test.ts
Normal file
115
packages/shared-stores/src/keyboard-shortcuts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
packages/shared-stores/src/keyboard-shortcuts.ts
Normal file
115
packages/shared-stores/src/keyboard-shortcuts.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue