Merge pull request #2948 from lightpanda-io/dom-pr-07-click-activation-passive

webapi: click activation, shadow retargeting, passive-by-default listeners
This commit is contained in:
Karl Seguin
2026-07-15 11:58:13 +08:00
committed by GitHub
6 changed files with 196 additions and 10 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 <area> elements when implement
const element = target.is(Element) orelse return;

View File

@@ -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,

View File

@@ -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) },
};
};

View File

@@ -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);