diff --git a/.changeset/add-lit-adapter.md b/.changeset/add-lit-adapter.md new file mode 100644 index 00000000..ad347ab6 --- /dev/null +++ b/.changeset/add-lit-adapter.md @@ -0,0 +1,6 @@ +--- +'@tanstack/lit-hotkeys': minor +'@tanstack/hotkeys': patch +--- + +Add `@tanstack/lit-hotkeys` package with Lit reactive controllers and decorators for hotkeys, hotkey sequences, key hold, held keys, hotkey recording, and sequence recording. Also fix input element detection inside shadow DOMs in the core hotkeys package. diff --git a/.npmrc b/.npmrc index 268c392d..0473bec6 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ provenance=true +use-node-version=24.14.1 \ No newline at end of file diff --git a/README.md b/README.md index 7c581ae2..c0473686 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/docs/config.json b/docs/config.json index 3ce2c3c1..5ba169a7 100644 --- a/docs/config.json +++ b/docs/config.json @@ -68,6 +68,15 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Quick Start", + "to": "framework/lit/quick-start" + } + ] + }, { "label": "svelte", "children": [ @@ -228,6 +237,35 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Hotkeys", + "to": "framework/lit/guides/hotkeys" + }, + { + "label": "Sequences", + "to": "framework/lit/guides/sequences" + }, + { + "label": "Hotkey Recording", + "to": "framework/lit/guides/hotkey-recording" + }, + { + "label": "Hotkey Sequence Recording", + "to": "framework/lit/guides/sequence-recording" + }, + { + "label": "Key State Tracking", + "to": "framework/lit/guides/key-state-tracking" + }, + { + "label": "Formatting & Display", + "to": "framework/lit/guides/formatting-display" + } + ] + }, { "label": "svelte", "children": [ @@ -313,6 +351,15 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "Lit Controllers & Decorators", + "to": "framework/lit/reference/index" + } + ] + }, { "label": "svelte", "children": [ @@ -548,6 +595,19 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "hotkey", + "to": "framework/lit/reference/functions/hotkey" + }, + { + "label": "HotkeyController", + "to": "framework/lit/reference/classes/HotkeyController" + } + ] + }, { "label": "svelte", "children": [ @@ -747,6 +807,19 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "hotkeySequence", + "to": "framework/lit/reference/functions/hotkeySequence" + }, + { + "label": "HotkeySequenceController", + "to": "framework/lit/reference/classes/HotkeySequenceController" + } + ] + }, { "label": "svelte", "children": [ @@ -886,6 +959,23 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "KeyHoldController", + "to": "framework/lit/reference/classes/KeyHoldController" + }, + { + "label": "HeldKeysController", + "to": "framework/lit/reference/classes/HeldKeysController" + }, + { + "label": "HeldKeyCodesController", + "to": "framework/lit/reference/classes/HeldKeyCodesController" + } + ] + }, { "label": "svelte", "children": [ @@ -1001,6 +1091,15 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "HotkeyRecorderController", + "to": "framework/lit/reference/classes/HotkeyRecorderController" + } + ] + }, { "label": "svelte", "children": [ @@ -1104,6 +1203,15 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "HotkeySequenceRecorderController", + "to": "framework/lit/reference/classes/HotkeySequenceRecorderController" + } + ] + }, { "label": "svelte", "children": [ @@ -1388,6 +1496,35 @@ } ] }, + { + "label": "lit", + "children": [ + { + "label": "hotkey", + "to": "framework/lit/examples/hotkey" + }, + { + "label": "hotkey-sequence", + "to": "framework/lit/examples/hotkey-sequence" + }, + { + "label": "hotkey-recorder", + "to": "framework/lit/examples/hotkey-recorder" + }, + { + "label": "hotkey-sequence-recorder", + "to": "framework/lit/examples/hotkey-sequence-recorder" + }, + { + "label": "held-keys", + "to": "framework/lit/examples/held-keys" + }, + { + "label": "key-hold", + "to": "framework/lit/examples/key-hold" + } + ] + }, { "label": "svelte", "children": [ diff --git a/docs/devtools.md b/docs/devtools.md index e1d04436..a6a2d90f 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -45,7 +45,7 @@ npm install @tanstack/solid-devtools @tanstack/solid-hotkeys-devtools npm install @tanstack/vue-hotkeys-devtools ``` -Angular does not currently ship a dedicated hotkeys devtools adapter. +Angular and Lit do not currently ship a dedicated hotkeys devtools adapter. ## Setup diff --git a/docs/framework/lit/guides/formatting-display.md b/docs/framework/lit/guides/formatting-display.md new file mode 100644 index 00000000..825cd66d --- /dev/null +++ b/docs/framework/lit/guides/formatting-display.md @@ -0,0 +1,266 @@ +--- +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. + +```ts +import { formatForDisplay } from '@tanstack/lit-hotkeys' + +// On macOS (symbols separated by spaces): +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') + useSymbols: true, // default; set false for text labels on macOS +}) +``` + +On macOS, modifier **order** matches canonical normalization (same as `formatWithLabels`), and symbols are joined with **spaces** (e.g., `⌘ ⇧ Z`). On Windows and Linux, modifiers are joined with `+` (e.g., `Ctrl+Shift+Z`). + +`platform` is used for both normalization and display. If you need to show the same [`ParsedHotkey`](../../../reference/interfaces/ParsedHotkey.md) under several platforms, first serialize with the platform it was parsed with, then format for each display platform: + +```ts +import { + formatForDisplay, + normalizeHotkeyFromParsed, + parseHotkey, +} from '@tanstack/lit-hotkeys' + +const parsed = parseHotkey('Mod+K', 'mac') +const canonical = normalizeHotkeyFromParsed(parsed, 'mac') +formatForDisplay(canonical, { platform: 'windows' }) // "Ctrl+K" +``` + +## `formatWithLabels` + +Returns human-readable text labels (e.g., "Cmd" instead of the symbol). Useful when you want readable text rather than symbols. + +```ts +import { formatWithLabels } from '@tanstack/lit-hotkeys' + +// On macOS: +formatWithLabels('Mod+S', { platform: 'mac' }) // "Cmd+S" +formatWithLabels('Mod+Shift+Z', { platform: 'mac' }) // "Cmd+Shift+Z" + +// On Windows/Linux: +formatWithLabels('Mod+S', { platform: 'windows' }) // "Ctrl+S" +formatWithLabels('Mod+Shift+Z', { platform: 'windows' }) // "Ctrl+Shift+Z" +``` + +Modifier order matches canonical normalization from the core package (e.g. `Mod` first, then `Shift`, then the key). + +## Using Formatted Hotkeys in Lit + +### Keyboard Shortcut Badges + +```ts +import { LitElement, html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { formatForDisplay } from '@tanstack/lit-hotkeys' + +@customElement('shortcut-badge') +class ShortcutBadge extends LitElement { + @property({ type: String }) hotkey = '' + + render() { + return html`${formatForDisplay(this.hotkey)}` + } +} +``` +Usage: +```html + + +``` + +### Menu Items with Hotkeys + +`HotkeyController` registers the shortcut; `formatForDisplay` renders the label in the template. + +```ts +import { LitElement, html } from 'lit' +import type { PropertyValues } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { HotkeyController, formatForDisplay } from '@tanstack/lit-hotkeys' + +@customElement('menu-item') +class MenuItem extends LitElement { + @property({ type: String }) label = '' + + @property({ type: String }) hotkey = '' + + private hotkeyController?: HotkeyController + + updated(changedProperties: PropertyValues) { + if (!changedProperties.has('hotkey')) return + + if (this.hotkeyController) { + this.hotkeyController.hostDisconnected() + this.removeController(this.hotkeyController) + this.hotkeyController = undefined + } + + if (!this.hotkey) return + + this.hotkeyController = new HotkeyController(this, this.hotkey, () => + this.dispatchEvent( + new CustomEvent('action', { bubbles: true, composed: true }), + ), + ) + this.addController(this.hotkeyController) + } + + render() { + return html` + + ` + } +} +``` + +Usage: +```ts +html` + + + +` +``` + +### Command Palette Items + +```ts +import { LitElement, html, nothing } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { formatForDisplay } from '@tanstack/lit-hotkeys' +import type { Hotkey } from '@tanstack/lit-hotkeys' + +interface Command { + id: string + label: string + hotkey?: Hotkey + action: () => void +} + +@customElement('command-palette-item') +class CommandPaletteItem extends LitElement { + @property({ type: Object }) command!: Command + + render() { + const { label, hotkey, action } = this.command + return html` +
+ ${label} + ${hotkey ? html`${formatForDisplay(hotkey)}` : nothing} +
+ ` + } +} +``` + +## 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 components: + +```ts +import { parseHotkey } from '@tanstack/lit-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` and `normalizeRegisterableHotkey` + +Core helpers produce a **canonical** hotkey string for storage and registration. When the platform allows `Mod` (Command on Mac without Control; Control on Windows/Linux without Meta), the output uses `Mod` and **Mod-first** modifier order (`Mod+Shift+E`), not expanded `Meta`/`Control`. + +```ts +import { normalizeHotkey, normalizeRegisterableHotkey } from '@tanstack/lit-hotkeys' + +normalizeHotkey('Cmd+S', 'mac') // 'Mod+S' +normalizeHotkey('Ctrl+Shift+s', 'windows') // 'Mod+Shift+S' +normalizeHotkey('Shift+Meta+E', 'mac') // 'Mod+Shift+E' + +// String or RawHotkey — same string adapters use internally: +normalizeRegisterableHotkey({ key: 'S', mod: true, shift: true }, 'mac') // 'Mod+Shift+S' +``` + +The Lit integration (`HotkeyController`, `@hotkey`) normalizes registerable hotkeys automatically via `normalizeRegisterableHotkey`. + +## Validation + +Use `validateHotkey` to check if a hotkey string is valid and get warnings about potential platform issues: + +```ts +import { validateHotkey } from '@tanstack/lit-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/lit/guides/hotkey-recording.md b/docs/framework/lit/guides/hotkey-recording.md new file mode 100644 index 00000000..32421afa --- /dev/null +++ b/docs/framework/lit/guides/hotkey-recording.md @@ -0,0 +1,208 @@ +--- +title: Hotkey Recording Guide +id: hotkey-recording +--- + +TanStack Hotkeys provides the `HotkeyRecorderController` 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 + +```ts +import { LitElement, html, nothing } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HotkeyRecorderController, formatForDisplay } from '@tanstack/lit-hotkeys' + +@customElement('shortcut-recorder') +class ShortcutRecorder extends LitElement { + private recorder = new HotkeyRecorderController(this, { + onRecord: (hotkey) => { + console.log('Recorded:', hotkey) // e.g., "Mod+Shift+S" + }, + }) + + render() { + const { isRecording, recordedHotkey } = this.recorder + return html` +
+ + ${isRecording + ? html`` + : nothing} +
+ ` + } +} +``` + +## Controller API + +`HotkeyRecorderController` exposes the following reactive getters and methods: + +| Member | Type | Description | +|--------|------|-------------| +| `isRecording` | `boolean` (getter) | Whether the recorder is currently listening for key presses | +| `recordedHotkey` | `Hotkey \| null` (getter) | 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 | +| `setOptions(opts)` | `(Partial) => void` | Update callbacks at runtime | + +The controller registers itself with the host in its constructor, subscribes to the underlying `HotkeyRecorder` store on `hostConnected`, and cleans up on `hostDisconnected`. + +## Options + +Pass options as the second argument to the constructor: + +```ts +new HotkeyRecorderController(this, { + 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). + + +## 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: + +```ts +import { LitElement, html } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { + HotkeyRecorderController, + HotkeyController, + formatForDisplay, +} from '@tanstack/lit-hotkeys' +import type { Hotkey } from '@tanstack/lit-hotkeys' + +interface ShortcutMap { + save: Hotkey + undo: Hotkey + search: Hotkey +} + +@customElement('shortcut-settings') +class ShortcutSettings extends LitElement { + @state() private shortcuts: ShortcutMap = { + save: 'Mod+S', + undo: 'Mod+Z', + search: 'Mod+K', + } + + @state() private editingAction: keyof ShortcutMap | null = null + + private recorder = new HotkeyRecorderController(this, { + onRecord: (hotkey) => { + if (this.editingAction) { + this.shortcuts = { ...this.shortcuts, [this.editingAction]: hotkey } + this.editingAction = null + } + }, + onCancel: () => { + this.editingAction = null + }, + }) + + private saveCtrl?: HotkeyController + private undoCtrl?: HotkeyController + private searchCtrl?: HotkeyController + + connectedCallback() { + super.connectedCallback() + this._registerHotkeys() + } + + updated() { + this._unregisterHotkeys() + this._registerHotkeys() + } + + private _registerHotkeys() { + this.saveCtrl = new HotkeyController(this, this.shortcuts.save, () => save()) + this.undoCtrl = new HotkeyController(this, this.shortcuts.undo, () => undo()) + this.searchCtrl = new HotkeyController(this, this.shortcuts.search, () => openSearch()) + } + + private _unregisterHotkeys() { + this.saveCtrl?.hostDisconnected() + this.undoCtrl?.hostDisconnected() + this.searchCtrl?.hostDisconnected() + } + + disconnectedCallback() { + super.disconnectedCallback() + this._unregisterHotkeys() + } + + render() { + return html` +
+

Keyboard Shortcuts

+ ${(Object.entries(this.shortcuts) as Array<[keyof ShortcutMap, Hotkey]>).map( + ([action, hotkey]) => html` +
+ ${action} + +
+ `, + )} +
+ ` + } +} +``` + +## Under the Hood + +The `HotkeyRecorderController` creates a `HotkeyRecorder` class instance and subscribes to its reactive state via the recorder's TanStack Store. The class manages its own keyboard event listeners and state, and the controller handles cleanup on disconnect. diff --git a/docs/framework/lit/guides/hotkeys.md b/docs/framework/lit/guides/hotkeys.md new file mode 100644 index 00000000..0e0ba535 --- /dev/null +++ b/docs/framework/lit/guides/hotkeys.md @@ -0,0 +1,317 @@ +--- +title: Hotkeys Guide +id: hotkeys +--- + +The `@hotkey` decorator is the primary way to register keyboard shortcuts in Lit applications. It binds a hotkey to a class method, automatically registering when the element connects to the DOM and unregistering when it disconnects. For more dynamic use cases, the `HotkeyController` provides imperative control over hotkey registration. + +Both approaches wrap the singleton `HotkeyManager` with automatic lifecycle management tied to Lit's `connectedCallback` / `disconnectedCallback`. + +## Basic Usage + +### The `@hotkey` Decorator + +Decorate any method to have it called when a hotkey is pressed: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { hotkey } from '@tanstack/lit-hotkeys' + +@customElement('my-editor') +class MyEditor extends LitElement { + @hotkey('Mod+S') + save() { + saveDocument() + } + + render() { + return html`
Press Cmd+S (Mac) or Ctrl+S (Windows) to save
` + } +} +``` + +The callback receives the original `KeyboardEvent` as the first argument and a `HotkeyCallbackContext` as the second: + +```ts +@hotkey('Mod+S') +save(event: KeyboardEvent, context: HotkeyCallbackContext) { + console.log(context.hotkey) + console.log(context.parsedHotkey) +} +``` + +### The `HotkeyController` + +For cases where you need to construct the hotkey dynamically or pass a callback that isn't a class method, use `HotkeyController` directly: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HotkeyController } from '@tanstack/lit-hotkeys' + +@customElement('my-editor') +class MyEditor extends LitElement { + private saveHotkey = new HotkeyController( + this, + 'Mod+S', + () => this.save(), + ) + + constructor() { + super() + this.addController(this.saveHotkey) + } + + private save() { + saveDocument() + } + + render() { + return html`
Press Cmd+S (Mac) or Ctrl+S (Windows) to save
` + } +} +``` + +## Default Options + +When you register a hotkey without passing options, or when you omit specific options, the following defaults apply: + +```ts +@hotkey('Mod+S', { + enabled: true, + preventDefault: true, + stopPropagation: true, + eventType: 'keydown', + requireReset: false, + ignoreInputs: undefined, // smart default: false for Mod+S, true for single keys + platform: undefined, // auto-detected + conflictBehavior: 'warn', +}) +save() { /* ... */ } +``` + +If you omit `target`, the Lit adapter resolves it when the controller connects: it listens on `document` in the browser, and skips registration in non-DOM environments. + +### 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. + +#### Smart Input Handling: `ignoreInputs` + +The `ignoreInputs` option strikes a balance between accessibility and usability. By default, hotkeys involving `Ctrl`/`Meta` modifiers (like `Mod+S`) and the `Escape` key fire even when focus is inside input elements (text fields, text areas, etc.) and button-type inputs (`type="button"`, `"submit"`, or `"reset"`). 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 register a hotkey that is already registered elsewhere in your app, the library logs a warning by default (`conflictBehavior: 'warn'`). This helps catch accidental duplicate bindings during development. + +## Hotkey Options + +### `enabled` + +Controls whether the hotkey is active. Defaults to `true`. + +```ts +@hotkey('Mod+S', { enabled: true }) +save() { saveDocument() } +``` + +### `preventDefault` + +Automatically calls `event.preventDefault()` when the hotkey fires. Defaults to `true`. + +```ts +// Browser default is prevented (default behavior) +@hotkey('Mod+S') +save() { saveDocument() } + +// Opt out when you want the browser's default behavior +@hotkey('Mod+P', { preventDefault: false }) +print() { customPrint() } +``` + +### `stopPropagation` + +Calls `event.stopPropagation()` when the hotkey fires. Defaults to `true`. + +```ts +// Event propagation is stopped (default behavior) +@hotkey('Escape') +close() { closeModal() } + +// Opt out when you need the event to bubble +@hotkey('Escape', { stopPropagation: false }) +close() { closeModal() } +``` + +### `eventType` + +Whether to listen on `keydown` (default) or `keyup`. + +```ts +// Fire when the key is released +@hotkey('Shift', { eventType: 'keyup' }) +deactivateMode() { this.shiftMode = false } +``` + +### `requireReset` + +When `true`, the hotkey fires only once per key press. The key must be released and pressed again to fire again. Defaults to `false`. + +```ts +// Only fires once per Escape press, not on key repeat +@hotkey('Escape', { requireReset: true }) +closePanel() { this.panelOpen = false } +``` + +### `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. When unset, a smart default applies: `Ctrl`/`Meta` shortcuts and `Escape` fire in inputs; single keys and `Shift`/`Alt` combos are ignored. + +```ts +// Single key — ignored in inputs by default (smart default) +@hotkey('K') +openSearch() { /* ... */ } + +// Mod+S and Escape — fire in inputs by default (smart default) +@hotkey('Mod+S') +save() { /* ... */ } + +// Override: force a single key to fire in inputs +@hotkey('Enter', { ignoreInputs: false }) +submit() { /* ... */ } +``` + +### `target` + +The DOM element to attach the event listener to. When omitted, the Lit adapter resolves `document` at connect time in the browser. Can be a DOM element, `document`, or `window`. Pass `null` to intentionally skip registration. + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { createRef, ref } from 'lit/directives/ref.js' +import { HotkeyController } from '@tanstack/lit-hotkeys' + +@customElement('my-panel') +class MyPanel extends LitElement { + private panelRef = createRef() + private escapeHotkey?: HotkeyController + + firstUpdated() { + if (!this.panelRef.value) return + + this.escapeHotkey = new HotkeyController( + this, + 'Escape', + () => this.dispatchEvent(new CustomEvent('close')), + { target: this.panelRef.value }, + ) + this.addController(this.escapeHotkey) + } + + render() { + return html` +
+

Press Escape while focused here to close

+
+ ` + } +} +``` + +> [!NOTE] +> When using a scoped 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 + +```ts +@hotkey('Mod+S', { conflictBehavior: 'replace' }) +save() { saveDocument() } +``` + +### `platform` + +Override the auto-detected platform. Useful for testing or for applications that need to force a specific platform behavior. + +```ts +@hotkey('Mod+S', { platform: 'mac' }) +save() { saveDocument() } +``` + +## Automatic Cleanup + +Both the `@hotkey` decorator and `HotkeyController` automatically unregister the hotkey when the element is disconnected from the DOM: + +```ts +@customElement('temporary-panel') +class TemporaryPanel extends LitElement { + // Automatically registered on connect, unregistered on disconnect + @hotkey('Escape') + close() { this.remove() } + + render() { + return html`
Panel content
` + } +} +``` + +## Multiple Hotkeys + +Register as many hotkeys as you need. Each `@hotkey` decorator is independent: + +```ts +@customElement('my-editor') +class MyEditor extends LitElement { + @hotkey('Mod+S') + save() { saveDocument() } + + @hotkey('Mod+Z') + undo() { undoAction() } + + @hotkey('Mod+Shift+Z') + redo() { redoAction() } + + @hotkey('Mod+F') + search() { openSearch() } + + @hotkey('Escape') + dismiss() { closeDialog() } +} +``` + +## Choosing Between Decorator and Controller + +| | `@hotkey` Decorator | `HotkeyController` | +|---|---|---| +| **Best for** | Static, declarative method binding | Dynamic hotkeys, programmatic control | +| **Registration** | Automatic via `connectedCallback` | Automatic via `hostConnected` | +| **Cleanup** | Automatic via `disconnectedCallback` | Automatic via `hostDisconnected` | +| **Dynamic hotkeys** | No (hotkey is fixed at decoration time) | Yes (can construct hotkey at runtime) | +| **Callback binding** | Bound to the host element automatically | Bound to the host element automatically | + +Use the `@hotkey` decorator for the common case of binding a static shortcut to a method. Use `HotkeyController` when you need to construct the hotkey string dynamically or manage registration imperatively. + +## The Hotkey Manager + +Under the hood, both the decorator and controller use the singleton `HotkeyManager`. You can access the manager directly when needed: + +```ts +import { getHotkeyManager } from '@tanstack/lit-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/lit/guides/key-state-tracking.md b/docs/framework/lit/guides/key-state-tracking.md new file mode 100644 index 00000000..82fbc098 --- /dev/null +++ b/docs/framework/lit/guides/key-state-tracking.md @@ -0,0 +1,227 @@ +--- +title: Key State Tracking Guide +id: key-state-tracking +--- + +TanStack Hotkeys provides three Lit **reactive controllers** 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. + + +## `HeldKeysController` + +Tracks all currently held key names. Exposes a reactive **`value`** getter: `Array`. + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HeldKeysController } from '@tanstack/lit-hotkeys' + +@customElement('key-display') +class KeyDisplay extends LitElement { + private heldKeys = new HeldKeysController(this) + + render() { + const keys = this.heldKeys.value + return html` +
+ ${keys.length > 0 ? `Held: ${keys.join(' + ')}` : 'No keys held'} +
+ ` + } +} +``` + +The array contains key names like `'Shift'`, `'Control'`, `'Meta'`, `'A'`, `'ArrowUp'`, etc. Keys appear in the order they were pressed. + +## `HeldKeyCodesController` + +Tracks held key names mapped to physical key codes (`event.code`). Exposes **`value`**: `Record`. + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HeldKeyCodesController } from '@tanstack/lit-hotkeys' + +@customElement('key-code-display') +class KeyCodeDisplay extends LitElement { + private heldKeyCodes = new HeldKeyCodesController(this) + + render() { + const codes = this.heldKeyCodes.value + // Example: { Shift: "ShiftLeft", Control: "ControlRight" } + return html` +
+ ${Object.entries(codes).map( + ([key, code]) => html`
${key}: ${code}
`, + )} +
+ ` + } +} +``` + +Use this when you need to distinguish left vs. right modifiers (or other physical keys). + +## `KeyHoldController` + +Tracks whether **one** specific key is held. Exposes **`value`**: `boolean`. Updates the host only when **that** key’s held state changes (not on every unrelated key press). + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { KeyHoldController } from '@tanstack/lit-hotkeys' + +@customElement('modifier-indicators') +class ModifierIndicators extends LitElement { + private shift = new KeyHoldController(this, 'Shift') + private ctrl = new KeyHoldController(this, 'Control') + private alt = new KeyHoldController(this, 'Alt') + private meta = new KeyHoldController(this, 'Meta') + + render() { + return html` +
+ Shift + Ctrl + Alt + Meta +
+ ` + } +} +``` + +## Common patterns + +### Hold-to-reveal UI + +Show extra actions while Shift is held: + +```ts +import { LitElement, html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { KeyHoldController } from '@tanstack/lit-hotkeys' + +@customElement('file-item') +class FileItem extends LitElement { + @property({ type: String }) fileName = '' + + private shift = new KeyHoldController(this, 'Shift') + + render() { + return html` +
+ ${this.fileName} + ${this.shift.value + ? html`` + : html``} +
+ ` + } + + private _permanentDelete = () => { + /* permanentlyDelete(file) */ + } + private _moveToTrash = () => { + /* moveToTrash(file) */ + } +} +``` + +### Keyboard shortcut hints + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { KeyHoldController } from '@tanstack/lit-hotkeys' + +@customElement('shortcut-hints') +class ShortcutHints extends LitElement { + private mod = new KeyHoldController(this, 'Meta') // use 'Control' on Windows if you prefer + + render() { + if (!this.mod.value) return html`` + return html` +
+
S - Save
+
Z - Undo
+
Shift+Z - Redo
+
K - Command Palette
+
+ ` + } +} +``` + +### Debugging key display + +Combine controllers with [`formatForDisplay`](./formatting-display.md) for readable labels: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { + HeldKeysController, + HeldKeyCodesController, + formatForDisplay, +} from '@tanstack/lit-hotkeys' +import type { RegisterableHotkey } from '@tanstack/lit-hotkeys' + +@customElement('key-debugger') +class KeyDebugger extends LitElement { + private heldKeys = new HeldKeysController(this) + private heldCodes = new HeldKeyCodesController(this) + + render() { + const keys = this.heldKeys.value + const codes = this.heldCodes.value + return html` +
+

Active Keys

+ ${keys.map( + (key) => html` +
+ + ${formatForDisplay(key as RegisterableHotkey, { + useSymbols: true, + })} + + ${codes[key] ?? ''} +
+ `, + )} + ${keys.length === 0 ? html`

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 after the user tabs away and releases keys outside the window. + +## Under the hood + +The three controllers subscribe to the singleton `KeyStateTracker` store from `@tanstack/hotkeys`. The tracker manages its own event listeners on `document` and maintains state in a TanStack Store, which the controllers read reactively. + +```ts +import { getKeyStateTracker } from '@tanstack/lit-hotkeys' + +const tracker = getKeyStateTracker() + +tracker.getHeldKeys() // string[] +tracker.store.state.heldCodes // Record +tracker.isKeyHeld('Shift') // boolean +tracker.isAnyKeyHeld(['Shift', 'Control']) // boolean +tracker.areAllKeysHeld(['Shift', 'Control']) // boolean +``` diff --git a/docs/framework/lit/guides/sequence-recording.md b/docs/framework/lit/guides/sequence-recording.md new file mode 100644 index 00000000..eec15a0a --- /dev/null +++ b/docs/framework/lit/guides/sequence-recording.md @@ -0,0 +1,122 @@ +--- +title: Sequence Recording Guide +id: sequence-recording +--- + +TanStack Hotkeys provides the `HotkeySequenceRecorderController` for building UIs where users record **multi-chord sequences** (Vim-style shortcuts). Each step is captured like a single hotkey chord; users finish with **Enter** by default, or you can use manual commit and optional idle timeout. + +## Basic Usage + +```ts +import { LitElement, html, nothing } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HotkeySequenceRecorderController, formatForDisplay } from '@tanstack/lit-hotkeys' +import type { HotkeySequence } from '@tanstack/lit-hotkeys' + +@customElement('sequence-recorder') +class SequenceRecorder extends LitElement { + private recorder = new HotkeySequenceRecorderController(this, { + onRecord: (sequence: HotkeySequence) => { + console.log('Recorded:', sequence) // e.g., ["G", "G"] + }, + }) + + render() { + const { isRecording, steps, recordedSequence } = this.recorder + return html` +
+ + ${isRecording + ? html`` + : nothing} +
+ ` + } +} +``` + +## Controller API + +`HotkeySequenceRecorderController` exposes the following reactive getters and methods: + +| Member | Type | Description | +|--------|------|-------------| +| `isRecording` | `boolean` | Whether the recorder is currently listening | +| `steps` | `HotkeySequence` | Chords captured in the current session | +| `recordedSequence` | `HotkeySequence \| null` | Last committed sequence | +| `startRecording()` | `() => void` | Start a new recording session | +| `stopRecording()` | `() => void` | Stop without calling `onRecord` | +| `cancelRecording()` | `() => void` | Stop and call `onCancel` | +| `commitRecording()` | `() => void` | Commit current `steps` (no-op if empty) | +| `setOptions(opts)` | `(Partial) => void` | Update options at runtime | + +The controller registers itself with the host in its constructor, subscribes to the underlying `HotkeySequenceRecorder` store on `hostConnected`, and cleans up on `hostDisconnected`. + +## Options + +Pass options as the second argument to the constructor: + +```ts +new HotkeySequenceRecorderController(this, { + onRecord: (sequence) => { /* called when a sequence is committed */ }, + onCancel: () => { /* called when recording is cancelled */ }, + onClear: () => { /* called when cleared via Backspace with no steps */ }, + commitKeys: 'enter', // or 'none' + idleTimeoutMs: 2000, +}) +``` + +### `onRecord` + +Called when a sequence is committed (including `[]` when cleared via Backspace with no steps). Receives the recorded `HotkeySequence` array. + +### `onCancel` + +Called when recording is cancelled (either by pressing Escape or calling `cancelRecording()`). + +### `onClear` + +Called when the sequence is cleared (by pressing Backspace or Delete during recording when no steps remain). + +### `commitKeys` + +Controls how the user finishes recording from the keyboard: + +- `'enter'` (default) — plain Enter (no modifiers) commits when at least one step exists. +- `'none'` — only `commitRecording()` or `idleTimeoutMs` finishes recording; plain Enter can be recorded as a chord. + +### `commitOnEnter` + +When `commitKeys` is `'enter'`, set to `false` to treat Enter as a normal chord. Use `commitRecording()` or idle timeout to finish instead. + +### `idleTimeoutMs` + +Milliseconds of inactivity **after the last completed chord** to auto-commit. The timer does not run while waiting for the **first** chord. + +## Recording Behavior + +| Input | Behavior | +|-------|----------| +| Valid chord | Appended to `steps`; listener stays active | +| Enter (no modifiers), `commitKeys: 'enter'`, `steps.length >= 1` | Commits and calls `onRecord` | +| Escape | Cancels; calls `onCancel` | +| Backspace / Delete (no modifiers) | Removes last step, or if empty runs `onClear` + `onRecord([])` and stops | + +Recorded chords use portable `Mod` format, same as `HotkeyRecorderController`. + +## Under the Hood + +The `HotkeySequenceRecorderController` creates a `HotkeySequenceRecorder` class instance and subscribes to its reactive state via `@tanstack/store`. The class manages its own keyboard event listeners and state, and the controller handles cleanup on disconnect. diff --git a/docs/framework/lit/guides/sequences.md b/docs/framework/lit/guides/sequences.md new file mode 100644 index 00000000..f3a44943 --- /dev/null +++ b/docs/framework/lit/guides/sequences.md @@ -0,0 +1,303 @@ +--- +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. + +In Lit, registration is **declarative** via the `@hotkeySequence` decorator, or **imperative** via `HotkeySequenceController` when the sequence or options are built at runtime. Both use the same singleton `SequenceManager`. + +## Basic Usage + +### The `@hotkeySequence` decorator + +Decorate a method to run when the user completes the key sequence: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { hotkeySequence } from '@tanstack/lit-hotkeys' + +@customElement('vim-view') +class VimView extends LitElement { + @hotkeySequence(['G', 'G']) + scrollTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + render() { + return html`
Press g then g to scroll to top
` + } +} +``` + +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. + +The method receives the `KeyboardEvent` and [`HotkeyCallbackContext`](../../../reference/interfaces/HotkeyCallbackContext.md) like `@hotkey`: + +```ts +import type { HotkeyCallbackContext } from '@tanstack/lit-hotkeys' + +@hotkeySequence(['G', 'G']) +scrollTop(event: KeyboardEvent, context: HotkeyCallbackContext) { + console.log(context.hotkey) +} +``` + +### `HotkeySequenceController` + +Use the controller when the sequence or callback cannot be expressed as a static decorator (e.g. data-driven shortcuts): + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HotkeySequenceController } from '@tanstack/lit-hotkeys' + +@customElement('vim-view') +class VimView extends LitElement { + private scrollTopSeq = new HotkeySequenceController( + this, + ['G', 'G'], + () => this.scrollTop(), + ) + + constructor() { + super() + this.addController(this.scrollTopSeq) + } + + private scrollTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + render() { + return html`
Press g then g to scroll to top
` + } +} +``` + +## Many sequences at once + +Register several sequences on the same element by applying **multiple** `@hotkeySequence` decorators (one per method), or by adding **multiple** `HotkeySequenceController` instances with `addController`. There is no Lit equivalent to React’s `useHotkeySequences` hook; several decorators on one class are the idiomatic pattern. + +```ts +@customElement('vim-navigation') +class VimNavigation extends LitElement { + @hotkeySequence(['G', 'G']) + goTop() { + scrollToTop() + } + + @hotkeySequence(['G', 'Shift+G']) + goBottom() { + scrollToBottom() + } + + @hotkeySequence(['D', 'D'], { timeout: 500 }) + deleteLine() { + deleteCurrentLine() + } + + render() { + return html`
` + } +} +``` + +## Sequence Options + +Pass options as the **second** argument to `@hotkeySequence` (or to `HotkeySequenceController`): + +```ts +@hotkeySequence(['G', 'G'], { + timeout: 1000, // Time allowed between keys (ms) + enabled: true, // Whether the sequence is active at connect time +}) +scrollTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }) +} +``` + +### `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). + +```ts +@hotkeySequence(['D', 'D'], { timeout: 500 }) +deleteLine() { + deleteCurrentLine() +} + +@hotkeySequence(['Shift+Z', 'Shift+Z'], { timeout: 2000 }) +forceQuit() { + quitWithoutSaving() +} +``` + +### `enabled` + +Controls whether the sequence is registered when the element connects. Defaults to `true`. If `enabled` is `false` at connection time, registration is skipped. To turn sequences on or off later, reconnect the element, or create a new `HotkeySequenceController` when your state changes. + +```ts +@hotkeySequence(['G', 'G'], { enabled: true }) +scrollToTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }) +} +``` + +### Default options + +When you omit options, the library uses the same defaults as the core [`SequenceOptions`](../../../reference/interfaces/SequenceOptions.md): `timeout: 1000`, `preventDefault` / `stopPropagation` enabled, smart `ignoreInputs`, and platform auto-detection. If you omit `target`, the Lit adapter resolves it to `document` when the controller connects in the browser. + +## Sequences with Modifiers + +Each step in a sequence can include modifiers: + +```ts +@hotkeySequence(['Mod+K', 'Mod+C']) +commentSelection() { + commentSelection() +} + +@hotkeySequence(['G', 'Shift+G']) +scrollBottom() { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }) +} +``` + +## Chained modifier chords + +You can repeat the same modifier across consecutive steps — for example `Shift+R` then `Shift+T`: + +```ts +@hotkeySequence(['Shift+R', 'Shift+T']) +chordSequence() { + runAfterChords() +} +``` + +### Modifier-only keys between steps + +While a sequence is in progress, **modifier-only** keydown events (Shift, Control, Alt, or Meta pressed alone, with no letter or other key) are ignored. They do not advance the sequence and they do **not** reset progress. That way a user can tap Shift (or hold it) between chords such as `Shift+R` and `Shift+T` without breaking the sequence — similar to Vim-style flows where a modifier may be pressed before the next chord. + +## Common Sequence Patterns + +### Vim-style navigation + +```ts +@customElement('vim-navigation') +class VimNavigation extends LitElement { + @hotkeySequence(['G', 'G']) + goTop() { + scrollToTop() + } + + @hotkeySequence(['G', 'Shift+G']) + goBottom() { + scrollToBottom() + } + + @hotkeySequence(['D', 'D']) + deleteLine() { + deleteCurrentLine() + } + + @hotkeySequence(['D', 'W']) + deleteWord() { + deleteCurrentWord() + } + + @hotkeySequence(['C', 'I', 'W']) + changeInnerWord() { + changeInnerWordImpl() + } + + render() { + return html`
` + } +} +``` + +### Konami code + +```ts +@hotkeySequence( + [ + 'ArrowUp', + 'ArrowUp', + 'ArrowDown', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'ArrowLeft', + 'ArrowRight', + 'B', + 'A', + ], + { timeout: 2000 }, +) +enableEasterEgg() { + showEasterEgg() +} +``` + +### Multi-step commands + +```ts +@hotkeySequence(['H', 'E', 'L', 'P']) +openHelp() { + showHelp() +} +``` + +## 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 +5. Modifier-only keydowns are ignored (they neither advance nor reset the sequence) + +### Overlapping sequences + +Multiple sequences can share the same prefix. The manager tracks progress for each sequence independently: + +```ts +@hotkeySequence(['D', 'D']) +dd() { + deleteLine() +} + +@hotkeySequence(['D', 'W']) +dw() { + deleteWord() +} + +@hotkeySequence(['D', 'I', 'W']) +diw() { + deleteInnerWord() +} +``` + +After pressing `D`, the manager waits for the next key to determine which sequence to complete. + +## The sequence manager + +Under the hood, `@hotkeySequence` and `HotkeySequenceController` use the singleton `SequenceManager`. You can also use the core `createSequenceMatcher` function for standalone sequence matching without the singleton: + +```ts +import { createSequenceMatcher } from '@tanstack/lit-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/lit/quick-start.md b/docs/framework/lit/quick-start.md new file mode 100644 index 00000000..4b2c7316 --- /dev/null +++ b/docs/framework/lit/quick-start.md @@ -0,0 +1,291 @@ +--- +title: Quick Start +id: quick-start +--- + +## Installation + +Don't have TanStack Hotkeys installed yet? See the [Installation](../../installation) page for instructions. + +## Your First Hotkey + +The Lit adapter offers two ways to register hotkeys: **decorators** for declarative method-level binding, and **controllers** for imperative, reactive state management. + +### Using the `@hotkey` Decorator + +The `@hotkey` decorator is the simplest way to bind a keyboard shortcut to a class method: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { hotkey } from '@tanstack/lit-hotkeys' + +@customElement('my-editor') +class MyEditor extends LitElement { + @hotkey('Mod+S') + save() { + saveDocument() + } + + render() { + return html`
Press Cmd+S (Mac) or Ctrl+S (Windows) to save
` + } +} +``` + +### Using `HotkeyController` + +For more control, use the `HotkeyController` directly: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { HotkeyController } from '@tanstack/lit-hotkeys' + +@customElement('my-editor') +class MyEditor extends LitElement { + private saveHotkey = new HotkeyController( + this, + 'Mod+S', + () => this.save(), + ) + + constructor() { + super() + this.addController(this.saveHotkey) + } + + private save() { + saveDocument() + } + + render() { + return html`
Press Cmd+S (Mac) or Ctrl+S (Windows) to save
` + } +} +``` + +The `Mod` modifier automatically resolves to `Meta` (Command) on macOS and `Control` on Windows/Linux, so your shortcuts work across platforms without extra logic. + +## Common Patterns + +### Multiple Hotkeys + +Register as many hotkeys as you need with the `@hotkey` decorator: + +```ts +@customElement('my-editor') +class MyEditor extends LitElement { + @hotkey('Mod+S') + save() { saveDocument() } + + @hotkey('Mod+Z') + undo() { undoAction() } + + @hotkey('Mod+Shift+Z') + redo() { redoAction() } + + @hotkey('Mod+F') + search() { openSearch() } + + @hotkey('Escape') + dismiss() { closeDialog() } +} +``` + +### Scoped Hotkeys + +Attach hotkeys to specific elements instead of the entire document using the `target` option. When the target comes from a ref, create the registration after the element has rendered: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { createRef, ref } from 'lit/directives/ref.js' +import { HotkeyController } from '@tanstack/lit-hotkeys' + +@customElement('my-panel') +class MyPanel extends LitElement { + private panelRef = createRef() + private escapeHotkey?: HotkeyController + + firstUpdated() { + if (!this.panelRef.value) return + + this.escapeHotkey = new HotkeyController( + this, + 'Escape', + () => this.closePanel(), + { target: this.panelRef.value }, + ) + this.addController(this.escapeHotkey) + } + + private closePanel() { + this.dispatchEvent(new CustomEvent('close')) + } + + render() { + return html` +
+

Press Escape while focused here to close

+
+ ` + } +} +``` + +### Conditional Hotkeys + +Enable or disable hotkeys based on application state via the `enabled` option: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { hotkey } from '@tanstack/lit-hotkeys' + +@customElement('my-modal') +class MyModal extends LitElement { + @hotkey('Escape', { enabled: true }) + close() { + this.dispatchEvent(new CustomEvent('close')) + } +} +``` + +### Multi-Key Sequences + +Register Vim-style key sequences with the `@hotkeySequence` decorator or `HotkeySequenceController`: + +```ts +import { LitElement } from 'lit' +import { customElement } from 'lit/decorators.js' +import { hotkeySequence } from '@tanstack/lit-hotkeys' + +@customElement('vim-editor') +class VimEditor extends LitElement { + @hotkeySequence(['G', 'G']) + scrollToTop() { + window.scrollTo({ top: 0 }) + } + + @hotkeySequence(['G', 'Shift+G']) + scrollToBottom() { + window.scrollTo({ top: document.body.scrollHeight }) + } +} +``` + +### Tracking Held Keys + +Display modifier key state for power-user UIs using `KeyHoldController` and `HeldKeysController`: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { KeyHoldController, HeldKeysController } from '@tanstack/lit-hotkeys' + +@customElement('status-bar') +class StatusBar extends LitElement { + private shiftHold = new KeyHoldController(this, 'Shift') + private heldKeys = new HeldKeysController(this) + + render() { + return html` +
+ ${this.shiftHold.value + ? html`Shift mode active` + : null} + ${this.heldKeys.value.length > 0 + ? html`Keys: ${this.heldKeys.value.join('+')}` + : null} +
+ ` + } +} +``` + +### Recording Hotkeys + +Build shortcut customization UIs with `HotkeyRecorderController`: + +```ts +import { LitElement, html, nothing } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { + HotkeyRecorderController, + formatForDisplay, + type Hotkey, +} from '@tanstack/lit-hotkeys' + +@customElement('shortcut-settings') +class ShortcutSettings extends LitElement { + private recorder = new HotkeyRecorderController(this, { + onRecord: (hotkey) => { + this.shortcut = hotkey + }, + onCancel: () => { + console.log('Recording cancelled') + }, + }) + + @state() private shortcut: Hotkey | null = null + + render() { + return html` + + ${this.shortcut + ? html`${formatForDisplay(this.shortcut)}` + : nothing} + ` + } +} +``` + +### Displaying Hotkeys in the UI + +Format hotkeys for platform-aware display: + +```ts +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { hotkey, formatForDisplay } from '@tanstack/lit-hotkeys' + +@customElement('save-button') +class SaveButton extends LitElement { + @hotkey('Mod+S') + save() { saveDocument() } + + render() { + return html` + + ` + } +} +``` + +## Decorators vs Controllers + +The Lit adapter provides two complementary approaches: + +| | Decorators (`@hotkey`, `@hotkeySequence`) | Controllers (`HotkeyController`, etc.) | +|---|---|---| +| **Best for** | Declarative method binding | Reactive state, dynamic hotkeys | +| **Registration** | Automatic on connect/disconnect | Automatic via `hostConnected`/`hostDisconnected` | +| **State access** | No (fire-and-forget callbacks) | Yes (`isRecording`, `value`, etc.) | +| **Dynamic hotkeys** | No (static at decoration time) | Yes (can re-register programmatically) | + +Use **decorators** when you simply want a method to fire on a key combo. Use **controllers** when you need reactive state (held keys, recording) or dynamic hotkey registration. + +## Next Steps + +- [Hotkeys Guide](./guides/hotkeys) - Deep dive into `@hotkey` decorator and `HotkeyController` options +- [Sequences Guide](./guides/sequences) - Multi-key sequence handling +- [Hotkey Recording Guide](./guides/hotkey-recording) - Building shortcut customization UIs +- [Sequence Recording Guide](./guides/sequence-recording) - Capture multi-step shortcuts +- [Key State Tracking Guide](./guides/key-state-tracking) - Real-time key state monitoring +- [Formatting & Display Guide](./guides/formatting-display) - Platform-aware hotkey formatting diff --git a/docs/framework/lit/reference/classes/HeldKeyCodesController.md b/docs/framework/lit/reference/classes/HeldKeyCodesController.md new file mode 100644 index 00000000..1a0581aa --- /dev/null +++ b/docs/framework/lit/reference/classes/HeldKeyCodesController.md @@ -0,0 +1,118 @@ +--- +id: HeldKeyCodesController +title: HeldKeyCodesController +--- + +# Class: HeldKeyCodesController + +Defined in: [controllers/held-key-codes.ts:27](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-key-codes.ts#L27) + +A Lit ReactiveController that tracks all currently held key names to their physical `event.code` values. + +Subscribes to the global KeyStateTracker and triggers host updates +whenever keys are pressed or released. + +## Example + +```ts +class KeyDisplay extends LitElement { + private heldKeyCodes = new HeldKeyCodesController(this) + + render() { + const heldCodes = Object.entries(this.heldKeyCodes.value).map(([key, code]) => `${key}: ${code}`).join(' + ') + return html` +
+ Currently pressed: ${heldCodes || 'None'} +
+ ` + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new HeldKeyCodesController(_host): HeldKeyCodesController; +``` + +Defined in: [controllers/held-key-codes.ts:41](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-key-codes.ts#L41) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +The Lit component that owns this controller. + +#### Returns + +`HeldKeyCodesController` + +## Accessors + +### value + +#### Get Signature + +```ts +get value(): Record; +``` + +Defined in: [controllers/held-key-codes.ts:34](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-key-codes.ts#L34) + +Map of currently held key names to their physical `event.code` values. + +##### Returns + +`Record`\<`string`, `string`\> + +## Methods + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/held-key-codes.ts:46](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-key-codes.ts#L46) + +Subscribes to the tracker store and updates the internal state when changes occur. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/held-key-codes.ts:62](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-key-codes.ts#L62) + +Unsubscribes from the tracker store and stops tracking the held key codes. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` diff --git a/docs/framework/lit/reference/classes/HeldKeysController.md b/docs/framework/lit/reference/classes/HeldKeysController.md new file mode 100644 index 00000000..687c5d92 --- /dev/null +++ b/docs/framework/lit/reference/classes/HeldKeysController.md @@ -0,0 +1,117 @@ +--- +id: HeldKeysController +title: HeldKeysController +--- + +# Class: HeldKeysController + +Defined in: [controllers/held-keys.ts:26](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-keys.ts#L26) + +A Lit ReactiveController that tracks all currently held keyboard keys. + +Subscribes to the global KeyStateTracker and triggers host updates +whenever keys are pressed or released. + +## Example + +```ts +class KeyDisplay extends LitElement { + private heldKeys = new HeldKeysController(this) + + render() { + return html` +
+ Currently pressed: ${this.heldKeys.value.join(' + ') || 'None'} +
+ ` + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new HeldKeysController(_host): HeldKeysController; +``` + +Defined in: [controllers/held-keys.ts:40](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-keys.ts#L40) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +The Lit component that owns this controller. + +#### Returns + +`HeldKeysController` + +## Accessors + +### value + +#### Get Signature + +```ts +get value(): string[]; +``` + +Defined in: [controllers/held-keys.ts:33](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-keys.ts#L33) + +Array of currently held key names. + +##### Returns + +`string`[] + +## Methods + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/held-keys.ts:45](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-keys.ts#L45) + +Subscribes to the tracker store and updates the internal state when changes occur. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/held-keys.ts:59](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/held-keys.ts#L59) + +Unsubscribes from the tracker store and stops tracking the held keys. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` diff --git a/docs/framework/lit/reference/classes/HotkeyController.md b/docs/framework/lit/reference/classes/HotkeyController.md new file mode 100644 index 00000000..bb323a90 --- /dev/null +++ b/docs/framework/lit/reference/classes/HotkeyController.md @@ -0,0 +1,117 @@ +--- +id: HotkeyController +title: HotkeyController +--- + +# Class: HotkeyController + +Defined in: [controllers/hotkey.ts:34](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey.ts#L34) + +A Lit ReactiveController that registers a keyboard hotkey when the host +element is connected and unregisters it when the host is disconnected. + +## Example + +```ts +class MyElement extends LitElement { + private hotkey = new HotkeyController(this, 'Mod+S', () => this.save()) + + constructor() { + super() + this.addController(this.hotkey) + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new HotkeyController( + _host, + _hotkey, + _callback, + _options): HotkeyController; +``` + +Defined in: [controllers/hotkey.ts:44](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey.ts#L44) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +The Lit component that owns this controller (use `this` and pass it to `addController()`). + +##### \_hotkey + +`RegisterableHotkey` + +The key or key combo to listen for (e.g. `'Mod+S'` or a raw hotkey object). + +##### \_callback + +`HotkeyCallback` + +Function to run when the hotkey is pressed; called with the host as `this`. + +##### \_options + +`HotkeyOptions` = `HOTKEY_DEFAULT_OPTIONS` + +Optional registration options (target, platform, enabled, etc.). + +#### Returns + +`HotkeyController` + +## Methods + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/hotkey.ts:55](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey.ts#L55) + +Registers the hotkey with the global manager when the host is connected to the DOM. +Skips registration if no target is available (e.g. no document or options.target is null). + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/hotkey.ts:90](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey.ts#L90) + +Unregisters the hotkey when the host is disconnected from the DOM. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` diff --git a/docs/framework/lit/reference/classes/HotkeyRecorderController.md b/docs/framework/lit/reference/classes/HotkeyRecorderController.md new file mode 100644 index 00000000..20f077ff --- /dev/null +++ b/docs/framework/lit/reference/classes/HotkeyRecorderController.md @@ -0,0 +1,225 @@ +--- +id: HotkeyRecorderController +title: HotkeyRecorderController +--- + +# Class: HotkeyRecorderController + +Defined in: [controllers/hotkey-recorder.ts:40](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L40) + +A Lit ReactiveController that records keyboard shortcuts. + +Wraps 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. + +## Example + +```ts +class ShortcutSettings extends LitElement { + private recorder = new HotkeyRecorderController(this, { + onRecord: (hotkey) => { + this.shortcut = hotkey + this.requestUpdate() + }, + onCancel: () => { + console.log('Recording cancelled') + }, + }) + + private shortcut: Hotkey | null = null + + render() { + return html` + + ${this.recorder.recordedHotkey + ? html`
Recording: ${this.recorder.recordedHotkey}
` + : nothing} + ` + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new HotkeyRecorderController(_host, _options): HotkeyRecorderController; +``` + +Defined in: [controllers/hotkey-recorder.ts:64](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L64) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +The Lit component that owns this controller. + +##### \_options + +`HotkeyRecorderOptions` + +Configuration options for the recorder. + +#### Returns + +`HotkeyRecorderController` + +## Accessors + +### isRecording + +#### Get Signature + +```ts +get isRecording(): boolean; +``` + +Defined in: [controllers/hotkey-recorder.ts:51](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L51) + +Whether recording is currently active. + +##### Returns + +`boolean` + +*** + +### recordedHotkey + +#### Get Signature + +```ts +get recordedHotkey(): Hotkey | null; +``` + +Defined in: [controllers/hotkey-recorder.ts:56](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L56) + +The currently recorded hotkey (for live preview). + +##### Returns + +`Hotkey` \| `null` + +## Methods + +### cancelRecording() + +```ts +cancelRecording(): void; +``` + +Defined in: [controllers/hotkey-recorder.ts:117](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L117) + +Cancel recording without saving. + +#### Returns + +`void` + +*** + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/hotkey-recorder.ts:73](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L73) + +Subscribes to the recorder store and updates the internal state when changes occur. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/hotkey-recorder.ts:92](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L92) + +Unsubscribes from the recorder store and destroys the recorder instance to prevent memory leaks. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` + +*** + +### setOptions() + +```ts +setOptions(options): void; +``` + +Defined in: [controllers/hotkey-recorder.ts:101](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L101) + +Updates the recorder options (e.g. callbacks). + +#### Parameters + +##### options + +`Partial`\<`HotkeyRecorderOptions`\> + +#### Returns + +`void` + +*** + +### startRecording() + +```ts +startRecording(): void; +``` + +Defined in: [controllers/hotkey-recorder.ts:107](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L107) + +Start recording a new hotkey. + +#### Returns + +`void` + +*** + +### stopRecording() + +```ts +stopRecording(): void; +``` + +Defined in: [controllers/hotkey-recorder.ts:112](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts#L112) + +Stop recording (same as cancel but without calling onCancel). + +#### Returns + +`void` diff --git a/docs/framework/lit/reference/classes/HotkeySequenceController.md b/docs/framework/lit/reference/classes/HotkeySequenceController.md new file mode 100644 index 00000000..96f10ab0 --- /dev/null +++ b/docs/framework/lit/reference/classes/HotkeySequenceController.md @@ -0,0 +1,117 @@ +--- +id: HotkeySequenceController +title: HotkeySequenceController +--- + +# Class: HotkeySequenceController + +Defined in: [controllers/hotkey-sequence.ts:28](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts#L28) + +A Lit ReactiveController that registers a keyboard sequence (e.g. Vim-style) +when the host element is connected and unregisters it when the host is disconnected. + +## Example + +```ts +class MyElement extends LitElement { + private seq = new HotkeySequenceController(this, ['G', 'G'], () => this.goToTop()) + + constructor() { + super() + this.addController(this.seq) + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new HotkeySequenceController( + _host, + _sequence, + _callback, + _options): HotkeySequenceController; +``` + +Defined in: [controllers/hotkey-sequence.ts:38](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts#L38) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +The Lit component that owns this controller (use `this` and pass it to `addController()`). + +##### \_sequence + +`HotkeySequence` + +The key sequence to listen for (e.g. `['G', 'G']`). + +##### \_callback + +`HotkeyCallback` + +Function to run when the sequence is completed; called with the host as `this`. + +##### \_options + +`SequenceOptions` = `HOTKEY_SEQUENCE_DEFAULT_OPTIONS` + +Optional sequence options (target, timeout, enabled, etc.). + +#### Returns + +`HotkeySequenceController` + +## Methods + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/hotkey-sequence.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts#L49) + +Registers the sequence with the global sequence manager when the host is connected to the DOM. +Skips registration if disabled, sequence is empty, or no target is available. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/hotkey-sequence.ts:76](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts#L76) + +Unregisters the sequence when the host is disconnected from the DOM. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` diff --git a/docs/framework/lit/reference/classes/HotkeySequenceRecorderController.md b/docs/framework/lit/reference/classes/HotkeySequenceRecorderController.md new file mode 100644 index 00000000..8a228404 --- /dev/null +++ b/docs/framework/lit/reference/classes/HotkeySequenceRecorderController.md @@ -0,0 +1,261 @@ +--- +id: HotkeySequenceRecorderController +title: HotkeySequenceRecorderController +--- + +# Class: HotkeySequenceRecorderController + +Defined in: [controllers/hotkey-sequence-recorder.ts:45](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L45) + +A Lit ReactiveController that records multi-chord sequences (Vim-style shortcuts). + +Wraps the framework-agnostic `HotkeySequenceRecorder` class, managing store +subscriptions and host update lifecycle automatically. + +## Example + +```ts +class ShortcutSettings extends LitElement { + private recorder = new HotkeySequenceRecorderController(this, { + onRecord: (sequence) => { + this.sequence = sequence + this.requestUpdate() + }, + onCancel: () => { + console.log('Recording cancelled') + }, + }) + + private sequence: HotkeySequence | null = null + + render() { + return html` + + ${this.recorder.steps.length + ? html`
Steps: ${this.recorder.steps.join(' → ')}
` + : nothing} + ${this.recorder.recordedSequence + ? html`
Recorded: ${this.recorder.recordedSequence.join(' → ')}
` + : nothing} + ` + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new HotkeySequenceRecorderController(_host, _options): HotkeySequenceRecorderController; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:76](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L76) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +The Lit component that owns this controller. + +##### \_options + +`HotkeySequenceRecorderOptions` + +Configuration options for the sequence recorder. + +#### Returns + +`HotkeySequenceRecorderController` + +## Accessors + +### isRecording + +#### Get Signature + +```ts +get isRecording(): boolean; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:58](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L58) + +Whether recording is currently active. + +##### Returns + +`boolean` + +*** + +### recordedSequence + +#### Get Signature + +```ts +get recordedSequence(): HotkeySequence | null; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:68](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L68) + +Last committed sequence, or null if none. + +##### Returns + +`HotkeySequence` \| `null` + +*** + +### steps + +#### Get Signature + +```ts +get steps(): HotkeySequence; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:63](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L63) + +Chords captured in the current session. + +##### Returns + +`HotkeySequence` + +## Methods + +### cancelRecording() + +```ts +cancelRecording(): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:133](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L133) + +Cancel recording without saving. + +#### Returns + +`void` + +*** + +### commitRecording() + +```ts +commitRecording(): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:138](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L138) + +Commit current steps as a sequence (no-op if empty). + +#### Returns + +`void` + +*** + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:85](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L85) + +Subscribes to the recorder store and updates internal state when changes occur. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:107](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L107) + +Unsubscribes from the recorder store and destroys the recorder instance to prevent memory leaks. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` + +*** + +### setOptions() + +```ts +setOptions(options): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:117](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L117) + +Updates the recorder options. + +#### Parameters + +##### options + +`Partial`\<`HotkeySequenceRecorderOptions`\> + +#### Returns + +`void` + +*** + +### startRecording() + +```ts +startRecording(): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:123](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L123) + +Start recording a new sequence. + +#### Returns + +`void` + +*** + +### stopRecording() + +```ts +stopRecording(): void; +``` + +Defined in: [controllers/hotkey-sequence-recorder.ts:128](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts#L128) + +Stop recording (same as cancel but without calling onCancel). + +#### Returns + +`void` diff --git a/docs/framework/lit/reference/classes/KeyHoldController.md b/docs/framework/lit/reference/classes/KeyHoldController.md new file mode 100644 index 00000000..c4172435 --- /dev/null +++ b/docs/framework/lit/reference/classes/KeyHoldController.md @@ -0,0 +1,140 @@ +--- +id: KeyHoldController +title: KeyHoldController +--- + +# Class: KeyHoldController + +Defined in: [controllers/key-hold.ts:43](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/key-hold.ts#L43) + +A Lit ReactiveController that tracks whether a specific key is currently held. + +Subscribes to the global KeyStateTracker and triggers host updates +when the held state of the specified key changes. + +## Examples + +```ts +class MyElement extends LitElement { + private shiftHold = new KeyHoldController(this, 'Shift') + + render() { + return html` +
+ ${this.shiftHold.value ? 'Shift is pressed!' : 'Press Shift'} +
+ ` + } +} +``` + +```ts +class ModifierIndicators extends LitElement { + private ctrl = new KeyHoldController(this, 'Control') + private shift = new KeyHoldController(this, 'Shift') + private alt = new KeyHoldController(this, 'Alt') + + render() { + return html` + Ctrl + Shift + Alt + ` + } +} +``` + +## Implements + +- `ReactiveController` + +## Constructors + +### Constructor + +```ts +new KeyHoldController(_host, _key): KeyHoldController; +``` + +Defined in: [controllers/key-hold.ts:58](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/key-hold.ts#L58) + +#### Parameters + +##### \_host + +`ReactiveControllerHost` + +##### \_key + +`HeldKey` + +#### Returns + +`KeyHoldController` + +## Accessors + +### value + +#### Get Signature + +```ts +get value(): boolean; +``` + +Defined in: [controllers/key-hold.ts:50](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/key-hold.ts#L50) + +Whether the tracked key is currently held down. + +##### Returns + +`boolean` + +## Methods + +### hostConnected() + +```ts +hostConnected(): void; +``` + +Defined in: [controllers/key-hold.ts:65](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/key-hold.ts#L65) + +Called when the host is connected to the component tree. For custom +element hosts, this corresponds to the `connectedCallback()` lifecycle, +which is only called when the component is connected to the document. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostConnected +``` + +*** + +### hostDisconnected() + +```ts +hostDisconnected(): void; +``` + +Defined in: [controllers/key-hold.ts:83](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/controllers/key-hold.ts#L83) + +Called when the host is disconnected from the component tree. For custom +element hosts, this corresponds to the `disconnectedCallback()` lifecycle, +which is called the host or an ancestor component is disconnected from the +document. + +#### Returns + +`void` + +#### Implementation of + +```ts +ReactiveController.hostDisconnected +``` diff --git a/docs/framework/lit/reference/functions/hotkey.md b/docs/framework/lit/reference/functions/hotkey.md new file mode 100644 index 00000000..a9e3c908 --- /dev/null +++ b/docs/framework/lit/reference/functions/hotkey.md @@ -0,0 +1,76 @@ +--- +id: hotkey +title: hotkey +--- + +# Function: hotkey() + +```ts +function hotkey(hotkey, options): (proto, propertyKey, descriptor?) => void; +``` + +Defined in: [decorators/hotkey.ts:29](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/decorators/hotkey.ts#L29) + +Decorator that registers a keyboard hotkey on the element when it connects +and unregisters when it disconnects. Uses [HotkeyController](../classes/HotkeyController.md) under the hood. + +## Parameters + +### hotkey + +`RegisterableHotkey` + +The key or key combo to listen for (e.g. `'Mod+S'` or a raw hotkey object). + +### options + +`HotkeyOptions` = `HOTKEY_DEFAULT_OPTIONS` + +Optional registration options (target, platform, enabled, etc.). + +## Returns + +A method decorator for use on LitElement methods. + +```ts +( + proto, + propertyKey, + descriptor?): void; +``` + +### Type Parameters + +#### T + +`T` *extends* `HotkeyCallback` + +### Parameters + +#### proto + +`LitElement` + +#### propertyKey + +`string` + +#### descriptor? + +`TypedPropertyDescriptor`\<`T`\> + +### Returns + +`void` + +## Example + +```ts +class MyElement extends LitElement { + @hotkey('Mod+S') + save() { this.doSave() } + + @hotkey('Escape') + close() { this.dismiss() } +} +``` diff --git a/docs/framework/lit/reference/functions/hotkeySequence.md b/docs/framework/lit/reference/functions/hotkeySequence.md new file mode 100644 index 00000000..175fae9e --- /dev/null +++ b/docs/framework/lit/reference/functions/hotkeySequence.md @@ -0,0 +1,77 @@ +--- +id: hotkeySequence +title: hotkeySequence +--- + +# Function: hotkeySequence() + +```ts +function hotkeySequence(sequence, options): (proto, methodName, descriptor) => void; +``` + +Defined in: [decorators/hotkey-sequence.ts:30](https://github.com/TanStack/hotkeys/blob/main/packages/lit-hotkeys/src/decorators/hotkey-sequence.ts#L30) + +Decorator that registers a keyboard sequence (e.g. Vim-style) on the element +when it connects and unregisters when it disconnects. Uses +[HotkeySequenceController](../classes/HotkeySequenceController.md) under the hood. + +## Parameters + +### sequence + +`HotkeySequence` + +The key sequence to listen for (e.g. `['G', 'G']` for "g g"). + +### options + +`SequenceOptions` = `HOTKEY_SEQUENCE_DEFAULT_OPTIONS` + +Optional sequence options (target, timeout, enabled, etc.). + +## Returns + +A method decorator for use on LitElement methods. + +```ts +( + proto, + methodName, + descriptor): void; +``` + +### Type Parameters + +#### T + +`T` *extends* `HotkeyCallback` + +### Parameters + +#### proto + +`LitElement` + +#### methodName + +`string` + +#### descriptor + +`TypedPropertyDescriptor`\<`T`\> + +### Returns + +`void` + +## Example + +```ts +class MyElement extends LitElement { + @hotkeySequence(['G', 'G']) + goToTop() { window.scrollTo(0, 0) } + + @hotkeySequence(['D', 'D'], { timeout: 500 }) + deleteLine() { this.deleteCurrentLine() } +} +``` diff --git a/docs/framework/lit/reference/index.md b/docs/framework/lit/reference/index.md new file mode 100644 index 00000000..f144b548 --- /dev/null +++ b/docs/framework/lit/reference/index.md @@ -0,0 +1,21 @@ +--- +id: "@tanstack/lit-hotkeys" +title: "@tanstack/lit-hotkeys" +--- + +# @tanstack/lit-hotkeys + +## Classes + +- [HeldKeyCodesController](classes/HeldKeyCodesController.md) +- [HeldKeysController](classes/HeldKeysController.md) +- [HotkeyController](classes/HotkeyController.md) +- [HotkeyRecorderController](classes/HotkeyRecorderController.md) +- [HotkeySequenceController](classes/HotkeySequenceController.md) +- [HotkeySequenceRecorderController](classes/HotkeySequenceRecorderController.md) +- [KeyHoldController](classes/KeyHoldController.md) + +## Functions + +- [hotkey](functions/hotkey.md) +- [hotkeySequence](functions/hotkeySequence.md) diff --git a/docs/installation.md b/docs/installation.md index 8dfb7f6c..f1bf8581 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 angular: @tanstack/angular-hotkeys +lit: @tanstack/lit-hotkeys preact: @tanstack/preact-hotkeys react: @tanstack/react-hotkeys solid: @tanstack/solid-hotkeys @@ -64,6 +65,16 @@ If you want the Vue devtools panel component, also install: + + +# Lit + +Start with the [Quick Start](./framework/lit/quick-start) guide and the Lit-specific [guides](./framework/lit/guides/hotkeys). + +Lit currently ships the hotkeys adapter only, so no dedicated Lit devtools package is required. + + + preact: @tanstack/preact-devtools @@ -106,5 +117,4 @@ See the [devtools](./devtools) documentation for setup details. See the [devtools](./devtools) documentation for setup details. - - + \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md index 8b5553cd..061d8b89 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -46,9 +46,9 @@ Surprisingly, in our experience, even AI often struggles to get hotkey managemen - Platform-aware formatting (e.g., `⌘⇧S` on Mac vs `Ctrl+Shift+S` on Windows) for cheatsheet UIs - **Framework Adapters** - - React and Preact hooks, Solid primitives, Angular inject APIs, and Vue composables + - React and Preact hooks, Solid primitives, Angular inject APIs, Vue composables, and Lit controllers/decorators - **Awesome Devtools!** - See all currently registered hotkeys, held keys, and more in real-time. -For a complete walkthrough, see the [React Quick Start](framework/react/quick-start), [Angular Quick Start](framework/angular/quick-start), or [Vue Quick Start](framework/vue/quick-start). +For a complete walkthrough, see the [React Quick Start](framework/react/quick-start), [Angular Quick Start](framework/angular/quick-start), [Vue Quick Start](framework/vue/quick-start) or [Lit Quick Start](framework/lit/quick-start). diff --git a/docs/reference/classes/HotkeyManager.md b/docs/reference/classes/HotkeyManager.md index b7b4ac51..41a3f152 100644 --- a/docs/reference/classes/HotkeyManager.md +++ b/docs/reference/classes/HotkeyManager.md @@ -5,7 +5,7 @@ title: HotkeyManager # Class: HotkeyManager -Defined in: [hotkey-manager.ts:145](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L145) +Defined in: [hotkey-manager.ts:144](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L144) Singleton manager for hotkey registrations. @@ -34,7 +34,7 @@ unregister() readonly registrations: Store>; ``` -Defined in: [hotkey-manager.ts:167](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L167) +Defined in: [hotkey-manager.ts:166](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L166) The TanStack Store containing all hotkey registrations. Use this to subscribe to registration changes or access current registrations. @@ -63,7 +63,7 @@ for (const [id, reg] of manager.registrations.state) { destroy(): void; ``` -Defined in: [hotkey-manager.ts:704](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L704) +Defined in: [hotkey-manager.ts:699](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L699) Destroys the manager and removes all listeners. @@ -79,7 +79,7 @@ Destroys the manager and removes all listeners. getRegistrationCount(): number; ``` -Defined in: [hotkey-manager.ts:675](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L675) +Defined in: [hotkey-manager.ts:670](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L670) Gets the number of registered hotkeys. @@ -95,7 +95,7 @@ Gets the number of registered hotkeys. isRegistered(hotkey, target?): boolean; ``` -Defined in: [hotkey-manager.ts:686](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L686) +Defined in: [hotkey-manager.ts:681](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L681) Checks if a specific hotkey is registered. @@ -130,7 +130,7 @@ register( options): HotkeyRegistrationHandle; ``` -Defined in: [hotkey-manager.ts:230](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L230) +Defined in: [hotkey-manager.ts:229](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L229) Registers a hotkey handler and returns a handle for updating the registration. @@ -186,7 +186,7 @@ handle.unregister() triggerRegistration(id): boolean; ``` -Defined in: [hotkey-manager.ts:639](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L639) +Defined in: [hotkey-manager.ts:634](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L634) Triggers a registration's callback programmatically from devtools. Creates a synthetic KeyboardEvent and invokes the callback. @@ -213,7 +213,7 @@ True if the registration was found and triggered static getInstance(): HotkeyManager; ``` -Defined in: [hotkey-manager.ts:188](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L188) +Defined in: [hotkey-manager.ts:187](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L187) Gets the singleton instance of HotkeyManager. @@ -229,7 +229,7 @@ Gets the singleton instance of HotkeyManager. static resetInstance(): void; ``` -Defined in: [hotkey-manager.ts:198](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L198) +Defined in: [hotkey-manager.ts:197](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L197) Resets the singleton instance. Useful for testing. diff --git a/docs/reference/classes/SequenceManager.md b/docs/reference/classes/SequenceManager.md index 4cc47ec5..84b1e4c0 100644 --- a/docs/reference/classes/SequenceManager.md +++ b/docs/reference/classes/SequenceManager.md @@ -5,7 +5,7 @@ title: SequenceManager # Class: SequenceManager -Defined in: [sequence-manager.ts:169](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L169) +Defined in: [sequence-manager.ts:168](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L168) ## Properties @@ -15,7 +15,7 @@ Defined in: [sequence-manager.ts:169](https://github.com/TanStack/hotkeys/blob/m readonly registrations: Store>; ``` -Defined in: [sequence-manager.ts:176](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L176) +Defined in: [sequence-manager.ts:175](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L175) The TanStack Store containing sequence registration views for devtools. Subscribe to this to observe registration changes. @@ -28,7 +28,7 @@ Subscribe to this to observe registration changes. destroy(): void; ``` -Defined in: [sequence-manager.ts:636](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L636) +Defined in: [sequence-manager.ts:631](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L631) Destroys the manager and removes all listeners. @@ -44,7 +44,7 @@ Destroys the manager and removes all listeners. getRegistrationCount(): number; ``` -Defined in: [sequence-manager.ts:629](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L629) +Defined in: [sequence-manager.ts:624](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L624) Gets the number of registered sequences. @@ -63,7 +63,7 @@ register( options): SequenceRegistrationHandle; ``` -Defined in: [sequence-manager.ts:228](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L228) +Defined in: [sequence-manager.ts:227](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L227) Registers a hotkey sequence handler. @@ -101,7 +101,7 @@ A handle to update or unregister the sequence resetAll(): void; ``` -Defined in: [sequence-manager.ts:572](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L572) +Defined in: [sequence-manager.ts:567](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L567) Resets all sequence progress. @@ -117,7 +117,7 @@ Resets all sequence progress. triggerSequence(id): boolean; ``` -Defined in: [sequence-manager.ts:587](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L587) +Defined in: [sequence-manager.ts:582](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L582) Triggers a sequence's callback programmatically from devtools. Creates a synthetic KeyboardEvent from the last key in the sequence. @@ -144,7 +144,7 @@ True if the registration was found and triggered static getInstance(): SequenceManager; ``` -Defined in: [sequence-manager.ts:197](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L197) +Defined in: [sequence-manager.ts:196](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L196) Gets the singleton instance of SequenceManager. @@ -160,7 +160,7 @@ Gets the singleton instance of SequenceManager. static resetInstance(): void; ``` -Defined in: [sequence-manager.ts:207](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L207) +Defined in: [sequence-manager.ts:206](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L206) Resets the singleton instance. Useful for testing. diff --git a/docs/reference/functions/createSequenceMatcher.md b/docs/reference/functions/createSequenceMatcher.md index 0b6d789d..114b0dab 100644 --- a/docs/reference/functions/createSequenceMatcher.md +++ b/docs/reference/functions/createSequenceMatcher.md @@ -9,7 +9,7 @@ title: createSequenceMatcher function createSequenceMatcher(sequence, options): object; ``` -Defined in: [sequence-manager.ts:675](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L675) +Defined in: [sequence-manager.ts:670](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L670) Creates a simple sequence matcher for one-off use. diff --git a/docs/reference/functions/getHotkeyManager.md b/docs/reference/functions/getHotkeyManager.md index 0e52a468..c0327dcc 100644 --- a/docs/reference/functions/getHotkeyManager.md +++ b/docs/reference/functions/getHotkeyManager.md @@ -9,7 +9,7 @@ title: getHotkeyManager function getHotkeyManager(): HotkeyManager; ``` -Defined in: [hotkey-manager.ts:720](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L720) +Defined in: [hotkey-manager.ts:715](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L715) Gets the singleton HotkeyManager instance. Convenience function for accessing the manager. diff --git a/docs/reference/functions/getSequenceManager.md b/docs/reference/functions/getSequenceManager.md index 54c89d6f..0a8a182d 100644 --- a/docs/reference/functions/getSequenceManager.md +++ b/docs/reference/functions/getSequenceManager.md @@ -9,7 +9,7 @@ title: getSequenceManager function getSequenceManager(): SequenceManager; ``` -Defined in: [sequence-manager.ts:649](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L649) +Defined in: [sequence-manager.ts:644](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L644) Gets the singleton SequenceManager instance. Convenience function for accessing the manager. diff --git a/docs/reference/interfaces/HotkeyOptions.md b/docs/reference/interfaces/HotkeyOptions.md index 1a63a4e6..87797a00 100644 --- a/docs/reference/interfaces/HotkeyOptions.md +++ b/docs/reference/interfaces/HotkeyOptions.md @@ -5,7 +5,7 @@ title: HotkeyOptions # Interface: HotkeyOptions -Defined in: [hotkey-manager.ts:28](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L28) +Defined in: [hotkey-manager.ts:27](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L27) Options for registering a hotkey. @@ -17,7 +17,7 @@ Options for registering a hotkey. optional conflictBehavior: ConflictBehavior; ``` -Defined in: [hotkey-manager.ts:30](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L30) +Defined in: [hotkey-manager.ts:29](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L29) Behavior when this hotkey conflicts with an existing registration on the same target. Defaults to 'warn' @@ -29,7 +29,7 @@ Behavior when this hotkey conflicts with an existing registration on the same ta optional enabled: boolean; ``` -Defined in: [hotkey-manager.ts:36](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L36) +Defined in: [hotkey-manager.ts:35](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L35) Soft-disable: when `false`, the callback does not run but the registration stays in `HotkeyManager` (and in devtools). Toggling this should update the @@ -43,7 +43,7 @@ existing handle via `setOptions` rather than unregistering. Defaults to `true`. optional eventType: "keydown" | "keyup"; ``` -Defined in: [hotkey-manager.ts:38](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L38) +Defined in: [hotkey-manager.ts:37](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L37) The event type to listen for. Defaults to 'keydown' @@ -55,7 +55,7 @@ The event type to listen for. Defaults to 'keydown' optional ignoreInputs: boolean; ``` -Defined in: [hotkey-manager.ts:40](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L40) +Defined in: [hotkey-manager.ts:39](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L39) Whether to ignore hotkeys when keyboard events originate from input-like elements (text inputs, textarea, select, contenteditable — button-type inputs like type=button/submit/reset are not ignored). Defaults based on hotkey: true for single keys and Shift/Alt combos; false for Ctrl/Meta shortcuts and Escape @@ -67,7 +67,7 @@ Whether to ignore hotkeys when keyboard events originate from input-like element optional platform: "mac" | "windows" | "linux"; ``` -Defined in: [hotkey-manager.ts:42](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L42) +Defined in: [hotkey-manager.ts:41](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L41) The target platform for resolving 'Mod' @@ -79,7 +79,7 @@ The target platform for resolving 'Mod' optional preventDefault: boolean; ``` -Defined in: [hotkey-manager.ts:44](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L44) +Defined in: [hotkey-manager.ts:43](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L43) Prevent the default browser action when the hotkey matches. Defaults to true @@ -91,7 +91,7 @@ Prevent the default browser action when the hotkey matches. Defaults to true optional requireReset: boolean; ``` -Defined in: [hotkey-manager.ts:46](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L46) +Defined in: [hotkey-manager.ts:45](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L45) If true, only trigger once until all keys are released. Default: false @@ -103,7 +103,7 @@ If true, only trigger once until all keys are released. Default: false optional stopPropagation: boolean; ``` -Defined in: [hotkey-manager.ts:48](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L48) +Defined in: [hotkey-manager.ts:47](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L47) Stop event propagation when the hotkey matches. Defaults to true @@ -115,6 +115,6 @@ Stop event propagation when the hotkey matches. Defaults to true optional target: HTMLElement | Document | Window | null; ``` -Defined in: [hotkey-manager.ts:50](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L50) +Defined in: [hotkey-manager.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L49) The DOM element to attach the event listener to. Defaults to document. diff --git a/docs/reference/interfaces/HotkeyRegistration.md b/docs/reference/interfaces/HotkeyRegistration.md index 5e4bb6c1..d8519e96 100644 --- a/docs/reference/interfaces/HotkeyRegistration.md +++ b/docs/reference/interfaces/HotkeyRegistration.md @@ -5,7 +5,7 @@ title: HotkeyRegistration # Interface: HotkeyRegistration -Defined in: [hotkey-manager.ts:56](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L56) +Defined in: [hotkey-manager.ts:55](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L55) A registered hotkey handler in the HotkeyManager. @@ -17,7 +17,7 @@ A registered hotkey handler in the HotkeyManager. callback: HotkeyCallback; ``` -Defined in: [hotkey-manager.ts:58](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L58) +Defined in: [hotkey-manager.ts:57](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L57) The callback to invoke @@ -29,7 +29,7 @@ The callback to invoke hasFired: boolean; ``` -Defined in: [hotkey-manager.ts:60](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L60) +Defined in: [hotkey-manager.ts:59](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L59) Whether this registration has fired and needs reset (for requireReset) @@ -41,7 +41,7 @@ Whether this registration has fired and needs reset (for requireReset) hotkey: Hotkey; ``` -Defined in: [hotkey-manager.ts:62](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L62) +Defined in: [hotkey-manager.ts:61](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L61) The original hotkey string @@ -53,7 +53,7 @@ The original hotkey string id: string; ``` -Defined in: [hotkey-manager.ts:64](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L64) +Defined in: [hotkey-manager.ts:63](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L63) Unique identifier for this registration @@ -65,7 +65,7 @@ Unique identifier for this registration options: HotkeyOptions; ``` -Defined in: [hotkey-manager.ts:66](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L66) +Defined in: [hotkey-manager.ts:65](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L65) Options for this registration @@ -77,7 +77,7 @@ Options for this registration parsedHotkey: ParsedHotkey; ``` -Defined in: [hotkey-manager.ts:68](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L68) +Defined in: [hotkey-manager.ts:67](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L67) The parsed hotkey @@ -89,7 +89,7 @@ The parsed hotkey target: HTMLElement | Document | Window; ``` -Defined in: [hotkey-manager.ts:70](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L70) +Defined in: [hotkey-manager.ts:69](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L69) The resolved target element for this registration @@ -101,6 +101,6 @@ The resolved target element for this registration triggerCount: number; ``` -Defined in: [hotkey-manager.ts:72](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L72) +Defined in: [hotkey-manager.ts:71](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L71) How many times this registration's callback has been triggered diff --git a/docs/reference/interfaces/HotkeyRegistrationHandle.md b/docs/reference/interfaces/HotkeyRegistrationHandle.md index 2631e33b..75ae9138 100644 --- a/docs/reference/interfaces/HotkeyRegistrationHandle.md +++ b/docs/reference/interfaces/HotkeyRegistrationHandle.md @@ -5,7 +5,7 @@ title: HotkeyRegistrationHandle # Interface: HotkeyRegistrationHandle -Defined in: [hotkey-manager.ts:98](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L98) +Defined in: [hotkey-manager.ts:97](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L97) A handle returned from HotkeyManager.register() that allows updating the callback and options without re-registering the hotkey. @@ -38,7 +38,7 @@ handle.unregister() callback: HotkeyCallback; ``` -Defined in: [hotkey-manager.ts:103](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L103) +Defined in: [hotkey-manager.ts:102](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L102) The callback function. Can be set directly to update without re-registering. This avoids stale closures when the callback references React state. @@ -51,7 +51,7 @@ This avoids stale closures when the callback references React state. readonly id: string; ``` -Defined in: [hotkey-manager.ts:105](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L105) +Defined in: [hotkey-manager.ts:104](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L104) Unique identifier for this registration @@ -63,7 +63,7 @@ Unique identifier for this registration readonly isActive: boolean; ``` -Defined in: [hotkey-manager.ts:107](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L107) +Defined in: [hotkey-manager.ts:106](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L106) Check if this registration is still active (not unregistered) @@ -75,7 +75,7 @@ Check if this registration is still active (not unregistered) setOptions: (options) => void; ``` -Defined in: [hotkey-manager.ts:112](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L112) +Defined in: [hotkey-manager.ts:111](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L111) Update options (merged with existing options). Useful for updating `enabled`, `preventDefault`, etc. without re-registering. @@ -98,7 +98,7 @@ Useful for updating `enabled`, `preventDefault`, etc. without re-registering. unregister: () => void; ``` -Defined in: [hotkey-manager.ts:114](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L114) +Defined in: [hotkey-manager.ts:113](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L113) Unregister this hotkey diff --git a/docs/reference/interfaces/SequenceOptions.md b/docs/reference/interfaces/SequenceOptions.md index dad492a0..65108cbd 100644 --- a/docs/reference/interfaces/SequenceOptions.md +++ b/docs/reference/interfaces/SequenceOptions.md @@ -5,7 +5,7 @@ title: SequenceOptions # Interface: SequenceOptions -Defined in: [sequence-manager.ts:28](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L28) +Defined in: [sequence-manager.ts:27](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L27) Options for hotkey sequence matching. Extends HotkeyOptions but excludes requireReset (not applicable to sequences). @@ -22,7 +22,7 @@ Extends HotkeyOptions but excludes requireReset (not applicable to sequences). optional conflictBehavior: ConflictBehavior; ``` -Defined in: [hotkey-manager.ts:30](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L30) +Defined in: [hotkey-manager.ts:29](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L29) Behavior when this hotkey conflicts with an existing registration on the same target. Defaults to 'warn' @@ -38,7 +38,7 @@ Behavior when this hotkey conflicts with an existing registration on the same ta optional enabled: boolean; ``` -Defined in: [hotkey-manager.ts:36](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L36) +Defined in: [hotkey-manager.ts:35](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L35) Soft-disable: when `false`, the callback does not run but the registration stays in `HotkeyManager` (and in devtools). Toggling this should update the @@ -56,7 +56,7 @@ existing handle via `setOptions` rather than unregistering. Defaults to `true`. optional eventType: "keydown" | "keyup"; ``` -Defined in: [hotkey-manager.ts:38](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L38) +Defined in: [hotkey-manager.ts:37](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L37) The event type to listen for. Defaults to 'keydown' @@ -72,7 +72,7 @@ The event type to listen for. Defaults to 'keydown' optional ignoreInputs: boolean; ``` -Defined in: [hotkey-manager.ts:40](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L40) +Defined in: [hotkey-manager.ts:39](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L39) Whether to ignore hotkeys when keyboard events originate from input-like elements (text inputs, textarea, select, contenteditable — button-type inputs like type=button/submit/reset are not ignored). Defaults based on hotkey: true for single keys and Shift/Alt combos; false for Ctrl/Meta shortcuts and Escape @@ -88,7 +88,7 @@ Whether to ignore hotkeys when keyboard events originate from input-like element optional platform: "mac" | "windows" | "linux"; ``` -Defined in: [hotkey-manager.ts:42](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L42) +Defined in: [hotkey-manager.ts:41](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L41) The target platform for resolving 'Mod' @@ -104,7 +104,7 @@ The target platform for resolving 'Mod' optional preventDefault: boolean; ``` -Defined in: [hotkey-manager.ts:44](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L44) +Defined in: [hotkey-manager.ts:43](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L43) Prevent the default browser action when the hotkey matches. Defaults to true @@ -120,7 +120,7 @@ Prevent the default browser action when the hotkey matches. Defaults to true optional stopPropagation: boolean; ``` -Defined in: [hotkey-manager.ts:48](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L48) +Defined in: [hotkey-manager.ts:47](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L47) Stop event propagation when the hotkey matches. Defaults to true @@ -136,7 +136,7 @@ Stop event propagation when the hotkey matches. Defaults to true optional target: HTMLElement | Document | Window | null; ``` -Defined in: [hotkey-manager.ts:50](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L50) +Defined in: [hotkey-manager.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/hotkey-manager.ts#L49) The DOM element to attach the event listener to. Defaults to document. @@ -152,6 +152,6 @@ The DOM element to attach the event listener to. Defaults to document. optional timeout: number; ``` -Defined in: [sequence-manager.ts:30](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L30) +Defined in: [sequence-manager.ts:29](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L29) Timeout between keys in milliseconds. Default: 1000 diff --git a/docs/reference/interfaces/SequenceRegistrationHandle.md b/docs/reference/interfaces/SequenceRegistrationHandle.md index dc046a84..0422bc1b 100644 --- a/docs/reference/interfaces/SequenceRegistrationHandle.md +++ b/docs/reference/interfaces/SequenceRegistrationHandle.md @@ -5,7 +5,7 @@ title: SequenceRegistrationHandle # Interface: SequenceRegistrationHandle -Defined in: [sequence-manager.ts:117](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L117) +Defined in: [sequence-manager.ts:116](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L116) A handle returned from SequenceManager.register() that allows updating the callback and options without re-registering the sequence. @@ -28,7 +28,7 @@ handle.unregister() callback: HotkeyCallback; ``` -Defined in: [sequence-manager.ts:120](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L120) +Defined in: [sequence-manager.ts:119](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L119) *** @@ -38,7 +38,7 @@ Defined in: [sequence-manager.ts:120](https://github.com/TanStack/hotkeys/blob/m readonly id: string; ``` -Defined in: [sequence-manager.ts:118](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L118) +Defined in: [sequence-manager.ts:117](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L117) *** @@ -48,7 +48,7 @@ Defined in: [sequence-manager.ts:118](https://github.com/TanStack/hotkeys/blob/m readonly isActive: boolean; ``` -Defined in: [sequence-manager.ts:119](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L119) +Defined in: [sequence-manager.ts:118](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L118) *** @@ -58,7 +58,7 @@ Defined in: [sequence-manager.ts:119](https://github.com/TanStack/hotkeys/blob/m setOptions: (options) => void; ``` -Defined in: [sequence-manager.ts:121](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L121) +Defined in: [sequence-manager.ts:120](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L120) #### Parameters @@ -78,7 +78,7 @@ Defined in: [sequence-manager.ts:121](https://github.com/TanStack/hotkeys/blob/m unregister: () => void; ``` -Defined in: [sequence-manager.ts:122](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L122) +Defined in: [sequence-manager.ts:121](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L121) #### Returns diff --git a/docs/reference/interfaces/SequenceRegistrationView.md b/docs/reference/interfaces/SequenceRegistrationView.md index 4a23fc74..fafb7e43 100644 --- a/docs/reference/interfaces/SequenceRegistrationView.md +++ b/docs/reference/interfaces/SequenceRegistrationView.md @@ -5,7 +5,7 @@ title: SequenceRegistrationView # Interface: SequenceRegistrationView -Defined in: [sequence-manager.ts:77](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L77) +Defined in: [sequence-manager.ts:76](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L76) View of a sequence registration for devtools display. Progress fields reflect an in-progress match (between first key and completion or timeout). @@ -18,7 +18,7 @@ Progress fields reflect an in-progress match (between first key and completion o id: string; ``` -Defined in: [sequence-manager.ts:78](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L78) +Defined in: [sequence-manager.ts:77](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L77) *** @@ -28,7 +28,7 @@ Defined in: [sequence-manager.ts:78](https://github.com/TanStack/hotkeys/blob/ma matchedStepCount: number; ``` -Defined in: [sequence-manager.ts:84](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L84) +Defined in: [sequence-manager.ts:83](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L83) Steps matched in the current attempt (0 when idle or just completed). @@ -40,7 +40,7 @@ Steps matched in the current attempt (0 when idle or just completed). options: SequenceOptions; ``` -Defined in: [sequence-manager.ts:80](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L80) +Defined in: [sequence-manager.ts:79](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L79) *** @@ -50,7 +50,7 @@ Defined in: [sequence-manager.ts:80](https://github.com/TanStack/hotkeys/blob/ma partialMatchLastKeyTime: number; ``` -Defined in: [sequence-manager.ts:86](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L86) +Defined in: [sequence-manager.ts:85](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L85) `Date.now()` when the last step in the current attempt matched; 0 if none. @@ -62,7 +62,7 @@ Defined in: [sequence-manager.ts:86](https://github.com/TanStack/hotkeys/blob/ma sequence: HotkeySequence; ``` -Defined in: [sequence-manager.ts:79](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L79) +Defined in: [sequence-manager.ts:78](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L78) *** @@ -72,7 +72,7 @@ Defined in: [sequence-manager.ts:79](https://github.com/TanStack/hotkeys/blob/ma target: Target; ``` -Defined in: [sequence-manager.ts:81](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L81) +Defined in: [sequence-manager.ts:80](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L80) *** @@ -82,4 +82,4 @@ Defined in: [sequence-manager.ts:81](https://github.com/TanStack/hotkeys/blob/ma triggerCount: number; ``` -Defined in: [sequence-manager.ts:82](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L82) +Defined in: [sequence-manager.ts:81](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L81) diff --git a/docs/reference/type-aliases/HotkeySequence.md b/docs/reference/type-aliases/HotkeySequence.md index b3e7ccb4..9fc95653 100644 --- a/docs/reference/type-aliases/HotkeySequence.md +++ b/docs/reference/type-aliases/HotkeySequence.md @@ -9,7 +9,7 @@ title: HotkeySequence type HotkeySequence = Hotkey[]; ``` -Defined in: [sequence-manager.ts:49](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L49) +Defined in: [sequence-manager.ts:48](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L48) A sequence of hotkeys for Vim-style shortcuts. diff --git a/docs/reference/variables/DEFAULT_SEQUENCE_TIMEOUT.md b/docs/reference/variables/DEFAULT_SEQUENCE_TIMEOUT.md index 692403c5..44098858 100644 --- a/docs/reference/variables/DEFAULT_SEQUENCE_TIMEOUT.md +++ b/docs/reference/variables/DEFAULT_SEQUENCE_TIMEOUT.md @@ -9,7 +9,7 @@ title: DEFAULT_SEQUENCE_TIMEOUT const DEFAULT_SEQUENCE_TIMEOUT: 1000 = 1000; ``` -Defined in: [sequence-manager.ts:55](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L55) +Defined in: [sequence-manager.ts:54](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/sequence-manager.ts#L54) Default timeout between keys in a sequence (in milliseconds). Exported for consumers that display sequence state (e.g. devtools). diff --git a/examples/angular/injectHotkey/src/app/app.component.ts b/examples/angular/injectHotkey/src/app/app.component.ts index 45fa795f..d4707c4c 100644 --- a/examples/angular/injectHotkey/src/app/app.component.ts +++ b/examples/angular/injectHotkey/src/app/app.component.ts @@ -279,14 +279,10 @@ export class AppComponent { }, () => ({ target: this.editorRef()?.nativeElement ?? null }), ) - injectHotkey( - 'J', - () => { - this.lastHotkey.set('J') - this.editorShortcutCount.update((c) => c + 1) - }, - () => ({ target: this.editorRef()?.nativeElement ?? null }), - ) + injectHotkey('J', () => { + this.lastHotkey.set('J') + this.editorShortcutCount.update((c) => c + 1) + }) } protected formatForDisplay = formatForDisplay diff --git a/examples/lit/held-keys/index.html b/examples/lit/held-keys/index.html new file mode 100644 index 00000000..c98c1914 --- /dev/null +++ b/examples/lit/held-keys/index.html @@ -0,0 +1,12 @@ + + + + + + held-keys - TanStack Hotkey Lit example + + + + + + diff --git a/examples/lit/held-keys/package.json b/examples/lit/held-keys/package.json new file mode 100644 index 00000000..a35d9ccf --- /dev/null +++ b/examples/lit/held-keys/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/hotkeys-example-lit-held-keys", + "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/lit-hotkeys": "0.8.3", + "lit": "^3.3.2" + }, + "devDependencies": { + "typescript": "6.0.2", + "vite": "^8.0.3" + } +} diff --git a/examples/lit/held-keys/src/app.ts b/examples/lit/held-keys/src/app.ts new file mode 100644 index 00000000..cc3912f8 --- /dev/null +++ b/examples/lit/held-keys/src/app.ts @@ -0,0 +1,150 @@ +import { LitElement, css, html, unsafeCSS } from 'lit' +import { customElement, state } from 'lit/decorators.js' + +import { + HeldKeyCodesController, + HeldKeysController, + formatForDisplay, +} from '@tanstack/lit-hotkeys' + +import appStyles from './index.css?raw' + +@customElement('my-app') +export class MyApp extends LitElement { + static styles = [ + css` + :host { + display: block; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; + box-sizing: border-box; + } + :host *, + :host *::before, + :host *::after { + box-sizing: border-box; + } + `, + unsafeCSS(appStyles), + ] + + private heldKeys = new HeldKeysController(this) + private heldCodes = new HeldKeyCodesController(this) + + @state() private history: Array = [] + + override updated(): void { + const keys = this.heldKeys.value + if (keys.length > 0) { + const combo = keys + .map((k) => formatForDisplay(k, { useSymbols: true })) + .join(' + ') + const last = this.history[this.history.length - 1] + if (last !== combo) { + this.history = [...this.history.slice(-9), combo] + } + } + } + + render() { + const keys = this.heldKeys.value + const codes = this.heldCodes.value + + return html` +
+
+

HeldKeysController

+

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

+
+ +
+
+

Currently Held Keys

+
+ ${keys.length > 0 + ? keys.map((key, index) => { + const code = codes[key] + return html` + ${index > 0 ? html`+` : null} + + ${formatForDisplay(key, { useSymbols: true })} + ${code && code !== key + ? html` ${code} ` + : null} + + ` + }) + : html`Press any keys...`} +
+
Keys held: ${keys.length}
+
+ +
+

Usage

+
+${`import { HeldKeysController } from '@tanstack/lit-hotkeys'
+
+class KeyDisplay extends LitElement {
+  private heldKeys = new HeldKeysController(this)
+
+  render() {
+    return html\`
+      
+ Currently pressed: \${this.heldKeys.value.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

+ ${this.history.length > 0 + ? html` +
    + ${this.history.map((combo) => html`
  • ${combo}
  • `)} +
+ ` + : html`

Press some key combinations...

`} + +
+ +
+

Use Cases

+
    +
  • Building a keyboard shortcut recorder
  • +
  • Displaying currently held keys to users
  • +
  • Debugging keyboard input
  • +
  • Creating key combination tutorials
  • +
+
+
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-app': MyApp + } +} diff --git a/examples/lit/held-keys/src/index.css b/examples/lit/held-keys/src/index.css new file mode 100644 index 00000000..b023d93b --- /dev/null +++ b/examples/lit/held-keys/src/index.css @@ -0,0 +1,130 @@ +* { + 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; + 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/lit/held-keys/tsconfig.json b/examples/lit/held-keys/tsconfig.json new file mode 100644 index 00000000..1b43085f --- /dev/null +++ b/examples/lit/held-keys/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/lit/hotkey-recorder/index.html b/examples/lit/hotkey-recorder/index.html new file mode 100644 index 00000000..40b40f46 --- /dev/null +++ b/examples/lit/hotkey-recorder/index.html @@ -0,0 +1,12 @@ + + + + + + hotkey-recorder - TanStack Hotkey Lit example + + + + + + diff --git a/examples/lit/hotkey-recorder/package.json b/examples/lit/hotkey-recorder/package.json new file mode 100644 index 00000000..670bca1a --- /dev/null +++ b/examples/lit/hotkey-recorder/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/hotkeys-example-lit-hotkey-recorder", + "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/lit-hotkeys": "0.8.3", + "lit": "^3.3.2" + }, + "devDependencies": { + "typescript": "6.0.2", + "vite": "^8.0.3" + } +} diff --git a/examples/lit/hotkey-recorder/src/app.ts b/examples/lit/hotkey-recorder/src/app.ts new file mode 100644 index 00000000..0d09fe80 --- /dev/null +++ b/examples/lit/hotkey-recorder/src/app.ts @@ -0,0 +1,322 @@ +import { LitElement, css, html, nothing, unsafeCSS } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { + HeldKeysController, + HotkeyRecorderController, + formatForDisplay, + getHotkeyManager, +} from '@tanstack/lit-hotkeys' +import appStyles from './index.css?raw' +import type { + Hotkey, + HotkeyCallback, + HotkeyRegistrationHandle, +} from '@tanstack/lit-hotkeys' + +interface ShortcutActions { + [key: string]: { + name: string + defaultHotkey: Hotkey + } +} + +const DEFAULT_SHORTCUT_ACTIONS: ShortcutActions = { + save: { name: 'Save', defaultHotkey: 'Mod+K' }, + open: { name: 'Open', defaultHotkey: 'Mod+E' }, + new: { name: 'New', defaultHotkey: 'Mod+G' }, + close: { name: 'Close', defaultHotkey: 'Mod+Shift+K' }, + undo: { name: 'Undo', defaultHotkey: 'Mod+Shift+E' }, + redo: { name: 'Redo', defaultHotkey: 'Mod+Shift+G' }, +} + +@customElement('my-app') +export class MyApp extends LitElement { + static styles = [ + css` + :host { + display: block; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; + box-sizing: border-box; + } + :host *, + :host *::before, + :host *::after { + box-sizing: border-box; + } + `, + unsafeCSS(appStyles), + ] + + private heldKeys = new HeldKeysController(this) + + private recorder = new HotkeyRecorderController(this, { + onRecord: (hotkey: Hotkey) => { + if (this._recordingActionId) { + this._shortcuts = { + ...this._shortcuts, + [this._recordingActionId]: hotkey, + } + this._recordingActionId = null + this._reregisterHotkeys() + } + }, + onCancel: () => { + this._recordingActionId = null + }, + onClear: () => { + if (this._recordingActionId) { + this._shortcuts = { + ...this._shortcuts, + [this._recordingActionId]: '', + } + this._recordingActionId = null + this._reregisterHotkeys() + } + }, + }) + + @state() private _shortcuts: Record = Object.fromEntries( + Object.entries(DEFAULT_SHORTCUT_ACTIONS).map(([id, action]) => [ + id, + action.defaultHotkey, + ]), + ) + + @state() private _recordingActionId: string | null = null + @state() private _counts: Record = { + save: 0, + open: 0, + new: 0, + close: 0, + undo: 0, + redo: 0, + } + + private _registrations: Array = [] + + override connectedCallback(): void { + super.connectedCallback() + this._reregisterHotkeys() + } + + override disconnectedCallback(): void { + super.disconnectedCallback() + this._unregisterAll() + } + + private _unregisterAll(): void { + for (const reg of this._registrations) { + if (reg.isActive) { + reg.unregister() + } + } + this._registrations = [] + } + + private _reregisterHotkeys(): void { + this._unregisterAll() + + if (this.recorder.isRecording) return + + const manager = getHotkeyManager() + + for (const [actionId, action] of Object.entries(DEFAULT_SHORTCUT_ACTIONS)) { + const hotkey = (this._shortcuts[actionId] || + action.defaultHotkey) as Hotkey + + const callback: HotkeyCallback = () => { + console.log(`${action.name} triggered:`, hotkey) + this._counts = { + ...this._counts, + [actionId]: (this._counts[actionId] ?? 0) + 1, + } + } + + const reg = manager.register(hotkey, callback, { target: document }) + this._registrations.push(reg) + } + } + + private _handleEdit(actionId: string): void { + this._unregisterAll() + this._recordingActionId = actionId + this.recorder.startRecording() + } + + private _handleCancel(): void { + this.recorder.cancelRecording() + this._recordingActionId = null + this._reregisterHotkeys() + } + + render() { + return html` +
+
+

Keyboard Shortcuts Settings

+

+ Customize your keyboard shortcuts. Click "Edit" to record a new + shortcut, or press Escape to cancel. +

+
+ +
+
+

Shortcuts

+
+ ${Object.entries(DEFAULT_SHORTCUT_ACTIONS).map( + ([actionId, action]) => { + const isRecordingThis = + this.recorder.isRecording && + this._recordingActionId === actionId + const hotkey = this._shortcuts[actionId] ?? '' + + return html` +
+
+
${action.name}
+
+ ${isRecordingThis + ? html` +
+ ${this.heldKeys.value.length > 0 + ? html` +
+ ${this.heldKeys.value.map( + (key, index) => html` + ${index > 0 + ? html`+` + : nothing} + ${key} + `, + )} +
+ ` + : html` + + Press any key combination... + + `} +
+ ` + : hotkey + ? html`${formatForDisplay(hotkey as Hotkey)}` + : html`No shortcut`} +
+
+
+ ${isRecordingThis + ? html` + + ` + : html` + + `} +
+
+ ` + }, + )} +
+
+ +
+

Demo Actions

+

Try your shortcuts! Actions will trigger when you press them.

+
+ ${Object.entries(DEFAULT_SHORTCUT_ACTIONS).map( + ([actionId, action]) => html` +
+
${action.name}
+
${this._counts[actionId] ?? 0}
+ + ${formatForDisplay( + (this._shortcuts[actionId] || + action.defaultHotkey) as Hotkey, + )} + +
+ `, + )} +
+
+ + ${this.recorder.isRecording + ? html` +
+ Recording shortcut... Press any key + combination or Escape to cancel. Press Backspace/Delete to + clear the shortcut. +
+ ` + : nothing} + +
+

Usage

+
+${`import { HotkeyRecorderController, formatForDisplay } from '@tanstack/lit-hotkeys'
+import type { Hotkey } from '@tanstack/lit-hotkeys'
+
+class ShortcutSettings extends LitElement {
+  private recorder = new HotkeyRecorderController(this, {
+    onRecord: (hotkey) => {
+      this.shortcut = hotkey
+      this.requestUpdate()
+    },
+    onCancel: () => {
+      console.log('Recording cancelled')
+    },
+  })
+
+  @state() private shortcut: Hotkey | null = null
+
+  render() {
+    return html\`
+      
+      \${this.shortcut
+        ? html\`\${formatForDisplay(this.shortcut)}\`
+        : nothing}
+    \`
+  }
+}`}
+
+
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-app': MyApp + } +} diff --git a/examples/lit/hotkey-recorder/src/index.css b/examples/lit/hotkey-recorder/src/index.css new file mode 100644 index 00000000..dd8d89b1 --- /dev/null +++ b/examples/lit/hotkey-recorder/src/index.css @@ -0,0 +1,243 @@ +.app { + max-width: 900px; + margin: 0 auto; + padding: 20px; +} +header { + text-align: center; + margin-bottom: 40px; +} +header h1 { + margin: 0 0 10px; + color: #0066cc; +} +header p { + color: #666; + max-width: 600px; + 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 p { + margin: 0 0 16px; +} +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; +} +button { + background: #0066cc; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} +button:hover { + background: #0052a3; +} +button:active { + background: #004080; +} +.cancel-button { + background: #dc3545; +} +.cancel-button:hover { + background: #c82333; +} +.edit-button { + background: #28a745; +} +.edit-button:hover { + background: #218838; +} +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; + margin-top: 16px; +} +.info-box { + background: #e3f2fd; + border-radius: 8px; + padding: 12px 16px; + margin: 20px 0; +} +.recording-notice { + background: #fff3cd; + border: 2px solid #ffc107; + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +/* Shortcuts List */ +.shortcuts-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.shortcut-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: #f8f9fa; + border: 2px solid transparent; + border-radius: 8px; + transition: all 0.2s; +} +.shortcut-item:hover { + background: #f0f0f0; +} +.shortcut-item.recording { + background: #fff3cd; + border-color: #ffc107; + box-shadow: 0 0 0 3px rgba(255, 193, 7, 0.2); + animation: recording-pulse 1.5s ease-in-out infinite; +} +@keyframes recording-pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px rgba(255, 193, 7, 0.2); + } + 50% { + box-shadow: 0 0 0 6px rgba(255, 193, 7, 0.1); + } +} +.shortcut-item-content { + display: flex; + align-items: center; + gap: 24px; + flex: 1; +} +.shortcut-action { + font-weight: 500; + min-width: 80px; + font-size: 15px; +} +.shortcut-hotkey { + display: flex; + align-items: center; + min-height: 32px; +} +.shortcut-hotkey kbd { + font-size: 14px; +} +.no-shortcut { + color: #999; + font-style: italic; + font-size: 14px; +} +.shortcut-actions { + display: flex; + gap: 8px; +} + +/* Recording Indicator */ +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; +} +.recording-text { + color: #856404; + font-style: italic; + font-size: 14px; +} +.held-hotkeys { + display: flex; + align-items: center; + gap: 4px; +} +.held-hotkeys .plus { + color: #856404; + font-size: 16px; + margin: 0 4px; +} +.held-hotkeys kbd { + background: #ffc107; + border-color: #ff9800; + color: #856404; + font-weight: 600; +} + +/* Demo Stats */ +.demo-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-top: 20px; +} +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + gap: 8px; +} +.stat-label { + font-size: 13px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.stat-value { + font-size: 32px; + font-weight: bold; + color: #0066cc; +} +.stat-item kbd { + margin-top: 4px; +} + +/* Responsive */ +@media (max-width: 600px) { + .shortcut-item { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .shortcut-item-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .shortcut-actions { + width: 100%; + justify-content: flex-end; + } + .demo-stats { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/examples/lit/hotkey-recorder/tsconfig.json b/examples/lit/hotkey-recorder/tsconfig.json new file mode 100644 index 00000000..1b43085f --- /dev/null +++ b/examples/lit/hotkey-recorder/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/lit/hotkey-sequence-recorder/index.html b/examples/lit/hotkey-sequence-recorder/index.html new file mode 100644 index 00000000..4dc90aa5 --- /dev/null +++ b/examples/lit/hotkey-sequence-recorder/index.html @@ -0,0 +1,12 @@ + + + + + + hotkey-sequence-recorder - TanStack Hotkey Lit example + + + + + + diff --git a/examples/lit/hotkey-sequence-recorder/package.json b/examples/lit/hotkey-sequence-recorder/package.json new file mode 100644 index 00000000..5ddaa6c5 --- /dev/null +++ b/examples/lit/hotkey-sequence-recorder/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/hotkeys-example-lit-hotkey-sequence-recorder", + "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/lit-hotkeys": "0.8.3", + "lit": "^3.3.2" + }, + "devDependencies": { + "typescript": "6.0.2", + "vite": "^8.0.3" + } +} diff --git a/examples/lit/hotkey-sequence-recorder/src/app.ts b/examples/lit/hotkey-sequence-recorder/src/app.ts new file mode 100644 index 00000000..6a474c7f --- /dev/null +++ b/examples/lit/hotkey-sequence-recorder/src/app.ts @@ -0,0 +1,364 @@ +import { LitElement, css, html, nothing, unsafeCSS } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { + HeldKeysController, + HotkeySequenceRecorderController, + formatForDisplay, + getSequenceManager, +} from '@tanstack/lit-hotkeys' +import appStyles from './index.css?raw' +import type { + HotkeyCallback, + HotkeySequence, + SequenceRegistrationHandle, +} from '@tanstack/lit-hotkeys' + +interface ShortcutAction { + name: string + defaultSequence: HotkeySequence +} + +interface ShortcutActions { + [key: string]: ShortcutAction +} + +const DEFAULT_SHORTCUT_ACTIONS: ShortcutActions = { + save: { name: 'Save', defaultSequence: ['Mod+S'] }, + open: { name: 'Open (gg)', defaultSequence: ['G', 'G'] }, + new: { name: 'New (dd)', defaultSequence: ['D', 'D'] }, + close: { name: 'Close', defaultSequence: ['Mod+Shift+K'] }, + undo: { name: 'Undo (yy)', defaultSequence: ['Y', 'Y'] }, + redo: { name: 'Redo', defaultSequence: ['Mod+Shift+G'] }, +} + +@customElement('my-app') +export class MyApp extends LitElement { + static styles = [ + css` + :host { + display: block; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; + box-sizing: border-box; + } + :host *, + :host *::before, + :host *::after { + box-sizing: border-box; + } + `, + unsafeCSS(appStyles), + ] + + private heldKeys = new HeldKeysController(this) + + private recorder = new HotkeySequenceRecorderController(this, { + onRecord: (sequence: HotkeySequence) => { + if (this._recordingActionId) { + this._shortcuts = { + ...this._shortcuts, + [this._recordingActionId]: sequence, + } + this._recordingActionId = null + this._reregisterSequences() + } + }, + onCancel: () => { + this._recordingActionId = null + this._reregisterSequences() + }, + onClear: () => { + if (this._recordingActionId) { + this._shortcuts = { + ...this._shortcuts, + [this._recordingActionId]: [], + } + this._recordingActionId = null + this._reregisterSequences() + } + }, + }) + + @state() private _shortcuts: Record = + Object.fromEntries( + Object.entries(DEFAULT_SHORTCUT_ACTIONS).map(([id, action]) => [ + id, + action.defaultSequence, + ]), + ) + + @state() private _recordingActionId: string | null = null + @state() private _counts: Record = { + save: 0, + open: 0, + new: 0, + close: 0, + undo: 0, + redo: 0, + } + + private _registrations: Array = [] + + override connectedCallback(): void { + super.connectedCallback() + this._reregisterSequences() + } + + override disconnectedCallback(): void { + super.disconnectedCallback() + this._unregisterAll() + } + + private _unregisterAll(): void { + for (const reg of this._registrations) { + if (reg.isActive) { + reg.unregister() + } + } + this._registrations = [] + } + + private _reregisterSequences(): void { + this._unregisterAll() + + if (this.recorder.isRecording) return + + const manager = getSequenceManager() + + for (const [actionId, action] of Object.entries(DEFAULT_SHORTCUT_ACTIONS)) { + const sequence = this._resolveSequence(actionId) + + if (sequence.length === 0) continue + + const callback: HotkeyCallback = () => { + console.log(`${action.name} triggered:`, sequence) + this._counts = { + ...this._counts, + [actionId]: (this._counts[actionId] ?? 0) + 1, + } + } + + const reg = manager.register(sequence, callback, { target: document }) + this._registrations.push(reg) + } + } + + private _resolveSequence(actionId: string): HotkeySequence { + return ( + this._shortcuts[actionId] ?? + DEFAULT_SHORTCUT_ACTIONS[actionId].defaultSequence + ) + } + + private _formatSequenceLabel(actionId: string): string { + const seq = this._resolveSequence(actionId) + if (seq.length === 0) return '—' + return seq.map((h) => formatForDisplay(h)).join(' ') + } + + private _handleEdit(actionId: string): void { + this._unregisterAll() + this._recordingActionId = actionId + this.recorder.startRecording() + } + + private _handleCancel(): void { + this.recorder.cancelRecording() + this._recordingActionId = null + this._reregisterSequences() + } + + render() { + return html` +
+
+

Sequence Shortcut Settings

+

+ Customize Vim-style sequences. Click Edit, press each chord in + order, then press Enter to save. Escape cancels; Backspace removes + the last chord or clears when empty. +

+
+ +
+
+

Shortcuts

+
+ ${Object.entries(DEFAULT_SHORTCUT_ACTIONS).map( + ([actionId, action]) => { + const isRecordingThis = + this.recorder.isRecording && + this._recordingActionId === actionId + const sequence = this._resolveSequence(actionId) + const disabled = sequence.length === 0 + + return html` +
+
+
${action.name}
+
+ ${isRecordingThis + ? html` +
+ ${this.recorder.steps.length > 0 + ? html` + + ${this.recorder.steps + .map((h) => formatForDisplay(h)) + .join(' ')} + + ` + : this.heldKeys.value.length > 0 + ? html` +
+ ${this.heldKeys.value.map( + (key, index) => html` + ${index > 0 + ? html`+` + : nothing} + ${key} + `, + )} +
+ ` + : html` + + Press chords, then Enter… + + `} +
+ ` + : disabled + ? html`No shortcut` + : html` + ${sequence + .map((h) => formatForDisplay(h)) + .join(' ')} + `} +
+
+
+ ${isRecordingThis + ? html` + + ` + : html` + + `} +
+
+ ` + }, + )} +
+
+ +
+

Demo Actions

+

Try your sequences within the default timeout window.

+
+ ${Object.entries(DEFAULT_SHORTCUT_ACTIONS).map( + ([actionId, action]) => html` +
+
${action.name}
+
${this._counts[actionId] ?? 0}
+ ${this._formatSequenceLabel(actionId)} +
+ `, + )} +
+
+ + ${this.recorder.isRecording + ? html` +
+ Recording sequence… Press each chord, then + Enter to finish. Escape cancels. Backspace removes the last + chord or clears. + ${this.recorder.steps.length > 0 + ? html` +
+ Steps: + ${this.recorder.steps.map( + (h, i) => html` + ${i > 0 ? ' ' : ''}${formatForDisplay(h)} + `, + )} +
+ ` + : nothing} +
+ ` + : nothing} + +
+

Usage

+
+${`import { HotkeySequenceRecorderController, formatForDisplay } from '@tanstack/lit-hotkeys'
+import type { HotkeySequence } from '@tanstack/lit-hotkeys'
+
+class ShortcutSettings extends LitElement {
+  private recorder = new HotkeySequenceRecorderController(this, {
+    onRecord: (sequence) => {
+      this.sequence = sequence
+      this.requestUpdate()
+    },
+    onCancel: () => {
+      console.log('Recording cancelled')
+    },
+  })
+
+  @state() private sequence: HotkeySequence | null = null
+
+  render() {
+    return html\`
+      
+      \${this.recorder.steps.length
+        ? html\`
Steps: \${this.recorder.steps.map(h => formatForDisplay(h)).join(' ')}
\` + : nothing} + \${this.sequence + ? html\`
Recorded: \${this.sequence.map(h => formatForDisplay(h)).join(' ')}
\` + : nothing} + \` + } +}`}
+
+
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-app': MyApp + } +} diff --git a/examples/lit/hotkey-sequence-recorder/src/index.css b/examples/lit/hotkey-sequence-recorder/src/index.css new file mode 100644 index 00000000..dd8d89b1 --- /dev/null +++ b/examples/lit/hotkey-sequence-recorder/src/index.css @@ -0,0 +1,243 @@ +.app { + max-width: 900px; + margin: 0 auto; + padding: 20px; +} +header { + text-align: center; + margin-bottom: 40px; +} +header h1 { + margin: 0 0 10px; + color: #0066cc; +} +header p { + color: #666; + max-width: 600px; + 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 p { + margin: 0 0 16px; +} +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; +} +button { + background: #0066cc; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} +button:hover { + background: #0052a3; +} +button:active { + background: #004080; +} +.cancel-button { + background: #dc3545; +} +.cancel-button:hover { + background: #c82333; +} +.edit-button { + background: #28a745; +} +.edit-button:hover { + background: #218838; +} +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; + margin-top: 16px; +} +.info-box { + background: #e3f2fd; + border-radius: 8px; + padding: 12px 16px; + margin: 20px 0; +} +.recording-notice { + background: #fff3cd; + border: 2px solid #ffc107; + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +/* Shortcuts List */ +.shortcuts-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.shortcut-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: #f8f9fa; + border: 2px solid transparent; + border-radius: 8px; + transition: all 0.2s; +} +.shortcut-item:hover { + background: #f0f0f0; +} +.shortcut-item.recording { + background: #fff3cd; + border-color: #ffc107; + box-shadow: 0 0 0 3px rgba(255, 193, 7, 0.2); + animation: recording-pulse 1.5s ease-in-out infinite; +} +@keyframes recording-pulse { + 0%, + 100% { + box-shadow: 0 0 0 3px rgba(255, 193, 7, 0.2); + } + 50% { + box-shadow: 0 0 0 6px rgba(255, 193, 7, 0.1); + } +} +.shortcut-item-content { + display: flex; + align-items: center; + gap: 24px; + flex: 1; +} +.shortcut-action { + font-weight: 500; + min-width: 80px; + font-size: 15px; +} +.shortcut-hotkey { + display: flex; + align-items: center; + min-height: 32px; +} +.shortcut-hotkey kbd { + font-size: 14px; +} +.no-shortcut { + color: #999; + font-style: italic; + font-size: 14px; +} +.shortcut-actions { + display: flex; + gap: 8px; +} + +/* Recording Indicator */ +.recording-indicator { + display: flex; + align-items: center; + gap: 8px; +} +.recording-text { + color: #856404; + font-style: italic; + font-size: 14px; +} +.held-hotkeys { + display: flex; + align-items: center; + gap: 4px; +} +.held-hotkeys .plus { + color: #856404; + font-size: 16px; + margin: 0 4px; +} +.held-hotkeys kbd { + background: #ffc107; + border-color: #ff9800; + color: #856404; + font-weight: 600; +} + +/* Demo Stats */ +.demo-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-top: 20px; +} +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + gap: 8px; +} +.stat-label { + font-size: 13px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.stat-value { + font-size: 32px; + font-weight: bold; + color: #0066cc; +} +.stat-item kbd { + margin-top: 4px; +} + +/* Responsive */ +@media (max-width: 600px) { + .shortcut-item { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + .shortcut-item-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .shortcut-actions { + width: 100%; + justify-content: flex-end; + } + .demo-stats { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/examples/lit/hotkey-sequence-recorder/tsconfig.json b/examples/lit/hotkey-sequence-recorder/tsconfig.json new file mode 100644 index 00000000..1b43085f --- /dev/null +++ b/examples/lit/hotkey-sequence-recorder/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/lit/hotkey-sequence/.gitignore b/examples/lit/hotkey-sequence/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/lit/hotkey-sequence/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/lit/hotkey-sequence/index.html b/examples/lit/hotkey-sequence/index.html new file mode 100644 index 00000000..35e0f52b --- /dev/null +++ b/examples/lit/hotkey-sequence/index.html @@ -0,0 +1,12 @@ + + + + + + hotkey-sequence - TanStack Hotkey Lit example + + + + + + diff --git a/examples/lit/hotkey-sequence/package.json b/examples/lit/hotkey-sequence/package.json new file mode 100644 index 00000000..9307510d --- /dev/null +++ b/examples/lit/hotkey-sequence/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/hotkeys-example-lit-hotkey-sequence", + "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/lit-hotkeys": "0.8.3", + "lit": "^3.3.2" + }, + "devDependencies": { + "typescript": "6.0.2", + "vite": "^8.0.3" + } +} diff --git a/examples/lit/hotkey-sequence/src/app.ts b/examples/lit/hotkey-sequence/src/app.ts new file mode 100644 index 00000000..49dbe1f2 --- /dev/null +++ b/examples/lit/hotkey-sequence/src/app.ts @@ -0,0 +1,255 @@ +import { LitElement, css, html, unsafeCSS } from 'lit' +import { customElement, state } from 'lit/decorators.js' + +import { hotkey, hotkeySequence } from '@tanstack/lit-hotkeys' + +import appStyles from './index.css?raw' +import type { HotkeyCallbackContext } from '@tanstack/lit-hotkeys' + +@customElement('my-app') +export class MyApp extends LitElement { + static styles = [ + css` + :host { + display: block; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; + box-sizing: border-box; + } + :host *, + :host *::before, + :host *::after { + box-sizing: border-box; + } + `, + unsafeCSS(appStyles), + ] + + @state() private lastSequence: string | null = null + @state() private history: Array = [] + + private addToHistory(action: string) { + this.lastSequence = action + this.history = [...this.history.slice(-9), action] + } + + render() { + return html` +
+
+

@hotkeySequence

+

+ Register multi-key sequences (like Vim commands). Keys must be + pressed within the timeout window (default: 1000ms). +

+
+ +
+
+

Vim-Style Commands

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SequenceAction
g gGo to top
G (Shift+G)Go to bottom
d dDelete line
y yYank (copy) line
d wDelete word
c i wChange inner word
+
+ +
+

Fun Sequences

+
+
+

Konami Code (Partial)

+

+ Use arrow keys within 1.5 seconds +
+
+

Side to Side

+

+ Arrow keys within 1.5 seconds +
+
+

Spell It Out

+

+ h e l l + o +

+ Type "hello" quickly +
+
+
+ + ${this.lastSequence + ? html` +
+ Triggered: ${this.lastSequence} +
+ ` + : null} + +
+

Input handling

+

+ Sequences are not detected when typing in text inputs, textareas, + selects, or contenteditable elements. Button-type inputs ( + type="button", submit, + reset) still receive sequences. Focus the input below + and try g g or hello + — nothing will trigger. Click outside to try again. +

+ +
+ +
+

Usage

+
+${`import { hotkeySequence } from '@tanstack/lit-hotkeys'
+
+class VimEditor extends LitElement {
+  // Basic sequence
+  @hotkeySequence(['G', 'G'])
+  goToTop() {
+    window.scrollTo(0, 0)
+  }
+
+  // With custom timeout (1.5 seconds)
+  @hotkeySequence(
+    ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown'],
+    { timeout: 1500 }
+  )
+  activateCheatMode() {
+    // ...
+  }
+
+  // Three-key sequence
+  @hotkeySequence(['C', 'I', 'W'])
+  changeInnerWord() {
+    // ...
+  }
+}`}
+
+ + ${this.history.length > 0 + ? html` +
+

History

+
    + ${this.history.map((item) => html`
  • ${item}
  • `)} +
+ +
+ ` + : null} + +

Press Escape to clear history

+
+
+ ` + } + + // ============================================================================ + // Sequences + // ============================================================================ + + @hotkeySequence(['G', 'G']) + private _gg() { + this.addToHistory('gg → Go to top') + } + + @hotkeySequence(['Shift+G']) + private _shiftG() { + this.addToHistory('G → Go to bottom') + } + + @hotkeySequence(['D', 'D']) + private _dd() { + this.addToHistory('dd → Delete line') + } + + @hotkeySequence(['Y', 'Y']) + private _yy() { + this.addToHistory('yy → Yank (copy) line') + } + + @hotkeySequence(['D', 'W']) + private _dw() { + this.addToHistory('dw → Delete word') + } + + @hotkeySequence(['C', 'I', 'W']) + private _ciw() { + this.addToHistory('ciw → Change inner word') + } + + @hotkeySequence(['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown'], { + timeout: 1500, + }) + private _konami() { + this.addToHistory('↑↑↓↓ → Konami code (partial)') + } + + @hotkeySequence(['ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight'], { + timeout: 1500, + }) + private _sideToSide() { + this.addToHistory('←→←→ → Side to side!') + } + + @hotkeySequence(['H', 'E', 'L', 'L', 'O']) + private _hello() { + this.addToHistory('hello → Hello World!') + } + + @hotkey('Escape') + private _escape(_event: KeyboardEvent, _ctx: HotkeyCallbackContext) { + this.lastSequence = null + this.history = [] + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-app': MyApp + } +} diff --git a/examples/lit/hotkey-sequence/src/index.css b/examples/lit/hotkey-sequence/src/index.css new file mode 100644 index 00000000..b9153d41 --- /dev/null +++ b/examples/lit/hotkey-sequence/src/index.css @@ -0,0 +1,165 @@ +* { + box-sizing: border-box; +} + +.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-left: auto; + margin-right: 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; +} + +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; + margin-right: 4px; +} + +.sequence-table { + width: 100%; + border-collapse: collapse; +} + +.sequence-table th, +.sequence-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #eee; +} + +.sequence-table th { + font-weight: 600; + color: #666; + font-size: 14px; +} + +.fun-sequences { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.sequence-card { + background: #f8f9fa; + border-radius: 8px; + padding: 16px; + text-align: center; +} + +.sequence-card h3 { + margin: 0 0 12px; + font-size: 16px; +} + +.sequence-card p { + margin: 0 0 8px; +} + +.hint { + font-size: 12px; + color: #888; + font-style: italic; +} + +.info-box { + background: #e3f2fd; + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 24px; + font-size: 18px; +} + +.info-box.success { + background: #e8f5e9; + color: #2e7d32; +} + +.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: 10px 14px; + background: #f0f0f0; + border-radius: 6px; + margin-bottom: 6px; + 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; +} + +.demo-input { + width: 100%; + max-width: 400px; + padding: 12px 16px; + font-size: 14px; + border: 1px solid #ddd; + border-radius: 6px; + margin-top: 8px; +} + +.demo-input:focus { + outline: 2px solid #0066cc; + outline-offset: 2px; + border-color: #0066cc; +} diff --git a/examples/lit/hotkey-sequence/tsconfig.json b/examples/lit/hotkey-sequence/tsconfig.json new file mode 100644 index 00000000..168d61a2 --- /dev/null +++ b/examples/lit/hotkey-sequence/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/lit/hotkey/index.html b/examples/lit/hotkey/index.html new file mode 100644 index 00000000..fd8a6330 --- /dev/null +++ b/examples/lit/hotkey/index.html @@ -0,0 +1,12 @@ + + + + + + hotkey - TanStack Hotkey Lit example + + + + + + diff --git a/examples/lit/hotkey/package.json b/examples/lit/hotkey/package.json new file mode 100644 index 00000000..263be27d --- /dev/null +++ b/examples/lit/hotkey/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/hotkeys-example-lit-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/lit-hotkeys": "0.8.3", + "lit": "^3.3.2" + }, + "devDependencies": { + "typescript": "6.0.2", + "vite": "^8.0.3" + } +} diff --git a/examples/lit/hotkey/src/app.ts b/examples/lit/hotkey/src/app.ts new file mode 100644 index 00000000..7384c79e --- /dev/null +++ b/examples/lit/hotkey/src/app.ts @@ -0,0 +1,814 @@ +import { LitElement, css, html, unsafeCSS } from 'lit' +import { createRef, ref } from 'lit/directives/ref.js' +import { customElement, state } from 'lit/decorators.js' + +import { + formatForDisplay, + getHotkeyManager, + hotkey, +} from '@tanstack/lit-hotkeys' + +import appStyles from './index.css?raw' +import type { Ref } from 'lit/directives/ref.js' +import type { + Hotkey, + HotkeyCallbackContext, + HotkeyRegistrationHandle, +} from '@tanstack/lit-hotkeys' + +@customElement('my-app') +export class MyApp extends LitElement { + static styles = [ + css` + :host { + display: block; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; + box-sizing: border-box; + } + :host *, + :host *::before, + :host *::after { + box-sizing: border-box; + } + `, + unsafeCSS(appStyles), + ] + + @state() private lastHotkey: Hotkey | null = null + @state() private saveCount = 0 + @state() private incrementCount = 0 + @state() private enabled = true + @state() private activeTab = 1 + @state() private navigationCount = 0 + @state() private functionKeyCount = 0 + @state() private multiModifierCount = 0 + @state() private editingKeyCount = 0 + + // Scoped shortcuts state + @state() private modalOpen = false + @state() private editorContent = '' + @state() private sidebarShortcutCount = 0 + @state() private modalShortcutCount = 0 + @state() private editorShortcutCount = 0 + + private sidebarRef: Ref = createRef() + private modalRef: Ref = createRef() + private editorRef: Ref = createRef() + + private _scopedRegistrations: Array = [] + private _modalRegistrations: Array = [] + + override updated(changedProps: Map): void { + super.updated(changedProps) + + if (this._scopedRegistrations.length === 0) { + this._registerScopedHotkeys() + } + + if (changedProps.has('modalOpen')) { + this._unregisterModal() + if (this.modalOpen) { + this._registerModalHotkeys() + this.modalRef.value?.focus() + } + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback() + this._unregisterScoped() + this._unregisterModal() + } + + private _unregisterScoped(): void { + for (const reg of this._scopedRegistrations) { + if (reg.isActive) reg.unregister() + } + this._scopedRegistrations = [] + } + + private _unregisterModal(): void { + for (const reg of this._modalRegistrations) { + if (reg.isActive) reg.unregister() + } + this._modalRegistrations = [] + } + + private _registerScopedHotkeys(): void { + const manager = getHotkeyManager() + + const sidebar = this.sidebarRef.value + if (sidebar) { + this._scopedRegistrations.push( + manager.register( + 'Mod+B', + () => { + this.lastHotkey = 'Mod+B' + this.sidebarShortcutCount++ + alert( + 'Sidebar shortcut triggered! This only works when the sidebar area is focused.', + ) + }, + { target: sidebar }, + ), + manager.register( + 'Mod+N', + () => { + this.lastHotkey = 'Mod+N' + this.sidebarShortcutCount++ + }, + { target: sidebar }, + ), + ) + } + + const editor = this.editorRef.value + if (editor) { + this._scopedRegistrations.push( + manager.register( + 'Mod+S', + () => { + this.lastHotkey = 'Mod+S' + this.editorShortcutCount++ + alert( + `Editor content saved: "${this.editorContent.substring(0, 50)}${this.editorContent.length > 50 ? '...' : ''}"`, + ) + }, + { target: editor }, + ), + manager.register( + 'Mod+/', + () => { + this.lastHotkey = 'Mod+/' + this.editorShortcutCount++ + this.editorContent += '\n// Comment added via shortcut' + }, + { target: editor }, + ), + manager.register( + 'Mod+K', + () => { + this.lastHotkey = 'Mod+K' + this.editorShortcutCount++ + this.editorContent = '' + }, + { target: editor }, + ), + manager.register('J', () => { + this.lastHotkey = 'J' + this.editorShortcutCount++ + }), + ) + } + } + + private _registerModalHotkeys(): void { + const manager = getHotkeyManager() + const modal = this.modalRef.value + if (!modal) return + + this._modalRegistrations.push( + manager.register( + 'Escape', + () => { + this.lastHotkey = 'Escape' + this.modalShortcutCount++ + this.modalOpen = false + }, + { target: modal }, + ), + manager.register( + 'Mod+Enter', + () => { + this.lastHotkey = 'Mod+Enter' + this.modalShortcutCount++ + alert('Modal submit shortcut!') + }, + { target: modal }, + ), + ) + } + + render() { + return html`
+
+

@hotkey

+

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

+
+ +
+
+

Basic Hotkey

+

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

+
Save triggered: ${this.saveCount}x
+
+${`@hotkey('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: ${this.incrementCount}
+

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

+
+${`@hotkey('Mod+K', { requireReset: true })
+private _handleIncrementing(
+  _event: KeyboardEvent,
+  { hotkey }: HotkeyCallbackContext,
+) {
+  this.incrementCount++
+}`}
+
+ +
+

Conditional Hotkey

+

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

+ +
+${`@property({ type: Boolean }) enabled = true
+
+@hotkey('Mod+E')
+private _handleConditional = (
+  _event: KeyboardEvent,
+  { hotkey }: HotkeyCallbackContext,
+) => {
+  if (!this.enabled) return
+  alert('This hotkey can be toggled!')
+}`}
+
+ +
+

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: ${this.activeTab}
+
+${`@hotkey('Mod+1')
+private _tab1 = () => { this.activeTab = 1 }
+
+@hotkey('Mod+2')
+private _tab2 = () => { this.activeTab = 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: ${this.navigationCount}x +
+
+${`@hotkey('Shift+ArrowUp', () => selectUp())
+@hotkey('Alt+ArrowLeft', () => navigateBack())
+@hotkey('Mod+Home', () => goToStart())
+@hotkey('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: ${this.functionKeyCount}x +
+
+${`@hotkey('Alt+F4', () => closeWindow())
+@hotkey('Control+F5', () => hardRefresh())
+@hotkey('Mod+F1', () => showHelp())
+@hotkey('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: ${this.multiModifierCount}x +
+
+${`@hotkey('Mod+Shift+S', () => saveAs())
+@hotkey('Mod+Shift+Z', () => redo())
+@hotkey('Control+Alt+A', () => specialAction())
+@hotkey('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: ${this.editingKeyCount}x +
+
+${`@hotkey('Mod+Enter', () => submitForm())
+@hotkey('Shift+Enter', () => insertNewline())
+@hotkey('Mod+Backspace', () => deleteWord())
+@hotkey('Control+Tab', () => nextTab())
+@hotkey('Mod+Space', () => toggle())`}
+
+ + ${this.lastHotkey + ? html` +
+ Last triggered: + ${formatForDisplay(this.lastHotkey)} +
+ ` + : null} + +

Press Escape to reset all counters

+ +
+

Scoped Keyboard Shortcuts

+

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

+ +
+
+

Sidebar (Scoped Area)

+

Click here to focus, then try:

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

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

+
+ +
+

Modal Dialog

+ + + ${this.modalOpen + ? html` + + ` + : null} +
+ +
+

Text Editor (Scoped)

+

Focus the editor below and try:

+
+
+ ${formatForDisplay('Mod+S')} — Save editor content +
+
${formatForDisplay('Mod+/')} — Add comment
+
+ ${formatForDisplay('Mod+K')} — Clear editor +
+
J — Single key (test ignoreInputs)
+
+ + +
+ Editor shortcuts: ${this.editorShortcutCount}x +
+

+ These shortcuts only work when the editor is focused. Notice + that + ${formatForDisplay('Mod+S')} here doesn't conflict + with the global + ${formatForDisplay('Mod+S')} shortcut. +

+
+
+ +
+${`// Scoped to a ref (register after first render)
+const manager = getHotkeyManager()
+const sidebar = this.sidebarRef.value
+
+manager.register(
+  'Mod+B',
+  () => { console.log('Sidebar shortcut!') },
+  { target: sidebar }
+)
+
+// Scoped to a modal (register/unregister when opened/closed)
+manager.register(
+  'Escape',
+  () => { this.modalOpen = false },
+  { target: this.modalRef.value }
+)
+
+// Scoped to an editor
+manager.register(
+  'Mod+S',
+  () => saveEditorContent(),
+  { target: this.editorRef.value }
+)`}
+
+
+
` + } + + // ============================================================================ + // Basic Hotkeys + // ============================================================================ + + // Browser default: Save page (downloads the current page) + // Basic hotkey with callback context + @hotkey('Mod+S') + private _handleSaving = ( + _event: KeyboardEvent, + { hotkey, parsedHotkey }: HotkeyCallbackContext, + ) => { + this.lastHotkey = hotkey + this.saveCount++ + console.log(this.saveCount) + console.log('Hotkey triggered:', hotkey) + console.log('Parsed hotkey:', parsedHotkey) + } + + // requireReset prevents repeated triggering while holding keys + @hotkey('Mod+K', { requireReset: true }) + private _handleIncrementing( + _event: KeyboardEvent, + { hotkey }: HotkeyCallbackContext, + ) { + this.lastHotkey = hotkey + this.incrementCount++ + } + + @hotkey('Mod+E') + private _handleConditional = ( + _event: KeyboardEvent, + { hotkey }: HotkeyCallbackContext, + ) => { + if (!this.enabled) return + this.lastHotkey = hotkey + alert('This hotkey can be toggled!') + } + + // Number key combinations (tab switching) + @hotkey('Mod+1') + private _tab1 = () => { + this.lastHotkey = 'Mod+1' + this.activeTab = 1 + } + + @hotkey('Mod+2') + private _tab2 = () => { + this.lastHotkey = 'Mod+2' + this.activeTab = 2 + } + + @hotkey('Mod+3') + private _tab3 = () => { + this.lastHotkey = 'Mod+3' + this.activeTab = 3 + } + + @hotkey('Mod+4') + private _tab4 = () => { + this.lastHotkey = 'Mod+4' + this.activeTab = 4 + } + + @hotkey('Mod+5') + private _tab5 = () => { + this.lastHotkey = 'Mod+5' + this.activeTab = 5 + } + + // Navigation + @hotkey('Shift+ArrowUp') + private _navUp = () => { + this.lastHotkey = 'Shift+ArrowUp' + this.navigationCount++ + } + + @hotkey('Shift+ArrowDown') + private _navDown = () => { + this.lastHotkey = 'Shift+ArrowDown' + this.navigationCount++ + } + + @hotkey('Alt+ArrowLeft') + private _navLeft = () => { + this.lastHotkey = 'Alt+ArrowLeft' + this.navigationCount++ + } + + @hotkey('Alt+ArrowRight') + private _navRight = () => { + this.lastHotkey = 'Alt+ArrowRight' + this.navigationCount++ + } + + @hotkey('Mod+Home') + private _navHome = () => { + this.lastHotkey = 'Mod+Home' + this.navigationCount++ + } + + @hotkey('Mod+End') + private _navEnd = () => { + this.lastHotkey = 'Mod+End' + this.navigationCount++ + } + + @hotkey('Control+PageUp') + private _pageUp = () => { + this.lastHotkey = 'Control+PageUp' + this.navigationCount++ + } + + @hotkey('Control+PageDown') + private _pageDown = () => { + this.lastHotkey = 'Control+PageDown' + this.navigationCount++ + } + + // Function keys + @hotkey('Meta+F4') + private _f4 = () => { + this.lastHotkey = 'Meta+F4' + this.functionKeyCount++ + alert('Meta+F4 pressed (normally closes window)') + } + + @hotkey('Control+F5') + private _f5 = () => { + this.lastHotkey = 'Control+F5' + this.functionKeyCount++ + } + + @hotkey('Mod+F1') + private _f1 = () => { + this.lastHotkey = 'Mod+F1' + this.functionKeyCount++ + } + + @hotkey('Shift+F10') + private _f10 = () => { + this.lastHotkey = 'Shift+F10' + this.functionKeyCount++ + } + + @hotkey('F12') + private _f12 = () => { + this.lastHotkey = 'F12' + this.functionKeyCount++ + } + + // Multi-modifier + @hotkey('Mod+Shift+S') + private _modShiftS = () => { + this.lastHotkey = 'Mod+Shift+S' + this.multiModifierCount++ + } + + @hotkey('Mod+Shift+Z') + private _modShiftZ = () => { + this.lastHotkey = 'Mod+Shift+Z' + this.multiModifierCount++ + } + + @hotkey({ key: 'A', ctrl: true, alt: true }) + private _ctrlAltA = () => { + this.lastHotkey = 'Control+Alt+A' + this.multiModifierCount++ + } + + @hotkey('Control+Shift+N') + private _ctrlShiftN = () => { + this.lastHotkey = 'Control+Shift+N' + this.multiModifierCount++ + } + + @hotkey('Mod+Alt+T') + private _modAltT = () => { + this.lastHotkey = 'Mod+Alt+T' + this.multiModifierCount++ + } + + @hotkey('Control+Alt+Shift+X') + private _ctrlAltShiftX = () => { + this.lastHotkey = 'Control+Alt+Shift+X' + this.multiModifierCount++ + } + + // Editing keys + @hotkey('Mod+Enter') + private _modEnter = () => { + this.lastHotkey = 'Mod+Enter' + this.editingKeyCount++ + } + + @hotkey('Shift+Enter') + private _shiftEnter = () => { + this.lastHotkey = 'Shift+Enter' + this.editingKeyCount++ + } + + @hotkey('Mod+Backspace') + private _modBackspace = () => { + this.lastHotkey = 'Mod+Backspace' + this.editingKeyCount++ + } + + @hotkey('Mod+Delete') + private _modDelete = () => { + this.lastHotkey = 'Mod+Delete' + this.editingKeyCount++ + } + + @hotkey('Control+Tab') + private _ctrlTab = () => { + this.lastHotkey = 'Control+Tab' + this.editingKeyCount++ + } + + @hotkey('Shift+Tab') + private _shiftTab = () => { + this.lastHotkey = 'Shift+Tab' + this.editingKeyCount++ + } + + @hotkey('Mod+Space') + private _modSpace = () => { + this.lastHotkey = 'Mod+Space' + this.editingKeyCount++ + } + + // Escape: reset all + @hotkey({ key: 'Escape' }) + private _escape = () => { + this.lastHotkey = null + this.saveCount = 0 + this.incrementCount = 0 + this.navigationCount = 0 + this.functionKeyCount = 0 + this.multiModifierCount = 0 + this.editingKeyCount = 0 + this.sidebarShortcutCount = 0 + this.modalShortcutCount = 0 + this.editorShortcutCount = 0 + this.activeTab = 1 + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-app': MyApp + } +} diff --git a/examples/lit/hotkey/src/index.css b/examples/lit/hotkey/src/index.css new file mode 100644 index 00000000..e9f3ca9d --- /dev/null +++ b/examples/lit/hotkey/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/lit/hotkey/tsconfig.json b/examples/lit/hotkey/tsconfig.json new file mode 100644 index 00000000..168d61a2 --- /dev/null +++ b/examples/lit/hotkey/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/lit/key-hold/index.html b/examples/lit/key-hold/index.html new file mode 100644 index 00000000..463bcd33 --- /dev/null +++ b/examples/lit/key-hold/index.html @@ -0,0 +1,12 @@ + + + + + + key-hold - TanStack Hotkey Lit example + + + + + + diff --git a/examples/lit/key-hold/package.json b/examples/lit/key-hold/package.json new file mode 100644 index 00000000..3eb3e440 --- /dev/null +++ b/examples/lit/key-hold/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/hotkeys-example-lit-key-hold", + "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/lit-hotkeys": "0.8.3", + "lit": "^3.3.2" + }, + "devDependencies": { + "typescript": "6.0.2", + "vite": "^8.0.3" + } +} diff --git a/examples/lit/key-hold/src/app.ts b/examples/lit/key-hold/src/app.ts new file mode 100644 index 00000000..8a6dc298 --- /dev/null +++ b/examples/lit/key-hold/src/app.ts @@ -0,0 +1,154 @@ +import { LitElement, css, html, unsafeCSS } from 'lit' +import { customElement } from 'lit/decorators.js' + +import { KeyHoldController } from '@tanstack/lit-hotkeys' + +import appStyles from './index.css?raw' + +@customElement('my-app') +export class MyApp extends LitElement { + static styles = [ + css` + :host { + display: block; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; + background: #f5f5f5; + color: #333; + box-sizing: border-box; + } + :host *, + :host *::before, + :host *::after { + box-sizing: border-box; + } + `, + unsafeCSS(appStyles), + ] + + private shiftHold = new KeyHoldController(this, 'Shift') + private controlHold = new KeyHoldController(this, 'Control') + private altHold = new KeyHoldController(this, 'Alt') + private metaHold = new KeyHoldController(this, 'Meta') + private spaceHold = new KeyHoldController(this, 'Space') + + render() { + return html` +
+
+

KeyHoldController

+

+ Returns a boolean indicating if a specific key is currently held. + Optimized to only re-render when that specific key changes. +

+
+ +
+
+

Modifier Key States

+
+
+ Shift + + ${this.shiftHold.value ? 'HELD' : 'Released'} + +
+
+ Control + + ${this.controlHold.value ? 'HELD' : 'Released'} + +
+
+ Alt / Option + + ${this.altHold.value ? 'HELD' : 'Released'} + +
+
+ Meta (⌘ / ⊞) + + ${this.metaHold.value ? 'HELD' : 'Released'} + +
+
+
+ +
+

Space Bar Demo

+
+ ${this.spaceHold.value ? '🚀 SPACE HELD!' : 'Hold Space Bar'} +
+
+ +
+

Usage

+
+${`import { KeyHoldController } from '@tanstack/lit-hotkeys'
+
+class ShiftIndicator extends LitElement {
+  private shiftHold = new KeyHoldController(this, 'Shift')
+
+  render() {
+    return html\`
+      
+ \${this.shiftHold.value ? 'Shift is pressed!' : 'Press Shift'} +
+ \` + } +}`}
+
+ +
+

Conditional UI Example

+

Hold Shift to reveal the secret message:

+
+ ${this.shiftHold.value + ? html`🎉 The secret password is: tanstack-hotkeys-rocks!` + : html`••••••••••••••••••••••••••`} +
+
+ +
+

Use Cases

+
    +
  • Show different UI based on modifier state
  • +
  • Enable "power user" mode while holding a key
  • +
  • Hold-to-reveal sensitive information
  • +
  • Drag-and-drop with modifier behaviors
  • +
  • Show additional options on hover + modifier
  • +
+
+
+
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'my-app': MyApp + } +} diff --git a/examples/lit/key-hold/src/index.css b/examples/lit/key-hold/src/index.css new file mode 100644 index 00000000..21f27708 --- /dev/null +++ b/examples/lit/key-hold/src/index.css @@ -0,0 +1,126 @@ +* { + 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; + 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 p { + margin: 0 0 12px; +} +.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; +} +.modifier-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.modifier-indicator { + background: #f0f0f0; + border: 2px solid #ddd; + border-radius: 12px; + padding: 20px; + text-align: center; + transition: all 0.15s ease; +} +.modifier-indicator.active { + background: #4caf50; + border-color: #388e3c; + color: white; + transform: scale(1.02); +} +.modifier-indicator .key-name { + display: block; + font-weight: bold; + font-size: 18px; + margin-bottom: 8px; +} +.modifier-indicator .status { + font-size: 14px; + opacity: 0.8; +} +.space-indicator { + background: #f0f0f0; + border: 3px solid #ddd; + border-radius: 16px; + padding: 40px; + text-align: center; + font-size: 24px; + transition: all 0.15s ease; +} +.space-indicator.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-color: #5a67d8; + color: white; + transform: scale(1.02); +} +.secret-box { + background: #f0f0f0; + border-radius: 8px; + padding: 20px; + text-align: center; + font-family: monospace; + font-size: 16px; + transition: all 0.3s ease; +} +.secret-box.revealed { + background: #e8f5e9; + color: #2e7d32; +} +.code-block { + background: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; +} diff --git a/examples/lit/key-hold/tsconfig.json b/examples/lit/key-hold/tsconfig.json new file mode 100644 index 00000000..1b43085f --- /dev/null +++ b/examples/lit/key-hold/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/preact/useHotkey/src/index.tsx b/examples/preact/useHotkey/src/index.tsx index 8ef17839..ed9d2c36 100644 --- a/examples/preact/useHotkey/src/index.tsx +++ b/examples/preact/useHotkey/src/index.tsx @@ -349,14 +349,10 @@ function App() { { target: editorRefForHotkey }, ) - useHotkey( - 'J', - () => { - setLastHotkey('J') - setEditorShortcutCount((c) => c + 1) - }, - { target: editorRefForHotkey }, - ) + useHotkey('J', () => { + setLastHotkey('J') + setEditorShortcutCount((c) => c + 1) + }) return (
diff --git a/examples/react/useHotkey/src/index.tsx b/examples/react/useHotkey/src/index.tsx index 336c0109..2ea7c18d 100644 --- a/examples/react/useHotkey/src/index.tsx +++ b/examples/react/useHotkey/src/index.tsx @@ -349,14 +349,10 @@ function App() { { target: editorRefForHotkey }, ) - useHotkey( - 'J', - () => { - setLastHotkey('J') - setEditorShortcutCount((c) => c + 1) - }, - { target: editorRefForHotkey }, - ) + useHotkey('J', () => { + setLastHotkey('J') + setEditorShortcutCount((c) => c + 1) + }) return (
diff --git a/examples/solid/createHotkey/src/index.tsx b/examples/solid/createHotkey/src/index.tsx index bae58b42..4756e96f 100644 --- a/examples/solid/createHotkey/src/index.tsx +++ b/examples/solid/createHotkey/src/index.tsx @@ -275,14 +275,10 @@ function App() { }, () => ({ target: editorRef() }), ) - createHotkey( - 'J', - () => { - setLastHotkey('J') - setEditorShortcutCount((c) => c + 1) - }, - () => ({ target: editorRef() }), - ) + createHotkey('J', () => { + setLastHotkey('J') + setEditorShortcutCount((c) => c + 1) + }) return (
diff --git a/examples/vue/useHotkey/src/App.vue b/examples/vue/useHotkey/src/App.vue index e95b9338..0b3619f2 100644 --- a/examples/vue/useHotkey/src/App.vue +++ b/examples/vue/useHotkey/src/App.vue @@ -277,14 +277,10 @@ useHotkey( { target: editorRef }, ) -useHotkey( - 'J', - () => { - lastHotkey.value = 'J' - editorShortcutCount.value++ - }, - { target: editorRef }, -) +useHotkey('J', () => { + lastHotkey.value = 'J' + editorShortcutCount.value++ +}) const basicCode = `useHotkey('Mod+S', (_event, { hotkey, parsedHotkey }) => { console.log('Hotkey:', hotkey) diff --git a/package.json b/package.json index b568b5bc..dfa1e938 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "clean": "find . -name 'dist' -type d -prune -exec rm -rf {} +", "clean:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf {} +", "clean:all": "pnpm run clean && pnpm run clean:node_modules", - "copy:readme": "cp README.md packages/hotkeys/README.md && cp README.md packages/hotkeys-devtools/README.md && cp README.md packages/angular-hotkeys/README.md && cp README.md packages/react-hotkeys/README.md && cp README.md packages/react-hotkeys-devtools/README.md && cp README.md packages/preact-hotkeys/README.md && cp README.md packages/preact-hotkeys-devtools/README.md && cp README.md packages/solid-hotkeys/README.md && cp README.md packages/solid-hotkeys-devtools/README.md && cp README.md packages/vue-hotkeys/README.md && cp README.md packages/vue-hotkeys-devtools/README.md && cp README.md packages/svelte-hotkeys/README.md", + "copy:readme": "cp README.md packages/hotkeys/README.md && cp README.md packages/hotkeys-devtools/README.md && cp README.md packages/angular-hotkeys/README.md && cp README.md packages/react-hotkeys/README.md && cp README.md packages/react-hotkeys-devtools/README.md && cp README.md packages/preact-hotkeys/README.md && cp README.md packages/preact-hotkeys-devtools/README.md && cp README.md packages/solid-hotkeys/README.md && cp README.md packages/solid-hotkeys-devtools/README.md && cp README.md packages/vue-hotkeys/README.md && cp README.md packages/vue-hotkeys-devtools/README.md && cp README.md packages/lit-hotkeys/README.md && cp README.md packages/svelte-hotkeys/README.md", "dev": "pnpm run watch", "format": "prettier --experimental-cli --ignore-unknown '**/*' --write", "generate-docs": "node scripts/generate-docs.ts && pnpm run copy:readme", @@ -50,6 +50,11 @@ "test:sherif" ] }, + "pnpm": { + "overrides": { + "rolldown": "1.0.0-rc.12" + } + }, "devDependencies": { "@changesets/cli": "^2.30.0", "@faker-js/faker": "^10.4.0", @@ -80,6 +85,8 @@ "@tanstack/angular-hotkeys": "workspace:*", "@tanstack/hotkeys": "workspace:*", "@tanstack/hotkeys-devtools": "workspace:*", + "@tanstack/lit-hotkeys": "workspace:*", + "@tanstack/lit-hotkeys-devtools": "workspace:*", "@tanstack/preact-hotkeys": "workspace:*", "@tanstack/preact-hotkeys-devtools": "workspace:*", "@tanstack/react-hotkeys": "workspace:*", @@ -89,5 +96,8 @@ "@tanstack/svelte-hotkeys": "workspace:*", "@tanstack/vue-hotkeys": "workspace:*", "@tanstack/vue-hotkeys-devtools": "workspace:*" + }, + "volta": { + "node": "24.14.1" } } diff --git a/packages/angular-hotkeys/README.md b/packages/angular-hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/angular-hotkeys/README.md +++ b/packages/angular-hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/hotkeys-devtools/README.md b/packages/hotkeys-devtools/README.md index 7c581ae2..c0473686 100644 --- a/packages/hotkeys-devtools/README.md +++ b/packages/hotkeys-devtools/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/hotkeys/README.md b/packages/hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/hotkeys/README.md +++ b/packages/hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/hotkeys/src/hotkey-manager.ts b/packages/hotkeys/src/hotkey-manager.ts index 01a60da3..448244c1 100644 --- a/packages/hotkeys/src/hotkey-manager.ts +++ b/packages/hotkeys/src/hotkey-manager.ts @@ -5,11 +5,10 @@ import { parseHotkey, rawHotkeyToParsedHotkey } from './parse' import { matchesKeyboardEvent } from './match' import { defaultHotkeyOptions, - getActiveElementForListenerTarget, getDefaultIgnoreInputs, handleConflict, isEventForTarget, - isInputElement, + shouldIgnoreInputEvent, } from './manager.utils' import type { ConflictBehavior } from './manager.utils' import type { @@ -457,11 +456,7 @@ export class HotkeyManager { // Check if we should ignore input elements (defaults to true) if (registration.options.ignoreInputs !== false) { - const focused = getActiveElementForListenerTarget(target) - const shouldIgnore = [focused, event.target].some( - (el) => isInputElement(el) && el !== registration.target, - ) - if (shouldIgnore) { + if (shouldIgnoreInputEvent(event, target, registration.target)) { continue } } diff --git a/packages/hotkeys/src/manager.utils.ts b/packages/hotkeys/src/manager.utils.ts index 03fc71eb..a8c01b7b 100644 --- a/packages/hotkeys/src/manager.utils.ts +++ b/packages/hotkeys/src/manager.utils.ts @@ -104,6 +104,38 @@ export function getActiveElementForListenerTarget( return (target as Window).document.activeElement ?? null } +/** + * Returns whether an event should be ignored because it originated from an + * input-like element other than the registration target. + * + * This checks: + * - the currently focused element for the listener target + * - the event's composed path (for shadow DOM) + * - the event target as a final fallback + */ +export function shouldIgnoreInputEvent( + event: KeyboardEvent, + listenerTarget: HTMLElement | Document | Window, + registrationTarget: HTMLElement | Document | Window, +): boolean { + const focused = getActiveElementForListenerTarget(listenerTarget) + if (focused && isInputElement(focused) && focused !== registrationTarget) { + return true + } + + if ( + event + .composedPath() + .some( + (element) => isInputElement(element) && element !== registrationTarget, + ) + ) { + return true + } + + return isInputElement(event.target) && event.target !== registrationTarget +} + /** * Checks if an event is for the given target (originated from or bubbled to it). * diff --git a/packages/hotkeys/src/sequence-manager.ts b/packages/hotkeys/src/sequence-manager.ts index da8f7312..05c0bed7 100644 --- a/packages/hotkeys/src/sequence-manager.ts +++ b/packages/hotkeys/src/sequence-manager.ts @@ -5,11 +5,10 @@ import { isModifierKey, parseHotkey } from './parse' import { matchesKeyboardEvent } from './match' import { defaultHotkeyOptions, - getActiveElementForListenerTarget, getDefaultIgnoreInputs, handleConflict, isEventForTarget, - isInputElement, + shouldIgnoreInputEvent, } from './manager.utils' import type { HotkeyOptions } from './hotkey-manager' import type { @@ -446,7 +445,7 @@ export class SequenceManager { const now = Date.now() - for (const id of targetRegs) { + registrationIds: for (const id of targetRegs) { const registration = this.#registrations.get(id) if (!registration) { continue @@ -462,12 +461,8 @@ export class SequenceManager { // Check if we should ignore input elements (defaults to true) if (registration.options.ignoreInputs !== false) { - const focused = getActiveElementForListenerTarget(target) - const shouldIgnore = [focused, event.target].some( - (el) => isInputElement(el) && el !== registration.target, - ) - if (shouldIgnore) { - continue + if (shouldIgnoreInputEvent(event, target, registration.target)) { + continue registrationIds } } diff --git a/packages/hotkeys/tests/hotkey-manager.test.ts b/packages/hotkeys/tests/hotkey-manager.test.ts index 2fcd0759..d3de31ef 100644 --- a/packages/hotkeys/tests/hotkey-manager.test.ts +++ b/packages/hotkeys/tests/hotkey-manager.test.ts @@ -850,6 +850,34 @@ describe('HotkeyManager', () => { document.body.removeChild(input) }) + it('should ignore single-key hotkeys when typing in a shadow-dom textarea', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register('K', callback, { + platform: 'mac', + }) + + const host = document.createElement('div') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const textarea = document.createElement('textarea') + shadowRoot.appendChild(textarea) + document.body.appendChild(host) + + textarea.focus() + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'k', + bubbles: true, + composed: true, + }), + ) + + expect(callback).not.toHaveBeenCalled() + + document.body.removeChild(host) + }) + it('should fire hotkeys when typing in non-input elements', () => { const manager = HotkeyManager.getInstance() const callback = vi.fn() diff --git a/packages/hotkeys/tests/sequence-manager.test.ts b/packages/hotkeys/tests/sequence-manager.test.ts index f8021ab7..d2cc73b6 100644 --- a/packages/hotkeys/tests/sequence-manager.test.ts +++ b/packages/hotkeys/tests/sequence-manager.test.ts @@ -301,6 +301,39 @@ describe('SequenceManager', () => { document.body.removeChild(textarea) }) + it('should ignore single-key sequences in a shadow-dom textarea by default', () => { + const manager = SequenceManager.getInstance() + const callback = vi.fn() + + manager.register(['G', 'G'], callback) + + const host = document.createElement('div') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const textarea = document.createElement('textarea') + shadowRoot.appendChild(textarea) + document.body.appendChild(host) + + textarea.focus() + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'g', + bubbles: true, + composed: true, + }), + ) + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'g', + bubbles: true, + composed: true, + }), + ) + + expect(callback).not.toHaveBeenCalled() + + document.body.removeChild(host) + }) + it('should ignore single-key sequences in contenteditable elements by default', () => { const manager = SequenceManager.getInstance() const callback = vi.fn() diff --git a/packages/lit-hotkeys/CHANGELOG.md b/packages/lit-hotkeys/CHANGELOG.md new file mode 100644 index 00000000..8f225296 --- /dev/null +++ b/packages/lit-hotkeys/CHANGELOG.md @@ -0,0 +1,7 @@ +# @tanstack/react-hotkeys + +## 0.9.0 + +### Minor Changes + +- add lit adapter and upgrade packages ([#59](https://github.com/TanStack/hotkeys/pull/59)) diff --git a/packages/lit-hotkeys/README.md b/packages/lit-hotkeys/README.md new file mode 100644 index 00000000..c0473686 --- /dev/null +++ b/packages/lit-hotkeys/README.md @@ -0,0 +1,123 @@ +
+ TanStack Hotkeys +
+ +
+ + + + + +
+ +### [Become a Sponsor!](https://github.com/sponsors/tannerlinsley/) + +
+ +# TanStack Hotkeys + +> [!NOTE] +> TanStack Hotkeys is alpha. We are actively developing the library and are open to feedback and contributions. + +Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objects, a cross-platform `Mod` key, a singleton Hotkey Manager, and utilities for cheatsheet UIs—built to stay SSR-friendly. + +- Type-safe bindings — template strings (`Mod+Shift+S`, `Escape`) or parsed objects for full control +- Flexible options — `keydown`/`keyup`, `preventDefault`, `stopPropagation`, conditional enabled, `requireReset` +- Cross-platform Mod — maps to Cmd on macOS and Ctrl on Windows/Linux +- Batteries included — validation + matching, sequences (Vim-style), key-state tracking, recorder UI helpers, framework adapters, and devtools + +### Read the docs → + +
+ +> [!NOTE] +> You may know **TanStack Hotkeys** by our adapter names, too! +> +> - [**React Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/react/react-hotkeys) +> - [**Preact Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/preact/preact-hotkeys) +> - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) +> - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) +> - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) +> - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) + +## Get Involved + +- We welcome issues and pull requests! +- Participate in [GitHub discussions](https://github.com/TanStack/hotkeys/discussions) +- Chat with the community on [Discord](https://discord.com/invite/WrRKjPJ) +- See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup instructions + +## Partners + +
+ + + + + + +
+ + + + + CodeRabbit + + + + + + + + Cloudflare + + +
+ +
+Keys & you? +

+We're looking for TanStack Hotkeys Partners to join our mission! Partner with us to push the boundaries of TanStack Hotkeys and build amazing things together. +

+LET'S CHAT +
+ +
+ +## Explore the TanStack Ecosystem + +- TanStack Config – Tooling for JS/TS packages +- TanStack DB – Reactive sync client store +- TanStack DevTools – Unified devtools panel +- TanStack Form – Type‑safe form state +- TanStack Hotkeys – Type‑safe keyboard shortcuts +- TanStack Query – Async state & caching +- TanStack Ranger – Range & slider primitives +- TanStack Router – Type‑safe routing, caching & URL state +- TanStack Start – Full‑stack SSR & streaming +- TanStack Store – Reactive data store +- TanStack Table – Headless datagrids +- TanStack Virtual – Virtualized rendering + +… and more at TanStack.com » diff --git a/packages/lit-hotkeys/eslint.config.js b/packages/lit-hotkeys/eslint.config.js new file mode 100644 index 00000000..e472c69e --- /dev/null +++ b/packages/lit-hotkeys/eslint.config.js @@ -0,0 +1,10 @@ +// @ts-check + +import rootConfig from '../../eslint.config.js' + +export default [ + ...rootConfig, + { + rules: {}, + }, +] diff --git a/packages/lit-hotkeys/package.json b/packages/lit-hotkeys/package.json new file mode 100644 index 00000000..d06db1a1 --- /dev/null +++ b/packages/lit-hotkeys/package.json @@ -0,0 +1,60 @@ +{ + "name": "@tanstack/lit-hotkeys", + "version": "0.8.3", + "description": "Lit adapter for TanStack Hotkeys", + "author": "Tanner Linsley", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/hotkeys.git", + "directory": "packages/lit-hotkeys" + }, + "homepage": "https://tanstack.com/hotkeys", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "keywords": [ + "lit", + "tanstack", + "keys" + ], + "scripts": { + "clean": "premove ./build ./dist", + "lint": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc", + "build": "tsdown" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@tanstack/hotkeys": "workspace:*" + }, + "devDependencies": { + "lit": "^3.3.2" + }, + "peerDependencies": { + "lit": ">=3.0.0" + } +} diff --git a/packages/lit-hotkeys/src/constants.ts b/packages/lit-hotkeys/src/constants.ts new file mode 100644 index 00000000..831c5f34 --- /dev/null +++ b/packages/lit-hotkeys/src/constants.ts @@ -0,0 +1,23 @@ +import type { HotkeyOptions, SequenceOptions } from '@tanstack/hotkeys' + +export const HOTKEY_DEFAULT_OPTIONS: HotkeyOptions = { + enabled: true, + preventDefault: true, + stopPropagation: true, + eventType: 'keydown', + requireReset: false, + ignoreInputs: undefined, // smart default: false for Mod+S, true for single keys + platform: undefined, // auto-detected + conflictBehavior: 'warn', +} + +export const HOTKEY_SEQUENCE_DEFAULT_OPTIONS: SequenceOptions = { + enabled: true, + timeout: 1000, + preventDefault: true, + stopPropagation: true, + eventType: 'keydown', + ignoreInputs: undefined, // smart default: false for Mod+S, true for single keys + platform: undefined, // auto-detected + conflictBehavior: 'warn', +} diff --git a/packages/lit-hotkeys/src/controllers/held-key-codes.ts b/packages/lit-hotkeys/src/controllers/held-key-codes.ts new file mode 100644 index 00000000..f08feecc --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/held-key-codes.ts @@ -0,0 +1,67 @@ +import { getKeyStateTracker } from '@tanstack/hotkeys' +import type { KeyStateTracker } from '@tanstack/hotkeys' +import type { ReactiveController, ReactiveControllerHost } from 'lit' + +/** + * A Lit ReactiveController that tracks all currently held key names to their physical `event.code` values. + * + * Subscribes to the global KeyStateTracker and triggers host updates + * whenever keys are pressed or released. + * + * @example + * ```ts + * class KeyDisplay extends LitElement { + * private heldKeyCodes = new HeldKeyCodesController(this) + * + * render() { + * const heldCodes = Object.entries(this.heldKeyCodes.value).map(([key, code]) => `${key}: ${code}`).join(' + ') + * return html` + *
+ * Currently pressed: ${heldCodes || 'None'} + *
+ * ` + * } + * } + * ``` + */ +export class HeldKeyCodesController implements ReactiveController { + /** The unsubscribe function to unsubscribe from the tracker store. */ + private _unsubscribe: (() => void) | undefined + /** The currently held key names to their physical `event.code` values. */ + private _value: Record = {} + + /** Map of currently held key names to their physical `event.code` values. */ + public get value(): Record { + return this._value + } + + /** + * @param _host - The Lit component that owns this controller. + */ + constructor(private _host: ReactiveControllerHost) { + this._host.addController(this) + } + + /** Subscribes to the tracker store and updates the internal state when changes occur. */ + public hostConnected(): void { + const tracker: KeyStateTracker = getKeyStateTracker() + this._value = tracker.store.state.heldCodes + this._host.requestUpdate() + + const subscription = tracker.store.subscribe(() => { + const { heldCodes } = tracker.store.state + + this._value = heldCodes + this._host.requestUpdate() + }) + + this._unsubscribe = () => subscription.unsubscribe() + } + + /** Unsubscribes from the tracker store and stops tracking the held key codes. */ + public hostDisconnected(): void { + this._unsubscribe?.() + this._unsubscribe = undefined + this._value = {} + } +} diff --git a/packages/lit-hotkeys/src/controllers/held-keys.ts b/packages/lit-hotkeys/src/controllers/held-keys.ts new file mode 100644 index 00000000..db3c5bb1 --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/held-keys.ts @@ -0,0 +1,64 @@ +import { getKeyStateTracker } from '@tanstack/hotkeys' +import type { KeyStateTracker } from '@tanstack/hotkeys' +import type { ReactiveController, ReactiveControllerHost } from 'lit' + +/** + * A Lit ReactiveController that tracks all currently held keyboard keys. + * + * Subscribes to the global KeyStateTracker and triggers host updates + * whenever keys are pressed or released. + * + * @example + * ```ts + * class KeyDisplay extends LitElement { + * private heldKeys = new HeldKeysController(this) + * + * render() { + * return html` + *
+ * Currently pressed: ${this.heldKeys.value.join(' + ') || 'None'} + *
+ * ` + * } + * } + * ``` + */ +export class HeldKeysController implements ReactiveController { + /** The unsubscribe function to unsubscribe from the tracker store. */ + private _unsubscribe: (() => void) | undefined + /** The currently held key names. */ + private _value: Array = [] + + /** Array of currently held key names. */ + public get value(): Array { + return this._value + } + + /** + * @param _host - The Lit component that owns this controller. + */ + constructor(private _host: ReactiveControllerHost) { + this._host.addController(this) + } + + /** Subscribes to the tracker store and updates the internal state when changes occur. */ + public hostConnected(): void { + const tracker: KeyStateTracker = getKeyStateTracker() + + const subscription = tracker.store.subscribe(() => { + const { heldKeys } = tracker.store.state + + this._value = heldKeys + this._host.requestUpdate() + }) + + this._unsubscribe = () => subscription.unsubscribe() + } + + /** Unsubscribes from the tracker store and stops tracking the held keys. */ + public hostDisconnected(): void { + this._unsubscribe?.() + this._unsubscribe = undefined + this._value = [] + } +} diff --git a/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts b/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts new file mode 100644 index 00000000..188b8fb7 --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/hotkey-recorder.ts @@ -0,0 +1,120 @@ +import { HotkeyRecorder } from '@tanstack/hotkeys' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import type { Hotkey, HotkeyRecorderOptions } from '@tanstack/hotkeys' + +/** + * A Lit ReactiveController that records keyboard shortcuts. + * + * Wraps 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. + * + * @example + * ```ts + * class ShortcutSettings extends LitElement { + * private recorder = new HotkeyRecorderController(this, { + * onRecord: (hotkey) => { + * this.shortcut = hotkey + * this.requestUpdate() + * }, + * onCancel: () => { + * console.log('Recording cancelled') + * }, + * }) + * + * private shortcut: Hotkey | null = null + * + * render() { + * return html` + * + * ${this.recorder.recordedHotkey + * ? html`
Recording: ${this.recorder.recordedHotkey}
` + * : nothing} + * ` + * } + * } + * ``` + */ +export class HotkeyRecorderController implements ReactiveController { + /** The recorder instance. */ + private _recorder: HotkeyRecorder + /** The unsubscribe function to unsubscribe from the recorder store. */ + private _unsubscribe: (() => void) | undefined + /** Whether recording is currently active. */ + private _isRecording = false + /** The currently recorded hotkey (for live preview). */ + private _recordedHotkey: Hotkey | null = null + + /** Whether recording is currently active. */ + public get isRecording(): boolean { + return this._isRecording + } + + /** The currently recorded hotkey (for live preview). */ + public get recordedHotkey(): Hotkey | null { + return this._recordedHotkey + } + + /** + * @param _host - The Lit component that owns this controller. + * @param _options - Configuration options for the recorder. + */ + constructor( + private _host: ReactiveControllerHost, + private _options: HotkeyRecorderOptions, + ) { + this._recorder = new HotkeyRecorder(_options) + this._host.addController(this) + } + + /** Subscribes to the recorder store and updates the internal state when changes occur. */ + public hostConnected(): void { + const subscription = this._recorder.store.subscribe(() => { + const { isRecording, recordedHotkey } = this._recorder.store.state + + const hasChanged: boolean = + isRecording !== this._isRecording || + recordedHotkey !== this._recordedHotkey + + if (hasChanged) { + this._isRecording = isRecording + this._recordedHotkey = recordedHotkey + this._host.requestUpdate() + } + }) + + this._unsubscribe = () => subscription.unsubscribe() + } + + /** Unsubscribes from the recorder store and destroys the recorder instance to prevent memory leaks. */ + public hostDisconnected(): void { + this._unsubscribe?.() + this._unsubscribe = undefined + this._recorder.stop() + this._isRecording = false + this._recordedHotkey = null + } + + /** Updates the recorder options (e.g. callbacks). */ + public setOptions(options: Partial): void { + this._options = { ...this._options, ...options } + this._recorder.setOptions(this._options) + } + + /** Start recording a new hotkey. */ + public startRecording(): void { + this._recorder.start() + } + + /** Stop recording (same as cancel but without calling onCancel). */ + public stopRecording(): void { + this._recorder.stop() + } + + /** Cancel recording without saving. */ + public cancelRecording(): void { + this._recorder.cancel() + } +} diff --git a/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts b/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts new file mode 100644 index 00000000..d8724057 --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/hotkey-sequence-recorder.ts @@ -0,0 +1,141 @@ +import { HotkeySequenceRecorder } from '@tanstack/hotkeys' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import type { + HotkeySequence, + HotkeySequenceRecorderOptions, +} from '@tanstack/hotkeys' + +/** + * A Lit ReactiveController that records multi-chord sequences (Vim-style shortcuts). + * + * Wraps the framework-agnostic `HotkeySequenceRecorder` class, managing store + * subscriptions and host update lifecycle automatically. + * + * @example + * ```ts + * class ShortcutSettings extends LitElement { + * private recorder = new HotkeySequenceRecorderController(this, { + * onRecord: (sequence) => { + * this.sequence = sequence + * this.requestUpdate() + * }, + * onCancel: () => { + * console.log('Recording cancelled') + * }, + * }) + * + * private sequence: HotkeySequence | null = null + * + * render() { + * return html` + * + * ${this.recorder.steps.length + * ? html`
Steps: ${this.recorder.steps.join(' → ')}
` + * : nothing} + * ${this.recorder.recordedSequence + * ? html`
Recorded: ${this.recorder.recordedSequence.join(' → ')}
` + * : nothing} + * ` + * } + * } + * ``` + */ +export class HotkeySequenceRecorderController implements ReactiveController { + /** The recorder instance. */ + private _recorder: HotkeySequenceRecorder + /** The unsubscribe function. */ + private _unsubscribe: (() => void) | undefined + /** Whether recording is currently active. */ + private _isRecording = false + /** Chords captured in the current session. */ + private _steps: HotkeySequence = [] + /** Last committed sequence, or null if none. */ + private _recordedSequence: HotkeySequence | null = null + + /** Whether recording is currently active. */ + public get isRecording(): boolean { + return this._isRecording + } + + /** Chords captured in the current session. */ + public get steps(): HotkeySequence { + return this._steps + } + + /** Last committed sequence, or null if none. */ + public get recordedSequence(): HotkeySequence | null { + return this._recordedSequence + } + + /** + * @param _host - The Lit component that owns this controller. + * @param _options - Configuration options for the sequence recorder. + */ + constructor( + private _host: ReactiveControllerHost, + private _options: HotkeySequenceRecorderOptions, + ) { + this._recorder = new HotkeySequenceRecorder(_options) + this._host.addController(this) + } + + /** Subscribes to the recorder store and updates internal state when changes occur. */ + public hostConnected(): void { + const subscription = this._recorder.store.subscribe(() => { + const { isRecording, steps, recordedSequence } = + this._recorder.store.state + + const hasChanged: boolean = + isRecording !== this._isRecording || + steps !== this._steps || + recordedSequence !== this._recordedSequence + + if (hasChanged) { + this._isRecording = isRecording + this._steps = steps + this._recordedSequence = recordedSequence + this._host.requestUpdate() + } + }) + + this._unsubscribe = () => subscription.unsubscribe() + } + + /** Unsubscribes from the recorder store and destroys the recorder instance to prevent memory leaks. */ + public hostDisconnected(): void { + this._unsubscribe?.() + this._unsubscribe = undefined + this._recorder.stop() + this._isRecording = false + this._steps = [] + this._recordedSequence = null + } + + /** Updates the recorder options. */ + public setOptions(options: Partial): void { + this._options = { ...this._options, ...options } + this._recorder.setOptions(this._options) + } + + /** Start recording a new sequence. */ + public startRecording(): void { + this._recorder.start() + } + + /** Stop recording (same as cancel but without calling onCancel). */ + public stopRecording(): void { + this._recorder.stop() + } + + /** Cancel recording without saving. */ + public cancelRecording(): void { + this._recorder.cancel() + } + + /** Commit current steps as a sequence (no-op if empty). */ + public commitRecording(): void { + this._recorder.commit() + } +} diff --git a/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts b/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts new file mode 100644 index 00000000..f1e289c9 --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/hotkey-sequence.ts @@ -0,0 +1,82 @@ +import { getSequenceManager } from '@tanstack/hotkeys' +import { HOTKEY_SEQUENCE_DEFAULT_OPTIONS } from '../constants' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import type { + HotkeyCallback, + HotkeySequence, + SequenceManager, + SequenceOptions, + SequenceRegistrationHandle, +} from '@tanstack/hotkeys' + +/** + * A Lit ReactiveController that registers a keyboard sequence (e.g. Vim-style) + * when the host element is connected and unregisters it when the host is disconnected. + * + * @example + * ```ts + * class MyElement extends LitElement { + * private seq = new HotkeySequenceController(this, ['G', 'G'], () => this.goToTop()) + * + * constructor() { + * super() + * this.addController(this.seq) + * } + * } + * ``` + */ +export class HotkeySequenceController implements ReactiveController { + /** The sequence registration handle. */ + private _registration: SequenceRegistrationHandle | undefined + + /** + * @param _host - The Lit component that owns this controller (use `this` and pass it to `addController()`). + * @param _sequence - The key sequence to listen for (e.g. `['G', 'G']`). + * @param _callback - Function to run when the sequence is completed; called with the host as `this`. + * @param _options - Optional sequence options (target, timeout, enabled, etc.). + */ + constructor( + private _host: ReactiveControllerHost, + private _sequence: HotkeySequence, + private _callback: HotkeyCallback, + private _options: SequenceOptions = HOTKEY_SEQUENCE_DEFAULT_OPTIONS, + ) {} + + /** + * Registers the sequence with the global sequence manager when the host is connected to the DOM. + * Skips registration if disabled, sequence is empty, or no target is available. + */ + public hostConnected(): void { + const { target: _target, ...optionsWithoutTarget } = this._options + + const manager: SequenceManager = getSequenceManager() + + const hasExplicitTarget = 'target' in this._options + const resolvedTarget = hasExplicitTarget + ? (this._options.target ?? null) + : typeof document !== 'undefined' + ? document + : null + + if (!resolvedTarget) { + return + } + + const boundCallback: HotkeyCallback = this._callback.bind( + this._host as unknown as object, + ) + + this._registration = manager.register(this._sequence, boundCallback, { + ...optionsWithoutTarget, + target: resolvedTarget, + }) + } + + /** Unregisters the sequence when the host is disconnected from the DOM. */ + public hostDisconnected(): void { + if (this._registration?.isActive) { + this._registration.unregister() + } + this._registration = undefined + } +} diff --git a/packages/lit-hotkeys/src/controllers/hotkey.ts b/packages/lit-hotkeys/src/controllers/hotkey.ts new file mode 100644 index 00000000..53290673 --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/hotkey.ts @@ -0,0 +1,96 @@ +import { + detectPlatform, + formatHotkey, + getHotkeyManager, + rawHotkeyToParsedHotkey, +} from '@tanstack/hotkeys' +import { HOTKEY_DEFAULT_OPTIONS } from '../constants' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import type { + Hotkey, + HotkeyCallback, + HotkeyManager, + HotkeyOptions, + HotkeyRegistrationHandle, + RegisterableHotkey, +} from '@tanstack/hotkeys' + +/** + * A Lit ReactiveController that registers a keyboard hotkey when the host + * element is connected and unregisters it when the host is disconnected. + * + * @example + * ```ts + * class MyElement extends LitElement { + * private hotkey = new HotkeyController(this, 'Mod+S', () => this.save()) + * + * constructor() { + * super() + * this.addController(this.hotkey) + * } + * } + * ``` + */ +export class HotkeyController implements ReactiveController { + /** The hotkey registration handle. */ + private _registration: HotkeyRegistrationHandle | undefined + + /** + * @param _host - The Lit component that owns this controller (use `this` and pass it to `addController()`). + * @param _hotkey - The key or key combo to listen for (e.g. `'Mod+S'` or a raw hotkey object). + * @param _callback - Function to run when the hotkey is pressed; called with the host as `this`. + * @param _options - Optional registration options (target, platform, enabled, etc.). + */ + constructor( + private _host: ReactiveControllerHost, + private _hotkey: RegisterableHotkey, + private _callback: HotkeyCallback, + private _options: HotkeyOptions = HOTKEY_DEFAULT_OPTIONS, + ) {} + + /** + * Registers the hotkey with the global manager when the host is connected to the DOM. + * Skips registration if no target is available (e.g. no document or options.target is null). + */ + public hostConnected(): void { + const manager: HotkeyManager = getHotkeyManager() + + const platform = this._options.platform ?? detectPlatform() + const hotkeyString: Hotkey = + typeof this._hotkey === 'string' + ? this._hotkey + : (formatHotkey( + rawHotkeyToParsedHotkey(this._hotkey, platform), + ) as Hotkey) + + const hasExplicitTarget = 'target' in this._options + const resolvedTarget = hasExplicitTarget + ? (this._options.target ?? null) + : typeof document !== 'undefined' + ? document + : null + + if (!resolvedTarget) { + return + } + + const { target: _target, ...optionsWithoutTarget } = this._options + + const boundCallback: HotkeyCallback = this._callback.bind( + this._host as unknown as object, + ) + + this._registration = manager.register(hotkeyString, boundCallback, { + ...optionsWithoutTarget, + target: resolvedTarget, + }) + } + + /** Unregisters the hotkey when the host is disconnected from the DOM. */ + public hostDisconnected(): void { + if (this._registration?.isActive) { + this._registration.unregister() + } + this._registration = undefined + } +} diff --git a/packages/lit-hotkeys/src/controllers/index.ts b/packages/lit-hotkeys/src/controllers/index.ts new file mode 100644 index 00000000..761ef88a --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/index.ts @@ -0,0 +1,7 @@ +export * from './held-key-codes' +export * from './held-keys' +export * from './hotkey' +export * from './hotkey-recorder' +export * from './hotkey-sequence' +export * from './hotkey-sequence-recorder' +export * from './key-hold' diff --git a/packages/lit-hotkeys/src/controllers/key-hold.ts b/packages/lit-hotkeys/src/controllers/key-hold.ts new file mode 100644 index 00000000..ddeff10c --- /dev/null +++ b/packages/lit-hotkeys/src/controllers/key-hold.ts @@ -0,0 +1,88 @@ +import { getKeyStateTracker } from '@tanstack/hotkeys' +import type { ReactiveController, ReactiveControllerHost } from 'lit' +import type { HeldKey, KeyStateTracker } from '@tanstack/hotkeys' + +/** + * A Lit ReactiveController that tracks whether a specific key is currently held. + * + * Subscribes to the global KeyStateTracker and triggers host updates + * when the held state of the specified key changes. + * + * @example + * ```ts + * class MyElement extends LitElement { + * private shiftHold = new KeyHoldController(this, 'Shift') + * + * render() { + * return html` + *
+ * ${this.shiftHold.value ? 'Shift is pressed!' : 'Press Shift'} + *
+ * ` + * } + * } + * ``` + * + * @example + * ```ts + * class ModifierIndicators extends LitElement { + * private ctrl = new KeyHoldController(this, 'Control') + * private shift = new KeyHoldController(this, 'Shift') + * private alt = new KeyHoldController(this, 'Alt') + * + * render() { + * return html` + * Ctrl + * Shift + * Alt + * ` + * } + * } + * ``` + */ +export class KeyHoldController implements ReactiveController { + /** The unsubscribe function to unsubscribe from the tracker store. */ + private _unsubscribe: (() => void) | undefined + /** Whether the tracked key is currently held down. */ + private _value = false + + /** Whether the tracked key is currently held down. */ + public get value(): boolean { + return this._value + } + + /** + * @param host - The Lit component that owns this controller. + * @param key - The key to track (e.g. 'Shift', 'Control', 'A'). + */ + constructor( + private _host: ReactiveControllerHost, + private _key: HeldKey, + ) { + this._host.addController(this) + } + + public hostConnected(): void { + const tracker: KeyStateTracker = getKeyStateTracker() + const normalizedKey: string = this._key.toLowerCase() + + const subscription = tracker.store.subscribe(() => { + const isHeld: boolean = tracker.store.state.heldKeys.some( + (heldKey: string) => heldKey.toLowerCase() === normalizedKey, + ) + + if (isHeld !== this._value) { + this._value = isHeld + this._host.requestUpdate() + } + }) + + this._unsubscribe = () => subscription.unsubscribe() + } + + public hostDisconnected(): void { + this._unsubscribe?.() + this._unsubscribe = undefined + this._value = false + } +} diff --git a/packages/lit-hotkeys/src/decorators/hotkey-sequence.ts b/packages/lit-hotkeys/src/decorators/hotkey-sequence.ts new file mode 100644 index 00000000..bdb8d7b6 --- /dev/null +++ b/packages/lit-hotkeys/src/decorators/hotkey-sequence.ts @@ -0,0 +1,68 @@ +import { HotkeySequenceController } from '../controllers/hotkey-sequence' +import { HOTKEY_SEQUENCE_DEFAULT_OPTIONS } from '../constants' +import type { LitElement } from 'lit' +import type { + HotkeyCallback, + HotkeySequence, + SequenceOptions, +} from '@tanstack/hotkeys' + +/** + * Decorator that registers a keyboard sequence (e.g. Vim-style) on the element + * when it connects and unregisters when it disconnects. Uses + * {@link HotkeySequenceController} under the hood. + * + * @param sequence - The key sequence to listen for (e.g. `['G', 'G']` for "g g"). + * @param options - Optional sequence options (target, timeout, enabled, etc.). + * @returns A method decorator for use on LitElement methods. + * + * @example + * ```ts + * class MyElement extends LitElement { + * @hotkeySequence(['G', 'G']) + * goToTop() { window.scrollTo(0, 0) } + * + * @hotkeySequence(['D', 'D'], { timeout: 500 }) + * deleteLine() { this.deleteCurrentLine() } + * } + * ``` + */ +export function hotkeySequence( + sequence: HotkeySequence, + options: SequenceOptions = HOTKEY_SEQUENCE_DEFAULT_OPTIONS, +) { + return function ( + proto: LitElement, + methodName: string, + descriptor: TypedPropertyDescriptor, + ) { + const originalConnected = proto.connectedCallback + const controllerKey = Symbol(`@hotkeySequence:${methodName}`) + + proto.connectedCallback = function () { + originalConnected.call(this) + + const host = this as unknown as { + [key: symbol]: HotkeySequenceController | undefined + addController: (c: unknown) => void + [key: string]: unknown + } + if (!host[controllerKey]) { + const callback: HotkeyCallback = descriptor.value + ? descriptor.value.bind(this) + : (host[methodName] as HotkeyCallback).bind(this) + + if (typeof callback !== 'function') { + throw new Error( + `@hotkeySequence decorator can only be applied to functions`, + ) + } + + const controller: HotkeySequenceController = + new HotkeySequenceController(this, sequence, callback, options) + host[controllerKey] = controller + this.addController(controller) + } + } + } +} diff --git a/packages/lit-hotkeys/src/decorators/hotkey.ts b/packages/lit-hotkeys/src/decorators/hotkey.ts new file mode 100644 index 00000000..9fd8702e --- /dev/null +++ b/packages/lit-hotkeys/src/decorators/hotkey.ts @@ -0,0 +1,70 @@ +import { HotkeyController } from '../controllers/hotkey' +import { HOTKEY_DEFAULT_OPTIONS } from '../constants' +import type { LitElement } from 'lit' +import type { + HotkeyCallback, + HotkeyOptions, + RegisterableHotkey, +} from '@tanstack/hotkeys' + +/** + * Decorator that registers a keyboard hotkey on the element when it connects + * and unregisters when it disconnects. Uses {@link HotkeyController} under the hood. + * + * @param hotkey - The key or key combo to listen for (e.g. `'Mod+S'` or a raw hotkey object). + * @param options - Optional registration options (target, platform, enabled, etc.). + * @returns A method decorator for use on LitElement methods. + * + * @example + * ```ts + * class MyElement extends LitElement { + * @hotkey('Mod+S') + * save() { this.doSave() } + * + * @hotkey('Escape') + * close() { this.dismiss() } + * } + * ``` + */ +export function hotkey( + hotkey: RegisterableHotkey, + options: HotkeyOptions = HOTKEY_DEFAULT_OPTIONS, +) { + return function ( + proto: LitElement, + propertyKey: string, + descriptor?: TypedPropertyDescriptor, + ) { + const originalConnected = proto.connectedCallback + const controllerKey = Symbol(`@hotkey:${propertyKey}`) + + proto.connectedCallback = function () { + originalConnected.call(this) + + const host = this as unknown as { + [key: symbol]: HotkeyController | undefined + addController: (c: unknown) => void + [key: string]: unknown + } + + if (!host[controllerKey]) { + const callback: HotkeyCallback = descriptor?.value + ? (descriptor.value.bind(this) as HotkeyCallback) + : (host[propertyKey] as HotkeyCallback) + + if (typeof callback !== 'function') { + throw new Error(`@hotkey decorator can only be applied to functions`) + } + + const controller: HotkeyController = new HotkeyController( + this, + hotkey, + callback, + options, + ) + host[controllerKey] = controller + this.addController(controller) + } + } + } +} diff --git a/packages/lit-hotkeys/src/decorators/index.ts b/packages/lit-hotkeys/src/decorators/index.ts new file mode 100644 index 00000000..2ea435f5 --- /dev/null +++ b/packages/lit-hotkeys/src/decorators/index.ts @@ -0,0 +1,2 @@ +export * from './hotkey-sequence' +export * from './hotkey' diff --git a/packages/lit-hotkeys/src/index.ts b/packages/lit-hotkeys/src/index.ts new file mode 100644 index 00000000..9f6d3053 --- /dev/null +++ b/packages/lit-hotkeys/src/index.ts @@ -0,0 +1,6 @@ +// Re-export everything from the core package +export * from '@tanstack/hotkeys' + +// Lit-specific exports +export * from './decorators' +export * from './controllers' diff --git a/packages/lit-hotkeys/tests/hotkey-sequence.spec.ts b/packages/lit-hotkeys/tests/hotkey-sequence.spec.ts new file mode 100644 index 00000000..4c7409e0 --- /dev/null +++ b/packages/lit-hotkeys/tests/hotkey-sequence.spec.ts @@ -0,0 +1,445 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SequenceManager } from '@tanstack/hotkeys' +import { HotkeySequenceController } from '../src/controllers/hotkey-sequence' +import type { ReactiveController, ReactiveControllerHost } from 'lit' + +function createMockHost(): ReactiveControllerHost & { + controllers: Array +} { + const controllers: Array = [] + return { + controllers, + addController(c) { + controllers.push(c) + }, + removeController(c) { + const idx = controllers.indexOf(c) + if (idx >= 0) controllers.splice(idx, 1) + }, + requestUpdate() {}, + get updateComplete() { + return Promise.resolve(true) + }, + } +} + +function dispatchKeydown( + target: EventTarget, + init: KeyboardEventInit, +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { ...init, bubbles: true }) + target.dispatchEvent(event) + return event +} + +function dispatchKeyup( + target: EventTarget, + init: KeyboardEventInit, +): KeyboardEvent { + const event = new KeyboardEvent('keyup', { ...init, bubbles: true }) + target.dispatchEvent(event) + return event +} + +describe('HotkeySequenceController', () => { + beforeEach(() => { + SequenceManager.resetInstance() + }) + + afterEach(() => { + SequenceManager.resetInstance() + }) + + it('should register a sequence listener on connect', () => { + const callback = vi.fn() + const addSpy = vi.spyOn(document, 'addEventListener') + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) + + ctrl.hostDisconnected() + addSpy.mockRestore() + }) + + it('should call callback when full sequence is pressed', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + expect(callback).not.toHaveBeenCalled() + + dispatchKeydown(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + }) + + it('should not call callback on partial sequence', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['D', 'I', 'W'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'd' }) + dispatchKeydown(document, { key: 'i' }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + + it('should call callback for a three-key sequence', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['D', 'I', 'W'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'd' }) + dispatchKeydown(document, { key: 'i' }) + dispatchKeydown(document, { key: 'w' }) + + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + }) + + it('should not call callback when wrong key is pressed', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'x' }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + + it('should not fire when enabled is false', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + enabled: false, + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + + it('should bind callback with host as this', () => { + let capturedThis: unknown + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], function ( + this: unknown, + ) { + capturedThis = this + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + + expect(capturedThis).toBe(host) + + ctrl.hostDisconnected() + }) + + describe('target handling', () => { + it('should skip registration when target is null', () => { + const callback = vi.fn() + const addSpy = vi.spyOn(document, 'addEventListener') + const callsBefore = addSpy.mock.calls.length + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + target: null, + }) + host.addController(ctrl) + ctrl.hostConnected() + + expect(addSpy.mock.calls.length).toBe(callsBefore) + + ctrl.hostDisconnected() + addSpy.mockRestore() + }) + + it('should register on a specific target element', () => { + const callback = vi.fn() + const targetEl = document.createElement('div') + document.body.appendChild(targetEl) + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + target: targetEl, + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + expect(callback).not.toHaveBeenCalled() + + dispatchKeydown(targetEl, { key: 'g' }) + dispatchKeydown(targetEl, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + targetEl.remove() + }) + + it('should default to document when target is not specified', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + }) + }) + + describe('lifecycle', () => { + it('should not fire after disconnect', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should re-register on reconnect', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + + ctrl.hostConnected() + ctrl.hostDisconnected() + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + }) + + it('should handle multiple connect/disconnect cycles', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + + for (let i = 0; i < 3; i++) { + ctrl.hostConnected() + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + ctrl.hostDisconnected() + } + + expect(callback).toHaveBeenCalledTimes(3) + }) + + it('should be safe to call hostDisconnected when not connected', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + + expect(() => ctrl.hostDisconnected()).not.toThrow() + }) + + it('should be safe to call hostDisconnected twice', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + expect(() => { + ctrl.hostDisconnected() + ctrl.hostDisconnected() + }).not.toThrow() + }) + }) + + describe('sequence timeout', () => { + it('should reset sequence after timeout', () => { + vi.useFakeTimers() + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + timeout: 500, + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + + vi.advanceTimersByTime(600) + + dispatchKeydown(document, { key: 'g' }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + vi.useRealTimers() + }) + + it('should complete sequence within timeout', () => { + vi.useFakeTimers() + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + timeout: 500, + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + + vi.advanceTimersByTime(400) + + dispatchKeydown(document, { key: 'g' }) + + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + vi.useRealTimers() + }) + }) + + describe('keyup event type', () => { + it('should support keyup event type', () => { + const callback = vi.fn() + const addSpy = vi.spyOn(document, 'addEventListener') + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + eventType: 'keyup', + }) + host.addController(ctrl) + ctrl.hostConnected() + + expect(addSpy).toHaveBeenCalledWith('keyup', expect.any(Function)) + + dispatchKeyup(document, { key: 'g' }) + dispatchKeyup(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + addSpy.mockRestore() + }) + + it('should not trigger on keydown when configured for keyup', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback, { + eventType: 'keyup', + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + }) + + describe('modifier sequences', () => { + it('should support sequences with modifier keys', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController( + host, + ['Control+K', 'Control+S'], + callback, + { platform: 'windows' }, + ) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'k', ctrlKey: true }) + dispatchKeydown(document, { key: 's', ctrlKey: true }) + + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + }) + }) + + describe('repeated triggers', () => { + it('should allow the same sequence to be triggered multiple times', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeySequenceController(host, ['G', 'G'], callback) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(1) + + dispatchKeydown(document, { key: 'g' }) + dispatchKeydown(document, { key: 'g' }) + expect(callback).toHaveBeenCalledTimes(2) + + ctrl.hostDisconnected() + }) + }) +}) diff --git a/packages/lit-hotkeys/tests/hotkey.spec.ts b/packages/lit-hotkeys/tests/hotkey.spec.ts new file mode 100644 index 00000000..b99e505b --- /dev/null +++ b/packages/lit-hotkeys/tests/hotkey.spec.ts @@ -0,0 +1,269 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HotkeyManager } from '@tanstack/hotkeys' +import { HotkeyController } from '../src/controllers/hotkey' +import type { ReactiveController, ReactiveControllerHost } from 'lit' + +/** + * Minimal host that satisfies ReactiveControllerHost for testing + * without pulling in the full LitElement rendering pipeline. + */ +function createMockHost(): ReactiveControllerHost & { + controllers: Array +} { + const controllers: Array = [] + return { + controllers, + addController(c) { + controllers.push(c) + }, + removeController(c) { + const idx = controllers.indexOf(c) + if (idx >= 0) controllers.splice(idx, 1) + }, + requestUpdate() {}, + get updateComplete() { + return Promise.resolve(true) + }, + } +} + +function dispatchKeydown( + target: EventTarget, + init: KeyboardEventInit, +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { ...init, bubbles: true }) + target.dispatchEvent(event) + return event +} + +function dispatchKeyup( + target: EventTarget, + init: KeyboardEventInit, +): KeyboardEvent { + const event = new KeyboardEvent('keyup', { ...init, bubbles: true }) + target.dispatchEvent(event) + return event +} + +describe('HotkeyController', () => { + beforeEach(() => { + HotkeyManager.resetInstance() + }) + + afterEach(() => { + HotkeyManager.resetInstance() + }) + + it('should register a keydown listener on connect', () => { + const callback = vi.fn() + const addSpy = vi.spyOn(document, 'addEventListener') + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) + + ctrl.hostDisconnected() + addSpy.mockRestore() + }) + + it('should remove listener on disconnect', () => { + const callback = vi.fn() + const removeSpy = vi.spyOn(document, 'removeEventListener') + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + ctrl.hostDisconnected() + + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) + + removeSpy.mockRestore() + }) + + it('should call callback when hotkey matches', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + + expect(callback).toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + + it('should not call callback when hotkey does not match', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 'a', metaKey: true }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + + it('should use keyup event when specified', () => { + const callback = vi.fn() + const addSpy = vi.spyOn(document, 'addEventListener') + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Escape', callback, { + eventType: 'keyup', + }) + host.addController(ctrl) + ctrl.hostConnected() + + expect(addSpy).toHaveBeenCalledWith('keyup', expect.any(Function)) + + dispatchKeyup(document, { key: 'Escape' }) + expect(callback).toHaveBeenCalled() + + ctrl.hostDisconnected() + addSpy.mockRestore() + }) + + it('should not fire when enabled is false', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + enabled: false, + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + + expect(callback).not.toHaveBeenCalled() + + ctrl.hostDisconnected() + }) + + it('should bind callback with host as this', () => { + let capturedThis: unknown + + const host = createMockHost() + const ctrl = new HotkeyController( + host, + 'Mod+S', + function (this: unknown) { + capturedThis = this + }, + { platform: 'mac' }, + ) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + + expect(capturedThis).toBe(host) + + ctrl.hostDisconnected() + }) + + describe('target handling', () => { + it('should skip registration when target is null', () => { + const callback = vi.fn() + const addSpy = vi.spyOn(document, 'addEventListener') + const callsBefore = addSpy.mock.calls.length + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + target: null, + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + + expect(addSpy.mock.calls.length).toBe(callsBefore) + + ctrl.hostDisconnected() + addSpy.mockRestore() + }) + + it('should register on a specific target element', () => { + const callback = vi.fn() + const targetEl = document.createElement('div') + document.body.appendChild(targetEl) + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + target: targetEl, + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + expect(callback).not.toHaveBeenCalled() + + dispatchKeydown(targetEl, { key: 's', metaKey: true }) + expect(callback).toHaveBeenCalled() + + ctrl.hostDisconnected() + targetEl.remove() + }) + }) + + describe('lifecycle', () => { + it('should not fire after disconnect', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + }) + host.addController(ctrl) + ctrl.hostConnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should re-register on reconnect', () => { + const callback = vi.fn() + + const host = createMockHost() + const ctrl = new HotkeyController(host, 'Mod+S', callback, { + platform: 'mac', + }) + host.addController(ctrl) + + ctrl.hostConnected() + ctrl.hostDisconnected() + ctrl.hostConnected() + + dispatchKeydown(document, { key: 's', metaKey: true }) + expect(callback).toHaveBeenCalledTimes(1) + + ctrl.hostDisconnected() + }) + }) +}) diff --git a/packages/lit-hotkeys/tsconfig.docs.json b/packages/lit-hotkeys/tsconfig.docs.json new file mode 100644 index 00000000..08866d6b --- /dev/null +++ b/packages/lit-hotkeys/tsconfig.docs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/hotkeys": ["../hotkeys/src"] + } + }, + "include": ["src"] +} diff --git a/packages/lit-hotkeys/tsconfig.json b/packages/lit-hotkeys/tsconfig.json new file mode 100644 index 00000000..ad5f22b1 --- /dev/null +++ b/packages/lit-hotkeys/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "vitest.config.ts", "tests"], + "exclude": ["eslint.config.js"] +} diff --git a/packages/lit-hotkeys/tsdown.config.ts b/packages/lit-hotkeys/tsdown.config.ts new file mode 100644 index 00000000..71071cb9 --- /dev/null +++ b/packages/lit-hotkeys/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm', 'cjs'], + unbundle: true, + dts: true, + sourcemap: true, + clean: true, + minify: false, + fixedExtension: false, + exports: true, + publint: { + strict: true, + }, +}) diff --git a/packages/lit-hotkeys/vitest.config.ts b/packages/lit-hotkeys/vitest.config.ts new file mode 100644 index 00000000..b6e6057b --- /dev/null +++ b/packages/lit-hotkeys/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' +import packageJson from './package.json' with { type: 'json' } + +export default defineConfig({ + plugins: [], + test: { + name: packageJson.name, + dir: './tests', + watch: false, + environment: 'happy-dom', + globals: true, + }, +}) diff --git a/packages/preact-hotkeys-devtools/README.md b/packages/preact-hotkeys-devtools/README.md index 7c581ae2..c0473686 100644 --- a/packages/preact-hotkeys-devtools/README.md +++ b/packages/preact-hotkeys-devtools/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/preact-hotkeys/README.md b/packages/preact-hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/preact-hotkeys/README.md +++ b/packages/preact-hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/react-hotkeys-devtools/README.md b/packages/react-hotkeys-devtools/README.md index 7c581ae2..c0473686 100644 --- a/packages/react-hotkeys-devtools/README.md +++ b/packages/react-hotkeys-devtools/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/react-hotkeys/README.md b/packages/react-hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/react-hotkeys/README.md +++ b/packages/react-hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/solid-hotkeys-devtools/README.md b/packages/solid-hotkeys-devtools/README.md index 7c581ae2..c0473686 100644 --- a/packages/solid-hotkeys-devtools/README.md +++ b/packages/solid-hotkeys-devtools/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/solid-hotkeys/README.md b/packages/solid-hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/solid-hotkeys/README.md +++ b/packages/solid-hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/svelte-hotkeys/README.md b/packages/svelte-hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/svelte-hotkeys/README.md +++ b/packages/svelte-hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/vue-hotkeys-devtools/README.md b/packages/vue-hotkeys-devtools/README.md index 7c581ae2..c0473686 100644 --- a/packages/vue-hotkeys-devtools/README.md +++ b/packages/vue-hotkeys-devtools/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/packages/vue-hotkeys/README.md b/packages/vue-hotkeys/README.md index 7c581ae2..c0473686 100644 --- a/packages/vue-hotkeys/README.md +++ b/packages/vue-hotkeys/README.md @@ -58,6 +58,7 @@ Type-safe keyboard shortcuts for the web. Template-string bindings, parsed objec > - [**Solid Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/solid/reference) > - [**Angular Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/angular/reference) > - [**Vue Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/vue/reference) +> - [**Lit Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/lit/reference) > - [**Svelte Hotkeys**](https://tanstack.com/hotkeys/latest/docs/framework/svelte/reference) ## Get Involved diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4741f3f8..0aa111df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + rolldown: 1.0.0-rc.12 + importers: .: @@ -641,6 +644,102 @@ importers: specifier: 6.0.2 version: 6.0.2 + examples/lit/held-keys: + dependencies: + '@tanstack/lit-hotkeys': + specifier: 0.8.3 + version: link:../../../packages/lit-hotkeys + lit: + specifier: ^3.3.2 + version: 3.3.2 + devDependencies: + typescript: + specifier: 6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + + examples/lit/hotkey: + dependencies: + '@tanstack/lit-hotkeys': + specifier: 0.8.3 + version: link:../../../packages/lit-hotkeys + lit: + specifier: ^3.3.2 + version: 3.3.2 + devDependencies: + typescript: + specifier: 6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + + examples/lit/hotkey-recorder: + dependencies: + '@tanstack/lit-hotkeys': + specifier: 0.8.3 + version: link:../../../packages/lit-hotkeys + lit: + specifier: ^3.3.2 + version: 3.3.2 + devDependencies: + typescript: + specifier: 6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + + examples/lit/hotkey-sequence: + dependencies: + '@tanstack/lit-hotkeys': + specifier: 0.8.3 + version: link:../../../packages/lit-hotkeys + lit: + specifier: ^3.3.2 + version: 3.3.2 + devDependencies: + typescript: + specifier: 6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + + examples/lit/hotkey-sequence-recorder: + dependencies: + '@tanstack/lit-hotkeys': + specifier: 0.8.3 + version: link:../../../packages/lit-hotkeys + lit: + specifier: ^3.3.2 + version: 3.3.2 + devDependencies: + typescript: + specifier: 6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + + examples/lit/key-hold: + dependencies: + '@tanstack/lit-hotkeys': + specifier: 0.8.3 + version: link:../../../packages/lit-hotkeys + lit: + specifier: ^3.3.2 + version: 3.3.2 + devDependencies: + typescript: + specifier: 6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.3 + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + examples/preact/useHeldKeys: dependencies: '@tanstack/preact-hotkeys': @@ -1767,6 +1866,16 @@ importers: specifier: ^2.11.11 version: 2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) + packages/lit-hotkeys: + dependencies: + '@tanstack/hotkeys': + specifier: workspace:* + version: link:../hotkeys + devDependencies: + lit: + specifier: ^3.3.2 + version: 3.3.2 + packages/preact-hotkeys: dependencies: '@tanstack/hotkeys': @@ -3441,6 +3550,12 @@ packages: '@inquirer/prompts': '>= 3 < 8' listr2: 9.0.5 + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@lmdb/lmdb-darwin-arm64@3.5.1': resolution: {integrity: sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==} cpu: [arm64] @@ -3892,9 +4007,6 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.113.0': - resolution: {integrity: sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==} - '@oxc-project/types@0.121.0': resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} @@ -4171,60 +4283,30 @@ packages: cpu: [arm64] os: [android] - '@rolldown/binding-android-arm64@1.0.0-rc.4': - resolution: {integrity: sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.0-rc.4': - resolution: {integrity: sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.12': resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.4': - resolution: {integrity: sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.0-rc.4': - resolution: {integrity: sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': - resolution: {integrity: sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4232,13 +4314,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': - resolution: {integrity: sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4246,13 +4321,6 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': - resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4274,13 +4342,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': - resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4288,68 +4349,35 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': - resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': - resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': - resolution: {integrity: sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': - resolution: {integrity: sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': - resolution: {integrity: sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@rolldown/pluginutils@1.0.0-rc.12': resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} - '@rolldown/pluginutils@1.0.0-rc.4': - resolution: {integrity: sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==} - '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -7402,6 +7430,15 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.2: + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + lmdb@3.5.1: resolution: {integrity: sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==} hasBin: true @@ -8392,7 +8429,7 @@ packages: peerDependencies: '@ts-macro/tsc': ^0.3.6 '@typescript/native-preview': '>=7.0.0-dev.20260325.1' - rolldown: ^1.0.0-rc.12 + rolldown: 1.0.0-rc.12 typescript: ^5.0.0 || ^6.0.0 vue-tsc: ~3.2.0 peerDependenciesMeta: @@ -8410,11 +8447,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rolldown@1.0.0-rc.4: - resolution: {integrity: sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -9936,7 +9968,7 @@ snapshots: parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 piscina: 5.1.4 - rolldown: 1.0.0-rc.4 + rolldown: 1.0.0-rc.12 sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 @@ -9990,7 +10022,7 @@ snapshots: parse5-html-rewriting-stream: 8.0.0 picomatch: 4.0.4 piscina: 5.1.4 - rolldown: 1.0.0-rc.4 + rolldown: 1.0.0-rc.12 sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 @@ -11531,6 +11563,12 @@ snapshots: transitivePeerDependencies: - '@types/node' + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lmdb/lmdb-darwin-arm64@3.5.1': optional: true @@ -11874,8 +11912,6 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.121.0': optional: true - '@oxc-project/types@0.113.0': {} - '@oxc-project/types@0.121.0': {} '@oxc-project/types@0.122.0': {} @@ -12144,45 +12180,24 @@ snapshots: '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.4': - optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.4': - optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.12': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.4': - optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.4': - optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.4': - optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.4': - optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': - optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': optional: true @@ -12192,49 +12207,27 @@ snapshots: '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': - optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.4': - optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': - optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': - dependencies: - '@napi-rs/wasm-runtime': 1.1.1 - optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': - optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.4': - optional: true - '@rolldown/pluginutils@1.0.0-rc.12': {} '@rolldown/pluginutils@1.0.0-rc.2': {} - '@rolldown/pluginutils@1.0.0-rc.4': {} - '@rolldown/pluginutils@1.0.0-rc.7': {} '@rollup/pluginutils@4.2.1': @@ -15614,6 +15607,22 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.2: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + lmdb@3.5.1: dependencies: '@harperfast/extended-iterable': 1.0.3 @@ -16739,25 +16748,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - rolldown@1.0.0-rc.4: - dependencies: - '@oxc-project/types': 0.113.0 - '@rolldown/pluginutils': 1.0.0-rc.4 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.4 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.4 - '@rolldown/binding-darwin-x64': 1.0.0-rc.4 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.4 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.4 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.4 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 - rollup@4.57.1: dependencies: '@types/estree': 1.0.8 diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 8047ed27..5d02cf28 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -24,6 +24,16 @@ await generateReferenceDocs({ tsconfig: resolve(__dirname, '../packages/hotkeys/tsconfig.docs.json'), outputDir: resolve(__dirname, '../docs/reference'), }, + { + name: 'lit-hotkeys', + entryPoints: [resolve(__dirname, '../packages/lit-hotkeys/src/index.ts')], + tsconfig: resolve( + __dirname, + '../packages/lit-hotkeys/tsconfig.docs.json', + ), + outputDir: resolve(__dirname, '../docs/framework/lit/reference'), + exclude: ['packages/hotkeys/**/*'], + }, { name: 'preact-hotkeys', entryPoints: [