diff --git a/README.md b/README.md index 2da2e21..4609ad9 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > You may know **TanStack Hotkeys** by our adapter names, too! > > - [**React Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/react/react-hotkeys) -> - Solid Hotkeys – needs a contributor! +> - [**Preact Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/preact/preact-hotkeys) +> - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/solid-hotkeys) > - Angular Hotkeys – needs a contributor! > - Svelte Hotkeys – needs a contributor! > - Vue Hotkeys – needs a contributor! diff --git a/docs/config.json b/docs/config.json index b2dc055..92e9bf8 100644 --- a/docs/config.json +++ b/docs/config.json @@ -31,6 +31,24 @@ "to": "framework/react/quick-start" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "Preact Hooks", + "to": "framework/preact/reference/index" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "Solid Primitives", + "to": "framework/solid/reference/index" + } + ] } ] }, @@ -62,6 +80,56 @@ "to": "framework/react/guides/formatting-display" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "Hotkeys", + "to": "framework/preact/guides/hotkeys" + }, + { + "label": "Sequences", + "to": "framework/preact/guides/sequences" + }, + { + "label": "Hotkey Recording", + "to": "framework/preact/guides/hotkey-recording" + }, + { + "label": "Key State Tracking", + "to": "framework/preact/guides/key-state-tracking" + }, + { + "label": "Formatting & Display", + "to": "framework/preact/guides/formatting-display" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "Hotkeys", + "to": "framework/solid/guides/hotkeys" + }, + { + "label": "Sequences", + "to": "framework/solid/guides/sequences" + }, + { + "label": "Hotkey Recording", + "to": "framework/solid/guides/hotkey-recording" + }, + { + "label": "Key State Tracking", + "to": "framework/solid/guides/key-state-tracking" + }, + { + "label": "Formatting & Display", + "to": "framework/solid/guides/formatting-display" + } + ] } ] }, @@ -82,6 +150,24 @@ "to": "framework/react/reference/index" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "Preact Hooks", + "to": "framework/preact/reference/index" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "Solid Primitives", + "to": "framework/solid/reference/index" + } + ] } ] }, @@ -136,6 +222,32 @@ "to": "framework/react/reference/interfaces/UseHotkeyOptions" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "useHotkey", + "to": "framework/preact/reference/functions/useHotkey" + }, + { + "label": "UseHotkeyOptions", + "to": "framework/preact/reference/interfaces/UseHotkeyOptions" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "createHotkey", + "to": "framework/solid/reference/functions/createHotkey" + }, + { + "label": "CreateHotkeyOptions", + "to": "framework/solid/reference/interfaces/CreateHotkeyOptions" + } + ] } ] }, @@ -178,6 +290,32 @@ "to": "framework/react/reference/interfaces/UseHotkeySequenceOptions" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "useHotkeySequence", + "to": "framework/preact/reference/functions/useHotkeySequence" + }, + { + "label": "UseHotkeySequenceOptions", + "to": "framework/preact/reference/interfaces/UseHotkeySequenceOptions" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "createHotkeySequence", + "to": "framework/solid/reference/functions/createHotkeySequence" + }, + { + "label": "CreateHotkeySequenceOptions", + "to": "framework/solid/reference/interfaces/CreateHotkeySequenceOptions" + } + ] } ] }, @@ -212,6 +350,24 @@ "to": "framework/react/reference/functions/useKeyHold" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "useKeyHold", + "to": "framework/preact/reference/functions/useKeyHold" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "createKeyHold", + "to": "framework/solid/reference/functions/createKeyHold" + } + ] } ] }, @@ -250,6 +406,32 @@ "to": "framework/react/reference/functions/useHeldKeyCodes" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "useHeldKeys", + "to": "framework/preact/reference/functions/useHeldKeys" + }, + { + "label": "useHeldKeyCodes", + "to": "framework/preact/reference/functions/useHeldKeyCodes" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "createHeldKeys", + "to": "framework/solid/reference/functions/createHeldKeys" + }, + { + "label": "createHeldKeyCodes", + "to": "framework/solid/reference/functions/createHeldKeyCodes" + } + ] } ] }, @@ -284,6 +466,32 @@ "to": "framework/react/reference/interfaces/ReactHotkeyRecorder" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "useHotkeyRecorder", + "to": "framework/preact/reference/functions/useHotkeyRecorder" + }, + { + "label": "PreactHotkeyRecorder", + "to": "framework/preact/reference/interfaces/PreactHotkeyRecorder" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "createHotkeyRecorder", + "to": "framework/solid/reference/functions/createHotkeyRecorder" + }, + { + "label": "SolidHotkeyRecorder", + "to": "framework/solid/reference/interfaces/SolidHotkeyRecorder" + } + ] } ] }, @@ -374,6 +582,56 @@ "to": "framework/react/examples/useKeyhold" } ] + }, + { + "label": "preact", + "children": [ + { + "label": "useHotkey", + "to": "framework/preact/examples/useHotkey" + }, + { + "label": "useHotkeySequence", + "to": "framework/preact/examples/useHotkeySequence" + }, + { + "label": "useHotkeyRecorder", + "to": "framework/preact/examples/useHotkeyRecorder" + }, + { + "label": "useHeldKeys", + "to": "framework/preact/examples/useHeldKeys" + }, + { + "label": "useKeyHold", + "to": "framework/preact/examples/useKeyhold" + } + ] + }, + { + "label": "solid", + "children": [ + { + "label": "createHotkey", + "to": "framework/solid/examples/createHotkey" + }, + { + "label": "createHotkeySequence", + "to": "framework/solid/examples/createHotkeySequence" + }, + { + "label": "createHotkeyRecorder", + "to": "framework/solid/examples/createHotkeyRecorder" + }, + { + "label": "createHeldKeys", + "to": "framework/solid/examples/createHeldKeys" + }, + { + "label": "createKeyHold", + "to": "framework/solid/examples/createKeyHold" + } + ] } ] } diff --git a/docs/framework/preact/guides/formatting-display.md b/docs/framework/preact/guides/formatting-display.md new file mode 100644 index 0000000..741686a --- /dev/null +++ b/docs/framework/preact/guides/formatting-display.md @@ -0,0 +1,216 @@ +--- +title: Formatting & Display Guide +id: formatting-display +--- + +TanStack Hotkeys provides several utilities for formatting hotkey strings into human-readable display text. These utilities handle platform differences automatically, so your UI shows the right symbols and labels for each operating system. + +## `formatForDisplay` + +The primary formatting function. Returns a platform-aware string using symbols on macOS and text labels on Windows/Linux. + +```tsx +import { formatForDisplay } from '@tanstack/preact-hotkeys' + +// On macOS: +formatForDisplay('Mod+S') // "⌘S" +formatForDisplay('Mod+Shift+Z') // "⇧⌘Z" +formatForDisplay('Control+Alt+D') // "⌃⌥D" + +// On Windows/Linux: +formatForDisplay('Mod+S') // "Ctrl+S" +formatForDisplay('Mod+Shift+Z') // "Ctrl+Shift+Z" +formatForDisplay('Control+Alt+D') // "Ctrl+Alt+D" +``` + +### Options + +```ts +formatForDisplay('Mod+S', { + platform: 'mac', // Override platform detection ('mac' | 'windows' | 'linux') +}) +``` + +On macOS, modifiers are joined without a separator (e.g., `⇧⌘Z`). On Windows and Linux, modifiers are joined with `+` (e.g., `Ctrl+Shift+Z`). This behavior is automatic and not configurable. + +## `formatWithLabels` + +Returns human-readable text labels (e.g., "Cmd" instead of the symbol). Useful when you want readable text rather than symbols. + +```tsx +import { formatWithLabels } from '@tanstack/preact-hotkeys' + +// On macOS: +formatWithLabels('Mod+S') // "Cmd+S" +formatWithLabels('Mod+Shift+Z') // "Cmd+Shift+Z" + +// On Windows/Linux: +formatWithLabels('Mod+S') // "Ctrl+S" +formatWithLabels('Mod+Shift+Z') // "Ctrl+Shift+Z" +``` + +## `formatKeyForDebuggingDisplay` + +Returns a rich label intended for devtools and debugging. Includes both the symbol and a descriptive label. + +```tsx +import { formatKeyForDebuggingDisplay } from '@tanstack/preact-hotkeys' + +// On macOS: +formatKeyForDebuggingDisplay('Meta') // "⌘ Mod (Cmd)" +formatKeyForDebuggingDisplay('Shift') // "⇧ Shift" +formatKeyForDebuggingDisplay('Control') // "⌃ Control" +``` + +## Using Formatted Hotkeys in Preact + +### Keyboard Shortcut Badges + +```tsx +import { formatForDisplay } from '@tanstack/preact-hotkeys' + +function ShortcutBadge({ hotkey }: { hotkey: string }) { + return {formatForDisplay(hotkey)} +} + +// Usage + // Renders: ⌘S (Mac) or Ctrl+S (Windows) + // Renders: ⇧⌘P (Mac) or Ctrl+Shift+P (Windows) +``` + +### Menu Items with Hotkeys + +```tsx +import { useHotkey, formatForDisplay } from '@tanstack/preact-hotkeys' + +function MenuItem({ + label, + hotkey, + onAction, +}: { + label: string + hotkey: string + onAction: () => void +}) { + useHotkey(hotkey, () => onAction()) + + return ( +
+ {label} + {formatForDisplay(hotkey)} +
+ ) +} + +// Usage + + + +``` + +### Command Palette Items + +```tsx +import { formatForDisplay } from '@tanstack/preact-hotkeys' +import type { Hotkey } from '@tanstack/preact-hotkeys' + +interface Command { + id: string + label: string + hotkey?: Hotkey + action: () => void +} + +function CommandPaletteItem({ command }: { command: Command }) { + return ( +
+ {command.label} + {command.hotkey && ( + {formatForDisplay(command.hotkey)} + )} +
+ ) +} +``` + +## Platform Symbols Reference + +On macOS, modifiers are displayed as symbols: + +| Modifier | Mac Symbol | Windows/Linux Label | +|----------|-----------|-------------------| +| Meta (Cmd) | `⌘` | `Win` / `Super` | +| Control | `⌃` | `Ctrl` | +| Alt/Option | `⌥` | `Alt` | +| Shift | `⇧` | `Shift` | + +Special keys also have display symbols: + +| Key | Display | +|-----|---------| +| Escape | `Esc` | +| Backspace | `⌫` (Mac) / `Backspace` | +| Delete | `⌦` (Mac) / `Del` | +| Enter | `↵` | +| Tab | `⇥` | +| ArrowUp | `↑` | +| ArrowDown | `↓` | +| ArrowLeft | `←` | +| ArrowRight | `→` | +| Space | `Space` | + +## Parsing and Normalization + +TanStack Hotkeys also provides utilities for parsing and normalizing hotkey strings: + +### `parseHotkey` + +Parse a hotkey string into its component parts: + +```ts +import { parseHotkey } from '@tanstack/preact-hotkeys' + +const parsed = parseHotkey('Mod+Shift+S') +// { +// key: 'S', +// ctrl: false, // true on Windows/Linux +// shift: true, +// alt: false, +// meta: true, // true on Mac +// modifiers: ['Shift', 'Meta'] // or ['Control', 'Shift'] on Windows +// } +``` + +### `normalizeHotkey` + +Normalize a hotkey string to its canonical form: + +```ts +import { normalizeHotkey } from '@tanstack/preact-hotkeys' + +normalizeHotkey('Cmd+S') // 'Meta+S' (on Mac) +normalizeHotkey('Ctrl+Shift+s') // 'Control+Shift+S' +normalizeHotkey('Mod+S') // 'Meta+S' (on Mac) or 'Control+S' (on Windows) +``` + +## Validation + +Use `validateHotkey` to check if a hotkey string is valid and get warnings about potential platform issues: + +```ts +import { validateHotkey } from '@tanstack/preact-hotkeys' + +const result = validateHotkey('Alt+A') +// { +// valid: true, +// warnings: ['Alt+letter combinations may not work on macOS due to special characters'], +// errors: [] +// } + +const result2 = validateHotkey('InvalidKey+S') +// { +// valid: false, +// warnings: [], +// errors: ['Unknown key: InvalidKey'] +// } +``` diff --git a/docs/framework/preact/guides/hotkey-recording.md b/docs/framework/preact/guides/hotkey-recording.md new file mode 100644 index 0000000..75c54ec --- /dev/null +++ b/docs/framework/preact/guides/hotkey-recording.md @@ -0,0 +1,168 @@ +--- +title: Hotkey Recording Guide +id: hotkey-recording +--- + +TanStack Hotkeys provides the `useHotkeyRecorder` hook for building keyboard shortcut customization UIs. This lets users record their own shortcuts by pressing the desired key combination, similar to how system preferences or IDE shortcut editors work. + +## Basic Usage + +```tsx +import { useHotkeyRecorder, formatForDisplay } from '@tanstack/preact-hotkeys' + +function ShortcutRecorder() { + const { isRecording, recordedHotkey, startRecording, stopRecording, cancelRecording } = + useHotkeyRecorder({ + onRecord: (hotkey) => { + console.log('Recorded:', hotkey) // e.g., "Mod+Shift+S" + }, + }) + + return ( +
+ + {isRecording && ( + + )} +
+ ) +} +``` + +## Return Value + +The `useHotkeyRecorder` hook returns an object with: + +| Property | Type | Description | +|----------|------|-------------| +| `isRecording` | `boolean` | Whether the recorder is currently listening for key presses | +| `recordedHotkey` | `Hotkey \| null` | The last recorded hotkey string, or `null` if nothing recorded | +| `startRecording` | `() => void` | Start listening for key presses | +| `stopRecording` | `() => void` | Stop listening and keep the recorded hotkey | +| `cancelRecording` | `() => void` | Stop listening and discard any recorded hotkey | + +## Options + +```tsx +useHotkeyRecorder({ + onRecord: (hotkey) => { /* called when a hotkey is recorded */ }, + onCancel: () => { /* called when recording is cancelled */ }, + onClear: () => { /* called when the recorded hotkey is cleared */ }, +}) +``` + +### `onRecord` + +Called when the user presses a valid key combination (a modifier + a non-modifier key, or a single non-modifier key). Receives the recorded `Hotkey` string. + +### `onCancel` + +Called when recording is cancelled (either by pressing Escape or calling `cancelRecording()`). + +### `onClear` + +Called when the recorded hotkey is cleared (by pressing Backspace or Delete during recording). + +### Global Default Options via Provider + +You can set default options for all `useHotkeyRecorder` calls by wrapping your component tree with `HotkeysProvider`. Per-hook options will override the provider defaults. + +```tsx +import { HotkeysProvider } from '@tanstack/preact-hotkeys' + + console.log('Recording cancelled'), + }, + }} +> + + +``` + +## Recording Behavior + +The recorder has specific behavior for different keys: + +| Key | Behavior | +|-----|----------| +| **Modifier only** (Shift, Ctrl, etc.) | Waits for a non-modifier key -- modifier-only presses don't complete a recording | +| **Modifier + key** (e.g., Ctrl+S) | Records the full combination | +| **Single key** (e.g., Escape, F1) | Records the single key | +| **Escape** | Cancels the recording | +| **Backspace / Delete** | Clears the currently recorded hotkey | + +### Mod Auto-Conversion + +Recorded hotkeys automatically use the portable `Mod` format. If a user on macOS presses Command+S, the recorded hotkey will be `Mod+S` rather than `Meta+S`. This ensures shortcuts are portable across platforms. + +## Building a Shortcut Settings UI + +Here's a more complete example of a shortcut customization panel: + +```tsx +import { useState } from 'preact/hooks' +import { + useHotkey, + useHotkeyRecorder, + formatForDisplay, +} from '@tanstack/preact-hotkeys' +import type { Hotkey } from '@tanstack/preact-hotkeys' + +function ShortcutSettings() { + const [shortcuts, setShortcuts] = useState>({ + save: 'Mod+S', + undo: 'Mod+Z', + search: 'Mod+K', + }) + + const [editingAction, setEditingAction] = useState(null) + + const recorder = useHotkeyRecorder({ + onRecord: (hotkey) => { + if (editingAction) { + setShortcuts((prev) => ({ ...prev, [editingAction]: hotkey })) + setEditingAction(null) + } + }, + onCancel: () => setEditingAction(null), + }) + + // Register the actual hotkeys with their current bindings + useHotkey(shortcuts.save, () => save()) + useHotkey(shortcuts.undo, () => undo()) + useHotkey(shortcuts.search, () => openSearch()) + + return ( +
+

Keyboard Shortcuts

+ {Object.entries(shortcuts).map(([action, hotkey]) => ( +
+ {action} + +
+ ))} +
+ ) +} +``` + +## Under the Hood + +The `useHotkeyRecorder` hook creates a `HotkeyRecorder` class instance and subscribes to its reactive state via `@tanstack/preact-store`. The class manages its own keyboard event listeners and state, and the hook handles cleanup on unmount. diff --git a/docs/framework/preact/guides/hotkeys.md b/docs/framework/preact/guides/hotkeys.md new file mode 100644 index 0000000..ad7aa8a --- /dev/null +++ b/docs/framework/preact/guides/hotkeys.md @@ -0,0 +1,252 @@ +--- +title: Hotkeys Guide +id: hotkeys +--- + +The `useHotkey` hook is the primary way to register keyboard shortcuts in Preact applications. It wraps the singleton `HotkeyManager` with automatic lifecycle management, stale-closure prevention, and Preact ref support. + +## Basic Usage + +```tsx +import { useHotkey } from '@tanstack/preact-hotkeys' + +function App() { + useHotkey('Mod+S', () => { + saveDocument() + }, { + // override the default options here + }) +} +``` + +The callback receives the original `KeyboardEvent` as the first argument and a `HotkeyCallbackContext` as the second: + +```tsx +useHotkey('Mod+S', (event, context) => { + console.log(context.hotkey) // 'Mod+S' + console.log(context.parsedHotkey) // { key: 'S', ctrl: false, shift: false, alt: false, meta: true, modifiers: ['Meta'] } +}) +``` + +You can pass a hotkey as a string or as a `RawHotkey` object (modifier booleans optional). Use `mod` for cross-platform shortcuts (Command on Mac, Control elsewhere): + +```tsx +useHotkey('Mod+S', () => save()) +useHotkey({ key: 'S', mod: true }, () => save()) // Same as above +useHotkey({ key: 'Escape' }, () => closeModal()) +useHotkey({ key: 'S', ctrl: true, shift: true }, () => saveAs()) +useHotkey({ key: 'S', mod: true, shift: true }, () => saveAs()) +``` + +## Default Options + +When you register a hotkey without passing options, or when you omit specific options, the following defaults apply: + +```tsx +useHotkey('Mod+S', callback, { + enabled: true, + preventDefault: true, + stopPropagation: true, + eventType: 'keydown', + requireReset: false, + ignoreInputs: undefined, // smart default: false for Mod+S, true for single keys + target: document, + platform: undefined, // auto-detected + conflictBehavior: 'warn', +}) +``` + +### Why These Defaults? + +Most hotkey registrations are intended to override default browser behavior—such as using `Mod+S` to save a document instead of showing the browser's "Save Page" dialog. To make this easy and consistent, the library sets `preventDefault` and `stopPropagation` to `true` by default, ensuring your hotkey handlers take precedence and reducing the amount of repetitive boilerplate code required. + +#### Smart Input Handling: `ignoreInputs` + +The `ignoreInputs` option is designed to strike a balance between accessibility and usability. By default, hotkeys involving `Ctrl`/`Meta` modifiers (like `Mod+S`) and the `Escape` key are allowed to fire even when the focus is inside input elements (such as text fields or text areas), and when focused on button-type inputs (`type="button"`, `"submit"`, or `"reset"`). This allows shortcuts like save or close to work wherever the user is focused. On the other hand, single key shortcuts or those using only `Shift`/`Alt` are ignored within non-button inputs to prevent interference with normal typing. + +#### Hotkey Conflicts: `conflictBehavior` + +When you attempt to register a hotkey that is already registered (possibly in another part of your app), the library logs a warning by default using the `conflictBehavior: 'warn'` setting. This helps you catch accidental duplicate bindings during development so they can be resolved before reaching production. + + +### Global Default Options via Provider + +You can change the default options for all `useHotkey` calls in your app by wrapping your component tree with `HotkeysProvider`. Per-hook options will override the provider defaults. + +```tsx +import { HotkeysProvider } from '@tanstack/preact-hotkeys' + + + + +``` + +## Hotkey Options + +### `enabled` + +Controls whether the hotkey is active. Defaults to `true`. + +```tsx +const [isEditing, setIsEditing] = useState(false) + +// Only active when editing +useHotkey('Mod+S', () => save(), { enabled: isEditing }) +``` + +### `preventDefault` + +Automatically calls `event.preventDefault()` when the hotkey fires. Defaults to `true`. + +```tsx +// Browser default is prevented by default +useHotkey('Mod+S', () => save()) + +// Opt out when you want the browser's default behavior +useHotkey('Mod+S', () => save(), { preventDefault: false }) +``` + +### `stopPropagation` + +Calls `event.stopPropagation()` when the hotkey fires. Defaults to `true`. + +```tsx +// Event propagation is stopped by default +useHotkey('Escape', () => closeModal()) + +// Opt out when you need the event to bubble +useHotkey('Escape', () => closeModal(), { stopPropagation: false }) +``` + +### `eventType` + +Whether to listen on `keydown` (default) or `keyup`. + +```tsx +// Fire when the key is released +useHotkey('Shift', () => deactivateMode(), { eventType: 'keyup' }) +``` + +### `requireReset` + +When `true`, the hotkey will only fire once per key press. The key must be released and pressed again to fire again. Defaults to `false`. + +```tsx +// Only fires once per Escape press, not on key repeat +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. + +```tsx +// Single key - ignored in inputs by default (smart default) +useHotkey('K', () => openSearch()) + +// Mod+S and Escape - fire in inputs by default (smart default) +useHotkey('Mod+S', () => save()) +useHotkey('Escape', () => closeDialog()) + +// Override: force a single key to fire in inputs +useHotkey('Enter', () => submit(), { ignoreInputs: false }) +``` + +Set `ignoreInputs: false` or `true` explicitly to override the smart default. + +### `target` + +The DOM element to attach the event listener to. Defaults to `document`. Can be a DOM element, `document`, `window`, or a Preact ref. + +```tsx +import { useRef } from 'preact/hooks' + +function Panel() { + const panelRef = useRef(null) + + // Only listens for events on this specific element + useHotkey('Escape', () => closePanel(), { target: panelRef }) + + return ( +
+

Panel content

+
+ ) +} +``` + +> [!NOTE] +> When using a ref as the target, make sure the element is focusable (has `tabIndex`) so it can receive keyboard events. + +### `conflictBehavior` + +Controls what happens when you register a hotkey that's already registered. Options: + +- `'warn'` (default) - Logs a warning but allows the registration +- `'error'` - Throws an error +- `'replace'` - Replaces the existing registration +- `'allow'` - Allows multiple registrations silently + +```tsx +useHotkey('Mod+S', () => save(), { conflictBehavior: 'replace' }) +``` + +### `platform` + +Override the auto-detected platform. Useful for testing or for applications that need to force a specific platform behavior. + +```tsx +useHotkey('Mod+S', () => save(), { platform: 'mac' }) +``` + +## Stale Closure Prevention + +The `useHotkey` hook automatically syncs the callback on every render, so you never need to worry about stale closures: + +```tsx +function Counter() { + const [count, setCount] = useState(0) + + // This callback always has access to the latest `count` value + useHotkey('Mod+Shift+C', () => { + console.log('Current count:', count) + }) + + return +} +``` + +## Automatic Cleanup + +The hook automatically unregisters the hotkey when the component unmounts: + +```tsx +function TemporaryPanel() { + // Automatically cleaned up when this component unmounts + useHotkey('Escape', () => closePanel()) + + return
Panel content
+} +``` + +## The Hotkey Manager + +Under the hood, `useHotkey` uses the singleton `HotkeyManager`. You can also access the manager directly if needed: + +```tsx +import { getHotkeyManager } from '@tanstack/preact-hotkeys' + +const manager = getHotkeyManager() + +// Check if a hotkey is registered +manager.isRegistered('Mod+S') + +// Get total number of registrations +manager.getRegistrationCount() +``` + +The manager attaches event listeners per target element, so only elements that have registered hotkeys receive listeners. This is more efficient than a single global listener. diff --git a/docs/framework/preact/guides/key-state-tracking.md b/docs/framework/preact/guides/key-state-tracking.md new file mode 100644 index 0000000..ca082ce --- /dev/null +++ b/docs/framework/preact/guides/key-state-tracking.md @@ -0,0 +1,186 @@ +--- +title: Key State Tracking Guide +id: key-state-tracking +--- + +TanStack Hotkeys provides three hooks for tracking the real-time state of keyboard keys. These are useful for building UIs that respond to modifier keys being held, displaying active key states, or implementing hold-to-activate features. + +## `useHeldKeys` + +Returns a reactive array of all currently held key names. + +```tsx +import { useHeldKeys } from '@tanstack/preact-hotkeys' + +function KeyDisplay() { + const heldKeys = useHeldKeys() + + return ( +
+ {heldKeys.length > 0 + ? `Held: ${heldKeys.join(' + ')}` + : 'No keys held'} +
+ ) +} +``` + +The returned array contains key names like `'Shift'`, `'Control'`, `'Meta'`, `'A'`, `'ArrowUp'`, etc. Keys appear in the order they were pressed. + +## `useHeldKeyCodes` + +Returns a reactive object mapping held key names to their physical key codes (`event.code` values). This is useful when you need to distinguish between left and right modifiers. + +```tsx +import { useHeldKeyCodes } from '@tanstack/preact-hotkeys' + +function KeyCodeDisplay() { + const heldCodes = useHeldKeyCodes() + // Example: { Shift: "ShiftLeft", Control: "ControlRight" } + + return ( +
+ {Object.entries(heldCodes).map(([key, code]) => ( +
+ {key}: {code} +
+ ))} +
+ ) +} +``` + +## `useKeyHold` + +Checks whether a specific key is currently held. This hook is optimized to only trigger re-renders when the specified key's held state changes, not when other keys are pressed or released. + +```tsx +import { useKeyHold } from '@tanstack/preact-hotkeys' + +function ModifierIndicators() { + const isShiftHeld = useKeyHold('Shift') + const isCtrlHeld = useKeyHold('Control') + const isAltHeld = useKeyHold('Alt') + const isMetaHeld = useKeyHold('Meta') + + return ( +
+ Shift + Ctrl + Alt + Meta +
+ ) +} +``` + +## Common Patterns + +### Hold-to-Reveal UI + +Show additional options while a modifier is held: + +```tsx +import { useKeyHold } from '@tanstack/preact-hotkeys' + +function FileItem({ file }: { file: File }) { + const isShiftHeld = useKeyHold('Shift') + + return ( +
+ {file.name} + {isShiftHeld && ( + + )} + {!isShiftHeld && ( + + )} +
+ ) +} +``` + +### Keyboard Shortcut Hints + +Display different shortcut hints based on which modifiers are held: + +```tsx +import { useKeyHold } from '@tanstack/preact-hotkeys' + +function ShortcutHints() { + const isModHeld = useKeyHold('Meta') // or 'Control' on Windows + + if (!isModHeld) return null + + return ( +
+
S - Save
+
Z - Undo
+
Shift+Z - Redo
+
K - Command Palette
+
+ ) +} +``` + +### Debugging Key Display + +Combine hooks with formatting utilities for a rich debugging display: + +```tsx +import { + useHeldKeys, + useHeldKeyCodes, + formatKeyForDebuggingDisplay, +} from '@tanstack/preact-hotkeys' + +function KeyDebugger() { + const heldKeys = useHeldKeys() + const heldCodes = useHeldKeyCodes() + + return ( +
+

Active Keys

+ {heldKeys.map((key) => ( +
+ {formatKeyForDebuggingDisplay(key)} + {heldCodes[key]} +
+ ))} + {heldKeys.length === 0 &&

Press any key...

} +
+ ) +} +``` + +## Platform Quirks + +The underlying `KeyStateTracker` handles several platform-specific issues: + +### macOS Modifier Key Behavior + +On macOS, when a modifier key is held and a non-modifier key is pressed, the OS sometimes swallows the `keyup` event for the non-modifier key. TanStack Hotkeys detects and handles this automatically so held key state stays accurate. + +### Window Blur + +When the browser window loses focus, all held keys are automatically cleared. This prevents "stuck" keys that would otherwise appear held even after the user tabs away and releases them. + +## Under the Hood + +All three hooks subscribe to the singleton `KeyStateTracker` via `@tanstack/preact-store`. The tracker manages its own event listeners on `document` and maintains state in a TanStack Store, which the hooks subscribe to reactively. + +```tsx +import { getKeyStateTracker } from '@tanstack/preact-hotkeys' + +const tracker = getKeyStateTracker() + +// Imperative access (outside of Preact) +tracker.getHeldKeys() // string[] +tracker.isKeyHeld('Shift') // boolean +tracker.isAnyKeyHeld(['Shift', 'Control']) // boolean +tracker.areAllKeysHeld(['Shift', 'Control']) // boolean +``` diff --git a/docs/framework/preact/guides/sequences.md b/docs/framework/preact/guides/sequences.md new file mode 100644 index 0000000..a06a08c --- /dev/null +++ b/docs/framework/preact/guides/sequences.md @@ -0,0 +1,171 @@ +--- +title: Sequences Guide +id: sequences +--- + +TanStack Hotkeys supports multi-key sequences -- shortcuts where you press keys one after another rather than simultaneously. This is commonly used for Vim-style navigation, cheat codes, or multi-step commands. + +## Basic Usage + +Use the `useHotkeySequence` hook to register a key sequence: + +```tsx +import { useHotkeySequence } from '@tanstack/preact-hotkeys' + +function App() { + // Vim-style: press g then g to scroll to top + useHotkeySequence(['G', 'G'], () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }) +} +``` + +The first argument is an array of `Hotkey` strings representing each step in the sequence. The user must press them in order within the timeout window. + +## Sequence Options + +The third argument is an options object: + +```tsx +useHotkeySequence(['G', 'G'], callback, { + timeout: 1000, // Time allowed between keys (ms) + enabled: true, // Whether the sequence is active + target: document, // Or a Preact ref for scoped sequences +}) +``` + +### `timeout` + +The maximum time (in milliseconds) allowed between consecutive key presses. If the user takes longer than this between any two keys, the sequence resets. Defaults to `1000` (1 second). + +```tsx +// Fast sequence - user must type quickly +useHotkeySequence(['D', 'D'], () => deleteLine(), { timeout: 500 }) + +// Slow sequence - user has more time between keys +useHotkeySequence(['Shift+Z', 'Shift+Z'], () => forceQuit(), { timeout: 2000 }) +``` + +### `enabled` + +Controls whether the sequence is active. Defaults to `true`. + +```tsx +const [isVimMode, setIsVimMode] = useState(true) + +useHotkeySequence(['G', 'G'], () => scrollToTop(), { enabled: isVimMode }) +``` + +### `target` + +The DOM element to attach the sequence listener to. Defaults to `document`. Can be a Preact ref, DOM element, `document`, or `window` for scoped sequences. + +### Global Default Options via Provider + +You can set default options for all `useHotkeySequence` calls by wrapping your component tree with `HotkeysProvider`. Per-hook options will override the provider defaults. + +```tsx +import { HotkeysProvider } from '@tanstack/preact-hotkeys' + + + + +``` + +## Sequences with Modifiers + +Each step in a sequence can include modifiers: + +```tsx +// Ctrl+K followed by Ctrl+C (VS Code-style comment) +useHotkeySequence(['Mod+K', 'Mod+C'], () => { + commentSelection() +}) + +// g then Shift+G (go to bottom, Vim-style) +useHotkeySequence(['G', 'Shift+G'], () => { + scrollToBottom() +}) +``` + +## Common Sequence Patterns + +### Vim-Style Navigation + +```tsx +function VimNavigation() { + useHotkeySequence(['G', 'G'], () => scrollToTop()) + useHotkeySequence(['G', 'Shift+G'], () => scrollToBottom()) + useHotkeySequence(['D', 'D'], () => deleteLine()) + useHotkeySequence(['D', 'W'], () => deleteWord()) + useHotkeySequence(['C', 'I', 'W'], () => changeInnerWord()) +} +``` + +### Konami Code + +```tsx +useHotkeySequence( + [ + 'ArrowUp', 'ArrowUp', + 'ArrowDown', 'ArrowDown', + 'ArrowLeft', 'ArrowRight', + 'ArrowLeft', 'ArrowRight', + 'B', 'A', + ], + () => enableEasterEgg(), + { timeout: 2000 }, +) +``` + +### Multi-Step Commands + +```tsx +// Press "h", "e", "l", "p" to open help +useHotkeySequence(['H', 'E', 'L', 'P'], () => openHelp()) +``` + +## How Sequences Work + +The `SequenceManager` (singleton) handles all sequence registrations. When a key is pressed: + +1. It checks if the key matches the next expected step in any registered sequence +2. If it matches, the sequence advances to the next step +3. If the timeout expires between steps, the sequence resets +4. When all steps are completed, the callback fires + +### Overlapping Sequences + +Multiple sequences can share the same prefix. The manager tracks progress for each sequence independently: + +```tsx +// Both share the 'D' prefix +useHotkeySequence(['D', 'D'], () => deleteLine()) // dd +useHotkeySequence(['D', 'W'], () => deleteWord()) // dw +useHotkeySequence(['D', 'I', 'W'], () => deleteInnerWord()) // diw +``` + +After pressing `D`, the manager waits for the next key to determine which sequence to complete. + +## The Sequence Manager + +Under the hood, `useHotkeySequence` uses the singleton `SequenceManager`. You can also use the core `createSequenceMatcher` function for standalone sequence matching without the singleton: + +```tsx +import { createSequenceMatcher } from '@tanstack/preact-hotkeys' + +const matcher = createSequenceMatcher(['G', 'G'], { + timeout: 1000, +}) + +document.addEventListener('keydown', (e) => { + if (matcher.match(e)) { + console.log('Sequence completed!') + } + console.log('Progress:', matcher.getProgress()) // e.g., 1/2 +}) +``` diff --git a/docs/framework/preact/reference/functions/HotkeysProvider.md b/docs/framework/preact/reference/functions/HotkeysProvider.md new file mode 100644 index 0000000..ceb8849 --- /dev/null +++ b/docs/framework/preact/reference/functions/HotkeysProvider.md @@ -0,0 +1,22 @@ +--- +id: HotkeysProvider +title: HotkeysProvider +--- + +# Function: HotkeysProvider() + +```ts +function HotkeysProvider(__namedParameters): Element; +``` + +Defined in: [HotkeysProvider.tsx:27](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L27) + +## Parameters + +### \_\_namedParameters + +[`HotkeysProviderProps`](../interfaces/HotkeysProviderProps.md) + +## Returns + +`Element` diff --git a/docs/framework/preact/reference/functions/useDefaultHotkeysOptions.md b/docs/framework/preact/reference/functions/useDefaultHotkeysOptions.md new file mode 100644 index 0000000..9cc15af --- /dev/null +++ b/docs/framework/preact/reference/functions/useDefaultHotkeysOptions.md @@ -0,0 +1,16 @@ +--- +id: useDefaultHotkeysOptions +title: useDefaultHotkeysOptions +--- + +# Function: useDefaultHotkeysOptions() + +```ts +function useDefaultHotkeysOptions(): HotkeysProviderOptions; +``` + +Defined in: [HotkeysProvider.tsx:49](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L49) + +## Returns + +[`HotkeysProviderOptions`](../interfaces/HotkeysProviderOptions.md) diff --git a/docs/framework/preact/reference/functions/useHeldKeyCodes.md b/docs/framework/preact/reference/functions/useHeldKeyCodes.md new file mode 100644 index 0000000..586b59c --- /dev/null +++ b/docs/framework/preact/reference/functions/useHeldKeyCodes.md @@ -0,0 +1,42 @@ +--- +id: useHeldKeyCodes +title: useHeldKeyCodes +--- + +# Function: useHeldKeyCodes() + +```ts +function useHeldKeyCodes(): Record; +``` + +Defined in: [useHeldKeyCodes.ts:30](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHeldKeyCodes.ts#L30) + +Preact hook that returns a map of currently held key names to their physical `event.code` values. + +This is useful for debugging which physical key was pressed (e.g. distinguishing +left vs right Shift via "ShiftLeft" / "ShiftRight"). + +## Returns + +`Record`\<`string`, `string`\> + +Record mapping normalized key names to their `event.code` values + +## Example + +```tsx +function KeyDebugDisplay() { + const heldKeys = useHeldKeys() + const heldCodes = useHeldKeyCodes() + + return ( +
+ {heldKeys.map((key) => ( + + {key} {heldCodes[key]} + + ))} +
+ ) +} +``` diff --git a/docs/framework/preact/reference/functions/useHeldKeys.md b/docs/framework/preact/reference/functions/useHeldKeys.md new file mode 100644 index 0000000..dc68478 --- /dev/null +++ b/docs/framework/preact/reference/functions/useHeldKeys.md @@ -0,0 +1,38 @@ +--- +id: useHeldKeys +title: useHeldKeys +--- + +# Function: useHeldKeys() + +```ts +function useHeldKeys(): string[]; +``` + +Defined in: [useHeldKeys.ts:26](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHeldKeys.ts#L26) + +Preact hook that returns an array of currently held keyboard keys. + +This hook uses `useStore` from `@tanstack/preact-store` to subscribe +to the global KeyStateTracker and updates whenever keys are pressed +or released. + +## Returns + +`string`[] + +Array of currently held key names + +## Example + +```tsx +function KeyDisplay() { + const heldKeys = useHeldKeys() + + return ( +
+ Currently pressed: {heldKeys.join(' + ') || 'None'} +
+ ) +} +``` diff --git a/docs/framework/preact/reference/functions/useHotkey.md b/docs/framework/preact/reference/functions/useHotkey.md new file mode 100644 index 0000000..7d47393 --- /dev/null +++ b/docs/framework/preact/reference/functions/useHotkey.md @@ -0,0 +1,91 @@ +--- +id: useHotkey +title: useHotkey +--- + +# Function: useHotkey() + +```ts +function useHotkey( + hotkey, + callback, + options): void; +``` + +Defined in: [useHotkey.ts:90](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkey.ts#L90) + +Preact hook for registering a keyboard hotkey. + +Uses the singleton HotkeyManager for efficient event handling. +The callback receives both the keyboard event and a context object +containing the hotkey string and parsed hotkey. + +This hook syncs the callback and options on every render to avoid +stale closures. This means +callbacks that reference Preact state will always have access to +the latest values. + +## Parameters + +### hotkey + +`RegisterableHotkey` + +The hotkey string (e.g., 'Mod+S', 'Escape') or RawHotkey object (supports `mod` for cross-platform) + +### callback + +`HotkeyCallback` + +The function to call when the hotkey is pressed + +### options + +[`UseHotkeyOptions`](../interfaces/UseHotkeyOptions.md) = `{}` + +Options for the hotkey behavior + +## Returns + +`void` + +## Examples + +```tsx +function SaveButton() { + const [count, setCount] = useState(0) + + // Callback always has access to latest count value + useHotkey('Mod+S', (event, { hotkey }) => { + console.log(`Save triggered, count is ${count}`) + handleSave() + }) + + return +} +``` + +```tsx +function Modal({ isOpen, onClose }) { + // enabled option is synced on every render + useHotkey('Escape', () => { + onClose() + }, { enabled: isOpen }) + + if (!isOpen) return null + return
...
+} +``` + +```tsx +function Editor() { + const editorRef = useRef(null) + + // Scoped to a specific element + useHotkey('Mod+S', () => { + save() + }, { target: editorRef }) + + return
...
+} +``` diff --git a/docs/framework/preact/reference/functions/useHotkeyRecorder.md b/docs/framework/preact/reference/functions/useHotkeyRecorder.md new file mode 100644 index 0000000..7d5ff10 --- /dev/null +++ b/docs/framework/preact/reference/functions/useHotkeyRecorder.md @@ -0,0 +1,61 @@ +--- +id: useHotkeyRecorder +title: useHotkeyRecorder +--- + +# Function: useHotkeyRecorder() + +```ts +function useHotkeyRecorder(options): PreactHotkeyRecorder; +``` + +Defined in: [useHotkeyRecorder.ts:58](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L58) + +Preact hook for recording keyboard shortcuts. + +This hook provides a thin wrapper around the framework-agnostic `HotkeyRecorder` +class, managing all the complexity of capturing keyboard events, converting them +to hotkey strings, and handling edge cases like Escape to cancel or Backspace/Delete +to clear. + +## Parameters + +### options + +`HotkeyRecorderOptions` + +Configuration options for the recorder + +## Returns + +[`PreactHotkeyRecorder`](../interfaces/PreactHotkeyRecorder.md) + +An object with recording state and control functions + +## Example + +```tsx +function ShortcutSettings() { + const [shortcut, setShortcut] = useState('Mod+S') + + const recorder = useHotkeyRecorder({ + onRecord: (hotkey) => { + setShortcut(hotkey) + }, + onCancel: () => { + console.log('Recording cancelled') + }, + }) + + return ( +
+ + {recorder.recordedHotkey && ( +
Recording: {recorder.recordedHotkey}
+ )} +
+ ) +} +``` diff --git a/docs/framework/preact/reference/functions/useHotkeySequence.md b/docs/framework/preact/reference/functions/useHotkeySequence.md new file mode 100644 index 0000000..4a6592c --- /dev/null +++ b/docs/framework/preact/reference/functions/useHotkeySequence.md @@ -0,0 +1,67 @@ +--- +id: useHotkeySequence +title: useHotkeySequence +--- + +# Function: useHotkeySequence() + +```ts +function useHotkeySequence( + sequence, + callback, + options): void; +``` + +Defined in: [useHotkeySequence.ts:62](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeySequence.ts#L62) + +Preact hook for registering a keyboard shortcut sequence (Vim-style). + +This hook allows you to register multi-key sequences like 'g g' or 'd d' +that trigger when the full sequence is pressed within a timeout. + +## Parameters + +### sequence + +`HotkeySequence` + +Array of hotkey strings that form the sequence + +### callback + +`HotkeyCallback` + +Function to call when the sequence is completed + +### options + +[`UseHotkeySequenceOptions`](../interfaces/UseHotkeySequenceOptions.md) = `{}` + +Options for the sequence behavior + +## Returns + +`void` + +## Example + +```tsx +function VimEditor() { + // 'g g' to go to top + useHotkeySequence(['G', 'G'], () => { + scrollToTop() + }) + + // 'd d' to delete line + useHotkeySequence(['D', 'D'], () => { + deleteLine() + }) + + // 'd i w' to delete inner word + useHotkeySequence(['D', 'I', 'W'], () => { + deleteInnerWord() + }, { timeout: 500 }) + + return
...
+} +``` diff --git a/docs/framework/preact/reference/functions/useHotkeysContext.md b/docs/framework/preact/reference/functions/useHotkeysContext.md new file mode 100644 index 0000000..5f10038 --- /dev/null +++ b/docs/framework/preact/reference/functions/useHotkeysContext.md @@ -0,0 +1,16 @@ +--- +id: useHotkeysContext +title: useHotkeysContext +--- + +# Function: useHotkeysContext() + +```ts +function useHotkeysContext(): HotkeysContextValue | null; +``` + +Defined in: [HotkeysProvider.tsx:45](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L45) + +## Returns + +`HotkeysContextValue` \| `null` diff --git a/docs/framework/preact/reference/functions/useKeyHold.md b/docs/framework/preact/reference/functions/useKeyHold.md new file mode 100644 index 0000000..cdbb832 --- /dev/null +++ b/docs/framework/preact/reference/functions/useKeyHold.md @@ -0,0 +1,62 @@ +--- +id: useKeyHold +title: useKeyHold +--- + +# Function: useKeyHold() + +```ts +function useKeyHold(key): boolean; +``` + +Defined in: [useKeyHold.ts:45](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useKeyHold.ts#L45) + +Preact hook that returns whether a specific key is currently being held. + +This hook uses `useStore` from `@tanstack/preact-store` to subscribe +to the global KeyStateTracker and uses a selector to determine if +the specified key is held. + +## Parameters + +### key + +`HeldKey` + +The key to check (e.g., 'Shift', 'Control', 'A') + +## Returns + +`boolean` + +True if the key is currently held down + +## Examples + +```tsx +function ShiftIndicator() { + const isShiftHeld = useKeyHold('Shift') + + return ( +
+ {isShiftHeld ? 'Shift is pressed!' : 'Press Shift'} +
+ ) +} +``` + +```tsx +function ModifierIndicators() { + const ctrl = useKeyHold('Control') + const shift = useKeyHold('Shift') + const alt = useKeyHold('Alt') + + return ( +
+ Ctrl + Shift + Alt +
+ ) +} +``` diff --git a/docs/framework/preact/reference/index.md b/docs/framework/preact/reference/index.md new file mode 100644 index 0000000..2667f3d --- /dev/null +++ b/docs/framework/preact/reference/index.md @@ -0,0 +1,26 @@ +--- +id: "@tanstack/preact-hotkeys" +title: "@tanstack/preact-hotkeys" +--- + +# @tanstack/preact-hotkeys + +## Interfaces + +- [HotkeysProviderOptions](interfaces/HotkeysProviderOptions.md) +- [HotkeysProviderProps](interfaces/HotkeysProviderProps.md) +- [PreactHotkeyRecorder](interfaces/PreactHotkeyRecorder.md) +- [UseHotkeyOptions](interfaces/UseHotkeyOptions.md) +- [UseHotkeySequenceOptions](interfaces/UseHotkeySequenceOptions.md) + +## Functions + +- [HotkeysProvider](functions/HotkeysProvider.md) +- [useDefaultHotkeysOptions](functions/useDefaultHotkeysOptions.md) +- [useHeldKeyCodes](functions/useHeldKeyCodes.md) +- [useHeldKeys](functions/useHeldKeys.md) +- [useHotkey](functions/useHotkey.md) +- [useHotkeyRecorder](functions/useHotkeyRecorder.md) +- [useHotkeysContext](functions/useHotkeysContext.md) +- [useHotkeySequence](functions/useHotkeySequence.md) +- [useKeyHold](functions/useKeyHold.md) diff --git a/docs/framework/preact/reference/interfaces/HotkeysProviderOptions.md b/docs/framework/preact/reference/interfaces/HotkeysProviderOptions.md new file mode 100644 index 0000000..f1f454f --- /dev/null +++ b/docs/framework/preact/reference/interfaces/HotkeysProviderOptions.md @@ -0,0 +1,38 @@ +--- +id: HotkeysProviderOptions +title: HotkeysProviderOptions +--- + +# Interface: HotkeysProviderOptions + +Defined in: [HotkeysProvider.tsx:8](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L8) + +## Properties + +### hotkey? + +```ts +optional hotkey: Partial; +``` + +Defined in: [HotkeysProvider.tsx:9](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L9) + +*** + +### hotkeyRecorder? + +```ts +optional hotkeyRecorder: Partial; +``` + +Defined in: [HotkeysProvider.tsx:10](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L10) + +*** + +### hotkeySequence? + +```ts +optional hotkeySequence: Partial; +``` + +Defined in: [HotkeysProvider.tsx:11](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L11) diff --git a/docs/framework/preact/reference/interfaces/HotkeysProviderProps.md b/docs/framework/preact/reference/interfaces/HotkeysProviderProps.md new file mode 100644 index 0000000..2a6e8cc --- /dev/null +++ b/docs/framework/preact/reference/interfaces/HotkeysProviderProps.md @@ -0,0 +1,28 @@ +--- +id: HotkeysProviderProps +title: HotkeysProviderProps +--- + +# Interface: HotkeysProviderProps + +Defined in: [HotkeysProvider.tsx:20](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L20) + +## Properties + +### children + +```ts +children: ComponentChildren; +``` + +Defined in: [HotkeysProvider.tsx:21](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L21) + +*** + +### defaultOptions? + +```ts +optional defaultOptions: HotkeysProviderOptions; +``` + +Defined in: [HotkeysProvider.tsx:22](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/HotkeysProvider.tsx#L22) diff --git a/docs/framework/preact/reference/interfaces/PreactHotkeyRecorder.md b/docs/framework/preact/reference/interfaces/PreactHotkeyRecorder.md new file mode 100644 index 0000000..d4d369d --- /dev/null +++ b/docs/framework/preact/reference/interfaces/PreactHotkeyRecorder.md @@ -0,0 +1,80 @@ +--- +id: PreactHotkeyRecorder +title: PreactHotkeyRecorder +--- + +# Interface: PreactHotkeyRecorder + +Defined in: [useHotkeyRecorder.ts:7](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L7) + +## Properties + +### cancelRecording() + +```ts +cancelRecording: () => void; +``` + +Defined in: [useHotkeyRecorder.ts:17](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L17) + +Cancel recording without saving + +#### Returns + +`void` + +*** + +### isRecording + +```ts +isRecording: boolean; +``` + +Defined in: [useHotkeyRecorder.ts:9](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L9) + +Whether recording is currently active + +*** + +### recordedHotkey + +```ts +recordedHotkey: Hotkey | null; +``` + +Defined in: [useHotkeyRecorder.ts:11](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L11) + +The currently recorded hotkey (for live preview) + +*** + +### startRecording() + +```ts +startRecording: () => void; +``` + +Defined in: [useHotkeyRecorder.ts:13](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L13) + +Start recording a new hotkey + +#### Returns + +`void` + +*** + +### stopRecording() + +```ts +stopRecording: () => void; +``` + +Defined in: [useHotkeyRecorder.ts:15](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeyRecorder.ts#L15) + +Stop recording (same as cancel) + +#### Returns + +`void` diff --git a/docs/framework/preact/reference/interfaces/UseHotkeyOptions.md b/docs/framework/preact/reference/interfaces/UseHotkeyOptions.md new file mode 100644 index 0000000..649ac84 --- /dev/null +++ b/docs/framework/preact/reference/interfaces/UseHotkeyOptions.md @@ -0,0 +1,31 @@ +--- +id: UseHotkeyOptions +title: UseHotkeyOptions +--- + +# Interface: UseHotkeyOptions + +Defined in: [useHotkey.ts:18](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkey.ts#L18) + +## Extends + +- `Omit`\<`HotkeyOptions`, `"target"`\> + +## Properties + +### target? + +```ts +optional target: + | HTMLElement + | Document + | Window + | RefObject + | null; +``` + +Defined in: [useHotkey.ts:24](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkey.ts#L24) + +The DOM element to attach the event listener to. +Can be a Preact ref, direct DOM element, or null. +Defaults to document. diff --git a/docs/framework/preact/reference/interfaces/UseHotkeySequenceOptions.md b/docs/framework/preact/reference/interfaces/UseHotkeySequenceOptions.md new file mode 100644 index 0000000..4811d38 --- /dev/null +++ b/docs/framework/preact/reference/interfaces/UseHotkeySequenceOptions.md @@ -0,0 +1,31 @@ +--- +id: UseHotkeySequenceOptions +title: UseHotkeySequenceOptions +--- + +# Interface: UseHotkeySequenceOptions + +Defined in: [useHotkeySequence.ts:13](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeySequence.ts#L13) + +## Extends + +- `Omit`\<`SequenceOptions`, `"target"`\> + +## Properties + +### target? + +```ts +optional target: + | HTMLElement + | Document + | Window + | RefObject + | null; +``` + +Defined in: [useHotkeySequence.ts:22](https://github.com/TanStack/hotkeys/blob/main/packages/preact-hotkeys/src/useHotkeySequence.ts#L22) + +The DOM element to attach the event listener to. +Can be a Preact ref, direct DOM element, or null. +Defaults to document. diff --git a/docs/framework/solid/guides/formatting-display.md b/docs/framework/solid/guides/formatting-display.md new file mode 100644 index 0000000..37b8f99 --- /dev/null +++ b/docs/framework/solid/guides/formatting-display.md @@ -0,0 +1,154 @@ +--- +title: Formatting & Display Guide +id: formatting-display +--- + +TanStack Hotkeys provides several utilities for formatting hotkey strings into human-readable display text. These utilities handle platform differences automatically, so your UI shows the right symbols and labels for each operating system. + +## `formatForDisplay` + +The primary formatting function. Returns a platform-aware string using symbols on macOS and text labels on Windows/Linux. + +```tsx +import { formatForDisplay } from '@tanstack/solid-hotkeys' + +// On macOS: +formatForDisplay('Mod+S') // "⌘S" +formatForDisplay('Mod+Shift+Z') // "⇧⌘Z" +formatForDisplay('Control+Alt+D') // "⌃⌥D" + +// On Windows/Linux: +formatForDisplay('Mod+S') // "Ctrl+S" +formatForDisplay('Mod+Shift+Z') // "Ctrl+Shift+Z" +formatForDisplay('Control+Alt+D') // "Ctrl+Alt+D" +``` + +### Options + +```ts +formatForDisplay('Mod+S', { + platform: 'mac', // Override platform detection ('mac' | 'windows' | 'linux') +}) +``` + +## `formatWithLabels` + +Returns human-readable text labels (e.g., "Cmd" instead of the symbol). + +```tsx +import { formatWithLabels } from '@tanstack/solid-hotkeys' + +formatWithLabels('Mod+S') // "Cmd+S" (Mac) / "Ctrl+S" (Windows) +formatWithLabels('Mod+Shift+Z') // "Cmd+Shift+Z" (Mac) / "Ctrl+Shift+Z" (Windows) +``` + +## `formatKeyForDebuggingDisplay` + +Returns a rich label for devtools and debugging. + +```tsx +import { formatKeyForDebuggingDisplay } from '@tanstack/solid-hotkeys' + +formatKeyForDebuggingDisplay('Meta') // "⌘ Mod (Cmd)" (Mac) +formatKeyForDebuggingDisplay('Shift') // "⇧ Shift" +formatKeyForDebuggingDisplay('Control') // "⌃ Control" +``` + +## Using Formatted Hotkeys in Solid + +### Keyboard Shortcut Badges + +```tsx +import { formatForDisplay } from '@tanstack/solid-hotkeys' + +function ShortcutBadge(props: { hotkey: string }) { + return {formatForDisplay(props.hotkey)} +} +``` + +### Menu Items with Hotkeys + +```tsx +import { createHotkey, formatForDisplay } from '@tanstack/solid-hotkeys' + +function MenuItem(props: { + label: string + hotkey: string + onAction: () => void +}) { + createHotkey(props.hotkey, () => props.onAction()) + + return ( + + ) +} +``` + +### Command Palette Items + +```tsx +import { formatForDisplay } from '@tanstack/solid-hotkeys' +import type { Hotkey } from '@tanstack/solid-hotkeys' + +interface Command { + id: string + label: string + hotkey?: Hotkey + action: () => void +} + +function CommandPaletteItem(props: { command: Command }) { + return ( +
+ {props.command.label} + + {formatForDisplay(props.command.hotkey!)} + +
+ ) +} +``` + +## Platform Symbols Reference + +| Modifier | Mac Symbol | Windows/Linux Label | +|----------|-----------|-------------------| +| Meta (Cmd) | `⌘` | `Win` / `Super` | +| Control | `⌃` | `Ctrl` | +| Alt/Option | `⌥` | `Alt` | +| Shift | `⇧` | `Shift` | + +## Parsing and Normalization + +### `parseHotkey` + +```ts +import { parseHotkey } from '@tanstack/solid-hotkeys' + +const parsed = parseHotkey('Mod+Shift+S') +// { key: 'S', ctrl: false, shift: true, alt: false, meta: true, modifiers: [...] } +``` + +### `normalizeHotkey` + +```ts +import { normalizeHotkey } from '@tanstack/solid-hotkeys' + +normalizeHotkey('Cmd+S') // 'Meta+S' (on Mac) +normalizeHotkey('Ctrl+Shift+s') // 'Control+Shift+S' +``` + +## Validation + +```ts +import { validateHotkey } from '@tanstack/solid-hotkeys' + +const result = validateHotkey('Alt+A') +// { valid: true, warnings: [...], errors: [] } + +const result2 = validateHotkey('InvalidKey+S') +// { valid: false, warnings: [], errors: ['Unknown key: InvalidKey'] } +``` diff --git a/docs/framework/solid/guides/hotkey-recording.md b/docs/framework/solid/guides/hotkey-recording.md new file mode 100644 index 0000000..91b5c3c --- /dev/null +++ b/docs/framework/solid/guides/hotkey-recording.md @@ -0,0 +1,169 @@ +--- +title: Hotkey Recording Guide +id: hotkey-recording +--- + +TanStack Hotkeys provides the `createHotkeyRecorder` primitive for building keyboard shortcut customization UIs. This lets users record their own shortcuts by pressing the desired key combination, similar to how system preferences or IDE shortcut editors work. + +## Basic Usage + +```tsx +import { createHotkeyRecorder, formatForDisplay } from '@tanstack/solid-hotkeys' + +function ShortcutRecorder() { + const recorder = createHotkeyRecorder({ + onRecord: (hotkey) => { + console.log('Recorded:', hotkey) // e.g., "Mod+Shift+S" + }, + }) + + return ( +
+ + + + +
+ ) +} +``` + +> [!NOTE] +> In Solid, `isRecording` and `recordedHotkey` are **accessors** (signal getters). You must call them with `()` to read the value: `recorder.isRecording()`, `recorder.recordedHotkey()`. + +## Return Value + +The `createHotkeyRecorder` primitive returns an object with: + +| Property | Type | Description | +|----------|------|-------------| +| `isRecording` | `() => boolean` | Accessor returning whether the recorder is currently listening | +| `recordedHotkey` | `() => Hotkey \| null` | Accessor returning the last recorded hotkey, or `null` | +| `startRecording` | `() => void` | Start listening for key presses | +| `stopRecording` | `() => void` | Stop listening and keep the recorded hotkey | +| `cancelRecording` | `() => void` | Stop listening and discard any recorded hotkey | + +## Options + +```tsx +createHotkeyRecorder({ + onRecord: (hotkey) => { /* called when a hotkey is recorded */ }, + onCancel: () => { /* called when recording is cancelled */ }, + onClear: () => { /* called when the recorded hotkey is cleared */ }, +}) +``` + +Options can also be passed as an accessor function for reactive configuration. + +### `onRecord` + +Called when the user presses a valid key combination. Receives the recorded `Hotkey` string. + +### `onCancel` + +Called when recording is cancelled (Escape or `cancelRecording()`). + +### `onClear` + +Called when the recorded hotkey is cleared (Backspace or Delete during recording). + +### Global Default Options via Provider + +```tsx +import { HotkeysProvider } from '@tanstack/solid-hotkeys' + + console.log('Recording cancelled'), + }, + }} +> + + +``` + +## Recording Behavior + +| Key | Behavior | +|-----|----------| +| **Modifier only** | Waits for a non-modifier key | +| **Modifier + key** | Records the full combination | +| **Single key** | Records the single key | +| **Escape** | Cancels the recording | +| **Backspace / Delete** | Clears the currently recorded hotkey | + +### Mod Auto-Conversion + +Recorded hotkeys automatically use the portable `Mod` format (Command on Mac, Control elsewhere). + +## Building a Shortcut Settings UI + +```tsx +import { createSignal } from 'solid-js' +import { + createHotkey, + createHotkeyRecorder, + formatForDisplay, +} from '@tanstack/solid-hotkeys' +import type { Hotkey } from '@tanstack/solid-hotkeys' + +function ShortcutSettings() { + const [shortcuts, setShortcuts] = createSignal>({ + save: 'Mod+S', + undo: 'Mod+Z', + search: 'Mod+K', + }) + + const [editingAction, setEditingAction] = createSignal(null) + + const recorder = createHotkeyRecorder({ + onRecord: (hotkey) => { + const action = editingAction() + if (action) { + setShortcuts((prev) => ({ ...prev, [action]: hotkey })) + setEditingAction(null) + } + }, + onCancel: () => setEditingAction(null), + }) + + // Register the actual hotkeys with their current bindings + createHotkey(() => shortcuts().save, () => save()) + createHotkey(() => shortcuts().undo, () => undo()) + createHotkey(() => shortcuts().search, () => openSearch()) + + return ( +
+

Keyboard Shortcuts

+ + {([action, hotkey]) => ( +
+ {action} + +
+ )} +
+
+ ) +} +``` + +## Under the Hood + +The `createHotkeyRecorder` primitive creates a `HotkeyRecorder` class instance and subscribes to its reactive state via `@tanstack/solid-store`. The class manages its own keyboard event listeners and state, and the primitive handles cleanup when the component is disposed. diff --git a/docs/framework/solid/guides/hotkeys.md b/docs/framework/solid/guides/hotkeys.md new file mode 100644 index 0000000..e84b8fd --- /dev/null +++ b/docs/framework/solid/guides/hotkeys.md @@ -0,0 +1,241 @@ +--- +title: Hotkeys Guide +id: hotkeys +--- + +The `createHotkey` primitive is the primary way to register keyboard shortcuts in SolidJS applications. It wraps the singleton `HotkeyManager` with automatic lifecycle management and reactive option support. + +## Basic Usage + +```tsx +import { createHotkey } from '@tanstack/solid-hotkeys' + +function App() { + createHotkey('Mod+S', () => { + saveDocument() + }, { + // override the default options here + }) +} +``` + +The callback receives the original `KeyboardEvent` as the first argument and a `HotkeyCallbackContext` as the second: + +```tsx +createHotkey('Mod+S', (event, context) => { + console.log(context.hotkey) // 'Mod+S' + console.log(context.parsedHotkey) // { key: 'S', ctrl: false, shift: false, alt: false, meta: true, modifiers: ['Meta'] } +}) +``` + +You can pass a hotkey as a string or as a `RawHotkey` object (modifier booleans optional). Use `mod` for cross-platform shortcuts (Command on Mac, Control elsewhere): + +```tsx +createHotkey('Mod+S', () => save()) +createHotkey({ key: 'S', mod: true }, () => save()) // Same as above +createHotkey({ key: 'Escape' }, () => closeModal()) +createHotkey({ key: 'S', ctrl: true, shift: true }, () => saveAs()) +createHotkey({ key: 'S', mod: true, shift: true }, () => saveAs()) +``` + +## Reactive Options + +Unlike React/Preact hooks, Solid primitives accept **accessor functions** for reactive options. Pass a function that returns the options object to have the hotkey automatically update when dependencies change: + +```tsx +function Modal(props) { + createHotkey('Escape', () => props.onClose(), () => ({ + enabled: props.isOpen, + })) + + return ( + + + + ) +} +``` + +For scoped targets, use an accessor so the hotkey waits for the element to be attached: + +```tsx +function Editor() { + const [editorRef, setEditorRef] = createSignal(null) + + createHotkey('Mod+S', save, () => ({ target: editorRef() })) + + return
...
+} +``` + +## Default Options + +When you register a hotkey without passing options, or when you omit specific options, the following defaults apply: + +```tsx +createHotkey('Mod+S', callback, { + enabled: true, + preventDefault: true, + stopPropagation: true, + eventType: 'keydown', + requireReset: false, + ignoreInputs: undefined, // smart default: false for Mod+S, true for single keys + target: document, + platform: undefined, // auto-detected + conflictBehavior: 'warn', +}) +``` + +### Why These Defaults? + +Most hotkey registrations are intended to override default browser behavior—such as using `Mod+S` to save a document instead of showing the browser's "Save Page" dialog. To make this easy and consistent, the library sets `preventDefault` and `stopPropagation` to `true` by default, ensuring your hotkey handlers take precedence and reducing the amount of repetitive boilerplate code required. + +#### Smart Input Handling: `ignoreInputs` + +The `ignoreInputs` option is designed to strike a balance between accessibility and usability. By default, hotkeys involving `Ctrl`/`Meta` modifiers (like `Mod+S`) and the `Escape` key are allowed to fire even when the focus is inside input elements (such as text fields or text areas), and when focused on button-type inputs (`type="button"`, `"submit"`, or `"reset"`). This allows shortcuts like save or close to work wherever the user is focused. On the other hand, single key shortcuts or those using only `Shift`/`Alt` are ignored within non-button inputs to prevent interference with normal typing. + +#### Hotkey Conflicts: `conflictBehavior` + +When you attempt to register a hotkey that is already registered (possibly in another part of your app), the library logs a warning by default using the `conflictBehavior: 'warn'` setting. This helps you catch accidental duplicate bindings during development so they can be resolved before reaching production. + +### Global Default Options via Provider + +You can change the default options for all `createHotkey` calls in your app by wrapping your component tree with `HotkeysProvider`. Per-primitive options will override the provider defaults. + +```tsx +import { HotkeysProvider } from '@tanstack/solid-hotkeys' + + + + +``` + +## Hotkey Options + +### `enabled` + +Controls whether the hotkey is active. Defaults to `true`. Use an accessor for reactive control. + +```tsx +const [isEditing, setIsEditing] = createSignal(false) + +createHotkey('Mod+S', () => save(), () => ({ enabled: isEditing() })) +``` + +### `preventDefault` + +Automatically calls `event.preventDefault()` when the hotkey fires. Defaults to `true`. + +```tsx +createHotkey('Mod+S', () => save()) +createHotkey('Mod+S', () => save(), { preventDefault: false }) +``` + +### `stopPropagation` + +Calls `event.stopPropagation()` when the hotkey fires. Defaults to `true`. + +```tsx +createHotkey('Escape', () => closeModal()) +createHotkey('Escape', () => closeModal(), { stopPropagation: false }) +``` + +### `eventType` + +Whether to listen on `keydown` (default) or `keyup`. + +```tsx +createHotkey('Shift', () => deactivateMode(), { eventType: 'keyup' }) +``` + +### `requireReset` + +When `true`, the hotkey will only fire once per key press. The key must be released and pressed again to fire again. Defaults to `false`. + +```tsx +createHotkey('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. When unset, a smart default applies based on the hotkey type. + +```tsx +createHotkey('K', () => openSearch()) // Smart default: ignored in inputs +createHotkey('Mod+S', () => save()) // Smart default: fires in inputs +createHotkey('Enter', () => submit(), { ignoreInputs: false }) +``` + +### `target` + +The DOM element to attach the event listener to. Defaults to `document`. Can be a DOM element, `document`, `window`, or from an accessor for reactive targets. + +```tsx +const [panelRef, setPanelRef] = createSignal(null) + +createHotkey('Escape', () => closePanel(), () => ({ target: panelRef() })) + +return
...
+``` + +> [!NOTE] +> When using an accessor for the target, the primitive waits for the element to be available before registering. Ensure the element is focusable (has `tabIndex`) so it can receive keyboard events. + +### `conflictBehavior` + +Controls what happens when you register a hotkey that's already registered. Options: `'warn'`, `'error'`, `'replace'`, `'allow'`. + +```tsx +createHotkey('Mod+S', () => save(), { conflictBehavior: 'replace' }) +``` + +### `platform` + +Override the auto-detected platform. + +```tsx +createHotkey('Mod+S', () => save(), { platform: 'mac' }) +``` + +## Automatic Dependency Tracking + +Solid's fine-grained reactivity means `createHotkey` automatically tracks reactive dependencies. The callback always has access to the latest signal values: + +```tsx +function Counter() { + const [count, setCount] = createSignal(0) + + createHotkey('Mod+Shift+C', () => { + console.log('Current count:', count()) + }) + + return +} +``` + +## Automatic Cleanup + +The primitive automatically unregisters the hotkey when the component unmounts (when the owning reactive scope is disposed): + +```tsx +function TemporaryPanel() { + createHotkey('Escape', () => closePanel()) + return
Panel content
+} +``` + +## The Hotkey Manager + +Under the hood, `createHotkey` uses the singleton `HotkeyManager`. You can also access the manager directly if needed: + +```tsx +import { getHotkeyManager } from '@tanstack/solid-hotkeys' + +const manager = getHotkeyManager() +manager.isRegistered('Mod+S') +manager.getRegistrationCount() +``` diff --git a/docs/framework/solid/guides/key-state-tracking.md b/docs/framework/solid/guides/key-state-tracking.md new file mode 100644 index 0000000..92768f2 --- /dev/null +++ b/docs/framework/solid/guides/key-state-tracking.md @@ -0,0 +1,188 @@ +--- +title: Key State Tracking Guide +id: key-state-tracking +--- + +TanStack Hotkeys provides three primitives for tracking the real-time state of keyboard keys. These are useful for building UIs that respond to modifier keys being held, displaying active key states, or implementing hold-to-activate features. + +## `createHeldKeys` + +Returns an accessor that yields an array of all currently held key names. + +```tsx +import { createHeldKeys } from '@tanstack/solid-hotkeys' + +function KeyDisplay() { + const heldKeys = createHeldKeys() + + return ( +
+ {heldKeys().length > 0 + ? `Held: ${heldKeys().join(' + ')}` + : 'No keys held'} +
+ ) +} +``` + +> [!NOTE] +> In Solid, `createHeldKeys()` returns an **accessor function**. Call it with `()` to read the current value: `heldKeys()`. + +The returned array contains key names like `'Shift'`, `'Control'`, `'Meta'`, `'A'`, `'ArrowUp'`, etc. Keys appear in the order they were pressed. + +## `createHeldKeyCodes` + +Returns an accessor that yields a reactive object mapping held key names to their physical key codes (`event.code` values). Useful for distinguishing between left and right modifiers. + +```tsx +import { createHeldKeyCodes } from '@tanstack/solid-hotkeys' + +function KeyCodeDisplay() { + const heldCodes = createHeldKeyCodes() + // Example: { Shift: "ShiftLeft", Control: "ControlRight" } + + return ( +
+ + {([key, code]) => ( +
+ {key}: {code} +
+ )} +
+
+ ) +} +``` + +## `createKeyHold` + +Returns an accessor that indicates whether a specific key is currently held. Optimized to only trigger updates when the specified key's held state changes. The `key` argument can be a string or an accessor for reactive keys. + +```tsx +import { createKeyHold } from '@tanstack/solid-hotkeys' + +function ModifierIndicators() { + const isShiftHeld = createKeyHold('Shift') + const isCtrlHeld = createKeyHold('Control') + const isAltHeld = createKeyHold('Alt') + const isMetaHeld = createKeyHold('Meta') + + return ( +
+ Shift + Ctrl + Alt + Meta +
+ ) +} +``` + +## Common Patterns + +### Hold-to-Reveal UI + +```tsx +import { createKeyHold } from '@tanstack/solid-hotkeys' + +function FileItem(props: { file: File }) { + const isShiftHeld = createKeyHold('Shift') + + return ( +
+ {props.file.name} + + + + + + +
+ ) +} +``` + +### Keyboard Shortcut Hints + +```tsx +import { createKeyHold } from '@tanstack/solid-hotkeys' + +function ShortcutHints() { + const isModHeld = createKeyHold('Meta') + + return ( + +
+
S - Save
+
Z - Undo
+
Shift+Z - Redo
+
K - Command Palette
+
+
+ ) +} +``` + +### Debugging Key Display + +```tsx +import { + createHeldKeys, + createHeldKeyCodes, + formatKeyForDebuggingDisplay, +} from '@tanstack/solid-hotkeys' + +function KeyDebugger() { + const heldKeys = createHeldKeys() + const heldCodes = createHeldKeyCodes() + + return ( +
+

Active Keys

+ + {(key) => ( +
+ {formatKeyForDebuggingDisplay(key)} + {heldCodes()[key]} +
+ )} +
+ +

Press any key...

+
+
+ ) +} +``` + +## Platform Quirks + +The underlying `KeyStateTracker` handles several platform-specific issues: + +### macOS Modifier Key Behavior + +On macOS, when a modifier key is held and a non-modifier key is pressed, the OS sometimes swallows the `keyup` event. TanStack Hotkeys detects and handles this automatically. + +### Window Blur + +When the browser window loses focus, all held keys are automatically cleared. + +## Under the Hood + +All three primitives subscribe to the singleton `KeyStateTracker` via `@tanstack/solid-store`. The tracker manages its own event listeners on `document` and maintains state in a TanStack Store. + +```tsx +import { getKeyStateTracker } from '@tanstack/solid-hotkeys' + +const tracker = getKeyStateTracker() + +tracker.getHeldKeys() // string[] +tracker.isKeyHeld('Shift') // boolean +tracker.isAnyKeyHeld(['Shift', 'Control']) // boolean +tracker.areAllKeysHeld(['Shift', 'Control']) // boolean +``` diff --git a/docs/framework/solid/guides/sequences.md b/docs/framework/solid/guides/sequences.md new file mode 100644 index 0000000..cf5c583 --- /dev/null +++ b/docs/framework/solid/guides/sequences.md @@ -0,0 +1,170 @@ +--- +title: Sequences Guide +id: sequences +--- + +TanStack Hotkeys supports multi-key sequences -- shortcuts where you press keys one after another rather than simultaneously. This is commonly used for Vim-style navigation, cheat codes, or multi-step commands. + +## Basic Usage + +Use the `createHotkeySequence` primitive to register a key sequence: + +```tsx +import { createHotkeySequence } from '@tanstack/solid-hotkeys' + +function App() { + // Vim-style: press g then g to scroll to top + createHotkeySequence(['G', 'G'], () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }) +} +``` + +The first argument is an array of `Hotkey` strings representing each step in the sequence. The user must press them in order within the timeout window. + +## Reactive Options + +Solid's `createHotkeySequence` accepts **accessor functions** for reactive sequence and options: + +```tsx +const [isVimMode, setIsVimMode] = createSignal(true) +const [sequence] = createSignal(['G', 'G'] as const) + +createHotkeySequence( + sequence, + () => scrollToTop(), + () => ({ enabled: isVimMode(), timeout: 1500 }), +) +``` + +## Sequence Options + +The third argument is an options object (or accessor returning options): + +```tsx +createHotkeySequence(['G', 'G'], callback, { + timeout: 1000, // Time allowed between keys (ms) + enabled: true, // Whether the sequence is active + target: document, // Or from an accessor for scoped sequences +}) +``` + +### `timeout` + +The maximum time (in milliseconds) allowed between consecutive key presses. Defaults to `1000` (1 second). + +```tsx +createHotkeySequence(['D', 'D'], () => deleteLine(), { timeout: 500 }) +createHotkeySequence(['Shift+Z', 'Shift+Z'], () => forceQuit(), { timeout: 2000 }) +``` + +### `enabled` + +Controls whether the sequence is active. Defaults to `true`. Use an accessor for reactive control. + +```tsx +const [isVimMode, setIsVimMode] = createSignal(true) + +createHotkeySequence(['G', 'G'], () => scrollToTop(), () => ({ + enabled: isVimMode(), +})) +``` + +### `target` + +The DOM element to attach the sequence listener to. Defaults to `document`. Can be from an accessor when the target becomes available after mount. + +### Global Default Options via Provider + +```tsx +import { HotkeysProvider } from '@tanstack/solid-hotkeys' + + + + +``` + +## Sequences with Modifiers + +Each step in a sequence can include modifiers: + +```tsx +createHotkeySequence(['Mod+K', 'Mod+C'], () => commentSelection()) +createHotkeySequence(['G', 'Shift+G'], () => scrollToBottom()) +``` + +## Common Sequence Patterns + +### Vim-Style Navigation + +```tsx +function VimNavigation() { + createHotkeySequence(['G', 'G'], () => scrollToTop()) + createHotkeySequence(['G', 'Shift+G'], () => scrollToBottom()) + createHotkeySequence(['D', 'D'], () => deleteLine()) + createHotkeySequence(['D', 'W'], () => deleteWord()) + createHotkeySequence(['C', 'I', 'W'], () => changeInnerWord()) +} +``` + +### Konami Code + +```tsx +createHotkeySequence( + [ + 'ArrowUp', 'ArrowUp', + 'ArrowDown', 'ArrowDown', + 'ArrowLeft', 'ArrowRight', + 'ArrowLeft', 'ArrowRight', + 'B', 'A', + ], + () => enableEasterEgg(), + { timeout: 2000 }, +) +``` + +### Multi-Step Commands + +```tsx +createHotkeySequence(['H', 'E', 'L', 'P'], () => openHelp()) +``` + +## How Sequences Work + +The `SequenceManager` (singleton) handles all sequence registrations. When a key is pressed: + +1. It checks if the key matches the next expected step in any registered sequence +2. If it matches, the sequence advances to the next step +3. If the timeout expires between steps, the sequence resets +4. When all steps are completed, the callback fires + +### Overlapping Sequences + +Multiple sequences can share the same prefix. The manager tracks progress for each sequence independently: + +```tsx +createHotkeySequence(['D', 'D'], () => deleteLine()) +createHotkeySequence(['D', 'W'], () => deleteWord()) +createHotkeySequence(['D', 'I', 'W'], () => deleteInnerWord()) +``` + +## The Sequence Manager + +Under the hood, `createHotkeySequence` uses the singleton `SequenceManager`. You can also use the core `createSequenceMatcher` function for standalone sequence matching: + +```tsx +import { createSequenceMatcher } from '@tanstack/solid-hotkeys' + +const matcher = createSequenceMatcher(['G', 'G'], { timeout: 1000 }) + +document.addEventListener('keydown', (e) => { + if (matcher.match(e)) { + console.log('Sequence completed!') + } + console.log('Progress:', matcher.getProgress()) +}) +``` diff --git a/docs/installation.md b/docs/installation.md index b92c872..0053f4c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -8,6 +8,7 @@ TanStack Hotkeys is compatible with various front-end frameworks. Install the co react: @tanstack/react-hotkeys +preact: @tanstack/preact-hotkeys @@ -40,3 +41,4 @@ react: @tanstack/react-hotkeys-devtools See the [devtools](./devtools) documentation for more information on how to set up and use the Hotkeys devtools. + diff --git a/examples/preact/useHeldKeys/eslint.config.js b/examples/preact/useHeldKeys/eslint.config.js new file mode 100644 index 0000000..3fd4ac4 --- /dev/null +++ b/examples/preact/useHeldKeys/eslint.config.js @@ -0,0 +1,11 @@ +// @ts-check + +import rootConfig from '../../../eslint.config.js' + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { + ignores: ['eslint.config.js'], + }, + ...rootConfig, +] diff --git a/examples/preact/useHeldKeys/index.html b/examples/preact/useHeldKeys/index.html new file mode 100644 index 0000000..6c2cce1 --- /dev/null +++ b/examples/preact/useHeldKeys/index.html @@ -0,0 +1,14 @@ + + + + + + + useHeldKeys - TanStack Hotkeys React Example + + + +
+ + + diff --git a/examples/preact/useHeldKeys/package.json b/examples/preact/useHeldKeys/package.json new file mode 100644 index 0000000..0f88274 --- /dev/null +++ b/examples/preact/useHeldKeys/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tanstack/hotkeys-example-preact-use-held-hotkeys", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3069", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/preact-hotkeys": "^0.3.0", + "preact": "^10.27.2" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "@tanstack/preact-devtools": "0.9.6", + "@tanstack/preact-hotkeys-devtools": "^0.3.0", + "typescript": "5.9.3", + "vite": "^7.3.1" + } +} diff --git a/examples/preact/useHeldKeys/src/index.css b/examples/preact/useHeldKeys/src/index.css new file mode 100644 index 0000000..5f83d60 --- /dev/null +++ b/examples/preact/useHeldKeys/src/index.css @@ -0,0 +1,131 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; +} +.app { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} +header { + text-align: center; + margin-bottom: 40px; +} +header h1 { + margin: 0 0 10px; + color: #0066cc; +} +header p { + color: #666; + margin: 0; + max-width: 500px; + margin: 0 auto; +} +.demo-section { + background: white; + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +.demo-section h2 { + margin: 0 0 16px; + font-size: 20px; +} +.demo-section ul { + margin: 0; + padding-left: 20px; +} +.demo-section li { + margin-bottom: 8px; +} +kbd { + background: linear-gradient(180deg, #f8f8f8 0%, #e8e8e8 100%); + border: 1px solid #ccc; + border-bottom-width: 2px; + border-radius: 4px; + padding: 2px 8px; + font-family: monospace; + font-size: 13px; +} +kbd.large { + font-size: 24px; + padding: 8px 16px; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +kbd.large .code-label { + display: block; + font-size: 11px; + color: #888; + font-weight: normal; +} +.key-display { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 80px; + flex-wrap: wrap; + background: #f8f9fa; + border-radius: 8px; + padding: 20px; +} +.key-display .plus { + font-size: 24px; + color: #666; +} +.placeholder { + color: #999; + font-style: italic; +} +.stats { + text-align: center; + margin-top: 16px; + font-size: 16px; + color: #666; +} +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; +} +.history-list { + list-style: none; + padding: 0; + margin: 0 0 16px; +} +.history-list li { + padding: 8px 12px; + background: #f0f0f0; + border-radius: 4px; + margin-bottom: 4px; + font-family: monospace; + font-size: 14px; +} +button { + background: #0066cc; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} +button:hover { + background: #0052a3; +} diff --git a/examples/preact/useHeldKeys/src/index.tsx b/examples/preact/useHeldKeys/src/index.tsx new file mode 100644 index 0000000..9ddaf6c --- /dev/null +++ b/examples/preact/useHeldKeys/src/index.tsx @@ -0,0 +1,150 @@ +import React from 'preact/compat' +import { render } from 'preact' +import { + formatKeyForDebuggingDisplay, + useHeldKeys, + useHeldKeyCodes, +} from '@tanstack/preact-hotkeys' +import { HotkeysProvider } from '@tanstack/preact-hotkeys' +import { hotkeysDevtoolsPlugin } from '@tanstack/preact-hotkeys-devtools' +import { TanStackDevtools } from '@tanstack/preact-devtools' +import './index.css' + +function App() { + const heldKeys = useHeldKeys() + const heldCodes = useHeldKeyCodes() + + // Track history of key combinations + const [history, setHistory] = React.useState>([]) + + React.useEffect(() => { + if (heldKeys.length > 0) { + const combo = heldKeys + .map((k) => formatKeyForDebuggingDisplay(k)) + .join(' + ') + setHistory((h) => { + // Only add if different from last entry + if (h[h.length - 1] !== combo) { + return [...h.slice(-9), combo] + } + return h + }) + } + }, [heldKeys]) + + console.log('heldKeys', heldKeys) + + return ( +
+
+

useHeldKeys

+

+ Returns an array of all currently pressed keys. Useful for displaying + key combinations or building custom shortcut recording. +

+
+ +
+
+

Currently Held Keys

+
+ {heldKeys.length > 0 ? ( + heldKeys.map((key, index) => { + const code = heldCodes[key] + return ( + + {index > 0 && +} + + {formatKeyForDebuggingDisplay(key)} + {code && code !== key && ( + + {formatKeyForDebuggingDisplay(code, { + source: 'code', + })} + + )} + + + ) + }) + ) : ( + Press any keys... + )} +
+
+ Keys held: {heldKeys.length} +
+
+ +
+

Usage

+
{`import { useHeldKeys } from '@tanstack/preact-hotkeys'
+
+function KeyDisplay() {
+  const heldKeys = useHeldKeys()
+
+  return (
+    
+ Currently pressed: {heldKeys.join(' + ') || 'None'} +
+ ) +}`}
+
+ +
+

Try These Combinations

+
    +
  • + Hold Shift + Control + A +
  • +
  • Press multiple letter keys at once
  • +
  • Hold modifiers and watch them appear
  • +
  • Release keys one by one
  • +
+
+ +
+

Recent Combinations

+ {history.length > 0 ? ( +
    + {history.map((combo, i) => ( +
  • {combo}
  • + ))} +
+ ) : ( +

Press some key combinations...

+ )} + +
+ +
+

Use Cases

+
    +
  • Building a keyboard shortcut recorder
  • +
  • Displaying currently held keys to users
  • +
  • Debugging keyboard input
  • +
  • Creating key combination tutorials
  • +
+
+
+
+ ) +} + +// TanStackDevtools as sibling of App to avoid Preact hook errors when hotkeys update state +const devtoolsPlugins = [hotkeysDevtoolsPlugin()] + +render( + // optionally, provide default options to an optional HotkeysProvider + + + + , + document.getElementById('root')!, +) diff --git a/examples/preact/useHeldKeys/tsconfig.json b/examples/preact/useHeldKeys/tsconfig.json new file mode 100644 index 0000000..faa3381 --- /dev/null +++ b/examples/preact/useHeldKeys/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "jsxImportSource": "preact" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/preact/useHeldKeys/vite.config.ts b/examples/preact/useHeldKeys/vite.config.ts new file mode 100644 index 0000000..bfe110c --- /dev/null +++ b/examples/preact/useHeldKeys/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import preact from '@preact/preset-vite' + +export default defineConfig({ + plugins: [preact()], +}) diff --git a/examples/preact/useHotkey/eslint.config.js b/examples/preact/useHotkey/eslint.config.js new file mode 100644 index 0000000..3fd4ac4 --- /dev/null +++ b/examples/preact/useHotkey/eslint.config.js @@ -0,0 +1,11 @@ +// @ts-check + +import rootConfig from '../../../eslint.config.js' + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { + ignores: ['eslint.config.js'], + }, + ...rootConfig, +] diff --git a/examples/preact/useHotkey/index.html b/examples/preact/useHotkey/index.html new file mode 100644 index 0000000..10fc3f3 --- /dev/null +++ b/examples/preact/useHotkey/index.html @@ -0,0 +1,14 @@ + + + + + + + useHotkey - TanStack Hotkeys React Example + + + +
+ + + diff --git a/examples/preact/useHotkey/package.json b/examples/preact/useHotkey/package.json new file mode 100644 index 0000000..1c27c64 --- /dev/null +++ b/examples/preact/useHotkey/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tanstack/hotkeys-example-preact-use-hotkey", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3069", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/preact-hotkeys": "^0.3.0", + "preact": "^10.27.2" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "@tanstack/preact-devtools": "0.9.6", + "@tanstack/preact-hotkeys-devtools": "^0.3.0", + "typescript": "5.9.3", + "vite": "^7.3.1" + } +} diff --git a/examples/preact/useHotkey/src/index.css b/examples/preact/useHotkey/src/index.css new file mode 100644 index 0000000..e9f3ca9 --- /dev/null +++ b/examples/preact/useHotkey/src/index.css @@ -0,0 +1,212 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; +} +.app { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} +header { + text-align: center; + margin-bottom: 40px; +} +header h1 { + margin: 0 0 10px; + color: #0066cc; +} +header p { + color: #666; + margin: 0; +} +.demo-section { + background: white; + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +.demo-section h2 { + margin: 0 0 12px; + font-size: 20px; +} +.demo-section p { + margin: 0 0 12px; +} +kbd { + background: linear-gradient(180deg, #f8f8f8 0%, #e8e8e8 100%); + border: 1px solid #ccc; + border-bottom-width: 2px; + border-radius: 4px; + padding: 2px 8px; + font-family: monospace; + font-size: 13px; +} +.counter { + font-size: 28px; + font-weight: bold; + color: #0066cc; + margin: 16px 0; +} +.hint { + font-size: 13px; + color: #888; + font-style: italic; +} +.info-box { + background: #e3f2fd; + border-radius: 8px; + padding: 12px 16px; + margin: 20px 0; +} +button { + background: #0066cc; + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} +button:hover { + background: #0052a3; +} +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; + margin-top: 16px; +} +.hotkey-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin: 16px 0; +} +.hotkey-grid > div { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #f8f9fa; + border-radius: 6px; + font-size: 14px; +} +.hotkey-grid kbd { + flex-shrink: 0; +} + +/* Scoped shortcuts section */ +.scoped-section { + margin-top: 40px; +} + +.scoped-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + margin: 24px 0; +} + +.scoped-area { + background: #f8f9fa; + border: 2px dashed #0066cc; + border-radius: 8px; + padding: 20px; + position: relative; +} + +.scoped-area:focus-within { + border-color: #0052a3; + border-style: solid; + background: #f0f7ff; + box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1); +} + +.scoped-area h3 { + margin: 0 0 12px; + font-size: 18px; + color: #0066cc; +} + +.scoped-area .hotkey-list { + margin: 12px 0; +} + +.scoped-area .hotkey-list > div { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + font-size: 14px; +} + +.scoped-editor { + width: 100%; + margin: 12px 0; + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-family: 'Courier New', monospace; + font-size: 14px; + resize: vertical; + min-height: 120px; +} + +.scoped-editor:focus { + outline: 2px solid #0066cc; + outline-offset: 2px; + border-color: #0066cc; +} + +/* Modal styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: white; + border-radius: 12px; + padding: 24px; + max-width: 500px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.modal-content:focus { + outline: 3px solid #0066cc; + outline-offset: 2px; +} + +.modal-content h3 { + margin: 0 0 16px; + font-size: 20px; + color: #0066cc; +} + +.modal-content button { + margin-top: 16px; +} diff --git a/examples/preact/useHotkey/src/index.tsx b/examples/preact/useHotkey/src/index.tsx new file mode 100644 index 0000000..6941eee --- /dev/null +++ b/examples/preact/useHotkey/src/index.tsx @@ -0,0 +1,749 @@ +import React from 'preact/compat' +import { render } from 'preact' +import { formatForDisplay, useHotkey } from '@tanstack/preact-hotkeys' +import { HotkeysProvider } from '@tanstack/preact-hotkeys' +import { hotkeysDevtoolsPlugin } from '@tanstack/preact-hotkeys-devtools' +import { TanStackDevtools } from '@tanstack/preact-devtools' +import type { Hotkey } from '@tanstack/preact-hotkeys' +import './index.css' + +function App() { + const [lastHotkey, setLastHotkey] = React.useState(null) + const [saveCount, setSaveCount] = React.useState(0) + const [incrementCount, setIncrementCount] = React.useState(0) + const [enabled, setEnabled] = React.useState(true) + const [activeTab, setActiveTab] = React.useState(1) + const [navigationCount, setNavigationCount] = React.useState(0) + const [functionKeyCount, setFunctionKeyCount] = React.useState(0) + const [multiModifierCount, setMultiModifierCount] = React.useState(0) + const [editingKeyCount, setEditingKeyCount] = React.useState(0) + + // Scoped shortcuts state + const [modalOpen, setModalOpen] = React.useState(false) + const [editorContent, setEditorContent] = React.useState('') + const [sidebarShortcutCount, setSidebarShortcutCount] = React.useState(0) + const [modalShortcutCount, setModalShortcutCount] = React.useState(0) + const [editorShortcutCount, setEditorShortcutCount] = React.useState(0) + + // Refs for scoped shortcuts + const sidebarRef = React.useRef(null) + const modalRef = React.useRef(null) + const editorRef = React.useRef(null) + + // Type-safe refs for useHotkey (HTMLTextAreaElement extends HTMLElement) + const editorRefForHotkey = editorRef as React.RefObject + + // ============================================================================ + // Basic Hotkeys + // ============================================================================ + + // Browser default: Save page (downloads the current page) + // Basic hotkey with callback context + useHotkey('Mod+S', (_event, { hotkey, parsedHotkey }) => { + setLastHotkey(hotkey) + setSaveCount((c) => c + 1) + console.log('Hotkey triggered:', hotkey) + console.log('Parsed hotkey:', parsedHotkey) + }) + + // requireReset prevents repeated triggering while holding keys + useHotkey( + 'Mod+K', + (_event, { hotkey }) => { + setLastHotkey(hotkey) + setIncrementCount((c) => c + 1) + }, + { requireReset: true }, + ) + + // Conditional hotkey (enabled/disabled) + useHotkey( + 'Mod+E', + (_event, { hotkey }) => { + setLastHotkey(hotkey) + alert('This hotkey can be toggled!') + }, + { enabled }, + ) + + // ============================================================================ + // Number Key Combinations (Tab/Section Switching) + // ============================================================================ + + // Browser default: Switch to tab 1 + useHotkey('Mod+1', () => { + setLastHotkey('Mod+1') + setActiveTab(1) + }) + + useHotkey('Mod+2', () => { + setLastHotkey('Mod+2') + setActiveTab(2) + }) + + useHotkey('Mod+3', () => { + setLastHotkey('Mod+3') + setActiveTab(3) + }) + + useHotkey('Mod+4', () => { + setLastHotkey('Mod+4') + setActiveTab(4) + }) + + useHotkey('Mod+5', () => { + setLastHotkey('Mod+5') + setActiveTab(5) + }) + + // ============================================================================ + // Navigation Key Combinations + // ============================================================================ + + useHotkey('Shift+ArrowUp', () => { + setLastHotkey('Shift+ArrowUp') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Shift+ArrowDown', () => { + setLastHotkey('Shift+ArrowDown') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Alt+ArrowLeft', () => { + setLastHotkey('Alt+ArrowLeft') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Alt+ArrowRight', () => { + setLastHotkey('Alt+ArrowRight') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Mod+Home', () => { + setLastHotkey('Mod+Home') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Mod+End', () => { + setLastHotkey('Mod+End') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Control+PageUp', () => { + setLastHotkey('Control+PageUp') + setNavigationCount((c) => c + 1) + }) + + useHotkey('Control+PageDown', () => { + setLastHotkey('Control+PageDown') + setNavigationCount((c) => c + 1) + }) + + // ============================================================================ + // Function Key Combinations + // ============================================================================ + + useHotkey('Meta+F4', () => { + setLastHotkey('Alt+F4') + setFunctionKeyCount((c) => c + 1) + alert('Alt+F4 pressed (normally closes window)') + }) + + useHotkey('Control+F5', () => { + setLastHotkey('Control+F5') + setFunctionKeyCount((c) => c + 1) + }) + + useHotkey('Mod+F1', () => { + setLastHotkey('Mod+F1') + setFunctionKeyCount((c) => c + 1) + }) + + useHotkey('Shift+F10', () => { + setLastHotkey('Shift+F10') + setFunctionKeyCount((c) => c + 1) + }) + + // ============================================================================ + // Multi-Modifier Combinations + // ============================================================================ + + useHotkey('Mod+Shift+S', () => { + setLastHotkey('Mod+Shift+S') + setMultiModifierCount((c) => c + 1) + }) + + useHotkey('Mod+Shift+Z', () => { + setLastHotkey('Mod+Shift+Z') + setMultiModifierCount((c) => c + 1) + }) + + useHotkey({ key: 'A', ctrl: true, alt: true }, () => { + setLastHotkey('Control+Alt+A') + setMultiModifierCount((c) => c + 1) + }) + + useHotkey('Control+Shift+N', () => { + setLastHotkey('Control+Shift+N') + setMultiModifierCount((c) => c + 1) + }) + + useHotkey('Mod+Alt+T', () => { + setLastHotkey('Mod+Alt+T') + setMultiModifierCount((c) => c + 1) + }) + + useHotkey('Control+Alt+Shift+X', () => { + setLastHotkey('Control+Alt+Shift+X') + setMultiModifierCount((c) => c + 1) + }) + + // ============================================================================ + // Editing Key Combinations + // ============================================================================ + + useHotkey('Mod+Enter', () => { + setLastHotkey('Mod+Enter') + setEditingKeyCount((c) => c + 1) + }) + + useHotkey('Shift+Enter', () => { + setLastHotkey('Shift+Enter') + setEditingKeyCount((c) => c + 1) + }) + + useHotkey('Mod+Backspace', () => { + setLastHotkey('Mod+Backspace') + setEditingKeyCount((c) => c + 1) + }) + + useHotkey('Mod+Delete', () => { + setLastHotkey('Mod+Delete') + setEditingKeyCount((c) => c + 1) + }) + + useHotkey('Control+Tab', () => { + setLastHotkey('Control+Tab') + setEditingKeyCount((c) => c + 1) + }) + + useHotkey('Shift+Tab', () => { + setLastHotkey('Shift+Tab') + setEditingKeyCount((c) => c + 1) + }) + + useHotkey('Mod+Space', () => { + setLastHotkey('Mod+Space') + setEditingKeyCount((c) => c + 1) + }) + + // ============================================================================ + // Single Keys + // ============================================================================ + + // Clear with Escape (RawHotkey object form) + useHotkey({ key: 'Escape' }, () => { + setLastHotkey(null) + setSaveCount(0) + setIncrementCount(0) + setNavigationCount(0) + setFunctionKeyCount(0) + setMultiModifierCount(0) + setEditingKeyCount(0) + setActiveTab(1) + }) + + useHotkey('F12', () => { + setLastHotkey('F12') + setFunctionKeyCount((c) => c + 1) + }) + + // ============================================================================ + // Scoped Keyboard Shortcuts + // ============================================================================ + + // Scoped to sidebar - only works when sidebar is focused or contains focus + // Auto-focus modal when opened so scoped shortcuts work immediately + React.useEffect(() => { + if (modalOpen) { + modalRef.current?.focus() + } + }, [modalOpen]) + + useHotkey( + 'Mod+B', + () => { + setLastHotkey('Mod+B') + setSidebarShortcutCount((c) => c + 1) + alert( + 'Sidebar shortcut triggered! This only works when the sidebar area is focused.', + ) + }, + { target: sidebarRef }, + ) + + useHotkey( + 'Mod+N', + () => { + setLastHotkey('Mod+N') + setSidebarShortcutCount((c) => c + 1) + }, + { target: sidebarRef }, + ) + + // Scoped to modal - only works when modal is open and focused + useHotkey( + 'Escape', + () => { + setLastHotkey('Escape') + setModalShortcutCount((c) => c + 1) + setModalOpen(false) + }, + { target: modalRef, enabled: modalOpen }, + ) + + useHotkey( + 'Mod+Enter', + () => { + setLastHotkey('Mod+Enter') + setModalShortcutCount((c) => c + 1) + alert('Modal submit shortcut!') + }, + { target: modalRef, enabled: modalOpen }, + ) + + // Scoped to editor - only works when editor is focused + useHotkey( + 'Mod+S', + () => { + setLastHotkey('Mod+S') + setEditorShortcutCount((c) => c + 1) + alert( + `Editor content saved: "${editorContent.substring(0, 50)}${editorContent.length > 50 ? '...' : ''}"`, + ) + }, + { target: editorRefForHotkey }, + ) + + useHotkey( + 'Mod+/', + () => { + setLastHotkey('Mod+/') + setEditorShortcutCount((c) => c + 1) + setEditorContent((prev) => prev + '\n// Comment added via shortcut') + }, + { target: editorRefForHotkey }, + ) + + useHotkey( + 'Mod+K', + () => { + setLastHotkey('Mod+K') + setEditorShortcutCount((c) => c + 1) + setEditorContent('') + }, + { target: editorRefForHotkey }, + ) + + return ( +
+
+

useHotkey

+

+ Register keyboard shortcuts with callback context containing the + hotkey and parsed hotkey information. +

+
+ +
+
+

Basic Hotkey

+

+ Press {formatForDisplay('Mod+S')} to trigger +

+
Save triggered: {saveCount}x
+
{`useHotkey('Mod+S', (_event, { hotkey, parsedHotkey }) => {
+  console.log('Hotkey:', hotkey)
+  console.log('Parsed:', parsedHotkey)
+})`}
+
+ +
+

With requireReset

+

+ Hold {formatForDisplay('Mod+K')} — only increments once + until you release all keys +

+
Increment: {incrementCount}
+

+ This prevents repeated triggering while holding the keys down. + Release all keys to allow re-triggering. +

+
{`useHotkey(
+  'Mod+K',
+  (event, { hotkey }) => {
+    setCount(c => c + 1)
+  },
+  { requireReset: true }
+)`}
+
+ +
+

Conditional Hotkey

+

+ {formatForDisplay('Mod+E')} is currently{' '} + {enabled ? 'enabled' : 'disabled'} +

+ +
{`const [enabled, setEnabled] = useState(true)
+
+useHotkey(
+  'Mod+E',
+  (event, { hotkey }) => {
+    alert('Triggered!')
+  },
+  { enabled }
+)`}
+
+ +
+

Number Key Combinations

+

Common for tab/section switching:

+
+
+ {formatForDisplay('Mod+1')} → Tab 1 +
+
+ {formatForDisplay('Mod+2')} → Tab 2 +
+
+ {formatForDisplay('Mod+3')} → Tab 3 +
+
+ {formatForDisplay('Mod+4')} → Tab 4 +
+
+ {formatForDisplay('Mod+5')} → Tab 5 +
+
+
Active Tab: {activeTab}
+
{`useHotkey('Mod+1', () => setActiveTab(1))
+useHotkey('Mod+2', () => setActiveTab(2))
+`}
+
+ +
+

Navigation Key Combinations

+

Selection and navigation shortcuts:

+
+
+ {formatForDisplay('Shift+ArrowUp')} — Select up +
+
+ {formatForDisplay('Shift+ArrowDown')} — Select down +
+
+ {formatForDisplay('Alt+ArrowLeft')} — Navigate back +
+
+ {formatForDisplay('Alt+ArrowRight')} — Navigate forward +
+
+ {formatForDisplay('Mod+Home')} — Go to start +
+
+ {formatForDisplay('Mod+End')} — Go to end +
+
+ {formatForDisplay('Control+PageUp')} — Previous page +
+
+ {formatForDisplay('Control+PageDown')} — Next page +
+
+
+ Navigation triggered: {navigationCount}x +
+
{`useHotkey('Shift+ArrowUp', () => selectUp())
+useHotkey('Alt+ArrowLeft', () => navigateBack())
+useHotkey('Mod+Home', () => goToStart())
+useHotkey('Control+PageUp', () => previousPage())`}
+
+ +
+

Function Key Combinations

+

System and application shortcuts:

+
+
+ {formatForDisplay('Alt+F4')} — Close window +
+
+ {formatForDisplay('Control+F5')} — Hard refresh +
+
+ {formatForDisplay('Mod+F1')} — Help +
+
+ {formatForDisplay('Shift+F10')} — Context menu +
+
+ {formatForDisplay('F12')} — DevTools +
+
+
+ Function keys triggered: {functionKeyCount}x +
+
{`useHotkey('Alt+F4', () => closeWindow())
+useHotkey('Control+F5', () => hardRefresh())
+useHotkey('Mod+F1', () => showHelp())
+useHotkey('F12', () => openDevTools())`}
+
+ +
+

Multi-Modifier Combinations

+

Complex shortcuts with multiple modifiers:

+
+
+ {formatForDisplay('Mod+Shift+S')} — Save As +
+
+ {formatForDisplay('Mod+Shift+Z')} — Redo +
+
+ {formatForDisplay('Control+Alt+A')} — Special action +
+
+ {formatForDisplay('Control+Shift+N')} — New incognito +
+
+ {formatForDisplay('Mod+Alt+T')} — Toggle theme +
+
+ {formatForDisplay('Control+Alt+Shift+X')} — Triple + modifier +
+
+
+ Multi-modifier triggered: {multiModifierCount}x +
+
{`useHotkey('Mod+Shift+S', () => saveAs())
+useHotkey('Mod+Shift+Z', () => redo())
+useHotkey('Control+Alt+A', () => specialAction())
+useHotkey('Control+Alt+Shift+X', () => complexAction())`}
+
+ +
+

Editing Key Combinations

+

Text editing and form shortcuts:

+
+
+ {formatForDisplay('Mod+Enter')} — Submit form +
+
+ {formatForDisplay('Shift+Enter')} — New line +
+
+ {formatForDisplay('Mod+Backspace')} — Delete word +
+
+ {formatForDisplay('Mod+Delete')} — Delete forward +
+
+ {formatForDisplay('Control+Tab')} — Next tab +
+
+ {formatForDisplay('Shift+Tab')} — Previous field +
+
+ {formatForDisplay('Mod+Space')} — Toggle +
+
+
+ Editing keys triggered: {editingKeyCount}x +
+
{`useHotkey('Mod+Enter', () => submitForm())
+useHotkey('Shift+Enter', () => insertNewline())
+useHotkey('Mod+Backspace', () => deleteWord())
+useHotkey('Control+Tab', () => nextTab())
+useHotkey('Mod+Space', () => toggle())`}
+
+ + {lastHotkey && ( +
+ Last triggered: {formatForDisplay(lastHotkey)} +
+ )} + +

+ Press Escape to reset all counters +

+ + {/* ==================================================================== */} + {/* Scoped Keyboard Shortcuts Section */} + {/* ==================================================================== */} +
+

Scoped Keyboard Shortcuts

+

+ Shortcuts can be scoped to specific DOM elements using the{' '} + target option. This allows different shortcuts to work + in different parts of your application. +

+ +
+ {/* Sidebar Example */} +
+

Sidebar (Scoped Area)

+

Click here to focus, then try:

+
+
+ {formatForDisplay('Mod+B')} — Trigger sidebar + action +
+
+ {formatForDisplay('Mod+N')} — New item +
+
+
+ Sidebar shortcuts: {sidebarShortcutCount}x +
+

+ These shortcuts only work when this sidebar area is focused or + contains focus. +

+
+ + {/* Modal Example */} +
+

Modal Dialog

+ + {modalOpen && ( +
setModalOpen(false)} + > +
e.stopPropagation()} + > +

Modal Dialog (Scoped)

+

Try these shortcuts while modal is open:

+
+
+ {formatForDisplay('Escape')} — Close modal +
+
+ {formatForDisplay('Mod+Enter')} — Submit +
+
+
+ Modal shortcuts: {modalShortcutCount}x +
+

+ These shortcuts only work when the modal is open and + focused. The Escape key here won't conflict with the + global Escape handler. +

+ +
+
+ )} +
+ + {/* Editor Example */} +
+

Text Editor (Scoped)

+

Focus the editor below and try:

+
+
+ {formatForDisplay('Mod+S')} — Save editor content +
+
+ {formatForDisplay('Mod+/')} — Add comment +
+
+ {formatForDisplay('Mod+K')} — Clear editor +
+
+