useDynamicShortcut.tsx128 lines · main
| 1 | import { useHotkeySequence, type HotkeySequence } from '@tanstack/react-hotkeys' |
| 2 | import { Fragment, useCallback, useMemo } from 'react' |
| 3 | import { KeyboardShortcut } from 'ui' |
| 4 | import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' |
| 5 | |
| 6 | import { hotkeyToKeys } from './formatShortcut' |
| 7 | import type { ShortcutHotkeyMeta, ShortcutOptions } from './types' |
| 8 | import { orderShortcutCommands } from './utils' |
| 9 | import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' |
| 10 | import 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 | */ |
| 34 | export 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 | |
| 47 | export 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 | */ |
| 125 | export function DynamicShortcut(props: DynamicShortcutProps) { |
| 126 | useDynamicShortcut(props) |
| 127 | return null |
| 128 | } |