Ember Example: UseHotkeySequences

import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'

import { on } from '@ember/modifier'

import {
  formatForDisplay,
  useHotkeySequences,
  useHotkey,
  useHotkeyRegistrations,
} from '@tanstack/ember-hotkeys'
import type {
  Hotkey,
  HotkeySequenceDefinition,
  HotkeyCallback,
} from '@tanstack/ember-hotkeys'
const eq = (a: unknown, b: unknown) => a === b
const neq = (a: unknown, b: unknown) => a !== b
const gt = (a: number, b: number) => a > b
const and = (a: unknown, b: unknown) => a && b
const or = <T, U>(a: T, b: U) => a || b

class App extends Component {
  usage0 =
    "get definitions() {\n  return [\n    { sequence: ['G', 'G'], callback: this.scrollToTop,\n      options: { meta: { name: 'Go to top' } } },\n    { sequence: ['C', 'I', 'W'], callback: this.changeInnerWord,\n      options: { meta: { name: 'Change inner word' } } },\n  ]\n}\nregistrations = useHotkeyRegistrations(this)\n\n<template>{{useHotkeySequences this.definitions}}</template>"
  @tracked lastSequence: string | null = null
  @tracked history: Array<string> = []
  @tracked helloSequenceEnabled: boolean = true
  toggleHello = () => (this.helloSequenceEnabled = !this.helloSequenceEnabled)
  clearHistory = () => (this.history = [])
  addToHistory = (action: string) => {
    this.lastSequence = action
    this.history = [...this.history.slice(-9), action]
  }
  get sequenceDefinitions(): Array<HotkeySequenceDefinition> {
    return [
      {
        sequence: ['G', 'G'],
        callback: () => this.addToHistory('gg → Go to top'),
        options: {
          meta: {
            name: 'Go to top',
            description: 'Scroll to the beginning of the document',
          },
        },
      },
      {
        sequence: ['Shift+G'],
        callback: () => this.addToHistory('G → Go to bottom'),
        options: {
          meta: {
            name: 'Go to bottom',
            description: 'Scroll to the end of the document',
          },
        },
      },
      {
        sequence: ['D', 'D'],
        callback: () => this.addToHistory('dd → Delete line'),
        options: {
          meta: { name: 'Delete line', description: 'Delete the current line' },
        },
      },
      {
        sequence: ['Y', 'Y'],
        callback: () => this.addToHistory('yy → Yank (copy) line'),
        options: {
          meta: {
            name: 'Yank line',
            description: 'Copy the current line to clipboard',
          },
        },
      },
      {
        sequence: ['D', 'W'],
        callback: () => this.addToHistory('dw → Delete word'),
        options: {
          meta: {
            name: 'Delete word',
            description: 'Delete from cursor to end of word',
          },
        },
      },
      {
        sequence: ['C', 'I', 'W'],
        callback: () => this.addToHistory('ciw → Change inner word'),
        options: {
          meta: {
            name: 'Change inner word',
            description: 'Delete word under cursor and enter insert mode',
          },
        },
      },
      {
        sequence: ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown'],
        callback: () => this.addToHistory('↑↑↓↓ → Konami code (partial)'),
        options: {
          timeout: 1500,
          meta: {
            name: 'Konami code',
            description: 'Partial Konami code using arrow keys',
          },
        },
      },
      {
        sequence: ['ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight'],
        callback: () => this.addToHistory('←→←→ → Side to side!'),
        options: {
          timeout: 1500,
          meta: {
            name: 'Side to side',
            description: 'Left-right-left-right arrow pattern',
          },
        },
      },
      {
        sequence: ['H', 'E', 'L', 'L', 'O'],
        callback: () => this.addToHistory('hello → Hello World!'),
        options: {
          enabled: this.helloSequenceEnabled,
          meta: { name: 'Hello', description: 'Spell out hello to trigger' },
        },
      },
      {
        sequence: ['Shift+R', 'Shift+T'],
        callback: () =>
          this.addToHistory('⇧R ⇧T → Chained Shift+letter (2 steps)'),
        options: {
          meta: {
            name: 'Chained Shift',
            description: 'Two consecutive Shift+letter chords',
          },
        },
      },
    ]
  }
  onEscape: HotkeyCallback = () => {
    this.lastSequence = null
    this.history = []
  }

