Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Hotkeys API Reference
App Hotkeys API Reference
Key hold & held keys API Reference
Hotkey Recorder API Reference
Normalization & format API Reference

Lit Example: Kitchen Sink

import { LitElement, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import {
  HeldKeyCodesController,
  HeldKeysController,
  HotkeyController,
  HotkeyHintController,
  HotkeyRecorderController,
  HotkeyRegistrationsController,
  KeyHoldController,
  formatForDisplay,
  hotkey,
  isHotkeySequence,
} from '@tanstack/lit-hotkeys'
import type { Hotkey, RecorderKeyMode } from '@tanstack/lit-hotkeys'
import './styles.css'

// Light DOM keeps the shared example CSS small and makes scoped targets easy to inspect.
class DemoElement extends LitElement {
  protected createRenderRoot() {
    return this
  }
  protected log(message: string) {
    this.dispatchEvent(
      new CustomEvent('activity', {
        detail: message,
        bubbles: true,
        composed: true,
      }),
    )
  }
}

@customElement('shortcut-hint')
class Hint extends DemoElement {
  @property() hotkey: Hotkey = 'Escape'
  @property({ type: Boolean }) enabled = true
  private hint = new HotkeyHintController(this, () => this.hotkey)
  render() {
    return this.hint.value && this.enabled
      ? html`<kbd>${formatForDisplay(this.hotkey)}</kbd>`
      : null
  }
}

@customElement('ticket-demo')
class Tickets extends DemoElement {
  @state() private count = 0
  @state() private enabled = true
  private create = () => {
    this.count++
    this.log('Ticket created')
  }
  private save = () => this.log('Ticket saved')
  constructor() {
    super()
    this.addController(
      new HotkeyController(this, 'Alt+[KeyC]', this.create, () => ({
        enabled: this.enabled,
        requireReset: true,
        ignoreInputs: false,
        meta: { name: 'Create ticket', group: 'Tickets' },
      })),
    )
    this.addController(
      new HotkeyController(this, 'Alt+[KeyS]', this.save, () => ({
        enabled: this.enabled,
        requireReset: true,
        meta: { name: 'Save ticket', group: 'Tickets' },
      })),
    )
  }
  private toggle = (event: Event) => {
    this.enabled = (event.target as HTMLInputElement).checked
  }
  render() {
    return html`<section>
      <h2>Tickets</h2>
      <p>Created: ${this.count}. Buttons and shortcuts share handlers.</p>
      <label
        ><input
          type="checkbox"
          .checked=${this.enabled}
          @change=${this.toggle}
        />
        Enable ticket actions</label
      >
      <button ?disabled=${!this.enabled} @click=${this.create}>
        Create ticket
        <shortcut-hint
          hotkey="Alt+[KeyC]"
          .enabled=${this.enabled}
        ></shortcut-hint>
      </button>
      <button ?disabled=${!this.enabled} @click=${this.save}>
        Save pending
        <shortcut-hint
          hotkey="Alt+[KeyS]"
          .enabled=${this.enabled}
        ></shortcut-hint>
      </button>
      <label
        >Ticket note <input placeholder="Try Alt + C while typing"
      /></label>
      <p>
        Create uses ignoreInputs: false; Save uses default filtering. Release
        before firing again.
      </p>
    </section>`
  }
}

@customElement('editor-pane')
class EditorPane extends DemoElement {
  @property() name = 'Editor'
  @state() private text = 'One shortcut, two independent editors.'
  @state() private saves = 0
  constructor() {
    super()
    this.addController(
      new HotkeyController(
        this,
        'Mod+[KeyS]',
        () => {
          this.saves++
          this.log(`${this.name} saved`)
        },
        {
          target: this,
          ignoreInputs: false,
          requireReset: true,
          meta: { name: 'Save editor', group: 'Editor' },
        },
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        'Alt+[Backspace]',
        () => {
          this.text = ''
        },
        {
          target: this,
          ignoreInputs: false,
          meta: { name: 'Clear editor', group: 'Editor' },
        },
      ),
    )
  }
  render() {
    return html`<fieldset>
      <legend>${this.name} — ${this.saves} saves</legend>
      <label
        >Editor content<textarea
          rows="3"
          .value=${this.text}
          @input=${(e: Event) => {
            this.text = (e.target as HTMLTextAreaElement).value
          }}
        ></textarea>
      </label>
      <p>
        <kbd>${formatForDisplay('Mod+[KeyS]')}</kbd> Save;
        <kbd>${formatForDisplay('Alt+[Backspace]')}</kbd> Clear
      </p>
    </fieldset>`
  }
}

@customElement('editor-demo')
class Editor extends DemoElement {
  @state() private count = 0
  @state() private repeat = false
  @state() private bubble = false
  constructor() {
    super()
    this.addController(
      new HotkeyController(
        this,
        'Alt+[ArrowRight]',
        () => this.count++,
        () => ({
          requireReset: !this.repeat,
          meta: { name: 'Counter', group: 'Editor' },
        }),
      ),
    )
    this.addController(
      // A null target waits until #scope renders.
      new HotkeyController(
        this,
        'Alt+[KeyB]',
        () => this.log('Target B'),
        () => ({
          target: this.querySelector<HTMLElement>('#scope'),
          preventDefault: !this.bubble,
          stopPropagation: !this.bubble,
          meta: { name: 'Target propagation', group: 'Editor' },
        }),
      ),
    )
  }
  @hotkey('Alt+[KeyU]', {
    eventType: 'keyup',
    meta: { name: 'Keyup', group: 'Editor' },
  })
  released() {
    this.log('Keyup U')
  }
  @hotkey('Alt+[KeyB]', {
    meta: { name: 'Document propagation', group: 'Editor' },
  })
  documentB() {
    this.log('Document B')
  }
  render() {
    return html`<section>
      <h2>Editor targets</h2>
      <p>Focus an editor to choose its Save handler.</p>
      <editor-pane name="Draft"></editor-pane
      ><editor-pane name="Notes"></editor-pane>
      <label
        ><input
          type="checkbox"
          .checked=${this.repeat}
          @change=${(e: Event) => {
            this.repeat = (e.target as HTMLInputElement).checked
          }}
        />
        Allow repeat</label
      >
      <p>
        Hold Alt + ArrowRight: ${this.count}. Release Alt + U for a keyup event.
      </p>
      <fieldset id="scope" tabindex="0">
        <legend>Propagation</legend>
        <label
          ><input
            type="checkbox"
            .checked=${this.bubble}
            @change=${(e: Event) => {
              this.bubble = (e.target as HTMLInputElement).checked
            }}
          />
          Allow bubbling and browser defaults</label
        >
        <p>Focus here and press Alt + B; watch Activity.</p>
        <button
          @click=${() => this.querySelector<HTMLElement>('#scope')?.focus()}
        >
          Focus this area
        </button>
      </fieldset>
    </section>`
  }
}

@customElement('sequence-demo')
class Sequences extends DemoElement {
  @state() private timeout = 1000
  private registry = new HotkeyRegistrationsController(this)
  constructor() {
    super()
    this.addController(
      // An array of chords registers a sequence.
      new HotkeyController(
        this,
        ['Shift+[KeyR]', 'Shift+[KeyT]'],
        () => this.log('Shift chain'),
        { meta: { name: 'Shift chain (1000ms)', group: 'Sequences' } },
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        ['[KeyG]', '[KeyG]'],
        () => this.log('Go to top'),
        () => ({
          timeout: this.timeout,
          meta: { name: 'Go to top', group: 'Sequences' },
        }),
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        ['[KeyG]', '[KeyI]'],
        () => this.log('Inbox'),
        () => ({
          timeout: this.timeout,
          meta: { name: 'Inbox', group: 'Sequences' },
        }),
      ),
    )
  }
  render() {
    return html`<section>
      <h2>Sequences</h2>
      <p>
        Physical G then G, G then I, or Shift + R then Shift + T. Repeats do not
        advance steps.
      </p>
      <label
        >G sequence timeout: ${this.timeout} ms
        <input
          type="range"
          min="300"
          max="2500"
          step="100"
          .value=${String(this.timeout)}
          @input=${(e: Event) => {
            this.timeout = Number((e.target as HTMLInputElement).value)
          }}
      /></label>
      <table>
        <tbody>
          ${this.registry.hotkeys.map((reg) =>
            reg.kind !== 'sequence'
              ? null
              : html`<tr>
                  <td>${reg.options.meta?.name}</td>
                  <td>
                    ${formatForDisplay(reg.hotkey, { sequenceSeparator: ' → ' })}
                  </td>
                  <td>
                    ${reg.matchedStepCount}/${reg.hotkey.length} steps;
                    ${reg.triggerCount} fired
                  </td>
                </tr>`,
          )}
        </tbody>
      </table>
    </section>`
  }
}

@customElement('recording-demo')
class Recording extends DemoElement {
  private initial: Hotkey = 'Alt+[KeyR]'
  @state() private binding: Hotkey = this.initial
  @state() private sequence: Hotkey = ['[KeyX]', '[KeyY]']
  @state() private mode: RecorderKeyMode = 'code'
  @state() private problem = ''
  @state() private idle = false
  @state() private enter = true
  private recorder = new HotkeyRecorderController(this, {
    ignoreInputs: false,
    detectConflicts: {
      exclude: (reg) => reg.options.meta?.name === 'Recorded action',
    },
    validate: (_, { parsedChord }) =>
      parsedChord.modifiers.length > 0 || 'Include a modifier.',
    onRecord: (value) => {
      this.binding = value
      this.problem = ''
    },
    onClear: () => {
      this.binding = this.initial
    },
    onReject: (r) => {
      this.problem = r.message
    },
    onCancel: () => {
      this.problem = 'Cancelled'
    },
  })
  // maxSteps above 1 records sequences; one step records a chord.
  private seq = new HotkeyRecorderController(this, {
    maxSteps: Infinity,
    ignoreInputs: false,
    detectConflicts: {
      exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
    },
    validate: (value) =>
      (isHotkeySequence(value) && value.length >= 2) ||
      'Record at least two chords.',
    onRecord: (value) => {
      this.sequence = value
      this.problem = ''
    },
    onClear: () => {
      this.sequence = ['[KeyX]', '[KeyY]']
    },
    onReject: (r) => {
      this.problem = r.message
    },
    onCancel: () => {
      this.problem = 'Cancelled'
    },
  })
  constructor() {
    super()
    // Hotkey getters re-register when the recorded binding changes.
    this.addController(
      new HotkeyController(
        this,
        () => this.binding,
        () => this.log('Recorded shortcut fired'),
        {
          requireReset: true,
          meta: { name: 'Recorded action', group: 'Recording' },
        },
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        () => this.sequence,
        () => this.log('Recorded sequence fired'),
        { meta: { name: 'Recorded sequence', group: 'Recording' } },
      ),
    )
  }
  render() {
    return html`<section>
      <h2>Recording</h2>
      <p>Bindings live in route state. Try Alt + 1 for a conflict.</p>
      <label
        >Record by
        <select
          .value=${this.mode}
          ?disabled=${this.recorder.isRecording || this.seq.isRecording}
          @change=${(e: Event) => {
            this.mode = (e.target as HTMLSelectElement).value as RecorderKeyMode
            this.recorder.setOptions({ recordBy: this.mode })
            this.seq.setOptions({ recordBy: this.mode })
          }}
        >
          <option value="code">Physical code</option>
          <option value="key">Logical key</option>
        </select></label
      >
      <h3>Single shortcut</h3>
      <p>
        <kbd>${formatForDisplay(this.binding)}</kbd> —
        <code>${this.binding}</code>
      </p>
      <button
        ?disabled=${this.recorder.isRecording || this.seq.isRecording}
        @click=${() => this.recorder.startRecording()}
      >
        Record shortcut</button
      ><button
        @click=${() => {
          this.recorder.stopRecording()
          this.binding = this.initial
        }}
      >
        Reset</button
      >${
        this.recorder.isRecording
          ? html`<button @click=${() => this.recorder.cancelRecording()}>
              Cancel
            </button>`
          : null
      }
      <h3>Sequence</h3>
      <p>
        ${formatForDisplay(
          this.seq.isRecording ? this.seq.steps : this.sequence,
          {
            sequenceSeparator: ' → ',
          },
        )}
      </p>
      <label
        ><input
          type="checkbox"
          .checked=${this.idle}
          @change=${(e: Event) => {
            this.idle = (e.target as HTMLInputElement).checked
            this.seq.setOptions({ idleTimeoutMs: this.idle ? 1500 : undefined })
          }}
        />
        Commit after 1.5 seconds idle</label
      >
      <label
        ><input
          type="checkbox"
          .checked=${this.enter}
          @change=${(e: Event) => {
            this.enter = (e.target as HTMLInputElement).checked
            this.seq.setOptions({ commitKeys: this.enter ? 'enter' : 'none' })
          }}
        />
        Enter commits</label
      >
      <button
        ?disabled=${this.recorder.isRecording || this.seq.isRecording}
        @click=${() => this.seq.startRecording()}
      >
        Record sequence</button
      ><button
        ?disabled=${!this.seq.isRecording || !this.seq.steps.length}
        @click=${() => this.seq.commitRecording()}
      >
        Commit</button
      >${
        this.seq.isRecording
          ? html`<button @click=${() => this.seq.cancelRecording()}>
              Cancel
            </button>`
          : null
      }
      <p>
        Backspace removes a step or restores the initial binding when empty.
        Rejected steps remain editable.
      </p>
      <p role="status">${this.problem}</p>
    </section>`
  }
}

@customElement('format-demo')
class Formatting extends DemoElement {
  @state() private platform: 'mac' | 'windows' | 'linux' = 'mac'
  @state() private modifiers = false
  @state() private keys = true
  render() {
    const options = {
      platform: this.platform,
      useSymbols: { modifiers: this.modifiers, keys: this.keys },
    }
    return html`<section>
      <h2>Formatting</h2>
      <label
        >Platform
        <select
          .value=${this.platform}
          @change=${(e: Event) => {
            this.platform = (e.target as HTMLSelectElement)
              .value as typeof this.platform
          }}
        >
          <option value="mac">macOS</option>
          <option value="windows">Windows</option>
          <option value="linux">Linux</option>
        </select></label
      >
      <label
        ><input
          type="checkbox"
          .checked=${this.modifiers}
          @change=${(e: Event) => {
            this.modifiers = (e.target as HTMLInputElement).checked
          }}
        />
        Modifier symbols</label
      ><label
        ><input
          type="checkbox"
          .checked=${this.keys}
          @change=${(e: Event) => {
            this.keys = (e.target as HTMLInputElement).checked
          }}
        />
        Key symbols</label
      >
      <table>
        <thead>
          <tr>
            <th>Binding</th>
            <th>String</th>
            <th>Parts</th>
          </tr>
        </thead>
        <tbody>
          ${(
            [
              'Mod+Shift+ArrowUp',
              'Control++',
              'Alt+[KeyS]',
              'Mod+[NumpadAdd]',
            ] as const
          ).map(
            (binding) =>
              html`<tr>
                <td><code>${binding}</code></td>
                <td>${formatForDisplay(binding, options)}</td>
                <td>
                  ${formatForDisplay(binding, { ...options, parts: true }).map(
                    (part) => html`<kbd>${part}</kbd>`,
                  )}
                </td>
              </tr>`,
          )}
        </tbody>
      </table>
      <p>
        Supply layoutMap or keyLabels for layout labels. Formatting never
        changes binding identity.
      </p>
    </section>`
  }
}

@customElement('my-app')
class App extends DemoElement {
  private pages = [
    { path: '/', name: 'Tickets', hotkey: 'Alt+[Digit1]' },
    { path: '/editor', name: 'Editor', hotkey: 'Alt+[Digit2]' },
    { path: '/sequences', name: 'Sequences', hotkey: 'Alt+[Digit3]' },
    { path: '/recording', name: 'Recording', hotkey: 'Alt+[Digit4]' },
    { path: '/formatting', name: 'Formatting', hotkey: 'Alt+[Digit5]' },
  ] as const
  @state() private path = location.hash.slice(1) || '/'
  @state() private show = false
  @state() private activity: Array<string> = []
  private keys = new HeldKeysController(this)
  private codes = new HeldKeyCodesController(this)
  private shift = new KeyHoldController(this, 'Shift')
  private registry = new HotkeyRegistrationsController(this)
  private updatePath = () => {
    this.path = location.hash.slice(1) || '/'
  }
  constructor() {
    super()
    for (const page of this.pages)
      this.addController(
        new HotkeyController(
          this,
          page.hotkey,
          () => {
            location.hash = page.path
          },
          {
            ignoreInputs: true,
            requireReset: true,
            meta: { name: page.name, group: 'Navigation' },
          },
        ),
      )
    this.addController(
      new HotkeyController(
        this,
        'Alt+Shift+[KeyK]',
        () => {
          this.show = !this.show
        },
        {
          requireReset: true,
          meta: { name: 'Show shortcuts', group: 'Navigation' },
        },
      ),
    )
  }
  connectedCallback() {
    super.connectedCallback()
    window.addEventListener('hashchange', this.updatePath)
  }
  disconnectedCallback() {
    window.removeEventListener('hashchange', this.updatePath)
    super.disconnectedCallback()
  }
  private route() {
    switch (this.path) {
      case '/editor':
        return html`<editor-demo></editor-demo>`
      case '/sequences':
        return html`<sequence-demo></sequence-demo>`
      case '/recording':
        return html`<recording-demo></recording-demo>`
      case '/formatting':
        return html`<format-demo></format-demo>`
      default:
        return html`<ticket-demo></ticket-demo>`
    }
  }
  render() {
    return html`<header>
        <h1>TanStack Hotkeys kitchen sink</h1>
        <p>
          Hold Alt / Option for hints. Route changes mount and unmount
          registrations.
        </p>
        <nav>
          ${this.pages.map(
            (page) =>
              html`<a
                href=${`#${page.path}`}
                class=${this.path === page.path ? 'active' : ''}
                >${page.name}
                <shortcut-hint .hotkey=${page.hotkey}></shortcut-hint
              ></a>`,
          )}
        </nav>
        <button
          @click=${() => {
            this.show = !this.show
          }}
        >
          ${this.show ? 'Hide' : 'Show'} shortcuts
        </button>
      </header>
      ${
        this.show
          ? html`<section>
              <h2>Live registry</h2>
              <table>
                <thead>
                  <tr>
                    <th>Group / action</th>
                    <th>Binding</th>
                    <th>Status</th>
                  </tr>
                </thead>
                <tbody>
                  ${this.registry.hotkeys.map(
                    (reg) =>
                      html`<tr>
                        <td>
                          ${reg.options.meta?.group} / ${reg.options.meta?.name}
                        </td>
                        <td>
                          ${formatForDisplay(reg.hotkey, {
                            sequenceSeparator: ' → ',
                          })}
                        </td>
                        <td>
                          ${
                            reg.options.enabled === false
                              ? 'Disabled'
                              : `${reg.triggerCount} fired`
                          }
                        </td>
                      </tr>`,
                  )}
                </tbody>
              </table>
            </section>`
          : null
      }
      <main
        @activity=${(e: CustomEvent<string>) => {
          this.activity = [e.detail, ...this.activity].slice(0, 6)
        }}
      >
        ${this.route()}
      </main>
      <section>
        <h2>Key state</h2>
        <p>
          Keys: ${this.keys.value.join(' + ') || 'None'}<br />Codes:
          ${Object.values(this.codes.value).join(' + ') || 'None'}<br />Shift:
          ${this.shift.value ? 'held' : 'released'}
        </p>
        <h2>Activity</h2>
        <button
          @click=${() => {
            this.activity = []
          }}
        >
          Clear log
        </button>
        <ul aria-live="polite">
          ${this.activity.map((message) => html`<li>${message}</li>`)}
        </ul>
      </section>`
  }
}
import { LitElement, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import {
  HeldKeyCodesController,
  HeldKeysController,
  HotkeyController,
  HotkeyHintController,
  HotkeyRecorderController,
  HotkeyRegistrationsController,
  KeyHoldController,
  formatForDisplay,
  hotkey,
  isHotkeySequence,
} from '@tanstack/lit-hotkeys'
import type { Hotkey, RecorderKeyMode } from '@tanstack/lit-hotkeys'
import './styles.css'

// Light DOM keeps the shared example CSS small and makes scoped targets easy to inspect.
class DemoElement extends LitElement {
  protected createRenderRoot() {
    return this
  }
  protected log(message: string) {
    this.dispatchEvent(
      new CustomEvent('activity', {
        detail: message,
        bubbles: true,
        composed: true,
      }),
    )
  }
}

@customElement('shortcut-hint')
class Hint extends DemoElement {
  @property() hotkey: Hotkey = 'Escape'
  @property({ type: Boolean }) enabled = true
  private hint = new HotkeyHintController(this, () => this.hotkey)
  render() {
    return this.hint.value && this.enabled
      ? html`<kbd>${formatForDisplay(this.hotkey)}</kbd>`
      : null
  }
}

@customElement('ticket-demo')
class Tickets extends DemoElement {
  @state() private count = 0
  @state() private enabled = true
  private create = () => {
    this.count++
    this.log('Ticket created')
  }
  private save = () => this.log('Ticket saved')
  constructor() {
    super()
    this.addController(
      new HotkeyController(this, 'Alt+[KeyC]', this.create, () => ({
        enabled: this.enabled,
        requireReset: true,
        ignoreInputs: false,
        meta: { name: 'Create ticket', group: 'Tickets' },
      })),
    )
    this.addController(
      new HotkeyController(this, 'Alt+[KeyS]', this.save, () => ({
        enabled: this.enabled,
        requireReset: true,
        meta: { name: 'Save ticket', group: 'Tickets' },
      })),
    )
  }
  private toggle = (event: Event) => {
    this.enabled = (event.target as HTMLInputElement).checked
  }
  render() {
    return html`<section>
      <h2>Tickets</h2>
      <p>Created: ${this.count}. Buttons and shortcuts share handlers.</p>
      <label
        ><input
          type="checkbox"
          .checked=${this.enabled}
          @change=${this.toggle}
        />
        Enable ticket actions</label
      >
      <button ?disabled=${!this.enabled} @click=${this.create}>
        Create ticket
        <shortcut-hint
          hotkey="Alt+[KeyC]"
          .enabled=${this.enabled}
        ></shortcut-hint>
      </button>
      <button ?disabled=${!this.enabled} @click=${this.save}>
        Save pending
        <shortcut-hint
          hotkey="Alt+[KeyS]"
          .enabled=${this.enabled}
        ></shortcut-hint>
      </button>
      <label
        >Ticket note <input placeholder="Try Alt + C while typing"
      /></label>
      <p>
        Create uses ignoreInputs: false; Save uses default filtering. Release
        before firing again.
      </p>
    </section>`
  }
}

@customElement('editor-pane')
class EditorPane extends DemoElement {
  @property() name = 'Editor'
  @state() private text = 'One shortcut, two independent editors.'
  @state() private saves = 0
  constructor() {
    super()
    this.addController(
      new HotkeyController(
        this,
        'Mod+[KeyS]',
        () => {
          this.saves++
          this.log(`${this.name} saved`)
        },
        {
          target: this,
          ignoreInputs: false,
          requireReset: true,
          meta: { name: 'Save editor', group: 'Editor' },
        },
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        'Alt+[Backspace]',
        () => {
          this.text = ''
        },
        {
          target: this,
          ignoreInputs: false,
          meta: { name: 'Clear editor', group: 'Editor' },
        },
      ),
    )
  }
  render() {
    return html`<fieldset>
      <legend>${this.name} — ${this.saves} saves</legend>
      <label
        >Editor content<textarea
          rows="3"
          .value=${this.text}
          @input=${(e: Event) => {
            this.text = (e.target as HTMLTextAreaElement).value
          }}
        ></textarea>
      </label>
      <p>
        <kbd>${formatForDisplay('Mod+[KeyS]')}</kbd> Save;
        <kbd>${formatForDisplay('Alt+[Backspace]')}</kbd> Clear
      </p>
    </fieldset>`
  }
}

@customElement('editor-demo')
class Editor extends DemoElement {
  @state() private count = 0
  @state() private repeat = false
  @state() private bubble = false
  constructor() {
    super()
    this.addController(
      new HotkeyController(
        this,
        'Alt+[ArrowRight]',
        () => this.count++,
        () => ({
          requireReset: !this.repeat,
          meta: { name: 'Counter', group: 'Editor' },
        }),
      ),
    )
    this.addController(
      // A null target waits until #scope renders.
      new HotkeyController(
        this,
        'Alt+[KeyB]',
        () => this.log('Target B'),
        () => ({
          target: this.querySelector<HTMLElement>('#scope'),
          preventDefault: !this.bubble,
          stopPropagation: !this.bubble,
          meta: { name: 'Target propagation', group: 'Editor' },
        }),
      ),
    )
  }
  @hotkey('Alt+[KeyU]', {
    eventType: 'keyup',
    meta: { name: 'Keyup', group: 'Editor' },
  })
  released() {
    this.log('Keyup U')
  }
  @hotkey('Alt+[KeyB]', {
    meta: { name: 'Document propagation', group: 'Editor' },
  })
  documentB() {
    this.log('Document B')
  }
  render() {
    return html`<section>
      <h2>Editor targets</h2>
      <p>Focus an editor to choose its Save handler.</p>
      <editor-pane name="Draft"></editor-pane
      ><editor-pane name="Notes"></editor-pane>
      <label
        ><input
          type="checkbox"
          .checked=${this.repeat}
          @change=${(e: Event) => {
            this.repeat = (e.target as HTMLInputElement).checked
          }}
        />
        Allow repeat</label
      >
      <p>
        Hold Alt + ArrowRight: ${this.count}. Release Alt + U for a keyup event.
      </p>
      <fieldset id="scope" tabindex="0">
        <legend>Propagation</legend>
        <label
          ><input
            type="checkbox"
            .checked=${this.bubble}
            @change=${(e: Event) => {
              this.bubble = (e.target as HTMLInputElement).checked
            }}
          />
          Allow bubbling and browser defaults</label
        >
        <p>Focus here and press Alt + B; watch Activity.</p>
        <button
          @click=${() => this.querySelector<HTMLElement>('#scope')?.focus()}
        >
          Focus this area
        </button>
      </fieldset>
    </section>`
  }
}

@customElement('sequence-demo')
class Sequences extends DemoElement {
  @state() private timeout = 1000
  private registry = new HotkeyRegistrationsController(this)
  constructor() {
    super()
    this.addController(
      // An array of chords registers a sequence.
      new HotkeyController(
        this,
        ['Shift+[KeyR]', 'Shift+[KeyT]'],
        () => this.log('Shift chain'),
        { meta: { name: 'Shift chain (1000ms)', group: 'Sequences' } },
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        ['[KeyG]', '[KeyG]'],
        () => this.log('Go to top'),
        () => ({
          timeout: this.timeout,
          meta: { name: 'Go to top', group: 'Sequences' },
        }),
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        ['[KeyG]', '[KeyI]'],
        () => this.log('Inbox'),
        () => ({
          timeout: this.timeout,
          meta: { name: 'Inbox', group: 'Sequences' },
        }),
      ),
    )
  }
  render() {
    return html`<section>
      <h2>Sequences</h2>
      <p>
        Physical G then G, G then I, or Shift + R then Shift + T. Repeats do not
        advance steps.
      </p>
      <label
        >G sequence timeout: ${this.timeout} ms
        <input
          type="range"
          min="300"
          max="2500"
          step="100"
          .value=${String(this.timeout)}
          @input=${(e: Event) => {
            this.timeout = Number((e.target as HTMLInputElement).value)
          }}
      /></label>
      <table>
        <tbody>
          ${this.registry.hotkeys.map((reg) =>
            reg.kind !== 'sequence'
              ? null
              : html`<tr>
                  <td>${reg.options.meta?.name}</td>
                  <td>
                    ${formatForDisplay(reg.hotkey, { sequenceSeparator: ' → ' })}
                  </td>
                  <td>
                    ${reg.matchedStepCount}/${reg.hotkey.length} steps;
                    ${reg.triggerCount} fired
                  </td>
                </tr>`,
          )}
        </tbody>
      </table>
    </section>`
  }
}

@customElement('recording-demo')
class Recording extends DemoElement {
  private initial: Hotkey = 'Alt+[KeyR]'
  @state() private binding: Hotkey = this.initial
  @state() private sequence: Hotkey = ['[KeyX]', '[KeyY]']
  @state() private mode: RecorderKeyMode = 'code'
  @state() private problem = ''
  @state() private idle = false
  @state() private enter = true
  private recorder = new HotkeyRecorderController(this, {
    ignoreInputs: false,
    detectConflicts: {
      exclude: (reg) => reg.options.meta?.name === 'Recorded action',
    },
    validate: (_, { parsedChord }) =>
      parsedChord.modifiers.length > 0 || 'Include a modifier.',
    onRecord: (value) => {
      this.binding = value
      this.problem = ''
    },
    onClear: () => {
      this.binding = this.initial
    },
    onReject: (r) => {
      this.problem = r.message
    },
    onCancel: () => {
      this.problem = 'Cancelled'
    },
  })
  // maxSteps above 1 records sequences; one step records a chord.
  private seq = new HotkeyRecorderController(this, {
    maxSteps: Infinity,
    ignoreInputs: false,
    detectConflicts: {
      exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
    },
    validate: (value) =>
      (isHotkeySequence(value) && value.length >= 2) ||
      'Record at least two chords.',
    onRecord: (value) => {
      this.sequence = value
      this.problem = ''
    },
    onClear: () => {
      this.sequence = ['[KeyX]', '[KeyY]']
    },
    onReject: (r) => {
      this.problem = r.message
    },
    onCancel: () => {
      this.problem = 'Cancelled'
    },
  })
  constructor() {
    super()
    // Hotkey getters re-register when the recorded binding changes.
    this.addController(
      new HotkeyController(
        this,
        () => this.binding,
        () => this.log('Recorded shortcut fired'),
        {
          requireReset: true,
          meta: { name: 'Recorded action', group: 'Recording' },
        },
      ),
    )
    this.addController(
      new HotkeyController(
        this,
        () => this.sequence,
        () => this.log('Recorded sequence fired'),
        { meta: { name: 'Recorded sequence', group: 'Recording' } },
      ),
    )
  }
  render() {
    return html`<section>
      <h2>Recording</h2>
      <p>Bindings live in route state. Try Alt + 1 for a conflict.</p>
      <label
        >Record by
        <select
          .value=${this.mode}
          ?disabled=${this.recorder.isRecording || this.seq.isRecording}
          @change=${(e: Event) => {
            this.mode = (e.target as HTMLSelectElement).value as RecorderKeyMode
            this.recorder.setOptions({ recordBy: this.mode })
            this.seq.setOptions({ recordBy: this.mode })
          }}
        >
          <option value="code">Physical code</option>
          <option value="key">Logical key</option>
        </select></label
      >
      <h3>Single shortcut</h3>
      <p>
        <kbd>${formatForDisplay(this.binding)}</kbd> —
        <code>${this.binding}</code>
      </p>
      <button
        ?disabled=${this.recorder.isRecording || this.seq.isRecording}
        @click=${() => this.recorder.startRecording()}
      >
        Record shortcut</button
      ><button
        @click=${() => {
          this.recorder.stopRecording()
          this.binding = this.initial
        }}
      >
        Reset</button
      >${
        this.recorder.isRecording
          ? html`<button @click=${() => this.recorder.cancelRecording()}>
              Cancel
            </button>`
          : null
      }
      <h3>Sequence</h3>
      <p>
        ${formatForDisplay(
          this.seq.isRecording ? this.seq.steps : this.sequence,
          {
            sequenceSeparator: ' → ',
          },
        )}
      </p>
      <label
        ><input
          type="checkbox"
          .checked=${this.idle}
          @change=${(e: Event) => {
            this.idle = (e.target as HTMLInputElement).checked
            this.seq.setOptions({ idleTimeoutMs: this.idle ? 1500 : undefined })
          }}
        />
        Commit after 1.5 seconds idle</label
      >
      <label
        ><input
          type="checkbox"
          .checked=${this.enter}
          @change=${(e: Event) => {
            this.enter = (e.target as HTMLInputElement).checked
            this.seq.setOptions({ commitKeys: this.enter ? 'enter' : 'none' })
          }}
        />
        Enter commits</label
      >
      <button
        ?disabled=${this.recorder.isRecording || this.seq.isRecording}
        @click=${() => this.seq.startRecording()}
      >
        Record sequence</button
      ><button
        ?disabled=${!this.seq.isRecording || !this.seq.steps.length}
        @click=${() => this.seq.commitRecording()}
      >
        Commit</button
      >${
        this.seq.isRecording
          ? html`<button @click=${() => this.seq.cancelRecording()}>
              Cancel
            </button>`
          : null
      }
      <p>
        Backspace removes a step or restores the initial binding when empty.
        Rejected steps remain editable.
      </p>
      <p role="status">${this.problem}</p>
    </section>`
  }
}

@customElement('format-demo')
class Formatting extends DemoElement {
  @state() private platform: 'mac' | 'windows' | 'linux' = 'mac'
  @state() private modifiers = false
  @state() private keys = true
  render() {
    const options = {
      platform: this.platform,
      useSymbols: { modifiers: this.modifiers, keys: this.keys },
    }
    return html`<section>
      <h2>Formatting</h2>
      <label
        >Platform
        <select
          .value=${this.platform}
          @change=${(e: Event) => {
            this.platform = (e.target as HTMLSelectElement)
              .value as typeof this.platform
          }}
        >
          <option value="mac">macOS</option>
          <option value="windows">Windows</option>
          <option value="linux">Linux</option>
        </select></label
      >
      <label
        ><input
          type="checkbox"
          .checked=${this.modifiers}
          @change=${(e: Event) => {
            this.modifiers = (e.target as HTMLInputElement).checked
          }}
        />
        Modifier symbols</label
      ><label
        ><input
          type="checkbox"
          .checked=${this.keys}
          @change=${(e: Event) => {
            this.keys = (e.target as HTMLInputElement).checked
          }}
        />
        Key symbols</label
      >
      <table>
        <thead>
          <tr>
            <th>Binding</th>
            <th>String</th>
            <th>Parts</th>
          </tr>
        </thead>
        <tbody>
          ${(
            [
              'Mod+Shift+ArrowUp',
              'Control++',
              'Alt+[KeyS]',
              'Mod+[NumpadAdd]',
            ] as const
          ).map(
            (binding) =>
              html`<tr>
                <td><code>${binding}</code></td>
                <td>${formatForDisplay(binding, options)}</td>
                <td>
                  ${formatForDisplay(binding, { ...options, parts: true }).map(
                    (part) => html`<kbd>${part}</kbd>`,
                  )}
                </td>
              </tr>`,
          )}
        </tbody>
      </table>
      <p>
        Supply layoutMap or keyLabels for layout labels. Formatting never
        changes binding identity.
      </p>
    </section>`
  }
}

@customElement('my-app')
class App extends DemoElement {
  private pages = [
    { path: '/', name: 'Tickets', hotkey: 'Alt+[Digit1]' },
    { path: '/editor', name: 'Editor', hotkey: 'Alt+[Digit2]' },
    { path: '/sequences', name: 'Sequences', hotkey: 'Alt+[Digit3]' },
    { path: '/recording', name: 'Recording', hotkey: 'Alt+[Digit4]' },
    { path: '/formatting', name: 'Formatting', hotkey: 'Alt+[Digit5]' },
  ] as const
  @state() private path = location.hash.slice(1) || '/'
  @state() private show = false
  @state() private activity: Array<string> = []
  private keys = new HeldKeysController(this)
  private codes = new HeldKeyCodesController(this)
  private shift = new KeyHoldController(this, 'Shift')
  private registry = new HotkeyRegistrationsController(this)
  private updatePath = () => {
    this.path = location.hash.slice(1) || '/'
  }
  constructor() {
    super()
    for (const page of this.pages)
      this.addController(
        new HotkeyController(
          this,
          page.hotkey,
          () => {
            location.hash = page.path
          },
          {
            ignoreInputs: true,
            requireReset: true,
            meta: { name: page.name, group: 'Navigation' },
          },
        ),
      )
    this.addController(
      new HotkeyController(
        this,
        'Alt+Shift+[KeyK]',
        () => {
          this.show = !this.show
        },
        {
          requireReset: true,
          meta: { name: 'Show shortcuts', group: 'Navigation' },
        },
      ),
    )
  }
  connectedCallback() {
    super.connectedCallback()
    window.addEventListener('hashchange', this.updatePath)
  }
  disconnectedCallback() {
    window.removeEventListener('hashchange', this.updatePath)
    super.disconnectedCallback()
  }
  private route() {
    switch (this.path) {
      case '/editor':
        return html`<editor-demo></editor-demo>`
      case '/sequences':
        return html`<sequence-demo></sequence-demo>`
      case '/recording':
        return html`<recording-demo></recording-demo>`
      case '/formatting':
        return html`<format-demo></format-demo>`
      default:
        return html`<ticket-demo></ticket-demo>`
    }
  }
  render() {
    return html`<header>
        <h1>TanStack Hotkeys kitchen sink</h1>
        <p>
          Hold Alt / Option for hints. Route changes mount and unmount
          registrations.
        </p>
        <nav>
          ${this.pages.map(
            (page) =>
              html`<a
                href=${`#${page.path}`}
                class=${this.path === page.path ? 'active' : ''}
                >${page.name}
                <shortcut-hint .hotkey=${page.hotkey}></shortcut-hint
              ></a>`,
          )}
        </nav>
        <button
          @click=${() => {
            this.show = !this.show
          }}
        >
          ${this.show ? 'Hide' : 'Show'} shortcuts
        </button>
      </header>
      ${
        this.show
          ? html`<section>
              <h2>Live registry</h2>
              <table>
                <thead>
                  <tr>
                    <th>Group / action</th>
                    <th>Binding</th>
                    <th>Status</th>
                  </tr>
                </thead>
                <tbody>
                  ${this.registry.hotkeys.map(
                    (reg) =>
                      html`<tr>
                        <td>
                          ${reg.options.meta?.group} / ${reg.options.meta?.name}
                        </td>
                        <td>
                          ${formatForDisplay(reg.hotkey, {
                            sequenceSeparator: ' → ',
                          })}
                        </td>
                        <td>
                          ${
                            reg.options.enabled === false
                              ? 'Disabled'
                              : `${reg.triggerCount} fired`
                          }
                        </td>
                      </tr>`,
                  )}
                </tbody>
              </table>
            </section>`
          : null
      }
      <main
        @activity=${(e: CustomEvent<string>) => {
          this.activity = [e.detail, ...this.activity].slice(0, 6)
        }}
      >
        ${this.route()}
      </main>
      <section>
        <h2>Key state</h2>
        <p>
          Keys: ${this.keys.value.join(' + ') || 'None'}<br />Codes:
          ${Object.values(this.codes.value).join(' + ') || 'None'}<br />Shift:
          ${this.shift.value ? 'held' : 'released'}
        </p>
        <h2>Activity</h2>
        <button
          @click=${() => {
            this.activity = []
          }}
        >
          Clear log
        </button>
        <ul aria-live="polite">
          ${this.activity.map((message) => html`<li>${message}</li>`)}
        </ul>
      </section>`
  }
}