Skip to content

Instantly share code, notes, and snippets.

@ali-master
Last active May 18, 2025 15:57
Show Gist options
  • Save ali-master/e533210714ad1c34e9752b8563a3a16f to your computer and use it in GitHub Desktop.
Save ali-master/e533210714ad1c34e9752b8563a3a16f to your computer and use it in GitHub Desktop.
A React hook to trigger a callback function when a specific keyboard shortcut is pressed globally.
import { useCallback, useEffect } from "react";
/**
* Represents the parsed details of a keyboard shortcut.
*/
type KeyCombo = {
key: string; // Original key name (e.g., 'e', 'enter') - useful for display
code?: string | null; // Expected KeyboardEvent.code (e.g., 'KeyE', 'Enter') - used for matching
ctrl: boolean; // True if Ctrl key is required
meta: boolean; // True if Meta key (Cmd/Win) is required
shift: boolean; // True if Shift key is required
alt: boolean; // True if Alt key (Option on Mac) is required
};
/**
* Configuration options for the useKeyboardShortcut hook.
*/
type KeyboardShortcutOptions = {
preventDefault?: boolean; // Call event.preventDefault() if shortcut matches (default: true)
ignoreInputs?: boolean; // Ignore shortcuts if focus is on input/textarea/select (default: true)
ignoreContentEditable?: boolean; // Ignore shortcuts if focus is on contentEditable element (default: true)
disabled?: boolean; // Disable the shortcut listener entirely (default: false)
};
// Default options for the hook
const defaultOptions: Required<KeyboardShortcutOptions> = {
preventDefault: true,
ignoreInputs: true,
ignoreContentEditable: true,
disabled: false,
};
/**
* Maps common key names (like 'a', 'enter', 'f1', 'comma') to their
* corresponding KeyboardEvent.code values (like 'KeyA', 'Enter', 'F1', 'Comma').
* Expand this function to support more keys as needed.
* @param keyName The user-friendly key name from the shortcut string.
* @returns The corresponding KeyboardEvent.code string, or null if not found.
*/
export const getKeyNameToCode = (keyName: string): string | null => {
const lowerKey = keyName.toLowerCase();
// Basic Letters (A-Z)
if (lowerKey.length === 1 && lowerKey >= "a" && lowerKey <= "z") {
return `Key${lowerKey.toUpperCase()}`; // 'a' -> 'KeyA'
}
// Digits (0-9) - Main number row
if (lowerKey.length === 1 && lowerKey >= "0" && lowerKey <= "9") {
return `Digit${lowerKey}`; // '1' -> 'Digit1'
}
// Common Control/Whitespace Keys
switch (lowerKey) {
case "enter":
return "Enter";
case "tab":
return "Tab";
case "space":
return "Space"; // Use 'space' keyword
case "esc":
case "escape":
return "Escape";
case "backspace":
return "Backspace";
case "delete":
return "Delete";
// Add more common keys as needed (e.g., Home, End, PageUp, PageDown)
}
// Arrow Keys
switch (lowerKey) {
case "up":
case "arrowup":
return "ArrowUp";
case "down":
case "arrowdown":
return "ArrowDown";
case "left":
case "arrowleft":
return "ArrowLeft";
case "right":
case "arrowright":
return "ArrowRight";
}
if (lowerKey.startsWith("f") && !Number.isNaN(Number.parseInt(lowerKey.substring(1), 10))) {
const fNum = Number.parseInt(lowerKey.substring(1), 10);
if (fNum >= 1 && fNum <= 12) {
return `F${fNum}`;
}
}
switch (lowerKey) {
case ",":
case "comma":
return "Comma";
case ".":
case "period":
return "Period";
case "/":
case "slash":
return "Slash";
case ";":
case "semicolon":
return "Semicolon";
case "'":
case "quote":
return "Quote";
case "[":
case "bracketleft":
return "BracketLeft";
case "]":
case "bracketright":
return "BracketRight";
case "\\":
case "backslash":
return "Backslash";
case "`":
case "backquote":
return "Backquote";
case "-":
case "minus":
return "Minus";
case "=":
case "equal":
return "Equal";
}
console.warn(
`[useKeyboardShortcut] Could not map key name "${keyName}" to a standard KeyboardEvent.code. You might need to use the code directly in the shortcut definition or expand the mapping.`,
);
return null; // Indicate failure to map
};
/**
* Parses a shortcut string (e.g., "ctrl+shift+k") into a KeyCombo object.
* It now maps the key name to its KeyboardEvent.code.
* @param shortcut The shortcut string to parse.
* @returns A KeyCombo object or null if parsing fails.
*/
export const parseShortcutString = (shortcut: string): KeyCombo | null => {
if (!shortcut || typeof shortcut !== "string") {
return null;
}
const parts = shortcut
.toLowerCase()
.split("+")
.map((part) => part.trim());
const combo: Partial<KeyCombo> & { key?: string; code?: string | null } = {};
let keyAssigned = false;
for (const part of parts) {
switch (part) {
case "ctrl":
case "control":
combo.ctrl = true;
break;
case "shift":
combo.shift = true;
break;
case "meta":
case "cmd":
case "win":
combo.meta = true;
break;
case "alt":
case "option":
combo.alt = true;
break;
default:
if (part.length > 0) {
if (!keyAssigned) {
combo.key = part; // Store original key name
combo.code = getKeyNameToCode(part); // Attempt to get the code
if (!combo.code) {
console.warn(
`[useKeyboardShortcut] Failed to map key "${part}" to code for shortcut: "${shortcut}". Please check spelling or expand getKeyNameToCode mapping.`,
);
return null; // Fail parsing if code cannot be determined
}
keyAssigned = true;
} else {
console.warn(
`[useKeyboardShortcut] Multiple non-modifier keys detected in shortcut: "${shortcut}"`,
);
return null;
}
} else {
console.warn(
`[useKeyboardShortcut] Empty part detected in shortcut string: "${shortcut}"`,
);
return null;
}
}
}
// Final validation: Ensure a key and code were actually assigned
if (!keyAssigned || !combo.key || !combo.code) {
console.warn(`[useKeyboardShortcut] No valid key/code identified for shortcut: "${shortcut}"`);
return null;
}
// Return the full KeyCombo object with explicit boolean values for modifiers
return {
key: combo.key,
code: combo.code,
ctrl: !!combo.ctrl,
meta: !!combo.meta,
shift: !!combo.shift,
alt: !!combo.alt,
};
};
/**
* A React hook to trigger a callback function when a specific keyboard shortcut is pressed globally.
* Matches shortcuts based on KeyboardEvent.code (physical key) for better reliability, especially with Alt/Option keys.
* Handles complex shortcut strings (e.g., "ctrl+shift+k") or pre-defined KeyCombo objects.
* Includes options to ignore inputs, contentEditable elements, prevent default behavior, and disable the listener.
*
* @param shortcut The keyboard shortcut definition (string like "ctrl+alt+e", "shift+enter", or KeyCombo object). Can be null/undefined to disable. Key names should be standard (e.g., 'a', 'enter', 'f1', 'comma').
* @param callback The function to execute when the shortcut is matched. Should be memoized (e.g., with useCallback) if defined inline or stable otherwise.
* @param options Optional configuration for the shortcut listener (KeyboardShortcutOptions). Options are merged with defaults. Provide a stable reference (e.g., useMemo) if passing an object.
*/
export function useKeyboardShortcut(
shortcut: string | KeyCombo | null | undefined,
callback: () => void,
options?: KeyboardShortcutOptions,
): void {
const mergedOptions = { ...defaultOptions, ...options };
const { preventDefault, ignoreInputs, ignoreContentEditable, disabled } = mergedOptions;
// Memoize callback for stability in the useEffect dependency array
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const memoizedCallback = useCallback(callback, [callback]);
useEffect(() => {
// Parse the shortcut definition inside the effect.
// This ensures we work with a stable KeyCombo object within this effect run.
let parsedCombo: KeyCombo | null = null;
if (typeof shortcut === "string") {
parsedCombo = parseShortcutString(shortcut);
} else if (shortcut?.key && shortcut.code) {
// If a KeyCombo object is passed, ensure modifier flags are boolean
parsedCombo = {
...shortcut,
ctrl: !!shortcut.ctrl,
meta: !!shortcut.meta,
shift: !!shortcut.shift,
alt: !!shortcut.alt,
};
} else if (shortcut) {
// Handle potentially invalid KeyCombo objects passed directly
console.warn("[useKeyboardShortcut] Invalid KeyCombo object provided:", shortcut);
}
// Exit if the hook is disabled or the shortcut definition is invalid/unparsed
if (disabled || !parsedCombo) {
return; // No listener will be attached
}
const requiredCombo = parsedCombo;
const handleKeyDown = (e: KeyboardEvent): void => {
const target = e.target as HTMLElement;
if (
ignoreInputs &&
(target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement)
) {
return; // Ignore if focus is on standard input elements
}
if (ignoreContentEditable && target.isContentEditable) {
return; // Ignore if focus is on a contentEditable element
}
// Match the physical key code (more reliable than e.key with modifiers)
const codeMatch = e.code === requiredCombo.code;
// Match modifier keys state
const ctrlMatch = e.ctrlKey === requiredCombo.ctrl;
const metaMatch = e.metaKey === requiredCombo.meta; // Cmd on Mac, Win key on Windows
const shiftMatch = e.shiftKey === requiredCombo.shift;
const altMatch = e.altKey === requiredCombo.alt; // Option on Mac
// Check if all conditions are met
if (codeMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
if (preventDefault) {
e.preventDefault(); // Prevent default browser action if configured
}
memoizedCallback();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [shortcut, memoizedCallback, preventDefault, ignoreInputs, ignoreContentEditable, disabled]);
}
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseShortcutString, useKeyboardShortcut } from "./use-keyboard-shortcut";
function dispatchKeyEvent(
key: string,
code: string,
options: {
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
target?: Element;
} = {},
) {
const event = new KeyboardEvent("keydown", {
key: key,
code: code,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false,
altKey: options.altKey ?? false,
bubbles: true,
cancelable: true,
});
vi.spyOn(event, "preventDefault");
act(() => {
(options.target ?? document).dispatchEvent(event);
});
return event;
}
describe("Keyboard Shortcut Functionality", () => {
describe("useKeyboardShortcut Hook", () => {
let callback: ReturnType<typeof vi.fn>;
beforeEach(() => {
callback = vi.fn();
vi.restoreAllMocks();
});
it("should call callback when the correct key is pressed", () => {
renderHook(() => useKeyboardShortcut("k", callback));
dispatchKeyEvent("k", "KeyK");
expect(callback).toHaveBeenCalledTimes(1);
});
it("should call callback when the correct key combination (Ctrl+S) is pressed", () => {
renderHook(() => useKeyboardShortcut("ctrl+s", callback));
dispatchKeyEvent("s", "KeyS", { ctrlKey: true });
expect(callback).toHaveBeenCalledTimes(1);
});
it("should call callback when the correct key combination (Shift+Alt+E) is pressed", () => {
renderHook(() => useKeyboardShortcut("shift+alt+e", callback));
dispatchKeyEvent("e", "KeyE", { shiftKey: true, altKey: true });
expect(callback).toHaveBeenCalledTimes(1);
});
it("should call callback when the correct key combination (Cmd+K) is pressed", () => {
renderHook(() => useKeyboardShortcut("meta+k", callback));
dispatchKeyEvent("k", "KeyK", { metaKey: true });
expect(callback).toHaveBeenCalledTimes(1);
});
it("should not call callback when the wrong key is pressed", () => {
renderHook(() => useKeyboardShortcut("k", callback));
dispatchKeyEvent("j", "KeyJ");
expect(callback).not.toHaveBeenCalled();
});
it("should not call callback when modifiers are missing", () => {
renderHook(() => useKeyboardShortcut("ctrl+k", callback));
dispatchKeyEvent("k", "KeyK");
expect(callback).not.toHaveBeenCalled();
});
it("should not call callback when extra modifiers are pressed", () => {
renderHook(() => useKeyboardShortcut("k", callback));
dispatchKeyEvent("k", "KeyK", { ctrlKey: true });
expect(callback).not.toHaveBeenCalled();
});
it("should handle special keys like Enter", () => {
renderHook(() => useKeyboardShortcut("enter", callback));
dispatchKeyEvent("Enter", "Enter");
expect(callback).toHaveBeenCalledTimes(1);
});
it("should handle special keys like Comma", () => {
renderHook(() => useKeyboardShortcut(",", callback));
dispatchKeyEvent(",", "Comma");
expect(callback).toHaveBeenCalledTimes(1);
});
it("should handle function keys like F5", () => {
renderHook(() => useKeyboardShortcut("f5", callback));
dispatchKeyEvent("F5", "F5");
expect(callback).toHaveBeenCalledTimes(1);
});
it("should properly reject modifier-only shortcuts", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
renderHook(() => useKeyboardShortcut("ctrl+shift", callback));
dispatchKeyEvent("Shift", "ShiftLeft", { ctrlKey: true, shiftKey: true });
expect(callback).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("should call event.preventDefault() by default", () => {
renderHook(() => useKeyboardShortcut("p", callback));
const event = dispatchKeyEvent("p", "KeyP");
expect(callback).toHaveBeenCalledTimes(1);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
});
describe("ignoreInputs option", () => {
let input: HTMLInputElement;
let textarea: HTMLTextAreaElement;
let select: HTMLSelectElement;
beforeEach(() => {
input = document.createElement("input");
textarea = document.createElement("textarea");
select = document.createElement("select");
document.body.appendChild(input);
document.body.appendChild(textarea);
document.body.appendChild(select);
input.focus();
});
afterEach(() => {
document.body.removeChild(input);
document.body.removeChild(textarea);
document.body.removeChild(select);
});
it("should ignore shortcut when focus is on an input element by default", () => {
renderHook(() => useKeyboardShortcut("i", callback));
dispatchKeyEvent("i", "KeyI", { target: input });
expect(callback).not.toHaveBeenCalled();
});
it("should ignore shortcut when focus is on a textarea element by default", () => {
renderHook(() => useKeyboardShortcut("t", callback));
textarea.focus();
dispatchKeyEvent("t", "KeyT", { target: textarea });
expect(callback).not.toHaveBeenCalled();
});
it("should ignore shortcut when focus is on a select element by default", () => {
renderHook(() => useKeyboardShortcut("s", callback));
select.focus();
dispatchKeyEvent("s", "KeyS", { target: select });
expect(callback).not.toHaveBeenCalled();
});
it("should NOT ignore shortcut from input when ignoreInputs is false", () => {
renderHook(() => useKeyboardShortcut("i", callback, { ignoreInputs: false }));
dispatchKeyEvent("i", "KeyI", { target: input });
expect(callback).toHaveBeenCalledTimes(1);
});
it("should NOT ignore shortcut from textarea when ignoreInputs is false", () => {
renderHook(() => useKeyboardShortcut("t", callback, { ignoreInputs: false }));
textarea.focus();
dispatchKeyEvent("t", "KeyT", { target: textarea });
expect(callback).toHaveBeenCalledTimes(1);
});
it("should NOT ignore shortcut from select when ignoreInputs is false", () => {
renderHook(() => useKeyboardShortcut("s", callback, { ignoreInputs: false }));
select.focus();
dispatchKeyEvent("s", "KeyS", { target: select });
expect(callback).toHaveBeenCalledTimes(1);
});
});
describe("ignoreContentEditable option", () => {
let editableDiv: HTMLDivElement;
beforeEach(() => {
editableDiv = document.createElement("div");
editableDiv.setAttribute("contenteditable", "true");
Object.defineProperty(editableDiv, "isContentEditable", {
value: true,
writable: false,
});
document.body.appendChild(editableDiv);
editableDiv.focus();
});
afterEach(() => {
document.body.removeChild(editableDiv);
});
it("should ignore shortcut when focus is on a contentEditable element by default", () => {
renderHook(() => useKeyboardShortcut("c", callback));
dispatchKeyEvent("c", "KeyC", { target: editableDiv });
expect(callback).not.toHaveBeenCalled();
});
it("should NOT ignore shortcut from contentEditable when ignoreContentEditable is false", () => {
renderHook(() => useKeyboardShortcut("c", callback, { ignoreContentEditable: false }));
dispatchKeyEvent("c", "KeyC", { target: editableDiv });
expect(callback).toHaveBeenCalledTimes(1);
});
});
it("should not attach listener or call callback if disabled is true", () => {
const addSpy = vi.spyOn(document, "addEventListener");
renderHook(() => useKeyboardShortcut("d", callback, { disabled: true }));
dispatchKeyEvent("d", "KeyD");
expect(addSpy).not.toHaveBeenCalledWith("keydown", expect.any(Function));
expect(callback).not.toHaveBeenCalled();
addSpy.mockRestore();
});
it("should not call callback if shortcut is null or undefined", () => {
renderHook(() => useKeyboardShortcut(null, callback));
dispatchKeyEvent("a", "KeyA");
expect(callback).not.toHaveBeenCalled();
renderHook(() => useKeyboardShortcut(undefined, callback));
dispatchKeyEvent("b", "KeyB");
expect(callback).not.toHaveBeenCalled();
});
it("should clean up event listener on unmount", () => {
const addSpy = vi.spyOn(document, "addEventListener");
const removeSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useKeyboardShortcut("u", callback));
const listener = addSpy.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(listener).toBeDefined();
expect(addSpy).toHaveBeenCalledWith("keydown", listener);
unmount();
expect(removeSpy).toHaveBeenCalledWith("keydown", listener);
addSpy.mockRestore();
removeSpy.mockRestore();
});
it("should update listener if shortcut changes", () => {
const addSpy = vi.spyOn(document, "addEventListener");
const removeSpy = vi.spyOn(document, "removeEventListener");
let shortcut = "a";
const { rerender } = renderHook(() => useKeyboardShortcut(shortcut, callback));
const listener1 = addSpy.mock.calls.find((call) => call[0] === "keydown")?.[1];
dispatchKeyEvent("a", "KeyA");
expect(callback).toHaveBeenCalledTimes(1);
dispatchKeyEvent("b", "KeyB");
expect(callback).toHaveBeenCalledTimes(1);
shortcut = "b";
rerender();
expect(removeSpy).toHaveBeenCalledWith("keydown", listener1);
const listener2 = addSpy.mock.calls.slice(-1)[0][1];
expect(addSpy).toHaveBeenCalledWith("keydown", listener2);
expect(listener1).not.toBe(listener2);
dispatchKeyEvent("a", "KeyA");
expect(callback).toHaveBeenCalledTimes(1);
dispatchKeyEvent("b", "KeyB");
expect(callback).toHaveBeenCalledTimes(2);
addSpy.mockRestore();
removeSpy.mockRestore();
});
it("should accept a KeyCombo object as input", () => {
const keyCombo = {
key: "x",
code: "KeyX",
ctrl: true,
meta: false,
shift: true,
alt: false,
};
renderHook(() => useKeyboardShortcut(keyCombo, callback));
dispatchKeyEvent("x", "KeyX", { ctrlKey: true, shiftKey: true });
expect(callback).toHaveBeenCalledTimes(1);
dispatchKeyEvent("x", "KeyX", { ctrlKey: true });
expect(callback).toHaveBeenCalledTimes(1);
});
});
describe("parseShortcutString Function", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
//@ts-expect-error should be fine
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it("should parse a single key correctly (lowercase)", () => {
expect(parseShortcutString("k")).toEqual({
key: "k",
code: "KeyK",
ctrl: false,
meta: false,
shift: false,
alt: false,
});
});
it("should parse a single key correctly (uppercase)", () => {
expect(parseShortcutString("K")).toEqual({
key: "k",
code: "KeyK",
ctrl: false,
meta: false,
shift: false,
alt: false,
});
});
it("should parse combined modifiers and key", () => {
expect(parseShortcutString("ctrl+shift+alt+meta+f")).toEqual({
key: "f",
code: "KeyF",
ctrl: true,
meta: true,
shift: true,
alt: true,
});
});
it("should parse number keys", () => {
expect(parseShortcutString("1")).toEqual({
key: "1",
code: "Digit1",
ctrl: false,
meta: false,
shift: false,
alt: false,
});
});
it("should parse special mapped keys (e.g., space, escape)", () => {
expect(parseShortcutString("space")).toEqual({
key: "space",
code: "Space",
ctrl: false,
meta: false,
shift: false,
alt: false,
});
expect(parseShortcutString("shift+escape")).toEqual({
key: "escape",
code: "Escape",
ctrl: false,
meta: false,
shift: true,
alt: false,
});
});
it("should parse punctuation keys (e.g., comma, bracketleft)", () => {
expect(parseShortcutString(",")).toEqual({
key: ",",
code: "Comma",
ctrl: false,
meta: false,
shift: false,
alt: false,
});
expect(parseShortcutString("ctrl+BracketLeft")).toEqual({
key: "bracketleft",
code: "BracketLeft",
ctrl: true,
meta: false,
shift: false,
alt: false,
});
});
it("should handle modifier aliases (cmd, option, control, win)", () => {
expect(parseShortcutString("cmd+k")).toEqual({
key: "k",
code: "KeyK",
ctrl: false,
meta: true,
shift: false,
alt: false,
});
expect(parseShortcutString("option+k")).toEqual({
key: "k",
code: "KeyK",
ctrl: false,
meta: false,
shift: false,
alt: true,
});
expect(parseShortcutString("control+k")).toEqual({
key: "k",
code: "KeyK",
ctrl: true,
meta: false,
shift: false,
alt: false,
});
expect(parseShortcutString("win+k")).toEqual({
key: "k",
code: "KeyK",
ctrl: false,
meta: true,
shift: false,
alt: false,
});
});
it("should handle whitespace and mixed case", () => {
expect(parseShortcutString(" Ctrl + SHIFT + a ")).toEqual({
key: "a",
code: "KeyA",
ctrl: true,
meta: false,
shift: true,
alt: false,
});
});
it("should return null for null or undefined input", () => {
expect(parseShortcutString(null as any)).toBeNull();
expect(parseShortcutString(undefined as any)).toBeNull();
expect(warnSpy).not.toHaveBeenCalled();
});
it("should return null for empty string input", () => {
expect(parseShortcutString("")).toBeNull();
expect(warnSpy).not.toHaveBeenCalled();
});
it("should return null for non-string input", () => {
expect(parseShortcutString(123 as any)).toBeNull();
expect(parseShortcutString({} as any)).toBeNull();
expect(warnSpy).not.toHaveBeenCalled();
});
it("should return null and warn for empty parts", () => {
expect(parseShortcutString("ctrl++k")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Empty part detected"));
});
it("should return null and warn for multiple non-modifier keys", () => {
expect(parseShortcutString("a+b")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Multiple non-modifier keys"));
warnSpy.mockClear();
expect(parseShortcutString("ctrl+a+b")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Multiple non-modifier keys"));
});
it("should return null and warn if only modifiers are provided", () => {
expect(parseShortcutString("ctrl+shift+")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Empty part detected"));
warnSpy.mockClear();
expect(parseShortcutString("ctrl+shift")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No valid key/code identified"));
});
it("should return null and warn for unmappable key names", () => {
expect(parseShortcutString("ctrl+unknownkey")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to map key "unknownkey"'),
);
});
it("should return null and warn for unknown modifiers treated as keys", () => {
expect(parseShortcutString("super+k")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to map key "super"'));
warnSpy.mockClear();
expect(parseShortcutString("k+super")).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Multiple non-modifier keys"));
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment