TanStack

Svelte Example: Create Hotkey Recorder Sequences

<script lang="ts">
  import {
    createHotkeyRecorder,
    createHotkeys,
    formatForDisplay,
    getHotkeyRegistrations,
  } from '@tanstack/svelte-hotkeys'
  import type { Hotkey } from '@tanstack/svelte-hotkeys'
  import ShortcutListItem from './ShortcutListItem.svelte'

  interface Shortcut {
    id: string
    name: string
    description: string
    /** A chord or a sequence; null when unassigned */
    hotkey: Hotkey | null
  }

  let nextId = 0
  function createId(): string {
    return `shortcut_${++nextId}`
  }

  let shortcuts = $state<Array<Shortcut>>([
    {
      id: createId(),
      name: 'Save',
      description: 'Save the current document',
      hotkey: 'Mod+S',
    },
    {
      id: createId(),
      name: 'Open (gg)',
      description: 'Open the file browser',
      hotkey: ['G', 'G'],
    },
    {
      id: createId(),
      name: 'New (dd)',
      description: 'Create a new document',
      hotkey: ['D', 'D'],
    },
    {
      id: createId(),
      name: 'Close',
      description: 'Close the current tab',
      hotkey: 'Mod+Shift+K',
    },
    {
      id: createId(),
      name: 'Undo (yy)',
      description: 'Undo the last action',
      hotkey: ['Y', 'Y'],
    },
    {
      id: createId(),
      name: 'Redo',
      description: 'Redo the last undone action',
      hotkey: 'Mod+Shift+G',
    },
  ])

  let editingId = $state<string | null>(null)
  let draftName = $state('')
  let draftDescription = $state('')

  const recorder = createHotkeyRecorder({
    // Record up to four chords; Enter or the fourth chord commits.
    maxSteps: 4,
    onRecord: (hotkey: Hotkey) => {
      if (editingId) {
        shortcuts = shortcuts.map((s) =>
          s.id === editingId
            ? {
                ...s,
                hotkey,
                name: draftName,
                description: draftDescription,
              }
            : s,
        )
        editingId = null
      }
    },
    onCancel: () => {
      if (editingId) {
        const shortcut = shortcuts.find((s) => s.id === editingId)
        if (shortcut && shortcut.hotkey === null) {
          shortcuts = shortcuts.filter((s) => s.id !== editingId)
        }
      }
      editingId = null
    },
    onClear: () => {
      if (editingId) {
        shortcuts = shortcuts.map((s) =>
          s.id === editingId
            ? {
                ...s,
                hotkey: null,
                name: draftName,
                description: draftDescription,
              }
            : s,
        )
        editingId = null
      }
    },
  })

  let isRecording = $derived(recorder.isRecording)

  // Register every assigned shortcut, chords and sequences alike, with meta
  createHotkeys(() =>
    shortcuts.flatMap((s) =>
      s.hotkey === null
        ? []
        : [
            {
              hotkey: s.hotkey,
              callback: () => {
                console.log(`${s.name} triggered:`, s.hotkey)
              },
              options: {
                meta: { name: s.name, description: s.description },
              },
            },
          ],
    ),
  )

  function handleEdit(id: string) {
    const shortcut = shortcuts.find((s) => s.id === id)
    if (!shortcut) return
    editingId = id
    draftName = shortcut.name
    draftDescription = shortcut.description
    recorder.startRecording()
  }

  function handleSaveEditing() {
    if (editingId) {
      shortcuts = shortcuts.map((s) =>
        s.id === editingId
          ? { ...s, name: draftName, description: draftDescription }
          : s,
      )
      recorder.stopRecording()
      editingId = null
    }
  }

  function handleCancel() {
    recorder.cancelRecording()
  }

  function handleDelete(id: string) {
    shortcuts = shortcuts.filter((s) => s.id !== id)
  }

  function handleCreateNew() {
    const newShortcut: Shortcut = {
      id: createId(),
      name: '',
      description: '',
      hotkey: null,
    }
    shortcuts = [...shortcuts, newShortcut]
    editingId = newShortcut.id
    draftName = ''
    draftDescription = ''
    recorder.startRecording()
  }

  // Registrations viewer
  const registrations = getHotkeyRegistrations()
</script>

