import { fn } from '@ember/helper'
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import { on } from '@ember/modifier'
import {
formatForDisplay,
useHotkeyRecorder,
useHotkeys,
useHotkeyRegistrations,
useHeldKeys,
} from '@tanstack/ember-hotkeys'
import type { Hotkey, HotkeyDefinition } 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 or = <T, U>(a: T, b: U) => a || b
interface Shortcut {
id: string
name: string
description: string
hotkey: Hotkey | ''
}
let nextId = 0
function createId(): string {
return `shortcut_${++nextId}`
}
const INITIAL_SHORTCUTS: Array<Shortcut> = [
{
id: createId(),
name: 'Save',
description: 'Save the current document',
hotkey: 'Mod+[KeyK]',
},
{
id: createId(),
name: 'Open',
description: 'Open a file from disk',
hotkey: 'Mod+E',
},
{
id: createId(),
name: 'New',
description: 'Create a new document',
hotkey: 'Mod+G',
},
{
id: createId(),
name: 'Close',
description: 'Close the current tab',
hotkey: 'Mod+Shift+K',
},
{
id: createId(),
name: 'Undo',
description: 'Undo the last action',
hotkey: 'Mod+Shift+E',
},
{
id: createId(),
name: 'Redo',
description: 'Redo the last undone action',
hotkey: 'Mod+Shift+G',
},
]
interface ShortcutListItemProps {
shortcut: Shortcut
isEditing: boolean
draftName: string
draftDescription: string
onDraftNameChange: (value: string) => void
onDraftDescriptionChange: (value: string) => void
onEdit: () => void
onSave: () => void
onCancel: () => void
onDelete: () => void
}
class App extends Component {
usage0 =
'recorder = useHotkeyRecorder(this, {\n onRecord: (value) => this.updateShortcut(value),\n onCancel: () => this.cancelEditing(),\n onClear: () => this.clearShortcut(),\n})\nregistrations = useHotkeyRegistrations(this)\n\n<template>\n {{useHotkeys 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 = useHotkeyRecorder(this, () => ({
onRecord: (hotkey: Hotkey) => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? {
...s,
hotkey,
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.hotkey === '') {
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,
hotkey: '',
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 hotkeyDefinitions(): Array<HotkeyDefinition> {
return this.shortcuts
.filter((s) => s.hotkey !== '')
.map((s) => ({
hotkey: s.hotkey as Hotkey,
callback: () => {
console.log(`${s.name} triggered:`, s.hotkey)
},
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: '',
hotkey: '',
}
this.shortcuts = [...this.shortcuts, newShortcut]
this.editingId = newShortcut.id
this.draftName = ''
this.draftDescription = ''
this.recorder.startRecording()
}
<template>
{{useHotkeys this.hotkeyDefinitions}}
<div class='app'>
<header>
<h1>Keyboard Shortcuts Settings</h1>
<p>
Customize your keyboard shortcuts. Click "Edit" to record a new shortcut,
or press Escape to cancel.
</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}}
@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 shortcut...</strong>
The physical key position will be saved. Press any key combination or
Escape to cancel. Press Backspace/Delete to clear the shortcut.
</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 hotkeys() {
return this.registrationsState.hotkeys
}
<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 shortcuts.
</p>
<table class='registrations-table'>
<thead>
<tr>
<th>Hotkey</th>
<th>Name</th>
<th>Description</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>
<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={{5}} class='empty-row'>
No hotkeys 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,
)
<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.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 any key combination...
</span>{{/if}}
</div>{{else if this.args.shortcut.hotkey}}<kbd>{{formatForDisplay
this.args.shortcut.hotkey
}}</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,
useHotkeyRecorder,
useHotkeys,
useHotkeyRegistrations,
useHeldKeys,
} from '@tanstack/ember-hotkeys'
import type { Hotkey, HotkeyDefinition } 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 or = <T, U>(a: T, b: U) => a || b
interface Shortcut {
id: string
name: string
description: string
hotkey: Hotkey | ''
}
let nextId = 0
function createId(): string {
return `shortcut_${++nextId}`
}
const INITIAL_SHORTCUTS: Array<Shortcut> = [
{
id: createId(),
name: 'Save',
description: 'Save the current document',
hotkey: 'Mod+[KeyK]',
},
{
id: createId(),
name: 'Open',
description: 'Open a file from disk',
hotkey: 'Mod+E',
},
{
id: createId(),
name: 'New',
description: 'Create a new document',
hotkey: 'Mod+G',
},
{
id: createId(),
name: 'Close',
description: 'Close the current tab',
hotkey: 'Mod+Shift+K',
},
{
id: createId(),
name: 'Undo',
description: 'Undo the last action',
hotkey: 'Mod+Shift+E',
},
{
id: createId(),
name: 'Redo',
description: 'Redo the last undone action',
hotkey: 'Mod+Shift+G',
},
]
interface ShortcutListItemProps {
shortcut: Shortcut
isEditing: boolean
draftName: string
draftDescription: string
onDraftNameChange: (value: string) => void
onDraftDescriptionChange: (value: string) => void
onEdit: () => void
onSave: () => void
onCancel: () => void
onDelete: () => void
}
class App extends Component {
usage0 =
'recorder = useHotkeyRecorder(this, {\n onRecord: (value) => this.updateShortcut(value),\n onCancel: () => this.cancelEditing(),\n onClear: () => this.clearShortcut(),\n})\nregistrations = useHotkeyRegistrations(this)\n\n<template>\n {{useHotkeys 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 = useHotkeyRecorder(this, () => ({
onRecord: (hotkey: Hotkey) => {
if (this.editingId) {
this.shortcuts = this.shortcuts.map((s) =>
s.id === this.editingId
? {
...s,
hotkey,
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.hotkey === '') {
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,
hotkey: '',
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 hotkeyDefinitions(): Array<HotkeyDefinition> {
return this.shortcuts
.filter((s) => s.hotkey !== '')
.map((s) => ({
hotkey: s.hotkey as Hotkey,
callback: () => {
console.log(`${s.name} triggered:`, s.hotkey)
},
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: '',
hotkey: '',
}
this.shortcuts = [...this.shortcuts, newShortcut]
this.editingId = newShortcut.id
this.draftName = ''
this.draftDescription = ''
this.recorder.startRecording()
}
<template>
{{useHotkeys this.hotkeyDefinitions}}
<div class='app'>
<header>
<h1>Keyboard Shortcuts Settings</h1>
<p>
Customize your keyboard shortcuts. Click "Edit" to record a new shortcut,
or press Escape to cancel.
</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}}
@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 shortcut...</strong>
The physical key position will be saved. Press any key combination or
Escape to cancel. Press Backspace/Delete to clear the shortcut.
</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 hotkeys() {
return this.registrationsState.hotkeys
}
<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 shortcuts.
</p>
<table class='registrations-table'>
<thead>
<tr>
<th>Hotkey</th>
<th>Name</th>
<th>Description</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>
<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={{5}} class='empty-row'>
No hotkeys 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,
)
<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.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 any key combination...
</span>{{/if}}
</div>{{else if this.args.shortcut.hotkey}}<kbd>{{formatForDisplay
this.args.shortcut.hotkey
}}</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