diff --git a/web/src/lib/__tests__/keyboardFocus.test.ts b/web/src/lib/__tests__/keyboardFocus.test.ts index 84f9273..2a6d476 100644 --- a/web/src/lib/__tests__/keyboardFocus.test.ts +++ b/web/src/lib/__tests__/keyboardFocus.test.ts @@ -11,8 +11,8 @@ import { isTextEntry, keyboard } from "@/lib/input/keyboard"; * swallows a keystroke in the first place. */ -const pressFrom = (el: Element, key: string) => { - const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); +const pressFrom = (el: EventTarget, key: string, init?: KeyboardEventInit) => { + const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }); el.dispatchEvent(e); return e; }; @@ -92,3 +92,22 @@ describe("shortcuts with a checkbox focused", () => { expect(handler).not.toHaveBeenCalled(); }); }); + +describe("shifted letter shortcuts", () => { + it("matches Shift+I and Shift+U bindings", () => { + const read = vi.fn(); + const unread = vi.fn(); + pop = keyboard.pushScope("test", [ + { keys: "shift+i", description: "Mark as read", group: "Actions", handler: read }, + { keys: "shift+u", description: "Mark as unread", group: "Actions", handler: unread }, + ]); + + const readEvent = pressFrom(window, "I", { shiftKey: true }); + const unreadEvent = pressFrom(window, "U", { shiftKey: true }); + + expect(read).toHaveBeenCalledOnce(); + expect(unread).toHaveBeenCalledOnce(); + expect(readEvent.defaultPrevented).toBe(true); + expect(unreadEvent.defaultPrevented).toBe(true); + }); +}); diff --git a/web/src/lib/__tests__/keyboardKeyless.test.ts b/web/src/lib/__tests__/keyboardKeyless.test.ts index d542a8f..41cd971 100644 --- a/web/src/lib/__tests__/keyboardKeyless.test.ts +++ b/web/src/lib/__tests__/keyboardKeyless.test.ts @@ -36,7 +36,7 @@ describe("a keydown with no key", () => { it("leaves real keys alone", () => { expect(comboOf(new KeyboardEvent("keydown", { key: "e" }))).toBe("e"); - expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("E"); + expect(comboOf(new KeyboardEvent("keydown", { key: "E", shiftKey: true }))).toBe("shift+e"); expect(comboOf(new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }))).toMatch(/enter$/); }); }); diff --git a/web/src/lib/input/keyboard.ts b/web/src/lib/input/keyboard.ts index aa81f53..d8b87a6 100644 --- a/web/src/lib/input/keyboard.ts +++ b/web/src/lib/input/keyboard.ts @@ -145,13 +145,14 @@ export function comboOf(e: KeyboardEvent): string | null { const mod = isMac ? e.metaKey : e.ctrlKey; if (mod) parts.push("mod"); if (e.altKey) parts.push("alt"); - if (e.shiftKey && key.length > 1) parts.push("shift"); + const shiftedLetter = e.shiftKey && /^[a-z]$/i.test(key); + if (e.shiftKey && (key.length > 1 || shiftedLetter)) parts.push("shift"); let k = key; if (k === " ") k = "space"; else if (k === "Escape") k = "esc"; else if (k.length === 1) { - // Single chars: shift is encoded by the character itself (e.g. "#", "!"). - k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k; + // Symbols encode Shift in the character itself (e.g. "#", "!"). + k = shiftedLetter || !e.shiftKey ? k.toLowerCase() : k; } else k = k.toLowerCase(); parts.push(k); return parts.join("+");