mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 09:46:05 -04:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user