Skip to content

Ember Example: UseHotkeys

import { fn } from '@ember/helper'
import { validateHotkey, normalizeHotkey } from '@tanstack/ember-hotkeys'
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'

import { on } from '@ember/modifier'
import {
  formatForDisplay,
  useHotkeys,
  useHotkeyRegistrations,
} from '@tanstack/ember-hotkeys'
import type {
  EmberHotkeyDefinition,
  Hotkey,
  HotkeyRegistrationView,
} 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
const not = (a: unknown) => !a
interface DynamicShortcut {
  id: number
  hotkey: Hotkey
  label: string
  description: string
  count: number
}

let nextId = 0

const DEFAULT_SHORTCUTS: Array<DynamicShortcut> = [
  {
    id: nextId++,
    hotkey: 'Shift+A',
    label: 'Action A',
    description: 'First dynamic action',
    count: 0,
  },
  {
    id: nextId++,
    hotkey: 'Shift+B',
    label: 'Action B',
    description: 'Second dynamic action',
    count: 0,
  },
  {
    id: nextId++,
    hotkey: 'Shift+C',
    label: 'Action C',
    description: 'Third dynamic action',
    count: 0,
  },
]

const progress = (reg: HotkeyRegistrationView) =>
  reg.kind === 'sequence'
    ? `${reg.matchedStepCount}/${reg.hotkey.length}`
    : '\u2014'

class App extends Component {
  <template>
    <div class='app'>
      <header>
        <h1>useHotkeys</h1>
        <p>
          Register multiple hotkeys in a single helper invocation. Supports dynamic
          arrays for variable-length shortcut lists.
        </p>
      </header>
      <BasicMultiHotkeys />
      <CommonOptionsDemo />
      <DynamicHotkeysDemo />
      <RegistrationsViewer />
    </div>
  </template>
}

class BasicMultiHotkeys extends Component {
  usage0 =
    "get definitions() {\n  return [\n    { hotkey: 'Shift+S', callback: this.save,\n      options: { meta: { name: 'Save', description: 'Save the document' } } },\n    { hotkey: 'Shift+U', callback: this.undo,\n      options: { meta: { name: 'Undo' } } },\n  ]\n}\n\n<template>{{useHotkeys this.definitions}}</template>"
  @tracked log: Array<string> = []
  @tracked saveCount: number = 0
  @tracked undoCount: number = 0
  @tracked redoCount: number = 0
  get definitions(): Array<EmberHotkeyDefinition> {
    return [
      {
        hotkey: 'Shift+S',
        callback: (_e, { hotkey }) => {
          this.saveCount = this.saveCount + 1
          this.log = [`${hotkey} pressed`, ...this.log].slice(0, 20)
        },
        options: {
          meta: { name: 'Save', description: 'Save the current document' },
        },
      },
      {
        hotkey: 'Shift+U',
        callback: (_e, { hotkey }) => {
          this.undoCount = this.undoCount + 1
          this.log = [`${hotkey} pressed`, ...this.log].slice(0, 20)
        },
        options: {
          meta: { name: 'Undo', description: 'Undo the last action' },
        },
      },
      {
        hotkey: 'Shift+R',
        callback: (_e, { hotkey }) => {
          this.redoCount = this.redoCount + 1
          this.log = [`${hotkey} pressed`, ...this.log].slice(0, 20)
        },
        options: {
          meta: { name: 'Redo', description: 'Redo the last undone action' },
        },
      },
    ]
  }

