Skip to content

Commit 0287617

Browse files
committed
Add useKeyboardShortcut hook
1 parent c21a308 commit 0287617

16 files changed

+2089
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type { UseKeyboardShortcutOptions } from './useKeyboardShortcut.js';
2+
export { useKeyboardShortcut } from './useKeyboardShortcut.js';
3+
4+
export type { Command, Shortcut, SimpleShortcut } from './useKeyboardShortcut.types.js';
5+
export { keyCodes, modifierKeys } from './keyCodes.js';
6+
export type { KeyCode, Modifier } from './keyCodes.js';
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isShortcutDown } from './isShortcutDown.js';
3+
import type { Command } from './useKeyboardShortcut.types.js';
4+
5+
describe('isShortcutDown', () => {
6+
it('should return true when key and modifiers match exactly', () => {
7+
const event = new KeyboardEvent('keydown', {
8+
code: 'KeyA',
9+
ctrlKey: true,
10+
shiftKey: false,
11+
altKey: false,
12+
metaKey: false,
13+
});
14+
15+
const command: Command = {
16+
code: 'KeyA',
17+
ctrlKey: true,
18+
};
19+
20+
expect(isShortcutDown(event, command)).toBe(true);
21+
});
22+
23+
it('should return false when key matches but modifiers do not', () => {
24+
const event = new KeyboardEvent('keydown', {
25+
code: 'KeyA',
26+
ctrlKey: true,
27+
shiftKey: true,
28+
altKey: false,
29+
metaKey: false,
30+
});
31+
32+
const command: Command = {
33+
code: 'KeyA',
34+
ctrlKey: true,
35+
};
36+
37+
expect(isShortcutDown(event, command)).toBe(false);
38+
});
39+
40+
it('should return false when modifiers match but key does not', () => {
41+
const event = new KeyboardEvent('keydown', {
42+
code: 'KeyB',
43+
ctrlKey: true,
44+
});
45+
46+
const command: Command = {
47+
code: 'KeyA',
48+
ctrlKey: true,
49+
};
50+
51+
expect(isShortcutDown(event, command)).toBe(false);
52+
});
53+
54+
it('should be case-insensitive for key matching with the shift key', () => {
55+
const event = new KeyboardEvent('keydown', {
56+
code: 'KeyA',
57+
ctrlKey: false,
58+
shiftKey: true,
59+
altKey: false,
60+
metaKey: false,
61+
});
62+
63+
const command: Command = {
64+
code: 'KeyA',
65+
shiftKey: true,
66+
};
67+
68+
expect(isShortcutDown(event, command)).toBe(true);
69+
});
70+
71+
it('should handle multiple modifiers correctly', () => {
72+
const event = new KeyboardEvent('keydown', {
73+
code: 'KeyA',
74+
ctrlKey: true,
75+
shiftKey: true,
76+
altKey: true,
77+
metaKey: true,
78+
});
79+
80+
const command: Command = {
81+
code: 'KeyA',
82+
ctrlKey: true,
83+
shiftKey: true,
84+
altKey: true,
85+
metaKey: true,
86+
};
87+
88+
expect(isShortcutDown(event, command)).toBe(true);
89+
});
90+
91+
it('should match alt+key even when it produces a special character', () => {
92+
// On macOS, alt+n produces ñ, but we want to match 'n'
93+
const event = new KeyboardEvent('keydown', {
94+
code: 'KeyN',
95+
altKey: true,
96+
});
97+
98+
const command: Command = {
99+
code: 'KeyN',
100+
altKey: true,
101+
};
102+
103+
expect(isShortcutDown(event, command)).toBe(true);
104+
});
105+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Command } from './useKeyboardShortcut.types.js';
2+
3+
const modifiers = ['ctrl', 'shift', 'alt', 'meta'] as const;
4+
5+
/**
6+
* Checks if the provided event matches the command.
7+
* - if they key code matches
8+
* - if all modifiers match, no more and no less
9+
*
10+
* @param event The keyboard event to check.
11+
* @param command The command to match against.
12+
* @returns True if the event matches the command, false otherwise.
13+
*/
14+
export function isShortcutDown(event: KeyboardEvent, command: Command): boolean {
15+
// Check that only the required modifiers are down
16+
const areModifiersCorrect = modifiers.every(
17+
(modifier) => event[`${modifier}Key`] === Boolean(command[`${modifier}Key`]),
18+
);
19+
if (!areModifiersCorrect) {
20+
return false;
21+
}
22+
23+
// Check if the pressed key matches the command key
24+
const isKeyDown = event.code === command.code;
25+
26+
// TODO: Add a mode where it checks against the `key` instead of `code`,
27+
// to allow for better support for different keyboard layouts,
28+
// where the same code might input a different character
29+
// However, this would only work with specific modifier:
30+
//
31+
// Shift will do capitals or other characters:
32+
// - shift+/ = ?, so '?' is the shortcut to register in that mode
33+
//
34+
// Alt/Option will do special characters:
35+
// - Alt+/ = ÷, so '÷' is the shortcut to register in that mode
36+
// - Alt+n = Dead Key for a ~ character above the next key, so cannot be used as a shortcut that way.
37+
//
38+
// Ctrl/Cmd should be fine, as they don't change the character that would be typed.
39+
//
40+
// Likely the `Command` type should be updated to add a `key` field, and both `key` and `code` should be optional.
41+
// Unless we can make one required based on the `mode` configuration.
42+
43+
return isKeyDown;
44+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
3+
export const modifierKeys = {
4+
AltLeft: 'AltLeft',
5+
AltRight: 'AltRight',
6+
ControlLeft: 'ControlLeft',
7+
ControlRight: 'ControlRight',
8+
OSLeft: 'OSLeft',
9+
OSRight: 'OSRight',
10+
ShiftLeft: 'ShiftLeft',
11+
ShiftRight: 'ShiftRight',
12+
MetaLeft: 'MetaLeft',
13+
MetaRight: 'MetaRight',
14+
};
15+
16+
export type Modifier = keyof typeof modifierKeys;
17+
18+
/**
19+
* Keyboard event code values based on MDN documentation
20+
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
21+
*/
22+
export const keyCodes = {
23+
// Digit Keys
24+
Digit0: 'Digit0',
25+
Digit1: 'Digit1',
26+
Digit2: 'Digit2',
27+
Digit3: 'Digit3',
28+
Digit4: 'Digit4',
29+
Digit5: 'Digit5',
30+
Digit6: 'Digit6',
31+
Digit7: 'Digit7',
32+
Digit8: 'Digit8',
33+
Digit9: 'Digit9',
34+
35+
// Letter Keys
36+
KeyA: 'KeyA',
37+
KeyB: 'KeyB',
38+
KeyC: 'KeyC',
39+
KeyD: 'KeyD',
40+
KeyE: 'KeyE',
41+
KeyF: 'KeyF',
42+
KeyG: 'KeyG',
43+
KeyH: 'KeyH',
44+
KeyI: 'KeyI',
45+
KeyJ: 'KeyJ',
46+
KeyK: 'KeyK',
47+
KeyL: 'KeyL',
48+
KeyM: 'KeyM',
49+
KeyN: 'KeyN',
50+
KeyO: 'KeyO',
51+
KeyP: 'KeyP',
52+
KeyQ: 'KeyQ',
53+
KeyR: 'KeyR',
54+
KeyS: 'KeyS',
55+
KeyT: 'KeyT',
56+
KeyU: 'KeyU',
57+
KeyV: 'KeyV',
58+
KeyW: 'KeyW',
59+
KeyX: 'KeyX',
60+
KeyY: 'KeyY',
61+
KeyZ: 'KeyZ',
62+
63+
// Function Keys
64+
F1: 'F1',
65+
F2: 'F2',
66+
F3: 'F3',
67+
F4: 'F4',
68+
F5: 'F5',
69+
F6: 'F6',
70+
F7: 'F7',
71+
F8: 'F8',
72+
F9: 'F9',
73+
F10: 'F10',
74+
F11: 'F11',
75+
F12: 'F12',
76+
F13: 'F13',
77+
F14: 'F14',
78+
F15: 'F15',
79+
F16: 'F16',
80+
F17: 'F17',
81+
F18: 'F18',
82+
F19: 'F19',
83+
F20: 'F20',
84+
F21: 'F21',
85+
F22: 'F22',
86+
F23: 'F23',
87+
F24: 'F24',
88+
89+
// Navigation Keys
90+
ArrowDown: 'ArrowDown',
91+
ArrowLeft: 'ArrowLeft',
92+
ArrowRight: 'ArrowRight',
93+
ArrowUp: 'ArrowUp',
94+
End: 'End',
95+
Home: 'Home',
96+
PageDown: 'PageDown',
97+
PageUp: 'PageUp',
98+
99+
// Editing Keys
100+
Space: 'Space',
101+
Backspace: 'Backspace',
102+
Delete: 'Delete',
103+
Enter: 'Enter',
104+
Insert: 'Insert',
105+
Tab: 'Tab',
106+
107+
// UI Keys
108+
Escape: 'Escape',
109+
CapsLock: 'CapsLock',
110+
PrintScreen: 'PrintScreen',
111+
ScrollLock: 'ScrollLock',
112+
Pause: 'Pause',
113+
114+
// Modifier Keys
115+
...modifierKeys,
116+
117+
// Punctuation Keys
118+
Backquote: 'Backquote',
119+
BracketLeft: 'BracketLeft',
120+
BracketRight: 'BracketRight',
121+
Comma: 'Comma',
122+
Period: 'Period',
123+
Semicolon: 'Semicolon',
124+
Quote: 'Quote',
125+
Backslash: 'Backslash',
126+
Slash: 'Slash',
127+
Minus: 'Minus',
128+
Equal: 'Equal',
129+
130+
// Numpad Keys
131+
NumLock: 'NumLock',
132+
Numpad0: 'Numpad0',
133+
Numpad1: 'Numpad1',
134+
Numpad2: 'Numpad2',
135+
Numpad3: 'Numpad3',
136+
Numpad4: 'Numpad4',
137+
Numpad5: 'Numpad5',
138+
Numpad6: 'Numpad6',
139+
Numpad7: 'Numpad7',
140+
Numpad8: 'Numpad8',
141+
Numpad9: 'Numpad9',
142+
NumpadAdd: 'NumpadAdd',
143+
NumpadDecimal: 'NumpadDecimal',
144+
NumpadDivide: 'NumpadDivide',
145+
NumpadEnter: 'NumpadEnter',
146+
NumpadEqual: 'NumpadEqual',
147+
NumpadMultiply: 'NumpadMultiply',
148+
NumpadSubtract: 'NumpadSubtract',
149+
NumpadComma: 'NumpadComma',
150+
151+
// Media Keys
152+
MediaPlayPause: 'MediaPlayPause',
153+
MediaStop: 'MediaStop',
154+
MediaTrackNext: 'MediaTrackNext',
155+
MediaTrackPrevious: 'MediaTrackPrevious',
156+
AudioVolumeMute: 'AudioVolumeMute',
157+
AudioVolumeDown: 'AudioVolumeDown',
158+
AudioVolumeUp: 'AudioVolumeUp',
159+
160+
// Browser Keys
161+
BrowserBack: 'BrowserBack',
162+
BrowserFavorites: 'BrowserFavorites',
163+
BrowserForward: 'BrowserForward',
164+
BrowserHome: 'BrowserHome',
165+
BrowserRefresh: 'BrowserRefresh',
166+
BrowserSearch: 'BrowserSearch',
167+
BrowserStop: 'BrowserStop',
168+
169+
// Special Keys
170+
LaunchApp1: 'LaunchApp1',
171+
LaunchApp2: 'LaunchApp2',
172+
LaunchMail: 'LaunchMail',
173+
MediaSelect: 'MediaSelect',
174+
ContextMenu: 'ContextMenu',
175+
Power: 'Power',
176+
Sleep: 'Sleep',
177+
WakeUp: 'WakeUp',
178+
179+
// International Keys
180+
IntlBackslash: 'IntlBackslash',
181+
IntlRo: 'IntlRo',
182+
IntlYen: 'IntlYen',
183+
KanaMode: 'KanaMode',
184+
Lang1: 'Lang1',
185+
Lang2: 'Lang2',
186+
Convert: 'Convert',
187+
NonConvert: 'NonConvert',
188+
} as const;
189+
190+
export type KeyCode = keyof typeof keyCodes;
191+
192+
export const characterMappings = {
193+
'`': keyCodes.Backquote,
194+
'-': keyCodes.Minus,
195+
'=': keyCodes.Equal,
196+
'[': keyCodes.BracketLeft,
197+
']': keyCodes.BracketRight,
198+
'\\': keyCodes.Backslash,
199+
';': keyCodes.Semicolon,
200+
"'": keyCodes.Quote,
201+
',': keyCodes.Comma,
202+
'.': keyCodes.Period,
203+
'/': keyCodes.Slash,
204+
up: 'ArrowUp',
205+
down: 'ArrowDown',
206+
left: 'ArrowLeft',
207+
right: 'ArrowRight',
208+
} as const;
209+
210+
export type CharacterKey = keyof typeof characterMappings;

0 commit comments

Comments
 (0)