From cde9a1787ca3ee8fb5cf82a007f13d9a8e1b4136 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Mon, 19 Oct 2026 13:04:27 -0500 Subject: [PATCH] feat(hotkeys): Show scopes and app hotkeys on the landing page - Bump @tanstack/react-hotkeys to ^0.15.1 for scopes. - Replace the platform labels section with a live hotkey scopes demo. - Move the platform and layout toggles into the recorder demo, which now uses app hotkeys and has a ? cheat sheet. - Rework the keydown listener cards and show the patched listener beside them. - Describe what Hotkeys is in the hero, and update the library tagline. --- package.json | 2 +- pnpm-lock.yaml | 18 +- src/components/landing/HotkeysLanding.tsx | 193 ++++-- .../landing/HotkeysLandingDemos.client.tsx | 612 ++++++++++++++---- src/libraries/hotkeys.tsx | 2 +- src/libraries/libraries.ts | 4 +- 6 files changed, 629 insertions(+), 202 deletions(-) diff --git a/package.json b/package.json index 93a281ce0..543053148 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@tanstack/markdown": "^1.0.0", "@tanstack/pacer": "^0.21.1", "@tanstack/react-db": "0.4.1", - "@tanstack/react-hotkeys": "^0.14.0", + "@tanstack/react-hotkeys": "^0.15.1", "@tanstack/react-pacer": "^0.22.1", "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "1.170.41", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be1b01d31..9e285ed8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,8 +182,8 @@ importers: specifier: 0.4.1 version: 0.4.1(react@19.2.3)(typescript@6.0.2) '@tanstack/react-hotkeys': - specifier: ^0.14.0 - version: 0.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^0.15.1 + version: 0.15.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-pacer': specifier: ^0.22.1 version: 0.22.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -4066,8 +4066,8 @@ packages: resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} engines: {node: '>=20.19'} - '@tanstack/hotkeys@0.12.0': - resolution: {integrity: sha512-DvVl1VMEFQbhOJPBlIouieJPq6Kq+ZuDOe/FC7c1rkEq5d7ZSnn32yDL6L7hlp5n4Z1Cj4F6cEcbcB7lIUC7KQ==} + '@tanstack/hotkeys@0.16.1': + resolution: {integrity: sha512-GqPOsL/WK2TrqCBnCCYK6RlsGCypylyXVY9v48+UIboynrSCHHKbrBfAuMR9T7cQry+N/uiAl0hxL1Cb9ggj2w==} engines: {node: '>=20'} '@tanstack/markdown@0.0.13': @@ -4130,8 +4130,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-hotkeys@0.14.0': - resolution: {integrity: sha512-LrjU1wCT6VFaoZd99KuisrhN0OOqoM512BFE2UNRHQCtGU6cYNufDKadYypCOiSq+pqvyLIxWEIpHykYgjePlQ==} + '@tanstack/react-hotkeys@0.15.1': + resolution: {integrity: sha512-OJQOwJYUx4hOHcECoBMBi51ha2jM2aal58HXg/wdiKeuxkbLbAd84fpI51h9e7Q0efAaoTGSfc3g4saXAIDDvw==} engines: {node: '>=20'} peerDependencies: react: '>=16.8' @@ -11738,7 +11738,7 @@ snapshots: '@tanstack/history@1.162.4': {} - '@tanstack/hotkeys@0.12.0': + '@tanstack/hotkeys@0.16.1': dependencies: '@tanstack/store': 0.11.2 @@ -11808,9 +11808,9 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-hotkeys@0.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-hotkeys@0.15.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/hotkeys': 0.12.0 + '@tanstack/hotkeys': 0.16.1 '@tanstack/react-store': 0.11.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) diff --git a/src/components/landing/HotkeysLanding.tsx b/src/components/landing/HotkeysLanding.tsx index b80b77f88..b2010b0b1 100644 --- a/src/components/landing/HotkeysLanding.tsx +++ b/src/components/landing/HotkeysLanding.tsx @@ -17,8 +17,8 @@ const loadDemos = createIsomorphicFn() const ShortcutLab = React.lazy(() => loadDemos().then((m) => ({ default: m.ShortcutLab })), ) -const FormatLab = React.lazy(() => - loadDemos().then((m) => ({ default: m.FormatLab })), +const ScopesLab = React.lazy(() => + loadDemos().then((m) => ({ default: m.ScopesLab })), ) const DefaultsLab = React.lazy(() => loadDemos().then((m) => ({ default: m.DefaultsLab })), @@ -28,50 +28,81 @@ const RebindLab = React.lazy(() => ) const hotkeysPrompt = - 'Add keyboard shortcuts with TanStack Hotkeys. Register commands with useHotkey using Mod bindings so they work on macOS, Windows, and Linux, and use physical [Code] bindings only where key position matters. Pass an array of keys such as G G to useHotkey for a sequence. Show platform-correct labels with formatForDisplay and reveal hints with useHotkeyHint while modifiers are held. Declare app hotkeys with defineAppHotkeys and pass them to HotkeysProvider as appHotkeys, with customHotkeys and onCustomHotkeysChange to persist user rebinding. Use useHotkeyRecorder with detectConflicts and onReject to record new bindings.' + 'Add keyboard shortcuts with TanStack Hotkeys. Register commands with useHotkey using Mod bindings so they work on macOS, Windows, and Linux, and use physical [Code] bindings only where key position matters. Pass an array of keys such as G G to useHotkey for a sequence. Wrap dialogs and other overlays in a HotkeysProvider with a scope and exclusive so their hotkeys suspend the rest of the page while they are open. Show platform-correct labels with formatForDisplay and reveal hints with useHotkeyHint while modifiers are held. Declare app hotkeys with defineAppHotkeys, list them in a cheat sheet with useAppHotkeyList, and pass them to HotkeysProvider as appHotkeys, with customHotkeys and onCustomHotkeysChange to persist user rebinding. Use useHotkeyRecorder with detectConflicts and onReject to record new bindings.' -const edgeCases = [ - { - title: 'Text fields', - tag: 'j in a textarea', - body: "Pressing J in a comment box types a J. It doesn't jump to the next item.", - }, +// A hand-written save shortcut after a few bug reports. Each card points at +// the line that patches it, and most of those patches have holes. +const listenerCode = [ + "document.addEventListener('keydown', (e) => {", + ' const el = document.activeElement', + " if (el?.closest('input, textarea')) return", + " if (el?.closest('button')) return", + ' if (dialogOpen) return', + ' if (e.isComposing) return', + ' const mod = isMac ? e.metaKey : e.ctrlKey', + " if (mod && (e.key === 's' || e.code === 'KeyS')) {", + ' e.preventDefault()', + ' save()', + ' }', + ' // TODO: update the cheat sheet', + '})', +] + +const edgeCases: Array<{ + tag: string + title: string + body: string + lines: Array +}> = [ { - title: 'Browser defaults', tag: '⌘S', + title: 'Browser shortcuts', body: '⌘S saves your doc, not the web page.', + lines: [8], }, { - title: 'macOS, Windows, and Linux', tag: 'Mod+S', + title: 'Mac and Windows modifiers', body: 'Write Mod once. Mac users get ⌘, everyone else gets Ctrl.', + lines: [6], }, { - title: 'Sequences', - tag: 'G then I', - body: "G then I for the inbox. The Konami code, if you're feeling it.", + tag: 'J in a textarea', + title: 'Typing in a field', + body: 'Letter shortcuts wait while someone types. ⌘S and Esc still work from the field.', + lines: [2], }, { - title: 'Conflicts', - tag: '⌘K twice', - body: 'Two features grab the same key? You decide who wins.', + tag: 'Esc', + title: 'Dialogs over the page', + body: 'Put the dialog in a scope, and Esc closes it, not the page behind it too.', + lines: [4], }, { - title: 'Cleanup', - tag: 'unmount', - body: 'Shortcuts go away with the component that added them.', + tag: 'Enter on a button', + title: 'Focused buttons and menus', + body: 'Buttons keep Enter and Space, and menus keep their arrow keys.', + lines: [3], }, { - title: 'Keyboard layouts', - tag: '⌥S → "ß"', - body: 'German, French, Russian, Dvorak. Your shortcuts still work.', + tag: 'Dvorak · AZERTY · Русский', + title: 'Other keyboard layouts', + body: 'Shortcuts match the letter people press, and still work on non-Latin layouts.', + lines: [7], }, { - title: 'Input methods', - tag: 'IME', - body: "People typing Japanese or Chinese won't trigger shortcuts by accident.", + tag: 'AltGr+Q → @', + title: 'AltGr and input methods', + body: "Typing @ on a German keyboard won't fire Ctrl+Alt+Q, and composing Japanese won't fire letter shortcuts.", + lines: [5, 6], }, -] as const + { + tag: '?', + title: 'Out-of-date cheat sheets', + body: 'Declare shortcuts once, and the cheat sheet and settings page read the same list.', + lines: [11], + }, +] // Real completions from the Hotkey type for 'Mod+Alt+K. const completions = [ @@ -86,7 +117,7 @@ const completions = [ export default function HotkeysLanding() { return ( @@ -99,27 +130,10 @@ export default function HotkeysLanding() { > -
    - {edgeCases.map((item) => ( -
  • - - {item.tag} - -

    - {item.title} -

    -

    - {item.body} -

    -
  • - ))} -
+
@@ -133,13 +147,13 @@ export default function HotkeysLanding() { -
- - - + +
+ +
@@ -160,11 +174,11 @@ export default function HotkeysLanding() {
- +
@@ -199,6 +213,73 @@ function SectionHeader({ ) } +function KeydownMisses() { + const [active, setActive] = React.useState(null) + const litLines = active === null ? [] : edgeCases[active].lines + + return ( +
+
+ +
+            
+              {listenerCode.map((line, index) => (
+                
+                  {line}
+                
+              ))}
+            
+          
+
+ + The same shortcut with Hotkeys + + + useHotkey('Mod+S', save) + +
+
+

+ Hover a card to find its patch. +

+
+
    + {edgeCases.map((item, index) => ( +
  • setActive(index)} + onMouseLeave={() => setActive(null)} + > + + {item.tag} + +

    + {item.title} +

    +

    + {item.body} +

    +
  • + ))} +
+
+ ) +} + function LiveDemo({ children, fallbackHeight, diff --git a/src/components/landing/HotkeysLandingDemos.client.tsx b/src/components/landing/HotkeysLandingDemos.client.tsx index 69506e154..7ec489ad7 100644 --- a/src/components/landing/HotkeysLandingDemos.client.tsx +++ b/src/components/landing/HotkeysLandingDemos.client.tsx @@ -1,13 +1,22 @@ import * as React from 'react' import { + HotkeysProvider, + defineAppHotkeys, detectPlatform, formatForDisplay, + useAppHotkeyList, + useCustomHotkeys, useHeldKeys, useHotkey, useHotkeyHint, useHotkeyRecorder, + useHotkeys, +} from '@tanstack/react-hotkeys' +import type { + Hotkey, + HotkeyChord, + RecorderRejection, } from '@tanstack/react-hotkeys' -import type { HotkeyChord, RecorderRejection } from '@tanstack/react-hotkeys' import { KeyCombo, Keycap } from './HotkeysKeycap' import { LandingWindow } from './LibraryLanding' @@ -216,72 +225,282 @@ export function ShortcutLab() { ) } -const formatRows = [ - 'Mod+S', - 'Mod+Alt+Shift+S', - 'Control+Meta+K', - 'Alt+[KeyQ]', - '[NumpadEnter]', -] as const satisfies ReadonlyArray +const scopeIssues = [ + { id: 101, title: 'Ship Hotkeys 1.0' }, + { id: 102, title: 'Record the launch video' }, + { id: 103, title: 'Update the docs' }, + { id: 104, title: 'Write the blog post' }, +] -// The layout labels a French AZERTY keyboard prints on these physical keys. -const azertyLayout = new Map([ - ['KeyQ', 'a'], - ['KeyA', 'q'], - ['KeyW', 'z'], - ['KeyZ', 'w'], -]) +type ScopeAction = 'next' | 'edit' | 'saveBoard' | 'saveIssue' | 'close' -export function FormatLab() { - const [platform, setPlatform] = React.useState(detectPlatform) - const [layout, setLayout] = React.useState<'qwerty' | 'azerty'>('qwerty') +// A line lights up when its hotkey runs. Lines with a boardKey are the +// board's hotkeys, which the dialog's exclusive scope suspends. +const scopeCode: Array<{ + text: string + action?: ScopeAction + boardKey?: string + provider?: boolean +}> = [ + { text: 'function Board() {' }, + { text: " useHotkey('J', nextIssue)", action: 'next', boardKey: 'J' }, + { text: " useHotkey('E', editIssue)", action: 'edit', boardKey: 'E' }, + { + text: " useHotkey('Mod+S', saveBoard)", + action: 'saveBoard', + boardKey: 'S', + }, + { text: ' return editing && (' }, + { text: ' ', provider: true }, + { text: ' ', provider: true }, + { text: ' ', provider: true }, + { text: ' )' }, + { text: '}' }, + { text: '' }, + { text: 'function EditDialog() {' }, + { text: " useHotkey('Mod+S', saveIssue)", action: 'saveIssue' }, + { text: " useHotkey('Escape', close)", action: 'close' }, + { text: ' return ' }, + { text: '}' }, +] + +export function ScopesLab() { + const [platform] = React.useState(detectPlatform) + const boardRef = React.useRef(null) + const [selected, setSelected] = React.useState(1) + const [editing, setEditing] = React.useState(false) + const [fired, setFired] = React.useState<{ + action: ScopeAction + count: number + }>() + const heldKeys = useHeldKeys() + + const fire = (action: ScopeAction) => + setFired((previous) => ({ action, count: (previous?.count ?? 0) + 1 })) + + React.useEffect(() => { + if (!fired) return + const timeout = window.setTimeout(() => setFired(undefined), 900) + return () => window.clearTimeout(timeout) + }, [fired]) + + // Bound to the board, so they don't also drive the launch board up top. + useHotkey( + 'J', + () => { + setSelected((index) => (index + 1) % scopeIssues.length) + fire('next') + }, + { target: boardRef, meta: { name: 'Next issue' } }, + ) + useHotkey( + 'E', + () => { + setEditing(true) + fire('edit') + }, + { target: boardRef, meta: { name: 'Edit issue' } }, + ) + useHotkey('Mod+S', () => fire('saveBoard'), { + target: boardRef, + meta: { name: 'Save board' }, + }) + + const saveKeys = formatForDisplay('Mod+S', { platform, parts: true }) + const legend: Array<{ + action: ScopeAction + name: string + keys: ReadonlyArray + }> = [ + { action: 'next', name: 'Next issue', keys: ['J'] }, + { action: 'edit', name: 'Edit issue', keys: ['E'] }, + { action: 'saveBoard', name: 'Save board', keys: saveKeys }, + ] return ( - -
- - + +
+
+          
+            {scopeCode.map((line, index) => {
+              const isLit =
+                (line.action !== undefined && line.action === fired?.action) ||
+                (line.provider && editing)
+              const isSuspended = editing && line.boardKey !== undefined
+              const isHeld = heldKeys.includes(line.boardKey ?? '')
+              return (
+                
+                  {line.text}
+                  {isSuspended ? (
+                    
+                      Suspended
+                    
+                  ) : null}
+                
+              )
+            })}
+          
+        
+ +
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + setEditing(false) + } + }} + role="application" + // The board takes focus so its hotkeys stay out of the demo up top. + // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex + tabIndex={0} + > +
+ + Launch board + + {fired?.action === 'saveBoard' ? ( + + Board saved + + ) : ( + + Click here, then press J or E + + )} +
+
    + {scopeIssues.map((issue, index) => ( +
  • + #{issue.id} + {issue.title} +
  • + ))} +
+
+ {legend.map((row) => ( + + + + {row.name} + + + ))} +
+ + {editing ? ( + + setEditing(false)} + onFire={fire} + saved={fired?.action === 'saveIssue'} + saveKeys={saveKeys} + /> + + ) : null} +
-
    - {formatRows.map((hotkey) => ( -
  • - - '{hotkey}' - - -
  • - ))} -
) } +function ScopeDialog({ + boardRef, + issue, + onClose, + onFire, + saved, + saveKeys, +}: { + boardRef: React.RefObject + issue: (typeof scopeIssues)[number] + onClose: () => void + onFire: (action: ScopeAction) => void + saved: boolean + saveKeys: ReadonlyArray +}) { + useHotkey('Mod+S', () => onFire('saveIssue'), { + target: boardRef, + meta: { name: 'Save issue' }, + }) + useHotkey( + 'Escape', + () => { + onClose() + onFire('close') + }, + { target: boardRef, meta: { name: 'Close the dialog' } }, + ) + + return ( +
+
+
+ + Edit #{issue.id} + + {saved ? ( + + Issue saved + + ) : null} +
+

+ Title + {issue.title} +

+
+ + + + Save issue + + + + + Close + +
+
+
+ ) +} + function Segmented({ label, onChange, @@ -362,27 +581,65 @@ function HintButton({ ) } +// The labels a French AZERTY keyboard prints on these physical keys. +// Recorded shortcuts are physical too, so they relabel with the layout. +const azertyLayout = new Map([ + ['Backquote', '²'], + ['KeyQ', 'a'], + ['KeyA', 'q'], + ['KeyW', 'z'], + ['KeyZ', 'w'], +]) + +// Declared once. The settings rows and the cheat sheet both read this list. +const settingsHotkeys = defineAppHotkeys({ + saveAs: { hotkey: 'Alt+S', meta: { name: 'Save as' } }, + terminal: { + hotkey: 'Control+[Backquote]', + meta: { name: 'Toggle terminal' }, + }, + deleteLine: { hotkey: ['D', 'D'], meta: { name: 'Delete line' } }, + quickOpen: { hotkey: 'Mod+P', meta: { name: 'Quick open' } }, + cheatSheet: { hotkey: '?', meta: { name: 'Cheat sheet' } }, +}) + export function RebindLab() { + return ( + + + + ) +} + +function ShortcutSettings() { + // Recording uses the real platform. The toggles only change the labels. const [platform] = React.useState(detectPlatform) + const [shownPlatform, setShownPlatform] = React.useState(platform) + const [layout, setLayout] = React.useState<'qwerty' | 'azerty'>('qwerty') const cardRef = React.useRef(null) - const [quickOpen, setQuickOpen] = React.useState('Mod+P') + const [sheetOpen, setSheetOpen] = React.useState(false) const [message, setMessage] = React.useState<{ tone: 'bad' | 'good' text: string } | null>(null) const [fired, setFired] = React.useState(null) + const appHotkeys = useAppHotkeyList() + const { setCustomHotkey } = useCustomHotkeys() - useHotkey('Alt+S', () => setFired('Save as'), { - target: cardRef, - meta: { name: 'Save as' }, - }) - useHotkey(['D', 'D'], () => setFired('Delete line'), { - target: cardRef, - meta: { name: 'Delete line' }, - }) - useHotkey(quickOpen, () => setFired('Quick open'), { + useHotkeys( + Object.values(settingsHotkeys).map((appHotkey) => ({ + hotkey: appHotkey, + callback: () => + appHotkey.id === 'cheatSheet' + ? setSheetOpen((open) => !open) + : setFired(appHotkey.id), + options: { target: cardRef }, + })), + ) + useHotkey('Escape', () => setSheetOpen(false), { target: cardRef, - meta: { name: 'Quick open' }, + enabled: sheetOpen, + meta: { name: 'Close the cheat sheet' }, }) const recorder = useHotkeyRecorder({ @@ -392,11 +649,21 @@ export function RebindLab() { registration.options.meta?.name === 'Quick open', }, onRecord: (hotkey) => { - setQuickOpen(hotkey) - setMessage({ tone: 'good', text: `Saved as '${hotkey}'` }) + const conflict = setCustomHotkey('quickOpen', hotkey)[0] + setMessage( + conflict + ? { + tone: 'bad', + text: `That shortcut is already used by "${conflict.meta?.name}".`, + } + : { tone: 'good', text: `Saved as '${hotkey}'` }, + ) }, onReject: (rejection) => - setMessage({ tone: 'bad', text: describeRejection(rejection, platform) }), + setMessage({ + tone: 'bad', + text: describeRejection(rejection, shownPlatform), + }), onCancel: () => setMessage(null), }) @@ -406,80 +673,159 @@ export function RebindLab() { return () => window.clearTimeout(timeout) }, [fired]) - const rows = [ - { - name: 'Save as', - keys: formatForDisplay('Alt+S', { platform, parts: true }), - }, - { name: 'Delete line', keys: ['D', 'D'] }, - ] + const display = (hotkey: Hotkey | null) => { + if (hotkey === null) return [] + const options = { + platform: shownPlatform, + parts: true, + layoutMap: layout === 'azerty' ? azertyLayout : undefined, + } as const + return typeof hotkey === 'string' + ? formatForDisplay(hotkey, options) + : formatForDisplay(hotkey, options).flat() + } return ( +
+ + +
    - {rows.map((row) => ( -
  • - - {row.name} - - -
  • - ))} -
  • appHotkey.id !== 'cheatSheet') + .map(({ appHotkey }) => { + const isQuickOpen = appHotkey.id === 'quickOpen' + const isLit = + fired === appHotkey.id || (isQuickOpen && recorder.isRecording) + return ( +
  • + + {appHotkey.meta?.name} + + {isQuickOpen ? ( + + ) : ( + + )} +
  • + ) + })} +
