Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/late-pens-enter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/hotkeys': patch
---

feat: add eventFilter option to hotkey registration
36 changes: 36 additions & 0 deletions docs/framework/react/guides/hotkeys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/hotkeys/src/hotkey-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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)) {
Expand Down
78 changes: 78 additions & 0 deletions packages/hotkeys/tests/hotkey-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down