import {
Show,
createContext,
createSignal,
onCleanup,
onMount,
useContext,
} from 'solid-js'
import { Dynamic, render } from 'solid-js/web'
import { TanStackDevtools } from '@tanstack/solid-devtools'
import { hotkeysDevtoolsPlugin } from '@tanstack/solid-hotkeys-devtools'
import {
HotkeysProvider,
createHeldKeyCodes,
createHeldKeys,
createHotkey,
createHotkeyHint,
createHotkeyRecorder,
createHotkeyRegistrations,
createHotkeySequence,
createHotkeySequenceRecorder,
createHotkeySequences,
createHotkeys,
createKeyHold,
formatForDisplay,
} from '@tanstack/solid-hotkeys'
import type {
Hotkey,
HotkeySequence,
RecorderKeyMode,
RegisterableHotkey,
} from '@tanstack/solid-hotkeys'
import './styles.css'
import type { JSX } from 'solid-js'
const ActivityContext = createContext<(message: string) => void>(() => {})
const pages = [
{ to: '/', label: 'Tickets', shortcut: 'Alt+[Digit1]' },
{ to: '/editor', label: 'Editor', shortcut: 'Alt+[Digit2]' },
{ to: '/sequences', label: 'Sequences', shortcut: 'Alt+[Digit3]' },
{ to: '/recording', label: 'Recording', shortcut: 'Alt+[Digit4]' },
{ to: '/formatting', label: 'Formatting', shortcut: 'Alt+[Digit5]' },
] as const
function Hint(props: { hotkey: RegisterableHotkey; enabled?: boolean }) {
const visible = createHotkeyHint(() => props.hotkey)
return (
<Show when={visible() && props.enabled !== false}>
<kbd>{formatForDisplay(props.hotkey)}</kbd>
</Show>
)
}
function Layout() {
const [path, setPath] = createSignal(location.hash.slice(1) || '/')
onMount(() => {
const update = () => setPath(location.hash.slice(1) || '/')
window.addEventListener('hashchange', update)
onCleanup(() => window.removeEventListener('hashchange', update))
})
const [showShortcuts, setShowShortcuts] = createSignal(false)
const [activity, setActivity] = createSignal<Array<string>>([])
const log = (message: string) => {
setActivity((items) => [message, ...items].slice(0, 6))
}
const keys = createHeldKeys()
const codes = createHeldKeyCodes()
const shift = createKeyHold('Shift')
const { hotkeys, sequences } = createHotkeyRegistrations()
const registrations = () => [...hotkeys(), ...sequences()]
const groups = () => [
...new Set(
registrations().map((reg) => reg.options.meta?.group ?? 'Other'),
),
]
createHotkeys(
pages.map((page) => ({
hotkey: page.shortcut,
callback: () => {
location.hash = page.to
},
options: { meta: { name: `Open ${page.label}`, group: 'Navigation' } },
})),
() => ({ ignoreInputs: true }),
)
createHotkey(
'Alt+Shift+[KeyK]',
() => setShowShortcuts((value) => !value),
() => ({
meta: { name: 'Show shortcuts', group: 'Navigation' },
}),
)
return (
<ActivityContext.Provider value={log}>
<header>
<h1>TanStack Hotkeys kitchen sink</h1>
<p>
Hold Alt / Option to see shortcut hints. Change routes to see
registrations mount and unmount.
</p>
<nav aria-label="Examples">
{pages.map((page) => (
<a href={`#${page.to}`} class={path() === page.to ? 'active' : ''}>
{page.label} <Hint hotkey={page.shortcut} />
</a>
))}
</nav>
<button
onClick={() => setShowShortcuts((value) => !value)}
aria-expanded={showShortcuts()}
>
{showShortcuts() ? 'Hide' : 'Show'} shortcuts{' '}
<Hint hotkey="Alt+Shift+[KeyK]" />
</button>
</header>
{showShortcuts() && (
<section aria-label="Registered shortcuts">
<h2>Registered shortcuts</h2>
<p>
Global and current-route registrations, including disabled handlers.
Scoped shortcuts run inside their target.
</p>
{groups().map((group) => (
<div>
<h3>{group}</h3>
<table>
<thead>
<tr>
<th>Action</th>
<th>Binding</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{registrations()
.filter(
(reg) => (reg.options.meta?.group ?? 'Other') === group,
)
.map((reg) => (
<tr>
<td>
{reg.options.meta?.name ?? reg.id}
<br />
<small>{reg.options.meta?.description}</small>
</td>
<td>
{('hotkey' in reg ? [reg.hotkey] : reg.sequence)
.map((step) => formatForDisplay(step))
.join(' → ')}
</td>
<td>
{reg.options.enabled === false
? 'Disabled'
: `${reg.triggerCount} fired`}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</section>
)}
<main>
<Dynamic component={routeComponents[path()] ?? Tickets} />
</main>
<section>
<h2>Key state</h2>
<p>
Keys: {keys().join(' + ') || 'None'}
<br />
Codes: {Object.values(codes()).join(' + ') || 'None'}
<br />
Shift: {shift() ? 'held' : 'released'}
</p>
<h2>Activity</h2>
<button onClick={() => setActivity([])}>Clear log</button>
<ul aria-live="polite">
{activity().map((message) => (
<li>{message}</li>
))}
</ul>
</section>
<TanStackDevtools plugins={[hotkeysDevtoolsPlugin()]} />
</ActivityContext.Provider>
)
}
function Tickets() {
const log = useContext(ActivityContext)
const [count, setCount] = createSignal(0)
const [enabled, setEnabled] = createSignal(true)
const create = () => {
setCount((n) => n + 1)
log('Ticket created')
}
const save = () => log('Pending ticket saved')
createHotkey('Alt+[KeyC]', create, () => ({
enabled: enabled(),
ignoreInputs: false,
meta: {
name: 'Create ticket',
description: 'Also works while entering a ticket note',
group: 'Tickets',
},
}))
createHotkey('Alt+[KeyS]', save, () => ({
enabled: enabled(),
meta: { name: 'Save pending ticket', group: 'Tickets' },
}))
return (
<section>
<h2>Tickets</h2>
<p>
Created: {count()}. Hold Alt / Option for hints. Release and press again
to create another ticket.
</p>
<label>
<input
type="checkbox"
checked={enabled()}
onChange={(e) => setEnabled(e.currentTarget.checked)}
/>{' '}
Enable ticket actions
</label>
<p>
<button disabled={!enabled()} onClick={create}>
Create ticket <Hint hotkey="Alt+[KeyC]" enabled={enabled()} />
</button>
<button disabled={!enabled()} onClick={save}>
Save pending <Hint hotkey="Alt+[KeyS]" enabled={enabled()} />
</button>
</p>
<label>
Ticket note{' '}
<input placeholder="Try Alt + the physical C key while typing" />
</label>
<p>
Create uses <code>ignoreInputs: false</code>; Save uses the default
input filtering. The provider supplies <code>requireReset: true</code>.
</p>
</section>
)
}
function EditorPane({ name }: { name: string }) {
const log = useContext(ActivityContext)
let target: HTMLFieldSetElement | undefined
const [text, setText] = createSignal('One shortcut, two independent editors.')
const [saves, setSaves] = createSignal(0)
createHotkeys(
[
{
hotkey: 'Mod+[KeyS]',
callback: () => {
setSaves((n) => n + 1)
log(`${name} saved`)
},
options: {
meta: {
name: `Save ${name}`,
group: 'Editor',
description: 'Scoped to this editor',
},
},
},
{
hotkey: 'Alt+[Backspace]',
callback: () => {
setText('')
log(`${name} cleared`)
},
options: { meta: { name: `Clear ${name}`, group: 'Editor' } },
},
],
() => ({ target, ignoreInputs: false }),
)
return (
<fieldset ref={target}>
<legend>
{name} — {saves()} saves
</legend>
<label>
Editor content
<textarea
rows={3}
value={text()}
onChange={(e) => setText(e.currentTarget.value)}
/>
</label>
<p>
<kbd>{formatForDisplay('Mod+[KeyS]')}</kbd> Save;{' '}
<kbd>{formatForDisplay('Alt+[Backspace]')}</kbd> Clear
</p>
</fieldset>
)
}
function Editor() {
const log = useContext(ActivityContext)
const [repeat, setRepeat] = createSignal(false)
const [count, setCount] = createSignal(0)
const [bubble, setBubble] = createSignal(false)
createHotkey(
'Alt+[ArrowRight]',
() => setCount((n) => n + 1),
() => ({
requireReset: !repeat(),
meta: { name: 'Advance counter', group: 'Editor' },
}),
)
let scope: HTMLFieldSetElement | undefined
createHotkey(
'Alt+[KeyB]',
() => log('Scoped B handler'),
() => ({
target: scope,
stopPropagation: !bubble(),
preventDefault: !bubble(),
meta: { name: 'Scoped propagation demo', group: 'Editor' },
}),
)
createHotkey(
'Alt+[KeyB]',
() => log('Document B handler received the bubbled event'),
() => ({ meta: { name: 'Document propagation demo', group: 'Editor' } }),
)
createHotkey(
'Alt+[KeyU]',
() => log('Key released: keyup handler'),
() => ({
eventType: 'keyup',
meta: { name: 'Run on release', group: 'Editor' },
}),
)
return (
<section>
<h2>Editor scopes</h2>
<p>
Save is registered in both editors. Focus a textarea to choose which
handler receives it.
</p>
<EditorPane name="Draft" />
<EditorPane name="Notes" />
<h3>Repeat and keyup</h3>
<label>
<input
type="checkbox"
checked={repeat()}
onChange={(e) => setRepeat(e.currentTarget.checked)}
/>{' '}
Allow key repeat
</label>
<p>
Hold <kbd>{formatForDisplay('Alt+[ArrowRight]')}</kbd> to advance:{' '}
{count()}. Release <kbd>{formatForDisplay('Alt+[KeyU]')}</kbd> to log a
keyup event.
</p>
<fieldset ref={scope} tabIndex={0}>
<legend>Propagation</legend>
<label>
<input
type="checkbox"
checked={bubble()}
onChange={(e) => setBubble(e.currentTarget.checked)}
/>{' '}
Allow bubbling and browser defaults
</label>
<p>
Focus this area and press <kbd>{formatForDisplay('Alt+[KeyB]')}</kbd>.
Activity shows whether the document handler also receives the event.
</p>
<button onClick={() => scope?.focus()}>Focus this area</button>
</fieldset>
</section>
)
}
function Sequences() {
const log = useContext(ActivityContext)
const [timeout, setTimeoutValue] = createSignal(1000)
createHotkeySequence(
['[KeyG]', '[KeyG]'],
() => log('Sequence: go to top'),
() => ({
timeout: timeout(),
meta: { name: 'Go to top', group: 'Sequences' },
}),
)
createHotkeySequences(
[
{
sequence: ['[KeyG]', '[KeyI]'],
callback: () => log('Sequence: inbox'),
options: { meta: { name: 'Open inbox', group: 'Sequences' } },
},
{
sequence: ['Shift+[KeyR]', 'Shift+[KeyT]'],
callback: () => log('Sequence: shifted chord chain'),
options: { meta: { name: 'Shifted chain', group: 'Sequences' } },
},
],
() => ({ timeout: timeout() }),
)
const { sequences } = createHotkeyRegistrations()
return (
<section>
<h2>Sequences</h2>
<p>
Try the physical G position twice, G then I, or hold Shift and press R
then T.
</p>
<label>
Time between steps: {timeout()} ms{' '}
<input
type="range"
min="300"
max="2500"
step="100"
value={timeout()}
onChange={(e) => setTimeoutValue(Number(e.currentTarget.value))}
/>
</label>
<table>
<thead>
<tr>
<th>Action</th>
<th>Sequence</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{sequences().map((reg) => (
<tr>
<td>{reg.options.meta?.name}</td>
<td>
{reg.sequence.map((step) => formatForDisplay(step)).join(' → ')}
</td>
<td>
{reg.matchedStepCount}/{reg.sequence.length} steps;{' '}
{reg.triggerCount} fired
</td>
</tr>
))}
</tbody>
</table>
<p>
Wrong keys and timeouts reset progress. Modifier-only presses and
automatic repeats do not advance a sequence.
</p>
</section>
)
}
function Recording() {
const log = useContext(ActivityContext)
const initial: Hotkey = 'Alt+[KeyR]'
const [hotkey, setHotkey] = createSignal<Hotkey>(initial)
const [mode, setMode] = createSignal<RecorderKeyMode>('code')
const [problem, setProblem] = createSignal('')
const [sequence, setSequence] = createSignal<HotkeySequence>([
'[KeyX]',
'[KeyY]',
])
const [sequenceProblem, setSequenceProblem] = createSignal('')
const [idle, setIdle] = createSignal(false)
const [commitOnEnter, setCommitOnEnter] = createSignal(true)
createHotkey(
hotkey,
() => log('Your recorded shortcut fired'),
() => ({
meta: { name: 'Recorded action', group: 'Recording' },
}),
)
createHotkeySequence(
sequence,
() => log('Your recorded sequence fired'),
() => ({
meta: { name: 'Recorded sequence', group: 'Recording' },
}),
)
const recorder = createHotkeyRecorder(() => ({
recordBy: mode(),
ignoreInputs: false,
detectConflicts: {
exclude: (reg) => reg.options.meta?.name === 'Recorded action',
},
validate: (_, { parsedHotkey }) =>
parsedHotkey.modifiers.length > 0 ||
'Include a modifier for this action.',
onReject: ({ message }) => setProblem(message),
onRecord: (value) => {
setHotkey(value)
setProblem('')
log(`Recorded ${value}`)
},
onClear: () => {
setHotkey(initial)
setProblem('')
log('Restored default shortcut')
},
onCancel: () => setProblem('Recording cancelled. Binding unchanged.'),
}))
const sequenceRecorder = createHotkeySequenceRecorder(() => ({
recordBy: mode(),
ignoreInputs: false,
idleTimeoutMs: idle() ? 1500 : undefined,
commitKeys: commitOnEnter() ? 'enter' : 'none',
detectConflicts: {
exclude: (reg) => reg.options.meta?.name === 'Recorded sequence',
},
validate: (steps) => steps.length >= 2 || 'Record at least two chords.',
onRecord: (value) => {
setSequence(value)
setSequenceProblem('')
log(`Recorded sequence ${value.join(' → ')}`)
},
onReject: (reason) => setSequenceProblem(reason.message),
onClear: () => {
setSequence(['[KeyX]', '[KeyY]'])
setSequenceProblem('Restored default sequence.')
},
onCancel: () => setSequenceProblem('Recording cancelled.'),
}))
return (
<section>
<h2>Recording</h2>
<p>
Recorded bindings go directly into registrations. Changes live in this
route's local state.
</p>
<label>
Record by{' '}
<select
value={mode()}
disabled={recorder.isRecording() || sequenceRecorder.isRecording()}
onChange={(e) => setMode(e.currentTarget.value as RecorderKeyMode)}
>
<option value="code">Physical code (default)</option>
<option value="key">Logical character</option>
</select>
</label>
<h3>Single shortcut</h3>
<p>
<kbd>{formatForDisplay(hotkey())}</kbd> — stored as{' '}
<code>{hotkey()}</code>
</p>
<button
disabled={recorder.isRecording() || sequenceRecorder.isRecording()}
onClick={() => {
setProblem('')
recorder.startRecording()
}}
>
{recorder.isRecording() ? 'Listening…' : 'Record shortcut'}
</button>
<button
onClick={() => {
recorder.stopRecording()
setHotkey(initial)
setProblem('')
}}
>
Reset
</button>
{recorder.isRecording() && (
<button onClick={recorder.cancelRecording}>Cancel</button>
)}
<p>
Include a modifier. Try Alt + 1 for a navigation conflict. Escape
cancels; Backspace restores the initial shortcut.
</p>
<p role="status">{problem()}</p>
<h3>Sequence</h3>
<p>
{(sequenceRecorder.isRecording()
? sequenceRecorder.steps()
: sequence()
)
.map((step) => formatForDisplay(step))
.join(' → ') || 'Waiting for the first chord…'}
</p>
<label>
<input
type="checkbox"
checked={idle()}
onChange={(e) => setIdle(e.currentTarget.checked)}
/>{' '}
Commit after 1.5 seconds idle
</label>
<label>
<input
type="checkbox"
checked={commitOnEnter()}
onChange={(e) => setCommitOnEnter(e.currentTarget.checked)}
/>{' '}
Enter commits the sequence
</label>
<p>
<button
disabled={recorder.isRecording() || sequenceRecorder.isRecording()}
onClick={() => {
setSequenceProblem('')
sequenceRecorder.startRecording()
}}
>
Record sequence
</button>
<button
disabled={
!sequenceRecorder.isRecording() || !sequenceRecorder.steps().length
}
onClick={sequenceRecorder.commitRecording}
>
Commit
</button>
{sequenceRecorder.isRecording() && (
<button onClick={sequenceRecorder.cancelRecording}>Cancel</button>
)}
</p>
<p>
Record at least two chords. Backspace removes a step; when empty it
restores the initial sequence. Rejected steps remain editable.
</p>
<p role="status">{sequenceProblem()}</p>
</section>
)
}
function Formatting() {
const [platform, setPlatform] = createSignal<'mac' | 'windows' | 'linux'>(
'mac',
)
const [modifierSymbols, setModifierSymbols] = createSignal(false)
const [keySymbols, setKeySymbols] = createSignal(true)
const options = () => ({
platform: platform(),
useSymbols: { modifiers: modifierSymbols(), keys: keySymbols() },
})
return (
<section>
<h2>Formatting</h2>
<label>
Platform{' '}
<select
value={platform()}
onChange={(e) =>
setPlatform(e.currentTarget.value as 'mac' | 'windows' | 'linux')
}
>
<option value="mac">macOS</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={modifierSymbols()}
onChange={(e) => setModifierSymbols(e.currentTarget.checked)}
/>{' '}
Modifier symbols
</label>
<label>
<input
type="checkbox"
checked={keySymbols()}
onChange={(e) => setKeySymbols(e.currentTarget.checked)}
/>{' '}
Key symbols
</label>
<table>
<thead>
<tr>
<th>Stored binding</th>
<th>String</th>
<th>Parts</th>
</tr>
</thead>
<tbody>
{(
[
'Mod+Shift+ArrowUp',
'Control++',
'Alt+[KeyS]',
'Mod+[NumpadAdd]',
] as const
).map((hotkey) => (
<tr>
<td>
<code>{hotkey}</code>
</td>
<td>{formatForDisplay(hotkey, options())}</td>
<td>
{formatForDisplay(hotkey, { ...options(), parts: true }).map(
(part) => (
<kbd>{part}</kbd>
),
)}
</td>
</tr>
))}
</tbody>
</table>
<p>
Physical codes use readable fallback labels. Supply a resolved{' '}
<code>layoutMap</code> or explicit <code>keyLabels</code> for
layout-specific labels.
</p>
</section>
)
}
const routeComponents: Record<string, () => JSX.Element> = {
'/': Tickets,
'/editor': Editor,
'/sequences': Sequences,
'/recording': Recording,
'/formatting': Formatting,
}
const dispose = render(
() => (
<HotkeysProvider defaultOptions={{ hotkey: { requireReset: true } }}>
<Layout />
</HotkeysProvider>
),
document.getElementById('root')!,
)
import.meta.hot?.dispose(dispose)