import {
Component,
DestroyRef,
Injectable,
computed,
inject,
input,
signal,
viewChild,
} from '@angular/core'
import {
formatForDisplay,
injectHeldKeyCodes,
injectHeldKeys,
injectHotkey,
injectHotkeyHint,
injectHotkeyRecorder,
injectHotkeyRegistrations,
injectHotkeySequence,
injectHotkeySequenceRecorder,
injectHotkeySequences,
injectHotkeys,
injectKeyHold,
} from '@tanstack/angular-hotkeys'
import type { ElementRef } from '@angular/core'
import type {
Hotkey,
HotkeySequence,
RecorderKeyMode,
} from '@tanstack/angular-hotkeys'
@Injectable({ providedIn: 'root' })
export class Activity {
readonly messages = signal<Array<string>>([])
log(message: string) {
this.messages.update((items) => [message, ...items].slice(0, 6))
}
}
@Component({
selector: 'shortcut-hint',
standalone: true,
template: `@if (visible() && enabled()) {
<kbd>{{ format(hotkey()) }}</kbd>
}`,
})
export class Hint {
readonly hotkey = input<Hotkey>('Escape')
readonly enabled = input(true)
readonly visible = injectHotkeyHint(() => this.hotkey())
readonly format = formatForDisplay
}
@Component({
selector: 'ticket-demo',
standalone: true,
imports: [Hint],
template: ` <section>
<h2>Tickets</h2>
<p>Created: {{ count() }}. Buttons and shortcuts share handlers.</p>
<label
><input
#toggle
type="checkbox"
[checked]="enabled()"
(change)="enabled.set(toggle.checked)"
/>
Enable ticket actions</label
>
<button [disabled]="!enabled()" (click)="create()">
Create ticket <shortcut-hint hotkey="Alt+[KeyC]" [enabled]="enabled()" />
</button>
<button [disabled]="!enabled()" (click)="save()">
Save pending <shortcut-hint hotkey="Alt+[KeyS]" [enabled]="enabled()" />
</button>
<label>Ticket note <input placeholder="Try Alt + C while typing" /></label>
<p>
Create uses ignoreInputs: false; Save uses default filtering. Release
before firing again.
</p>
</section>`,
})
export class Tickets {
readonly activity = inject(Activity)
readonly count = signal(0)
readonly enabled = signal(true)
create = () => {
this.count.update((n) => n + 1)
this.activity.log('Ticket created')
}
save = () => this.activity.log('Ticket saved')
constructor() {
injectHotkey('Alt+[KeyC]', this.create, () => ({
enabled: this.enabled(),
ignoreInputs: false,
meta: { name: 'Create ticket', group: 'Tickets' },
}))
injectHotkey('Alt+[KeyS]', this.save, () => ({
enabled: this.enabled(),
meta: { name: 'Save ticket', group: 'Tickets' },
}))
}
}
@Component({
selector: 'editor-pane',
standalone: true,
template: ` <fieldset #target>
<legend>{{ name() }} — {{ saves() }} saves</legend>
<label
>Editor content<textarea
#content
rows="3"
[value]="text()"
(input)="text.set(content.value)"
></textarea>
</label>
<p>
<kbd>{{ format('Mod+[KeyS]') }}</kbd> Save;
<kbd>{{ format('Alt+[Backspace]') }}</kbd> Clear
</p>
</fieldset>`,
})
export class EditorPane {
readonly name = input('Editor')
readonly target = viewChild<ElementRef<HTMLElement>>('target')
readonly text = signal('One shortcut, two independent editors.')
readonly saves = signal(0)
readonly activity = inject(Activity)
readonly format = formatForDisplay
constructor() {
injectHotkeys(
() => [
{
hotkey: 'Mod+[KeyS]',
callback: () => {
this.saves.update((n) => n + 1)
this.activity.log(`${this.name()} saved`)
},
options: { meta: { name: `Save ${this.name()}`, group: 'Editor' } },
},
{
hotkey: 'Alt+[Backspace]',
callback: () => this.text.set(''),
options: { meta: { name: `Clear ${this.name()}`, group: 'Editor' } },
},
],
() => ({ target: this.target()?.nativeElement, ignoreInputs: false }),
)
}
}
@Component({
selector: 'editor-demo',
standalone: true,
imports: [EditorPane],
template: ` <section>
<h2>Editor scopes</h2>
<p>Focus an editor to choose its Save handler.</p>
<editor-pane name="Draft" /><editor-pane name="Notes" />
<label
><input
#repeatToggle
type="checkbox"
[checked]="repeat()"
(change)="repeat.set(repeatToggle.checked)"
/>
Allow repeat</label
>
<p>
Hold Alt + ArrowRight: {{ count() }}. Release Alt + U for a keyup event.
</p>
<fieldset #scope tabindex="0">
<legend>Propagation</legend>
<label
><input
#bubbleToggle
type="checkbox"
[checked]="bubble()"
(change)="bubble.set(bubbleToggle.checked)"
/>
Allow bubbling and browser defaults</label
>
<p>Focus here and press Alt + B; watch Activity.</p>
<button (click)="scope.focus()">Focus this area</button>
</fieldset>
</section>`,
})
export class Editor {
readonly repeat = signal(false)
readonly count = signal(0)
readonly bubble = signal(false)
readonly scope = viewChild<ElementRef<HTMLElement>>('scope')
readonly activity = inject(Activity)
constructor() {
injectHotkey(
'Alt+[ArrowRight]',
() => this.count.update((n) => n + 1),
() => ({
requireReset: !this.repeat(),
meta: { name: 'Counter', group: 'Editor' },
}),
)
injectHotkey(
'Alt+[KeyB]',
() => this.activity.log('Scoped B'),
() => ({
target: this.scope()?.nativeElement,
stopPropagation: !this.bubble(),
preventDefault: !this.bubble(),
meta: { name: 'Scoped propagation', group: 'Editor' },
}),
)
injectHotkey('Alt+[KeyB]', () => this.activity.log('Document B'), {
meta: { name: 'Document propagation', group: 'Editor' },
})
injectHotkey('Alt+[KeyU]', () => this.activity.log('Keyup U'), {
eventType: 'keyup',
meta: { name: 'Keyup', group: 'Editor' },
})
}
}
@Component({
selector: 'sequence-demo',
standalone: true,
template: ` <section>
<h2>Sequences</h2>
<p>
Physical G then G, G then I, or Shift + R then Shift + T. Repeats do not
advance steps.
</p>
<label
>Timeout: {{ timeout() }} ms
<input
#time
type="range"
min="300"
max="2500"
step="100"
[value]="timeout()"
(input)="timeout.set(+time.value)"
/></label>
<table>
<tbody>
@for (reg of registry.sequences(); track reg.id) {
<tr>
<td>{{ reg.options.meta?.name }}</td>
<td>{{ label(reg.sequence) }}</td>
<td>
{{ reg.matchedStepCount }}/{{ reg.sequence.length }} steps;
{{ reg.triggerCount }} fired
</td>
</tr>
}
</tbody>
</table>
</section>`,
})
export class Sequences {
readonly timeout = signal(1000)
readonly activity = inject(Activity)
readonly registry = injectHotkeyRegistrations()
label = (sequence: HotkeySequence) =>
sequence.map((h) => formatForDisplay(h)).join(' → ')
constructor() {
injectHotkeySequence(
['[KeyG]', '[KeyG]'],
() => this.activity.log('Go to top'),
() => ({
timeout: this.timeout(),
meta: { name: 'Go to top', group: 'Sequences' },
}),
)
injectHotkeySequences(
[
{
sequence: ['[KeyG]', '[KeyI]'],
callback: () => this.activity.log('Inbox'),
options: { meta: { name: 'Inbox', group: 'Sequences' } },
},
{
sequence: ['Shift+[KeyR]', 'Shift+[KeyT]'],
callback: () => this.activity.log('Shift chain'),
options: { meta: { name: 'Shift chain', group: 'Sequences' } },
},
],
() => ({ timeout: this.timeout() }),
)
}
}
@Component({
selector: 'recording-demo',
standalone: true,
template: ` <section>
<h2>Recording</h2>
<p>Bindings live in route state. Try Alt + 1 for a conflict.</p>
<label
>Record by
<select
#modeInput
[value]="mode()"
[disabled]="recorder.isRecording() || seq.isRecording()"
(change)="setMode(modeInput.value)"
>
<option value="code">Physical code</option>
<option value="key">Logical key</option>
</select></label
>
<h3>Single shortcut</h3>
<p>
<kbd>{{ format(hotkey()) }}</kbd> — <code>{{ hotkey() }}</code>
</p>
<button
[disabled]="recorder.isRecording() || seq.isRecording()"
(click)="recorder.startRecording()"
>
Record shortcut</button
><button (click)="recorder.stopRecording(); hotkey.set(initial)">
Reset
</button>
@if (recorder.isRecording()) {
<button (click)="recorder.cancelRecording()">Cancel</button>
}
<h3>Sequence</h3>
<p>{{ label(seq.isRecording() ? seq.steps() : sequence()) }}</p>
<label
><input
#idleInput
type="checkbox"
[checked]="idle()"
(change)="idle.set(idleInput.checked)"
/>
Commit after 1.5 seconds idle</label
><label
><input
#enterInput
type="checkbox"
[checked]="enter()"
(change)="enter.set(enterInput.checked)"
/>
Enter commits</label
>
<button
[disabled]="recorder.isRecording() || seq.isRecording()"
(click)="seq.startRecording()"
>
Record sequence</button
><button
[disabled]="!seq.isRecording() || !seq.steps().length"
(click)="seq.commitRecording()"
>
Commit
</button>
@if (seq.isRecording()) {
<button (click)="seq.cancelRecording()">Cancel</button>
}
<p>
Backspace removes a step or restores the initial binding when empty.
Rejected steps remain editable.
</p>
<p role="status">{{ problem() }}</p>
</section>`,
})
export class Recording {
readonly initial: Hotkey = 'Alt+[KeyR]'
readonly hotkey = signal<Hotkey>(this.initial)
readonly sequence = signal<HotkeySequence>(['[KeyX]', '[KeyY]'])
readonly mode = signal<RecorderKeyMode>('code')
readonly problem = signal('')
readonly idle = signal(false)
readonly enter = signal(true)
readonly activity = inject(Activity)
readonly format = formatForDisplay
label = (sequence: HotkeySequence) =>
sequence.map((h) => formatForDisplay(h)).join(' → ')
setMode(value: string) {
this.mode.set(value === 'key' ? 'key' : 'code')
}
readonly recorder = injectHotkeyRecorder(() => ({
recordBy: this.mode(),
ignoreInputs: false,
detectConflicts: {
exclude: (reg) => reg.options.meta?.name === 'Recorded action',
},
validate: (_, { parsedHotkey }) =>
parsedHotkey.modifiers.length > 0 || 'Include a modifier.',
onRecord: (value) => {
this.hotkey.set(value)
this.problem.set('')
},
onClear: () => this.hotkey.set(this.initial),
onReject: (r) => this.problem.set(r.message),
onCancel: () => this.problem.set('Cancelled'),
}))
readonly seq = injectHotkeySequenceRecorder(() => ({
recordBy: this.mode(),
ignoreInputs: false,
idleTimeoutMs: this.idle() ? 1500 : undefined,
commitKeys: this.enter() ? 'enter' : 'none',
detectConflicts: {
exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
},
validate: (steps) => steps.length >= 2 || 'Record at least two chords.',
onRecord: (value) => {
this.sequence.set(value)
this.problem.set('')
},
onClear: () => this.sequence.set(['[KeyX]', '[KeyY]']),
onReject: (r) => this.problem.set(r.message),
onCancel: () => this.problem.set('Cancelled'),
}))
constructor() {
injectHotkey(
this.hotkey,
() => this.activity.log('Recorded shortcut fired'),
{ meta: { name: 'Recorded action', group: 'Recording' } },
)
injectHotkeySequence(
this.sequence,
() => this.activity.log('Recorded sequence fired'),
{ meta: { name: 'Recorded sequence', group: 'Recording' } },
)
}
}
@Component({
selector: 'format-demo',
standalone: true,
template: ` <section>
<h2>Formatting</h2>
<label
>Platform
<select
#platformInput
[value]="platform()"
(change)="setPlatform(platformInput.value)"
>
<option value="mac">macOS</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
</select></label
>
<label
><input
#mods
type="checkbox"
[checked]="modifiers()"
(change)="modifiers.set(mods.checked)"
/>
Modifier symbols</label
><label
><input
#keyInput
type="checkbox"
[checked]="keys()"
(change)="keys.set(keyInput.checked)"
/>
Key symbols</label
>
<table>
<thead>
<tr>
<th>Binding</th>
<th>String</th>
<th>Parts</th>
</tr>
</thead>
<tbody>
@for (binding of bindings; track binding) {
<tr>
<td>
<code>{{ binding }}</code>
</td>
<td>{{ format(binding, options()) }}</td>
<td>
@for (part of parts(binding); track $index) {
<kbd>{{ part }}</kbd>
}
</td>
</tr>
}
</tbody>
</table>
<p>
Supply layoutMap or keyLabels for layout labels. Formatting never changes
binding identity.
</p>
</section>`,
})
export class Formatting {
readonly platform = signal<'mac' | 'windows' | 'linux'>('mac')
readonly modifiers = signal(false)
readonly keys = signal(true)
readonly options = computed(() => ({
platform: this.platform(),
useSymbols: { modifiers: this.modifiers(), keys: this.keys() },
}))
readonly bindings = [
'Mod+Shift+ArrowUp',
'Control++',
'Alt+[KeyS]',
'Mod+[NumpadAdd]',
] as const
readonly format = formatForDisplay
parts = (binding: Hotkey) =>
formatForDisplay(binding, { ...this.options(), parts: true })
setPlatform(value: string) {
this.platform.set(value === 'mac' || value === 'linux' ? value : 'windows')
}
}
@Component({
selector: 'app-root',
standalone: true,
imports: [Hint, Tickets, Editor, Sequences, Recording, Formatting],
template: ` <header>
<h1>TanStack Hotkeys kitchen sink</h1>
<p>
Hold Alt / Option for hints. Route changes mount and unmount
registrations.
</p>
<nav>
@for (page of pages; track page.path) {
<a [href]="'#' + page.path" [class.active]="path() === page.path"
>{{ page.name }} <shortcut-hint [hotkey]="page.hotkey"
/></a>
}
</nav>
<button (click)="show.set(!show())">
{{ show() ? 'Hide' : 'Show' }} shortcuts
</button>
</header>
@if (show()) {
<section>
<h2>Live registry</h2>
<table>
<thead>
<tr>
<th>Group / action</th>
<th>Binding</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (reg of registrations(); track reg.id) {
<tr>
<td>
{{ reg.options.meta?.group }} / {{ reg.options.meta?.name }}
</td>
<td>{{ bindingLabel(reg) }}</td>
<td>
{{
reg.options.enabled === false
? 'Disabled'
: reg.triggerCount + ' fired'
}}
</td>
</tr>
}
</tbody>
</table>
</section>
}
<main>
@switch (path()) {
@case ('/editor') {
<editor-demo />
}
@case ('/sequences') {
<sequence-demo />
}
@case ('/recording') {
<recording-demo />
}
@case ('/formatting') {
<format-demo />
}
@default {
<ticket-demo />
}
}
</main>
<section>
<h2>Key state</h2>
<p>
Keys: {{ keys().join(' + ') || 'None' }}<br />Codes: {{ codeLabel()
}}<br />Shift: {{ shift() ? 'held' : 'released' }}
</p>
<h2>Activity</h2>
<button (click)="activity.messages.set([])">Clear log</button>
<ul aria-live="polite">
@for (message of activity.messages(); track $index) {
<li>{{ message }}</li>
}
</ul>
</section>`,
})
export class AppComponent {
readonly pages = [
{ path: '/', name: 'Tickets', hotkey: 'Alt+[Digit1]' },
{ path: '/editor', name: 'Editor', hotkey: 'Alt+[Digit2]' },
{ path: '/sequences', name: 'Sequences', hotkey: 'Alt+[Digit3]' },
{ path: '/recording', name: 'Recording', hotkey: 'Alt+[Digit4]' },
{ path: '/formatting', name: 'Formatting', hotkey: 'Alt+[Digit5]' },
] as const
readonly path = signal(location.hash.slice(1) || '/')
readonly show = signal(false)
readonly activity = inject(Activity)
readonly keys = injectHeldKeys()
readonly codes = injectHeldKeyCodes()
readonly shift = injectKeyHold('Shift')
readonly codeLabel = computed(
() => Object.values(this.codes()).join(' + ') || 'None',
)
readonly registry = injectHotkeyRegistrations()
readonly registrations = computed(() => [
...this.registry.hotkeys(),
...this.registry.sequences(),
])
bindingLabel = (reg: ReturnType<typeof this.registrations>[number]) =>
('hotkey' in reg ? [reg.hotkey] : reg.sequence)
.map((h) => formatForDisplay(h))
.join(' → ')
constructor() {
const update = () => this.path.set(location.hash.slice(1) || '/')
window.addEventListener('hashchange', update)
inject(DestroyRef).onDestroy(() =>
window.removeEventListener('hashchange', update),
)
injectHotkeys(
this.pages.map((page) => ({
hotkey: page.hotkey,
callback: () => {
location.hash = page.path
},
options: { meta: { name: page.name, group: 'Navigation' } },
})),
{ ignoreInputs: true },
)
injectHotkey(
'Alt+Shift+[KeyK]',
() => this.show.update((value) => !value),
{ meta: { name: 'Show shortcuts', group: 'Navigation' } },
)
}
}