+
+

- - Quick open - - +

+ + {sheetOpen ? ( +
+
- {recorder.isRecording ? ( - - Press a shortcut… +
+ + Cheat sheet - ) : ( - - )} - Change the Quick open shortcut - - - -

- {message?.text} -

+ + Esc to close + +
+
    + {appHotkeys.map(({ appHotkey, isCustom }) => ( +
  • + + {appHotkey.meta?.name} + {isCustom ? ( + + custom + + ) : null} + + +
  • + ))} +
+
+
+ ) : null}
) diff --git a/src/libraries/hotkeys.tsx b/src/libraries/hotkeys.tsx index 765d34e79..b79fd4e42 100644 --- a/src/libraries/hotkeys.tsx +++ b/src/libraries/hotkeys.tsx @@ -10,7 +10,7 @@ const textStyles = 'text-category-performance' export const hotkeysProject = { ...hotkeys, - description: `A type-safe, cross-platform hotkey library with sequence detection, key state tracking, hotkey recording, and framework adapters for React and more.`, + description: `A type-safe, cross-platform hotkey library with scopes, sequences, user rebinding, platform-correct labels, and devtools, with adapters for React, Vue, Solid, Svelte, Angular, and more.`, latestBranch: 'main', defaultDocs: 'overview', featureHighlights: [ diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index ede262694..fa706834d 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -589,9 +589,9 @@ export const hotkeys: LibrarySlim = { name: 'TanStack Hotkeys', to: '/hotkeys/latest', tagline: - 'Type-safe keyboard shortcuts, sequences, and key state tracking for your apps', + 'Type-safe keyboard shortcuts with scopes, sequences, and user rebinding', description: - 'A type-safe, cross-platform hotkey library with sequence detection, key state tracking, hotkey recording, and framework adapters for React and more.', + 'A type-safe, cross-platform hotkey library with scopes, sequences, user rebinding, platform-correct labels, and devtools, with adapters for React, Vue, Solid, Svelte, Angular, and more.', badge: 'beta', repo: 'tanstack/hotkeys', frameworks: [