useDynamicShortcut.tsx128 lines · main
1import { useHotkeySequence, type HotkeySequence } from '@tanstack/react-hotkeys'
2import { Fragment, useCallback, useMemo } from 'react'
3import { KeyboardShortcut } from 'ui'
4import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
5
6import { hotkeyToKeys } from './formatShortcut'
7import type { ShortcutHotkeyMeta, ShortcutOptions } from './types'
8import { orderShortcutCommands } from './utils'
9import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
10import useLatest from '@/hooks/misc/useLatest'
11
12/**
13 * Props shared by both the hook and the `<DynamicShortcut>` component.
14 *
15 * "Dynamic" means the shortcut isn't pre-declared in `SHORTCUT_DEFINITIONS` —
16 * the call site provides the `id`, `sequence`, and `label` at mount time.
17 * Use this when the shortcut list itself is derived from data (e.g. a tab list
18 * whose count and labels vary per page).
19 *
20 * Behavior matches `useShortcut` for the surfaces a dynamic shortcut can
21 * meaningfully participate in:
22 * - Reference sheet — picks these up via the `meta` payload while mounted.
23 * - Cmd+K command menu — opt-in via `registerInCommandMenu`, same pattern
24 * and ordering as registered shortcuts.
25 *
26 * What it does *not* support, because both are keyed on static `ShortcutId`s:
27 * - User-preference toggle in Account → Preferences.
28 * - `showInSettings` rendering in the keyboard shortcuts settings page.
29 *
30 * `id` must be unique among currently-mounted shortcuts. TanStack's hotkey
31 * lib warns by default when two registrations share a `sequence`, so prefer
32 * scoping IDs by surface (e.g. `integration-detail.tab-${index}`).
33 */
34export interface DynamicShortcutProps {
35 id: string
36 sequence: HotkeySequence
37 label: string
38 callback: () => void
39 enabled?: boolean
40 referenceGroup?: string
41 registerInCommandMenu?: boolean
42 ignoreInputs?: ShortcutOptions['ignoreInputs']
43 timeout?: ShortcutOptions['timeout']
44 conflictBehavior?: ShortcutOptions['conflictBehavior']
45}
46
47export function useDynamicShortcut({
48 id,
49 sequence,
50 label,
51 callback,
52 enabled = true,
53 referenceGroup,
54 registerInCommandMenu = false,
55 ignoreInputs,
56 timeout,
57 conflictBehavior,
58}: DynamicShortcutProps) {
59 const meta = useMemo<ShortcutHotkeyMeta>(
60 () => ({ id, name: label, referenceGroup }),
61 [id, label, referenceGroup]
62 )
63
64 useHotkeySequence(sequence, callback, {
65 enabled,
66 timeout,
67 meta,
68 ...(ignoreInputs !== undefined && { ignoreInputs }),
69 ...(conflictBehavior !== undefined && { conflictBehavior }),
70 })
71
72 const enabledInCommandMenu = enabled && registerInCommandMenu
73 const callbackRef = useLatest(callback)
74 const setCommandMenuOpen = useSetCommandMenuOpen()
75 const stableAction = useCallback(() => {
76 setCommandMenuOpen(false)
77 callbackRef.current()
78 }, [callbackRef, setCommandMenuOpen])
79
80 useRegisterCommands(
81 COMMAND_MENU_SECTIONS.SHORTCUTS,
82 [
83 {
84 id,
85 name: label,
86 action: stableAction,
87 badge: () => (
88 <div className="flex items-center gap-1">
89 {sequence.map((step, i) => (
90 <Fragment key={i}>
91 {i > 0 && <span className="text-foreground-lighter text-[11px]">then</span>}
92 <KeyboardShortcut keys={hotkeyToKeys(step)} />
93 </Fragment>
94 ))}
95 </div>
96 ),
97 },
98 ],
99 {
100 enabled: enabledInCommandMenu,
101 deps: [enabled, label, sequence.join('+')],
102 orderCommands: orderShortcutCommands,
103 sectionMeta: { priority: 1 },
104 }
105 )
106}
107
108/**
109 * Renderable wrapper around `useDynamicShortcut`. Lets callers compose
110 * shortcuts inside a `.map()` over dynamic data without violating rules-of-hooks:
111 *
112 * ```tsx
113 * {tabs.map((tab, i) => (
114 * <DynamicShortcut
115 * key={tab.href}
116 * id={`integration-detail.tab-${i}`}
117 * sequence={[String(i + 1)]}
118 * label={`Go to ${tab.label}`}
119 * registerInCommandMenu
120 * callback={() => router.push(tab.href)}
121 * />
122 * ))}
123 * ```
124 */
125export function DynamicShortcut(props: DynamicShortcutProps) {
126 useDynamicShortcut(props)
127 return null
128}