handles keyboard shortcuts sensibly
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: stop inhibit flags from matching partial key names

+104 -22
+17 -4
README.md
··· 49 49 - `<select>` 50 50 - Jump to value (`a`, `s`, ...) 51 51 52 - `createKeybindHandler` will also call `.preventDefault()` on matching events by 53 - default, you can disable this by passing `false` as the second parameter. It's 54 - recommended to just write your own handler however if you need flexibility, all 55 - the relevant functions are exported. 52 + Prefix a keybind with `!` to opt out of all of the above. 53 + 54 + ```ts 55 + const handler = createKeybindHandler({ 56 + '!ArrowDown'() { 57 + // Runs even inside a text field, for a combobox listbox... 58 + }, 59 + }); 60 + ``` 61 + 62 + `createKeybindHandler` will call `.preventDefault()` on matching events by 63 + default, you can disable this by passing `false` as the second parameter. Events 64 + that another handler has already called `.preventDefault()` on are skipped 65 + either way. 66 + 67 + I'd recommend writing your own handler if you need more flexibility, 68 + all the relevant functions are exported. 56 69 57 70 ## License 58 71
+53 -1
lib/index.test.ts
··· 1 1 import { describe, it, expect } from 'bun:test'; 2 2 3 - import { InhibitFlags, parseKeybind } from './index.js'; 3 + import { InhibitFlags, MOD, parseKeybind } from './index.js'; 4 4 5 5 // @ts-expect-error 6 6 const { ARROW, ENTER, ENTER_EXTRA, SELECT, SPACE, TEXT } = InhibitFlags; ··· 47 47 ['$mod+z', TEXT], 48 48 // enter action 49 49 ['Enter', ENTER], 50 + ['NumpadEnter', ENTER], 50 51 ['$mod+Enter', ENTER_EXTRA], 51 52 ['$mod+Shift+Enter', ENTER_EXTRA], 53 + // keys whose names merely start like a modifier 54 + ['Comma', SELECT | TEXT], 55 + ['End', SELECT | TEXT], 56 + ['Home', SELECT | TEXT], 57 + ['ContextMenu', SELECT | TEXT], 58 + // per-word deletion and per-document positioning 59 + ['$mod+Delete', TEXT], 60 + ['$mod+Home', TEXT], 61 + ['$mod+End', TEXT], 62 + ['$mod+Shift+Home', TEXT], 63 + ['$mod+Shift+End', TEXT], 64 + // redo, and paste without formatting 65 + ['$mod+Shift+z', TEXT], 66 + ['$mod+Shift+v', TEXT], 67 + // `event.code` spellings have to inhibit the same as `event.key` ones 68 + ['KeyA', SELECT | TEXT], 69 + ['$mod+KeyA', TEXT], 70 + ['$mod+KeyC', TEXT], 71 + ['$mod+KeyV', TEXT], 72 + ['$mod+Shift+KeyZ', TEXT], 73 + // keys that don't produce text 74 + ['Escape', 0], 75 + ['Tab', 0], 76 + ['F5', 0], 77 + ['CapsLock', 0], 78 + ['Shift+Tab', 0], 79 + // bindings that shouldn't be inhibited at all 80 + ['$mod+k', 0], 81 + ['$mod+KeyK', 0], 82 + ['$mod+Shift+k', 0], 52 83 ])('%s', (keybind, expected) => { 53 84 const [_mods, _key, flags] = parseKeybind(keybind); 54 85 expect(flags).toBe(expected); 55 86 }); 87 + 88 + it.each([ 89 + ['!s', 0], 90 + ['!ArrowDown', 0], 91 + ['!Enter', 0], 92 + ['!$mod+a', 0], 93 + ])('%s opts out of inhibition', (keybind, expected) => { 94 + const [_mods, _key, flags] = parseKeybind(keybind); 95 + expect(flags).toBe(expected); 96 + }); 97 + }); 98 + 99 + it('resolves $mod', () => { 100 + const [mods] = parseKeybind('$mod+Shift+k'); 101 + expect(mods).toEqual([MOD, 'Shift']); 102 + }); 103 + 104 + it('rejects unknown modifiers', () => { 105 + expect(() => parseKeybind('Ctrl+k')).toThrow(TypeError); 106 + expect(() => parseKeybind('$mod+Option+k')).toThrow(TypeError); 107 + expect(() => parseKeybind('$mod+Shift+k')).not.toThrow(); 56 108 }); 57 109 });
+34 -17
lib/index.ts
··· 58 58 range: InhibitFlags.ARROW, 59 59 }; 60 60 61 + // Keys that neither insert nor edit text 62 + const NON_TEXT = /Alt|Control|Escape|F\d|Lock|Meta|Tab|Enter/; 63 + // ...plus deletion, which Shift alone doesn't turn into text insertion 64 + const NON_TEXT_SHIFT = /Alt|Control|Escape|F\d|Lock|Meta|Tab|Enter|Backspace|Delete/; 65 + // Native text editing shortcuts, matching both `event.key` and `event.code` spellings 66 + const MOD_EDIT = /^(?:Key)?[acvxyz]$|Arrow|Backspace|Delete|Home|End/i; 67 + const MOD_SHIFT_EDIT = /^(?:Key)?[vz]$|Arrow|Home|End/i; 68 + 61 69 export const parseKeybind = (keybind: string): Keybind => { 62 - const mods = keybind 70 + // Prefixing with `!` opts out of every inhibition below 71 + const escaped = keybind[0] == '!'; 72 + 73 + const mods = (escaped ? keybind.slice(1) : keybind) 63 74 .replace(/\s/g, '') 64 75 .split(/\b\+/) 65 76 .map((mod) => (mod == '$mod' ? MOD : mod)); 66 77 67 78 const key = mods.pop()!; 68 79 80 + // Typo'd modifiers would otherwise never match, silently 81 + const unknown = mods.find((mod) => !MODIFIERS.includes(mod)); 82 + if (unknown !== undefined) { 83 + throw new TypeError(`unknown modifier: ${unknown}`); 84 + } 85 + 86 + if (escaped) { 87 + return [mods, key, 0]; 88 + } 89 + 69 90 const len = mods.length; 70 91 const standalone = len == 0; 71 92 const shiftModifierOnly = len == 1 && mods[0] == 'Shift'; ··· 75 96 let flags = 0; 76 97 77 98 if ( 78 - // Alt | Control | Escape | Meta | Tab | Enter | ScrollLock 79 - (standalone && !/Al|Co|Es|F\d|Me|Ta|En|Sc/.test(key)) || 80 - // Alt | Control | Escape | Meta | Tab | Enter | Backspace | *Lock | Delete 81 - (shiftModifierOnly && !/Al|Co|Es|F\d|Me|Ta|En|Ba|Lo|De/.test(key)) || 82 - // Arrow* | Back 83 - (modModifierOnly && /^[acvxyz]|Ar|Ba/.test(key)) || 84 - // Arrow 85 - (modShiftModifierOnly && /V$|Ar/.test(key)) 99 + (standalone && !NON_TEXT.test(key)) || 100 + (shiftModifierOnly && !NON_TEXT_SHIFT.test(key)) || 101 + (modModifierOnly && MOD_EDIT.test(key)) || 102 + (modShiftModifierOnly && MOD_SHIFT_EDIT.test(key)) 86 103 ) { 87 104 flags |= InhibitFlags.TEXT; 88 105 } 89 106 90 - // Alt | Control | Escape | Meta | Tab | Enter | Backspace | *Lock | Delete 91 - if (standalone && !/Al|Co|Es|F\d|Me|Ta|En|Ba|Lo|De/.test(key)) { 107 + if (standalone && !NON_TEXT_SHIFT.test(key)) { 92 108 flags |= InhibitFlags.SELECT; 93 109 } 94 110 95 - if (standalone && /Ar/.test(key)) { 111 + if (standalone && /Arrow/.test(key)) { 96 112 flags |= InhibitFlags.ARROW; 97 113 } 98 114 99 - if (standalone && /Sp/.test(key)) { 115 + if (standalone && /Space/.test(key)) { 100 116 flags |= InhibitFlags.SPACE; 101 117 } 102 118 103 - if (standalone && /En/.test(key)) { 119 + if (standalone && /Enter/.test(key)) { 104 120 flags |= InhibitFlags.ENTER; 105 121 } 106 122 107 - if ((shiftModifierOnly || modModifierOnly || modShiftModifierOnly) && /En/.test(key)) { 123 + if ((shiftModifierOnly || modModifierOnly || modShiftModifierOnly) && /Enter/.test(key)) { 108 124 flags |= InhibitFlags.ENTER_EXTRA; 109 125 } 110 126 ··· 119 135 * @returns Whether keybind can proceed to be matched 120 136 */ 121 137 export const isKeybindAllowed = (ev: KeyboardEvent, flags: number): boolean => { 122 - const target = ev.target; 138 + // `event.target` is retargeted to the host for events inside a shadow root 139 + const target = ev.composedPath()[0]; 123 140 124 141 if (target instanceof HTMLInputElement) { 125 142 const type = target.type; ··· 192 209 }); 193 210 194 211 const listener = (ev: KeyboardEvent) => { 195 - if (preventDefault && ev.defaultPrevented) { 212 + if (ev.defaultPrevented) { 196 213 return; 197 214 } 198 215