diff --git a/package-lock.json b/package-lock.json index 6e4d127..61825c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "shx": "^0.3.4", "storybook": "^8.5.2", "ts-node": "^10.9.1", - "typescript": "^5.0.2", + "typescript": "^5.7.3", "vitest": "^3.0.5", "wait-on": "^7.1.0" }, @@ -19011,10 +19011,11 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -33234,9 +33235,9 @@ } }, "typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index db0dd75..6d93007 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "shx": "^0.3.4", "storybook": "^8.5.2", "ts-node": "^10.9.1", - "typescript": "^5.0.2", + "typescript": "^5.7.3", "vitest": "^3.0.5", "wait-on": "^7.1.0" }, diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 612796e..717fcb4 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/unified-signatures, @typescript-eslint/no-explicit-any */ -import { useEffect, type RefObject } from 'react'; -import { unref } from '../../utils/unref/unref.js'; +import { useEffect } from 'react'; +import { unref, type Unreffable } from '../../utils/unref/unref.js'; import { useRefValue } from '../useRefValue/useRefValue.js'; -export function useEventListener( - targetOption: RefObject | T | null | undefined, +export function useEventListener( + targetOption: Unreffable | undefined, type: string, listener: EventListener, options?: boolean | AddEventListenerOptions, @@ -14,14 +14,18 @@ export function useEventListener( useEffect(() => { const target = unref(targetOption); + if (!target) { + return; + } + function callback(this: unknown, event: Event): void { listenerRef.current?.(event); } - target?.addEventListener(type, callback, options); + target.addEventListener(type, callback, options); return () => { - target?.removeEventListener(type, callback, options); + target.removeEventListener(type, callback, options); }; }, [listenerRef, options, targetOption, type]); } diff --git a/src/hooks/useKeyboardShortcut/index.ts b/src/hooks/useKeyboardShortcut/index.ts new file mode 100644 index 0000000..0d75e5e --- /dev/null +++ b/src/hooks/useKeyboardShortcut/index.ts @@ -0,0 +1,6 @@ +export type { UseKeyboardShortcutOptions } from './useKeyboardShortcut.js'; +export { useKeyboardShortcut } from './useKeyboardShortcut.js'; + +export type { Command, Shortcut, SimpleShortcut, Modifier } from './useKeyboardShortcut.types.js'; +export { keyCodes, modifierKeys } from './keyCodes.js'; +export type { KeyCode, ModifierKeyCode } from './keyCodes.js'; diff --git a/src/hooks/useKeyboardShortcut/isShortcutDown.test.ts b/src/hooks/useKeyboardShortcut/isShortcutDown.test.ts new file mode 100644 index 0000000..13a1a8d --- /dev/null +++ b/src/hooks/useKeyboardShortcut/isShortcutDown.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { isShortcutDown } from './isShortcutDown.js'; +import type { Command } from './useKeyboardShortcut.types.js'; + +describe('isShortcutDown', () => { + it('should return true when key and modifiers match exactly', () => { + const event = new KeyboardEvent('keydown', { + code: 'KeyA', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }); + + const command: Command = { + code: 'KeyA', + ctrlKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(true); + }); + + it('should return false when key matches but modifiers do not', () => { + const event = new KeyboardEvent('keydown', { + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + }); + + const command: Command = { + code: 'KeyA', + ctrlKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(false); + }); + + it('should return false when modifiers match but key does not', () => { + const event = new KeyboardEvent('keydown', { + code: 'KeyB', + ctrlKey: true, + }); + + const command: Command = { + code: 'KeyA', + ctrlKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(false); + }); + + it('should be case-insensitive for key matching with the shift key', () => { + const event = new KeyboardEvent('keydown', { + code: 'KeyA', + ctrlKey: false, + shiftKey: true, + altKey: false, + metaKey: false, + }); + + const command: Command = { + code: 'KeyA', + shiftKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(true); + }); + + it('should handle multiple modifiers correctly', () => { + const event = new KeyboardEvent('keydown', { + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }); + + const command: Command = { + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(true); + }); + + it('should match alt+key even when it produces a special character', () => { + // On macOS, alt+n produces ñ, but we want to match 'n' + const event = new KeyboardEvent('keydown', { + code: 'KeyN', + altKey: true, + }); + + const command: Command = { + code: 'KeyN', + altKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(true); + }); + + it('should handle single modifier keys on either side', () => { + const event = new KeyboardEvent('keydown', { + code: 'ControlRight', + ctrlKey: true, + }); + + const command: Command = { + code: 'ControlLeft', + ctrlKey: true, + }; + + expect(isShortcutDown(event, command)).toBe(true); + }); +}); diff --git a/src/hooks/useKeyboardShortcut/isShortcutDown.ts b/src/hooks/useKeyboardShortcut/isShortcutDown.ts new file mode 100644 index 0000000..58fb9a2 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/isShortcutDown.ts @@ -0,0 +1,50 @@ +import { isModifierKeyCode } from './parseShortcut.js'; +import type { Command } from './useKeyboardShortcut.types.js'; + +const modifiers = ['ctrl', 'shift', 'alt', 'meta'] as const; + +/** + * Checks if the provided event matches the command. + * - if they key code matches + * - if all modifiers match, no more and no less + * + * @param event The keyboard event to check. + * @param command The command to match against. + * @returns True if the event matches the command, false otherwise. + */ +export function isShortcutDown(event: KeyboardEvent, command: Command): boolean { + // Check that only the required modifiers are down + const areModifiersCorrect = modifiers.every( + (modifier) => event[`${modifier}Key`] === Boolean(command[`${modifier}Key`]), + ); + if (!areModifiersCorrect) { + return false; + } + + // if we are only testing for modifiers, we are done + if (isModifierKeyCode(command.code)) { + return true; + } + + // Check if the pressed key matches the command key + const isKeyDown = event.code === command.code; + + // TODO: Add a mode where it checks against the `key` instead of `code`, + // to allow for better support for different keyboard layouts, + // where the same code might input a different character + // However, this would only work with specific modifier: + // + // Shift will do capitals or other characters: + // - shift+/ = ?, so '?' is the shortcut to register in that mode + // + // Alt/Option will do special characters: + // - Alt+/ = ÷, so '÷' is the shortcut to register in that mode + // - Alt+n = Dead Key for a ~ character above the next key, so cannot be used as a shortcut that way. + // + // Ctrl/Cmd should be fine, as they don't change the character that would be typed. + // + // Likely the `Command` type should be updated to add a `key` field, and both `key` and `code` should be optional. + // Unless we can make one required based on the `mode` configuration. + + return isKeyDown; +} diff --git a/src/hooks/useKeyboardShortcut/keyCodes.ts b/src/hooks/useKeyboardShortcut/keyCodes.ts new file mode 100644 index 0000000..eb39f92 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/keyCodes.ts @@ -0,0 +1,210 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +export const modifierKeys = { + AltLeft: 'AltLeft', + AltRight: 'AltRight', + ControlLeft: 'ControlLeft', + ControlRight: 'ControlRight', + OSLeft: 'OSLeft', + OSRight: 'OSRight', + ShiftLeft: 'ShiftLeft', + ShiftRight: 'ShiftRight', + MetaLeft: 'MetaLeft', + MetaRight: 'MetaRight', +}; + +export type ModifierKeyCode = keyof typeof modifierKeys; + +/** + * Keyboard event code values based on MDN documentation + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export const keyCodes = { + // Digit Keys + Digit0: 'Digit0', + Digit1: 'Digit1', + Digit2: 'Digit2', + Digit3: 'Digit3', + Digit4: 'Digit4', + Digit5: 'Digit5', + Digit6: 'Digit6', + Digit7: 'Digit7', + Digit8: 'Digit8', + Digit9: 'Digit9', + + // Letter Keys + KeyA: 'KeyA', + KeyB: 'KeyB', + KeyC: 'KeyC', + KeyD: 'KeyD', + KeyE: 'KeyE', + KeyF: 'KeyF', + KeyG: 'KeyG', + KeyH: 'KeyH', + KeyI: 'KeyI', + KeyJ: 'KeyJ', + KeyK: 'KeyK', + KeyL: 'KeyL', + KeyM: 'KeyM', + KeyN: 'KeyN', + KeyO: 'KeyO', + KeyP: 'KeyP', + KeyQ: 'KeyQ', + KeyR: 'KeyR', + KeyS: 'KeyS', + KeyT: 'KeyT', + KeyU: 'KeyU', + KeyV: 'KeyV', + KeyW: 'KeyW', + KeyX: 'KeyX', + KeyY: 'KeyY', + KeyZ: 'KeyZ', + + // Function Keys + F1: 'F1', + F2: 'F2', + F3: 'F3', + F4: 'F4', + F5: 'F5', + F6: 'F6', + F7: 'F7', + F8: 'F8', + F9: 'F9', + F10: 'F10', + F11: 'F11', + F12: 'F12', + F13: 'F13', + F14: 'F14', + F15: 'F15', + F16: 'F16', + F17: 'F17', + F18: 'F18', + F19: 'F19', + F20: 'F20', + F21: 'F21', + F22: 'F22', + F23: 'F23', + F24: 'F24', + + // Navigation Keys + ArrowDown: 'ArrowDown', + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + ArrowUp: 'ArrowUp', + End: 'End', + Home: 'Home', + PageDown: 'PageDown', + PageUp: 'PageUp', + + // Editing Keys + Space: 'Space', + Backspace: 'Backspace', + Delete: 'Delete', + Enter: 'Enter', + Insert: 'Insert', + Tab: 'Tab', + + // UI Keys + Escape: 'Escape', + CapsLock: 'CapsLock', + PrintScreen: 'PrintScreen', + ScrollLock: 'ScrollLock', + Pause: 'Pause', + + // Modifier Keys + ...modifierKeys, + + // Punctuation Keys + Backquote: 'Backquote', + BracketLeft: 'BracketLeft', + BracketRight: 'BracketRight', + Comma: 'Comma', + Period: 'Period', + Semicolon: 'Semicolon', + Quote: 'Quote', + Backslash: 'Backslash', + Slash: 'Slash', + Minus: 'Minus', + Equal: 'Equal', + + // Numpad Keys + NumLock: 'NumLock', + Numpad0: 'Numpad0', + Numpad1: 'Numpad1', + Numpad2: 'Numpad2', + Numpad3: 'Numpad3', + Numpad4: 'Numpad4', + Numpad5: 'Numpad5', + Numpad6: 'Numpad6', + Numpad7: 'Numpad7', + Numpad8: 'Numpad8', + Numpad9: 'Numpad9', + NumpadAdd: 'NumpadAdd', + NumpadDecimal: 'NumpadDecimal', + NumpadDivide: 'NumpadDivide', + NumpadEnter: 'NumpadEnter', + NumpadEqual: 'NumpadEqual', + NumpadMultiply: 'NumpadMultiply', + NumpadSubtract: 'NumpadSubtract', + NumpadComma: 'NumpadComma', + + // Media Keys + MediaPlayPause: 'MediaPlayPause', + MediaStop: 'MediaStop', + MediaTrackNext: 'MediaTrackNext', + MediaTrackPrevious: 'MediaTrackPrevious', + AudioVolumeMute: 'AudioVolumeMute', + AudioVolumeDown: 'AudioVolumeDown', + AudioVolumeUp: 'AudioVolumeUp', + + // Browser Keys + BrowserBack: 'BrowserBack', + BrowserFavorites: 'BrowserFavorites', + BrowserForward: 'BrowserForward', + BrowserHome: 'BrowserHome', + BrowserRefresh: 'BrowserRefresh', + BrowserSearch: 'BrowserSearch', + BrowserStop: 'BrowserStop', + + // Special Keys + LaunchApp1: 'LaunchApp1', + LaunchApp2: 'LaunchApp2', + LaunchMail: 'LaunchMail', + MediaSelect: 'MediaSelect', + ContextMenu: 'ContextMenu', + Power: 'Power', + Sleep: 'Sleep', + WakeUp: 'WakeUp', + + // International Keys + IntlBackslash: 'IntlBackslash', + IntlRo: 'IntlRo', + IntlYen: 'IntlYen', + KanaMode: 'KanaMode', + Lang1: 'Lang1', + Lang2: 'Lang2', + Convert: 'Convert', + NonConvert: 'NonConvert', +} as const; + +export type KeyCode = keyof typeof keyCodes; + +export const characterMappings = { + '`': keyCodes.Backquote, + '-': keyCodes.Minus, + '=': keyCodes.Equal, + '[': keyCodes.BracketLeft, + ']': keyCodes.BracketRight, + '\\': keyCodes.Backslash, + ';': keyCodes.Semicolon, + "'": keyCodes.Quote, + ',': keyCodes.Comma, + '.': keyCodes.Period, + '/': keyCodes.Slash, + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', +} as const; + +export type CharacterKey = keyof typeof characterMappings; diff --git a/src/hooks/useKeyboardShortcut/parseShortcut.test.ts b/src/hooks/useKeyboardShortcut/parseShortcut.test.ts new file mode 100644 index 0000000..92aa9a1 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/parseShortcut.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from 'vitest'; +import { createCommand, convertKey, parseCommand, parseShortcut } from './parseShortcut.js'; + +describe('convertKey', () => { + it('converts single lowercase letters to KeyCode format', () => { + expect(convertKey('a')).toBe('KeyA'); + expect(convertKey('z')).toBe('KeyZ'); + }); + + it('converts digits to KeyCode format', () => { + expect(convertKey('1')).toBe('Digit1'); + expect(convertKey('9')).toBe('Digit9'); + }); + + it('converts special characters using characterMappings', () => { + expect(convertKey('.')).toBe('Period'); + expect(convertKey(',')).toBe('Comma'); + }); + + it('capitalizes special keys', () => { + expect(convertKey('space')).toBe('Space'); + expect(convertKey('enter')).toBe('Enter'); + }); + + it('converts modifiers', () => { + expect(convertKey('ctrl')).toBe('ControlLeft'); + expect(convertKey('shift')).toBe('ShiftLeft'); + expect(convertKey('alt')).toBe('AltLeft'); + expect(convertKey('meta')).toBe('MetaLeft'); + expect(convertKey('cmd')).toBe('MetaLeft'); + }); + + it('converts arrow keys to KeyCode format', () => { + expect(convertKey('up')).toBe('ArrowUp'); + expect(convertKey('down')).toBe('ArrowDown'); + expect(convertKey('left')).toBe('ArrowLeft'); + expect(convertKey('right')).toBe('ArrowRight'); + }); +}); + +describe('createCommand', () => { + it('creates a command with no modifiers', () => { + expect(createCommand('KeyA')).toEqual({ + code: 'KeyA', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }); + }); + + it('creates a command with single modifier', () => { + expect(createCommand('KeyA', ['ctrl'])).toEqual({ + code: 'KeyA', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }); + expect(createCommand('KeyA', ['cmd'])).toEqual({ + code: 'KeyA', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: true, + }); + }); + + it('creates a command with multiple modifiers', () => { + expect(createCommand('KeyA', ['ctrl', 'shift', 'alt'])).toEqual({ + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: false, + }); + }); + + it('creates a command with only a modifier', () => { + expect(createCommand('ControlLeft', ['ctrl'])).toEqual({ + code: 'ControlLeft', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }); + }); +}); + +describe('parseCommand', () => { + it('returns null for empty command', () => { + // @ts-expect-error test runtime behavior + expect(parseCommand('')).toBeNull(); + }); + + it('parses single key commands', () => { + expect(parseCommand('a')).toEqual({ + code: 'KeyA', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }); + }); + + it('parses single key commands', () => { + expect(parseCommand('ctrl')).toEqual({ + code: 'ControlLeft', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }); + }); + + it('parses commands with modifiers', () => { + expect(parseCommand('ctrl+a')).toEqual({ + code: 'KeyA', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }); + expect(parseCommand('cmd+a')).toEqual({ + code: 'KeyA', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: true, + }); + }); + + it('parses commands with multiple modifiers', () => { + expect(parseCommand('ctrl+shift+a')).toEqual({ + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + }); + }); +}); + +describe('parseShortcut', () => { + it('parses single string command', () => { + expect(parseShortcut('a')).toEqual([ + { + code: 'KeyA', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }, + ]); + }); + + it('parses single string command with modifiers', () => { + expect(parseShortcut('ctrl+shift+a')).toEqual([ + { + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + }, + ]); + }); + + it('parses a sequence of single-letter commands', () => { + expect(parseShortcut(['a', 'b'])).toEqual([ + { + code: 'KeyA', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { + code: 'KeyB', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }, + ]); + }); + + it('parses modifiers as single keys', () => { + expect(parseShortcut(['ctrl', 'b'])).toEqual([ + { + code: 'ControlLeft', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { + code: 'KeyB', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + }, + ]); + }); + + it('parses modifiers as single keys', () => { + expect(parseShortcut(['ctrl', 'shift'])).toEqual([ + { + code: 'ControlLeft', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { + code: 'ShiftLeft', + ctrlKey: false, + shiftKey: true, + altKey: false, + metaKey: false, + }, + ]); + }); + + it('parses array of string commands', () => { + expect(parseShortcut(['ctrl+k', 'ctrl+s'])).toEqual([ + { + code: 'KeyK', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { + code: 'KeyS', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }, + ]); + }); + + it('parses array of arrays with modifiers and keys', () => { + expect( + parseShortcut([ + ['ctrl', 'k'], + ['ctrl', 's'], + ]), + ).toEqual([ + { + code: 'KeyK', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { + code: 'KeyS', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + }, + ]); + }); +}); diff --git a/src/hooks/useKeyboardShortcut/parseShortcut.ts b/src/hooks/useKeyboardShortcut/parseShortcut.ts new file mode 100644 index 0000000..64d006d --- /dev/null +++ b/src/hooks/useKeyboardShortcut/parseShortcut.ts @@ -0,0 +1,205 @@ +import { capitalize } from 'es-toolkit/string'; +import { characterMappings, modifierKeys, type KeyCode, type ModifierKeyCode } from './keyCodes.js'; +import type { + CombinedCommand, + Command, + Key, + Modifier, + SimpleKey, + SimpleShortcut, +} from './useKeyboardShortcut.types.js'; + +const modifierNames = ['ctrl', 'shift', 'alt', 'meta', 'cmd'] as const; + +function isModifier(key: string): key is Modifier { + return modifierNames.includes(key as Modifier); +} + +export function isModifierKeyCode(key: KeyCode): key is ModifierKeyCode { + return Object.values(modifierKeys).includes(key); +} + +function isArrayOfStrings(array: Array): array is Array { + return array.every((item) => typeof item === 'string'); +} + +function isArrayOfArrays(array: Array): array is Array> { + return array.every((item) => Array.isArray(item) && isArrayOfStrings(item)); +} + +function isArrayOfCombinedCommands(array: Array): array is Array { + return array.every((item) => item.includes('+')); +} + +function isAllKeys(array: Array): array is Array { + return array.every((item) => !isModifier(item as string)); +} + +/** + * Creates a command object from a key and optional modifiers. + * + * @param key - The key to create the command from. + * @param modifiers - Optional array of modifiers to include in the command. + * @returns A command object with the specified key and modifiers. + */ +export function createCommand( + key: Key, + modifiers: Array = [], +): Command { + if (isModifierKeyCode(key) && modifiers.length === 0) { + // eslint-disable-next-line no-param-reassign + modifiers = [key]; + } + + const modifiersCheck = modifiers.toString().toLowerCase(); + + const result: Command = { + code: key, + ctrlKey: modifiersCheck.includes('ctrl') || modifiersCheck.includes('control'), + shiftKey: modifiersCheck.includes('shift'), + altKey: modifiersCheck.includes('alt'), + metaKey: modifiersCheck.includes('meta') || modifiersCheck.includes('cmd'), + }; + + return result; +} + +/** + * Converts a string key to a Key enum value. + * + * @param key - The key to convert. + * @returns The converted key as a Key enum value. + */ +export function convertKey(key: SimpleKey | Modifier | CombinedCommand): Key { + // Convert single character keys to KeyCode format + if (key.length === 1 && /[a-z]/u.test(key)) { + return `Key${key.toUpperCase()}` as Key; + } + + // Convert digits to KeyCode format + if (/^\d$/u.test(key)) { + return `Digit${key}` as Key; + } + + // Convert special characters to KeyCode format + if (key in characterMappings) { + return characterMappings[key as keyof typeof characterMappings] as Key; + } + + if (isModifier(key as string)) { + // eslint-disable-next-line default-case, @typescript-eslint/switch-exhaustiveness-check + switch (key) { + case 'meta': + case 'cmd': { + return 'MetaLeft'; + } + case 'alt': { + return 'AltLeft'; + } + case 'ctrl': { + return 'ControlLeft'; + } + case 'shift': { + return 'ShiftLeft'; + } + } + } + + // For special keys like 'space', 'enter', etc. + // Convert to proper case for KeyCode + return capitalize(key) as Key; +} + +/** + * Parses a command string into a command object. + * + * Input can be: + * - a single key (e.g. 'a') + * - a combination of keys (e.g. 'ctrl+a', 'ctrl+space') + * - capital letters (e.g. 'Ctrl+A') + * + * @param command The command string to parse. + * @returns The parsed command, or null if the input is invalid. + */ +export function parseCommand(command: SimpleKey | Modifier | CombinedCommand): Command | null { + if (!command) { + return null; + } + + // reversing makes it easier to destructure + const [key, ...modifiers] = command.toLowerCase().split('+').toReversed(); + + return createCommand(convertKey(key as SimpleKey | Modifier), modifiers as Array); +} + +/** + * Input can be: + * - a single command (e.g. 'a', or 'ctrl+a', or ['ctrl', 'a']) + * - a sequence of commands (e.g. ['ctrl+k', 'ctrl+s'], or [['ctrl', 'k'], ['ctrl', 's']]) + * + * @param command The command to parse, can be in any supported format. + * @returns An array of commands. + */ +export function parseShortcut(command: SimpleShortcut): Array { + if (typeof command === 'string') { + // 1. + // 'ctrl+shift+k' + // to 2 + // ['ctrl+shift+k'] + // + // or + // 'g' + // to 4 + // ['g'] + return parseShortcut([command]); + } + + if (isArrayOfStrings(command)) { + // now we can have either a sequence of individual keys, or a shortcut with modifiers, or mixed + + if (isArrayOfCombinedCommands(command)) { + // 2. + // ['ctrl+k', 'ctrl+s'] + // to 5 + // [['ctrl', 'k'], ['ctrl', 's']] + return parseShortcut( + command.map((commandItem) => commandItem.split('+') as [...Array, SimpleKey]), + ); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isModifier(command.at(0)!)) { + // 3. + // ['ctrl', 'shift', 'a'] + // to 5 + // [['ctrl'], ['shift'], ['a']] + + return parseShortcut(command.map((cmd) => [cmd])); + } + + if (isAllKeys(command)) { + // 4. + // ['g', 'i'] + + return (command as Array) + .map((key) => createCommand(convertKey(key))) + .filter(Boolean) as Array; + } + } + + if (isArrayOfArrays(command)) { + // 5. + // [['ctrl', 'shift', 'a'], ['ctrl', 'shift', 'g']] + + return command + .map((commandItem) => { + // reversing makes it easier to destructure + const [key, ...modifiers] = commandItem.toReversed(); + + return createCommand(convertKey(key as SimpleKey | Modifier), modifiers as Array); + }) + .filter(Boolean) as Array; + } + + return []; +} diff --git a/src/hooks/useKeyboardShortcut/useDebugMessage.ts b/src/hooks/useKeyboardShortcut/useDebugMessage.ts new file mode 100644 index 0000000..f62ed16 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useDebugMessage.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; + +/** + * Only used in Demo Stories + * + * Hook for displaying debug messages that automatically clear after a specified duration + * @param duration Duration in milliseconds before the message is cleared + * @returns Tuple containing the current message and a function to set a new message + */ +export const useDebugMessage = (duration = 2000): [string, (message: string) => void] => { + const [message, setMessage] = useState(''); + + useEffect(() => { + if (message) { + const timer = setTimeout(() => { + setMessage(''); + }, duration); + + return () => { + clearTimeout(timer); + }; + } + }, [duration, message]); + + return [message, setMessage]; +}; diff --git a/src/hooks/useKeyboardShortcut/useKeyboardShortcut.mdx b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.mdx new file mode 100644 index 0000000..203d8fd --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.mdx @@ -0,0 +1,298 @@ +import { Meta, Stories } from '@storybook/blocks'; +import * as KeyboardShortcutStories from './useKeyboardShortcut.stories'; + + + +# useKeyboardShortcut + +Use this hook to add keyboard shortcuts to your application. Supports single keys, modifiers and +sequences. + +The default configuration works out of the box with inputs and is cross-platform. + +```ts +useKeyboardShortcut('ctrl+a', () => { + console.log('shortcut pressed'); +}); +``` + +## Reference + +```ts +type UseKeyboardShortcutOptions = { + ref?: RefObject | T | HTMLElement | null | undefined; + ignoreWhenInputFocused?: 'auto' | true | false; + command?: Shortcut; + convertPlatforms?: boolean; + windowsCommand?: Shortcut; + macOsCommand?: Shortcut; + eventType?: 'keyup' | 'keydown'; + shouldStopPropagation?: boolean; + shouldPreventDefault?: boolean; +}; + +function useKeyboardShortcut( + keyOrOptions: SimpleShortcut | UseKeyboardShortcutOptions, + callback: (keyboardEvent: KeyboardEvent) => void, +): void; +``` + +### Parameters + +`keyOrOptions` - Either a simple key or a combination of keys (as a `string`), or an options object. + +When it's a string, the following types of shortcuts are supported: `a`, `ctrl+a`, `ctrl+shift+a`, +etc. + +The options object supports the following properties: + +- `ref` = `document.body` - The `useRef` of the element to listen to. If not specified, the document + body will be used.
Useful to contain shortcuts to specific input elements or components. + +- `ignoreWhenInputFocused` = `"auto"` - Whether to ignore the shortcut when the input is focused. + Defaults to "auto". + + - `auto` - If the shortcut contains the `ctrl` or `cmd` modifier, it will be set to `false`. + Otherwise, it will be set to `true`. + - `true` - Always ignore when an input (`input`, `textarea`, `select`) has the focus. + - `false` - Never ignore, shortcut can always trigger. + +- `command` - The shortcut command, which key(s) to press.
Is used as a default when no + specific `windowsCommand` or `macOsCommand` is provided.
Can be defined in many formats as + **can be seen below**. + +- `convertPlatforms` = `true` - Whether to convert the modifier keys to match the current platform. + Defaults to true. There is no reason to ever set this to false, instead just provide an explicit + `windowsCommand`. + + - `true` - If macOS shortcuts with the `cmd`/`meta` modifier are used, they will be converted to + `ctrl` when on the Windows platform. Conversions the other way are not done. For easiest + configuration, always specific macOS commands. + - `false` - No automatic conversion is happening. When configuring macOS shortcuts, use the + `windowsCommand` to manually set the alternative command. + +- `windowsCommand` - The windows-specific shortcut command, which key(s) to press. If not defined, + will use the `command` parameter instead. + +- `macOsCommand` - The macOS-specific shortcut command, which key(s) to press. If not defined, will + use the `command` parameter instead. + +- `eventType` = `"keydown"` - The event type to listen to. Defaults to "keydown".
`"keyup"` + can work for single keys, but is less user friendly when modifiers are involved. + +- `shouldStopPropagation` = `false` - Whether to stop propagation of the event. Defaults to + false.
When set to `true`, `event.stopPropagation()` will be called when the shortcut is + pressed. + +- `shouldPreventDefault` = `false` - Whether to prevent default of the event. Defaults to + false.
When set to `true`, `event.preventDefault()` will be called when the shortcut is + pressed. + +`callback` - The callback function that will be called when the shortcut (sequence) is pressed. The +`KeyboardEvent` is passed to the callback, so you can propagation or default prevention based on +conditional logic. + +### Types + +#### `Shortcut` + +A union type for different ways to define a shortcut. + +- `SimpleShortcut` - A shortcut can be a string or array of strings in multiple formats (see below). +- `Command` – A single shortcut command (see below). +- `Array` – A sequence of shortcut commands, the commands have to be pressed in order + within a short time. +- `Modifier` – A shortcut command that only requires a modifier key to be pressed. + +```ts +export type Shortcut = SimpleShortcut | Command | Array | Modifier; +``` + +#### `SimpleShortcut` + +Instead of defining shortcuts as objects with KeyCodes and Modifier fields, the `SimpleShortcut` +type allows you to provide most shortcuts as typed strings, with simpler key and modifier values. + +These string based shortcuts are not official `KeyCode` names, but are mapped to them.
These +strings are fully typed, including up to two modifier keys (e.g. `cmd+shift+b`). + +If you want more modifier keys at the same time, use the `Command` format instead.
Both `cmd` +and `meta` can be used for "Command" key on macOS. + +Examples: + +- a single key, e.g. `'a'` +- a shortcut, e.g. `'ctrl+b'` +- a sequence of keys, e.g. `['g', 'i']` or `['ctrl', 'shift']` +- a sequence of shortcuts, e.g. `['cmd+k', 'cmd+s']` or `[['cmd', 'k'], ['cmd', 's']]` + +```ts +export type SimpleShortcut = SimpleCommand | Array; + +export type SimpleCommand = SimpleKey | [...Array, SimpleKey] | CombinedCommand; + +export type CombinedCommand = + | `${Modifiers}+${SimpleKey}` + | `ctrl+${Exclude}+${SimpleKey}` + | `cmd+${Exclude}+${SimpleKey}` + | `shift+${Exclude}+${SimpleKey}`; +``` + +#### `Command` + +The most expressive shortcut format, allowing you to specify all modifier keys at the same time, but +more verbose. + +All string-based formats are internally converted to this format. + +- `key` – A `KeyCode` name (e.g. `KeyA` or `Enter`). See + [MDN KeyboardEvent.code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code). +- `ctrlKey`, `shiftKey`, `altKey`, `metaKey` – Boolean values specifying whether the corresponding + modifier keys should be pressed. + +```ts +export type Command = { + code: KeyCode; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +}; +``` + +## Usage + +### Basic usage + +This is the simplest use case; globally listen to a single keypress, and invoke a callback. + +```tsx +function DemoComponent() { + useKeyboardShortcut('a', () => { + console.log('a pressed'); + }); + + return
; +} +``` + +### Shortcut Formats + +```tsx +function DemoComponent() { + // string formats + useKeyboardShortcut('a', () => {}); + useKeyboardShortcut('ctrl+a', () => {}); + useKeyboardShortcut(['g', 'i'], () => {}); + useKeyboardShortcut(['cmd+k', 'cmd+s'], () => {}); + + // When using options, the shortcut can be defined in both formats + useKeyboardShortcut({ command: 'a' }, () => {}); + useKeyboardShortcut({ command: 'ctrl+a' }, () => {}); + + // with 3+ modifiers, have to the Command object + useKeyboardShortcut( + { + command: { + code: 'KeyA', + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }, + }, + () => {}, + ); + + // You can also do this, but there is no reason to + useKeyboardShortcut({ command: [{ code: 'KeyG' }, { code: 'KeyI' }] }, () => {}); + useKeyboardShortcut( + { + command: [ + { code: 'KeyK', metaKey: true }, + { code: 'KeyS', metaKey: true }, + ], + }, + () => {}, + ); + + return
; +} +``` + +### Platforms + +This is the simplest use case; globally listen to a single keypress, and invoke a callback. + +```tsx +function DemoComponent() { + // Is cmd+k on macOs + // Is ctrl+k on windows + useKeyboardShortcut('cmd+k', () => { + console.log('key pressed'); + }); + + // Is cmd+k on macOs + // Is ctrl+alt+k on windows + useKeyboardShortcut( + { + macOsCommand: 'cmd+k', + windowsCommand: 'ctrl+alt+k', + }, + () => { + console.log('key pressed'); + }, + ); + + return
; +} +``` + +### Inputs + +```tsx +export const DemoComponent() { + // will NOT fire when the input has focus + useKeyboardShortcut('a', () => { + console.log('key pressed'); + }); + // WILL fire when the input has focus + useKeyboardShortcut('cmd+a', () => { + console.log('key pressed'); + }); + + // WILL fire when the input has focus + // probably not what you want + useKeyboardShortcut( + { + command: 'a', + ignoreWhenInputFocused: false, + }, + () => { + console.log('key pressed'); + }, + ); + + // will NOT fire when input has focus + // but there is likely no reason to prevent this + useKeyboardShortcut( + { + command: 'cmd+k', + ignoreWhenInputFocused: true, + }, + () => { + console.log('key pressed'); + }, + ); + + return ( +
+ +
+ ); +}; +``` + +## Examples + + diff --git a/src/hooks/useKeyboardShortcut/useKeyboardShortcut.stories.tsx b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.stories.tsx new file mode 100644 index 0000000..d6399aa --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.stories.tsx @@ -0,0 +1,268 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable react/jsx-no-bind */ +/* eslint-disable react/jsx-no-literals */ +/* eslint-disable react/no-multi-comp */ +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; +import { useDebugMessage } from './useDebugMessage.js'; +import { useKeyboardShortcut } from './useKeyboardShortcut.js'; + +const meta = { + title: 'Hooks/useKeyboardShortcut', + parameters: { + layout: 'centered', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: 'h', + }, + () => { + setLastPressed('H key pressed!'); + }, + ); + + return ( +
+

Basic Keyboard Shortcut

+

Press 'h' to trigger the shortcut

+
Last action: {lastPressed}
+
+ ); + }, +}; + +export const Combination: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: 'cmd+s', + }, + (event) => { + event.preventDefault(); + setLastPressed('Save shortcut pressed! (Cmd/Ctrl + S)'); + }, + ); + + return ( +
+

Combination Shortcut

+

Press Cmd+S (macOS) or Ctrl+S (Windows) to trigger

+
Last action: {lastPressed}
+
+ ); + }, +}; + +export const PlatformSpecific: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + macOsCommand: 'cmd+z', + windowsCommand: 'ctrl+z', + }, + () => { + setLastPressed('Undo shortcut pressed!'); + }, + ); + + return ( +
+

Platform-Specific Shortcut

+

Press Cmd+Z (macOS) or Ctrl+Z (Windows) to trigger

+
Last action: {lastPressed}
+
+ ); + }, +}; + +export const InputField: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: 'cmd+k', + }, + (event) => { + event.preventDefault(); + setLastPressed('Cmd+K pressed while input may be focused!'); + }, + ); + + return ( +
+

Input Field Behavior

+

Press Cmd+K, it will work even when input is focused

+ +
Last action: {lastPressed}
+
+ ); + }, +}; + +export const CustomElement: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + const buttonRef = useRef(null); + + useKeyboardShortcut( + { + ref: buttonRef, + command: 'e', + }, + () => { + setLastPressed('E pressed while button was focused!'); + }, + ); + + return ( +
+

Custom Element Binding

+

Focus the button and press 'e'

+ {/* eslint-disable-next-line react/button-has-type */} + +
Last action: {lastPressed}
+
+ ); + }, +}; + +export const SingleKeyAutoIgnore: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: 'q', + }, + () => { + setLastPressed('Q key pressed! (ignored when input is focused)'); + }, + ); + + return ( +
+

Single Key with Auto Ignore

+

Press 'q' - shortcut will be ignored when input is focused

+ +
Last action: {lastPressed}
+
+ ); + }, +}; + +export const SingleKeyNoIgnore: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: 'w', + ignoreWhenInputFocused: false, + }, + () => { + setLastPressed('W key pressed! (works even when input is focused)'); + }, + ); + + return ( +
+

Single Key without Ignore

+

Press 'w' - shortcut will work even when input is focused

+ +
Last action: {lastPressed}
+
+ ); + }, +}; + +export const Sequence: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: ['alt+n', 'alt+m'], + }, + (event) => { + event.preventDefault(); + setLastPressed('Alt+N Alt+M sequence completed!'); + }, + ); + + return ( +
+

Sequence Shortcut

+

Press Alt+N followed by Alt+M within 1 second

+
Last action: {lastPressed}
+
+ ); + }, +}; + +export const Modifiers: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + + useKeyboardShortcut( + { + command: ['shift', 'ctrl', 'alt', 'cmd'], + }, + (event) => { + event.preventDefault(); + setLastPressed('Shift, Ctrl, Alt, Cmd sequence completed!'); + }, + ); + + return ( +
+

Sequence Shortcut

+

Press Shift, Ctrl, Alt, Cmd after each other

+
Last action: {lastPressed}
+
+ ); + }, +}; + +export const KonamiCode: Story = { + render: () => { + const [lastPressed, setLastPressed] = useDebugMessage(); + const [cheatActivated, setCheatActivated] = useState(false); + + useKeyboardShortcut( + { + command: ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'b', 'a'], + }, + () => { + setLastPressed('Konami Code Activated! 🎮'); + setCheatActivated(true); + }, + ); + + return ( +
+

Konami Code

+

Enter the Konami Code: ↑ ↑ ↓ ↓ ← → ← → B A

+
Last action: {lastPressed}
+ {cheatActivated && ( +
+ 🎉 Congratulations! You found the secret! 🎉 +
+ )} +
+ ); + }, +}; diff --git a/src/hooks/useKeyboardShortcut/useKeyboardShortcut.test.ts b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.test.ts new file mode 100644 index 0000000..b19a2ca --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.test.ts @@ -0,0 +1,296 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import type { KeyCode, modifierKeys } from './keyCodes.js'; +import { useKeyboardShortcut } from './useKeyboardShortcut.js'; + +const createKeyboardEvent = ( + code: KeyCode | keyof typeof modifierKeys, + options: Partial = {}, +): KeyboardEvent => + new KeyboardEvent('keydown', { + code, + bubbles: true, + ...options, + }); + +describe('useKeyboardShortcut', () => { + let callback: ReturnType; + let mockRef: { current: HTMLDivElement }; + + beforeEach(() => { + callback = vi.fn(); + mockRef = { + current: document.createElement('div'), + }; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should trigger callback when shortcut matches', () => { + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: 'ctrl+a', + }, + callback, + ); + }); + + mockRef.current.dispatchEvent( + createKeyboardEvent('KeyA', { + ctrlKey: true, + }), + ); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not trigger callback when shortcut does not match', () => { + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: 'ctrl+a', + }, + callback, + ); + }); + + mockRef.current.dispatchEvent( + createKeyboardEvent('KeyB', { + ctrlKey: true, + }), + ); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should ignore shortcuts in input elements when ignoreWhenInputFocused is true', () => { + const input = document.createElement('input'); + mockRef.current.append(input); + + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: 'a', + ignoreWhenInputFocused: true, + }, + callback, + ); + }); + + const event = createKeyboardEvent('KeyA'); + Object.defineProperty(event, 'target', { value: input }); + mockRef.current.dispatchEvent(event); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle shortcuts in input elements when ignoreWhenInputFocused is false', () => { + const input = document.createElement('input'); + mockRef.current.append(input); + + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: 'a', + ignoreWhenInputFocused: false, + }, + callback, + ); + }); + + const event = createKeyboardEvent('KeyA'); + Object.defineProperty(event, 'target', { value: input }); + mockRef.current.dispatchEvent(event); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop event propagation when shouldStopPropagation is true', () => { + const stopPropagation = vi.fn(); + + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: 'a', + shouldStopPropagation: true, + }, + callback, + ); + }); + + const event = createKeyboardEvent('KeyA'); + Object.defineProperty(event, 'stopPropagation', { value: stopPropagation }); + mockRef.current.dispatchEvent(event); + + expect(stopPropagation).toHaveBeenCalledTimes(1); + }); + + it('should prevent default when shouldPreventDefault is true', () => { + const preventDefault = vi.fn(); + + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: 'a', + shouldPreventDefault: true, + }, + callback, + ); + }); + + const event = createKeyboardEvent('KeyA'); + Object.defineProperty(event, 'preventDefault', { value: preventDefault }); + mockRef.current.dispatchEvent(event); + + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + it('should handle platform-specific commands', () => { + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + windowsCommand: 'ctrl+a', + macOsCommand: 'meta+a', + convertPlatforms: true, + }, + callback, + ); + }); + + // Test Windows shortcut + mockRef.current.dispatchEvent( + createKeyboardEvent('KeyA', { + ctrlKey: true, + }), + ); + + // Test macOS shortcut + mockRef.current.dispatchEvent( + createKeyboardEvent('KeyA', { + metaKey: true, + }), + ); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should ignore modifier key events', () => { + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: ['ctrl+a', 'ctrl+b'], + }, + callback, + ); + }); + + mockRef.current.dispatchEvent(createKeyboardEvent('ControlLeft', { ctrlKey: true })); + mockRef.current.dispatchEvent(createKeyboardEvent('KeyA', { ctrlKey: true })); + mockRef.current.dispatchEvent(createKeyboardEvent('ControlLeft', { ctrlKey: true })); + mockRef.current.dispatchEvent(createKeyboardEvent('KeyB', { ctrlKey: true })); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should trigger the konami code', () => { + renderHook(() => { + useKeyboardShortcut( + { + ref: mockRef, + command: ['up', 'up', 'down', 'down', 'left', 'right', 'left', 'right', 'b', 'a'], + }, + callback, + ); + }); + + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowUp')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowUp')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowDown')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowDown')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowLeft')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowRight')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowLeft')); + mockRef.current.dispatchEvent(createKeyboardEvent('ArrowRight')); + mockRef.current.dispatchEvent(createKeyboardEvent('KeyB')); + mockRef.current.dispatchEvent(createKeyboardEvent('KeyA')); + + expect(callback).toHaveBeenCalledOnce(); + }); + + describe('with shortcuts as the first parameter', () => { + it(`should support 'a'`, () => { + renderHook(() => { + useKeyboardShortcut('a', callback); + }); + + document.body.dispatchEvent(createKeyboardEvent('KeyA')); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it(`should support ['a']`, () => { + renderHook(() => { + useKeyboardShortcut(['a'], callback); + }); + + document.body.dispatchEvent(createKeyboardEvent('KeyA')); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it(`should support ['a', 'b']`, () => { + renderHook(() => { + useKeyboardShortcut(['a', 'b'], callback); + }); + + document.body.dispatchEvent(createKeyboardEvent('KeyA')); + document.body.dispatchEvent(createKeyboardEvent('KeyB')); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it(`should support 'cmd+a'`, () => { + renderHook(() => { + useKeyboardShortcut('cmd+a', callback); + }); + + document.body.dispatchEvent(createKeyboardEvent('KeyA', { metaKey: true })); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it(`should support ['cmd+k', 'cmd+s']`, () => { + renderHook(() => { + useKeyboardShortcut(['cmd+k', 'cmd+s'], callback); + }); + + document.body.dispatchEvent(createKeyboardEvent('KeyK', { metaKey: true })); + document.body.dispatchEvent(createKeyboardEvent('KeyS', { metaKey: true })); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it(`should support ['cmd', 'shift']`, () => { + renderHook(() => { + useKeyboardShortcut(['cmd', 'shift'], callback); + }); + + document.body.dispatchEvent(createKeyboardEvent('MetaLeft', { metaKey: true })); + document.body.dispatchEvent(createKeyboardEvent('ShiftLeft', { shiftKey: true })); + + expect(callback).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/hooks/useKeyboardShortcut/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.ts new file mode 100644 index 0000000..37be38d --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.ts @@ -0,0 +1,106 @@ +import type { Unreffable } from '../../utils/unref/unref.js'; +import { useEventListener } from '../useEventListener/useEventListener.js'; +import { isShortcutDown } from './isShortcutDown.js'; +import { type KeyCode } from './keyCodes.js'; +import { isModifierKeyCode } from './parseShortcut.js'; +import type { Command, Shortcut, SimpleShortcut } from './useKeyboardShortcut.types.js'; +import { getPlatformCommand, hasInputSafeModifiers } from './useKeyboardShortcut.utils.js'; +import { useSequence } from './useSequence.js'; + +export type UseKeyboardShortcutOptions = { + ref?: Unreffable; + ignoreWhenInputFocused?: 'auto' | true | false; + command?: Shortcut; + convertPlatforms?: boolean; + windowsCommand?: Shortcut; + macOsCommand?: Shortcut; + eventType?: 'keyup' | 'keydown'; + shouldStopPropagation?: boolean; + shouldPreventDefault?: boolean; +}; + +export function useKeyboardShortcut( + keyOrOptions: SimpleShortcut | UseKeyboardShortcutOptions, + callback: (keyboardEvent: KeyboardEvent) => void, +): void { + const options = + typeof keyOrOptions === 'string' || Array.isArray(keyOrOptions) + ? { command: keyOrOptions } + : keyOrOptions; + + const { + ref = document.body, + ignoreWhenInputFocused: ignoreWhenInputFocusedProp = 'auto', + eventType = 'keydown', + shouldStopPropagation = false, + shouldPreventDefault = false, + ...shortcutProps + } = options; + + const shortcut = getPlatformCommand(shortcutProps); + + // For "auto" + // - when we have Ctrl/Cmd modifiers, the shortcut is safe to use + // - otherwise, we ignore the shortcut when in an input, + // as it will also type the character in the input when wanting to trigger the shortcut + const ignoreWhenInputFocused = + ignoreWhenInputFocusedProp === 'auto' + ? !hasInputSafeModifiers(shortcut) + : ignoreWhenInputFocusedProp; + + const { check } = useSequence( + shortcut, + // check the keyboard input against the next-up command in the sequence + (currentCommand, keyboardEvent) => { + // if a modifier key is pressed, we should ignore this if we are not checking for a modifier as a key itself + // the sequence will reset the timer to give users more time, but not the progress itself, so it's safe + if ( + !isModifierKeyCode(currentCommand.code) && + isModifierKeyCode(keyboardEvent.code as KeyCode) + ) { + return; + } + + // check if the current command in the sequence is down, including all its modifiers + return isShortcutDown(keyboardEvent, currentCommand); + }, + // this fires when the sequence is complete + (keyboardEvent) => { + if (shouldStopPropagation) { + keyboardEvent.stopPropagation(); + } + + if (shouldPreventDefault) { + keyboardEvent.preventDefault(); + } + + callback(keyboardEvent); + }, + ); + + useEventListener(ref, eventType, (event: Event): void => { + const keyboardEvent = event as KeyboardEvent; + + if (keyboardEvent.key === undefined) { + // Synthetic event (e.g., Chrome autofill). Ignore. + return; + } + + const target = keyboardEvent.target as HTMLElement; + + // TODO: make this configurable? + if ( + ignoreWhenInputFocused && + (target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement) + ) { + // Ignore shortcut when inside an input. + return; + } + + // Check the keyboard event against the current command in the sequence + check(keyboardEvent); + }); +} diff --git a/src/hooks/useKeyboardShortcut/useKeyboardShortcut.types.ts b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.types.ts new file mode 100644 index 0000000..f4f1f89 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.types.ts @@ -0,0 +1,110 @@ +import type { CharacterKey, KeyCode } from './keyCodes.js'; + +export type Key = KeyCode; + +type EnglishAlphabetCharacters = + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z'; +type LatinDigits = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; +type FunctionKeys = + | 'f1' + | 'f2' + | 'f3' + | 'f4' + | 'f5' + | 'f6' + | 'f7' + | 'f8' + | 'f9' + | 'f10' + | 'f11' + | 'f12'; +type NavigationKeys = 'down' | 'left' | 'right' | 'up' | 'end' | 'home' | 'pageup' | 'pagedown'; +type EditingKeys = 'space' | 'backspace' | 'delete' | 'enter' | 'insert' | 'tab'; +type UiKeys = 'escape'; + +export type SimpleKey = + | EnglishAlphabetCharacters + | LatinDigits + | FunctionKeys + | NavigationKeys + | EditingKeys + | UiKeys + | CharacterKey; +export type Modifier = 'ctrl' | 'shift' | 'alt' | 'meta' | 'cmd'; + +// only create types for single and double modifiers +// if you want three or 4, use the array type instead, or use a cast (runtime it will work) +export type CombinedCommand = + | `${Modifier}+${SimpleKey}` + | `ctrl+${Exclude}+${SimpleKey}` + | `cmd+${Exclude}+${SimpleKey}` + | `shift+${Exclude}+${SimpleKey}`; + +// 'a', ['ctrl', 'a'], 'ctrl+a' +export type SimpleCommand = + | SimpleKey + | Modifier + | [...Array, SimpleKey] + | CombinedCommand; + +export type Command = { + code: Key; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + // Cmd on MacOS + metaKey?: boolean; +}; + +// SimpleCommand can be: +// - a single key, e.g. 'a' +// - a shortcut, e.g. ['ctrl', 'b'] +// - a shortcut, e.g. 'ctrl+b' +// - a sequence of keys, e.g. ['a', 'b', 'c'] +// - a sequence of shortcuts, e.g. [['ctrl', 'b'], ['ctrl', 'c']] +// - a sequence of shortcuts, e.g. [['ctrl+b'], ['ctrl+c']] +export type SimpleShortcut = + | SimpleCommand + | Array + | Array>; + +export type Shortcut = Command | Array | SimpleShortcut; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +// const x: SimpleShortcut = 'x'; +// const ctrlX: SimpleShortcut = ['ctrl', 'x']; +// const ctrlShiftX: SimpleShortcut = ['ctrl', 'shift', 'x']; +// const gi: SimpleShortcut = ['g', 'i']; +// // eslint-disable-next-line @typescript-eslint/naming-convention +// const ctrlKCtrlS: SimpleShortcut = [ +// ['ctrl', 'k'], +// ['ctrl', 's'], +// ]; + +// const ctrlY: SimpleShortcut = ['ctrl+y']; +// const ctrlShiftSpace: SimpleShortcut = ['ctrl+shift+space']; diff --git a/src/hooks/useKeyboardShortcut/useKeyboardShortcut.utils.ts b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.utils.ts new file mode 100644 index 0000000..f202ef4 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useKeyboardShortcut.utils.ts @@ -0,0 +1,94 @@ +import { parseShortcut } from './parseShortcut.js'; +import type { UseKeyboardShortcutOptions } from './useKeyboardShortcut.js'; +import type { Command, Shortcut } from './useKeyboardShortcut.types.js'; + +const { userAgent } = window.navigator; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const platform = (window.navigator as any)?.userAgentData?.platform || userAgent; +const isWindows = platform.includes('Win'); + +/** + * When Ctrl or Cmd keys are required for the command, + * it's safe to use in inputs, since it won't insert a character. + * + * Shift does capitals, and option adds special characters. + */ +export function hasInputSafeModifiers(shortcut: Array): boolean { + return shortcut.every((modifier) => modifier.ctrlKey || modifier.metaKey); +} + +/** + * On windows, we don't have a Cmd key, so we map it to Ctrl. + */ +function convertToWindowsCommand(command: Command): Command { + // if it already has a ctrlKey, we should not "remove" the meta key + if (command.ctrlKey) { + return command; + } + return { + ...command, + ctrlKey: command.ctrlKey || command.metaKey, + metaKey: false, + }; +} + +function isCommand(command: object | string): command is Command { + if (typeof command === 'string') { + return false; + } + + return command && 'key' in command; +} + +/** + * Normalizes the shortcut into an array of commands, so it can be used in the Sequence hook. + * + * @param shortcut The shortcut in any format + * @returns + */ +function ensureCommandFormat(shortcut: Shortcut | undefined): Array { + if (!shortcut) { + return []; + } + + // a single command, wrap as an array + if (isCommand(shortcut)) { + return [shortcut]; + } + + // make sure this is an array of actual commands + if (Array.isArray(shortcut) && shortcut.every((item) => isCommand(item))) { + return shortcut; + } + + // if not any of the above, it can only be a SimpleShortcut type (i.e. we need string parsing) + return parseShortcut(shortcut); +} + +export function getPlatformCommand({ + command: commandFromProps, + convertPlatforms = true, + windowsCommand: windowsCommandProps, + macOsCommand: macOsCommandProps, +}: Pick< + UseKeyboardShortcutOptions, + 'windowsCommand' | 'macOsCommand' | 'convertPlatforms' | 'command' +>): Array { + const internalCommand = ensureCommandFormat(commandFromProps); + + // eslint-disable-next-line no-nested-ternary + const windowsCommand = windowsCommandProps + ? ensureCommandFormat(windowsCommandProps) + : convertPlatforms + ? internalCommand.map((element) => convertToWindowsCommand(element)) + : internalCommand; + + // eslint-disable-next-line no-nested-ternary + const macOsCommand = macOsCommandProps + ? ensureCommandFormat(macOsCommandProps) + : convertPlatforms + ? internalCommand.map((element) => element) + : internalCommand; + + return isWindows ? windowsCommand : macOsCommand; +} diff --git a/src/hooks/useKeyboardShortcut/useSequence.test.ts b/src/hooks/useKeyboardShortcut/useSequence.test.ts new file mode 100644 index 0000000..d89fd16 --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useSequence.test.ts @@ -0,0 +1,96 @@ +/* eslint-disable unicorn/consistent-function-scoping */ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { useSequence } from './useSequence.js'; + +describe('useSequence', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should call callback when sequence is matched in order', () => { + const items = ['a', 'b', 'c']; + const callback = vi.fn(); + const predicate = (item: string, input: string): boolean => item === input; + + const { result } = renderHook(() => useSequence(items, predicate, callback)); + + result.current.check('a'); + result.current.check('b'); + result.current.check('c'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('c'); + }); + + it('should reset sequence when wrong input is provided', () => { + const items = ['a', 'b', 'c']; + const callback = vi.fn(); + const predicate = (item: string, input: string): boolean | undefined => item === input; + + const { result } = renderHook(() => useSequence(items, predicate, callback)); + + result.current.check('a'); + // Wrong input + result.current.check('x'); + result.current.check('b'); + result.current.check('c'); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should reset sequence after debounce timeout', () => { + const items = ['a', 'b', 'c']; + const callback = vi.fn(); + const predicate = (item: string, input: string): boolean => item === input; + const debounce = 1000; + + const { result } = renderHook(() => useSequence(items, predicate, callback, debounce)); + + result.current.check('a'); + result.current.check('b'); + + vi.advanceTimersByTime(debounce + 100); + + result.current.check('c'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not reset sequence when undefined is returned from predicate', () => { + const items = ['a', 'b']; + const callback = vi.fn(); + const predicate = (item: string, input: string): boolean | undefined => + input === 'skip' ? undefined : item === input; + + const { result } = renderHook(() => useSequence(items, predicate, callback)); + + result.current.check('a'); + result.current.check('skip'); + result.current.check('b'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('b'); + }); + + it('should restart timer on each valid input', () => { + const items = ['a', 'b', 'c']; + const callback = vi.fn(); + const predicate = (item: string, input: string): boolean => item === input; + const debounce = 1000; + + const { result } = renderHook(() => useSequence(items, predicate, callback, debounce)); + + result.current.check('a'); + vi.advanceTimersByTime(800); + result.current.check('b'); + vi.advanceTimersByTime(800); + result.current.check('c'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('c'); + }); +}); diff --git a/src/hooks/useKeyboardShortcut/useSequence.ts b/src/hooks/useKeyboardShortcut/useSequence.ts new file mode 100644 index 0000000..1067f5f --- /dev/null +++ b/src/hooks/useKeyboardShortcut/useSequence.ts @@ -0,0 +1,73 @@ +import { useRef, useCallback } from 'react'; + +/** + * Checks a sequence of items against a predicate and calls a callback when the sequence is complete. + * Each item is checked against an input value (for each step), and if the predicate succeeds, we increase the index. + * If the predicate fails, or if the debounce timeout has passed, we reset the index. + * + * @param items The sequence of items to check + * @param predicate A function that takes an item and the input and returns true if the input is valid, + * after which it progresses to the next item in the sequence. + * @param callback A function that is called when the sequence is complete + * @param debounce The debounce time in milliseconds, the time between each input before resetting the index. + * @returns An object with a `check` method that takes an input, which can be called from the outside, and triggers the predicate. + */ +export function useSequence( + items: Array, + predicate: (item: T, input: I) => boolean | undefined, + callback: (input: I) => void, + debounce: number = 1000, +): { check(input: I): void } { + const indexRef = useRef(0); + const timerRef = useRef | null>(null); + + // Reset index and timer + const reset = useCallback((): void => { + indexRef.current = 0; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + // Start the debounce timer + const startTimer = useCallback((): void => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + reset(); + }, debounce); + }, [debounce, reset]); + + const check = useCallback( + (input: I) => { + startTimer(); + + // When returned undefined, we don't do anything + // For example, for keyboard shortcuts, when pressing modifier keys, they won't match anything, + // but we don't want to "abort" the sequence. + // We could just not call `check` from the outside, but it's nice that this way we reset the timer, + // giving the user slightly more time to press complex modifier keys. + const isMatch = predicate(items[indexRef.current], input); + + if (isMatch === true) { + indexRef.current++; + + // check if we have reached the end + if (indexRef.current >= items.length) { + reset(); + callback(input); + } + } else if (isMatch === false) { + // TODO: should we re-check a failed predicate after resetting the index? + // TODO: or should we keep the input history and re-check the inputs against + // the sequence to see if there is a overlapping start at some point? + reset(); + } + }, + [items, predicate, callback, startTimer, reset], + ); + + return { check }; +} diff --git a/src/index.ts b/src/index.ts index aa79b8a..8a846e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './hooks/useForceRerender/useForceRerender.js'; export * from './hooks/useHasFocus/useHasFocus.js'; export * from './hooks/useIntersectionObserver/useIntersectionObserver.js'; export * from './hooks/useInterval/useInterval.js'; +export * from './hooks/useKeyboardShortcut/index.js'; export * from './hooks/useMediaDuration/useMediaDuration.js'; export * from './hooks/useMediaQuery/useMediaQuery.js'; export * from './hooks/useMutationObserver/useMutationObserver.js';