diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 092d4b6..f254834 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -22,6 +22,63 @@ const NON_BUBBLING = new Set(["mouseenter", "mouseleave", "focus", "blur"]); // Wire MIME for the dragged element's data-key. Set on dragstart, read on drop. const LVT_DRAG_MIME = "application/x-lvt-key"; +/** + * Match a KeyboardEvent against an `lvt-key` filter. + * + * The filter is either a bare `KeyboardEvent.key` value ("Enter", "Escape", + * "j", "+") — matched verbatim, regardless of modifiers, for backward + * compatibility — or a modifier combo joined by "+", whose FINAL segment is + * the key and whose leading segments are required modifiers: + * + * Mod metaKey || ctrlKey (the platform command key: ⌘ macOS, Ctrl elsewhere) + * Meta / Cmd metaKey + * Ctrl / Control ctrlKey + * Shift shiftKey + * Alt / Option altKey + * + * e.g. `lvt-key="Mod+Enter"`, `lvt-key="Shift+ArrowDown"`. Matching is lenient: + * modifiers beyond those named are ignored. Returns `combo` so the caller can + * `preventDefault()` a chord (a deliberate Mod+Enter shouldn't also type a + * newline into the focused field). The literal "+" key stays matchable as the + * bare filter "+". + */ +export function keyFilterMatches( + e: KeyboardEvent, + filter: string +): { matched: boolean; combo: boolean } { + if (!filter.includes("+") || filter === "+") { + return { matched: e.key === filter, combo: false }; + } + const parts = filter.split("+"); + const key = parts[parts.length - 1]; + if (e.key !== key) return { matched: false, combo: true }; + for (const mod of parts.slice(0, -1)) { + switch (mod.toLowerCase()) { + case "mod": + if (!(e.metaKey || e.ctrlKey)) return { matched: false, combo: true }; + break; + case "meta": + case "cmd": + if (!e.metaKey) return { matched: false, combo: true }; + break; + case "ctrl": + case "control": + if (!e.ctrlKey) return { matched: false, combo: true }; + break; + case "shift": + if (!e.shiftKey) return { matched: false, combo: true }; + break; + case "alt": + case "option": + if (!e.altKey) return { matched: false, combo: true }; + break; + default: + return { matched: false, combo: true }; // unknown modifier token + } + } + return { matched: true, combo: true }; +} + const DRAG_EVENTS = new Set([ "dragstart", "dragover", @@ -384,9 +441,16 @@ export class EventDelegator { ) { const keyFilter = actionElement.getAttribute("lvt-key"); const keyboardEvent = e as KeyboardEvent; - if (keyFilter && keyboardEvent.key !== keyFilter) { - element = element.parentElement; - continue; + if (keyFilter) { + const { matched, combo } = keyFilterMatches( + keyboardEvent, + keyFilter + ); + if (!matched) { + element = element.parentElement; + continue; + } + if (combo) keyboardEvent.preventDefault(); } } @@ -754,8 +818,15 @@ export class EventDelegator { const keyboardEvent = e as KeyboardEvent; if (element.hasAttribute("lvt-key")) { const keyFilter = element.getAttribute("lvt-key"); - if (keyFilter && keyboardEvent.key !== keyFilter) { - return; + if (keyFilter) { + const { matched, combo } = keyFilterMatches( + keyboardEvent, + keyFilter + ); + if (!matched) { + return; + } + if (combo) keyboardEvent.preventDefault(); } } diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index d2c12d6..cb14027 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -1,6 +1,7 @@ import { EventDelegator, EventDelegationContext, + keyFilterMatches, } from "../dom/event-delegation"; import { createLogger } from "../utils/logger"; @@ -658,6 +659,36 @@ describe("EventDelegator", () => { expect(context.send).not.toHaveBeenCalled(); }); + it("fires a Mod+Enter binding on Cmd/Ctrl+Enter and prevents the default", () => { + const { context } = setupWindowKeydown( + "wrapper-mod-enter", + `
` + ); + const e = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + cancelable: true, + }); + const prevented = jest.spyOn(e, "preventDefault"); + window.dispatchEvent(e); + + expect(context.send).toHaveBeenCalledWith( + expect.objectContaining({ action: "addComment" }) + ); + // The chord is consumed so it doesn't also type a newline into the field. + expect(prevented).toHaveBeenCalled(); + }); + + it("does NOT fire a Mod+Enter binding on a plain Enter (newline preserved)", () => { + const { context } = setupWindowKeydown( + "wrapper-mod-enter-plain", + `
` + ); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + + expect(context.send).not.toHaveBeenCalled(); + }); + it("suppresses an opted-in binding while a text input is focused", () => { const { wrapper, context } = setupWindowKeydown( "wrapper-guard-input", @@ -1954,3 +1985,69 @@ describe("EventDelegator", () => { }); }); }); + +describe("keyFilterMatches (lvt-key)", () => { + const ev = (key: string, mods: Partial = {}) => + new KeyboardEvent("keydown", { key, ...mods }); + + it("matches a bare key verbatim regardless of modifiers (back-compat)", () => { + expect(keyFilterMatches(ev("Enter"), "Enter")).toEqual({ + matched: true, + combo: false, + }); + // A bare "Enter" filter still matches a modified Enter — unchanged from + // the old exact-equality behavior, so existing bindings are untouched. + expect(keyFilterMatches(ev("Enter", { metaKey: true }), "Enter")).toEqual({ + matched: true, + combo: false, + }); + expect(keyFilterMatches(ev("Escape"), "Enter").matched).toBe(false); + expect(keyFilterMatches(ev("j"), "j").matched).toBe(true); + }); + + it("matches the literal '+' key as a bare filter", () => { + expect(keyFilterMatches(ev("+"), "+")).toEqual({ + matched: true, + combo: false, + }); + }); + + it("Mod+Enter matches metaKey OR ctrlKey, never a plain Enter", () => { + expect(keyFilterMatches(ev("Enter", { metaKey: true }), "Mod+Enter")).toEqual( + { matched: true, combo: true } + ); + expect(keyFilterMatches(ev("Enter", { ctrlKey: true }), "Mod+Enter")).toEqual( + { matched: true, combo: true } + ); + expect(keyFilterMatches(ev("Enter"), "Mod+Enter")).toEqual({ + matched: false, + combo: true, + }); + }); + + it("distinguishes Meta+ from Ctrl+", () => { + expect(keyFilterMatches(ev("Enter", { metaKey: true }), "Meta+Enter").matched).toBe(true); + expect(keyFilterMatches(ev("Enter", { ctrlKey: true }), "Meta+Enter").matched).toBe(false); + expect(keyFilterMatches(ev("Enter", { ctrlKey: true }), "Control+Enter").matched).toBe(true); + expect(keyFilterMatches(ev("Enter", { metaKey: true }), "Ctrl+Enter").matched).toBe(false); + }); + + it("supports Shift, Alt, and multi-modifier combos", () => { + expect(keyFilterMatches(ev("ArrowDown", { shiftKey: true }), "Shift+ArrowDown").matched).toBe(true); + expect(keyFilterMatches(ev("ArrowDown"), "Shift+ArrowDown").matched).toBe(false); + expect(keyFilterMatches(ev("s", { metaKey: true, shiftKey: true }), "Mod+Shift+s").matched).toBe(true); + expect(keyFilterMatches(ev("s", { metaKey: true }), "Mod+Shift+s").matched).toBe(false); + }); + + it("is lenient about extra modifiers beyond those named", () => { + expect(keyFilterMatches(ev("Enter", { metaKey: true, shiftKey: true }), "Mod+Enter").matched).toBe(true); + }); + + it("rejects an unknown modifier token", () => { + expect(keyFilterMatches(ev("Enter", { metaKey: true }), "Hyper+Enter").matched).toBe(false); + }); + + it("requires the key to match even with the right modifiers", () => { + expect(keyFilterMatches(ev("Escape", { metaKey: true }), "Mod+Enter").matched).toBe(false); + }); +});