Skip to main content

Hotkeys Guide

还没翻译译自英文(tanstack.com)本页 68 段都还没译

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.

Logical keys and physical positions

Use a logical binding when the shortcut should follow the character on the active layout. Use a physical binding when it should follow a keyboard position:

BindingIdentity checked
Mod+S or { key: 'S', mod: true }Logical event.key, with conservative code fallback
Mod+[KeyS] or { code: 'KeyS', mod: true }Exact event.code
EnterLogical Enter, including numpad Enter
[Enter] / [NumpadEnter]Separate physical Enter positions

Every physical code uses brackets in strings, including names shared with logical keys such as [Enter] and [F13]. Supported codes are type-safe and available in autocomplete. Do not put a bracketed code in an object's key field; use code. A binding has either key or code, never both.

On a layout where the KeyQ position produces a, A follows that character and [KeyQ] follows the position. Logical ASCII letters remain layout-aware; conservative physical fallback helps with transformed output such as macOS Option keys. Exact matches take priority over weaker fallbacks among eligible registrations on the same target.

Callbacks expose the same distinction in context.parsedHotkey: check parsed.code !== undefined before reading its physical identity. Use formatForDisplay for labels; stored physical strings retain their brackets.

Basic usage

The @hotkey decorator

Decorate any method to have it called when a hotkey is pressed:

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`<div>Press Cmd+S (Mac) or Ctrl+S (Windows) to save</div>`
  }
}

The callback receives the original KeyboardEvent as the first argument and a HotkeyCallbackContext as the second:

@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:

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`<div>Press Cmd+S (Mac) or Ctrl+S (Windows) to save</div>`
  }
}

Changing a binding

Pass a new logical or physical binding through your framework's normal state mechanism. A recorder result such as Alt+[KeyS] can be passed directly to the same registration API. Keep an initial binding in application state if you want a reset button; the library does not need a separate preferences store.

Property getters

HotkeyController and HotkeySequenceController accept property getters or functions returning options. Controllers read options when the host connects and after each host update, so scoped targets can become available during rendering.

import { LitElement, html } from 'lit'
import { HotkeyController } from '@tanstack/lit-hotkeys'

export class Editor extends LitElement {
	static properties = { enabled: { type: Boolean } }
	declare enabled: boolean

	constructor() {
		super()
		this.enabled = true
		const component = this
		this.addController(new HotkeyController(this, 'Mod+S', () => console.log('Save'), {
			get enabled() {
				return component.enabled
			},
		}))
	}

	render() {
		return html`<button @click=${() => this.enabled = !this.enabled}>Toggle shortcut</button>`
	}
}

You can also pass () => ({ enabled: this.enabled }). Use Lit reactive properties, or call requestUpdate() after changing plain state. A getter alone does not schedule a host update. Ordinary option changes preserve registration identity. Changing the target moves listeners, and disconnecting releases registrations. For options that depend on the element instance, use a controller rather than decorator configuration created at class definition time.

Recorder controllers accept the same two forms and read them during recording, including active sessions. Callback values remain functions and are not invoked to resolve options.

Default options

When you register a hotkey without passing options, or when you omit specific options, the following defaults apply:

@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 meant to override default browser behavior, such as using Mod+S to save a document instead of showing the browser's "Save Page" dialog. So preventDefault and stopPropagation are true by default, and you opt out per hotkey when you actually want the browser behavior.

Smart input handling: ignoreInputs

The ignoreInputs default depends on the hotkey. 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, and those using only Shift/Alt, are ignored within non-button inputs so they don't interfere 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.

@hotkey('Mod+S', { enabled: true })
save() { saveDocument() }

preventDefault

Automatically calls event.preventDefault() when the hotkey fires. Defaults to true.

// 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.

// 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.

// 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.

// Only fires once per Escape press, not on key repeat
@hotkey('Escape', { requireReset: true })
closePanel() { this.panelOpen = false }

ignoreInputs

