From 6c44f08a84484257731a0b51f0150b63e5524493 Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Tue, 3 Mar 2026 22:52:15 +0100 Subject: [PATCH 1/4] feat(web): replace existing shortcuts with TanStack Hotkeys --- packages/web/package.json | 2 +- .../src/common/hooks/useKeyboardEvent.test.ts | 141 +++++++++++------- .../web/src/common/hooks/useKeyboardEvent.ts | 124 ++++----------- .../components/Dedication/Dedication.tsx | 2 +- .../hooks/shortcuts/useFocusHotkey.ts | 10 +- .../src/views/Forms/EventForm/EventForm.tsx | 16 +- .../useSomedayFormShortcuts.test.tsx | 34 +++-- .../useSomedayFormShortcuts.ts | 18 +-- yarn.lock | 35 ++++- 9 files changed, 182 insertions(+), 200 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index 05bfc414a..f2618e280 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,6 +19,7 @@ "@react-oauth/google": "^0.7.0", "@reduxjs/toolkit": "^1.6.1", "@svgr/webpack": "^6.2.1", + "@tanstack/react-hotkeys": "^0.3.1", "axios": "^1.2.2", "classnames": "^2.3.1", "css-loader": "^6.3.0", @@ -34,7 +35,6 @@ "react-cmdk": "^1.3.9", "react-datepicker": "^4.2.1", "react-dom": "^18.1.0", - "react-hotkeys-hook": "^4.4.1", "react-modal": "^3.16.1", "react-redux": "^8.1.2", "react-router-dom": "^6.8.1", diff --git a/packages/web/src/common/hooks/useKeyboardEvent.test.ts b/packages/web/src/common/hooks/useKeyboardEvent.test.ts index 10bd78781..2ce21e453 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.test.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.test.ts @@ -1,8 +1,14 @@ -import { fireEvent } from "@testing-library/react"; import { renderHook } from "@web/__tests__/__mocks__/mock.render"; import { useKeyboardEvent } from "@web/common/hooks/useKeyboardEvent"; import { getModifierKey } from "@web/common/utils/shortcut/shortcut.util"; +// Mock TanStack Hotkeys +jest.mock("@tanstack/react-hotkeys", () => ({ + useHotkeys: jest.fn(), +})); + +const mockUseHotkeys = jest.requireMock("@tanstack/react-hotkeys").useHotkeys; + // Mock isEditable jest.mock("@web/views/Day/util/day.shortcut.util", () => ({ isEditable: jest.fn(), @@ -20,7 +26,7 @@ describe("useKeyboardEvent", () => { mockIsEditable.mockReturnValue(false); }); - it("should call handler when key combination matches (keyup default)", () => { + it("should register hotkey with correct key combination (keyup default)", () => { renderHook(() => useKeyboardEvent({ combination: ["a"], @@ -29,13 +35,19 @@ describe("useKeyboardEvent", () => { }), ); - fireEvent.keyDown(window, { key: "a" }); - fireEvent.keyUp(window, { key: "a" }); - - expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockUseHotkeys).toHaveBeenCalledWith( + "a", + expect.any(Function), + expect.objectContaining({ + keydown: false, + keyup: true, + enabled: true, + }), + expect.any(Array), + ); }); - it("should call handler when key combination matches (keydown)", () => { + it("should register hotkey with correct key combination (keydown)", () => { renderHook(() => useKeyboardEvent({ combination: ["a"], @@ -44,24 +56,16 @@ describe("useKeyboardEvent", () => { }), ); - fireEvent.keyDown(window, { key: "a" }); - - expect(mockHandler).toHaveBeenCalledTimes(1); - }); - - it("should not call handler when key combination does not match", () => { - renderHook(() => - useKeyboardEvent({ - combination: ["a"], - handler: mockHandler, - eventType: "keyup", + expect(mockUseHotkeys).toHaveBeenCalledWith( + "a", + expect.any(Function), + expect.objectContaining({ + keydown: true, + keyup: false, + enabled: true, }), + expect.any(Array), ); - - fireEvent.keyDown(window, { key: "b" }); - fireEvent.keyUp(window, { key: "b" }); - - expect(mockHandler).not.toHaveBeenCalled(); }); it("should handle multi-key combinations", () => { @@ -73,62 +77,71 @@ describe("useKeyboardEvent", () => { }), ); - fireEvent.keyDown(window, { key: getModifierKey() }); - fireEvent.keyDown(window, { key: "a", ctrlKey: true }); - fireEvent.keyUp(window, { key: "a", ctrlKey: true }); - - expect(mockHandler).toHaveBeenCalledTimes(1); - }); + const expectedKey = `${getModifierKey().toLowerCase()}+a`.toLowerCase(); - it("should respect exactMatch = true (default)", () => { - renderHook(() => - useKeyboardEvent({ - combination: ["a"], - handler: mockHandler, - exactMatch: true, - eventType: "keyup", + expect(mockUseHotkeys).toHaveBeenCalledWith( + expectedKey, + expect.any(Function), + expect.objectContaining({ + keydown: false, + keyup: true, + enabled: true, }), + expect.any(Array), ); - - fireEvent.keyDown(window, { key: "Shift" }); - fireEvent.keyDown(window, { key: "a", shiftKey: true }); - fireEvent.keyUp(window, { key: "a", shiftKey: true }); - - expect(mockHandler).not.toHaveBeenCalled(); }); - it("should respect exactMatch = false", () => { + it("should call handler when hotkey is triggered and not editing", () => { + mockIsEditable.mockReturnValue(false); + renderHook(() => useKeyboardEvent({ combination: ["a"], handler: mockHandler, - exactMatch: false, eventType: "keyup", }), ); - // ... (comments) + // Get the registered handler + const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const mockEvent = { + preventDefault: jest.fn(), + target: document.createElement("div"), + } as unknown as KeyboardEvent; + + registeredHandler(mockEvent); + + expect(mockHandler).toHaveBeenCalledWith(mockEvent); + expect(mockEvent.preventDefault).toHaveBeenCalled(); }); - it("should not call handler when editing if listenWhileEditing is false (default)", () => { + it("should not call handler when editing if listenWhileEditing is false", () => { mockIsEditable.mockReturnValue(true); renderHook(() => useKeyboardEvent({ combination: ["a"], handler: mockHandler, + listenWhileEditing: false, eventType: "keyup", }), ); - fireEvent.keyDown(window, { key: "a" }); - fireEvent.keyUp(window, { key: "a" }); + // Get the registered handler + const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const mockEvent = { + preventDefault: jest.fn(), + target: document.createElement("input"), + } as unknown as KeyboardEvent; + + registeredHandler(mockEvent); expect(mockHandler).not.toHaveBeenCalled(); }); it("should call handler when editing if listenWhileEditing is true", () => { mockIsEditable.mockReturnValue(true); + const mockBlur = jest.fn(); renderHook(() => useKeyboardEvent({ @@ -139,10 +152,26 @@ describe("useKeyboardEvent", () => { }), ); - fireEvent.keyDown(window, { key: "a" }); - fireEvent.keyUp(window, { key: "a" }); + // Get the registered handler + const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const mockElement = document.createElement("input"); + mockElement.blur = mockBlur; - expect(mockHandler).toHaveBeenCalledTimes(1); + Object.defineProperty(document, "activeElement", { + value: mockElement, + writable: true, + configurable: true, + }); + + const mockEvent = { + preventDefault: jest.fn(), + target: mockElement, + } as unknown as KeyboardEvent; + + registeredHandler(mockEvent); + + expect(mockHandler).toHaveBeenCalledWith(mockEvent); + expect(mockBlur).toHaveBeenCalled(); }); it("should not call handler when the app is locked", () => { @@ -156,8 +185,14 @@ describe("useKeyboardEvent", () => { }), ); - fireEvent.keyDown(window, { key: "a" }); - fireEvent.keyUp(window, { key: "a" }); + // Get the registered handler + const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const mockEvent = { + preventDefault: jest.fn(), + target: document.createElement("div"), + } as unknown as KeyboardEvent; + + registeredHandler(mockEvent); expect(mockHandler).not.toHaveBeenCalled(); diff --git a/packages/web/src/common/hooks/useKeyboardEvent.ts b/packages/web/src/common/hooks/useKeyboardEvent.ts index 47e32b331..c61f319dc 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.ts @@ -1,17 +1,10 @@ -import { type DependencyList, useCallback, useEffect, useMemo } from "react"; -import { filter, map } from "rxjs/operators"; -import { - type KeyCombination, - globalOnKeyPressHandler, - globalOnKeyUpHandler, - keyPressed$, - keyReleased$, -} from "@web/common/utils/dom/event-emitter.util"; +import { type DependencyList } from "react"; +import { useHotkeys } from "@tanstack/react-hotkeys"; import { isEditable } from "@web/views/Day/util/day.shortcut.util"; interface Options { combination: string[]; - handler?: (e: KeyCombination) => void; + handler?: (e: KeyboardEvent) => void; exactMatch?: boolean; listenWhileEditing?: boolean; deps?: DependencyList; @@ -23,7 +16,7 @@ interface Options { * * hook to listen to specific global DOM keyboard events key combination * can be called multiple times in different components - * leverages single root event listener for keydown and keyup events + * uses TanStack Hotkeys for efficient key handling */ export function useKeyboardEvent({ combination, @@ -33,40 +26,17 @@ export function useKeyboardEvent({ deps = [], eventType = "keyup", }: Options) { - const $event = useMemo( - () => - eventType === "keydown" - ? keyPressed$.pipe(filter((keyCombination) => keyCombination !== null)) - : keyReleased$, - [eventType], - ); - - const combinationFilter = useCallback( - ({ sequence }: KeyCombination) => { - if (!exactMatch) { - return ( - [...sequence] - .sort((a, b) => a.localeCompare(b)) - .join("+") - .toLowerCase() === - [...combination] - .sort((a, b) => a.localeCompare(b)) - .join("+") - .toLowerCase() - ); - } + // Convert combination array to TanStack hotkey format + const hotkeyString = combination.join("+").toLowerCase(); - return ( - sequence.join("+").toLowerCase() === combination.join("+").toLowerCase() - ); - }, - [combination, exactMatch], - ); + useHotkeys( + hotkeyString, + (event) => { + if (!handler) return; - const listenFilter = useCallback( - ({ event }: KeyCombination) => { + // Check if app is locked if (document?.body?.dataset?.appLocked === "true") { - return false; + return; } const targetElement = event.target as HTMLElement; @@ -75,78 +45,40 @@ export function useKeyboardEvent({ const eventTargetEditable = isEditable(targetElement); const isInsideEditable = activeElementEditable || eventTargetEditable; + // Handle listenWhileEditing behavior if (listenWhileEditing && isInsideEditable) { if (activeElement) { activeElement?.blur?.(); } else if (targetElement) { targetElement?.blur?.(); } + } else if (!listenWhileEditing && isInsideEditable) { + return; } - return listenWhileEditing ? true : !isInsideEditable; + event.preventDefault(); + handler(event); + }, + { + keydown: eventType === "keydown", + keyup: eventType === "keyup", + enabled: true, }, - [listenWhileEditing], - ); - - const preventDefault = useCallback((combination: KeyCombination) => { - combination.event.preventDefault(); - - return combination; - }, []); - - const resetSequence = useCallback((combination: KeyCombination) => { - const { event, sequence } = combination; - const metaKeys = ["Meta", "Control", "Alt", "Shift"]; - const nextSequence = sequence.filter((key) => metaKeys.includes(key)); - - if (nextSequence.length === 0) { - keyPressed$.next(null); - } else { - keyPressed$.next({ event, sequence: nextSequence }); - } - - return combination; - }, []); - - useEffect(() => { - if (!handler) return; - - const subscription = $event - .pipe(filter(combinationFilter)) - .pipe(filter(listenFilter)) - .pipe(map(preventDefault)) - .pipe(map(resetSequence)) - .subscribe(handler); - - return () => subscription.unsubscribe(); - }, [ - $event, - combinationFilter, - listenFilter, - preventDefault, - resetSequence, - handler, // eslint-disable-next-line react-hooks/exhaustive-deps - ...(deps ?? []), - ]); + [hotkeyString, handler, listenWhileEditing, ...(deps ?? [])], + ); } /** * useSetupKeyboardEvents * * hook to setup global key event listeners - * should only be ideally called once in the app root component + * TanStack Hotkeys handles this automatically, so this is now a no-op + * kept for backward compatibility */ export function useSetupKeyboardEvents() { - useEffect(() => { - window.addEventListener("keydown", globalOnKeyPressHandler); - window.addEventListener("keyup", globalOnKeyUpHandler); - - return () => { - window.removeEventListener("keydown", globalOnKeyPressHandler); - window.removeEventListener("keyup", globalOnKeyUpHandler); - }; - }, []); + // TanStack Hotkeys automatically sets up event listeners + // This function is kept for backward compatibility but does nothing } export function useKeyUpEvent(options: Omit) { diff --git a/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx b/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx index 5edeb79fc..f00ea52ee 100644 --- a/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx +++ b/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import Modal from "react-modal"; +import { useHotkeys } from "@tanstack/react-hotkeys"; import derekImg from "@web/assets/png/derek.png"; import { Flex } from "@web/components/Flex"; import { FlexDirections, JustifyContent } from "@web/components/Flex/styled"; diff --git a/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts b/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts index 2b3eb73ea..48b23ae4d 100644 --- a/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts +++ b/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts @@ -1,13 +1,7 @@ -import { useHotkeys } from "react-hotkeys-hook"; +import { useHotkeys } from "@tanstack/react-hotkeys"; export const useReminderHotkey = ( callback: () => void, dependencies: unknown[] = [], enabled = true, -) => - useHotkeys( - "R", - callback, - { description: "reminder", preventDefault: true, enabled }, - dependencies, - ); +) => useHotkeys("r", callback, { enabled }, dependencies); diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index ef04f4635..f5e43009b 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -2,9 +2,8 @@ import fastDeepEqual from "fast-deep-equal/react"; import { type KeyboardEvent } from "react"; import type React from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { type OptionsOrDependencyArray } from "react-hotkeys-hook/dist/types"; import { Key } from "ts-key-enum"; +import { useHotkeys } from "@tanstack/react-hotkeys"; import { Priorities } from "@core/constants/core.constants"; import { darken } from "@core/util/color.utils"; import dayjs from "@core/util/date/dayjs"; @@ -34,8 +33,8 @@ import { type SetEventFormField, } from "@web/views/Forms/EventForm/types"; -const hotkeysOptions: OptionsOrDependencyArray = { - enableOnFormTags: ["input"], +const hotkeysOptions = { + enabled: true, }; export const EventForm: React.FC> = memo( @@ -261,23 +260,23 @@ export const EventForm: React.FC> = memo( onDuplicate?.(event); }, hotkeysOptions, + [onDuplicate, event], ); useHotkeys( - "mod+enter", + "$mod+enter", (e) => { e.preventDefault(); onSubmitForm(); }, { enabled: isFormOpen, - enableOnFormTags: true, }, [isFormOpen, onSubmitForm], ); useHotkeys( - "ctrl+meta+left", + "ctrl+meta+arrowleft", () => { if (isDraft) { return; @@ -287,9 +286,8 @@ export const EventForm: React.FC> = memo( }, { enabled: isFormOpen, - enableOnFormTags: true, }, - [isFormOpen], + [isFormOpen, onConvert], ); return ( diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx index 2e075684c..571448ae9 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx @@ -7,11 +7,11 @@ import { useSomedayFormShortcuts, } from "@web/views/Forms/SomedayEventForm/useSomedayFormShortcuts"; -jest.mock("react-hotkeys-hook", () => ({ +jest.mock("@tanstack/react-hotkeys", () => ({ useHotkeys: jest.fn(), })); -const { useHotkeys } = jest.requireMock("react-hotkeys-hook"); +const { useHotkeys } = jest.requireMock("@tanstack/react-hotkeys"); const TestComponent = (props: SomedayFormShortcutsProps) => { useSomedayFormShortcuts(props); @@ -37,7 +37,7 @@ describe("SomedayEventForm shortcuts hook", () => { }); const getHotkeyHandler = (combo: string) => { - const call = useHotkeys.mock.calls.find(([registeredCombo]) => { + const call = useHotkeys.mock.calls.find(([registeredCombo]: [string]) => { return registeredCombo === combo; }); if (!call) { @@ -49,22 +49,26 @@ describe("SomedayEventForm shortcuts hook", () => { test("registers all expected shortcuts with shared options", () => { render(); - const registeredCombos = useHotkeys.mock.calls.map(([combo]) => combo); + const registeredCombos = useHotkeys.mock.calls.map( + ([combo]: [string]) => combo, + ); expect(registeredCombos).toEqual([ "delete", "enter", - "mod+enter", + "$mod+enter", "meta+d", - "ctrl+meta+up", - "ctrl+meta+down", - "ctrl+meta+right", - "ctrl+meta+left", + "ctrl+meta+arrowup", + "ctrl+meta+arrowdown", + "ctrl+meta+arrowright", + "ctrl+meta+arrowleft", ]); - useHotkeys.mock.calls.forEach(([, , options]) => { - expect(options).toBe(SOMEDAY_HOTKEY_OPTIONS); - }); + useHotkeys.mock.calls.forEach( + ([, , options]: [string, Function, unknown]) => { + expect(options).toBe(SOMEDAY_HOTKEY_OPTIONS); + }, + ); }); test("directional shortcuts prevent propagation and call onMigrate", () => { @@ -75,7 +79,7 @@ describe("SomedayEventForm shortcuts hook", () => { stopPropagation: jest.fn(), } as unknown as KeyboardEvent; - const upHandler = getHotkeyHandler("ctrl+meta+up"); + const upHandler = getHotkeyHandler("ctrl+meta+arrowup"); upHandler(keyboardEvent); expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1); @@ -112,7 +116,7 @@ describe("SomedayEventForm shortcuts hook", () => { target: document.createElement("input"), } as unknown as KeyboardEvent; - const handler = getHotkeyHandler("mod+enter"); + const handler = getHotkeyHandler("$mod+enter"); handler(keyboardEvent); expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); @@ -130,7 +134,7 @@ describe("SomedayEventForm shortcuts hook", () => { target: menuButton, } as unknown as KeyboardEvent; - const handler = getHotkeyHandler("mod+enter"); + const handler = getHotkeyHandler("$mod+enter"); handler(keyboardEvent); expect(defaultProps.onSubmit).toHaveBeenCalled(); diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts index 6948bdc30..0635e9f3a 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts @@ -1,5 +1,4 @@ -import { useHotkeys } from "react-hotkeys-hook"; -import { type OptionsOrDependencyArray } from "react-hotkeys-hook/dist/types"; +import { useHotkeys } from "@tanstack/react-hotkeys"; import { type Categories_Event, type Direction_Migrate, @@ -7,11 +6,8 @@ import { } from "@core/types/event.types"; import { isComboboxInteraction } from "@web/common/utils/form/form.util"; -export const SOMEDAY_HOTKEY_OPTIONS: OptionsOrDependencyArray = { - enableOnFormTags: ["input"], - enableOnContentEditable: true, +export const SOMEDAY_HOTKEY_OPTIONS = { enabled: true, - eventListenerOptions: { capture: true }, }; export interface SomedayFormShortcutsProps { @@ -96,7 +92,7 @@ export const useSomedayFormShortcuts = ({ [onSubmit], ); useHotkeys( - "mod+enter", + "$mod+enter", (keyboardEvent) => { keyboardEvent.preventDefault(); keyboardEvent.stopPropagation(); @@ -114,28 +110,28 @@ export const useSomedayFormShortcuts = ({ ); useHotkeys( - "ctrl+meta+up", + "ctrl+meta+arrowup", handleMigration("up", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); useHotkeys( - "ctrl+meta+down", + "ctrl+meta+arrowdown", handleMigration("down", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); useHotkeys( - "ctrl+meta+right", + "ctrl+meta+arrowright", handleMigration("forward", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); useHotkeys( - "ctrl+meta+left", + "ctrl+meta+arrowleft", handleMigration("back", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], diff --git a/yarn.lock b/yarn.lock index 2f33416c8..b27ce859d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2406,6 +2406,29 @@ postcss "^8.4.41" tailwindcss "4.1.14" +"@tanstack/hotkeys@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@tanstack/hotkeys/-/hotkeys-0.3.1.tgz#1b48de8196242a1fc2e90f900dc4c53af31ac641" + integrity sha512-G+v+PUac8ff/jg752yhhfPqw7/0xr8RYKL69Jb4i7L1JCPKwwoO4aLqVSmI17WSDN6sh1+nNf8QzUOphuPLAuw== + dependencies: + "@tanstack/store" "^0.9.1" + +"@tanstack/react-hotkeys@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-hotkeys/-/react-hotkeys-0.3.1.tgz#26c8deee1e71092ae3184cbe63bbb031f27f83e3" + integrity sha512-QXTIwVy4mdLZD0Fz501hzw85aNEI9522kLnxF85Bgyr6iKSHYq5L6ChBsBVx28d4zbyEiDQ6HHf96/qzIcNtOg== + dependencies: + "@tanstack/hotkeys" "0.3.1" + "@tanstack/react-store" "^0.9.1" + +"@tanstack/react-store@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@tanstack/react-store/-/react-store-0.9.1.tgz#e9952273d908b8eddb3f496dba790dd7e70fce98" + integrity sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA== + dependencies: + "@tanstack/store" "0.9.1" + use-sync-external-store "^1.6.0" + "@tanstack/react-virtual@^3.0.0-beta.60": version "3.13.12" resolved "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz" @@ -2413,6 +2436,11 @@ dependencies: "@tanstack/virtual-core" "3.13.12" +"@tanstack/store@0.9.1", "@tanstack/store@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.9.1.tgz#965130b17e655385a38b963d82446bcdaf41e878" + integrity sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg== + "@tanstack/virtual-core@3.13.12": version "3.13.12" resolved "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz" @@ -9223,11 +9251,6 @@ react-fast-compare@^3.0.1: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== -react-hotkeys-hook@^4.4.1: - version "4.6.2" - resolved "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.6.2.tgz" - integrity sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q== - react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -11080,7 +11103,7 @@ use-memo-one@^1.1.3: resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== -use-sync-external-store@^1.0.0: +use-sync-external-store@^1.0.0, use-sync-external-store@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== From c8e43351ef2134d5a3dfcae315a001ef7ff6380a Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Tue, 3 Mar 2026 23:14:39 +0100 Subject: [PATCH 2/4] fix(web) add key normalization --- .../src/common/hooks/useKeyboardEvent.test.ts | 4 ++- .../web/src/common/hooks/useKeyboardEvent.ts | 25 ++++++++++++++++++- .../hooks/shortcuts/useFocusHotkey.ts | 13 +++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/web/src/common/hooks/useKeyboardEvent.test.ts b/packages/web/src/common/hooks/useKeyboardEvent.test.ts index 2ce21e453..b5f1236ea 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.test.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.test.ts @@ -77,7 +77,9 @@ describe("useKeyboardEvent", () => { }), ); - const expectedKey = `${getModifierKey().toLowerCase()}+a`.toLowerCase(); + // getModifierKey() returns "Control" or "Meta", which normalizes to "ctrl" or "meta" + const modifierKey = getModifierKey(); + const expectedKey = modifierKey === "Meta" ? "meta+a" : "ctrl+a"; expect(mockUseHotkeys).toHaveBeenCalledWith( expectedKey, diff --git a/packages/web/src/common/hooks/useKeyboardEvent.ts b/packages/web/src/common/hooks/useKeyboardEvent.ts index c61f319dc..313dc82e5 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.ts @@ -11,6 +11,28 @@ interface Options { eventType: "keydown" | "keyup"; } +/** + * Normalize KeyboardEvent.key values to TanStack Hotkeys format + */ +function normalizeKey(key: string): string { + const keyMap: Record = { + Control: "ctrl", + Meta: "meta", + Alt: "alt", + Shift: "shift", + Escape: "esc", + ArrowUp: "arrowup", + ArrowDown: "arrowdown", + ArrowLeft: "arrowleft", + ArrowRight: "arrowright", + Delete: "delete", + Backspace: "backspace", + Enter: "enter", + }; + + return keyMap[key] ?? key.toLowerCase(); +} + /** * useKeyboardEvent * @@ -27,7 +49,8 @@ export function useKeyboardEvent({ eventType = "keyup", }: Options) { // Convert combination array to TanStack hotkey format - const hotkeyString = combination.join("+").toLowerCase(); + // Normalize keys like "Control" -> "ctrl", "ArrowRight" -> "arrowright" + const hotkeyString = combination.map(normalizeKey).join("+"); useHotkeys( hotkeyString, diff --git a/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts b/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts index 48b23ae4d..44333b036 100644 --- a/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts +++ b/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts @@ -4,4 +4,15 @@ export const useReminderHotkey = ( callback: () => void, dependencies: unknown[] = [], enabled = true, -) => useHotkeys("r", callback, { enabled }, dependencies); +) => + useHotkeys( + "r", + (event) => { + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + callback(); + }, + { enabled }, + dependencies, + ); From 44c00c56cdd6b6fc3ec64d638450a474e07e2917 Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Thu, 5 Mar 2026 12:19:27 +0100 Subject: [PATCH 3/4] fix: update hotkeys functionality --- e2e/utils/event-test-utils.ts | 4 +- .../src/common/hooks/useKeyboardEvent.test.ts | 18 ++--- .../web/src/common/hooks/useKeyboardEvent.ts | 4 +- .../CompassProvider/CompassProvider.tsx | 71 ++++++++++--------- .../components/Dedication/Dedication.tsx | 4 +- .../hooks/shortcuts/useFocusHotkey.ts | 4 +- .../src/views/Forms/EventForm/EventForm.tsx | 12 ++-- .../useSomedayFormShortcuts.test.tsx | 10 +-- .../useSomedayFormShortcuts.ts | 18 ++--- 9 files changed, 74 insertions(+), 71 deletions(-) diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index 714766a0c..32abb7254 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -13,7 +13,7 @@ const FORM_TIMEOUT = 10000; */ const pressShortcut = async (page: Page, key: string) => { await page.evaluate((shortcut) => { - window.dispatchEvent( + document.dispatchEvent( new KeyboardEvent("keydown", { key: shortcut, bubbles: true, @@ -21,7 +21,7 @@ const pressShortcut = async (page: Page, key: string) => { composed: true, }), ); - window.dispatchEvent( + document.dispatchEvent( new KeyboardEvent("keyup", { key: shortcut, bubbles: true, diff --git a/packages/web/src/common/hooks/useKeyboardEvent.test.ts b/packages/web/src/common/hooks/useKeyboardEvent.test.ts index b5f1236ea..feac6f985 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.test.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.test.ts @@ -4,10 +4,10 @@ import { getModifierKey } from "@web/common/utils/shortcut/shortcut.util"; // Mock TanStack Hotkeys jest.mock("@tanstack/react-hotkeys", () => ({ - useHotkeys: jest.fn(), + useHotkey: jest.fn(), })); -const mockUseHotkeys = jest.requireMock("@tanstack/react-hotkeys").useHotkeys; +const mockUseHotkey = jest.requireMock("@tanstack/react-hotkeys").useHotkey; // Mock isEditable jest.mock("@web/views/Day/util/day.shortcut.util", () => ({ @@ -35,7 +35,7 @@ describe("useKeyboardEvent", () => { }), ); - expect(mockUseHotkeys).toHaveBeenCalledWith( + expect(mockUseHotkey).toHaveBeenCalledWith( "a", expect.any(Function), expect.objectContaining({ @@ -56,7 +56,7 @@ describe("useKeyboardEvent", () => { }), ); - expect(mockUseHotkeys).toHaveBeenCalledWith( + expect(mockUseHotkey).toHaveBeenCalledWith( "a", expect.any(Function), expect.objectContaining({ @@ -81,7 +81,7 @@ describe("useKeyboardEvent", () => { const modifierKey = getModifierKey(); const expectedKey = modifierKey === "Meta" ? "meta+a" : "ctrl+a"; - expect(mockUseHotkeys).toHaveBeenCalledWith( + expect(mockUseHotkey).toHaveBeenCalledWith( expectedKey, expect.any(Function), expect.objectContaining({ @@ -105,7 +105,7 @@ describe("useKeyboardEvent", () => { ); // Get the registered handler - const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const registeredHandler = mockUseHotkey.mock.calls[0][1]; const mockEvent = { preventDefault: jest.fn(), target: document.createElement("div"), @@ -130,7 +130,7 @@ describe("useKeyboardEvent", () => { ); // Get the registered handler - const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const registeredHandler = mockUseHotkey.mock.calls[0][1]; const mockEvent = { preventDefault: jest.fn(), target: document.createElement("input"), @@ -155,7 +155,7 @@ describe("useKeyboardEvent", () => { ); // Get the registered handler - const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const registeredHandler = mockUseHotkey.mock.calls[0][1]; const mockElement = document.createElement("input"); mockElement.blur = mockBlur; @@ -188,7 +188,7 @@ describe("useKeyboardEvent", () => { ); // Get the registered handler - const registeredHandler = mockUseHotkeys.mock.calls[0][1]; + const registeredHandler = mockUseHotkey.mock.calls[0][1]; const mockEvent = { preventDefault: jest.fn(), target: document.createElement("div"), diff --git a/packages/web/src/common/hooks/useKeyboardEvent.ts b/packages/web/src/common/hooks/useKeyboardEvent.ts index 313dc82e5..b7ea7ed21 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.ts @@ -1,5 +1,5 @@ import { type DependencyList } from "react"; -import { useHotkeys } from "@tanstack/react-hotkeys"; +import { useHotkey } from "@tanstack/react-hotkeys"; import { isEditable } from "@web/views/Day/util/day.shortcut.util"; interface Options { @@ -52,7 +52,7 @@ export function useKeyboardEvent({ // Normalize keys like "Control" -> "ctrl", "ArrowRight" -> "arrowright" const hotkeyString = combination.map(normalizeKey).join("+"); - useHotkeys( + useHotkey( hotkeyString, (event) => { if (!handler) return; diff --git a/packages/web/src/components/CompassProvider/CompassProvider.tsx b/packages/web/src/components/CompassProvider/CompassProvider.tsx index c7edff969..247f4f77d 100644 --- a/packages/web/src/components/CompassProvider/CompassProvider.tsx +++ b/packages/web/src/components/CompassProvider/CompassProvider.tsx @@ -4,6 +4,7 @@ import { Provider } from "react-redux"; import { ToastContainer } from "react-toastify"; import { ThemeProvider } from "styled-components"; import { GoogleOAuthProvider } from "@react-oauth/google"; +import { HotkeysProvider } from "@tanstack/react-hotkeys"; import { SessionProvider } from "@web/auth/session/SessionProvider"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { CompassRefsProvider } from "@web/common/context/compass-refs"; @@ -23,42 +24,44 @@ function isPosthogEnabled() { export const CompassRequiredProviders = ( props: PropsWithChildren<{ store?: typeof store }>, ) => ( - - - - - - - - - - {props.children} - - - + + + + + + + + + + + {props.children} + + + - + - - - - - - - - + + + + + + + + + ); export const CompassOptionalProviders = ({ children }: PropsWithChildren) => { diff --git a/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx b/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx index f00ea52ee..6935cebaa 100644 --- a/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx +++ b/packages/web/src/views/Calendar/components/Dedication/Dedication.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import Modal from "react-modal"; -import { useHotkeys } from "@tanstack/react-hotkeys"; +import { useHotkey } from "@tanstack/react-hotkeys"; import derekImg from "@web/assets/png/derek.png"; import { Flex } from "@web/components/Flex"; import { FlexDirections, JustifyContent } from "@web/components/Flex/styled"; @@ -17,7 +17,7 @@ import { export const Dedication = () => { const [isOpen, setIsOpen] = useState(false); - useHotkeys("ctrl+shift+0", () => { + useHotkey("ctrl+shift+0", () => { setIsOpen(!isOpen); }); diff --git a/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts b/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts index 44333b036..7a21854da 100644 --- a/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts +++ b/packages/web/src/views/Calendar/hooks/shortcuts/useFocusHotkey.ts @@ -1,11 +1,11 @@ -import { useHotkeys } from "@tanstack/react-hotkeys"; +import { useHotkey } from "@tanstack/react-hotkeys"; export const useReminderHotkey = ( callback: () => void, dependencies: unknown[] = [], enabled = true, ) => - useHotkeys( + useHotkey( "r", (event) => { if (event && typeof event.preventDefault === "function") { diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index f5e43009b..fb4f46c32 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -3,7 +3,7 @@ import { type KeyboardEvent } from "react"; import type React from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Key } from "ts-key-enum"; -import { useHotkeys } from "@tanstack/react-hotkeys"; +import { useHotkey } from "@tanstack/react-hotkeys"; import { Priorities } from "@core/constants/core.constants"; import { darken } from "@core/util/color.utils"; import dayjs from "@core/util/date/dayjs"; @@ -227,7 +227,7 @@ export const EventForm: React.FC> = memo( setEvent, }; - useHotkeys( + useHotkey( "delete", () => { if (isDraft) { @@ -241,7 +241,7 @@ export const EventForm: React.FC> = memo( [onDelete], ); - useHotkeys( + useHotkey( "enter", (keyboardEvent) => { if (isComboboxInteraction(keyboardEvent)) { @@ -254,7 +254,7 @@ export const EventForm: React.FC> = memo( [onSubmitForm], ); - useHotkeys( + useHotkey( "meta+d", () => { onDuplicate?.(event); @@ -263,7 +263,7 @@ export const EventForm: React.FC> = memo( [onDuplicate, event], ); - useHotkeys( + useHotkey( "$mod+enter", (e) => { e.preventDefault(); @@ -275,7 +275,7 @@ export const EventForm: React.FC> = memo( [isFormOpen, onSubmitForm], ); - useHotkeys( + useHotkey( "ctrl+meta+arrowleft", () => { if (isDraft) { diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx index 571448ae9..469ff718c 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx @@ -8,10 +8,10 @@ import { } from "@web/views/Forms/SomedayEventForm/useSomedayFormShortcuts"; jest.mock("@tanstack/react-hotkeys", () => ({ - useHotkeys: jest.fn(), + useHotkey: jest.fn(), })); -const { useHotkeys } = jest.requireMock("@tanstack/react-hotkeys"); +const { useHotkey } = jest.requireMock("@tanstack/react-hotkeys"); const TestComponent = (props: SomedayFormShortcutsProps) => { useSomedayFormShortcuts(props); @@ -37,7 +37,7 @@ describe("SomedayEventForm shortcuts hook", () => { }); const getHotkeyHandler = (combo: string) => { - const call = useHotkeys.mock.calls.find(([registeredCombo]: [string]) => { + const call = useHotkey.mock.calls.find(([registeredCombo]: [string]) => { return registeredCombo === combo; }); if (!call) { @@ -49,7 +49,7 @@ describe("SomedayEventForm shortcuts hook", () => { test("registers all expected shortcuts with shared options", () => { render(); - const registeredCombos = useHotkeys.mock.calls.map( + const registeredCombos = useHotkey.mock.calls.map( ([combo]: [string]) => combo, ); @@ -64,7 +64,7 @@ describe("SomedayEventForm shortcuts hook", () => { "ctrl+meta+arrowleft", ]); - useHotkeys.mock.calls.forEach( + useHotkey.mock.calls.forEach( ([, , options]: [string, Function, unknown]) => { expect(options).toBe(SOMEDAY_HOTKEY_OPTIONS); }, diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts index 0635e9f3a..6bd0b630d 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts @@ -1,4 +1,4 @@ -import { useHotkeys } from "@tanstack/react-hotkeys"; +import { useHotkey } from "@tanstack/react-hotkeys"; import { type Categories_Event, type Direction_Migrate, @@ -67,13 +67,13 @@ export const useSomedayFormShortcuts = ({ onDuplicate, onMigrate, }: SomedayFormShortcutsProps) => { - useHotkeys( + useHotkey( "delete", stopPropagationWrapper(onDelete), SOMEDAY_HOTKEY_OPTIONS, [onDelete], ); - useHotkeys( + useHotkey( "enter", (keyboardEvent) => { if ( @@ -91,7 +91,7 @@ export const useSomedayFormShortcuts = ({ SOMEDAY_HOTKEY_OPTIONS, [onSubmit], ); - useHotkeys( + useHotkey( "$mod+enter", (keyboardEvent) => { keyboardEvent.preventDefault(); @@ -102,35 +102,35 @@ export const useSomedayFormShortcuts = ({ [onSubmit], ); - useHotkeys( + useHotkey( "meta+d", stopPropagationWrapper(onDuplicate), SOMEDAY_HOTKEY_OPTIONS, [onDuplicate], ); - useHotkeys( + useHotkey( "ctrl+meta+arrowup", handleMigration("up", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); - useHotkeys( + useHotkey( "ctrl+meta+arrowdown", handleMigration("down", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); - useHotkeys( + useHotkey( "ctrl+meta+arrowright", handleMigration("forward", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); - useHotkeys( + useHotkey( "ctrl+meta+arrowleft", handleMigration("back", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, From e5669745a65959237b76c94f8efbaf025ff2b041 Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Thu, 5 Mar 2026 12:57:03 +0100 Subject: [PATCH 4/4] fix: update tests to use real events --- e2e/utils/event-test-utils.ts | 2 +- .../src/common/hooks/useKeyboardEvent.test.ts | 181 ++++++-------- .../web/src/common/hooks/useKeyboardEvent.ts | 2 - .../common/utils/dom/event-emitter.util.ts | 2 +- .../web/src/common/utils/form/form.util.ts | 2 +- .../hooks/shortcuts/useDayViewShortcuts.ts | 6 +- .../useSomedayFormShortcuts.test.tsx | 227 ++++++++++-------- .../useSomedayFormShortcuts.ts | 11 +- 8 files changed, 219 insertions(+), 214 deletions(-) diff --git a/e2e/utils/event-test-utils.ts b/e2e/utils/event-test-utils.ts index 32abb7254..9475435ea 100644 --- a/e2e/utils/event-test-utils.ts +++ b/e2e/utils/event-test-utils.ts @@ -8,7 +8,7 @@ const LOCAL_DB_NAME = "compass-local"; const FORM_TIMEOUT = 10000; /** - * Dispatch a keyboard shortcut to the window. + * Dispatch a keyboard shortcut to the document. * Uses the same event properties as the app's internal pressKey utility. */ const pressShortcut = async (page: Page, key: string) => { diff --git a/packages/web/src/common/hooks/useKeyboardEvent.test.ts b/packages/web/src/common/hooks/useKeyboardEvent.test.ts index feac6f985..16f355f99 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.test.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.test.ts @@ -1,14 +1,7 @@ -import { renderHook } from "@web/__tests__/__mocks__/mock.render"; +import { renderHook, waitFor } from "@web/__tests__/__mocks__/mock.render"; import { useKeyboardEvent } from "@web/common/hooks/useKeyboardEvent"; import { getModifierKey } from "@web/common/utils/shortcut/shortcut.util"; -// Mock TanStack Hotkeys -jest.mock("@tanstack/react-hotkeys", () => ({ - useHotkey: jest.fn(), -})); - -const mockUseHotkey = jest.requireMock("@tanstack/react-hotkeys").useHotkey; - // Mock isEditable jest.mock("@web/views/Day/util/day.shortcut.util", () => ({ isEditable: jest.fn(), @@ -18,15 +11,34 @@ const mockIsEditable = jest.requireMock( "@web/views/Day/util/day.shortcut.util", ).isEditable; +/** + * Helper function to dispatch a keyboard event to the document + */ +function dispatchKeyEvent( + key: string, + type: "keydown" | "keyup", + options: KeyboardEventInit = {}, +) { + const event = new KeyboardEvent(type, { + key, + bubbles: true, + cancelable: true, + composed: true, + ...options, + }); + document.dispatchEvent(event); +} + describe("useKeyboardEvent", () => { const mockHandler = jest.fn(); beforeEach(() => { jest.clearAllMocks(); mockIsEditable.mockReturnValue(false); + document.body.removeAttribute("data-app-locked"); }); - it("should register hotkey with correct key combination (keyup default)", () => { + it("should call handler when key is pressed (keyup)", async () => { renderHook(() => useKeyboardEvent({ combination: ["a"], @@ -35,19 +47,16 @@ describe("useKeyboardEvent", () => { }), ); - expect(mockUseHotkey).toHaveBeenCalledWith( - "a", - expect.any(Function), - expect.objectContaining({ - keydown: false, - keyup: true, - enabled: true, - }), - expect.any(Array), - ); + dispatchKeyEvent("a", "keydown"); + dispatchKeyEvent("a", "keyup"); + + await waitFor(() => { + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(expect.any(KeyboardEvent)); + }); }); - it("should register hotkey with correct key combination (keydown)", () => { + it("should call handler when key is pressed (keydown)", async () => { renderHook(() => useKeyboardEvent({ combination: ["a"], @@ -56,68 +65,47 @@ describe("useKeyboardEvent", () => { }), ); - expect(mockUseHotkey).toHaveBeenCalledWith( - "a", - expect.any(Function), - expect.objectContaining({ - keydown: true, - keyup: false, - enabled: true, - }), - expect.any(Array), - ); - }); + dispatchKeyEvent("a", "keydown"); - it("should handle multi-key combinations", () => { - renderHook(() => - useKeyboardEvent({ - combination: [getModifierKey(), "a"], - handler: mockHandler, - eventType: "keyup", - }), - ); - - // getModifierKey() returns "Control" or "Meta", which normalizes to "ctrl" or "meta" - const modifierKey = getModifierKey(); - const expectedKey = modifierKey === "Meta" ? "meta+a" : "ctrl+a"; - - expect(mockUseHotkey).toHaveBeenCalledWith( - expectedKey, - expect.any(Function), - expect.objectContaining({ - keydown: false, - keyup: true, - enabled: true, - }), - expect.any(Array), - ); + await waitFor(() => { + expect(mockHandler).toHaveBeenCalledTimes(1); + }); }); - it("should call handler when hotkey is triggered and not editing", () => { - mockIsEditable.mockReturnValue(false); + it("should handle multi-key combinations with modifier keys", async () => { + const modifierKey = getModifierKey(); + const isCtrl = modifierKey === "Control"; renderHook(() => useKeyboardEvent({ - combination: ["a"], + combination: [modifierKey, "a"], handler: mockHandler, eventType: "keyup", }), ); - // Get the registered handler - const registeredHandler = mockUseHotkey.mock.calls[0][1]; - const mockEvent = { - preventDefault: jest.fn(), - target: document.createElement("div"), - } as unknown as KeyboardEvent; + // Press modifier key first + dispatchKeyEvent(modifierKey, "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - registeredHandler(mockEvent); + // Then press 'a' while holding modifier + dispatchKeyEvent("a", "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); + dispatchKeyEvent("a", "keyup", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - expect(mockHandler).toHaveBeenCalledWith(mockEvent); - expect(mockEvent.preventDefault).toHaveBeenCalled(); + await waitFor(() => { + expect(mockHandler).toHaveBeenCalled(); + }); }); - it("should not call handler when editing if listenWhileEditing is false", () => { + it("should not call handler when editing if listenWhileEditing is false", async () => { mockIsEditable.mockReturnValue(true); renderHook(() => @@ -129,21 +117,26 @@ describe("useKeyboardEvent", () => { }), ); - // Get the registered handler - const registeredHandler = mockUseHotkey.mock.calls[0][1]; - const mockEvent = { - preventDefault: jest.fn(), - target: document.createElement("input"), - } as unknown as KeyboardEvent; + dispatchKeyEvent("a", "keydown"); + dispatchKeyEvent("a", "keyup"); - registeredHandler(mockEvent); + // Wait a bit to ensure handler is not called + await new Promise((resolve) => setTimeout(resolve, 100)); expect(mockHandler).not.toHaveBeenCalled(); }); - it("should call handler when editing if listenWhileEditing is true", () => { + it("should call handler and blur element when editing if listenWhileEditing is true", async () => { mockIsEditable.mockReturnValue(true); const mockBlur = jest.fn(); + const mockElement = document.createElement("input"); + mockElement.blur = mockBlur; + + Object.defineProperty(document, "activeElement", { + value: mockElement, + writable: true, + configurable: true, + }); renderHook(() => useKeyboardEvent({ @@ -154,29 +147,16 @@ describe("useKeyboardEvent", () => { }), ); - // Get the registered handler - const registeredHandler = mockUseHotkey.mock.calls[0][1]; - const mockElement = document.createElement("input"); - mockElement.blur = mockBlur; + dispatchKeyEvent("a", "keydown"); + dispatchKeyEvent("a", "keyup"); - Object.defineProperty(document, "activeElement", { - value: mockElement, - writable: true, - configurable: true, + await waitFor(() => { + expect(mockHandler).toHaveBeenCalled(); + expect(mockBlur).toHaveBeenCalled(); }); - - const mockEvent = { - preventDefault: jest.fn(), - target: mockElement, - } as unknown as KeyboardEvent; - - registeredHandler(mockEvent); - - expect(mockHandler).toHaveBeenCalledWith(mockEvent); - expect(mockBlur).toHaveBeenCalled(); }); - it("should not call handler when the app is locked", () => { + it("should not call handler when the app is locked", async () => { document.body.setAttribute("data-app-locked", "true"); renderHook(() => @@ -187,17 +167,12 @@ describe("useKeyboardEvent", () => { }), ); - // Get the registered handler - const registeredHandler = mockUseHotkey.mock.calls[0][1]; - const mockEvent = { - preventDefault: jest.fn(), - target: document.createElement("div"), - } as unknown as KeyboardEvent; + dispatchKeyEvent("a", "keydown"); + dispatchKeyEvent("a", "keyup"); - registeredHandler(mockEvent); + // Wait a bit to ensure handler is not called + await new Promise((resolve) => setTimeout(resolve, 100)); expect(mockHandler).not.toHaveBeenCalled(); - - document.body.removeAttribute("data-app-locked"); }); }); diff --git a/packages/web/src/common/hooks/useKeyboardEvent.ts b/packages/web/src/common/hooks/useKeyboardEvent.ts index b7ea7ed21..3e8f6e86a 100644 --- a/packages/web/src/common/hooks/useKeyboardEvent.ts +++ b/packages/web/src/common/hooks/useKeyboardEvent.ts @@ -5,7 +5,6 @@ import { isEditable } from "@web/views/Day/util/day.shortcut.util"; interface Options { combination: string[]; handler?: (e: KeyboardEvent) => void; - exactMatch?: boolean; listenWhileEditing?: boolean; deps?: DependencyList; eventType: "keydown" | "keyup"; @@ -42,7 +41,6 @@ function normalizeKey(key: string): string { */ export function useKeyboardEvent({ combination, - exactMatch = true, handler, listenWhileEditing, deps = [], diff --git a/packages/web/src/common/utils/dom/event-emitter.util.ts b/packages/web/src/common/utils/dom/event-emitter.util.ts index 7243efdf6..a0ee01143 100644 --- a/packages/web/src/common/utils/dom/event-emitter.util.ts +++ b/packages/web/src/common/utils/dom/event-emitter.util.ts @@ -137,7 +137,7 @@ export function pressKey( keyUpInit = {}, keyDownInit = {}, }: { keyUpInit?: KeyboardEventInit; keyDownInit?: KeyboardEventInit } = {}, - target: Element | Node | Window | Document = window, + target: Element | Node | Window | Document = document, ) { target.dispatchEvent( new KeyboardEvent("keydown", { ...keyDownInit, key, composed: true }), diff --git a/packages/web/src/common/utils/form/form.util.ts b/packages/web/src/common/utils/form/form.util.ts index 1c5a9aef4..59232dad0 100644 --- a/packages/web/src/common/utils/form/form.util.ts +++ b/packages/web/src/common/utils/form/form.util.ts @@ -18,7 +18,7 @@ export const isComboboxInteraction = ( ) => { const target = keyboardEvent.target as HTMLElement | null; - if (!target) { + if (!target || !(target instanceof HTMLElement)) { return false; } diff --git a/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts b/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts index 198d6c814..37b3782c1 100644 --- a/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts +++ b/packages/web/src/views/Day/hooks/shortcuts/useDayViewShortcuts.ts @@ -146,15 +146,13 @@ export function useDayViewShortcuts(config: KeyboardShortcutsConfig) { }); useKeyDownEvent({ - combination: ["Control", "Meta", "ArrowRight"], - exactMatch: false, + combination: [getModifierKey(), "ArrowRight"], listenWhileEditing: true, handler: handleMigrationNavigation("forward"), }); useKeyDownEvent({ - combination: ["Control", "Meta", "ArrowLeft"], - exactMatch: false, + combination: [getModifierKey(), "ArrowLeft"], listenWhileEditing: true, handler: handleMigrationNavigation("backward"), }); diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx index 469ff718c..9ebdeb898 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.test.tsx @@ -1,17 +1,29 @@ -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import { Categories_Event } from "@core/types/event.types"; import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; +import { getModifierKey } from "@web/common/utils/shortcut/shortcut.util"; import { - SOMEDAY_HOTKEY_OPTIONS, type SomedayFormShortcutsProps, useSomedayFormShortcuts, } from "@web/views/Forms/SomedayEventForm/useSomedayFormShortcuts"; -jest.mock("@tanstack/react-hotkeys", () => ({ - useHotkey: jest.fn(), -})); - -const { useHotkey } = jest.requireMock("@tanstack/react-hotkeys"); +/** + * Helper function to dispatch a keyboard event to the document + */ +function dispatchKeyEvent( + key: string, + type: "keydown" | "keyup", + options: KeyboardEventInit = {}, +) { + const event = new KeyboardEvent(type, { + key, + bubbles: true, + cancelable: true, + composed: true, + ...options, + }); + document.dispatchEvent(event); +} const TestComponent = (props: SomedayFormShortcutsProps) => { useSomedayFormShortcuts(props); @@ -36,127 +48,148 @@ describe("SomedayEventForm shortcuts hook", () => { jest.clearAllMocks(); }); - const getHotkeyHandler = (combo: string) => { - const call = useHotkey.mock.calls.find(([registeredCombo]: [string]) => { - return registeredCombo === combo; + test("delete shortcut calls onDelete", async () => { + render(); + + dispatchKeyEvent("Delete", "keydown"); + dispatchKeyEvent("Delete", "keyup"); + + await waitFor(() => { + expect(defaultProps.onDelete).toHaveBeenCalled(); }); - if (!call) { - throw new Error(`Hotkey ${combo} was not registered`); - } - return call[1] as (keyboardEvent: KeyboardEvent) => void; - }; + }); - test("registers all expected shortcuts with shared options", () => { + test("enter shortcut calls onSubmit", async () => { render(); - const registeredCombos = useHotkey.mock.calls.map( - ([combo]: [string]) => combo, - ); - - expect(registeredCombos).toEqual([ - "delete", - "enter", - "$mod+enter", - "meta+d", - "ctrl+meta+arrowup", - "ctrl+meta+arrowdown", - "ctrl+meta+arrowright", - "ctrl+meta+arrowleft", - ]); - - useHotkey.mock.calls.forEach( - ([, , options]: [string, Function, unknown]) => { - expect(options).toBe(SOMEDAY_HOTKEY_OPTIONS); - }, - ); + dispatchKeyEvent("Enter", "keydown"); + dispatchKeyEvent("Enter", "keyup"); + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalled(); + }); }); - test("directional shortcuts prevent propagation and call onMigrate", () => { + test("meta+d shortcut calls onDuplicate", async () => { render(); - const keyboardEvent = { - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as unknown as KeyboardEvent; - - const upHandler = getHotkeyHandler("ctrl+meta+arrowup"); - upHandler(keyboardEvent); - - expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1); - expect(keyboardEvent.stopPropagation).toHaveBeenCalledTimes(1); - expect(defaultProps.onMigrate).toHaveBeenCalledWith( - defaultProps.event, - defaultProps.category, - "up", - ); + // Press Meta key + dispatchKeyEvent("Meta", "keydown", { metaKey: true }); + + // Press 'd' while holding Meta + dispatchKeyEvent("d", "keydown", { metaKey: true }); + dispatchKeyEvent("d", "keyup", { metaKey: true }); + + await waitFor(() => { + expect(defaultProps.onDuplicate).toHaveBeenCalled(); + }); }); - test("duplicate shortcut prevents propagation and calls onDuplicate", () => { + test("ctrl+meta+arrowup calls onMigrate with 'up'", async () => { + const modifierKey = getModifierKey(); + const isCtrl = modifierKey === "Control"; + render(); - const keyboardEvent = { - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as unknown as KeyboardEvent; + // Press modifier key + dispatchKeyEvent(modifierKey, "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - const handler = getHotkeyHandler("meta+d"); - handler(keyboardEvent); + // Press ArrowUp while holding modifier + dispatchKeyEvent("ArrowUp", "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1); - expect(keyboardEvent.stopPropagation).toHaveBeenCalledTimes(1); - expect(defaultProps.onDuplicate).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(defaultProps.onMigrate).toHaveBeenCalledWith( + defaultProps.event, + defaultProps.category, + "up", + ); + }); }); - test("mod+enter shortcut triggers submit with propagation blocked", () => { + test("ctrl+meta+arrowdown calls onMigrate with 'down'", async () => { + const modifierKey = getModifierKey(); + const isCtrl = modifierKey === "Control"; + render(); - const keyboardEvent = { - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - target: document.createElement("input"), - } as unknown as KeyboardEvent; + // Press modifier key + dispatchKeyEvent(modifierKey, "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - const handler = getHotkeyHandler("$mod+enter"); - handler(keyboardEvent); + // Press ArrowDown while holding modifier + dispatchKeyEvent("ArrowDown", "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(defaultProps.onMigrate).toHaveBeenCalledWith( + defaultProps.event, + defaultProps.category, + "down", + ); + }); }); - test("mod+enter shortcut invokes onSubmit", () => { - render(); + test("ctrl+meta+arrowright calls onMigrate with 'forward'", async () => { + const modifierKey = getModifierKey(); + const isCtrl = modifierKey === "Control"; - const menuButton = document.createElement("button"); - menuButton.setAttribute("role", "menuitem"); + render(); - const keyboardEvent = { - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - target: menuButton, - } as unknown as KeyboardEvent; + // Press modifier key + dispatchKeyEvent(modifierKey, "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - const handler = getHotkeyHandler("$mod+enter"); - handler(keyboardEvent); + // Press ArrowRight while holding modifier + dispatchKeyEvent("ArrowRight", "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - expect(defaultProps.onSubmit).toHaveBeenCalled(); + await waitFor(() => { + expect(defaultProps.onMigrate).toHaveBeenCalledWith( + defaultProps.event, + defaultProps.category, + "forward", + ); + }); }); - test("enter shortcut ignores events originating from the recurrence combobox", () => { - render(); + test("ctrl+meta+arrowleft calls onMigrate with 'back'", async () => { + const modifierKey = getModifierKey(); + const isCtrl = modifierKey === "Control"; - const combobox = document.createElement("div"); - combobox.setAttribute("role", "combobox"); + render(); - const keyboardEvent = { - target: combobox, - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - } as unknown as KeyboardEvent; + // Press modifier key + dispatchKeyEvent(modifierKey, "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - const handler = getHotkeyHandler("enter"); - handler(keyboardEvent); + // Press ArrowLeft while holding modifier + dispatchKeyEvent("ArrowLeft", "keydown", { + ctrlKey: isCtrl, + metaKey: !isCtrl, + }); - expect(defaultProps.onSubmit).not.toHaveBeenCalled(); - expect(keyboardEvent.preventDefault).not.toHaveBeenCalled(); - expect(keyboardEvent.stopPropagation).not.toHaveBeenCalled(); + await waitFor(() => { + expect(defaultProps.onMigrate).toHaveBeenCalledWith( + defaultProps.event, + defaultProps.category, + "back", + ); + }); }); }); diff --git a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts index 6bd0b630d..227a0bceb 100644 --- a/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts +++ b/packages/web/src/views/Forms/SomedayEventForm/useSomedayFormShortcuts.ts @@ -5,6 +5,7 @@ import { type Schema_Event, } from "@core/types/event.types"; import { isComboboxInteraction } from "@web/common/utils/form/form.util"; +import { getModifierKey } from "@web/common/utils/shortcut/shortcut.util"; export const SOMEDAY_HOTKEY_OPTIONS = { enabled: true, @@ -26,7 +27,7 @@ export interface SomedayFormShortcutsProps { const isMenuInteraction = (keyboardEvent: KeyboardEvent) => { const target = keyboardEvent.target as HTMLElement | null; - if (!target) { + if (!target || !(target instanceof HTMLElement)) { return false; } @@ -110,28 +111,28 @@ export const useSomedayFormShortcuts = ({ ); useHotkey( - "ctrl+meta+arrowup", + `${getModifierKey().toLowerCase()}+arrowup`, handleMigration("up", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); useHotkey( - "ctrl+meta+arrowdown", + `${getModifierKey().toLowerCase()}+arrowdown`, handleMigration("down", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); useHotkey( - "ctrl+meta+arrowright", + `${getModifierKey().toLowerCase()}+arrowright`, handleMigration("forward", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate], ); useHotkey( - "ctrl+meta+arrowleft", + `${getModifierKey().toLowerCase()}+arrowleft`, handleMigration("back", { event, category, onMigrate }), SOMEDAY_HOTKEY_OPTIONS, [event, category, onMigrate],