diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index e6d1ec0b..3eb02bae 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -162,18 +162,36 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E } fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + var path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; var node: ?*Node = target; - while (node) |n| : (node = n._parent) { + while (node) |n| { if (path_len >= path_buffer.len) break; path_buffer[path_len] = n.asEventTarget(); path_len += 1; + + // Check if this node is a shadow root + if (n.is(ShadowRoot)) |shadow| { + event._needs_retargeting = true; + + // If event is not composed, stop at shadow boundary + if (!event._composed) { + break; + } + + // Otherwise, jump to the shadow host and continue + node = shadow._host.asNode(); + continue; + } + + node = n._parent; } // Even though the window isn't part of the DOM, events always propagate - // through it in the capture phase + // through it in the capture phase (unless we stopped at a shadow boundary) if (path_len < path_buffer.len) { path_buffer[path_len] = self.page.window.asEventTarget(); path_len += 1; @@ -257,6 +275,12 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe was_handled.* = true; event._current_target = current_target; + // Compute adjusted target for shadow DOM retargeting (only if needed) + const original_target = event._target; + if (event._needs_retargeting) { + event._target = getAdjustedTarget(original_target, current_target); + } + switch (listener.function) { .value => |value| try value.call(void, .{event}), .string => |string| { @@ -265,6 +289,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe }, } + // Restore original target (only if we changed it) + if (event._needs_retargeting) { + event._target = original_target; + } + if (listener.once) { self.removeListener(list, listener); } @@ -325,3 +354,56 @@ const Function = union(enum) { }; } }; + +// Computes the adjusted target for shadow DOM event retargeting +// Returns the lowest shadow-including ancestor of original_target that is +// also an ancestor-or-self of current_target +fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget { + const ShadowRoot = @import("webapi/ShadowRoot.zig"); + + const orig_node = switch ((original_target orelse return null)._type) { + .node => |n| n, + else => return original_target, + }; + const curr_node = switch (current_target._type) { + .node => |n| n, + else => return original_target, + }; + + // Walk up from original target, checking if we can reach current target + var node: ?*Node = orig_node; + while (node) |n| { + // Check if current_target is an ancestor of n (or n itself) + if (isAncestorOrSelf(curr_node, n)) { + return n.asEventTarget(); + } + + // Cross shadow boundary if needed + if (n.is(ShadowRoot)) |shadow| { + node = shadow._host.asNode(); + continue; + } + + node = n._parent; + } + + return original_target; +} + +// Check if ancestor is an ancestor of (or the same as) node +// WITHOUT crossing shadow boundaries (just regular DOM tree) +fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool { + if (ancestor == node) { + return true; + } + + var current: ?*Node = node._parent; + while (current) |n| { + if (n == ancestor) { + return true; + } + current = n._parent; + } + + return false; +} diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 4ab5be8a..41d8fa2c 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -144,7 +144,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args const result = self.func.castToFunction().call(context.v8_context, js_this, js_args); if (result == null) { - // std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); + std.debug.print("CB ERR: {s}\n", .{self.src() catch "???"}); return error.JSExecCallback; } diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html index 46285cb2..de6f7cdc 100644 --- a/src/browser/tests/shadowroot/events.html +++ b/src/browser/tests/shadowroot/events.html @@ -52,34 +52,135 @@ + + + + + + + + diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 70de6e07..b11a83fa 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -21,6 +21,7 @@ const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); const EventTarget = @import("EventTarget.zig"); +const Node = @import("Node.zig"); const String = @import("../../string.zig").String; pub const Event = @This(); @@ -28,6 +29,7 @@ pub const Event = @This(); _type: Type, _bubbles: bool = false, _cancelable: bool = false, +_composed: bool = false, _type_string: String, _target: ?*EventTarget = null, _current_target: ?*EventTarget = null, @@ -36,6 +38,7 @@ _stop_propagation: bool = false, _stop_immediate_propagation: bool = false, _event_phase: EventPhase = .none, _time_stamp: u64 = 0, +_needs_retargeting: bool = false, pub const EventPhase = enum(u8) { none = 0, @@ -54,6 +57,7 @@ pub const Type = union(enum) { const Options = struct { bubbles: bool = false, cancelable: bool = false, + composed: bool = false, }; pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { @@ -68,6 +72,7 @@ pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event { ._bubbles = opts.bubbles, ._time_stamp = time_stamp, ._cancelable = opts.cancelable, + ._composed = opts.composed, ._type_string = try String.init(page.arena, typ, .{}), }); } @@ -84,6 +89,10 @@ pub fn getCancelable(self: *const Event) bool { return self._cancelable; } +pub fn getComposed(self: *const Event) bool { + return self._composed; +} + pub fn getTarget(self: *const Event) ?*EventTarget { return self._target; } @@ -117,6 +126,68 @@ pub fn getTimeStamp(self: *const Event) u64 { return self._time_stamp; } +pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget { + // Return empty array if event is not being dispatched + if (self._event_phase == .none) { + return &.{}; + } + + // If there's no target, return empty array + const target = self._target orelse return &.{}; + + // Only nodes have a propagation path + const target_node = switch (target._type) { + .node => |n| n, + else => return &.{}, + }; + + // Build the path by walking up from target + var path_len: usize = 0; + var path_buffer: [128]*EventTarget = undefined; + var stopped_at_shadow_boundary = false; + + var node: ?*Node = target_node; + while (node) |n| { + if (path_len >= path_buffer.len) { + break; + } + path_buffer[path_len] = n.asEventTarget(); + path_len += 1; + + // Check if this node is a shadow root + if (n._type == .document_fragment) { + if (n._type.document_fragment._type == .shadow_root) { + const shadow = n._type.document_fragment._type.shadow_root; + + // If event is not composed, stop at shadow boundary + if (!self._composed) { + stopped_at_shadow_boundary = true; + break; + } + + // Otherwise, jump to the shadow host and continue + node = shadow._host.asNode(); + continue; + } + } + + node = n._parent; + } + + // Add window at the end (unless we stopped at shadow boundary) + if (!stopped_at_shadow_boundary) { + if (path_len < path_buffer.len) { + path_buffer[path_len] = page.window.asEventTarget(); + path_len += 1; + } + } + + // Allocate and return the path using call_arena (short-lived) + const path = try page.call_arena.alloc(*EventTarget, path_len); + @memcpy(path, path_buffer[0..path_len]); + return path; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Event); @@ -131,6 +202,7 @@ pub const JsApi = struct { pub const @"type" = bridge.accessor(Event.getType, null, .{}); pub const bubbles = bridge.accessor(Event.getBubbles, null, .{}); pub const cancelable = bridge.accessor(Event.getCancelable, null, .{}); + pub const composed = bridge.accessor(Event.getComposed, null, .{}); pub const target = bridge.accessor(Event.getTarget, null, .{}); pub const currentTarget = bridge.accessor(Event.getCurrentTarget, null, .{}); pub const eventPhase = bridge.accessor(Event.getEventPhase, null, .{}); @@ -139,6 +211,7 @@ pub const JsApi = struct { pub const preventDefault = bridge.function(Event.preventDefault, .{}); pub const stopPropagation = bridge.function(Event.stopPropagation, .{}); pub const stopImmediatePropagation = bridge.function(Event.stopImmediatePropagation, .{}); + pub const composedPath = bridge.function(Event.composedPath, .{}); // Event phase constants pub const NONE = bridge.property(@intFromEnum(EventPhase.none));