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