Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 76 additions & 5 deletions dom/event-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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();
}
}

Expand Down
97 changes: 97 additions & 0 deletions tests/event-delegation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
EventDelegator,
EventDelegationContext,
keyFilterMatches,
} from "../dom/event-delegation";
import { createLogger } from "../utils/logger";

Expand Down Expand Up @@ -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",
`<form lvt-on:window:keydown="addComment" lvt-key="Mod+Enter"></form>`
);
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",
`<form lvt-on:window:keydown="addComment" lvt-key="Mod+Enter"></form>`
);
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",
Expand Down Expand Up @@ -1954,3 +1985,69 @@ describe("EventDelegator", () => {
});
});
});

describe("keyFilterMatches (lvt-key)", () => {
const ev = (key: string, mods: Partial<KeyboardEventInit> = {}) =>
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);
});
});
Loading