diff --git a/src/browser/tests/event/input.html b/src/browser/tests/event/input.html new file mode 100644 index 000000000..f687f64c1 --- /dev/null +++ b/src/browser/tests/event/input.html @@ -0,0 +1,66 @@ + + + + + + + + + diff --git a/src/browser/webapi/event/InputEvent.zig b/src/browser/webapi/event/InputEvent.zig index 5a48b3733..3b0f6458b 100644 --- a/src/browser/webapi/event/InputEvent.zig +++ b/src/browser/webapi/event/InputEvent.zig @@ -80,11 +80,18 @@ fn initWithTrusted(arena: *lp.Arena, typ: String, _opts: ?Options, trusted: bool Event.populatePrototypes(event, opts, trusted); - // https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event - const rootevt = event._proto._proto; - rootevt._bubbles = true; - rootevt._cancelable = false; - rootevt._composed = true; + if (trusted) { + // Browser-generated input events bubble and are composed. `beforeinput` + // is cancelable — cancelling it vetoes the edit — while `input` is + // dispatched after the fact and is not: + // https://w3c.github.io/uievents/#event-type-beforeinput + // https://w3c.github.io/uievents/#event-type-input + // Synthetic ones follow the EventInit dictionary defaults. + const rootevt = event._proto._proto; + rootevt._bubbles = true; + rootevt._cancelable = typ.eqlSlice("beforeinput"); + rootevt._composed = true; + } // Hold a ref on the DataTransfer (when present) for this event's lifetime; // released in deinit. Almost always null for input events, but keeps the @@ -146,3 +153,8 @@ pub const JsApi = struct { pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{}); pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{}); }; + +const testing = @import("../../../testing.zig"); +test "WebApi: InputEvent" { + try testing.htmlRunner("event/input.html", .{}); +} diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 1f3c0c15e..85110812f 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -406,3 +406,75 @@ test "cdp.input: dispatchKeyEvent Tab runs sequential focus navigation" { }); try testing.expect((try ls.local.compileAndRun("document.activeElement.id === 'b1'", null)).isTrue()); } + +test "cdp.input: dispatchKeyEvent beforeinput can veto the edit" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const frame = page.frame().?; + try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ + .reason = .address_bar, + .kind = .{ .push = null }, + }); + try testing.waitForPage(bc); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + // A listener that rejects every keystroke, the way masked-input libraries + // do. Records what it saw so we can assert the event was cancelable. + _ = try ls.local.compileAndRun( + \\const inp = document.getElementById('inp'); + \\inp.value = 'ab'; + \\inp.focus(); + \\window.seen = []; + \\inp.addEventListener('beforeinput', (e) => { + \\ window.seen.push(['beforeinput', e.cancelable].join(':')); + \\ e.preventDefault(); + \\ window.seen.push(['defaultPrevented', e.defaultPrevented].join(':')); + \\}); + \\inp.addEventListener('input', () => window.seen.push('input')); + , null); + + try ctx.processMessage(.{ + .id = 1, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "keyDown", .key = "c", .text = "c" }, + }); + + // The veto held: no character inserted and no `input` event followed. + try testing.expect((try ls.local.compileAndRun( + \\inp.value === 'ab' && + \\window.seen.join(',') === 'beforeinput:true,defaultPrevented:true' + , null)).isTrue()); + + // Backspace goes through the same pre-edit gate. + try ctx.processMessage(.{ + .id = 2, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "keyDown", .key = "Backspace", .code = "Backspace" }, + }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'ab'", null)).isTrue()); + + // Without the veto, the edit goes through and `input` is dispatched — and + // `input` itself is not cancelable. + _ = try ls.local.compileAndRun( + \\const clone = inp.cloneNode(true); + \\inp.replaceWith(clone); + \\clone.focus(); + \\window.seen = []; + \\clone.addEventListener('input', (e) => window.seen.push(['input', e.cancelable].join(':'))); + , null); + + try ctx.processMessage(.{ + .id = 3, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "keyDown", .key = "c", .text = "c" }, + }); + try testing.expect((try ls.local.compileAndRun( + \\clone.value === 'abc' && window.seen.join(',') === 'input:false' + , null)).isTrue()); +}