  <template>
    {{useHotkeys this.definitions}}
    <div class='demo-section'>
      <h2>Basic Multi-Hotkey Registration</h2>
      <p>
        All three hotkeys are registered in a single
        <code>useHotkeys()</code>
        call with
        <code>meta</code>
        for name and description.
      </p>
      <div class='hotkey-grid'>
        <div>
          <kbd>{{formatForDisplay 'Shift+S'}}</kbd>
          Save ({{this.saveCount}})
        </div>
        <div>
          <kbd>{{formatForDisplay 'Shift+U'}}</kbd>
          Undo ({{this.undoCount}})
        </div>
        <div>
          <kbd>{{formatForDisplay 'Shift+R'}}</kbd>
          Redo ({{this.redoCount}})
        </div>
      </div>
      {{#if (gt this.log.length 0)}}<div class='log'>
          {{#each this.log as |entry i|}}<div class='log-entry'>
              {{entry}}
            </div>{{/each}}
        </div>{{/if}}
      <pre class='code-block'>{{this.usage0}}</pre>
    </div>
  </template>
}

class CommonOptionsDemo extends Component {
  usage0 =
    "get definitions() {\n  return [\n    { hotkey: 'Alt+J', callback: this.actionA },\n    { hotkey: 'Alt+L', callback: this.actionC, options: { enabled: true } },\n  ]\n}\n\n<template>{{useHotkeys this.definitions enabled=this.enabled}}</template>"
  @tracked enabled: boolean = true
  @tracked counts: { a: number; b: number; c: number } = { a: 0, b: 0, c: 0 }
  toggleEnabled = () => (this.enabled = !this.enabled)
  get definitions(): Array<EmberHotkeyDefinition> {
    return [
      {
        hotkey: 'Alt+J',
        callback: () =>
          (this.counts = { ...this.counts, a: this.counts.a + 1 }),
        options: {
          meta: {
            name: 'Action A',
            description: 'First action (respects toggle)',
          },
        },
      },
      {
        hotkey: 'Alt+K',
        callback: () =>
          (this.counts = { ...this.counts, b: this.counts.b + 1 }),
        options: {
          meta: {
            name: 'Action B',
            description: 'Second action (respects toggle)',
          },
        },
      },
      {
        hotkey: 'Alt+L',
        callback: () =>
          (this.counts = { ...this.counts, c: this.counts.c + 1 }),
        options: {
          enabled: true,
          meta: {
            name: 'Action C',
            description: 'Always-on action (overrides toggle)',
          },
        },
      },
    ]
  }

  <template>
    {{useHotkeys this.definitions enabled=this.enabled}}
    <div class='demo-section'>
      <h2>Common Options with Per-Hotkey Overrides</h2>
      <p>
        <kbd>{{formatForDisplay 'Alt+J'}}</kbd>
        and
        <kbd>{{formatForDisplay 'Alt+K'}}</kbd>
        respect the global toggle.
        <kbd>{{formatForDisplay 'Alt+L'}}</kbd>
        overrides
        <code>enabled: true</code>
        so it always works.
      </p>
      <div style='margin-bottom:12px'>
        <button {{on 'click' this.toggleEnabled}}>
          {{if this.enabled 'Disable' 'Enable'}}
          common hotkeys
        </button>
      </div>
      <div class='hotkey-grid'>
        <div>
          <kbd>{{formatForDisplay 'Alt+J'}}</kbd>
          Action A ({{this.counts.a}})
        </div>
        <div>
          <kbd>{{formatForDisplay 'Alt+K'}}</kbd>
          Action B ({{this.counts.b}})
        </div>
        <div>
          <kbd>{{formatForDisplay 'Alt+L'}}</kbd>
          Action C ({{this.counts.c}})
          <span class='hint'> (always on)</span>
        </div>
      </div>
      <pre class='code-block'>{{this.usage0}}</pre>
    </div>
  </template>
}

class DynamicHotkeysDemo extends Component {
  usage0 =
    'get definitions() {\n  return this.shortcuts.map((shortcut) => ({\n    hotkey: shortcut.hotkey,\n    callback: () => this.runShortcut(shortcut.id),\n    options: { meta: { name: shortcut.label, description: shortcut.description } },\n  }))\n}\n\n<template>{{useHotkeys this.definitions}}</template>'
  @tracked shortcuts: Array<DynamicShortcut> = DEFAULT_SHORTCUTS
  @tracked newHotkey: string = ''
  @tracked newLabel: string = ''
  @tracked newDescription: string = ''
  @tracked error = ''
  remove = (s: DynamicShortcut) => this.removeShortcut(s.id)
  updateHotkey = (e: Event) =>
    (this.newHotkey = (e.currentTarget as HTMLInputElement).value)
  addOnEnter = (e: KeyboardEvent) => {
    if (e.key === 'Enter') this.addShortcut()
  }
  updateLabel = (e: Event) =>
    (this.newLabel = (e.currentTarget as HTMLInputElement).value)
  updateDescription = (e: Event) =>
    (this.newDescription = (e.currentTarget as HTMLInputElement).value)
  get definitions(): Array<EmberHotkeyDefinition> {
    return this.shortcuts.map((s) => ({
      hotkey: s.hotkey,
      callback: () => {
        this.shortcuts = this.shortcuts.map((item) =>
          item.id === s.id ? { ...item, count: item.count + 1 } : item,
        )
      },
      options: {
        meta: { name: s.label, description: s.description },
      },
    }))
  }
  addShortcut = () => {
    const trimmed = this.newHotkey.trim()
    if (!this.newLabel.trim()) return
    const validation = validateHotkey(trimmed)
    if (!validation.valid || validation.warnings.length > 0) {
      this.error = [...validation.errors, ...validation.warnings].join(' ')
      return
    }
    this.error = ''
    this.shortcuts = [
      ...this.shortcuts,
      {
        id: nextId++,
        hotkey: normalizeHotkey(trimmed),
        label: this.newLabel.trim(),
        description: this.newDescription.trim(),
        count: 0,
      },
    ]
    this.newHotkey = ''
    this.newLabel = ''
    this.newDescription = ''
  }
  removeShortcut = (id: number) => {
    this.shortcuts = this.shortcuts.filter((s) => s.id !== id)
  }

  <template>
    {{useHotkeys this.definitions}}
    <div class='demo-section'>
      <h2>Dynamic Hotkey List</h2>
      <p>
        Add or remove hotkeys at runtime. Because
        <code>useHotkeys</code>
        accepts a dynamic array, this is safe without breaking the registration
        lifecycle.
      </p>
      {{#if this.error}}<p role='alert'>{{this.error}}</p>{{/if}}
      <div class='dynamic-list'>
        {{#each this.shortcuts key='id' as |s|}}<div class='dynamic-item'>
            <kbd>{{formatForDisplay s.hotkey}}</kbd>
            <span>{{s.label}}</span>
            <span class='count'>{{s.count}}</span>
            <button {{on 'click' (fn this.remove s)}}>Remove</button>
          </div>{{/each}}
        {{#if (eq this.shortcuts.length 0)}}<p class='hint'>No shortcuts registered.
            Add one below.</p>{{/if}}
      </div>
      <div class='add-form'>
        <input
          type='text'
          placeholder='Hotkey (e.g. Shift+D)'
          value={{this.newHotkey}}
          {{on 'input' this.updateHotkey}}
          {{on 'keydown' this.addOnEnter}}
        />
        <input
          type='text'
          placeholder='Name (e.g. Action D)'
          value={{this.newLabel}}
          {{on 'input' this.updateLabel}}
          {{on 'keydown' this.addOnEnter}}
        />
        <input
          type='text'
          placeholder='Description (optional)'
          value={{this.newDescription}}
          {{on 'input' this.updateDescription}}
          {{on 'keydown' this.addOnEnter}}
        />
        <button
          {{on 'click' this.addShortcut}}
          disabled={{or (not this.newHotkey) (not this.newLabel)}}
        >
          Add
        </button>
      </div>
      <pre class='code-block'>{{this.usage0}}</pre>
    </div>
  </template>
}

class RegistrationsViewer extends Component {
  usage0 =
    'registrations = useHotkeyRegistrations(this)\n\n<template>\n  {{#each this.registrations.hotkeys key="id" as |registration|}}\n    <p>{{registration.options.meta.name}}: {{registration.triggerCount}}</p>\n  {{/each}}\n</template>'
  registrationsState = useHotkeyRegistrations(this)
  get hotkeys() {
    return this.registrationsState.hotkeys
  }
  <template>
    <div class='demo-section'>
      <h2>Live Registrations (useHotkeyRegistrations)</h2>
      <p>
        This table is rendered from
        <code>useHotkeyRegistrations()</code>
        — a reactive view of all registered hotkeys, chords and sequences. It
        updates automatically as
        hotkeys are added, removed, enabled/disabled, or triggered.
      </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 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>{{progress reg}}</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.hotkeys.length 0)}}<tr>
              <td colspan={{6}} class='hint'>
                No hotkeys registered
              </td>
            </tr>{{/if}}
        </tbody>
      </table>
      <pre class='code-block'>{{this.usage0}}</pre>
    </div>
  </template>
}
export default App