From 955ac8114707fdf46489a2086f9eccf282b0a174 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 04:34:04 +0000 Subject: [PATCH 1/8] Add opt-in lvt-mod:skip-when-typing guard for window keyboard bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global lvt-on:window:keydown shortcuts previously fired even while the user was typing in a text field, so a single-letter shortcut (e.g. "j") could not coexist with text entry. Add an opt-in lvt-mod:skip-when-typing attribute: when present, the binding is suppressed if document.activeElement is (or is within) an editable element — text-like input, textarea, select, or contenteditable. Button/checkbox/radio inputs are not treated as editable, and bindings without the attribute (e.g. Escape-to-cancel) still fire while typing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 29 ++++++++++ tests/event-delegation.test.ts | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 35935ad..b994016 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -31,6 +31,24 @@ const DRAG_EVENTS = new Set([ "dragleave", ]); +// Elements where the user is "typing" — text-entry inputs, textareas, selects +// (type-ahead), and contenteditable regions. Button/checkbox/radio inputs are +// deliberately excluded: they accept activation keys, not text, so a global +// shortcut should still fire when one of them is focused. Used by the opt-in +// `lvt-mod:skip-when-typing` guard on window keyboard bindings. +const EDITABLE_SELECTOR = [ + 'input:not([type="button"]):not([type="submit"]):not([type="reset"])' + + ':not([type="checkbox"]):not([type="radio"])', + "textarea", + "select", + '[contenteditable=""]', + '[contenteditable="true"]', +].join(", "); + +function isEditableTarget(node: EventTarget | null): boolean { + return node instanceof Element && node.closest(EDITABLE_SELECTOR) !== null; +} + export interface EventDelegationContext { getWrapperElement(): Element | null; getRateLimitedHandlers(): WeakMap>; @@ -723,6 +741,17 @@ export class EventDelegator { 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. Bindings WITHOUT this attribute + // (e.g. Escape-to-cancel) still fire while typing. + if ( + element.hasAttribute("lvt-mod:skip-when-typing") && + isEditableTarget(document.activeElement) + ) { + return; + } } const message: any = { action, data: {} }; diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index 860db51..1a5b685 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -623,6 +623,107 @@ 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("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("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"); From bb26e938df1930be1ebc97385e00293e5e42e722 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 17:52:30 +0000 Subject: [PATCH 2/8] Resolve deep active element across shadow DOM in skip-when-typing guard Addresses the Claude review on #137: document.activeElement returns the shadow HOST when focus is inside a web component's shadow root, so isEditableTarget would miss a focused input there and let a shortcut fire while the user types. Add deepActiveElement(), which descends through shadowRoot.activeElement (recursively, for nested shadow DOM), and use it in the window-keydown guard. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 14 +++++++++++++- tests/event-delegation.test.ts | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index b994016..cc42dce 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -45,6 +45,18 @@ const EDITABLE_SELECTOR = [ '[contenteditable="true"]', ].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: EventTarget | null): boolean { return node instanceof Element && node.closest(EDITABLE_SELECTOR) !== null; } @@ -748,7 +760,7 @@ export class EventDelegator { // (e.g. Escape-to-cancel) still fire while typing. if ( element.hasAttribute("lvt-mod:skip-when-typing") && - isEditableTarget(document.activeElement) + isEditableTarget(deepActiveElement()) ) { return; } diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index 1a5b685..562b02b 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -688,6 +688,27 @@ describe("EventDelegator", () => { 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", From 0cb37ba0157ca9579528e5e959feecb5ac75aee8 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 17:57:32 +0000 Subject: [PATCH 3/8] Address #137 review nits: exclude input[type=image]; cover keyup guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude input[type="image"] from EDITABLE_SELECTOR — it's a submit-style button, not a text field, so shortcuts should fire while it's focused. - Add a test confirming the skip-when-typing guard also applies to keyup bindings (the guard already covers keydown and keyup; this pins it). The reviewer's third point (test DOM cleanup) is already handled: the suite's beforeEach/afterEach both reset document.body.innerHTML, which removes appended nodes including the shadow host. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 2 +- tests/event-delegation.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index cc42dce..e6025a6 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -38,7 +38,7 @@ const DRAG_EVENTS = new Set([ // `lvt-mod:skip-when-typing` guard on window keyboard bindings. const EDITABLE_SELECTOR = [ 'input:not([type="button"]):not([type="submit"]):not([type="reset"])' - + ':not([type="checkbox"]):not([type="radio"])', + + ':not([type="checkbox"]):not([type="radio"]):not([type="image"])', "textarea", "select", '[contenteditable=""]', diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index 562b02b..6c04f0a 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -725,6 +725,21 @@ describe("EventDelegator", () => { expect(context.send).toHaveBeenCalledWith({ action: "nextFile", data: {} }); }); + 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("fires a binding WITHOUT the opt-in even while a textarea is focused (Escape case)", () => { const { wrapper, context } = setupWindowKeydown( "wrapper-guard-escape", From 689f5ea519341fe50b1c7a47bcaa097eb1f73f37 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 18:01:16 +0000 Subject: [PATCH 4/8] Address #137 review: tighten isEditableTarget param type; note range input - isEditableTarget now takes Element | null (its only caller passes deepActiveElement()'s Element | null result); the instanceof guard is kept. - Document that input[type="range"] is intentionally treated as editable so arrow-bound shortcuts yield to a focused slider. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index e6025a6..b24dcf5 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -32,10 +32,13 @@ const DRAG_EVENTS = new Set([ ]); // Elements where the user is "typing" — text-entry inputs, textareas, selects -// (type-ahead), and contenteditable regions. Button/checkbox/radio inputs are -// deliberately excluded: they accept activation keys, not text, so a global -// shortcut should still fire when one of them is focused. Used by the opt-in -// `lvt-mod:skip-when-typing` guard on window keyboard bindings. +// (type-ahead), and contenteditable regions. Button/submit/reset/checkbox/ +// radio/image inputs are deliberately excluded: they accept activation keys, +// not text, so a global shortcut should still fire when one of them is focused. +// input[type="range"] is intentionally NOT excluded — it responds to arrow +// keys, so suppressing arrow-bound shortcuts while a slider is focused lets the +// slider consume the arrows. Used by the opt-in `lvt-mod:skip-when-typing` +// guard on window keyboard bindings. const EDITABLE_SELECTOR = [ 'input:not([type="button"]):not([type="submit"]):not([type="reset"])' + ':not([type="checkbox"]):not([type="radio"]):not([type="image"])', @@ -57,7 +60,7 @@ function deepActiveElement(): Element | null { return el; } -function isEditableTarget(node: EventTarget | null): boolean { +function isEditableTarget(node: Element | null): boolean { return node instanceof Element && node.closest(EDITABLE_SELECTOR) !== null; } From 16a29591cd3103808d404d18b9ef9099bb470885 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 18:05:24 +0000 Subject: [PATCH 5/8] Pin input[type=range] guard behavior with a test (#137 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a test asserting an arrow-bound skip-when-typing shortcut is suppressed while a range input is focused, so the slider consumes the arrow — pinning the documented intentional inclusion of range in EDITABLE_SELECTOR. (The reviewer's second point — test DOM cleanup — is already covered by the suite's top-level beforeEach/afterEach, both of which reset document.body.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- tests/event-delegation.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index 6c04f0a..0eccd6d 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -725,6 +725,23 @@ describe("EventDelegator", () => { 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", From ad3247a2181096331a2fa95cbe3abdf219ae9e5a Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 18:09:49 +0000 Subject: [PATCH 6/8] Apply skip-when-typing guard independent of lvt-key; match plaintext-only CE Addresses #137 review: - The guard lived inside the `hasAttribute("lvt-key")` block, so a binding with `lvt-mod:skip-when-typing` but no `lvt-key` was silently never guarded. Move the guard out so it applies to any keydown/keyup binding that opts in, regardless of a key filter (test added). - Match `contenteditable="plaintext-only"` (a distinct editable mode in Chrome) in EDITABLE_SELECTOR. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 20 +++++++++++--------- tests/event-delegation.test.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index b24dcf5..f07362d 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -46,6 +46,7 @@ const EDITABLE_SELECTOR = [ "select", '[contenteditable=""]', '[contenteditable="true"]', + '[contenteditable="plaintext-only"]', ].join(", "); // deepActiveElement resolves the *actually* focused element across shadow @@ -747,20 +748,21 @@ 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) { - return; + 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. Bindings WITHOUT this attribute - // (e.g. Escape-to-cancel) still fire while typing. + // 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()) diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index 0eccd6d..d2c12d6 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -757,6 +757,21 @@ describe("EventDelegator", () => { 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", From 18d3390089f482ecdf1c2bbfa9c1d35b3402e016 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 18:13:50 +0000 Subject: [PATCH 7/8] Exclude input[type=file] from editable set; document number input (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - file inputs are button-like (open a picker, take no typed text) — exclude them so shortcuts fire while one is focused, consistent with button/image. - Clarify the rationale comment that input[type=number] is intentionally editable (you type digits and arrows step the value). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index f07362d..41be932 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -32,16 +32,17 @@ const DRAG_EVENTS = new Set([ ]); // Elements where the user is "typing" — text-entry inputs, textareas, selects -// (type-ahead), and contenteditable regions. Button/submit/reset/checkbox/ -// radio/image inputs are deliberately excluded: they accept activation keys, -// not text, so a global shortcut should still fire when one of them is focused. -// input[type="range"] is intentionally NOT excluded — it responds to arrow -// keys, so suppressing arrow-bound shortcuts while a slider is focused lets the -// slider consume the arrows. Used by the opt-in `lvt-mod:skip-when-typing` -// guard on window keyboard bindings. +// (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="checkbox"]):not([type="radio"]):not([type="image"]):not([type="file"])', "textarea", "select", '[contenteditable=""]', From 3b169b865a501ff8e0d78e67c17e70ac83b92ce6 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 27 Jun 2026 18:17:43 +0000 Subject: [PATCH 8/8] Exclude input[type=color]; drop redundant instanceof in isEditableTarget (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - color inputs open a picker and accept no typed text — exclude them from the editable set (the last non-text input type, alongside button/file/image/etc). - isEditableTarget's param is Element | null, so the instanceof Element check only guarded null; use node !== null. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bnEwkKH6E9q2QQnqj3cWe --- dom/event-delegation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 41be932..092d4b6 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -42,7 +42,8 @@ const DRAG_EVENTS = new Set([ // 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="checkbox"]):not([type="radio"]):not([type="image"]):not([type="file"])' + + ':not([type="color"])', "textarea", "select", '[contenteditable=""]', @@ -63,7 +64,7 @@ function deepActiveElement(): Element | null { } function isEditableTarget(node: Element | null): boolean { - return node instanceof Element && node.closest(EDITABLE_SELECTOR) !== null; + return node !== null && node.closest(EDITABLE_SELECTOR) !== null; } export interface EventDelegationContext {