import { LitElement, html } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import {
HeldKeyCodesController,
HeldKeysController,
HotkeyController,
HotkeyHintController,
HotkeyRecorderController,
HotkeyRegistrationsController,
KeyHoldController,
formatForDisplay,
hotkey,
isHotkeySequence,
} from '@tanstack/lit-hotkeys'
import type { Hotkey, RecorderKeyMode } from '@tanstack/lit-hotkeys'
import './styles.css'
class DemoElement extends LitElement {
protected createRenderRoot() {
return this
}
protected log(message: string) {
this.dispatchEvent(
new CustomEvent('activity', {
detail: message,
bubbles: true,
composed: true,
}),
)
}
}
@customElement('shortcut-hint')
class Hint extends DemoElement {
@property() hotkey: Hotkey = 'Escape'
@property({ type: Boolean }) enabled = true
private hint = new HotkeyHintController(this, () => this.hotkey)
render() {
return this.hint.value && this.enabled
? html`<kbd>${formatForDisplay(this.hotkey)}</kbd>`
: null
}
}
@customElement('ticket-demo')
class Tickets extends DemoElement {
@state() private count = 0
@state() private enabled = true
private create = () => {
this.count++
this.log('Ticket created')
}
private save = () => this.log('Ticket saved')
constructor() {
super()
this.addController(
new HotkeyController(this, 'Alt+[KeyC]', this.create, () => ({
enabled: this.enabled,
requireReset: true,
ignoreInputs: false,
meta: { name: 'Create ticket', group: 'Tickets' },
})),
)
this.addController(
new HotkeyController(this, 'Alt+[KeyS]', this.save, () => ({
enabled: this.enabled,
requireReset: true,
meta: { name: 'Save ticket', group: 'Tickets' },
})),
)
}
private toggle = (event: Event) => {
this.enabled = (event.target as HTMLInputElement).checked
}
render() {
return html`<section>
<h2>Tickets</h2>
<p>Created: ${this.count}. Buttons and shortcuts share handlers.</p>
<label
><input
type="checkbox"
.checked=${this.enabled}
@change=${this.toggle}
/>
Enable ticket actions</label
>
<button ?disabled=${!this.enabled} @click=${this.create}>
Create ticket
<shortcut-hint
hotkey="Alt+[KeyC]"
.enabled=${this.enabled}
></shortcut-hint>
</button>
<button ?disabled=${!this.enabled} @click=${this.save}>
Save pending
<shortcut-hint
hotkey="Alt+[KeyS]"
.enabled=${this.enabled}
></shortcut-hint>
</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>`
}
}
@customElement('editor-pane')
class EditorPane extends DemoElement {
@property() name = 'Editor'
@state() private text = 'One shortcut, two independent editors.'
@state() private saves = 0
constructor() {
super()
this.addController(
new HotkeyController(
this,
'Mod+[KeyS]',
() => {
this.saves++
this.log(`${this.name} saved`)
},
{
target: this,
ignoreInputs: false,
requireReset: true,
meta: { name: 'Save editor', group: 'Editor' },
},
),
)
this.addController(
new HotkeyController(
this,
'Alt+[Backspace]',
() => {
this.text = ''
},
{
target: this,
ignoreInputs: false,
meta: { name: 'Clear editor', group: 'Editor' },
},
),
)
}
render() {
return html`<fieldset>
<legend>${this.name} — ${this.saves} saves</legend>
<label
>Editor content<textarea
rows="3"
.value=${this.text}
@input=${(e: Event) => {
this.text = (e.target as HTMLTextAreaElement).value
}}
></textarea>
</label>
<p>
<kbd>${formatForDisplay('Mod+[KeyS]')}</kbd> Save;
<kbd>${formatForDisplay('Alt+[Backspace]')}</kbd> Clear
</p>
</fieldset>`
}
}
@customElement('editor-demo')
class Editor extends DemoElement {
@state() private count = 0
@state() private repeat = false
@state() private bubble = false
constructor() {
super()
this.addController(
new HotkeyController(
this,
'Alt+[ArrowRight]',
() => this.count++,
() => ({
requireReset: !this.repeat,
meta: { name: 'Counter', group: 'Editor' },
}),
),
)
this.addController(
new HotkeyController(
this,
'Alt+[KeyB]',
() => this.log('Scoped B'),
() => ({
target: this.querySelector<HTMLElement>('#scope'),
preventDefault: !this.bubble,
stopPropagation: !this.bubble,
meta: { name: 'Scoped propagation', group: 'Editor' },
}),
),
)
}
@hotkey('Alt+[KeyU]', {
eventType: 'keyup',
meta: { name: 'Keyup', group: 'Editor' },
})
released() {
this.log('Keyup U')
}
@hotkey('Alt+[KeyB]', {
meta: { name: 'Document propagation', group: 'Editor' },
})
documentB() {
this.log('Document B')
}
render() {
return html`<section>
<h2>Editor scopes</h2>
<p>Focus an editor to choose its Save handler.</p>
<editor-pane name="Draft"></editor-pane
><editor-pane name="Notes"></editor-pane>
<label
><input
type="checkbox"
.checked=${this.repeat}
@change=${(e: Event) => {
this.repeat = (e.target as HTMLInputElement).checked
}}
/>
Allow repeat</label
>
<p>
Hold Alt + ArrowRight: ${this.count}. Release Alt + U for a keyup event.
</p>
<fieldset id="scope" tabindex="0">
<legend>Propagation</legend>
<label
><input
type="checkbox"
.checked=${this.bubble}
@change=${(e: Event) => {
this.bubble = (e.target as HTMLInputElement).checked
}}
/>
Allow bubbling and browser defaults</label
>
<p>Focus here and press Alt + B; watch Activity.</p>
<button
@click=${() => this.querySelector<HTMLElement>('#scope')?.focus()}
>
Focus this area
</button>
</fieldset>
</section>`
}
}
@customElement('sequence-demo')
class Sequences extends DemoElement {
@state() private timeout = 1000
private registry = new HotkeyRegistrationsController(this)
constructor() {
super()
this.addController(
new HotkeyController(
this,
['Shift+[KeyR]', 'Shift+[KeyT]'],
() => this.log('Shift chain'),
{ meta: { name: 'Shift chain (1000ms)', group: 'Sequences' } },
),
)
this.addController(
new HotkeyController(
this,
['[KeyG]', '[KeyG]'],
() => this.log('Go to top'),
() => ({
timeout: this.timeout,
meta: { name: 'Go to top', group: 'Sequences' },
}),
),
)
this.addController(
new HotkeyController(
this,
['[KeyG]', '[KeyI]'],
() => this.log('Inbox'),
() => ({
timeout: this.timeout,
meta: { name: 'Inbox', group: 'Sequences' },
}),
),
)
}
render() {
return html`<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
>G sequence timeout: ${this.timeout} ms
<input
type="range"
min="300"
max="2500"
step="100"
.value=${String(this.timeout)}
@input=${(e: Event) => {
this.timeout = Number((e.target as HTMLInputElement).value)
}}
/></label>
<table>
<tbody>
${this.registry.hotkeys.map((reg) =>
reg.kind !== 'sequence'
? null
: html`<tr>
<td>${reg.options.meta?.name}</td>
<td>
${formatForDisplay(reg.hotkey, { sequenceSeparator: ' → ' })}
</td>
<td>
${reg.matchedStepCount}/${reg.hotkey.length} steps;
${reg.triggerCount} fired
</td>
</tr>`,
)}
</tbody>
</table>
</section>`
}
}
@customElement('recording-demo')
class Recording extends DemoElement {
private initial: Hotkey = 'Alt+[KeyR]'
@state() private binding: Hotkey = this.initial
@state() private sequence: Hotkey = ['[KeyX]', '[KeyY]']
@state() private mode: RecorderKeyMode = 'code'
@state() private problem = ''
@state() private idle = false
@state() private enter = true
private recorder = new HotkeyRecorderController(this, {
ignoreInputs: false,
detectConflicts: {
exclude: (reg) => reg.options.meta?.name === 'Recorded action',
},
validate: (_, { parsedHotkey }) =>
parsedHotkey.modifiers.length > 0 || 'Include a modifier.',
onRecord: (value) => {
this.binding = value
this.problem = ''
},
onClear: () => {
this.binding = this.initial
},
onReject: (r) => {
this.problem = r.message
},
onCancel: () => {
this.problem = 'Cancelled'
},
})
private seq = new HotkeyRecorderController(this, {
maxSteps: Infinity,
ignoreInputs: false,
detectConflicts: {
exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
},
validate: (value) =>
(isHotkeySequence(value) && value.length >= 2) ||
'Record at least two chords.',
onRecord: (value) => {
this.sequence = value
this.problem = ''
},
onClear: () => {
this.sequence = ['[KeyX]', '[KeyY]']
},
onReject: (r) => {
this.problem = r.message
},
onCancel: () => {
this.problem = 'Cancelled'
},
})
constructor() {
super()
this.addController(
new HotkeyController(
this,
() => this.binding,
() => this.log('Recorded shortcut fired'),
{
requireReset: true,
meta: { name: 'Recorded action', group: 'Recording' },
},
),
)
this.addController(
new HotkeyController(
this,
() => this.sequence,
() => this.log('Recorded sequence fired'),
{ meta: { name: 'Recorded sequence', group: 'Recording' } },
),
)
}
render() {
return html`<section>
<h2>Recording</h2>
<p>Bindings live in route state. Try Alt + 1 for a conflict.</p>
<label
>Record by
<select
.value=${this.mode}
?disabled=${this.recorder.isRecording || this.seq.isRecording}
@change=${(e: Event) => {
this.mode = (e.target as HTMLSelectElement).value as RecorderKeyMode
this.recorder.setOptions({ recordBy: this.mode })
this.seq.setOptions({ recordBy: this.mode })
}}
>
<option value="code">Physical code</option>
<option value="key">Logical key</option>
</select></label
>
<h3>Single shortcut</h3>
<p>
<kbd>${formatForDisplay(this.binding)}</kbd> —
<code>${this.binding}</code>
</p>
<button
?disabled=${this.recorder.isRecording || this.seq.isRecording}
@click=${() => this.recorder.startRecording()}
>
Record shortcut</button
><button
@click=${() => {
this.recorder.stopRecording()
this.binding = this.initial
}}
>
Reset</button
>${
this.recorder.isRecording
? html`<button @click=${() => this.recorder.cancelRecording()}>
Cancel
</button>`
: null
}
<h3>Sequence</h3>
<p>
${formatForDisplay(
this.seq.isRecording ? this.seq.steps : this.sequence,
{
sequenceSeparator: ' → ',
},
)}
</p>
<label
><input
type="checkbox"
.checked=${this.idle}
@change=${(e: Event) => {
this.idle = (e.target as HTMLInputElement).checked
this.seq.setOptions({ idleTimeoutMs: this.idle ? 1500 : undefined })
}}
/>
Commit after 1.5 seconds idle</label
>
<label
><input
type="checkbox"
.checked=${this.enter}
@change=${(e: Event) => {
this.enter = (e.target as HTMLInputElement).checked
this.seq.setOptions({ commitKeys: this.enter ? 'enter' : 'none' })
}}
/>
Enter commits</label
>
<button
?disabled=${this.recorder.isRecording || this.seq.isRecording}
@click=${() => this.seq.startRecording()}
>
Record sequence</button
><button
?disabled=${!this.seq.isRecording || !this.seq.steps.length}
@click=${() => this.seq.commitRecording()}
>
Commit</button
>${
this.seq.isRecording
? html`<button @click=${() => this.seq.cancelRecording()}>
Cancel
</button>`
: null
}
<p>
Backspace removes a step or restores the initial binding when empty.
Rejected steps remain editable.
</p>
<p role="status">${this.problem}</p>
</section>`
}
}
@customElement('format-demo')
class Formatting extends DemoElement {
@state() private platform: 'mac' | 'windows' | 'linux' = 'mac'
@state() private modifiers = false
@state() private keys = true
render() {
const options = {
platform: this.platform,
useSymbols: { modifiers: this.modifiers, keys: this.keys },
}
return html`<section>
<h2>Formatting</h2>
<label
>Platform
<select
.value=${this.platform}
@change=${(e: Event) => {
this.platform = (e.target as HTMLSelectElement)
.value as typeof this.platform
}}
>
<option value="mac">macOS</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
</select></label
>
<label
><input
type="checkbox"
.checked=${this.modifiers}
@change=${(e: Event) => {
this.modifiers = (e.target as HTMLInputElement).checked
}}
/>
Modifier symbols</label
><label
><input
type="checkbox"
.checked=${this.keys}
@change=${(e: Event) => {
this.keys = (e.target as HTMLInputElement).checked
}}
/>
Key symbols</label
>
<table>
<thead>
<tr>
<th>Binding</th>
<th>String</th>
<th>Parts</th>
</tr>
</thead>
<tbody>
${(
[
'Mod+Shift+ArrowUp',
'Control++',
'Alt+[KeyS]',
'Mod+[NumpadAdd]',
] as const
).map(
(binding) =>
html`<tr>
<td><code>${binding}</code></td>
<td>${formatForDisplay(binding, options)}</td>
<td>
${formatForDisplay(binding, { ...options, parts: true }).map(
(part) => html`<kbd>${part}</kbd>`,
)}
</td>
</tr>`,
)}
</tbody>
</table>
<p>
Supply layoutMap or keyLabels for layout labels. Formatting never
changes binding identity.
</p>
</section>`
}
}
@customElement('my-app')
class App extends DemoElement {
private 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
@state() private path = location.hash.slice(1) || '/'
@state() private show = false
@state() private activity: Array<string> = []
private keys = new HeldKeysController(this)
private codes = new HeldKeyCodesController(this)
private shift = new KeyHoldController(this, 'Shift')
private registry = new HotkeyRegistrationsController(this)
private updatePath = () => {
this.path = location.hash.slice(1) || '/'
}
constructor() {
super()
for (const page of this.pages)
this.addController(
new HotkeyController(
this,
page.hotkey,
() => {
location.hash = page.path
},
{
ignoreInputs: true,
requireReset: true,
meta: { name: page.name, group: 'Navigation' },
},
),
)
this.addController(
new HotkeyController(
this,
'Alt+Shift+[KeyK]',
() => {
this.show = !this.show
},
{
requireReset: true,
meta: { name: 'Show shortcuts', group: 'Navigation' },
},
),
)
}
connectedCallback() {
super.connectedCallback()
window.addEventListener('hashchange', this.updatePath)
}
disconnectedCallback() {
window.removeEventListener('hashchange', this.updatePath)
super.disconnectedCallback()
}
private route() {
switch (this.path) {
case '/editor':
return html`<editor-demo></editor-demo>`
case '/sequences':
return html`<sequence-demo></sequence-demo>`
case '/recording':
return html`<recording-demo></recording-demo>`
case '/formatting':
return html`<format-demo></format-demo>`
default:
return html`<ticket-demo></ticket-demo>`
}
}
render() {
return html`<header>
<h1>TanStack Hotkeys kitchen sink</h1>
<p>
Hold Alt / Option for hints. Route changes mount and unmount
registrations.
</p>
<nav>
${this.pages.map(
(page) =>
html`<a
href=${`#${page.path}`}
class=${this.path === page.path ? 'active' : ''}
>${page.name}
<shortcut-hint .hotkey=${page.hotkey}></shortcut-hint
></a>`,
)}
</nav>
<button
@click=${() => {
this.show = !this.show
}}
>
${this.show ? 'Hide' : 'Show'} shortcuts
</button>
</header>
${
this.show
? html`<section>
<h2>Live registry</h2>
<table>
<thead>
<tr>
<th>Group / action</th>
<th>Binding</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${this.registry.hotkeys.map(
(reg) =>
html`<tr>
<td>
${reg.options.meta?.group} / ${reg.options.meta?.name}
</td>
<td>
${formatForDisplay(reg.hotkey, {
sequenceSeparator: ' → ',
})}
</td>
<td>
${
reg.options.enabled === false
? 'Disabled'
: `${reg.triggerCount} fired`
}
</td>
</tr>`,
)}
</tbody>
</table>
</section>`
: null
}
<main
@activity=${(e: CustomEvent<string>) => {
this.activity = [e.detail, ...this.activity].slice(0, 6)
}}
>
${this.route()}
</main>
<section>
<h2>Key state</h2>
<p>
Keys: ${this.keys.value.join(' + ') || 'None'}<br />Codes:
${Object.values(this.codes.value).join(' + ') || 'None'}<br />Shift:
${this.shift.value ? 'held' : 'released'}
</p>
<h2>Activity</h2>
<button
@click=${() => {
this.activity = []
}}
>
Clear log
</button>
<ul aria-live="polite">
${this.activity.map((message) => html`<li>${message}</li>`)}
</ul>
</section>`
}
}