Skip to content
Merged
60 changes: 54 additions & 6 deletions dom/event-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element, Map<string, Function>>;
Expand Down Expand Up @@ -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;
}
}
Expand Down
169 changes: 169 additions & 0 deletions tests/event-delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
`
<div lvt-on:window:keydown="nextFile" lvt-key="j" lvt-mod:skip-when-typing></div>
<textarea id="composer"></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",
`
<div lvt-on:window:keydown="nextFile" lvt-key="j" lvt-mod:skip-when-typing></div>
<input id="filter" type="text" />
`
);
(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",
`
<div lvt-on:window:keydown="nextFile" lvt-key="j" lvt-mod:skip-when-typing></div>
<div id="rich" contenteditable="true" tabindex="0"></div>
`
);
(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",
`
<div lvt-on:window:keydown="nextFile" lvt-key="j" lvt-mod:skip-when-typing></div>
<div id="host"></div>
`
);
// 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",
`
<div lvt-on:window:keydown="nextFile" lvt-key="j" lvt-mod:skip-when-typing></div>
<button id="go" type="button">go</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",
`
<div lvt-on:window:keydown="nextFile" lvt-key="ArrowDown" lvt-mod:skip-when-typing></div>
<input id="slider" type="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",
`
<div lvt-on:window:keyup="nextFile" lvt-key="j" lvt-mod:skip-when-typing></div>
<textarea id="composer"></textarea>
`
);
(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",
`
<div lvt-on:window:keydown="ping" lvt-mod:skip-when-typing></div>
<textarea id="composer"></textarea>
`
);
(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",
`
<div lvt-on:window:keydown="clearSelection" lvt-key="Escape"></div>
<textarea id="composer"></textarea>
`
);
(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");
Expand Down
Loading