  <template>
    {{useHotkeySequences this.sequenceDefinitions}}
    {{useHotkey 'Escape' this.onEscape}}
    <div class='app'>
      <header>
        <h1>useHotkeySequences</h1>
        <p>
          Register many multi-key sequences in one hook (like Vim commands). Keys
          must be pressed within the timeout window (default: 1000ms).
        </p>
      </header>

      <main>
        <section class='demo-section'>
          <h2>Vim-Style Commands</h2>
          <table class='sequence-table'>
            <thead>
              <tr>
                <th>Sequence</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
              <tr>
                <td>
                  <kbd>g</kbd>
                  <kbd>g</kbd>
                </td>
                <td>Go to top</td>
              </tr>
              <tr>
                <td>
                  <kbd>G</kbd>
                  (Shift+G)
                </td>
                <td>Go to bottom</td>
              </tr>
              <tr>
                <td>
                  <kbd>d</kbd>
                  <kbd>d</kbd>
                </td>
                <td>Delete line</td>
              </tr>
              <tr>
                <td>
                  <kbd>y</kbd>
                  <kbd>y</kbd>
                </td>
                <td>Yank (copy) line</td>
              </tr>
              <tr>
                <td>
                  <kbd>d</kbd>
                  <kbd>w</kbd>
                </td>
                <td>Delete word</td>
              </tr>
              <tr>
                <td>
                  <kbd>c</kbd>
                  <kbd>i</kbd>
                  <kbd>w</kbd>
                </td>
                <td>Change inner word</td>
              </tr>
            </tbody>
          </table>
        </section>

        <section class='demo-section'>
          <h2>Fun Sequences</h2>
          <div class='fun-sequences'>
            <div class='sequence-card'>
              <h3>Konami Code (Partial)</h3>
              <p>
                <kbd>↑</kbd>
                <kbd>↑</kbd>
                <kbd>↓</kbd>
                <kbd>↓</kbd>
              </p>
              <span class='hint'>Use arrow keys within 1.5 seconds</span>
            </div>
            <div class='sequence-card'>
              <h3>Side to Side</h3>
              <p>
                <kbd>←</kbd>
                <kbd>→</kbd>
                <kbd>←</kbd>
                <kbd>→</kbd>
              </p>
              <span class='hint'>Arrow keys within 1.5 seconds</span>
            </div>
            <div class='sequence-card'>
              <h3>Spell It Out</h3>
              <p>
                <kbd>h</kbd>
                <kbd>e</kbd>
                <kbd>l</kbd>
                <kbd>l</kbd>
                <kbd>o</kbd>
              </p>
              <span class='hint'>Type "hello" quickly</span>
              <p class='sequence-toggle-status'>
                This sequence is
                <strong>{{if
                    this.helloSequenceEnabled
                    'enabled'
                    'disabled'
                  }}</strong>
                .
              </p>
              <button type='button' {{on 'click' this.toggleHello}}>
                {{if this.helloSequenceEnabled 'Disable' 'Enable'}}
                sequence
              </button>
            </div>
          </div>
        </section>

