HotkeyEditor
List, search, and remap the shortcuts in a hotkey registry, with grouped bindings, reset controls, and conflict messages.
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import {
HotkeyEditor,
HotkeyEditorItem,
HotkeyEditorKeys,
HotkeyEditorChange,
HotkeyEditorEdit,
HotkeyEditorReset,
HotkeyEditorCapture,
HotkeyEditorInput,
HotkeyEditorProblem,
HotkeyEditorConflicts,
useHotkeyEditorItem,
} from "@/components/ui/hotkey-editor"
const BINDINGS: HotkeyBinding[] = [
{ id: "palette.open", keys: "mod+k", scope: "editing", description: "Open the command palette" },
]
function Shortcut() {
const { entry } = useHotkeyEditorItem()
return (
<>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1">{entry.description}</span>
{entry.remapped && <span className="text-muted-foreground">changed</span>}
<HotkeyEditorKeys />
</div>
<div className="flex flex-wrap gap-1">
<HotkeyEditorChange>Change</HotkeyEditorChange>
<HotkeyEditorEdit>Type it</HotkeyEditorEdit>
{entry.remapped && <HotkeyEditorReset>Reset</HotkeyEditorReset>}
</div>
<HotkeyEditorCapture />
<HotkeyEditorInput />
<HotkeyEditorProblem />
<HotkeyEditorConflicts />
</>
)
}
export default function HotkeyEditorDemo() {
return (
<HotkeysProvider bindings={BINDINGS}>
<HotkeyEditor className="w-sm max-w-full">
<HotkeyEditorItem bindingId="palette.open">
<Shortcut />
</HotkeyEditorItem>
</HotkeyEditor>
</HotkeysProvider>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/hotkey-editor#v2.0.0
npx shadcn@latest add tradecn/ui/hotkey-editor#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/hotkey-editor#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/hotkey-editor#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add button input kbdnpx shadcn@latest add button input kbdyarn dlx shadcn@latest add button input kbdbunx --bun shadcn@latest add button input kbd -
Copy the files into your project:
components/ui/ hotkey-editor.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useId, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, type Ref } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useHotkeyList, useHotkeys } from "@/hooks/use-hotkeys" import { formatKeys, keysFromEvent, type HotkeyConflict, type HotkeyEntry, type HotkeyRegistry } from "@/lib/hotkeys" export interface HotkeyEditorLabels { title: string search: string change: string pressKeys: string edit: string unbound: string reset: string keysFor: string notKeys: string duplicate: string prefix: string shadow: string cancelHint: string } export const DEFAULT_HOTKEY_EDITOR_LABELS: HotkeyEditorLabels = { title: "Keyboard shortcuts", search: "Find a shortcut", change: "Change", pressKeys: "Press the new shortcut", edit: "Type it", unbound: "unbound", reset: "Reset", keysFor: "Keys for", notKeys: "Not a shortcut. Write keys joined by +, steps of a chord separated by a space: mod+k, g b.", duplicate: "same keys as", prefix: "starts the chord of", shadow: "hides, while focus is in this panel,", cancelHint: "Escape cancels, Tab moves on, Backspace unbinds", } export interface HotkeyEditorProps extends ComponentProps<"div"> { children: ReactNode hide?: (entry: HotkeyEntry) => boolean labels?: Partial<HotkeyEditorLabels> } export interface HotkeyEditorGroup { name: string entries: readonly HotkeyEntry[] } export interface HotkeyEditorState { registry: HotkeyRegistry entries: readonly HotkeyEntry[] groups: readonly HotkeyEditorGroup[] conflicts: readonly HotkeyConflict[] remapped: number query: string setQuery: (query: string) => void labels: HotkeyEditorLabels } const EditorContext = createContext<HotkeyEditorState | null>(null) const FocusContext = createContext<(() => void) | null>(null) export function useHotkeyEditor(): HotkeyEditorState { const value = useContext(EditorContext) if (!value) throw new Error("Hotkey editor parts must be inside HotkeyEditor") return value } function assignRef<T>(ref: Ref<T> | undefined, value: T | null) { if (typeof ref === "function") return ref(value) if (ref) ref.current = value } function useEditorRef<T>(localRef: { current: T | null }, forwarded: Ref<T> | undefined) { return useCallback((node: T | null) => { localRef.current = node const cleanup = assignRef(forwarded, node) return () => { localRef.current = null if (typeof cleanup === "function") cleanup() else assignRef(forwarded, null) } }, [localRef, forwarded]) } /** The word for a scope on a row: "global", "editing", or a panel's kind. */ export function scopeWord(scope: string): string { return scope.startsWith("panel:") ? scope.slice("panel:".length) : scope } /** The group a binding is listed under: its own, else its scope. */ export function groupOf(entry: HotkeyEntry): string { return entry.group ?? scopeWord(entry.scope) } /** True when the query lands in the description, the group, the id, or the keys as shown. */ export function matchesQuery(entry: HotkeyEntry, query: string, platform: "mac" | "other"): boolean { const q = query.trim().toLowerCase() if (!q) return true const caps = entry.keys ? formatKeys(entry.keys, platform).flat().join(" ").toLowerCase() : "" return [entry.description, groupOf(entry), entry.id, entry.keys, caps].some((s) => s.toLowerCase().includes(q)) } export function HotkeyEditor({ hide, labels: labelsProp, className, children, ref, ...props }: HotkeyEditorProps) { const registry = useHotkeys() const entries = useHotkeyList() const [query, setQuery] = useState("") const root = useRef<HTMLDivElement>(null) const rootRef = useEditorRef(root, ref) const focusFallback = useCallback(() => { const node = root.current if (!node?.isConnected) return const search = node.querySelector<HTMLInputElement>("[data-hotkey-search]:not(:disabled)") const target = search ?? node target.focus({ preventScroll: true }) }, []) // The list's identity changes exactly when the registry emits, and the conflicts change with it. const conflicts = useMemo(() => { void entries return registry.conflicts() }, [registry, entries]) const groups = useMemo(() => { const shown = entries.filter((entry) => !hide?.(entry) && matchesQuery(entry, query, registry.platform)) const rank = (entry: HotkeyEntry) => entry.group ? 0 : 1 shown.sort((a, b) => rank(a) - rank(b) || groupOf(a).localeCompare(groupOf(b)) || a.description.localeCompare(b.description)) const grouped = new Map<string, HotkeyEntry[]>() for (const entry of shown) { const name = groupOf(entry) const group = grouped.get(name) if (group) group.push(entry) else grouped.set(name, [entry]) } return [...grouped].map(([name, entries]) => ({ name, entries })) }, [entries, hide, query, registry.platform]) const labels = { ...DEFAULT_HOTKEY_EDITOR_LABELS, ...labelsProp } const remapped = entries.filter((entry) => entry.remapped).length return ( <EditorContext value={{ registry, entries, groups, conflicts, remapped, query, setQuery, labels }}> <FocusContext value={focusFallback}> <div role="region" tabIndex={-1} aria-label={props["aria-labelledby"] ? undefined : labels.title} data-slot="tradecn-hotkey-editor" data-remapped={remapped} className={cn("flex min-w-0 flex-col gap-2 text-xs lining-nums tabular-nums", className)} {...props} ref={rootRef}> {children} </div> </FocusContext> </EditorContext> ) } type InputProps = Omit<ComponentProps<typeof Input>, "value" | "defaultValue"> type ActionProps = Omit<ComponentProps<typeof Button>, "children"> & { children: ReactNode } export function HotkeyEditorSearch({ onChange, className, ...props }: InputProps) { const { query, setQuery, labels } = useHotkeyEditor() return <Input aria-label={labels.search} placeholder={labels.search} spellCheck={false} autoComplete="off" data-hotkey-search="" className={cn("h-7 max-w-64 font-(family-name:--tradecn-font-mono) text-xs md:text-xs", className)} {...props} value={query} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setQuery(event.target.value) }} /> } export type HotkeyEditorMode = "idle" | "capture" | "text" export interface HotkeyEditorItemState { entry: HotkeyEntry mode: HotkeyEditorMode draft: string setDraft: (draft: string) => void problem: string | null problemId: string conflicts: readonly HotkeyConflict[] startCapture: (trigger?: HTMLElement) => void startEdit: (trigger?: HTMLElement) => void commit: (keys: string) => void cancel: () => void reset: () => void } const ItemContext = createContext<(HotkeyEditorItemState & { blur: () => void }) | null>(null) function useItemContext() { const value = useContext(ItemContext) if (!value) throw new Error("Shortcut readings and controls must be inside HotkeyEditorItem") return value } export function useHotkeyEditorItem(): HotkeyEditorItemState { return useItemContext() } export interface HotkeyEditorItemProps extends ComponentProps<"div"> { bindingId: string children: ReactNode } export function HotkeyEditorItem({ bindingId, ...props }: HotkeyEditorItemProps) { const { entries } = useHotkeyEditor() const entry = entries.find((entry) => entry.id === bindingId) return entry ? <Item key={bindingId} entry={entry} {...props} /> : null } interface EditSession { registry: HotkeyRegistry entry: HotkeyEntry mode: "capture" | "text" draft: string problem: string | null } function sameBinding(a: HotkeyEntry, b: HotkeyEntry): boolean { return a.id === b.id && a.keys === b.keys && a.defaultKeys === b.defaultKeys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function Item({ entry, className, ref, onFocusCapture, onBlurCapture, ...props }: ComponentProps<"div"> & { entry: HotkeyEntry }) { const { registry, conflicts, labels } = useHotkeyEditor() const focusFallback = useContext(FocusContext)! const root = useRef<HTMLDivElement>(null) const rootRef = useEditorRef(root, ref) const origin = useRef<HTMLElement | null>(null) const restoreFocus = useRef(true) const lastFocused = useRef<HTMLElement | null>(null) const previousMode = useRef<HotkeyEditorMode>("idle") const [session, setSession] = useState<EditSession | null>(null) const current = session?.registry === registry && sameBinding(session.entry, entry) ? session : null if (session && !current) setSession(null) const mode = current?.mode ?? "idle" const problemId = useId() useLayoutEffect(() => { const node = root.current if (!node) return const active = node.ownerDocument.activeElement if (mode !== "idle" && mode !== previousMode.current) { node.querySelector<HTMLElement>(mode === "capture" ? "[data-hotkey-capture]" : "[data-hotkey-input]")?.focus() } else if (mode === "idle" && previousMode.current !== "idle" && restoreFocus.current && (active === node.ownerDocument.body || node.contains(active))) { const target = origin.current if (target?.isConnected && !target.matches(":disabled, [aria-disabled=true]")) target.focus({ preventScroll: true }) else node.focus({ preventScroll: true }) } const focused = lastFocused.current if (focused && (!focused.isConnected || focused.matches(":disabled")) && (node.ownerDocument.activeElement === node.ownerDocument.body || node.ownerDocument.activeElement === focused)) node.focus({ preventScroll: true }) previousMode.current = mode }) useLayoutEffect(() => { const node = root.current return () => { if (node?.contains(node.ownerDocument.activeElement)) focusFallback() } }, [focusFallback]) const start = (mode: "capture" | "text", trigger?: HTMLElement) => { if (current) return origin.current = trigger ?? root.current restoreFocus.current = true setSession({ registry, entry, mode, draft: entry.keys, problem: null }) } const cancel = () => setSession(null) const state: HotkeyEditorItemState = { entry, mode, problemId, draft: current?.draft ?? entry.keys, problem: current?.problem ?? null, conflicts: conflicts.filter((conflict) => conflict.ids.includes(entry.id)), setDraft: (draft) => { if (current) setSession({ ...current, draft }) }, startCapture: (trigger) => start("capture", trigger), startEdit: (trigger) => start("text", trigger), cancel, commit: (keys) => { if (!current) return try { registry.remap(entry.id, keys) cancel() } catch { setSession({ ...current, problem: labels.notKeys }) } }, reset: () => { registry.reset(entry.id); cancel() }, } const blur = () => { // Blur can commit while activeElement is still body, before its destination receives focus. restoreFocus.current = false lastFocused.current = null cancel() } return <ItemContext value={{ ...state, blur }}><div role="group" tabIndex={-1} aria-label={props["aria-labelledby"] ? undefined : entry.description} data-slot="tradecn-hotkey-editor-item" data-hotkey-row={entry.id} data-remapped={entry.remapped || undefined} data-mode={mode} className={cn("flex min-w-0 flex-col gap-1.5", className)} {...props} ref={rootRef} onFocusCapture={(event) => { lastFocused.current = event.target onFocusCapture?.(event) }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) lastFocused.current = null onBlurCapture?.(event) }} /></ItemContext> } export function HotkeyEditorKeys({ className, ...props }: Omit<ComponentProps<"span">, "children">) { const { entry } = useHotkeyEditorItem() const { registry, labels } = useHotkeyEditor() const steps = entry.keys ? formatKeys(entry.keys, registry.platform) : [] return <span data-hotkey-keys={entry.keys} className={cn("inline-flex flex-wrap items-center gap-1", className)} {...props}> {steps.length ? steps.map((caps, index) => <KbdGroup key={index}>{caps.map((cap) => <Kbd key={cap} className="text-xs">{cap}</Kbd>)}</KbdGroup>) : <span className="text-muted-foreground">{labels.unbound}</span>} </span> } export function HotkeyEditorChange({ onClick, disabled, className, ...props }: ActionProps) { const { entry, mode, startCapture } = useHotkeyEditorItem() const { labels } = useHotkeyEditor() return <Button type="button" variant="ghost" size="sm" aria-label={`${labels.change}: ${entry.description}`} className={cn("h-6 px-1.5 text-xs", className)} {...props} disabled={disabled || mode !== "idle"} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) startCapture(event.currentTarget) }} /> } export function HotkeyEditorEdit({ onClick, disabled, className, ...props }: ActionProps) { const { entry, mode, startEdit } = useHotkeyEditorItem() const { labels } = useHotkeyEditor() return <Button type="button" variant="ghost" size="sm" aria-label={`${labels.edit}: ${entry.description}`} className={cn("h-6 px-1.5 text-xs", className)} {...props} disabled={disabled || mode !== "idle"} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) startEdit(event.currentTarget) }} /> } export function HotkeyEditorReset({ onClick, disabled, className, ...props }: ActionProps) { const { entry, reset } = useHotkeyEditorItem() const { labels } = useHotkeyEditor() return <Button type="button" variant="ghost" size="sm" aria-label={`${labels.reset}: ${entry.description}`} className={cn("h-6 px-1.5 text-xs", className)} {...props} disabled={disabled || !entry.remapped} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) reset() }} /> } export function HotkeyEditorResetAll({ onClick, disabled, className, ref, ...props }: ActionProps) { const { registry, remapped } = useHotkeyEditor() const focusFallback = useContext(FocusContext)! const button = useRef<HTMLButtonElement>(null) const buttonRef = useEditorRef(button, ref) useLayoutEffect(() => { const node = button.current if ((disabled || remapped === 0) && node && node.ownerDocument.activeElement === node) focusFallback() }, [disabled, remapped, focusFallback]) return <Button type="button" variant="outline" size="sm" className={cn("h-7 px-2 text-xs", className)} {...props} ref={buttonRef} disabled={disabled || remapped === 0} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) registry.reset() }} /> } // While a capture or a text edit is open, Escape on it cancels the edit and goes no further. It is taken at the // window, ahead of a dialog's own Escape handling in either base, so a dialog that holds the editor stays open, and // a caller's onKeyDown never sees that Escape. function useEscapeCancels<T extends HTMLElement>(cancel: () => void, ref: Ref<T> | undefined) { const latest = useRef(cancel) useInsertionEffect(() => { latest.current = cancel }) return useCallback((node: T | null) => { const forwarded = assignRef(ref, node) const view = node?.ownerDocument.defaultView if (!node || !view) return forwarded const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape" || event.isComposing) return const target = event.composedPath()[0] as Node | undefined if (!target || !node.contains(target)) return event.stopPropagation() event.preventDefault() latest.current() } view.addEventListener("keydown", onKey, true) return () => { view.removeEventListener("keydown", onKey, true) if (typeof forwarded === "function") forwarded() else assignRef(ref, null) } }, [ref]) } export function HotkeyEditorCapture({ ref, onKeyDown, onBlur, className, children, "aria-describedby": describedBy, ...props }: ComponentProps<typeof Button>) { const { mode, commit, cancel, blur, problem, problemId } = useItemContext() const { labels } = useHotkeyEditor() const hintId = useId() const own = useEscapeCancels(cancel, ref) if (mode !== "capture") return null return <><Button type="button" variant="outline" size="sm" aria-pressed aria-invalid={problem ? true : undefined} aria-describedby={[describedBy, hintId, problem ? problemId : null].filter(Boolean).join(" ")} data-hotkey-capture="true" className={cn("h-auto min-h-7 self-start whitespace-normal px-2 text-start text-xs", className)} {...props} ref={own} onKeyDown={(event) => { onKeyDown?.(event) if (event.defaultPrevented) return // A Tab with any move-on modifier is someone leaving, not a binding: Shift reverses // and Option is how Safari tabs to all controls, so only Ctrl or Meta records a Tab. // Bare Enter and Space are refused too — they would turn the next activation of // whatever holds the binding's action into the action itself — while Shift, Ctrl, // Meta, or Alt makes them recordable. if (event.key === "Tab" && !event.ctrlKey && !event.metaKey) { // The move proceeds natively, but application tab bindings stay out of it. event.stopPropagation() return cancel() } event.preventDefault() event.stopPropagation() if (event.key === "Backspace" || event.key === "Delete") return commit("") // The window takes Escape first; in a document with no window, it still cancels and is never a binding. if (event.key === "Escape") return cancel() if ((event.key === "Enter" || event.key === " ") && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) return const keys = keysFromEvent(event.nativeEvent) if (keys) commit(keys) }} onBlur={(event) => { onBlur?.(event) if (!event.defaultPrevented) blur() }}> {children ?? labels.pressKeys} </Button><span id={hintId} className="sr-only">{labels.cancelHint}</span></> } export function HotkeyEditorInput({ ref, onChange, onKeyDown, onBlur, className, "aria-describedby": describedBy, ...props }: InputProps) { const { entry, mode, draft, setDraft, commit, cancel, blur, problem, problemId } = useItemContext() const { labels } = useHotkeyEditor() const own = useEscapeCancels(cancel, ref) if (mode !== "text") return null return <Input aria-label={`${labels.keysFor} ${entry.description}`} aria-invalid={problem ? true : undefined} aria-describedby={[describedBy, problem ? problemId : null].filter(Boolean).join(" ") || undefined} spellCheck={false} autoComplete="off" data-hotkey-input="" className={cn("h-7 w-40 font-(family-name:--tradecn-font-mono) text-xs md:text-xs", className)} {...props} ref={own} value={draft} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setDraft(event.target.value) }} onKeyDown={(event) => { onKeyDown?.(event) if (event.defaultPrevented) return // Editing keys stay out of application hotkeys, including editing-scope bindings. event.stopPropagation() if (event.nativeEvent.isComposing) return if (event.key === "Enter") { event.preventDefault() commit(draft) } }} onBlur={(event) => { onBlur?.(event) if (!event.defaultPrevented) blur() }} /> } export function HotkeyEditorProblem({ className, ...props }: Omit<ComponentProps<"p">, "id" | "children">) { const { problem, problemId } = useHotkeyEditorItem() return problem ? <p role="alert" data-hotkey-problem="true" className={cn("text-destructive", className)} {...props} id={problemId}>{problem}</p> : null } export function HotkeyEditorConflicts({ className, ...props }: Omit<ComponentProps<"ul">, "children">) { const { entry, conflicts } = useHotkeyEditorItem() const { entries, labels } = useHotkeyEditor() if (!conflicts.length) return null return <ul data-hotkey-conflicts="true" className={cn("text-stale", className)} {...props}> {conflicts.map((conflict, index) => { const other = entries.find((candidate) => candidate.id === (conflict.ids[0] === entry.id ? conflict.ids[1] : conflict.ids[0])) return <li key={index}>{labels[conflict.kind]} {other ? `"${other.description}"` : conflict.ids.join(", ")}</li> })} </ul> }hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }lib/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.toLowerCase().split("+") : []) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = declaredSteps } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) clearPending() emit() } } }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import {
HotkeyEditor,
HotkeyEditorItem,
HotkeyEditorKeys,
HotkeyEditorChange,
HotkeyEditorEdit,
HotkeyEditorReset,
HotkeyEditorCapture,
HotkeyEditorInput,
HotkeyEditorProblem,
HotkeyEditorConflicts,
useHotkeyEditorItem,
} from "@/components/ui/hotkey-editor"
const BINDINGS: HotkeyBinding[] = [
{ id: "palette.open", keys: "mod+k", scope: "editing", description: "Open the command palette" },
]
function Shortcut() {
const { entry } = useHotkeyEditorItem()
return (
<>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1">{entry.description}</span>
{entry.remapped && <span className="text-muted-foreground">changed</span>}
<HotkeyEditorKeys />
</div>
<div className="flex flex-wrap gap-1">
<HotkeyEditorChange>Change</HotkeyEditorChange>
<HotkeyEditorEdit>Type it</HotkeyEditorEdit>
{entry.remapped && <HotkeyEditorReset>Reset</HotkeyEditorReset>}
</div>
<HotkeyEditorCapture />
<HotkeyEditorInput />
<HotkeyEditorProblem />
<HotkeyEditorConflicts />
</>
)
}
export default function ShortcutSettings() {
return (
<HotkeysProvider bindings={BINDINGS}>
<HotkeyEditor className="w-sm max-w-full">
<HotkeyEditorItem bindingId="palette.open">
<Shortcut />
</HotkeyEditorItem>
</HotkeyEditor>
</HotkeysProvider>
)
}
Keep bindings stable, as a module constant. Attach their handlers with useHotkey.
Reuse your application's HotkeysProvider when it already owns the registry.
Composition
Use the following composition to build a HotkeyEditor:
HotkeysProvider
└── HotkeyEditor
├── HotkeyEditorSearch
├── Your groups and empty state
│ └── HotkeyEditorItem
│ ├── Your description and changed indicator
│ ├── HotkeyEditorKeys
│ ├── HotkeyEditorChange
│ ├── HotkeyEditorEdit
│ ├── HotkeyEditorReset
│ ├── HotkeyEditorCapture
│ ├── HotkeyEditorInput
│ ├── HotkeyEditorProblem
│ └── HotkeyEditorConflicts
├── HotkeyEditorResetAll
└── Your import and export controls
The root coordinates the registry and search. Each item coordinates one binding's editing state. Choose the readings and controls your layout needs, and supply descriptions, action text, group headings, and empty states in your JSX.
To place the editor in a dialog, install shadcn dialog separately and give it a DialogTitle. While a capture or a text edit is open, Escape on it cancels the edit and stops there: the editor takes it at the window in the capture phase, ahead of the dialog's own Escape handling, so the dialog stays open, and your onKeyDown on that field doesn't see that Escape. Only a capture listener on the window added before the edit opened hears it first.
Groups
Use useHotkeyEditor().groups to render filtered bindings under their group names and add an export control.
import { useState, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { type HotkeyBinding, type HotkeyOverrides } from "@/lib/hotkeys"
import {
HotkeyEditor,
HotkeyEditorSearch,
HotkeyEditorItem,
HotkeyEditorKeys,
HotkeyEditorChange,
HotkeyEditorEdit,
HotkeyEditorReset,
HotkeyEditorResetAll,
HotkeyEditorCapture,
HotkeyEditorInput,
HotkeyEditorProblem,
HotkeyEditorConflicts,
useHotkeyEditor,
useHotkeyEditorItem,
} from "@/components/ui/hotkey-editor"
const BINDINGS: HotkeyBinding[] = [
{ id: "palette.open", keys: "mod+k", scope: "editing", description: "Open the command palette", group: "General" },
{ id: "go.blotter", keys: "g b", scope: "global", description: "Go to the blotter", group: "Go" },
{ id: "go.inquiries", keys: "g i", scope: "global", description: "Go to the inquiries", group: "Go" },
{ id: "rfq.send", keys: "mod+enter", scope: "editing", description: "Send the quote", group: "Inquiry" },
{ id: "book.cancel", keys: "x", scope: "panel:book", description: "Cancel the selected order" },
]
function ShortcutRow() {
const { entry } = useHotkeyEditorItem()
return (
<>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1">{entry.description}</span>
{entry.remapped && <span className="rounded border px-1 text-muted-foreground">changed</span>}
<HotkeyEditorKeys />
<div className="flex flex-wrap gap-1">
<HotkeyEditorChange>Change</HotkeyEditorChange>
<HotkeyEditorEdit>Type it</HotkeyEditorEdit>
{entry.remapped && <HotkeyEditorReset>Reset</HotkeyEditorReset>}
</div>
</div>
<HotkeyEditorCapture />
<HotkeyEditorInput />
<HotkeyEditorProblem />
<HotkeyEditorConflicts />
</>
)
}
export function HotkeyEditorGroups({ children }: { children?: ReactNode }) {
const { groups } = useHotkeyEditor()
return (
<>
<div className="flex flex-wrap items-center gap-2">
<HotkeyEditorSearch />
<div className="ml-auto flex flex-wrap gap-1">
{children}
<HotkeyEditorResetAll>Reset all</HotkeyEditorResetAll>
</div>
</div>
{groups.length === 0 && <p className="text-muted-foreground">No shortcut matches.</p>}
{groups.map((group) => (
<section key={group.name} aria-label={group.name} data-hotkey-group={group.name}>
<h3 className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">{group.name}</h3>
{group.entries.map((entry) => (
<HotkeyEditorItem key={entry.id} bindingId={entry.id} className="border-b border-border py-2 last:border-b-0">
<ShortcutRow />
</HotkeyEditorItem>
))}
</section>
))}
</>
)
}
function ExportButton({ onExport }: { onExport: (overrides: HotkeyOverrides) => void }) {
const { registry } = useHotkeyEditor()
return <Button type="button" size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => onExport(registry.overrides())}>Export</Button>
}
export default function HotkeyEditorGroupsDemo() {
const [exported, setExported] = useState<HotkeyOverrides | null>(null)
return (
<HotkeysProvider bindings={BINDINGS}>
<div className="flex w-xl max-w-full flex-col gap-3 text-xs lining-nums tabular-nums">
<HotkeyEditor>
<HotkeyEditorGroups><ExportButton onExport={setExported} /></HotkeyEditorGroups>
</HotkeyEditor>
{exported !== null && (
<section aria-label="Exported overrides" className="flex flex-col gap-1">
<p className="text-muted-foreground">Exported overrides</p>
<pre tabIndex={0} role="region" aria-label="Exported overrides JSON" className="max-h-36 overflow-auto rounded-md border border-border bg-card p-2 font-(family-name:--tradecn-font-mono) text-xs">{JSON.stringify(exported, null, 2)}</pre>
</section>
)}
</div>
</HotkeysProvider>
)
}
Cards
Arrange items in a card grid and use HotkeyEditorEdit with HotkeyEditorInput for text editing.
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import {
HotkeyEditor,
HotkeyEditorSearch,
HotkeyEditorItem,
HotkeyEditorKeys,
HotkeyEditorEdit,
HotkeyEditorInput,
HotkeyEditorProblem,
HotkeyEditorConflicts,
HotkeyEditorResetAll,
useHotkeyEditor,
} from "@/components/ui/hotkey-editor"
const BINDINGS: HotkeyBinding[] = [
{ id: "go.blotter", keys: "g b", scope: "global", description: "Go to the blotter" },
{ id: "go.book", keys: "g o", scope: "global", description: "Go to the book" },
]
function ShortcutCards() {
const { groups } = useHotkeyEditor()
const entries = groups.flatMap((group) => group.entries).reverse()
return (
<>
<p className="text-muted-foreground">Use a sequence such as g b to move between views.</p>
<HotkeyEditorSearch />
<div className="grid gap-3 sm:grid-cols-2">
{entries.map((entry) => (
<HotkeyEditorItem key={entry.id} bindingId={entry.id} className="gap-3 rounded-md border p-3">
<h3 className="font-medium">{entry.description}</h3>
<HotkeyEditorKeys />
{entry.remapped && <span className="text-muted-foreground">changed</span>}
<HotkeyEditorEdit className="self-start" variant="outline">Type it</HotkeyEditorEdit>
<HotkeyEditorInput className="w-full" />
<HotkeyEditorProblem />
<HotkeyEditorConflicts />
</HotkeyEditorItem>
))}
</div>
{entries.length === 0 && <p className="text-muted-foreground">No shortcut matches.</p>}
<div className="flex flex-wrap items-center justify-between gap-2">
<a href="https://tradecn.dev/docs/use-hotkeys/" target="_blank" rel="noreferrer" className="underline underline-offset-4">Shortcut reference</a>
<HotkeyEditorResetAll>Reset all</HotkeyEditorResetAll>
</div>
</>
)
}
export default function HotkeyEditorLayoutDemo() {
return (
<HotkeysProvider bindings={BINDINGS}>
<HotkeyEditor className="w-xl max-w-full gap-3">
<ShortcutCards />
</HotkeyEditor>
</HotkeysProvider>
)
}
API Reference
Props
HotkeyEditor reads the nearest HotkeysProvider registry. It renders a div with a region role and tabIndex={-1}, and accepts native div props and a ref.
| Prop | Type | Default | Purpose |
|---|---|---|---|
children |
ReactNode |
Required | The editor's composition. Conditional content is supported. |
hide |
(entry: HotkeyEntry) => boolean |
Show all | Exclude entries from groups. Direct items and entries remain available. |
labels |
Partial<HotkeyEditorLabels> |
DEFAULT_HOTKEY_EDITOR_LABELS |
Accessible names and behavior messages. |
className |
string |
- | Additional classes to apply to the editor. |
There is one registry subscription per root. Hooks and parts under it share that subscription. They add no timers or application shortcut handlers.
HotkeyEditorItem
Coordinates capture, text editing, validation, and focus for one registered binding. It renders a named div group with tabIndex={-1}, and accepts native div props and a ref. An unknown or removed binding renders nothing.
| Prop | Type | Default | Purpose |
|---|---|---|---|
bindingId |
string |
Required | A registered binding's id. |
children |
ReactNode |
Required | The item's readings, controls, and application content. |
className |
string |
- | Additional classes to apply to the item. |
Keep React keys equal to binding ids when mapping items.
Edit state belongs to each mounted item. Two presentations share the registry but have separate drafts.
A change to the binding's keys, declaration fields, or provider registry cancels an open draft. Unrelated registry updates preserve it.
Search and editing fields
| Part | Renders | Behavior |
|---|---|---|
HotkeyEditorSearch |
Installed Input | Reads and updates the root's query. |
HotkeyEditorCapture |
Installed Button | Appears during capture and records one shortcut step. Optional children replace the visible prompt. |
HotkeyEditorInput |
Installed Input | Appears during text editing and updates the item's draft. |
Inputs accept their installed component's props except value and defaultValue, which are coordinated. Capture accepts installed Button props.
All three forward refs and native events. Caller handlers run first. preventDefault() skips the part's behavior for that event, except Escape during a capture or a text edit, which the editor takes at the window before the part's handlers see it.
Actions
HotkeyEditorChange, HotkeyEditorEdit, HotkeyEditorReset, and HotkeyEditorResetAll accept installed Button props and refs. Each requires children for its content. Caller onClick runs first and can cancel the action with preventDefault().
| Part | Action | Disabled when |
|---|---|---|
HotkeyEditorChange |
Start single-step capture. | The item is already editing. |
HotkeyEditorEdit |
Edit the effective keys as text. | The item is already editing. |
HotkeyEditorReset |
Remove this binding's override and end its edit. | The binding is not remapped. |
HotkeyEditorResetAll |
Remove all overrides. | No registered entry is remapped. |
Passing disabled also disables an action. Reset controls remain rendered when disabled. Render Reset conditionally when you want it to appear only for changed bindings.
Readings
| Part | Native props and ref | Content |
|---|---|---|
HotkeyEditorKeys |
span |
Effective keys as keycaps, or the unbound label. Remains visible during editing. |
HotkeyEditorProblem |
p, except coordinated id |
Current validation error, with an alert role and a generated id linked from the editing field. Renders nothing without an error. |
HotkeyEditorConflicts |
ul |
One message per conflict involving the item. Renders nothing without conflicts. |
These readings supply their own content and omit children from their props. Use useHotkeyEditorItem to write a different reading or conflict list.
Render the editor on the client: its key caps come from the registry's platform and saved overrides, which a server doesn't have.
Hooks
useHotkeyEditor() requires a root. useHotkeyEditorItem() requires an item. Both throw when their coordinating parent is absent.
useHotkeyEditor value |
Type | Description |
|---|---|---|
registry |
HotkeyRegistry |
Registry used for remapping, reset, import, and export. |
entries |
readonly HotkeyEntry[] |
All registered bindings in registry order. |
groups |
readonly HotkeyEditorGroup[] |
Filtered and sorted groups. Each has name: string and entries: readonly HotkeyEntry[]. |
conflicts |
readonly HotkeyConflict[] |
Conflicts across all bindings. |
remapped |
number |
Count of registered entries with a stored override, one that fails to parse or matches the defaults included. |
query, setQuery |
string, (query: string) => void |
Shared search state. |
labels |
HotkeyEditorLabels |
Merged labels. |
useHotkeyEditorItem value |
Type | Description |
|---|---|---|
entry |
HotkeyEntry |
This binding's current effective keys, defaults, and remapped flag. |
mode |
"idle" | "capture" | "text" |
Current editing mode. |
draft, setDraft |
string, (draft: string) => void |
Text being edited. Changes require an active edit. |
problem, problemId |
string | null, string |
Validation error and its field-description id. |
conflicts |
readonly HotkeyConflict[] |
Conflicts involving this binding. |
startCapture, startEdit |
(trigger?: HTMLElement) => void |
Begin editing and remember the trigger for focus return. |
commit |
(keys: string) => void |
Remap during an active edit. Invalid input keeps the edit open. |
cancel |
() => void |
Discard the draft and return focus to the trigger or item. Calling this on blur can override the destination's focus. |
reset |
() => void |
Remove the binding's override and end editing. |
Use the public capture and input parts with custom triggers to retain keyboard handling and focus. Pass the trigger to startCapture or startEdit to return focus to it. Otherwise, focus returns to the item.
The list
Groups use the binding's group, falling back to its scope: global, editing, or the name after panel:. Named groups sort before scope-derived groups, then by group and description. Render groups in that order for the default arrangement, or reorder and limit entries at the call site.
Search trims the query and matches case-insensitive substrings of the description, group, id, normalized keys, or displayed keycaps (ctrl finds Ctrl K on non-Mac platforms). hide and search only filter groups: hidden bindings stay registered, can appear in another row's conflict message, and remain included in export and Reset all.
HotkeyEntry extends HotkeyBinding with normalized effective keys, normalized defaultKeys, the declaration's own spelling as declaredKeys, and remapped. Registry types come from @/lib/hotkeys.
Three ways to change a shortcut
| Control | Behavior |
|---|---|
| Capture | Records one key with its modifiers through keysFromEvent. Bare modifiers and other ignored keys leave capture open. Escape cancels, and a Tab cancels and moves on unless Ctrl or Meta is held — Shift reverses and Option is Safari's tab-to-all-controls move, so a Tab-away must never become a live binding. Bare Enter and Space are refused too; Shift, Ctrl, Meta, or Alt makes them recordable. Backspace or Delete unbinds, even with modifiers held. |
| Text | Join modifiers and a key with + (mod+k). Separate chord steps with spaces (g b). Enter commits, Escape cancels, and an empty field unbinds. Parse errors leave the binding unchanged. |
| Reset | Removes the binding's override. Reset all also removes hidden and unregistered overrides. |
Blur cancels an unfinished edit and clears its validation message.
Capture consumes every keydown except a leaving Tab. Text editing stops keydown propagation to application handlers and prevents default behavior for Enter and Escape.
Use text to enter chords or bindings capture refuses: Escape, Backspace, Delete, bare Enter and Space, and leaving Tabs — Ctrl or Meta records a Tab too.
Commit and Escape return focus to the initiating control, or the item if that control is unavailable. Blur preserves the destination's focus.
Removing a focused item moves focus to the editor's search field, or its root when there is no search field. A focused Reset that is removed or disabled moves focus to its item. A focused Reset all that becomes disabled moves focus to search or the root.
Keep a public editing field mounted for every editing mode your triggers can start.
The plus key works through either entry method: capture and typed entry store ctrl+plus in its canonical form ctrl++, remapping accepts it, and the keycaps print Ctrl + (⌃ + on a Mac). See remapping.
Conflicts
The root reads registry.conflicts() when the registry changes. Mount HotkeyEditorConflicts under each item that should show its messages, or render the hook's conflicts yourself.
| Kind | Reported overlap | Default phrase |
|---|---|---|
duplicate |
Same keys in the same scope, or between global and editing. | same keys as |
prefix |
One sequence starts another in the same scope, or between global and editing. | starts the chord of |
shadow |
A panel and a global or editing binding share keys or a chord prefix. | hides, while focus is in this panel, |
Each phrase names the other binding. Prefix and shadow messages use the same wording on both rows and do not identify which is the shorter sequence or panel binding.
Reports compare declarations regardless of when().
Unbound bindings and different panel scope names are excluded. A pair is also excluded when all its handlers are fenced to elements that do not contain one another. That pair is reported until both sides have such handlers.
Conflicts do not block remapping or decide which shortcut runs. See the registry's conflict rules. Updating from v1? The HotkeyEditor migration entry carries the capture and plus-key changes.
Export and import
Use the hook's registry.overrides() in your own export button. HotkeyOverrides is Record<string, string>, keyed by binding id. Import with registry.load(overrides) after reading and validating your file.
load replaces all overrides and refreshes the list, but does not call onChange. An override that fails to parse remains stored while the binding falls back to its defaults, so exported values can differ from the effective keys shown; such an entry reads as remapped, so Reset can clear it.
For persistence, create a registry with createHotkeyRegistry and pass it to the provider.
Load saved overrides during browser startup. Subscribe to registry.onChange to save remaps and resets, and unsubscribe when the owner is disposed.
Persist imports separately because load does not call onChange.
Labels
labels merges with DEFAULT_HOTKEY_EDITOR_LABELS. Supply descriptions, group headings, changed indicators, empty states, import/export text, and action children in your composition.
| Labels | Defaults |
|---|---|
title, search |
Keyboard shortcuts, Find a shortcut |
change, edit, reset |
Change, Type it, Reset |
pressKeys, unbound, keysFor |
Press the new shortcut, unbound, Keys for |
cancelHint |
Escape cancels, Tab moves on, Backspace unbinds |
notKeys |
Not a shortcut. Write keys joined by +, steps of a chord separated by a space: mod+k, g b. |
duplicate, prefix, shadow |
See Conflicts. |
The root's accessible name defaults to title. Search uses search for its name and placeholder.
Change, Edit, and Reset include the binding description in their accessible names. Input uses keysFor followed by the description. Capture links cancelHint as an accessible description, alongside any caller description and validation error.
Match visible control text to labels.change, labels.edit, and labels.reset, or override the corresponding native accessible-name props.
Problem supplies a live alert. Conflicts and changed indicators are ordinary text. Add announcements at the call site if your application needs them.
Helpers
These pure helpers remain exported from @/components/ui/hotkey-editor.
| Helper | Result |
|---|---|
scopeWord(scope: string) |
Removes a leading panel:. Other names stay unchanged. |
groupOf(entry: HotkeyEntry) |
entry.group ?? scopeWord(entry.scope). |
matchesQuery(entry: HotkeyEntry, query: string, platform: "mac" | "other") |
Matches the fields described under The list. A blank query matches every entry. |
What it does not do
The editor declares no bindings or handlers, persists no overrides, reads or writes no files, and does not resolve conflicts. Search and per-item drafts are its local state.
Tokens
Installation adds stale for conflict text, the mono and accessible font tokens, and the hyperlegible remap, each when absent. The input fields use the consumer's --tradecn-font-mono token.
Built on shadcn's button, input, and kbd.
This page is docs/hotkey-editor.md at v2.0.0.