useDynamicShortcut.tsx 4.1 KB

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