        {{#if this.lastSequence}}<div class='info-box success'>
            <strong>Triggered:</strong>
            {{this.lastSequence}}
          </div>{{/if}}

        <section class='demo-section'>
          <h2>Input handling</h2>
          <p>
            Sequences are not detected when typing in text inputs, textareas,
            selects, or contenteditable elements. Button-type inputs (
            <code>type="button"</code>,
            <code>submit</code>,
            <code>reset</code>) still receive sequences. Focus the input below and
            try
            <kbd>g</kbd>
            <kbd>g</kbd>
            or
            <kbd>h</kbd>
            <kbd>e</kbd>
            <kbd>l</kbd>
            <kbd>l</kbd>
            <kbd>o</kbd>
            — nothing will trigger. Click outside to try again.
          </p>
          <input
            type='text'
            class='demo-input'
            placeholder="Focus here – sequences won't trigger while typing..."
          />
        </section>

        <section class='demo-section'>
          <h2>Chained Shift+letter sequences</h2>
          <p>
            Each step is a chord: hold
            <kbd>Shift</kbd>
            and press a letter. You can press
            <kbd>Shift</kbd>
            alone between steps—those modifier-only presses do not reset progress,
            so the next chord still counts.
          </p>
          <table class='sequence-table'>
            <thead>
              <tr>
                <th>Sequence</th>
                <th>Action</th>
              </tr>
            </thead>
            <tbody>
              <tr>
                <td>
                  <kbd>Shift</kbd>+<kbd>r</kbd>
                  then
                  <kbd>Shift</kbd>+
                  <kbd>t</kbd>
                </td>
                <td>Chained Shift+letter (2 steps)</td>
              </tr>
            </tbody>
          </table>
        </section>

        <RegistrationsViewer />

        <section class='demo-section'>
          <h2>Usage</h2>
          <pre class='code-block'>{{this.usage0}}</pre>
        </section>

        {{#if (gt this.history.length 0)}}<section class='demo-section'>
            <h2>History</h2>
            <ul class='history-list'>
              {{#each this.history as |item i|}}<li>{{item}}</li>{{/each}}
            </ul>
            <button {{on 'click' this.clearHistory}}>Clear History</button>
          </section>{{/if}}

        <p class='hint'>
          Press
          <kbd>Escape</kbd>
          to clear history
        </p>
      </main>

    </div>
  </template>
}

class RegistrationsViewer extends Component {
  registrationsState = useHotkeyRegistrations(this)
  get hotkeys() {
    return this.registrationsState.hotkeys
  }
  get sequences() {
    return this.registrationsState.sequences
  }

  <template>
    <section class='demo-section'>
      <h2>Live Registrations (useHotkeyRegistrations)</h2>
      <p>
        This table is rendered from
        <code>useHotkeyRegistrations()</code>
        — a reactive view of all registered hotkeys and sequences. Trigger counts
        update in real-time.
      </p>

      {{#if (gt this.hotkeys.length 0)}}
        <h3>Hotkeys</h3>
        <table class='registrations-table'>
          <thead>
            <tr>
              <th>Hotkey</th>
              <th>Name</th>
              <th>Description</th>
              <th>Triggers</th>
            </tr>
          </thead>
          <tbody>
            {{#each this.hotkeys key='id' as |reg|}}<tr>
                <td>
                  <kbd>{{formatForDisplay reg.hotkey}}</kbd>
                </td>
                <td>{{or reg.options.meta.name '—'}}</td>
                <td class='description-cell'>
                  {{or reg.options.meta.description '—'}}
                </td>
                <td class='trigger-count'>{{reg.triggerCount}}</td>
              </tr>{{/each}}
          </tbody>
        </table>
      {{/if}}

      <h3>Sequences ({{this.sequences.length}})</h3>
      <table class='registrations-table'>
        <thead>
          <tr>
            <th>Sequence</th>
            <th>Name</th>
            <th>Description</th>
            <th>Enabled</th>
            <th>Triggers</th>
          </tr>
        </thead>
        <tbody>
          {{#each this.sequences key='id' as |reg|}}<tr>
              <td>
                {{#each reg.sequence as |s i|}}
                  {{and (gt i 0) ' '}}
                  <kbd>{{formatForDisplay s}}</kbd>
                {{/each}}
              </td>
              <td>{{or reg.options.meta.name '—'}}</td>
              <td class='description-cell'>
                {{or reg.options.meta.description '—'}}
              </td>
              <td>
                <span
                  class={{if
                    (neq reg.options.enabled false)
                    'status-on'
                    'status-off'
                  }}
                >
                  {{if (neq reg.options.enabled false) 'yes' 'no'}}
                </span>
              </td>
              <td class='trigger-count'>{{reg.triggerCount}}</td>
            </tr>{{/each}}
          {{#if (eq this.sequences.length 0)}}<tr>
              <td colspan={{5}} class='hint'>
                No sequences registered
              </td>
            </tr>{{/if}}
        </tbody>
      </table>
    </section>
  </template>
}
export default App