<div class="app">
  <header>
    <h1>Recording Sequences</h1>
    <p>
      <code>createHotkeyRecorder</code> with <code>maxSteps</code> records up to that
      many chords. Click Edit, press each chord in order, then press Enter (or reach
      four chords) to save. One chord saves as a regular hotkey. Escape cancels; Backspace
      removes the last chord or clears when empty.
    </p>
  </header>

  <main>
    <section class="demo-section">
      <h2>Shortcuts</h2>
      <div class="shortcuts-list">
        {#each shortcuts as shortcut (shortcut.id)}
          <ShortcutListItem
            {shortcut}
            isEditing={editingId === shortcut.id}
            draftName={editingId === shortcut.id ? draftName : shortcut.name}
            draftDescription={editingId === shortcut.id
              ? draftDescription
              : shortcut.description}
            onDraftNameChange={(v) => (draftName = v)}
            onDraftDescriptionChange={(v) => (draftDescription = v)}
            liveSteps={recorder.steps}
            onEdit={() => handleEdit(shortcut.id)}
            onSave={handleSaveEditing}
            onCancel={handleCancel}
            onDelete={() => handleDelete(shortcut.id)}
          />
        {/each}
      </div>
      <button
        type="button"
        class="create-button"
        onclick={handleCreateNew}
        disabled={isRecording}
      >
        + Create New Shortcut
      </button>
    </section>

    {#if recorder.isRecording}
      <div class="info-box recording-notice">
        <strong>Recording...</strong> Press up to four chords, then Enter to
        finish. Escape cancels. Backspace removes the last chord or clears.
        {#if recorder.steps.length > 0}
          <div>
            Steps:
            <kbd>{formatForDisplay(recorder.steps)}</kbd>
          </div>
        {/if}
      </div>
    {/if}

    <!-- Registrations Viewer -->
    <section class="demo-section">
      <h2>Live Registrations</h2>
      <p>
        This table is powered by <code>getHotkeyRegistrations()</code> — trigger counts,
        names, and descriptions update in real-time as you use your shortcuts.
      </p>
      <table class="registrations-table">
        <thead>
          <tr>
            <th>Hotkey</th>
            <th>Name</th>
            <th>Description</th>
            <th>Progress</th>
            <th>Enabled</th>
            <th>Triggers</th>
          </tr>
        </thead>
        <tbody>
          {#each registrations.hotkeys as reg (reg.id)}
            <tr>
              <td>
                <kbd>{formatForDisplay(reg.hotkey)}</kbd>
              </td>
              <td>{reg.options.meta?.name ?? '—'}</td>
              <td class="description-cell">
                {reg.options.meta?.description ?? '—'}
              </td>
              <td>
                {reg.kind === 'sequence'
                  ? `${reg.matchedStepCount}/${reg.hotkey.length}`
                  : '—'}
              </td>
              <td>
                <span
                  class={reg.options.enabled !== false
                    ? 'status-on'
                    : 'status-off'}
                >
                  {reg.options.enabled !== false ? 'yes' : 'no'}
                </span>
              </td>
              <td class="trigger-count">{reg.triggerCount}</td>
            </tr>
          {/each}
          {#if registrations.hotkeys.length === 0}
            <tr>
              <td colspan="6" class="hint">No hotkeys registered</td>
            </tr>
          {/if}
        </tbody>
      </table>
    </section>

    <section class="demo-section">
      <h2>Usage</h2>
      <pre class="code-block">{`import {
  createHotkeys,
  createHotkeyRecorder,
  getHotkeyRegistrations,
} from '@tanstack/svelte-hotkeys'

// Record a chord or a sequence of up to four chords
const recorder = createHotkeyRecorder({
  maxSteps: 4,
  onRecord: (hotkey) => save(hotkey), // 'Mod+S' or ['G', 'G']
})

// Register chords and sequences together, with meta
createHotkeys(() =>
  shortcuts.map((s) => ({
    hotkey: s.hotkey,
    callback: () => handleAction(s.id),
    options: {
      meta: { name: s.name, description: s.description },
    },
  })),
)

// Read all registrations reactively
const registrations = getHotkeyRegistrations()
// registrations.hotkeys[0].options.meta?.name → 'Save'
// registrations.hotkeys[0].triggerCount → 3`}</pre>
    </section>
  </main>
</div>