diff --git a/.changeset/tidy-tigers-swim.md b/.changeset/tidy-tigers-swim.md new file mode 100644 index 00000000..54e79532 --- /dev/null +++ b/.changeset/tidy-tigers-swim.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +Preserve native button Space/Enter and link Enter activation when global hotkeys or sequences ignore inputs. Explicit element targets and ignoreInputs: false can still override these keys. diff --git a/docs/framework/angular/guides/hotkeys.md b/docs/framework/angular/guides/hotkeys.md index 5733b042..149b91ee 100644 --- a/docs/framework/angular/guides/hotkeys.md +++ b/docs/framework/angular/guides/hotkeys.md @@ -139,6 +139,8 @@ injectHotkey('Escape', () => closePanel(), { requireReset: true }) ### `ignoreInputs` +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + ```ts injectHotkey('K', () => openSearch()) injectHotkey('Enter', () => submit(), { ignoreInputs: false }) diff --git a/docs/framework/lit/guides/hotkeys.md b/docs/framework/lit/guides/hotkeys.md index 75a5be84..103b5f9d 100644 --- a/docs/framework/lit/guides/hotkeys.md +++ b/docs/framework/lit/guides/hotkeys.md @@ -187,7 +187,9 @@ closePanel() { this.panelOpen = false } ### `ignoreInputs` -When `true`, the hotkey doesn't fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) are not ignored. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + +When `true`, the hotkey doesn't fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) allow unrelated shortcuts. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. ```ts // Single key - ignored in inputs by default (smart default) diff --git a/docs/framework/preact/guides/hotkeys.md b/docs/framework/preact/guides/hotkeys.md index 96f1d291..320c70c4 100644 --- a/docs/framework/preact/guides/hotkeys.md +++ b/docs/framework/preact/guides/hotkeys.md @@ -165,7 +165,9 @@ useHotkey('Escape', () => closePanel(), { requireReset: true }) ### `ignoreInputs` -When `true`, the hotkey doesn't fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) are not ignored, so shortcuts like Mod+S work when the user has tabbed to a form button. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + +When `true`, the hotkey doesn't fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) allow unrelated shortcuts, so shortcuts like Mod+S work when the user has tabbed to a form button. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. ```tsx // Single key - ignored in inputs by default (smart default) diff --git a/docs/framework/react/guides/hotkeys.md b/docs/framework/react/guides/hotkeys.md index ed4d0ba0..5e997323 100644 --- a/docs/framework/react/guides/hotkeys.md +++ b/docs/framework/react/guides/hotkeys.md @@ -84,7 +84,7 @@ Most hotkey registrations exist to override the browser. When you bind `Mod+S` t #### Smart input handling with `ignoreInputs` -By default, `Ctrl`/`Meta` shortcuts (like `Mod+S`) and `Escape` fire even while focus is inside a text field or textarea, so save and close work wherever the user happens to be. Single keys and `Shift`/`Alt` combos are ignored inside non-button inputs, because those are just typing. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) don't block any hotkeys. +By default, `Ctrl`/`Meta` shortcuts (like `Mod+S`) and `Escape` fire even while focus is inside a text field or textarea, so save and close work wherever the user happens to be. Single keys and `Shift`/`Alt` combos are ignored inside non-button inputs, because those are just typing. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) allow unrelated shortcuts while preserving their native activation keys. #### Hotkey conflicts and `conflictBehavior` @@ -165,7 +165,9 @@ useHotkey('Escape', () => closePanel(), { requireReset: true }) ### `ignoreInputs` -When `true`, the hotkey will not fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) are not ignored, so shortcuts like Mod+S work when the user has tabbed to a form button. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + +When `true`, the hotkey will not fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (`type="button"`, `"submit"`, `"reset"`) allow unrelated shortcuts, so shortcuts like Mod+S work when the user has tabbed to a form button. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. ```tsx // Single key - ignored in inputs by default (smart default) diff --git a/docs/framework/solid/guides/hotkeys.md b/docs/framework/solid/guides/hotkeys.md index 00ac5403..0f262421 100644 --- a/docs/framework/solid/guides/hotkeys.md +++ b/docs/framework/solid/guides/hotkeys.md @@ -186,6 +186,8 @@ createHotkey('Escape', () => closePanel(), { requireReset: true }) ### `ignoreInputs` +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + When `true`, the hotkey doesn't fire when the user is focused on a text input, textarea, select, or contentEditable element. When unset, a smart default applies based on the hotkey type. ```tsx diff --git a/docs/framework/svelte/guides/hotkeys.md b/docs/framework/svelte/guides/hotkeys.md index 3d72eb39..46559672 100644 --- a/docs/framework/svelte/guides/hotkeys.md +++ b/docs/framework/svelte/guides/hotkeys.md @@ -127,6 +127,8 @@ createHotkey('Escape', () => closePanel(), { requireReset: true }) ### `ignoreInputs` +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + ```ts createHotkey('K', () => openSearch()) createHotkey('Enter', () => submit(), { ignoreInputs: false }) diff --git a/docs/framework/vue/guides/hotkeys.md b/docs/framework/vue/guides/hotkeys.md index cb8d73a7..d6777533 100644 --- a/docs/framework/vue/guides/hotkeys.md +++ b/docs/framework/vue/guides/hotkeys.md @@ -129,6 +129,8 @@ useHotkey('Escape', () => closePanel(), { requireReset: true }) ### `ignoreInputs` +When `ignoreInputs` is enabled, global hotkeys and sequences (targeting `document` or `window`) also preserve unmodified `Space` and `Enter` on native buttons and button-type inputs, and `Enter` on links with an `href`. Other keys and modifier shortcuts still work on these controls. Set `ignoreInputs: false`, or use an explicit element `target`, to intentionally handle their activation keys. This does not automatically detect keyboard handling in custom ARIA widgets. + ```ts useHotkey('K', () => openSearch()) useHotkey('Enter', () => submit(), { ignoreInputs: false }) diff --git a/docs/reference/classes/HotkeyManager.md b/docs/reference/classes/HotkeyManager.md index 23a15316..571c9d31 100644 --- a/docs/reference/classes/HotkeyManager.md +++ b/docs/reference/classes/HotkeyManager.md @@ -3,7 +3,7 @@ id: HotkeyManager title: HotkeyManager --- -Defined in: [hotkey-manager.ts:191](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L191) +Defined in: [hotkey-manager.ts:197](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L197) Singleton manager for hotkey registrations. @@ -32,7 +32,7 @@ unregister() readonly registrations: Store>; ``` -Defined in: [hotkey-manager.ts:213](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L213) +Defined in: [hotkey-manager.ts:219](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L219) The TanStack Store containing all hotkey registrations. Use this to subscribe to registration changes or access current registrations. @@ -61,7 +61,7 @@ for (const [id, reg] of manager.registrations.state) { destroy(): void; ``` -Defined in: [hotkey-manager.ts:796](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L796) +Defined in: [hotkey-manager.ts:802](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L802) Destroys the manager and removes all listeners. @@ -77,7 +77,7 @@ Destroys the manager and removes all listeners. getRegistrationCount(): number; ``` -Defined in: [hotkey-manager.ts:761](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L761) +Defined in: [hotkey-manager.ts:767](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L767) Gets the number of registered hotkeys. @@ -93,7 +93,7 @@ Gets the number of registered hotkeys. isRegistered(hotkey, target?): boolean; ``` -Defined in: [hotkey-manager.ts:772](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L772) +Defined in: [hotkey-manager.ts:778](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L778) Checks if a specific hotkey is registered. @@ -128,7 +128,7 @@ register( options?): HotkeyRegistrationHandle; ``` -Defined in: [hotkey-manager.ts:276](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L276) +Defined in: [hotkey-manager.ts:282](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L282) Registers a hotkey handler and returns a handle for updating the registration. @@ -184,7 +184,7 @@ handle.unregister() triggerRegistration(id): boolean; ``` -Defined in: [hotkey-manager.ts:723](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L723) +Defined in: [hotkey-manager.ts:729](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L729) Triggers a registration's callback programmatically from devtools. Creates a synthetic KeyboardEvent and invokes the callback. @@ -211,7 +211,7 @@ True if the registration was found and triggered static getInstance(): HotkeyManager; ``` -Defined in: [hotkey-manager.ts:234](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L234) +Defined in: [hotkey-manager.ts:240](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L240) Gets the singleton instance of HotkeyManager. @@ -227,7 +227,7 @@ Gets the singleton instance of HotkeyManager. static resetInstance(): void; ``` -Defined in: [hotkey-manager.ts:244](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L244) +Defined in: [hotkey-manager.ts:250](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L250) Resets the singleton instance. Useful for testing. diff --git a/docs/reference/functions/getHotkeyManager.md b/docs/reference/functions/getHotkeyManager.md index 722451ae..dde672a8 100644 --- a/docs/reference/functions/getHotkeyManager.md +++ b/docs/reference/functions/getHotkeyManager.md @@ -7,7 +7,7 @@ title: getHotkeyManager function getHotkeyManager(): HotkeyManager; ``` -Defined in: [hotkey-manager.ts:812](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L812) +Defined in: [hotkey-manager.ts:818](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L818) Gets the singleton HotkeyManager instance. Convenience function for accessing the manager. diff --git a/docs/reference/functions/toHotkeyRegistrationView.md b/docs/reference/functions/toHotkeyRegistrationView.md index ea7cb501..cad447b5 100644 --- a/docs/reference/functions/toHotkeyRegistrationView.md +++ b/docs/reference/functions/toHotkeyRegistrationView.md @@ -7,7 +7,7 @@ title: toHotkeyRegistrationView function toHotkeyRegistrationView(reg): HotkeyRegistrationView; ``` -Defined in: [hotkey-manager.ts:107](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L107) +Defined in: [hotkey-manager.ts:113](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L113) Creates a public view from an internal registration, stripping the callback function. diff --git a/docs/reference/interfaces/HotkeyOptions.md b/docs/reference/interfaces/HotkeyOptions.md index 5bbc1596..53037224 100644 --- a/docs/reference/interfaces/HotkeyOptions.md +++ b/docs/reference/interfaces/HotkeyOptions.md @@ -53,9 +53,13 @@ The event type to listen for. Defaults to 'keydown' optional ignoreInputs?: boolean; ``` -Defined in: [hotkey-manager.ts:43](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L43) +Defined in: [hotkey-manager.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L49) -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 +Ignore events from text inputs, textarea, select, and contenteditable. +Document/window targets also preserve unmodified Space/Enter on native +buttons and Enter on links. Explicit element targets can override activation. +Defaults to true for single keys and Shift/Alt combos; false for Ctrl/Meta +shortcuts and Escape. Set false to handle keys even in these controls. *** @@ -65,7 +69,7 @@ Whether to ignore hotkeys when keyboard events originate from input-like element optional meta?: HotkeyMeta; ``` -Defined in: [hotkey-manager.ts:55](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L55) +Defined in: [hotkey-manager.ts:61](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L61) Optional metadata (name, description, custom fields via declaration merging) @@ -77,7 +81,7 @@ Optional metadata (name, description, custom fields via declaration merging) optional platform?: "mac" | "windows" | "linux"; ``` -Defined in: [hotkey-manager.ts:45](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L45) +Defined in: [hotkey-manager.ts:51](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L51) The target platform for resolving 'Mod' @@ -89,7 +93,7 @@ The target platform for resolving 'Mod' optional preventDefault?: boolean; ``` -Defined in: [hotkey-manager.ts:47](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L47) +Defined in: [hotkey-manager.ts:53](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L53) Prevent the default browser action when the hotkey matches. Defaults to true @@ -101,7 +105,7 @@ Prevent the default browser action when the hotkey matches. Defaults to true optional requireReset?: boolean; ``` -Defined in: [hotkey-manager.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L49) +Defined in: [hotkey-manager.ts:55](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L55) If true, only trigger once until all keys are released. Default: false @@ -113,7 +117,7 @@ If true, only trigger once until all keys are released. Default: false optional stopPropagation?: boolean; ``` -Defined in: [hotkey-manager.ts:51](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L51) +Defined in: [hotkey-manager.ts:57](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L57) Stop event propagation when the hotkey matches. Defaults to true @@ -125,6 +129,6 @@ Stop event propagation when the hotkey matches. Defaults to true optional target?: Document | Window | HTMLElement | null; ``` -Defined in: [hotkey-manager.ts:53](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L53) +Defined in: [hotkey-manager.ts:59](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L59) The DOM element to attach the event listener to. Defaults to document. diff --git a/docs/reference/interfaces/HotkeyRegistration.md b/docs/reference/interfaces/HotkeyRegistration.md index 944b2f40..b6340c4e 100644 --- a/docs/reference/interfaces/HotkeyRegistration.md +++ b/docs/reference/interfaces/HotkeyRegistration.md @@ -3,7 +3,7 @@ id: HotkeyRegistration title: HotkeyRegistration --- -Defined in: [hotkey-manager.ts:61](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L61) +Defined in: [hotkey-manager.ts:67](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L67) A registered hotkey handler in the HotkeyManager. @@ -15,7 +15,7 @@ A registered hotkey handler in the HotkeyManager. optional activeMatch?: object; ``` -Defined in: [hotkey-manager.ts:67](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L67) +Defined in: [hotkey-manager.ts:73](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L73) The concrete key/code that activated a requireReset registration. @@ -39,7 +39,7 @@ key: string; callback: HotkeyCallback; ``` -Defined in: [hotkey-manager.ts:63](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L63) +Defined in: [hotkey-manager.ts:69](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L69) The callback to invoke @@ -51,7 +51,7 @@ The callback to invoke hasFired: boolean; ``` -Defined in: [hotkey-manager.ts:65](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L65) +Defined in: [hotkey-manager.ts:71](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L71) Whether this registration has fired and needs reset (for requireReset) @@ -63,7 +63,7 @@ Whether this registration has fired and needs reset (for requireReset) hotkey: Hotkey; ``` -Defined in: [hotkey-manager.ts:69](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L69) +Defined in: [hotkey-manager.ts:75](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L75) The original hotkey string @@ -75,7 +75,7 @@ The original hotkey string id: string; ``` -Defined in: [hotkey-manager.ts:71](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L71) +Defined in: [hotkey-manager.ts:77](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L77) Unique identifier for this registration @@ -87,7 +87,7 @@ Unique identifier for this registration options: HotkeyOptions; ``` -Defined in: [hotkey-manager.ts:73](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L73) +Defined in: [hotkey-manager.ts:79](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L79) Options for this registration @@ -99,7 +99,7 @@ Options for this registration parsedHotkey: ParsedHotkey; ``` -Defined in: [hotkey-manager.ts:75](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L75) +Defined in: [hotkey-manager.ts:81](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L81) The parsed hotkey @@ -111,7 +111,7 @@ The parsed hotkey target: Document | Window | HTMLElement; ``` -Defined in: [hotkey-manager.ts:77](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L77) +Defined in: [hotkey-manager.ts:83](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L83) The resolved target element for this registration @@ -123,6 +123,6 @@ The resolved target element for this registration triggerCount: number; ``` -Defined in: [hotkey-manager.ts:79](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L79) +Defined in: [hotkey-manager.ts:85](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L85) How many times this registration's callback has been triggered diff --git a/docs/reference/interfaces/HotkeyRegistrationHandle.md b/docs/reference/interfaces/HotkeyRegistrationHandle.md index 33d2dfaf..67c1a5c4 100644 --- a/docs/reference/interfaces/HotkeyRegistrationHandle.md +++ b/docs/reference/interfaces/HotkeyRegistrationHandle.md @@ -3,7 +3,7 @@ id: HotkeyRegistrationHandle title: HotkeyRegistrationHandle --- -Defined in: [hotkey-manager.ts:144](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L144) +Defined in: [hotkey-manager.ts:150](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L150) A handle returned from HotkeyManager.register() that allows updating the callback and options without re-registering the hotkey. @@ -36,7 +36,7 @@ handle.unregister() callback: HotkeyCallback; ``` -Defined in: [hotkey-manager.ts:149](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L149) +Defined in: [hotkey-manager.ts:155](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L155) The callback function. Can be set directly to update without re-registering. This avoids stale closures when the callback references React state. @@ -49,7 +49,7 @@ This avoids stale closures when the callback references React state. readonly id: string; ``` -Defined in: [hotkey-manager.ts:151](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L151) +Defined in: [hotkey-manager.ts:157](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L157) Unique identifier for this registration @@ -61,7 +61,7 @@ Unique identifier for this registration readonly isActive: boolean; ``` -Defined in: [hotkey-manager.ts:153](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L153) +Defined in: [hotkey-manager.ts:159](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L159) Check if this registration is still active (not unregistered) @@ -73,7 +73,7 @@ Check if this registration is still active (not unregistered) setOptions: (options) => void; ``` -Defined in: [hotkey-manager.ts:158](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L158) +Defined in: [hotkey-manager.ts:164](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L164) Update options (merged with existing options). Useful for updating `enabled`, `preventDefault`, etc. without re-registering. @@ -96,7 +96,7 @@ Useful for updating `enabled`, `preventDefault`, etc. without re-registering. unregister: () => void; ``` -Defined in: [hotkey-manager.ts:160](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L160) +Defined in: [hotkey-manager.ts:166](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L166) Unregister this hotkey diff --git a/docs/reference/interfaces/HotkeyRegistrationView.md b/docs/reference/interfaces/HotkeyRegistrationView.md index 71c34bb8..93e1b3d5 100644 --- a/docs/reference/interfaces/HotkeyRegistrationView.md +++ b/docs/reference/interfaces/HotkeyRegistrationView.md @@ -3,7 +3,7 @@ id: HotkeyRegistrationView title: HotkeyRegistrationView --- -Defined in: [hotkey-manager.ts:86](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L86) +Defined in: [hotkey-manager.ts:92](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L92) Public view of a hotkey registration for display and introspection. Omits the callback function which is an internal implementation detail. @@ -16,7 +16,7 @@ Omits the callback function which is an internal implementation detail. hasFired: boolean; ``` -Defined in: [hotkey-manager.ts:100](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L100) +Defined in: [hotkey-manager.ts:106](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L106) Whether this registration has fired and needs reset (for requireReset) @@ -28,7 +28,7 @@ Whether this registration has fired and needs reset (for requireReset) hotkey: Hotkey; ``` -Defined in: [hotkey-manager.ts:88](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L88) +Defined in: [hotkey-manager.ts:94](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L94) The original hotkey string @@ -40,7 +40,7 @@ The original hotkey string id: string; ``` -Defined in: [hotkey-manager.ts:90](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L90) +Defined in: [hotkey-manager.ts:96](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L96) Unique identifier for this registration @@ -52,7 +52,7 @@ Unique identifier for this registration options: HotkeyOptions; ``` -Defined in: [hotkey-manager.ts:92](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L92) +Defined in: [hotkey-manager.ts:98](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L98) Options for this registration @@ -64,7 +64,7 @@ Options for this registration parsedHotkey: ParsedHotkey; ``` -Defined in: [hotkey-manager.ts:94](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L94) +Defined in: [hotkey-manager.ts:100](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L100) The parsed hotkey @@ -76,7 +76,7 @@ The parsed hotkey target: Document | Window | HTMLElement; ``` -Defined in: [hotkey-manager.ts:96](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L96) +Defined in: [hotkey-manager.ts:102](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L102) The resolved target element for this registration @@ -88,6 +88,6 @@ The resolved target element for this registration triggerCount: number; ``` -Defined in: [hotkey-manager.ts:98](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L98) +Defined in: [hotkey-manager.ts:104](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L104) How many times this registration's callback has been triggered diff --git a/docs/reference/interfaces/SequenceOptions.md b/docs/reference/interfaces/SequenceOptions.md index 19991eb2..fbae989b 100644 --- a/docs/reference/interfaces/SequenceOptions.md +++ b/docs/reference/interfaces/SequenceOptions.md @@ -70,9 +70,13 @@ The event type to listen for. Defaults to 'keydown' optional ignoreInputs?: boolean; ``` -Defined in: [hotkey-manager.ts:43](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L43) +Defined in: [hotkey-manager.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L49) -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 +Ignore events from text inputs, textarea, select, and contenteditable. +Document/window targets also preserve unmodified Space/Enter on native +buttons and Enter on links. Explicit element targets can override activation. +Defaults to true for single keys and Shift/Alt combos; false for Ctrl/Meta +shortcuts and Escape. Set false to handle keys even in these controls. #### Inherited from @@ -86,7 +90,7 @@ Whether to ignore hotkeys when keyboard events originate from input-like element optional meta?: HotkeyMeta; ``` -Defined in: [hotkey-manager.ts:55](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L55) +Defined in: [hotkey-manager.ts:61](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L61) Optional metadata (name, description, custom fields via declaration merging) @@ -102,7 +106,7 @@ Optional metadata (name, description, custom fields via declaration merging) optional platform?: "mac" | "windows" | "linux"; ``` -Defined in: [hotkey-manager.ts:45](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L45) +Defined in: [hotkey-manager.ts:51](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L51) The target platform for resolving 'Mod' @@ -118,7 +122,7 @@ The target platform for resolving 'Mod' optional preventDefault?: boolean; ``` -Defined in: [hotkey-manager.ts:47](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L47) +Defined in: [hotkey-manager.ts:53](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L53) Prevent the default browser action when the hotkey matches. Defaults to true @@ -134,7 +138,7 @@ Prevent the default browser action when the hotkey matches. Defaults to true optional stopPropagation?: boolean; ``` -Defined in: [hotkey-manager.ts:51](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L51) +Defined in: [hotkey-manager.ts:57](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L57) Stop event propagation when the hotkey matches. Defaults to true @@ -150,7 +154,7 @@ Stop event propagation when the hotkey matches. Defaults to true optional target?: Document | Window | HTMLElement | null; ``` -Defined in: [hotkey-manager.ts:53](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L53) +Defined in: [hotkey-manager.ts:59](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L59) The DOM element to attach the event listener to. Defaults to document. diff --git a/packages/hotkeys/src/_event-target.ts b/packages/hotkeys/src/_event-target.ts index 9978beb9..eba1b388 100644 --- a/packages/hotkeys/src/_event-target.ts +++ b/packages/hotkeys/src/_event-target.ts @@ -1,3 +1,5 @@ +import { normalizeKeyName } from './constants' + /** * Checks if an element is an input-like element that should be ignored for hotkeys. * @@ -69,9 +71,30 @@ export function getActiveElementForListenerTarget( return (target as Window).document.activeElement ?? null } +/** Native buttons use Space/Enter; links only use Enter for activation. */ +function isNativeActivationElement( + element: EventTarget | null, + key: string, +): boolean { + if (!element || !('tagName' in element)) return false + const control = element as HTMLElement + const tagName = control.tagName.toLowerCase() + if (tagName === 'button') return true + if (tagName === 'input') { + const type = (control as HTMLInputElement).type + return type === 'button' || type === 'submit' || type === 'reset' + } + return ( + key === 'Enter' && + (tagName === 'a' || tagName === 'area') && + control.hasAttribute('href') + ) +} + /** * Returns whether an event should be ignored because it originated from an - * input-like element other than the registration target. + * input-like element other than the registration target. Document/window + * registrations also leave unmodified native control activation keys alone. * * This checks: * - the currently focused element for the listener target @@ -83,22 +106,29 @@ export function shouldIgnoreInputEvent( listenerTarget: HTMLElement | Document | Window, registrationTarget: HTMLElement | Document | Window, ): boolean { + const key = normalizeKeyName(event.key) + const preserveActivation = + ('document' in registrationTarget || registrationTarget.nodeType === 9) && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + (key === 'Space' || key === 'Enter') + const shouldIgnore = (element: EventTarget | null) => + element !== registrationTarget && + (isInputElement(element) || + (preserveActivation && isNativeActivationElement(element, key))) + const focused = getActiveElementForListenerTarget(listenerTarget) - if (focused && isInputElement(focused) && focused !== registrationTarget) { + if (shouldIgnore(focused)) { return true } - if ( - event - .composedPath() - .some( - (element) => isInputElement(element) && element !== registrationTarget, - ) - ) { + if (event.composedPath().some(shouldIgnore)) { return true } - return isInputElement(event.target) && event.target !== registrationTarget + return shouldIgnore(event.target) } /** diff --git a/packages/hotkeys/src/hotkey-manager.ts b/packages/hotkeys/src/hotkey-manager.ts index 6a2f0c4e..5ca75744 100644 --- a/packages/hotkeys/src/hotkey-manager.ts +++ b/packages/hotkeys/src/hotkey-manager.ts @@ -39,7 +39,13 @@ export interface HotkeyOptions { enabled?: 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 */ + /** + * Ignore events from text inputs, textarea, select, and contenteditable. + * Document/window targets also preserve unmodified Space/Enter on native + * buttons and Enter on links. Explicit element targets can override activation. + * Defaults to true for single keys and Shift/Alt combos; false for Ctrl/Meta + * shortcuts and Escape. Set false to handle keys even in these controls. + */ ignoreInputs?: boolean /** The target platform for resolving 'Mod' */ platform?: 'mac' | 'windows' | 'linux' diff --git a/packages/hotkeys/tests/native-activation.test.ts b/packages/hotkeys/tests/native-activation.test.ts new file mode 100644 index 00000000..61a4a07c --- /dev/null +++ b/packages/hotkeys/tests/native-activation.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HotkeyManager } from '../src/hotkey-manager' +import { SequenceManager } from '../src/sequence-manager' +import type { Hotkey, HotkeyOptions } from '../src' + +function focusControl(markup = '') { + document.body.innerHTML = markup + const element = document.body.firstElementChild as HTMLElement + element.focus() + return element +} + +function dispatchKey( + element: EventTarget, + key: string, + options: KeyboardEventInit = {}, + type = 'keydown', +) { + const event = new KeyboardEvent(type, { + key, + bubbles: true, + cancelable: true, + composed: true, + ...options, + }) + element.dispatchEvent(event) + return event +} + +afterEach(() => { + HotkeyManager.resetInstance() + SequenceManager.resetInstance() + document.body.innerHTML = '' +}) + +describe.each(['hotkey', 'sequence'] as const)( + '%s native activation', + (kind) => { + function register(hotkey: Hotkey, options: HotkeyOptions = {}) { + const callback = vi.fn() + if (kind === 'hotkey') { + HotkeyManager.getInstance().register(hotkey, callback, options) + } else { + SequenceManager.getInstance().register([hotkey], callback, options) + } + return callback + } + + it.each([ + '', + '', + '', + '', + ])('preserves Space and Enter on %s', (markup) => { + const element = focusControl(markup) + const space = register('Space') + const enter = register('Enter') + + expect(dispatchKey(element, ' ').defaultPrevented).toBe(false) + expect(dispatchKey(element, 'Enter').defaultPrevented).toBe(false) + expect(space).not.toHaveBeenCalled() + expect(enter).not.toHaveBeenCalled() + }) + + it('preserves Enter on links but still handles Space', () => { + const element = focusControl('Link') + const enter = register('Enter') + const space = register('Space') + + expect(dispatchKey(element, 'Enter').defaultPrevented).toBe(false) + expect(enter).not.toHaveBeenCalled() + expect(dispatchKey(element, ' ').defaultPrevented).toBe(true) + expect(space).toHaveBeenCalledOnce() + }) + + it('still handles Enter on an anchor without href', () => { + const element = focusControl('Placeholder') + const callback = register('Enter') + dispatchKey(element, 'Enter') + expect(callback).toHaveBeenCalledOnce() + }) + + it.each(['keydown', 'keyup'] as const)( + 'preserves activation for %s listeners', + (eventType) => { + const element = focusControl() + const callback = register('Space', { eventType }) + expect(dispatchKey(element, ' ', {}, eventType).defaultPrevented).toBe( + false, + ) + expect(callback).not.toHaveBeenCalled() + element.blur() + dispatchKey(document.body, ' ', {}, eventType) + expect(callback).toHaveBeenCalledOnce() + }, + ) + + it('preserves explicit ignoreInputs: false', () => { + const element = focusControl() + const callback = register('Space', { ignoreInputs: false }) + expect(dispatchKey(element, ' ').defaultPrevented).toBe(true) + expect(callback).toHaveBeenCalledOnce() + }) + + it.each(['control', 'container'] as const)( + 'preserves explicit %s targets', + (scope) => { + const container = focusControl('
') + const element = container.firstElementChild as HTMLElement + element.focus() + const callback = register('Enter', { + target: scope === 'control' ? element : container, + }) + expect(dispatchKey(element, 'Enter').defaultPrevented).toBe(true) + expect(callback).toHaveBeenCalledOnce() + }, + ) + + it.each([ + ['K', 'k', {}], + ['Escape', 'Escape', {}], + ['Mod+S', 's', { metaKey: true }], + ['Shift+Space', ' ', { shiftKey: true }], + ['Alt+Enter', 'Enter', { altKey: true, code: 'Enter' }], + ['Control+Enter', 'Enter', { ctrlKey: true }], + ['Meta+Enter', 'Enter', { metaKey: true }], + ] as const)('preserves %s on buttons', (hotkey, key, modifiers) => { + const element = focusControl() + const callback = register(hotkey, { platform: 'mac' }) + dispatchKey(element, key, modifiers) + expect(callback).toHaveBeenCalledOnce() + }) + + it('protects a button in an open shadow root', () => { + const host = focusControl('
') + const shadow = host.attachShadow({ mode: 'open' }) + const button = document.createElement('button') + shadow.append(button) + button.focus() + const callback = register('Enter') + expect(dispatchKey(button, 'Enter').defaultPrevented).toBe(false) + expect(callback).not.toHaveBeenCalled() + }) + + it('still handles activation keys when no native control has focus', () => { + const element = focusControl('
Content
') + const callback = register('Space') + dispatchKey(element, ' ') + expect(callback).toHaveBeenCalledOnce() + }) + }, +) + +it('does not consume native activation as a sequence step', () => { + const element = focusControl() + const callback = vi.fn() + const manager = SequenceManager.getInstance() + const handle = manager.register(['Space', 'G'], callback) + dispatchKey(element, ' ') + expect(manager.registrations.state.get(handle.id)?.matchedStepCount).toBe(0) + element.blur() + dispatchKey(document.body, 'g') + expect(callback).not.toHaveBeenCalled() +}) + +it('preserves native activation for a physical-code binding', () => { + const element = focusControl() + const callback = vi.fn() + HotkeyManager.getInstance().register({ code: 'NumpadEnter' }, callback) + expect( + dispatchKey(element, 'Enter', { code: 'NumpadEnter' }).defaultPrevented, + ).toBe(false) + expect(callback).not.toHaveBeenCalled() +})