+
)
}
-function ScopeStack() {
- const [activeScope, setActiveScope] = React.useState('modal')
-
+function SectionHeader({
+ body,
+ title,
+}: {
+ body: React.ReactNode
+ title: string
+}) {
return (
-
- {scopes.map((scope, index) => {
- const isActive = scope.name === activeScope
- const offsetClassName =
- index === 0
- ? 'sm:ml-24 sm:w-[calc(100%-6rem)]'
- : index === 1
- ? 'sm:ml-12 sm:w-[calc(100%-3rem)]'
- : ''
- return (
-
- )
- })}
+
+
+ {title}
+
+
+ {body}
+
)
}
-function KeySequence({
- binding,
- centered = false,
- className = '',
+function LiveDemo({
+ children,
+ fallbackHeight,
+ label,
}: {
- binding: string
- centered?: boolean
- className?: string
+ children: React.ReactNode
+ fallbackHeight: string
+ label: string
}) {
- const groups = binding.split(/\s+then\s+/i)
+ const fallback = (
+
+
+
+ )
return (
-
- {groups.map((group, groupIndex) => (
-
- {groupIndex > 0 ? (
-
- then
-
- ) : null}
- {group
- .split(/\s*\+\s*|\s+/)
- .filter(Boolean)
- .map((key, keyIndex) =>
- key === '·' ? (
-
- ·
-
- ) : (
-
- {key}
-
- ),
- )}
-
- ))}
-
+
+ {children}
+
)
}
-function CommandFact({ label, value }: { label: string; value: string }) {
+function TypeSafetyEditor() {
return (
-
-
- {label}
-
-
- {value}
-
-
+
+
+
+ useHotkeys([
+
+
+ {'{ hotkey: '}
+ 'Mod+Alt+K
+
+
+
+ {completions.map(([match, rest], index) => (
+
+ {match}
+ {rest}
+
+ ))}
+
+
+ {'{ hotkey: '}
+
+ 'Mod+Control+S'
+
+ {' },'}
+
+
+ Type error:
+ Type '"Mod+Control+S"' is not assignable to type 'RegisterableHotkey'.
+ Did you mean '"Control+S"'?
+
+
])
+
+
)
}
-
-function normalizeKey(key: string) {
- if (key === ' ') return 'Space'
- if (key === 'Escape') return 'Esc'
- return key.length === 1 ? key.toUpperCase() : key
-}
-
-function formatBinding(binding: string, platform: 'mac' | 'windows') {
- if (platform === 'windows') return binding.replace('Mod', 'Ctrl')
- return binding
- .replace('Mod', '⌘')
- .replace('Shift', '⇧')
- .replace('Alt', '⌥')
- .replaceAll('+', ' ')
-}
diff --git a/src/components/landing/HotkeysLandingDemos.client.tsx b/src/components/landing/HotkeysLandingDemos.client.tsx
new file mode 100644
index 000000000..be7c7315c
--- /dev/null
+++ b/src/components/landing/HotkeysLandingDemos.client.tsx
@@ -0,0 +1,499 @@
+import * as React from 'react'
+import {
+ detectPlatform,
+ formatForDisplay,
+ useHeldKeys,
+ useHotkey,
+ useHotkeyHint,
+ useHotkeyRecorder,
+ useHotkeySequence,
+} from '@tanstack/react-hotkeys'
+import type { Hotkey, RecorderRejection } from '@tanstack/react-hotkeys'
+
+import { KeyCombo, Keycap } from './HotkeysKeycap'
+import { LandingWindow } from './LibraryLanding'
+
+type Platform = ReturnType
+
+const startingItems = [
+ 'Ship Hotkeys 1.0',
+ 'Write the announcement',
+ 'Record the launch video',
+ 'Update the docs',
+ 'Test every keyboard layout',
+]
+
+type BoardItem = { id: number; title: string; done: boolean }
+
+const heldLabels: Record> = {
+ Meta: { mac: '⌘', windows: 'Win', linux: 'Super' },
+ Control: { mac: '⌃', windows: 'Ctrl', linux: 'Ctrl' },
+ Alt: { mac: '⌥', windows: 'Alt', linux: 'Alt' },
+ Shift: { mac: '⇧', windows: 'Shift', linux: 'Shift' },
+}
+
+// These shortcuts are registered on the whole page, so they work without
+// clicking into the demo first.
+export function ShortcutLab() {
+ const [platform] = React.useState(detectPlatform)
+ const [items, setItems] = React.useState>(() =>
+ startingItems.map((title, id) => ({ id, title, done: id === 0 })),
+ )
+ const [selected, setSelected] = React.useState(2)
+ const [note, setNote] = React.useState('')
+ const [fired, setFired] = React.useState<{ name: string; count: number }>()
+ const noteRef = React.useRef(null)
+ const heldKeys = useHeldKeys()
+
+ const fire = (name: string) =>
+ setFired((previous) => ({ name, count: (previous?.count ?? 0) + 1 }))
+
+ React.useEffect(() => {
+ if (!fired) return
+ const timeout = window.setTimeout(() => setFired(undefined), 900)
+ return () => window.clearTimeout(timeout)
+ }, [fired])
+
+ useHotkey(
+ 'J',
+ () => {
+ setSelected((index) => Math.min(index + 1, items.length - 1))
+ fire('Next / previous')
+ },
+ { meta: { name: 'Next item' } },
+ )
+ useHotkey(
+ 'K',
+ () => {
+ setSelected((index) => Math.max(index - 1, 0))
+ fire('Next / previous')
+ },
+ { meta: { name: 'Previous item' } },
+ )
+ useHotkeySequence(
+ ['G', 'G'],
+ () => {
+ setSelected(0)
+ fire('Jump to top')
+ },
+ { meta: { name: 'Go to top' } },
+ )
+ useHotkey(
+ 'X',
+ () => {
+ setItems((current) =>
+ current.map((item, index) =>
+ index === selected ? { ...item, done: !item.done } : item,
+ ),
+ )
+ fire('Mark done')
+ },
+ { meta: { name: 'Mark done' } },
+ )
+ useHotkey('Mod+S', () => fire('Save'), { meta: { name: 'Save' } })
+ useHotkey(
+ 'Mod+Enter',
+ () => {
+ const title = note.trim()
+ if (!title) return
+ setItems((current) => [
+ { id: current.length + 100, title, done: false },
+ ...current,
+ ])
+ setSelected(0)
+ setNote('')
+ fire('Add note')
+ },
+ { target: noteRef, meta: { name: 'Add note' } },
+ )
+ useHotkey(
+ 'Escape',
+ () => {
+ noteRef.current?.blur()
+ fire('Leave the note')
+ },
+ { target: noteRef, meta: { name: 'Leave the note' } },
+ )
+
+ const legend = [
+ { name: 'Next / previous', keys: ['J', 'K'] },
+ { name: 'Jump to top', keys: ['G', 'G'] },
+ { name: 'Mark done', keys: ['X'] },
+ {
+ name: 'Save',
+ keys: formatForDisplay('Mod+S', { platform, parts: true }),
+ },
+ {
+ name: 'Add note',
+ keys: formatForDisplay('Mod+Enter', { platform, parts: true }),
+ },
+ { name: 'Leave the note', keys: ['Esc'] },
+ ]
+
+ return (
+
+
+
+
+ {fired?.name === 'Save' ? (
+
+ Saved
+
+ ) : null}
+
+
+ {items.slice(0, 6).map((item, index) => (
+ -
+
+ {item.done ? '✓' : null}
+
+
+ {item.title}
+
+
+ ))}
+
+
+
+
+
+ Shortcuts
+
+
+ {legend.map((row) => {
+ const isFired = fired?.name === row.name
+ return (
+ -
+
+ {row.name}
+
+
+
+ )
+ })}
+
+
+
+
+ Held
+ {heldKeys.length === 0 ? (
+ nothing
+ ) : (
+ heldKeys.map((key) => (
+
+ {heldLabels[key]?.[platform] ?? key.toUpperCase()}
+
+ ))
+ )}
+
+
+ )
+}
+
+const formatRows = [
+ 'Mod+S',
+ 'Mod+Alt+Shift+S',
+ 'Control+Meta+K',
+ 'Alt+[KeyQ]',
+ '[NumpadEnter]',
+] as const satisfies ReadonlyArray
+
+// The layout labels a French AZERTY keyboard prints on these physical keys.
+const azertyLayout = new Map([
+ ['KeyQ', 'a'],
+ ['KeyA', 'q'],
+ ['KeyW', 'z'],
+ ['KeyZ', 'w'],
+])
+
+export function FormatLab() {
+ const [platform, setPlatform] = React.useState(detectPlatform)
+ const [layout, setLayout] = React.useState<'qwerty' | 'azerty'>('qwerty')
+
+ return (
+
+
+
+
+
+
+ {formatRows.map((hotkey) => (
+ -
+
+ '{hotkey}'
+
+
+
+ ))}
+
+
+ )
+}
+
+function Segmented({
+ label,
+ onChange,
+ options,
+ value,
+}: {
+ label: string
+ onChange: (value: TValue) => void
+ options: ReadonlyArray
+ value: TValue
+}) {
+ return (
+
+ {options.map(([optionValue, optionLabel]) => (
+
+ ))}
+
+ )
+}
+
+const toolbar = [
+ { name: 'Save', hotkey: 'Mod+S' },
+ { name: 'Save as', hotkey: 'Alt+S' },
+ { name: 'Duplicate', hotkey: 'Alt+D' },
+] as const satisfies ReadonlyArray<{ name: string; hotkey: Hotkey }>
+
+export function DefaultsLab() {
+ const [platform] = React.useState(detectPlatform)
+
+ return (
+
+
+
+ {toolbar.map((tool) => (
+
+ ))}
+
+
+ const showHint = useHotkeyHint(
+ 'Alt+S')
+
+
+
+ )
+}
+
+function HintButton({
+ hotkey,
+ name,
+ platform,
+}: {
+ hotkey: Hotkey
+ name: string
+ platform: Platform
+}) {
+ const isVisible = useHotkeyHint(hotkey, { platform })
+ return (
+
+ {name}
+ {isVisible ? (
+
+ {formatForDisplay(hotkey, { platform })}
+
+ ) : null}
+
+ )
+}
+
+export function RebindLab() {
+ const [platform] = React.useState(detectPlatform)
+ const cardRef = React.useRef(null)
+ const [quickOpen, setQuickOpen] = React.useState('Mod+P')
+ const [message, setMessage] = React.useState<{
+ tone: 'bad' | 'good'
+ text: string
+ } | null>(null)
+ const [fired, setFired] = React.useState(null)
+
+ useHotkey('Alt+S', () => setFired('Save as'), {
+ target: cardRef,
+ meta: { name: 'Save as' },
+ })
+ useHotkeySequence(['D', 'D'], () => setFired('Delete line'), {
+ target: cardRef,
+ meta: { name: 'Delete line' },
+ })
+ useHotkey(quickOpen, () => setFired('Quick open'), {
+ target: cardRef,
+ meta: { name: 'Quick open' },
+ })
+
+ const recorder = useHotkeyRecorder({
+ platform,
+ detectConflicts: {
+ exclude: (registration) =>
+ registration.options.meta?.name === 'Quick open',
+ },
+ onRecord: (hotkey) => {
+ setQuickOpen(hotkey)
+ setMessage({ tone: 'good', text: `Saved as '${hotkey}'` })
+ },
+ onReject: (rejection) =>
+ setMessage({ tone: 'bad', text: describeRejection(rejection, platform) }),
+ onCancel: () => setMessage(null),
+ })
+
+ React.useEffect(() => {
+ if (!fired) return
+ const timeout = window.setTimeout(() => setFired(null), 700)
+ return () => window.clearTimeout(timeout)
+ }, [fired])
+
+ const rows = [
+ {
+ name: 'Save as',
+ keys: formatForDisplay('Alt+S', { platform, parts: true }),
+ },
+ { name: 'Delete line', keys: ['D', 'D'] },
+ ]
+
+ return (
+
+
+
+ {rows.map((row) => (
+ -
+
+ {row.name}
+
+
+
+ ))}
+ -
+
+ Quick open
+
+
+
+
+
+ {message?.text}
+
+
+
+ )
+}
+
+function describeRejection(rejection: RecorderRejection, platform: Platform) {
+ const conflict = rejection.conflicts?.[0]
+ if (!conflict) return rejection.message
+ const name = conflict.registration.options.meta?.name ?? 'another shortcut'
+ const pressed = rejection.hotkey
+ ? formatForDisplay(rejection.hotkey, { platform })
+ : 'That shortcut'
+ return conflict.type === 'sequence'
+ ? `${pressed} starts the "${name}" sequence.`
+ : `${pressed} is already used by "${name}".`
+}