diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 7cffc2d79..a03d36d47 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -166,6 +166,15 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts const et = target.asEventTarget(); event._target = et; event._dispatch_target = et; // Store original target for composedPath() + + // Retarget the relatedTarget against the dispatch target up front + // (DOM dispatch step 4); listeners observe the retargeted value and + // it survives the dispatch. + if (event.relatedTargetPtr()) |related_ptr| { + if (related_ptr.*) |related| { + related_ptr.* = getAdjustedTarget(related, et); + } + } } const frame = self.frame; @@ -195,6 +204,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts var path_len: usize = 0; var node_path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; + var clear_targets = false; // Defer runs even on early return - ensures event phase is reset // and default actions execute (unless prevented) @@ -203,7 +213,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts event._current_target = null; event._stop_propagation = false; event._stop_immediate_propagation = false; - if (event._needs_retargeting and node_path_len > 0) { + if (clear_targets) { + // Don't leak nodes living in a shadow tree: reset the targets + // (decided on the pre-dispatch tree, see below). + event._target = null; + if (event.relatedTargetPtr()) |related_ptr| { + related_ptr.* = null; + } + } else if (event._needs_retargeting and node_path_len > 0) { const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]); event._target = if (rootIsShadowRoot(adjusted)) null else adjusted; } @@ -216,9 +233,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts if (event._prevent_default) { // can't return in a defer (╯°□°)╯︵ ┻━┻ } else if (event._type_string.eql(comptime .wrap("click"))) { - Frame.user_input.handleClick(frame, target) catch |err| { - log.warn(.event, "frame.click", .{ .err = err }); - }; + // Per spec, only a MouseEvent "click" is an activation event, and + // the activation target is the nearest inclusive ancestor with + // activation behavior (ancestors only for bubbling events). + if (event.is(@import("webapi/event/MouseEvent.zig")) != null) { + if (Frame.user_input.findClickActivationTarget(target, event._bubbles)) |activation_target| { + Frame.user_input.handleClick(frame, activation_target) catch |err| { + log.warn(.event, "frame.click", .{ .err = err }); + }; + } + } } else if (event._type_string.eql(comptime .wrap("keydown"))) { Frame.user_input.handleKeydown(frame, target, event) catch |err| { log.warn(.event, "frame.keydown", .{ .err = err }); @@ -274,6 +298,26 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } + // DOM dispatch: decide up front — on the pre-dispatch tree, so listener + // mutations can't affect it — whether target and relatedTarget must be + // reset after dispatch because they would expose nodes inside a shadow + // tree. + if (node_path_len > 0) { + const last = path_buffer[node_path_len - 1]; + if (event._needs_retargeting) { + if (rootIsShadowRoot(getAdjustedTarget(event._dispatch_target, last))) { + clear_targets = true; + } + } + if (event.relatedTargetPtr()) |related_ptr| { + if (related_ptr.*) |related| { + if (rootIsShadowRoot(getAdjustedTarget(related, last))) { + clear_targets = true; + } + } + } + } + const path = path_buffer[0..path_len]; // Phase 1: Capturing phase (root → target, excluding target) @@ -619,12 +663,18 @@ const ActivationState = struct { const Input = Element.Html.Input; - fn create(event: *const Event, target: *Node, frame: *Frame) !?ActivationState { + fn create(event: *Event, target: *Node, frame: *Frame) !?ActivationState { if (event._type_string.eql(comptime .wrap("click")) == false) { return null; } - const input = target.is(Element.Html.Input) orelse return null; + // Per spec, only a MouseEvent "click" is an activation event. + if (event.is(@import("webapi/event/MouseEvent.zig")) == null) { + return null; + } + + const activation_node = Frame.user_input.findClickActivationTarget(target, event._bubbles) orelse return null; + const input = activation_node.is(Element.Html.Input) orelse return null; if (input._input_type != .checkbox and input._input_type != .radio) { return null; } diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 4e7e5b34f..e55af9316 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3151,6 +3151,11 @@ const SubmitFormOpts = struct { pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void { const form = form_ orelse return; + if (submit_opts.fire_event and form.asNode().isConnected() == false) { + // interactive submission (e.g. submit button) noop if the form is disconnected + return; + } + // see the `_constructing_entry_list` field documentation if (form._constructing_entry_list) { return; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index a9c761a8f..3248974eb 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -78,6 +78,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { }); } try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0); + try focusEditingHostForMouseDown(frame, target); } pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void { @@ -200,6 +201,76 @@ fn deltaToScroll(d: f64) i32 { } // callback when the "click" event reaches the frame. +// Whether the element has a click activation behavior that handleClick +// implements. +fn hasClickActivationBehavior(node: *Node) bool { + const element = node.is(Element) orelse return false; + const html_element = element.is(Element.Html) orelse return false; + return switch (html_element._type) { + .anchor => element.getAttributeSafe(comptime .wrap("href")) != null, + .input, .button, .select, .textarea, .label => true, + .generic => |generic| generic._tag == .summary, + else => false, + }; +} + +// Clicks on editable content are for editing: they don't activate the +// element or any enclosing link. +// "contenteditable" is 15 bytes — past the comptime SSO limit — so the +// String wrap runs at runtime, mirroring Html.getIsContentEditable. +fn isEditingHost(node: *Node) bool { + const element = node.is(Element) orelse return false; + const value = element.getAttributeSafe(.wrap("contenteditable")) orelse return false; + return std.ascii.eqlIgnoreCase(value, "false") == false; +} + +// A mousedown on editable content focuses its editing host: the outermost +// element of the contiguous editable chain containing the target. +pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void { + var node: ?*Node = target.asNode(); + var editable: ?*Node = null; + while (node) |n| : (node = n._parent) { + if (isEditingHost(n)) { + editable = n; + break; + } + } + var host = editable orelse return; + while (host._parent) |p| { + if (!isEditingHost(p)) { + break; + } + host = p; + } + const host_element = host.is(Element) orelse return; + try host_element.focus(frame); +} + +// Per the DOM dispatch algorithm, a click's activation target is the event +// target itself when it has activation behavior, otherwise — for bubbling +// events only — the nearest ancestor that has one. +pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node { + if (isEditingHost(target)) { + return null; + } + if (hasClickActivationBehavior(target)) { + return target; + } + if (!bubbles) { + return null; + } + var node = target._parent; + while (node) |n| : (node = n._parent) { + if (isEditingHost(n)) { + return null; + } + if (hasClickActivationBehavior(n)) { + return n; + } + } + return null; +} + pub fn handleClick(frame: *Frame, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 298370fc7..a0323c86f 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -169,6 +169,19 @@ pub fn as(self: *Event, comptime T: type) *T { return self.is(T).?; } +// Storage of the subtype's relatedTarget, for event types that have one. +// Used by dispatch for retargeting and shadow-tree resets. +pub fn relatedTargetPtr(self: *Event) ?*?*EventTarget { + switch (self._type) { + .ui_event => |ui| switch (ui._type) { + .mouse_event => |me| return &me._related_target, + .focus_event => |fe| return &fe._related_target, + else => return null, + }, + else => return null, + } +} + pub fn is(self: *Event, comptime T: type) ?*T { switch (self._type) { .generic => return if (T == Event) self else null, diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index ce130a143..f0c25e1c3 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -93,14 +93,47 @@ const AddEventListenerOptions = union(enum) { // The signal is kept as a raw js.Value so that an explicit null (or any // non-AbortSignal value) can be told apart from an absent or undefined // member, and rejected with a TypeError per the dictionary conversion. + // passive is optional so that an absent (or undefined) member falls back + // to the type- and target-dependent default passive value. const Options = struct { once: bool = false, capture: bool = false, - passive: bool = false, + passive: ?bool = null, signal: ?js.Value = null, }; }; +// The DOM spec's "default passive value": listeners for the scroll-blocking +// event types are passive by default on the window, the document, and the +// html and body elements. +fn defaultPassiveValue(self: *EventTarget, typ: []const u8) bool { + const scroll_blocking_event_types = std.StaticStringMap(void).initComptime(.{ + .{ "touchstart", {} }, + .{ "touchmove", {} }, + .{ "wheel", {} }, + .{ "mousewheel", {} }, + }); + if (scroll_blocking_event_types.has(typ) == false) { + return false; + } + + switch (self._type) { + .window => return true, + .node => |n| { + const Element = @import("Element.zig"); + if (n._type == .document) { + return true; + } + const element = n.is(Element) orelse return false; + return switch (element.getTag()) { + .html, .body => true, + else => false, + }; + }, + else => return false, + } +} + pub const EventListenerCallback = union(enum) { function: js.Function, object: js.Object, @@ -109,12 +142,12 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi // Convert the options before the null-callback early return: per spec, // the dictionary conversion throws even when the callback is null. const options = blk: { - const o = opts_ orelse break :blk RegisterOptions{}; + const o = opts_ orelse break :blk RegisterOptions{ .passive = self.defaultPassiveValue(typ) }; break :blk switch (o) { .options => |opts| RegisterOptions{ .once = opts.once, .capture = opts.capture, - .passive = opts.passive, + .passive = opts.passive orelse self.defaultPassiveValue(typ), .signal = signal: { const signal = opts.signal orelse break :signal null; if (signal.isUndefined()) { @@ -123,7 +156,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi break :signal try signal.toZig(*AbortSignal); }, }, - .capture => |capture| RegisterOptions{ .capture = capture }, + .capture => |capture| RegisterOptions{ .capture = capture, .passive = self.defaultPassiveValue(typ) }, }; }; diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 643cd6d98..140df74ea 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -56,6 +56,17 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) ! // synchronously so the events are observable when the testdriver promise // resolves. pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { + if (element.is(Element.Html)) |html| { + switch (html._type) { + inline .button, .input, .textarea, .select => |i| { + if (i.getDisabled()) { + return; + } + }, + else => {}, + } + } + dispatchPointer(element, "pointerdown", 0, 1, frame); dispatchMouse(element, "mousedown", 0, 1, frame); dispatchPointer(element, "pointerup", 0, 0, frame); @@ -173,6 +184,9 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void { const button = readI32(action, "button", 0); dispatchPointer(el, "pointerdown", button, 1, frame); dispatchMouse(el, "mousedown", button, 1, frame); + Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| { + log.warn(.app, "webdriver editable focus", .{ .err = err }); + }; } else if (action_type.eql(comptime .wrap("pointerUp"))) { const el = target orelse continue; const button = readI32(action, "button", 0);