Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

193 changes: 137 additions & 56 deletions src/components/landing/HotkeysLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand All @@ -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<number>
}> = [
{
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 = [
Expand All @@ -86,7 +117,7 @@ const completions = [
export default function HotkeysLanding() {
return (
<LibraryLandingShell
description="Type-safe keyboard shortcuts for any framework. They stay out of the way while people type, show the right keys on every platform, and let users pick their own."
description="A type-safe library for keyboard shortcuts in web apps. One framework-agnostic core, plus adapters for React, Vue, Solid, Svelte, Angular, and more."
headline="Great defaults for everyone. Superpowers for your power users."
hero={
<LiveDemo fallbackHeight="min-h-[27rem]" label="launch board">
Expand All @@ -99,27 +130,10 @@ export default function HotkeysLanding() {
>
<LandingSection tone="raised">
<SectionHeader
body="A keydown listener takes five minutes. Then someone types in a text box, or opens your app on Windows, or uses a German keyboard."
body="A keydown listener takes five minutes. Then someone types in a text box, opens your app on Windows, or uses a German keyboard, and the patches pile up."
title="What a keydown listener misses"
/>
<ul className="mt-12 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{edgeCases.map((item) => (
<li
key={item.title}
className="rounded-xl border border-border-subtle bg-background-surface p-5"
>
<code className="inline-block rounded-md bg-[rgb(var(--landing-glow)/0.12)] px-2 py-1 font-ds-mono text-ds-mono-xs text-(--landing-accent-bright)">
{item.tag}
</code>
<h3 className="mt-5 text-ds-heading-5 text-text-primary">
{item.title}
</h3>
<p className="mt-2 text-ds-body-sm text-text-primary/60">
{item.body}
</p>
</li>
))}
</ul>
<KeydownMisses />
</LandingSection>

<LandingSection>
Expand All @@ -133,13 +147,13 @@ export default function HotkeysLanding() {
</LandingSection>

<LandingSection tone="accent">
<div className="grid gap-12 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<SectionHeader
body="Mac users see ⌥⇧⌘S. Windows users see Ctrl+Alt+Shift+S. Same shortcut, and nobody has to translate."
title="Labels for each platform"
/>
<LiveDemo fallbackHeight="min-h-[22rem]" label="formatForDisplay">
<FormatLab />
<SectionHeader
body="Wrap a dialog in an exclusive scope and it owns the keyboard. ⌘S saves the issue, not the board behind it, and the board's other shortcuts wait until it closes. Nothing else has to know the dialog is open."
title="Hotkey scopes"
/>
<div className="mt-12">
<LiveDemo fallbackHeight="min-h-[30rem]" label="hotkey scopes">
<ScopesLab />
</LiveDemo>
</div>
</LandingSection>
Expand All @@ -160,11 +174,11 @@ export default function HotkeysLanding() {
<div className="grid gap-12 lg:grid-cols-[0.8fr_1.2fr] lg:items-center">
<div>
<SectionHeader
body="List your shortcuts once and get a cheat sheet and a settings page out of it. Users press the keys they want, and Hotkeys tells them when something else already has them."
body="List your shortcuts once and get a cheat sheet and a settings page out of it. Users press the keys they want, and Hotkeys tells them when something else already has them. Labels match each platform and keyboard layout, so ⌘P on a Mac is Ctrl+P on Windows."
title="Shortcuts users can change"
/>
</div>
<LiveDemo fallbackHeight="min-h-[20rem]" label="shortcut settings">
<LiveDemo fallbackHeight="min-h-[26rem]" label="shortcut settings">
<RebindLab />
</LiveDemo>
</div>
Expand Down Expand Up @@ -199,6 +213,73 @@ function SectionHeader({
)
}

function KeydownMisses() {
const [active, setActive] = React.useState<number | null>(null)
const litLines = active === null ? [] : edgeCases[active].lines

return (
<div className="mt-12 grid gap-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-start">
<div className="lg:sticky lg:top-24">
<LandingWindow label="keydown.ts">
<pre className="overflow-x-auto py-4 font-ds-mono text-ds-mono-xs">
<code>
{listenerCode.map((line, index) => (
<span
key={index}
className={`block h-7 whitespace-pre px-5 leading-7 transition-colors duration-150 ${
litLines.includes(index)
? 'bg-[rgb(var(--landing-glow)/0.16)] text-(--landing-accent-bright) shadow-[inset_2px_0_0_var(--landing-accent)]'
: active === null
? 'text-text-primary/70'
: 'text-text-primary/35'
}`}
>
{line}
</span>
))}
</code>
</pre>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-border-subtle px-5 py-4">
<span className="font-ds-mono text-ds-mono-caps-xs uppercase text-text-primary/45">
The same shortcut with Hotkeys
</span>
<code className="font-ds-mono text-ds-mono-sm text-(--landing-accent-bright)">
useHotkey('Mod+S', save)
</code>
</div>
</LandingWindow>
<p className="mt-3 hidden text-ds-label-sm text-text-primary/45 lg:block">
Hover a card to find its patch.
</p>
</div>
<ul className="grid gap-4 sm:grid-cols-2">
{edgeCases.map((item, index) => (
<li
key={item.title}
className={`rounded-xl border bg-background-surface p-5 transition-colors duration-150 ${
active === index
? 'border-(--landing-accent)'
: 'border-border-subtle'
}`}
onMouseEnter={() => setActive(index)}
onMouseLeave={() => setActive(null)}
>
<code className="inline-block rounded-md bg-[rgb(var(--landing-glow)/0.12)] px-2 py-1 font-ds-mono text-ds-mono-xs text-(--landing-accent-bright)">
{item.tag}
</code>
<h3 className="mt-5 text-ds-heading-5 text-text-primary">
{item.title}
</h3>
<p className="mt-2 text-ds-body-sm text-text-primary/60">
{item.body}
</p>
</li>
))}
</ul>
</div>
)
}

function LiveDemo({
children,
fallbackHeight,
Expand Down
Loading
Loading