From 2ba7a50b00bb2535166a1d9cbc97d32aef79d109 Mon Sep 17 00:00:00 2001 From: Arno V Date: Thu, 26 Sep 2024 22:12:07 -0400 Subject: [PATCH] feat(ui-hooks): adding useHotkeys (#696) --- .../src/hooks/__tests__/useHotkeys.test.ts | 59 ++++ .../src/hooks/__tests__/utilities.test.ts | 326 ++++++++++++++++++ packages/ui-hooks/src/hooks/useHotkeys.ts | 63 ++++ packages/ui-hooks/src/hooks/utilities.ts | 97 ++++++ 4 files changed, 545 insertions(+) create mode 100644 packages/ui-hooks/src/hooks/__tests__/useHotkeys.test.ts create mode 100644 packages/ui-hooks/src/hooks/__tests__/utilities.test.ts create mode 100644 packages/ui-hooks/src/hooks/useHotkeys.ts create mode 100644 packages/ui-hooks/src/hooks/utilities.ts diff --git a/packages/ui-hooks/src/hooks/__tests__/useHotkeys.test.ts b/packages/ui-hooks/src/hooks/__tests__/useHotkeys.test.ts new file mode 100644 index 00000000..3570e45d --- /dev/null +++ b/packages/ui-hooks/src/hooks/__tests__/useHotkeys.test.ts @@ -0,0 +1,59 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { shouldFireEvent, useHotkeys } from "../useHotkeys"; + +const dispatchEvent = (data: any) => { + const event = new KeyboardEvent("keydown", data); + document.documentElement.dispatchEvent(event); +}; + +describe("useHotkey", () => { + it("should listen to document events", () => { + const handler = vi.fn(); + renderHook(() => useHotkeys([["shift+ctrl+S", handler]])); + dispatchEvent({ shiftKey: true, ctrlKey: true, key: "S" }); + expect(handler).toHaveBeenCalled(); + }); + + it("should not fire when keys mismatch", () => { + const handler = vi.fn(); + renderHook(() => useHotkeys([["alt+L", handler]])); + dispatchEvent({ metaKey: true, key: "L" }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("should not fire when event is no exact match", () => { + const handler = vi.fn(); + renderHook(() => useHotkeys([["mod+P", handler]])); + dispatchEvent({ metaKey: true, altKey: true, key: "P" }); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe("shouldFireEvent", () => { + it("should return true if event target is not an HTML element", () => { + const event = { target: null } as KeyboardEvent; + expect(shouldFireEvent(event, [])).toBe(true); + }); + + it("should return true if event target is an HTML element and not content editable", () => { + const event = { + target: document.createElement("div"), + } as unknown as KeyboardEvent; + expect(shouldFireEvent(event, [])).toBe(true); + }); + + it("should return false if event target is an ignored tag", () => { + const input = document.createElement("input"); + const event = { target: input } as unknown as KeyboardEvent; + expect(shouldFireEvent(event, ["INPUT"])).toBe(false); + }); + + it("should return true if event target is content editable and triggerOnContentEditable is true", () => { + const div = document.createElement("div"); + div.contentEditable = "true"; + const event = { target: div } as unknown as KeyboardEvent; + expect(shouldFireEvent(event, [], true)).toBe(true); + }); +}); diff --git a/packages/ui-hooks/src/hooks/__tests__/utilities.test.ts b/packages/ui-hooks/src/hooks/__tests__/utilities.test.ts new file mode 100644 index 00000000..eab6e5eb --- /dev/null +++ b/packages/ui-hooks/src/hooks/__tests__/utilities.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getHotkeyHandler, getHotkeyMatcher, parseHotkey } from "../utilities"; +import type { HotkeyItemOptions } from "../utilities"; + +describe("@mantine/hooks/use-hot-key/parse-hotkey", () => { + it("should parse hotkey correctly", () => { + expect(parseHotkey("meta+S")).toMatchObject({ + alt: false, + ctrl: false, + meta: true, + mod: false, + shift: false, + key: "s", + }); + + expect(parseHotkey("alt+shift+L")).toMatchObject({ + alt: true, + ctrl: false, + meta: false, + mod: false, + shift: true, + key: "l", + }); + + expect(parseHotkey("mod+K")).toMatchObject({ + alt: false, + ctrl: false, + meta: false, + mod: true, + shift: false, + key: "k", + }); + + expect(parseHotkey("ctrl+shift+alt+K")).toMatchObject({ + alt: true, + ctrl: true, + meta: false, + mod: false, + shift: true, + key: "k", + }); + + expect(parseHotkey("mod+S+A")).toMatchObject({ + alt: false, + ctrl: false, + meta: false, + mod: true, + shift: false, + key: "s", + }); + }); + + it("should detect exact hotkey", () => { + expect( + getHotkeyMatcher("ctrl+alt+I")( + new KeyboardEvent("keydown", { + ctrlKey: true, + altKey: true, + key: "I", + }), + ), + ).toBe(true); + + expect( + getHotkeyMatcher("mod+E")( + new KeyboardEvent("keydown", { + ctrlKey: true, + key: "E", + }), + ), + ).toBe(true); + + expect( + getHotkeyMatcher("mod+E")( + new KeyboardEvent("keydown", { + metaKey: true, + key: "E", + }), + ), + ).toBe(true); + + expect( + getHotkeyMatcher("mod+S")( + new KeyboardEvent("keydown", { + metaKey: true, + key: "E", + }), + ), + ).toBe(false); + + expect( + getHotkeyMatcher("mod+S")( + new KeyboardEvent("keydown", { + shiftKey: true, + metaKey: true, + key: "E", + }), + ), + ).toBe(false); + + expect( + getHotkeyMatcher("mod+S")( + new KeyboardEvent("keydown", { + metaKey: true, + shiftKey: true, + key: "E", + }), + ), + ).toBe(false); + + expect( + getHotkeyMatcher("shift+alt+O")( + new KeyboardEvent("keydown", { + ctrlKey: true, + altKey: true, + shiftKey: true, + key: "O", + }), + ), + ).toBe(false); + }); +}); + +//---------------------------------------------- + +describe("parseHotkey", () => { + it("should parse a hotkey string into a Hotkey object", () => { + expect(parseHotkey("ctrl+alt+shift+a")).toEqual({ + ctrl: true, + alt: true, + shift: true, + mod: false, + meta: false, + key: "a", + }); + + expect(parseHotkey("mod+b")).toEqual({ + ctrl: false, + alt: false, + shift: false, + mod: true, + meta: false, + key: "b", + }); + + expect(parseHotkey("meta+c")).toEqual({ + ctrl: false, + alt: false, + shift: false, + mod: false, + meta: true, + key: "c", + }); + }); + + it("should handle hotkeys without a free key", () => { + expect(parseHotkey("ctrl+alt+shift")).toEqual({ + ctrl: true, + alt: true, + shift: true, + mod: false, + meta: false, + key: undefined, + }); + }); +}); + +describe("getHotkeyMatcher", () => { + it("should match the correct hotkey event", () => { + const matcher = getHotkeyMatcher("ctrl+alt+a"); + + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + altKey: true, + key: "a", + }); + + expect(matcher(event)).toBe(true); + }); + + it("should not match an incorrect hotkey event", () => { + const matcher = getHotkeyMatcher("ctrl+alt+b"); + + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + altKey: true, + key: "a", + }); + + expect(matcher(event)).toBe(false); + }); + + it("should not match if mod key is expected but neither ctrl nor meta is pressed", () => { + const matcher = getHotkeyMatcher("mod+a"); + + const event = new KeyboardEvent("keydown", { + key: "a", + }); + + expect(matcher(event)).toBe(false); + }); + + it("should match if mod key is expected and ctrl is pressed", () => { + const matcher = getHotkeyMatcher("mod+a"); + + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "a", + }); + + expect(matcher(event)).toBe(true); + }); + + it("should match if mod key is expected and meta is pressed", () => { + const matcher = getHotkeyMatcher("mod+a"); + + const event = new KeyboardEvent("keydown", { + metaKey: true, + key: "a", + }); + + expect(matcher(event)).toBe(true); + }); + + it("should not match if meta key is expected but not pressed", () => { + const matcher = getHotkeyMatcher("meta+a"); + + const event = new KeyboardEvent("keydown", { + key: "a", + }); + + expect(matcher(event)).toBe(false); + }); + + it("should match if meta key is expected and pressed", () => { + const matcher = getHotkeyMatcher("meta+a"); + + const event = new KeyboardEvent("keydown", { + metaKey: true, + key: "a", + }); + + expect(matcher(event)).toBe(true); + }); +}); + +describe("getHotkeyHandler", () => { + it("should call the correct handler for a matching hotkey", () => { + const handler = vi.fn(); + const hotkeys: [string, (event: any) => void, HotkeyItemOptions?][] = [ + ["ctrl+a", handler], + ]; + + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "a", + }); + + const hotkeyHandler = getHotkeyHandler(hotkeys); + hotkeyHandler(event); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("should prevent default if option is set", () => { + const handler = vi.fn(); + const hotkeys: [string, (event: any) => void, HotkeyItemOptions?][] = [ + ["ctrl+a", handler, { preventDefault: true }], + ]; + + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "a", + }); + + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + + const hotkeyHandler = getHotkeyHandler(hotkeys); + hotkeyHandler(event); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it("should not prevent default if option is not set", () => { + const handler = vi.fn(); + const hotkeys: [string, (event: any) => void, HotkeyItemOptions?][] = [ + ["ctrl+a", handler, { preventDefault: false }], + ]; + + const event = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "a", + }); + + const preventDefaultSpy = vi.spyOn(event, "preventDefault"); + + const hotkeyHandler = getHotkeyHandler(hotkeys); + hotkeyHandler(event); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it("should handle events with nativeEvent correctly", () => { + const handler = vi.fn(); + const hotkeys: [string, (event: any) => void, HotkeyItemOptions?][] = [ + ["ctrl+a", handler], + ]; + + const nativeEvent = new KeyboardEvent("keydown", { + ctrlKey: true, + key: "a", + }); + + const event = { + nativeEvent, + preventDefault: vi.fn(), + }; + + const hotkeyHandler = getHotkeyHandler(hotkeys); + hotkeyHandler(event as unknown as React.KeyboardEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui-hooks/src/hooks/useHotkeys.ts b/packages/ui-hooks/src/hooks/useHotkeys.ts new file mode 100644 index 00000000..260f425f --- /dev/null +++ b/packages/ui-hooks/src/hooks/useHotkeys.ts @@ -0,0 +1,63 @@ +import { useEffect } from "react"; +import { + HotkeyItemOptions, + getHotkeyHandler, + getHotkeyMatcher, +} from "./utilities"; + +export type { HotkeyItemOptions }; +export { getHotkeyHandler }; + +export type HotkeyItem = [ + string, + (event: KeyboardEvent) => void, + HotkeyItemOptions?, +]; + +export function shouldFireEvent( + event: KeyboardEvent, + tagsToIgnore: string[], + triggerOnContentEditable = false, +) { + if (event.target instanceof HTMLElement) { + if (triggerOnContentEditable) { + return !tagsToIgnore.includes(event.target.tagName); + } + + return ( + !event.target.isContentEditable && + !tagsToIgnore.includes(event.target.tagName) + ); + } + + return true; +} + +export function useHotkeys( + hotkeys: HotkeyItem[], + tagsToIgnore: string[] = ["INPUT", "TEXTAREA", "SELECT"], + triggerOnContentEditable = false, +) { + useEffect(() => { + const keydownListener = (event: KeyboardEvent) => { + hotkeys.forEach( + ([hotkey, handler, options = { preventDefault: true }]) => { + if ( + getHotkeyMatcher(hotkey)(event) && + shouldFireEvent(event, tagsToIgnore, triggerOnContentEditable) + ) { + if (options.preventDefault) { + event.preventDefault(); + } + + handler(event); + } + }, + ); + }; + + document.documentElement.addEventListener("keydown", keydownListener); + return () => + document.documentElement.removeEventListener("keydown", keydownListener); + }, [hotkeys, tagsToIgnore, triggerOnContentEditable]); +} diff --git a/packages/ui-hooks/src/hooks/utilities.ts b/packages/ui-hooks/src/hooks/utilities.ts new file mode 100644 index 00000000..9cb95cc0 --- /dev/null +++ b/packages/ui-hooks/src/hooks/utilities.ts @@ -0,0 +1,97 @@ +export type KeyboardModifiers = { + alt: boolean; + ctrl: boolean; + meta: boolean; + mod: boolean; + shift: boolean; +}; + +export type Hotkey = KeyboardModifiers & { + key?: string; +}; + +type CheckHotkeyMatch = (event: KeyboardEvent) => boolean; + +export function parseHotkey(hotkey: string): Hotkey { + const keys = hotkey + .toLowerCase() + .split("+") + .map((part) => part.trim()); + + const modifiers: KeyboardModifiers = { + alt: keys.includes("alt"), + ctrl: keys.includes("ctrl"), + meta: keys.includes("meta"), + mod: keys.includes("mod"), + shift: keys.includes("shift"), + }; + + const reservedKeys = ["alt", "ctrl", "meta", "shift", "mod"]; + + const freeKey = keys.find((key) => !reservedKeys.includes(key)); + + return { + ...modifiers, + key: freeKey, + }; +} + +function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean { + const { alt, ctrl, meta, mod, shift, key } = hotkey; + const { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey } = event; + + if (alt !== altKey) { + return false; + } + + if (mod) { + if (!ctrlKey && !metaKey) { + return false; + } + } else { + if (ctrl !== ctrlKey) { + return false; + } + if (meta !== metaKey) { + return false; + } + } + if (shift !== shiftKey) { + return false; + } + + if ( + key && + (pressedKey.toLowerCase() === key.toLowerCase() || + event.code.replace("Key", "").toLowerCase() === key.toLowerCase()) + ) { + return true; + } + + return false; +} + +export function getHotkeyMatcher(hotkey: string): CheckHotkeyMatch { + return (event) => isExactHotkey(parseHotkey(hotkey), event); +} + +export interface HotkeyItemOptions { + preventDefault?: boolean; +} + +type HotkeyItem = [string, (event: any) => void, HotkeyItemOptions?]; + +export function getHotkeyHandler(hotkeys: HotkeyItem[]) { + return (event: React.KeyboardEvent | KeyboardEvent) => { + const _event = "nativeEvent" in event ? event.nativeEvent : event; + hotkeys.forEach(([hotkey, handler, options = { preventDefault: true }]) => { + if (getHotkeyMatcher(hotkey)(_event)) { + if (options.preventDefault) { + event.preventDefault(); + } + + handler(_event); + } + }); + }; +}