import { fn } from '@ember/helper'
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { on } from '@ember/modifier'
import {
formatForDisplay,
useHotkeySequenceRecorder,
useHotkeySequences,
useHotkeyRegistrations,
useHeldKeys,
} from '@tanstack/ember-hotkeys'
import type {
HotkeySequence,
HotkeySequenceDefinition,
} 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
interface Shortcut {
id: string
name: string
description: string
sequence: HotkeySequence
}
let nextId = 0
function createId(): string {
return `shortcut_${++nextId}`
}
const INITIAL_SHORTCUTS: Array<Shortcut> = [
{
id: createId(),
name: 'Save',
description: 'Save the current document',
sequence: ['Mod+S'],
},
{
id: createId(),
name: 'Open (gg)',
description: 'Open the file browser',
sequence: ['G', 'G'],
},
{
id: createId(),
name: 'New (dd)',
description: 'Create a new document',
sequence: ['D', 'D'],
},
{
id: createId(),
name: 'Close',
description: 'Close the current tab',
sequence: ['Mod+Shift+K'],
},
{
id: createId(),
name: 'Undo (yy)',
description: 'Undo the last action',
sequence: ['Y', 'Y'],
},
{
id: createId(),
name: 'Redo',
description: 'Redo the last undone action',
sequence: ['Mod+Shift+G'],
},
]
interface ShortcutListItemProps {
shortcut: Shortcut
isEditing: boolean
draftName: string
draftDescription: string
onDraftNameChange: (value: string) => void
onDraftDescriptionChange: (value: string) => void
liveSteps: HotkeySequence
onEdit: () => void
onSave: () => void
onCancel: () => void
onDelete: () => void
}
class App extends Component {
usage0 =
'recorder = useHotkeySequenceRecorder(this, {\n onRecord: (value) => this.updateShortcut(value),\n onCancel: () => this.cancelEditing(),\n onClear: () => this.clearShortcut(),\n})\nregistrations = useHotkeyRegistrations(this)\n\n<template>\n {{useHotkeySequences this.definitions}}\n <button type="button" {{on "click" this.recorder.startRecording}}>Record</button>\n</template>'
@tracked shortcuts: Array<Shortcut> = INITIAL_SHORTCUTS
@tracked editingId: string | null = null
@tracked draftName: string = ''
@tracked draftDescription: string = ''
recorder = useHotkeySequenceRecorder(this, () => ({
onRecord: (sequence: HotkeySequence) => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? {
...s,
sequence,
name: this.draftName,
description: this.draftDescription,
}
: s,
)
this.editingId = null
}
},
onCancel: () => {
if (this.editingId) {
this.shortcuts = (() => {
const shortcut = this.shortcuts.find((s) => s.id === this.editingId)
if (shortcut && shortcut.sequence.length === 0) {
return this.shortcuts.filter((s) => s.id !== this.editingId)
}
return this.shortcuts
})()
}
this.editingId = null
},
onClear: () => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? {
...s,
sequence: [],
name: this.draftName,
description: this.draftDescription,
}
: s,
)
this.editingId = null
}
},
}))
setDraftName = (value: string) => {
this.draftName = value
}
setDraftDescription = (value: string) => {
this.draftDescription = value
}
get isRecording() {
return this.recorder.isRecording
}
get sequenceDefinitions(): Array<HotkeySequenceDefinition> {
return this.shortcuts
.filter((s) => s.sequence.length > 0)
.map((s) => ({
sequence: s.sequence,
callback: () => {
console.log(`${s.name} triggered:`, s.sequence)
},
options: {
meta: {
name: s.name,
description: s.description,
},
},
}))
}
handleEdit = (id: string) => {
const shortcut = this.shortcuts.find((s) => s.id === id)
if (!shortcut) return
this.editingId = id
this.draftName = shortcut.name
this.draftDescription = shortcut.description
this.recorder.startRecording()
}
handleSaveEditing = () => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? { ...s, name: this.draftName, description: this.draftDescription }
: s,
)
this.recorder.stopRecording()
this.editingId = null
}
}
handleCancel = () => {
this.recorder.cancelRecording()
}
handleDelete = (id: string) => {
this.shortcuts = this.shortcuts.filter((s) => s.id !== id)
}
handleCreateNew = () => {
const newShortcut: Shortcut = {
id: createId(),
name: '',
description: '',
sequence: [],
}
this.shortcuts = [...this.shortcuts, newShortcut]
this.editingId = newShortcut.id
this.draftName = ''
this.draftDescription = ''
this.recorder.startRecording()
}
<template>
{{useHotkeySequences this.sequenceDefinitions}}
<div class='app'>
<header>
<h1>Sequence Shortcut Settings</h1>
<p>
Customize Vim-style sequences. Click Edit, press each chord in order, then
press Enter to save. 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 this.shortcuts key='id' as |shortcut|}}<ShortcutListItem
@shortcut={{shortcut}}
@isEditing={{eq this.editingId shortcut.id}}
@draftName={{if
(eq this.editingId shortcut.id)
this.draftName
shortcut.name
}}
@draftDescription={{if
(eq this.editingId shortcut.id)
this.draftDescription
shortcut.description
}}
@onDraftNameChange={{this.setDraftName}}
@onDraftDescriptionChange={{this.setDraftDescription}}
@liveSteps={{this.recorder.steps}}
@onEdit={{fn this.handleEdit shortcut.id}}
@onSave={{this.handleSaveEditing}}
@onCancel={{this.handleCancel}}
@onDelete={{fn this.handleDelete shortcut.id}}
/>{{/each}}
</div>
<button
type='button'
class='create-button'
{{on 'click' this.handleCreateNew}}
disabled={{this.isRecording}}
>
+ Create New Shortcut
</button>
</section>
{{#if this.recorder.isRecording}}<div class='info-box recording-notice'>
<strong>Recording sequence...</strong>
Press each chord, then Enter to finish. Escape cancels. Backspace
removes the last chord or clears.
{{#if (gt this.recorder.steps.length 0)}}<div>
Steps:
{{#each this.recorder.steps as |h i|}}
{{and (gt i 0) ' '}}
<kbd>{{formatForDisplay h}}</kbd>
{{/each}}
</div>{{/if}}
</div>{{/if}}
<RegistrationsViewer />
<section class='demo-section'>
<h2>Usage</h2>
<pre class='code-block'>{{this.usage0}}</pre>
</section>
</main>
</div>
</template>
}
class RegistrationsViewer extends Component {
registrationsState = useHotkeyRegistrations(this)
get sequences() {
return this.registrationsState.sequences
}
<template>
<section class='demo-section'>
<h2>Live Registrations</h2>
<p>
This table is powered by
<code>useHotkeyRegistrations()</code>
— trigger counts, names, and descriptions update in real-time as you use
your sequences.
</p>
<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='empty-row'>
No sequences registered
</td>
</tr>{{/if}}
</tbody>
</table>
</section>
</template>
}
class ShortcutListItem extends Component<{ Args: ShortcutListItemProps }> {
heldKeys = useHeldKeys(this)
get itemClass() {
return `shortcut-item ${this.args.isEditing ? 'recording' : ''}`
}
updateDraftName = (e: Event) =>
this.args.onDraftNameChange((e.currentTarget as HTMLInputElement).value)
updateDraftDescription = (e: Event) =>
this.args.onDraftDescriptionChange(
(e.currentTarget as HTMLInputElement).value,
)
get liveSequenceText() {
return this.args.liveSteps.map((h) => formatForDisplay(h)).join(' ')
}
get savedSequenceText() {
return this.args.shortcut.sequence.map((h) => formatForDisplay(h)).join(' ')
}
<template>
<div class={{this.itemClass}}>
<div class='shortcut-item-content'>
<div class='shortcut-action'>
{{#if this.args.isEditing}}<div class='editing-fields'>
<input
type='text'
class='edit-input edit-name'
value={{this.args.draftName}}
{{on 'input' this.updateDraftName}}
placeholder='Shortcut name'
/>
<input
type='text'
class='edit-input edit-description'
value={{this.args.draftDescription}}
{{on 'input' this.updateDraftDescription}}
placeholder='Description (optional)'
/>
</div>{{else}}
{{#if this.args.shortcut.name}}{{this.args.shortcut.name}}{{else}}<span
class='unnamed'
>Unnamed</span>{{/if}}
{{#if this.args.shortcut.description}}<div class='shortcut-description'>
{{this.args.shortcut.description}}
</div>{{/if}}
{{/if}}
</div>
<div class='shortcut-hotkey'>
{{#if this.args.isEditing}}<div class='recording-indicator'>
{{#if (gt this.args.liveSteps.length 0)}}<span class='held-hotkeys'>
{{this.liveSequenceText}}
</span>{{else if (gt this.heldKeys.value.length 0)}}<div
class='held-hotkeys'
>
{{#each this.heldKeys.value as |key index|}}
{{#if (gt index 0)}}<span class='plus'>+</span>{{/if}}
<kbd>{{key}}</kbd>
{{/each}}
</div>{{else}}<span class='recording-text'>
Press chords, then Enter...
</span>{{/if}}
</div>{{else if (gt this.args.shortcut.sequence.length 0)}}<kbd>
{{this.savedSequenceText}}
</kbd>{{else}}<span class='no-shortcut'>No shortcut</span>{{/if}}
</div>
</div>
<div class='shortcut-actions'>
{{#if this.args.isEditing}}
<button type='button' {{on 'click' this.args.onSave}} class='save-button'>
Save
</button>
<button
type='button'
{{on 'click' this.args.onCancel}}
class='cancel-button'
>
Cancel
</button>
{{else}}
<button type='button' {{on 'click' this.args.onEdit}} class='edit-button'>
Edit
</button>
<button
type='button'
{{on 'click' this.args.onDelete}}
class='delete-button'
>
Delete
</button>
{{/if}}
</div>
</div>
</template>
}
export default App
import { fn } from '@ember/helper'
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { on } from '@ember/modifier'
import {
formatForDisplay,
useHotkeySequenceRecorder,
useHotkeySequences,
useHotkeyRegistrations,
useHeldKeys,
} from '@tanstack/ember-hotkeys'
import type {
HotkeySequence,
HotkeySequenceDefinition,
} 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
interface Shortcut {
id: string
name: string
description: string
sequence: HotkeySequence
}
let nextId = 0
function createId(): string {
return `shortcut_${++nextId}`
}
const INITIAL_SHORTCUTS: Array<Shortcut> = [
{
id: createId(),
name: 'Save',
description: 'Save the current document',
sequence: ['Mod+S'],
},
{
id: createId(),
name: 'Open (gg)',
description: 'Open the file browser',
sequence: ['G', 'G'],
},
{
id: createId(),
name: 'New (dd)',
description: 'Create a new document',
sequence: ['D', 'D'],
},
{
id: createId(),
name: 'Close',
description: 'Close the current tab',
sequence: ['Mod+Shift+K'],
},
{
id: createId(),
name: 'Undo (yy)',
description: 'Undo the last action',
sequence: ['Y', 'Y'],
},
{
id: createId(),
name: 'Redo',
description: 'Redo the last undone action',
sequence: ['Mod+Shift+G'],
},
]
interface ShortcutListItemProps {
shortcut: Shortcut
isEditing: boolean
draftName: string
draftDescription: string
onDraftNameChange: (value: string) => void
onDraftDescriptionChange: (value: string) => void
liveSteps: HotkeySequence
onEdit: () => void
onSave: () => void
onCancel: () => void
onDelete: () => void
}
class App extends Component {
usage0 =
'recorder = useHotkeySequenceRecorder(this, {\n onRecord: (value) => this.updateShortcut(value),\n onCancel: () => this.cancelEditing(),\n onClear: () => this.clearShortcut(),\n})\nregistrations = useHotkeyRegistrations(this)\n\n<template>\n {{useHotkeySequences this.definitions}}\n <button type="button" {{on "click" this.recorder.startRecording}}>Record</button>\n</template>'
@tracked shortcuts: Array<Shortcut> = INITIAL_SHORTCUTS
@tracked editingId: string | null = null
@tracked draftName: string = ''
@tracked draftDescription: string = ''
recorder = useHotkeySequenceRecorder(this, () => ({
onRecord: (sequence: HotkeySequence) => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? {
...s,
sequence,
name: this.draftName,
description: this.draftDescription,
}
: s,
)
this.editingId = null
}
},
onCancel: () => {
if (this.editingId) {
this.shortcuts = (() => {
const shortcut = this.shortcuts.find((s) => s.id === this.editingId)
if (shortcut && shortcut.sequence.length === 0) {
return this.shortcuts.filter((s) => s.id !== this.editingId)
}
return this.shortcuts
})()
}
this.editingId = null
},
onClear: () => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? {
...s,
sequence: [],
name: this.draftName,
description: this.draftDescription,
}
: s,
)
this.editingId = null
}
},
}))
setDraftName = (value: string) => {
this.draftName = value
}
setDraftDescription = (value: string) => {
this.draftDescription = value
}
get isRecording() {
return this.recorder.isRecording
}
get sequenceDefinitions(): Array<HotkeySequenceDefinition> {
return this.shortcuts
.filter((s) => s.sequence.length > 0)
.map((s) => ({
sequence: s.sequence,
callback: () => {
console.log(`${s.name} triggered:`, s.sequence)
},
options: {
meta: {
name: s.name,
description: s.description,
},
},
}))
}
handleEdit = (id: string) => {
const shortcut = this.shortcuts.find((s) => s.id === id)
if (!shortcut) return
this.editingId = id
this.draftName = shortcut.name
this.draftDescription = shortcut.description
this.recorder.startRecording()
}
handleSaveEditing = () => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? { ...s, name: this.draftName, description: this.draftDescription }
: s,
)
this.recorder.stopRecording()
this.editingId = null
}
}
handleCancel = () => {
this.recorder.cancelRecording()
}
handleDelete = (id: string) => {
this.shortcuts = this.shortcuts.filter((s) => s.id !== id)
}
handleCreateNew = () => {
const newShortcut: Shortcut = {
id: createId(),
name: '',
description: '',
sequence: [],
}
this.shortcuts = [...this.shortcuts, newShortcut]
this.editingId = newShortcut.id
this.draftName = ''
this.draftDescription = ''
this.recorder.startRecording()
}
<template>
{{useHotkeySequences this.sequenceDefinitions}}
<div class='app'>
<header>
<h1>Sequence Shortcut Settings</h1>
<p>
Customize Vim-style sequences. Click Edit, press each chord in order, then
press Enter to save. 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 this.shortcuts key='id' as |shortcut|}}<ShortcutListItem
@shortcut={{shortcut}}
@isEditing={{eq this.editingId shortcut.id}}
@draftName={{if
(eq this.editingId shortcut.id)
this.draftName
shortcut.name
}}
@draftDescription={{if
(eq this.editingId shortcut.id)
this.draftDescription
shortcut.description
}}
@onDraftNameChange={{this.setDraftName}}
@onDraftDescriptionChange={{this.setDraftDescription}}
@liveSteps={{this.recorder.steps}}
@onEdit={{fn this.handleEdit shortcut.id}}
@onSave={{this.handleSaveEditing}}
@onCancel={{this.handleCancel}}
@onDelete={{fn this.handleDelete shortcut.id}}
/>{{/each}}
</div>
<button
type='button'
class='create-button'
{{on 'click' this.handleCreateNew}}
disabled={{this.isRecording}}
>
+ Create New Shortcut
</button>
</section>
{{#if this.recorder.isRecording}}<div class='info-box recording-notice'>
<strong>Recording sequence...</strong>
Press each chord, then Enter to finish. Escape cancels. Backspace
removes the last chord or clears.
{{#if (gt this.recorder.steps.length 0)}}<div>
Steps:
{{#each this.recorder.steps as |h i|}}
{{and (gt i 0) ' '}}
<kbd>{{formatForDisplay h}}</kbd>
{{/each}}
</div>{{/if}}
</div>{{/if}}
<RegistrationsViewer />
<section class='demo-section'>
<h2>Usage</h2>
<pre class='code-block'>{{this.usage0}}</pre>
</section>
</main>
</div>
</template>
}
class RegistrationsViewer extends Component {
registrationsState = useHotkeyRegistrations(this)
get sequences() {
return this.registrationsState.sequences
}
<template>
<section class='demo-section'>
<h2>Live Registrations</h2>
<p>
This table is powered by
<code>useHotkeyRegistrations()</code>
— trigger counts, names, and descriptions update in real-time as you use
your sequences.
</p>
<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='empty-row'>
No sequences registered
</td>
</tr>{{/if}}
</tbody>
</table>
</section>
</template>
}
class ShortcutListItem extends Component<{ Args: ShortcutListItemProps }> {
heldKeys = useHeldKeys(this)
get itemClass() {
return `shortcut-item ${this.args.isEditing ? 'recording' : ''}`
}
updateDraftName = (e: Event) =>
this.args.onDraftNameChange((e.currentTarget as HTMLInputElement).value)
updateDraftDescription = (e: Event) =>
this.args.onDraftDescriptionChange(
(e.currentTarget as HTMLInputElement).value,
)
get liveSequenceText() {
return this.args.liveSteps.map((h) => formatForDisplay(h)).join(' ')
}
get savedSequenceText() {
return this.args.shortcut.sequence.map((h) => formatForDisplay(h)).join(' ')
}
<template>
<div class={{this.itemClass}}>
<div class='shortcut-item-content'>
<div class='shortcut-action'>
{{#if this.args.isEditing}}<div class='editing-fields'>
<input
type='text'
class='edit-input edit-name'
value={{this.args.draftName}}
{{on 'input' this.updateDraftName}}
placeholder='Shortcut name'
/>
<input
type='text'
class='edit-input edit-description'
value={{this.args.draftDescription}}
{{on 'input' this.updateDraftDescription}}
placeholder='Description (optional)'
/>
</div>{{else}}
{{#if this.args.shortcut.name}}{{this.args.shortcut.name}}{{else}}<span
class='unnamed'
>Unnamed</span>{{/if}}
{{#if this.args.shortcut.description}}<div class='shortcut-description'>
{{this.args.shortcut.description}}
</div>{{/if}}
{{/if}}
</div>
<div class='shortcut-hotkey'>
{{#if this.args.isEditing}}<div class='recording-indicator'>
{{#if (gt this.args.liveSteps.length 0)}}<span class='held-hotkeys'>
{{this.liveSequenceText}}
</span>{{else if (gt this.heldKeys.value.length 0)}}<div
class='held-hotkeys'
>
{{#each this.heldKeys.value as |key index|}}
{{#if (gt index 0)}}<span class='plus'>+</span>{{/if}}
<kbd>{{key}}</kbd>
{{/each}}
</div>{{else}}<span class='recording-text'>
Press chords, then Enter...
</span>{{/if}}
</div>{{else if (gt this.args.shortcut.sequence.length 0)}}<kbd>
{{this.savedSequenceText}}
</kbd>{{else}}<span class='no-shortcut'>No shortcut</span>{{/if}}
</div>
</div>
<div class='shortcut-actions'>
{{#if this.args.isEditing}}
<button type='button' {{on 'click' this.args.onSave}} class='save-button'>
Save
</button>
<button
type='button'
{{on 'click' this.args.onCancel}}
class='cancel-button'
>
Cancel
</button>
{{else}}
<button type='button' {{on 'click' this.args.onEdit}} class='edit-button'>
Edit
</button>
<button
type='button'
{{on 'click' this.args.onDelete}}
class='delete-button'
>
Delete
</button>
{{/if}}
</div>
</div>
</template>
}
export default App