When true, the hotkey doesn't fire when the user is focused on a text input, textarea, select, or contentEditable element. Button-type inputs (type="button", "submit", "reset") are not ignored. When unset, a smart default applies: Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.

// 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.

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<HTMLDivElement>()
  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`
      <div ${ref(this.panelRef)} tabindex="0">
        <p>Press Escape while focused here to close</p>
      </div>
    `
  }
}

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
@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.

@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:

@customElement('temporary-panel')
class TemporaryPanel extends LitElement {
  // Automatically registered on connect, unregistered on disconnect
  @hotkey('Escape')
  close() { this.remove() }

  render() {
    return html`<div>Panel content</div>`
  }
}

Multiple hotkeys

Register as many hotkeys as you need. Each @hotkey decorator is independent:

@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 DecoratorHotkeyController
Best forStatic, declarative method bindingDynamic hotkeys, programmatic control
RegistrationAutomatic via connectedCallbackAutomatic via hostConnected
CleanupAutomatic via disconnectedCallbackAutomatic via hostDisconnected
Dynamic hotkeysNo (hotkey is fixed at decoration time)Yes (can construct hotkey at runtime)
Callback bindingBound to the host element automaticallyBound 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.

Metadata (name, description, and group)

Every hotkey registration can carry a meta object with a name, description, and group. Metadata never affects hotkey behavior, but it flows through to registrations and devtools, so you can build shortcut palettes and help screens from it.

@hotkey('Mod+S', { meta: { name: 'Save', description: 'Save the document' } })
save() { saveDocument() }

// Or with HotkeyController:
new HotkeyController(this, 'Mod+S', () => this.save(), {
  meta: { name: 'Save', description: 'Save the document' },
})

The meta option is typed as HotkeyMeta, which ships with name, description, and group fields. You can extend it with additional properties using TypeScript declaration merging:

declare module '@tanstack/hotkeys' {
  interface HotkeyMeta {
    icon?: string
  }
}

@hotkey('Mod+S', { meta: { name: 'Save', description: 'Save the document', icon: 'floppy', group: 'File' } })
save() { saveDocument() }

Group is descriptive metadata, not an execution scope. A shortcuts panel can group live registration views directly. Disabled registrations remain listed; unmounted registrations disappear.

Introspecting registrations

Use HotkeyRegistrationsController to get a live view of all hotkey and sequence registrations. Use it to build shortcut palettes, help dialogs, or devtools.

import { LitElement, html } from 'lit'
import { customElement } from 'lit/decorators.js'
import { HotkeyRegistrationsController, formatForDisplay } from '@tanstack/lit-hotkeys'

@customElement('shortcut-palette')
class ShortcutPalette extends LitElement {
  private registrations = new HotkeyRegistrationsController(this)

  render() {
    const { hotkeys, sequences } = this.registrations

    return html`
      <h2>Keyboard Shortcuts</h2>
      <ul>
        ${hotkeys.map(
          (reg) => html`
            <li>
              <kbd>${formatForDisplay(reg.hotkey)}</kbd>
              ${reg.options.meta?.name ? html`<span> — ${reg.options.meta.name}</span>` : ''}
              ${reg.options.meta?.description ? html`<p>${reg.options.meta.description}</p>` : ''}
            </li>
          `,
        )}
      </ul>
      ${sequences.length > 0
        ? html`
            <h2>Sequences</h2>
            <ul>
              ${sequences.map(
                (reg) => html`
                  <li>
                    <kbd>${reg.sequence.map((step) => formatForDisplay(step)).join(' → ')}</kbd>
                    ${reg.options.meta?.name ? html`<span> — ${reg.options.meta.name}</span>` : ''}
                  </li>
                `,
              )}
            </ul>
          `
        : ''}
    `
  }
}

The controller exposes hotkeys and sequences arrays. The hotkeys array contains registration objects with the hotkey string, options (including meta), and enabled state. The sequences array contains sequence registrations with the same structure.

The hotkey manager

Under the hood, both the decorator and controller use the singleton HotkeyManager. You can access the manager directly when needed:

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. That beats a single global listener that has to inspect every keystroke.