import { useCallback, useState } from 'react'
import { createRoot } from 'react-dom/client'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
useNavigate,
} from '@tanstack/react-router'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { hotkeysDevtoolsPlugin } from '@tanstack/react-hotkeys-devtools'
import {
HotkeysProvider,
useAppHotkeyList,
useAppHotkeys,
useHeldKeyCodes,
useHeldKeys,
useHotkey,
useHotkeyRegistrations,
useHotkeys,
useKeyHold,
} from '@tanstack/react-hotkeys'
import { appHotkeys } from './app-hotkeys'
import { ActivityContext, Hint, formatHotkey } from './shared'
import type { CustomHotkeys } from '@tanstack/react-hotkeys'
import './styles.css'
const pages = [
{ to: '/', label: 'Tickets', hotkeyId: 'nav.tickets' },
{ to: '/editor', label: 'Editor', hotkeyId: 'nav.editor' },
{ to: '/sequences', label: 'Sequences', hotkeyId: 'nav.sequences' },
{ to: '/shortcuts', label: 'Shortcuts', hotkeyId: 'nav.shortcuts' },
{ to: '/formatting', label: 'Formatting', hotkeyId: 'nav.formatting' },
] as const
const statusLabels = {
active: 'Active',
disabled: 'Disabled',
inactive: 'Not on this page',
unbound: 'Unbound',
}
function ShortcutPanel() {
const items = useAppHotkeyList()
const undeclared = useHotkeyRegistrations().filter(
(reg) => reg.options.appHotkeyId === undefined,
)
const groups = [...new Set(items.map((item) => item.appHotkey.meta?.group))]
return (
<section aria-label="Shortcuts">
<h2>Shortcuts</h2>
<p>
Declared hotkeys from every route, before their route loads. Status
shows whether the current page registers each one.
</p>
{groups.map((group) => (
<div key={group}>
<h3>{group}</h3>
<table>
<thead>
<tr>
<th>Action</th>
<th>Hotkey</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{items
.filter((item) => item.appHotkey.meta?.group === group)
.map(({ appHotkey, status, isCustom, registrations }) => (
<tr key={appHotkey.id} data-status={status}>
<td>
{appHotkey.meta?.name}
<br />
<small>{appHotkey.meta?.description}</small>
</td>
<td>
{formatHotkey(appHotkey.hotkey)}
{isCustom && <small> (custom)</small>}
</td>
<td>
{statusLabels[status]}
{registrations.length > 1 &&
` (${registrations.length} registrations)`}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{undeclared.length > 0 && (
<>
<h3>Undeclared on this page</h3>
<p>
Registered with a plain hotkey string, so they appear only while
mounted.
</p>
<table>
<tbody>
{undeclared.map((reg) => (
<tr key={reg.id}>
<td>{reg.options.meta?.name ?? reg.id}</td>
<td>{formatHotkey(reg.hotkey)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
)
}
function Layout() {
const navigate = useNavigate()
const hotkeys = useAppHotkeys()
const [showShortcuts, setShowShortcuts] = useState(false)
const [activity, setActivity] = useState<Array<string>>([])
const log = useCallback((message: string) => {
setActivity((items) => [message, ...items].slice(0, 6))
}, [])
const keys = useHeldKeys()
const codes = useHeldKeyCodes()
const shift = useKeyHold('Shift')
useHotkeys(
pages.map((page) => ({
hotkey: hotkeys[page.hotkeyId],
callback: () => {
void navigate({ to: page.to })
},
})),
)
useHotkey(hotkeys['panel.toggle'], () => setShowShortcuts((value) => !value))
return (
<ActivityContext.Provider value={log}>
<header>
<h1>TanStack Hotkeys kitchen sink</h1>
<p>
Hold Alt / Option to see shortcut hints. Every hotkey is declared in{' '}
<code>app-hotkeys.ts</code>; each route loads its code on first visit.
</p>
<nav aria-label="Examples">
{pages.map((page) => (
<Link
key={page.to}
to={page.to}
activeOptions={{ exact: true }}
activeProps={{ className: 'active' }}
>
{page.label} <Hint id={page.hotkeyId} />
</Link>
))}
</nav>
<button
onClick={() => setShowShortcuts((value) => !value)}
aria-expanded={showShortcuts}
>
{showShortcuts ? 'Hide' : 'Show'} shortcuts <Hint id="panel.toggle" />
</button>
</header>
{showShortcuts && <ShortcutPanel />}
<main>
<Outlet />
</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, i) => (
<li key={i}>{message}</li>
))}
</ul>
</section>
<TanStackDevtools plugins={[hotkeysDevtoolsPlugin()]} />
</ActivityContext.Provider>
)
}
const rootRoute = createRootRoute({ component: Layout })
const routeTree = rootRoute.addChildren([
createRoute({ getParentRoute: () => rootRoute, path: '/' }).lazy(() =>
import('./routes/tickets').then((d) => d.Route),
),
createRoute({ getParentRoute: () => rootRoute, path: '/editor' }).lazy(() =>
import('./routes/editor').then((d) => d.Route),
),
createRoute({ getParentRoute: () => rootRoute, path: '/sequences' }).lazy(
() => import('./routes/sequences').then((d) => d.Route),
),
createRoute({ getParentRoute: () => rootRoute, path: '/shortcuts' }).lazy(
() => import('./routes/shortcuts').then((d) => d.Route),
),
createRoute({ getParentRoute: () => rootRoute, path: '/formatting' }).lazy(
() => import('./routes/formatting').then((d) => d.Route),
),
])
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const STORAGE_KEY = 'tanstack-hotkeys-kitchen-sink-custom-hotkeys'
function loadCustomHotkeys(): CustomHotkeys | null {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null')
} catch {
return null
}
}
function App() {
return (
<HotkeysProvider
appHotkeys={appHotkeys}
customHotkeys={loadCustomHotkeys()}
onCustomHotkeysChange={(customHotkeys) =>
localStorage.setItem(STORAGE_KEY, JSON.stringify(customHotkeys))
}
defaultOptions={{ hotkey: { requireReset: true } }}
>
<RouterProvider router={router} />
</HotkeysProvider>
)
}
const root = createRoot(document.getElementById('root')!)
root.render(<App />)
import.meta.hot?.dispose(() => root.unmount())