useShortcut.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import { render, renderHook } from '@testing-library/react'
  2. import type { ICommand } from 'ui-patterns/CommandMenu/api/types'
  3. import { beforeEach, describe, expect, it, vi } from 'vitest'
  4. import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from './registry'
  5. import { useShortcut } from './useShortcut'
  6. const {
  7. mockUseHotkeySequence,
  8. mockUseRegisterCommands,
  9. mockUseIsShortcutEnabled,
  10. mockSetCommandMenuOpen,
  11. } = vi.hoisted(() => ({
  12. mockUseHotkeySequence: vi.fn(),
  13. mockUseRegisterCommands: vi.fn(),
  14. mockUseIsShortcutEnabled: vi.fn(),
  15. mockSetCommandMenuOpen: vi.fn(),
  16. }))
  17. vi.mock('@tanstack/react-hotkeys', () => ({
  18. useHotkeySequence: mockUseHotkeySequence,
  19. }))
  20. vi.mock('ui-patterns/CommandMenu', () => ({
  21. useRegisterCommands: mockUseRegisterCommands,
  22. useSetCommandMenuOpen: () => mockSetCommandMenuOpen,
  23. }))
  24. vi.mock('./useIsShortcutEnabled', () => ({
  25. useIsShortcutEnabled: mockUseIsShortcutEnabled,
  26. }))
  27. const getLastHotkeyOptions = () => {
  28. const call = mockUseHotkeySequence.mock.calls.at(-1)
  29. if (!call) throw new Error('useHotkeySequence was not called')
  30. return call[2] as {
  31. enabled: boolean
  32. timeout: number | undefined
  33. ignoreInputs?: boolean
  34. meta?: { id?: string; name?: string; referenceGroup?: string }
  35. }
  36. }
  37. const getLastRegisterCall = () => {
  38. const call = mockUseRegisterCommands.mock.calls.at(-1)
  39. if (!call) throw new Error('useRegisterCommands was not called')
  40. return call as [
  41. string,
  42. Array<{ id: string; name: string; action: () => void; badge: () => any }>,
  43. { enabled: boolean; deps: unknown[]; orderCommands?: unknown },
  44. ]
  45. }
  46. describe('useShortcut', () => {
  47. beforeEach(() => {
  48. vi.clearAllMocks()
  49. mockUseIsShortcutEnabled.mockReturnValue(true)
  50. })
  51. describe('hotkey wiring', () => {
  52. it('passes the registry sequence to useHotkeySequence', () => {
  53. const cb = vi.fn()
  54. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, cb))
  55. const [sequence, callback] = mockUseHotkeySequence.mock.calls[0]
  56. expect(sequence).toEqual(SHORTCUT_DEFINITIONS[SHORTCUT_IDS.COMMAND_MENU_OPEN].sequence)
  57. expect(callback).toBe(cb)
  58. })
  59. it('passes multi-step sequences (G-chords) through unchanged', () => {
  60. renderHook(() => useShortcut(SHORTCUT_IDS.NAV_HOME, vi.fn()))
  61. expect(mockUseHotkeySequence.mock.calls[0][0]).toEqual(['G', 'H'])
  62. })
  63. it('wires the callback by reference — useHotkeySequence receives the same function', () => {
  64. const cb = vi.fn()
  65. renderHook(() => useShortcut(SHORTCUT_IDS.ACTION_BAR_SAVE, cb))
  66. const passedCallback = mockUseHotkeySequence.mock.calls[0][1]
  67. passedCallback()
  68. expect(cb).toHaveBeenCalledTimes(1)
  69. })
  70. })
  71. describe('enabled resolution', () => {
  72. it('defaults to enabled: true when no options and no registry default', () => {
  73. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn()))
  74. expect(getLastHotkeyOptions().enabled).toBe(true)
  75. })
  76. it('caller option takes priority over fallback', () => {
  77. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { enabled: false }))
  78. expect(getLastHotkeyOptions().enabled).toBe(false)
  79. })
  80. it('global disable forces enabled to false, regardless of caller', () => {
  81. mockUseIsShortcutEnabled.mockReturnValue(false)
  82. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { enabled: true }))
  83. expect(getLastHotkeyOptions().enabled).toBe(false)
  84. })
  85. it('global enabled AND caller enabled = true', () => {
  86. mockUseIsShortcutEnabled.mockReturnValue(true)
  87. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { enabled: true }))
  88. expect(getLastHotkeyOptions().enabled).toBe(true)
  89. })
  90. it('global enabled AND caller undefined = true', () => {
  91. mockUseIsShortcutEnabled.mockReturnValue(true)
  92. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn()))
  93. expect(getLastHotkeyOptions().enabled).toBe(true)
  94. })
  95. it('subscribes to the correct shortcut id for global preference', () => {
  96. renderHook(() => useShortcut(SHORTCUT_IDS.NAV_TABLE_EDITOR, vi.fn()))
  97. expect(mockUseIsShortcutEnabled).toHaveBeenCalledWith(SHORTCUT_IDS.NAV_TABLE_EDITOR)
  98. })
  99. })
  100. describe('timeout resolution', () => {
  101. it('defaults to undefined (falls through to TanStack default)', () => {
  102. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn()))
  103. expect(getLastHotkeyOptions().timeout).toBeUndefined()
  104. })
  105. it('uses caller-provided timeout', () => {
  106. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { timeout: 2000 }))
  107. expect(getLastHotkeyOptions().timeout).toBe(2000)
  108. })
  109. })
  110. describe('ignoreInputs resolution', () => {
  111. it('omits the key when no registry default and no caller override (library applies its per-hotkey default)', () => {
  112. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn()))
  113. const options = getLastHotkeyOptions()
  114. expect('ignoreInputs' in options).toBe(false)
  115. })
  116. it('uses the registry default when no caller override', () => {
  117. renderHook(() => useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, vi.fn()))
  118. expect(getLastHotkeyOptions().ignoreInputs).toBe(true)
  119. })
  120. it('caller override takes priority over registry default', () => {
  121. renderHook(() =>
  122. useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, vi.fn(), { ignoreInputs: false })
  123. )
  124. expect(getLastHotkeyOptions().ignoreInputs).toBe(false)
  125. })
  126. })
  127. describe('command menu registration', () => {
  128. it('calls useRegisterCommands under the "Shortcuts" section', () => {
  129. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn()))
  130. const [section] = getLastRegisterCall()
  131. expect(section).toBe('Shortcuts')
  132. })
  133. it('is disabled by default (registerInCommandMenu defaults to false)', () => {
  134. renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn()))
  135. expect(getLastRegisterCall()[2].enabled).toBe(false)
  136. })
  137. it('is enabled when registerInCommandMenu: true AND enabled', () => {
  138. renderHook(() =>
  139. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { registerInCommandMenu: true })
  140. )
  141. expect(getLastRegisterCall()[2].enabled).toBe(true)
  142. })
  143. it('is disabled when globally disabled, even with registerInCommandMenu: true', () => {
  144. mockUseIsShortcutEnabled.mockReturnValue(false)
  145. renderHook(() =>
  146. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { registerInCommandMenu: true })
  147. )
  148. expect(getLastRegisterCall()[2].enabled).toBe(false)
  149. })
  150. it('is disabled when caller passes enabled: false, even with registerInCommandMenu: true', () => {
  151. renderHook(() =>
  152. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), {
  153. enabled: false,
  154. registerInCommandMenu: true,
  155. })
  156. )
  157. expect(getLastRegisterCall()[2].enabled).toBe(false)
  158. })
  159. it('registers the command with id and label from the registry', () => {
  160. renderHook(() =>
  161. useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, vi.fn(), { registerInCommandMenu: true })
  162. )
  163. const [, commands] = getLastRegisterCall()
  164. expect(commands).toHaveLength(1)
  165. expect(commands[0].id).toBe(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN)
  166. expect(commands[0].name).toBe(SHORTCUT_DEFINITIONS[SHORTCUT_IDS.RESULTS_COPY_MARKDOWN].label)
  167. })
  168. it('command action calls the LATEST callback after a rerender (no stale closure)', () => {
  169. const cb1 = vi.fn()
  170. const cb2 = vi.fn()
  171. const { rerender } = renderHook(
  172. ({ cb }: { cb: () => void }) =>
  173. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, cb, { registerInCommandMenu: true }),
  174. { initialProps: { cb: cb1 } }
  175. )
  176. // Capture the action from the first render and fire it after a rerender —
  177. // it should call the NEW callback, proving we dodge the stale closure.
  178. const firstAction = mockUseRegisterCommands.mock.calls[0][1][0].action
  179. rerender({ cb: cb2 })
  180. firstAction()
  181. expect(cb1).not.toHaveBeenCalled()
  182. expect(cb2).toHaveBeenCalledTimes(1)
  183. })
  184. it('command action closes the command menu when fired', () => {
  185. const cb = vi.fn()
  186. renderHook(() =>
  187. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, cb, { registerInCommandMenu: true })
  188. )
  189. const action = mockUseRegisterCommands.mock.calls[0][1][0].action
  190. action()
  191. expect(mockSetCommandMenuOpen).toHaveBeenCalledWith(false)
  192. expect(cb).toHaveBeenCalledTimes(1)
  193. })
  194. it('command action identity is stable across renders', () => {
  195. const { rerender } = renderHook(
  196. ({ cb }: { cb: () => void }) =>
  197. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, cb, { registerInCommandMenu: true }),
  198. { initialProps: { cb: vi.fn() } }
  199. )
  200. const firstAction = mockUseRegisterCommands.mock.calls[0][1][0].action
  201. rerender({ cb: vi.fn() })
  202. const secondAction = mockUseRegisterCommands.mock.calls.at(-1)![1][0].action
  203. expect(firstAction).toBe(secondAction)
  204. })
  205. it('deps track enabled + label so downstream can invalidate correctly', () => {
  206. renderHook(() =>
  207. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { registerInCommandMenu: true })
  208. )
  209. const [, , options] = getLastRegisterCall()
  210. expect(options.deps).toEqual([
  211. true,
  212. SHORTCUT_DEFINITIONS[SHORTCUT_IDS.COMMAND_MENU_OPEN].label,
  213. ])
  214. })
  215. it('orders "Show all keyboard shortcuts" last within the Shortcuts section', () => {
  216. renderHook(() =>
  217. useShortcut(SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, vi.fn(), { registerInCommandMenu: true })
  218. )
  219. const [, commands, options] = getLastRegisterCall()
  220. const orderCommands = options.orderCommands as (
  221. existing: ICommand[],
  222. commandsToInsert: ICommand[]
  223. ) => ICommand[]
  224. const ordered = orderCommands(
  225. [
  226. { id: SHORTCUT_IDS.TABLE_EDITOR_INSERT_ROW, name: 'Insert row', action: vi.fn() },
  227. { id: SHORTCUT_IDS.TABLE_EDITOR_INSERT_COLUMN, name: 'Insert column', action: vi.fn() },
  228. ],
  229. commands
  230. )
  231. expect(ordered.map((command) => command.id)).toEqual([
  232. SHORTCUT_IDS.TABLE_EDITOR_INSERT_ROW,
  233. SHORTCUT_IDS.TABLE_EDITOR_INSERT_COLUMN,
  234. SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE,
  235. ])
  236. })
  237. describe('badge rendering', () => {
  238. it('renders a single KeyboardShortcut pill for single-step sequences (no "then")', () => {
  239. renderHook(() =>
  240. useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn(), { registerInCommandMenu: true })
  241. )
  242. const badgeNode = getLastRegisterCall()[1][0].badge()
  243. const { container } = render(badgeNode)
  244. expect(container.textContent).not.toContain('then')
  245. })
  246. it('renders a "then" separator between steps for multi-step sequences', () => {
  247. renderHook(() =>
  248. useShortcut(SHORTCUT_IDS.NAV_HOME, vi.fn(), { registerInCommandMenu: true })
  249. )
  250. const badgeNode = getLastRegisterCall()[1][0].badge()
  251. const { container } = render(badgeNode)
  252. expect(container.textContent).toContain('then')
  253. })
  254. it('renders the converted keys (Mod → ⌘ or Ctrl via KeyboardShortcut)', () => {
  255. renderHook(() =>
  256. useShortcut(SHORTCUT_IDS.RESULTS_COPY_MARKDOWN, vi.fn(), {
  257. registerInCommandMenu: true,
  258. })
  259. )
  260. const badgeNode = getLastRegisterCall()[1][0].badge()
  261. const { container } = render(badgeNode)
  262. // Mod+Shift+M → the "M" key is always rendered as-is; the platform-specific
  263. // ⌘/Ctrl handling lives in KeyboardShortcut and is asserted there.
  264. expect(container.textContent).toContain('M')
  265. expect(container.textContent).toContain('⇧')
  266. })
  267. })
  268. })
  269. describe('reference-sheet metadata', () => {
  270. it('forwards id, label, and referenceGroup as registration meta', () => {
  271. renderHook(() => useShortcut(SHORTCUT_IDS.NAV_HOME, vi.fn()))
  272. expect(getLastHotkeyOptions().meta).toEqual({
  273. id: SHORTCUT_IDS.NAV_HOME,
  274. name: SHORTCUT_DEFINITIONS[SHORTCUT_IDS.NAV_HOME].label,
  275. referenceGroup: SHORTCUT_DEFINITIONS[SHORTCUT_IDS.NAV_HOME].referenceGroup,
  276. })
  277. })
  278. it('uses the caller label override in meta.name', () => {
  279. renderHook(() => useShortcut(SHORTCUT_IDS.NAV_HOME, vi.fn(), { label: 'Go home' }))
  280. expect(getLastHotkeyOptions().meta?.name).toBe('Go home')
  281. })
  282. it('keeps a stable meta reference when inputs do not change', () => {
  283. const { rerender } = renderHook(
  284. ({ cb }: { cb: () => void }) => useShortcut(SHORTCUT_IDS.NAV_HOME, cb),
  285. { initialProps: { cb: vi.fn() } }
  286. )
  287. const first = getLastHotkeyOptions().meta
  288. rerender({ cb: vi.fn() })
  289. expect(getLastHotkeyOptions().meta).toBe(first)
  290. })
  291. })
  292. })