Shortcut.tsx85 lines · main
1import { TooltipContentProps } from '@ui/components/shadcn/ui/tooltip'
2import type { ReactNode } from 'react'
3
4import { ShortcutTooltip } from './ShortcutTooltip'
5import type { ShortcutId } from '@/state/shortcuts/registry'
6import type { ShortcutOptions } from '@/state/shortcuts/types'
7import { useShortcut } from '@/state/shortcuts/useShortcut'
8
9interface ShortcutProps {
10 /** Registered shortcut id — drives both the hotkey binding and the tooltip. */
11 id: ShortcutId
12 /** Fires on the hotkey. Usually the same handler wired to the child's `onClick`. */
13 onTrigger: () => void
14 /** Element to bind the shortcut to and wrap in the tooltip. */
15 children: ReactNode
16 /** Per-mount overrides for the shortcut — see `ShortcutOptions`. */
17 options?: ShortcutOptions
18 side?: TooltipContentProps['side']
19 align?: TooltipContentProps['align']
20 sideOffset?: number
21 delayDuration?: number
22 /**
23 * Override the label from the registry. Use when the wrapped element's
24 * action is a narrower/contextual variant of the registered shortcut.
25 */
26 label?: string
27 /**
28 * Controlled open state for the tooltip. Pass `false` to force the tooltip
29 * closed (e.g. while a popover or dialog opened by the wrapped element is
30 * visible). Leave `undefined` for default uncontrolled behavior.
31 */
32 tooltipOpen?: boolean
33}
34
35/**
36 * Bind a registered shortcut to an element AND show its keybind on hover,
37 * Linear-style. Single source of truth: one `id` drives both the hotkey
38 * listener and the tooltip, so they can't drift.
39 *
40 * The wrapped child stays fully interactive — Radix `asChild` passes clicks,
41 * focus, and refs through untouched.
42 *
43 * @example
44 * <Shortcut id={SHORTCUT_IDS.RESULTS_COPY_MARKDOWN} onTrigger={handleCopy}>
45 * <Button onClick={handleCopy}>Copy</Button>
46 * </Shortcut>
47 *
48 * @example
49 * // Gate the hotkey on local state; tooltip still renders:
50 * <Shortcut
51 * id={SHORTCUT_IDS.ACTION_BAR_SAVE}
52 * onTrigger={handleSave}
53 * options={{ enabled: hasUnsavedChanges }}
54 * >
55 * <Button onClick={handleSave} disabled={!hasUnsavedChanges}>Save</Button>
56 * </Shortcut>
57 */
58export const Shortcut = ({
59 id,
60 onTrigger,
61 children,
62 options,
63 side,
64 align,
65 sideOffset,
66 delayDuration,
67 label,
68 tooltipOpen,
69}: ShortcutProps) => {
70 useShortcut(id, onTrigger, label !== undefined ? { ...options, label } : options)
71
72 return (
73 <ShortcutTooltip
74 shortcutId={id}
75 side={side}
76 align={align}
77 sideOffset={sideOffset}
78 delayDuration={delayDuration}
79 label={label}
80 open={tooltipOpen}
81 >
82 {children}
83 </ShortcutTooltip>
84 )
85}