diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 35935ad..092d4b6 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -31,6 +31,42 @@ const DRAG_EVENTS = new Set([ "dragleave", ]); +// Elements where the user is "typing" — text-entry inputs, textareas, selects +// (type-ahead), and contenteditable regions. Inputs that act as buttons rather +// than text fields — button/submit/reset/checkbox/radio/image/file — are +// deliberately excluded: they accept activation keys, not text, so a global +// shortcut should still fire when one of them is focused. The rest stay in +// scope, including the keys-but-no-typing cases: input[type="range"] (arrows +// move the slider) and input[type="number"] (arrows step + you type digits) — +// suppressing shortcuts while one is focused lets the control consume the key. +// Used by the opt-in `lvt-mod:skip-when-typing` guard on window key bindings. +const EDITABLE_SELECTOR = [ + 'input:not([type="button"]):not([type="submit"]):not([type="reset"])' + + ':not([type="checkbox"]):not([type="radio"]):not([type="image"]):not([type="file"])' + + ':not([type="color"])', + "textarea", + "select", + '[contenteditable=""]', + '[contenteditable="true"]', + '[contenteditable="plaintext-only"]', +].join(", "); + +// deepActiveElement resolves the *actually* focused element across shadow +// boundaries. document.activeElement returns the shadow HOST when focus is +// inside a web component's shadow root, so we descend through shadowRoot. +// activeElement (recursively, for nested shadow DOM) to reach the real input. +function deepActiveElement(): Element | null { + let el: Element | null = document.activeElement; + while (el?.shadowRoot?.activeElement) { + el = el.shadowRoot.activeElement; + } + return el; +} + +function isEditableTarget(node: Element | null): boolean { + return node !== null && node.closest(EDITABLE_SELECTOR) !== null; +} + export interface EventDelegationContext { getWrapperElement(): Element | null; getRateLimitedHandlers(): WeakMap>; @@ -714,13 +750,25 @@ export class EventDelegator { const action = element.getAttribute(attrName); if (!action) return; - if ( - (eventType === "keydown" || eventType === "keyup") && - element.hasAttribute("lvt-key") - ) { - const keyFilter = element.getAttribute("lvt-key"); + if (eventType === "keydown" || eventType === "keyup") { const keyboardEvent = e as KeyboardEvent; - if (keyFilter && keyboardEvent.key !== keyFilter) { + if (element.hasAttribute("lvt-key")) { + const keyFilter = element.getAttribute("lvt-key"); + if (keyFilter && keyboardEvent.key !== keyFilter) { + return; + } + } + + // Opt-in guard: suppress this binding while the user is typing in an + // editable element, so e.g. "j" typed into a comment box doesn't + // trigger a navigation shortcut. Applies whenever the attribute is + // present — independent of lvt-key — so a guarded binding without a + // key filter is still suppressed while typing. Bindings WITHOUT this + // attribute (e.g. Escape-to-cancel) still fire while typing. + if ( + element.hasAttribute("lvt-mod:skip-when-typing") && + isEditableTarget(deepActiveElement()) + ) { return; } } diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index 860db51..d2c12d6 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -623,6 +623,175 @@ describe("EventDelegator", () => { }); }); + describe("window keydown skip-when-typing guard", () => { + const setupWindowKeydown = (wrapperId: string, innerHTML: string) => { + const wrapper = document.createElement("div"); + wrapper.setAttribute("data-lvt-id", wrapperId); + wrapper.innerHTML = innerHTML; + document.body.appendChild(wrapper); + + const context = createContext(wrapper); + const delegator = new EventDelegator( + context, + createLogger({ scope: "EventDelegatorTest", level: "silent" }) + ); + delegator.setupWindowEventDelegation(); + return { wrapper, context }; + }; + + const pressKey = (key: string) => { + window.dispatchEvent(new KeyboardEvent("keydown", { key })); + }; + + it("suppresses an opted-in binding while a textarea is focused", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-textarea", + ` +
+ + ` + ); + (wrapper.querySelector("#composer") as HTMLTextAreaElement).focus(); + + pressKey("j"); + + expect(context.send).not.toHaveBeenCalled(); + }); + + it("suppresses an opted-in binding while a text input is focused", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-input", + ` +
+ + ` + ); + (wrapper.querySelector("#filter") as HTMLInputElement).focus(); + + pressKey("j"); + + expect(context.send).not.toHaveBeenCalled(); + }); + + it("suppresses an opted-in binding while a contenteditable region is focused", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-ce", + ` +
+
+ ` + ); + (wrapper.querySelector("#rich") as HTMLElement).focus(); + + pressKey("j"); + + expect(context.send).not.toHaveBeenCalled(); + }); + + it("suppresses an opted-in binding while a shadow-DOM input is focused", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-shadow", + ` +
+
+ ` + ); + // document.activeElement returns the shadow HOST, so the guard must + // descend through shadowRoot.activeElement to find the real input. + const host = wrapper.querySelector("#host") as HTMLElement; + const root = host.attachShadow({ mode: "open" }); + const input = document.createElement("textarea"); + root.appendChild(input); + input.focus(); + + pressKey("j"); + + expect(context.send).not.toHaveBeenCalled(); + }); + + it("fires an opted-in binding when focus is on a non-editable element", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-button", + ` +
+ + ` + ); + (wrapper.querySelector("#go") as HTMLButtonElement).focus(); + + pressKey("j"); + + expect(context.send).toHaveBeenCalledTimes(1); + expect(context.send).toHaveBeenCalledWith({ action: "nextFile", data: {} }); + }); + + it("suppresses an opted-in binding while a range input is focused (arrows adjust the slider)", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-range", + ` +
+ + ` + ); + (wrapper.querySelector("#slider") as HTMLInputElement).focus(); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + + // range inputs are intentionally "editable": the arrow goes to the slider, + // not the shortcut. + expect(context.send).not.toHaveBeenCalled(); + }); + + it("guards keyup bindings too (skip-when-typing is not keydown-only)", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-keyup", + ` +
+ + ` + ); + (wrapper.querySelector("#composer") as HTMLTextAreaElement).focus(); + + window.dispatchEvent(new KeyboardEvent("keyup", { key: "j" })); + + expect(context.send).not.toHaveBeenCalled(); + }); + + it("guards a skip-when-typing binding even without an lvt-key filter", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-nokey", + ` +
+ + ` + ); + (wrapper.querySelector("#composer") as HTMLTextAreaElement).focus(); + + pressKey("x"); // any key — no lvt-key filter on the binding + + expect(context.send).not.toHaveBeenCalled(); + }); + + it("fires a binding WITHOUT the opt-in even while a textarea is focused (Escape case)", () => { + const { wrapper, context } = setupWindowKeydown( + "wrapper-guard-escape", + ` +
+ + ` + ); + (wrapper.querySelector("#composer") as HTMLTextAreaElement).focus(); + + pressKey("Escape"); + + expect(context.send).toHaveBeenCalledTimes(1); + expect(context.send).toHaveBeenCalledWith({ + action: "clearSelection", + data: {}, + }); + }); + }); + describe("orphan buttons (formless standalone)", () => { it("button with name triggers action", () => { const wrapper = document.createElement("div");