Last active
May 18, 2025 15:57
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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