Skip to content

Commit

Permalink
feat(ui-hooks): adding useHotkeys (#696)
Browse files Browse the repository at this point in the history
  • Loading branch information
aversini authored Sep 27, 2024
1 parent 6dbd349 commit 2ba7a50
Show file tree
Hide file tree
Showing 4 changed files with 545 additions and 0 deletions.
59 changes: 59 additions & 0 deletions packages/ui-hooks/src/hooks/__tests__/useHotkeys.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
326 changes: 326 additions & 0 deletions packages/ui-hooks/src/hooks/__tests__/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>);

expect(handler).toHaveBeenCalledTimes(1);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
});
});
Loading

0 comments on commit 2ba7a50

Please sign in to comment.