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