diff --git a/.changeset/late-pens-enter.md b/.changeset/late-pens-enter.md new file mode 100644 index 0000000..e65643e --- /dev/null +++ b/.changeset/late-pens-enter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +feat: add eventFilter option to hotkey registration diff --git a/docs/framework/react/guides/hotkeys.md b/docs/framework/react/guides/hotkeys.md index b95c1c1..d6dbbc7 100644 --- a/docs/framework/react/guides/hotkeys.md +++ b/docs/framework/react/guides/hotkeys.md @@ -50,6 +50,7 @@ useHotkey('Mod+S', callback, { eventType: 'keydown', requireReset: false, ignoreInputs: undefined, // smart default: false for Mod+S, true for single keys + eventFilter: undefined, // no filtering by default target: document, platform: undefined, // auto-detected conflictBehavior: 'warn', @@ -158,6 +159,41 @@ useHotkey('Enter', () => submit(), { ignoreInputs: false }) Set `ignoreInputs: false` or `true` explicitly to override the smart default. +### `eventFilter` + +A custom filter function that receives the `KeyboardEvent` and returns `false` to suppress the hotkey. Runs before all other checks except `enabled`, giving you full control over when a hotkey should fire. + +```tsx +// Ignore repeated key events (held-down keys) +useHotkey('Mod+S', () => save(), { + eventFilter: (event) => !event.repeat, +}) + +// Only fire when the target has a specific data attribute +useHotkey('K', () => openSearch(), { + eventFilter: (event) => { + return (event.target as HTMLElement)?.dataset?.hotkeys !== 'disabled' + }, +}) + +// Custom input element filtering (replaces ignoreInputs with your own logic) +useHotkey('K', () => doSomething(), { + ignoreInputs: false, + eventFilter: (event) => { + const target = event.target as HTMLElement + // Suppress in standard inputs AND custom rich-text editors + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.classList.contains('rich-editor') + ) { + return false + } + return true + }, +}) +``` + ### `target` The DOM element to attach the event listener to. Defaults to `document`. Can be a DOM element, `document`, `window`, or a React ref. diff --git a/docs/overview.md b/docs/overview.md index 528756d..9740c15 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -31,7 +31,7 @@ Surprisingly, in our experience, even AI often struggles to get hotkey managemen - `event.code` is used as a fallback for letter keys (A-Z) and digit keys (0-9) when `event.key` produces special characters (e.g., macOS Option+letter or Shift+number). - **Hotkey Registration** - - Centralized `HotkeyManager` with per-target listeners, conflict detection, and automatic input filtering + - Centralized `HotkeyManager` with per-target listeners, conflict detection, automatic input filtering, and custom event filtering via `eventFilter` - **Multi-Key Sequences** - Vim-style sequences (e.g., `['G', 'G']`, `['D', 'I', 'W']`) with configurable timeout diff --git a/packages/hotkeys/src/hotkey-manager.ts b/packages/hotkeys/src/hotkey-manager.ts index bf9e685..c9628c5 100644 --- a/packages/hotkeys/src/hotkey-manager.ts +++ b/packages/hotkeys/src/hotkey-manager.ts @@ -29,6 +29,21 @@ export interface HotkeyOptions { conflictBehavior?: ConflictBehavior /** Whether the hotkey is enabled. Defaults to true */ enabled?: boolean + /** + * Custom event filter. Return `false` to suppress the hotkey for this event. + * Runs before all other checks except `enabled`. + * + * @example + * ```ts + * manager.register('K', callback, { + * eventFilter: (event) => { + * // Only fire when the target has a specific data attribute + * return (event.target as HTMLElement)?.dataset?.hotkeys !== 'disabled' + * } + * }) + * ``` + */ + eventFilter?: (event: KeyboardEvent) => boolean /** The event type to listen for. Defaults to 'keydown' */ eventType?: 'keydown' | 'keyup' /** Whether to ignore hotkeys when keyboard events originate from input-like elements (text inputs, textarea, select, contenteditable — button-type inputs like type=button/submit/reset are not ignored). Defaults based on hotkey: true for single keys and Shift/Alt combos; false for Ctrl/Meta shortcuts and Escape */ @@ -450,6 +465,14 @@ export class HotkeyManager { continue } + // Custom event filter runs before all other filtering + if ( + registration.options.eventFilter && + !registration.options.eventFilter(event) + ) { + continue + } + // Check if we should ignore input elements (defaults to true) if (registration.options.ignoreInputs !== false) { if (isInputElement(event.target)) { diff --git a/packages/hotkeys/tests/hotkey-manager.test.ts b/packages/hotkeys/tests/hotkey-manager.test.ts index f57a85c..1d214fd 100644 --- a/packages/hotkeys/tests/hotkey-manager.test.ts +++ b/packages/hotkeys/tests/hotkey-manager.test.ts @@ -1056,6 +1056,84 @@ describe('HotkeyManager', () => { }) }) + describe('eventFilter option', () => { + it('should suppress hotkey when eventFilter returns false', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register('K', callback, { + platform: 'mac', + ignoreInputs: false, + eventFilter: () => false, + }) + + document.dispatchEvent(createKeyboardEvent('keydown', 'k')) + + expect(callback).not.toHaveBeenCalled() + }) + + it('should fire hotkey when eventFilter returns true', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register('K', callback, { + platform: 'mac', + ignoreInputs: false, + eventFilter: () => true, + }) + + const event = createKeyboardEvent('keydown', 'k') + document.dispatchEvent(event) + + expect(callback).toHaveBeenCalledWith( + event, + expect.objectContaining({ hotkey: 'K' }), + ) + }) + + it('should pass the keyboard event to eventFilter', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + const filter = vi.fn(() => true) + + manager.register('K', callback, { + platform: 'mac', + ignoreInputs: false, + eventFilter: filter, + }) + + const event = createKeyboardEvent('keydown', 'k') + document.dispatchEvent(event) + + expect(filter).toHaveBeenCalledWith(expect.any(KeyboardEvent)) + }) + + it('should allow conditional filtering based on event properties', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register('Mod+S', callback, { + platform: 'mac', + eventFilter: (event) => !event.repeat, + }) + + const repeatingEvent = new KeyboardEvent('keydown', { + key: 's', + metaKey: true, + repeat: true, + bubbles: true, + }) + document.dispatchEvent(repeatingEvent) + expect(callback).not.toHaveBeenCalled() + + const normalEvent = createKeyboardEvent('keydown', 's', { + metaKey: true, + }) + document.dispatchEvent(normalEvent) + expect(callback).toHaveBeenCalledOnce() + }) + }) + describe('conflict detection', () => { it('should warn by default when registering a conflicting hotkey', () => { const manager = HotkeyManager.getInstance()