diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index 400f1ba0..b4db2d37 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -53,7 +53,6 @@ const DocumentFragment = @import("webapi/DocumentFragment.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
-const HtmlScript = @import("webapi/Element.zig").Html.Script;
const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
@@ -95,6 +94,7 @@ _element_styles: Element.StyleLookup = .{},
_element_datasets: Element.DatasetLookup = .{},
_element_class_lists: Element.ClassListLookup = .{},
_element_shadow_roots: Element.ShadowRootLookup = .{},
+_element_assigned_slots: Element.AssignedSlotLookup = .{},
_script_manager: ScriptManager,
@@ -108,6 +108,10 @@ _intersection_observers: std.ArrayList(*IntersectionObserver) = .{},
_intersection_check_scheduled: bool = false,
_intersection_delivery_scheduled: bool = false,
+// Slots that need slotchange events to be fired
+_slots_pending_slotchange: std.AutoHashMapUnmanaged(*Element.Html.Slot, void) = .{},
+_slotchange_delivery_scheduled: bool = false,
+
// Lookup for customized built-in elements. Maps element pointer to definition.
_customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{},
_customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{},
@@ -242,6 +246,7 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._element_datasets = .{};
self._element_class_lists = .{};
self._element_shadow_roots = .{};
+ self._element_assigned_slots = .{};
self._notified_network_idle = .init;
self._notified_network_almost_idle = .init;
@@ -251,6 +256,8 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._intersection_observers = .{};
self._intersection_check_scheduled = false;
self._intersection_delivery_scheduled = false;
+ self._slots_pending_slotchange = .{};
+ self._slotchange_delivery_scheduled = false;
self._customized_builtin_definitions = .{};
self._customized_builtin_connected_callback_invoked = .{};
self._customized_builtin_disconnected_callback_invoked = .{};
@@ -770,7 +777,7 @@ pub fn tick(self: *Page) void {
self.js.runMicrotasks();
}
-pub fn scriptAddedCallback(self: *Page, script: *HtmlScript) !void {
+pub fn scriptAddedCallback(self: *Page, script: *Element.Html.Script) !void {
self._script_manager.addFromElement(script, "parsing") catch |err| {
log.err(.page, "page.scriptAddedCallback", .{
.err = err,
@@ -889,6 +896,14 @@ pub fn scheduleIntersectionDelivery(self: *Page) !void {
try self.js.queueIntersectionDelivery();
}
+pub fn scheduleSlotchangeDelivery(self: *Page) !void {
+ if (self._slotchange_delivery_scheduled) {
+ return;
+ }
+ self._slotchange_delivery_scheduled = true;
+ try self.js.queueSlotchangeDelivery();
+}
+
pub fn performScheduledIntersectionChecks(self: *Page) void {
if (!self._intersection_check_scheduled) {
return;
@@ -945,6 +960,42 @@ pub fn deliverMutations(self: *Page) void {
}
}
+pub fn deliverSlotchangeEvents(self: *Page) void {
+ if (!self._slotchange_delivery_scheduled) {
+ return;
+ }
+ self._slotchange_delivery_scheduled = false;
+
+ // we need to collect the pending slots, and then clear it and THEN exeute
+ // the slot change. We do this in case the slotchange event itself schedules
+ // more slot changes (which should only be executed on the next microtask)
+ const pending = self._slots_pending_slotchange.count();
+
+ var i: usize = 0;
+ var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| {
+ log.err(.page, "deliverSlotchange.append", .{ .err = err });
+ return;
+ };
+
+ var it = self._slots_pending_slotchange.keyIterator();
+ while (it.next()) |slot| {
+ slots[i] = slot.*;
+ i += 1;
+ }
+ self._slots_pending_slotchange.clearRetainingCapacity();
+
+ for (slots) |slot| {
+ const event = Event.init("slotchange", .{ .bubbles = true }, self) catch |err| {
+ log.err(.page, "deliverSlotchange.init", .{ .err = err });
+ continue;
+ };
+ const target = slot.asNode().asEventTarget();
+ _ = target.dispatchEvent(event, self) catch |err| {
+ log.err(.page, "deliverSlotchange.dispatch", .{ .err = err });
+ };
+ }
+}
+
fn notifyNetworkIdle(self: *Page) void {
std.debug.assert(self._notified_network_idle == .done);
self._session.browser.notification.dispatch(.page_network_idle, &.{
@@ -1564,6 +1615,28 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
child._parent = null;
child._child_link = .{};
+ // Handle slot assignment removal before mutation observers
+ if (child.is(Element)) |el| {
+ // Check if the parent was a shadow host
+ if (parent.is(Element)) |parent_el| {
+ if (self._element_shadow_roots.get(parent_el)) |shadow_root| {
+ // Signal slot changes for any affected slots
+ const slot_name = el.getAttributeSafe("slot") orelse "";
+ var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(shadow_root.asNode(), .{});
+ while (tw.next()) |slot_el| {
+ if (slot_el.is(Element.Html.Slot)) |slot| {
+ if (std.mem.eql(u8, slot.getName(), slot_name)) {
+ self.signalSlotChange(slot);
+ break;
+ }
+ }
+ }
+ }
+ }
+ // Remove from assigned slot lookup
+ _ = self._element_assigned_slots.remove(el);
+ }
+
if (self.hasMutationObservers()) {
const removed = [_]*Node{child};
self.childListChange(parent, &.{}, &removed, previous_sibling, next_sibling);
@@ -1733,6 +1806,12 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
return;
}
+ // Update slot assignments for the inserted child if parent is a shadow host
+ // This needs to happen even if the element isn't connected to the document
+ if (child.is(Element)) |el| {
+ self.updateElementAssignedSlot(el);
+ }
+
if (opts.child_already_connected and !opts.adopting_to_new_document) {
// The child is already connected in the same document, we don't have to reconnect it
return;
@@ -1779,6 +1858,16 @@ pub fn attributeChange(self: *Page, element: *Element, name: []const u8, value:
log.err(.page, "attributeChange.notifyObserver", .{ .err = err });
};
}
+
+ // Handle slot assignment changes
+ if (std.mem.eql(u8, name, "slot")) {
+ self.updateSlotAssignments(element);
+ } else if (std.mem.eql(u8, name, "name")) {
+ // Check if this is a slot element
+ if (element.is(Element.Html.Slot)) |slot| {
+ self.signalSlotChange(slot);
+ }
+ }
}
pub fn attributeRemove(self: *Page, element: *Element, name: []const u8, old_value: []const u8) void {
@@ -1793,6 +1882,88 @@ pub fn attributeRemove(self: *Page, element: *Element, name: []const u8, old_val
log.err(.page, "attributeRemove.notifyObserver", .{ .err = err });
};
}
+
+ // Handle slot assignment changes
+ if (std.mem.eql(u8, name, "slot")) {
+ self.updateSlotAssignments(element);
+ } else if (std.mem.eql(u8, name, "name")) {
+ // Check if this is a slot element
+ if (element.is(Element.Html.Slot)) |slot| {
+ self.signalSlotChange(slot);
+ }
+ }
+}
+
+fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void {
+ self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| {
+ log.err(.page, "signalSlotChange.put", .{ .err = err });
+ return;
+ };
+ self.scheduleSlotchangeDelivery() catch |err| {
+ log.err(.page, "signalSlotChange.schedule", .{ .err = err });
+ };
+}
+
+fn updateSlotAssignments(self: *Page, element: *Element) void {
+ // Find all slots in the shadow root that might be affected
+ const parent = element.asNode()._parent orelse return;
+
+ // Check if parent is a shadow host
+ const parent_el = parent.is(Element) orelse return;
+ _ = self._element_shadow_roots.get(parent_el) orelse return;
+
+ // Signal change for the old slot (if any)
+ if (self._element_assigned_slots.get(element)) |old_slot| {
+ self.signalSlotChange(old_slot);
+ }
+
+ // Update the assignedSlot lookup to the new slot
+ self.updateElementAssignedSlot(element);
+
+ // Signal change for the new slot (if any)
+ if (self._element_assigned_slots.get(element)) |new_slot| {
+ self.signalSlotChange(new_slot);
+ }
+}
+
+fn updateElementAssignedSlot(self: *Page, element: *Element) void {
+ // Remove old assignment
+ _ = self._element_assigned_slots.remove(element);
+
+ // Find the new assigned slot
+ const parent = element.asNode()._parent orelse return;
+ const parent_el = parent.is(Element) orelse return;
+ const shadow_root = self._element_shadow_roots.get(parent_el) orelse return;
+
+ const slot_name = element.getAttributeSafe("slot") orelse "";
+
+ // Recursively search through the shadow root for a matching slot
+ if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| {
+ self._element_assigned_slots.put(self.arena, element, slot) catch |err| {
+ log.err(.page, "updateElementAssignedSlot.put", .{ .err = err });
+ };
+ }
+}
+
+fn findMatchingSlot(node: *Node, slot_name: []const u8) ?*Element.Html.Slot {
+ // Check if this node is a matching slot
+ if (node.is(Element)) |el| {
+ if (el.is(Element.Html.Slot)) |slot| {
+ if (std.mem.eql(u8, slot.getName(), slot_name)) {
+ return slot;
+ }
+ }
+ }
+
+ // Search children
+ var it = node.childrenIterator();
+ while (it.next()) |child| {
+ if (findMatchingSlot(child, slot_name)) |slot| {
+ return slot;
+ }
+ }
+
+ return null;
}
pub fn hasMutationObservers(self: *const Page) bool {
diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig
index 1273a4b7..d1c15fb3 100644
--- a/src/browser/js/Context.zig
+++ b/src/browser/js/Context.zig
@@ -2007,6 +2007,15 @@ pub fn queueIntersectionDelivery(self: *Context) !void {
}.run, self.page);
}
+pub fn queueSlotchangeDelivery(self: *Context) !void {
+ self.isolate.enqueueMicrotask(struct {
+ fn run(data: ?*anyopaque) callconv(.c) void {
+ const page: *Page = @ptrCast(@alignCast(data.?));
+ page.deliverSlotchangeEvents();
+ }
+ }.run, self.page);
+}
+
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
self.isolate.enqueueMicrotaskFunc(cb.func.castToFunction());
}
diff --git a/src/browser/tests/element/html/slot.html b/src/browser/tests/element/html/slot.html
index af2b0808..b3ee2a58 100644
--- a/src/browser/tests/element/html/slot.html
+++ b/src/browser/tests/element/html/slot.html
@@ -382,3 +382,137 @@
testing.expectTrue(flattened[2] === span);
}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index 0e68d7fd..c62437c1 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -45,6 +45,7 @@ pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap);
pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties);
pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
+pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot);
pub const Namespace = enum(u8) {
html,
@@ -400,6 +401,14 @@ pub fn setId(self: *Element, value: []const u8, page: *Page) !void {
return self.setAttributeSafe("id", value, page);
}
+pub fn getSlot(self: *const Element) []const u8 {
+ return self.getAttributeSafe("slot") orelse "";
+}
+
+pub fn setSlot(self: *Element, value: []const u8, page: *Page) !void {
+ return self.setAttributeSafe("slot", value, page);
+}
+
pub fn getDir(self: *const Element) []const u8 {
return self.getAttributeSafe("dir") orelse "";
}
@@ -481,6 +490,10 @@ pub fn getShadowRoot(self: *Element, page: *Page) ?*ShadowRoot {
return shadow_root;
}
+pub fn getAssignedSlot(self: *Element, page: *Page) ?*Html.Slot {
+ return page._element_assigned_slots.get(self);
+}
+
pub fn attachShadow(self: *Element, mode_str: []const u8, page: *Page) !*ShadowRoot {
if (page._element_shadow_roots.get(self)) |_| {
return error.AlreadyHasShadowRoot;
@@ -1242,6 +1255,7 @@ pub const JsApi = struct {
pub const localName = bridge.accessor(Element.getLocalName, null, .{});
pub const id = bridge.accessor(Element.getId, Element.setId, .{});
+ pub const slot = bridge.accessor(Element.getSlot, Element.setSlot, .{});
pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
pub const classList = bridge.accessor(Element.getClassList, null, .{});
@@ -1259,6 +1273,7 @@ pub const JsApi = struct {
pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{});
pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true });
pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{});
+ pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{});
pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true });
pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true });
pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true });
diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig
index 9e0c41b9..e3e59c6c 100644
--- a/src/browser/webapi/element/html/Slot.zig
+++ b/src/browser/webapi/element/html/Slot.zig
@@ -62,6 +62,7 @@ fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionTy
const allocator = page.call_arena;
const host = shadow_root.getHost();
+ const initial_count = coll.items.len;
var it = host.asNode().childrenIterator();
while (it.next()) |child| {
if (!isAssignedToSlot(child, slot_name)) {
@@ -87,6 +88,20 @@ fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionTy
try coll.append(allocator, child);
}
}
+
+ // If flatten is true and no assigned nodes were found, return fallback content
+ if (opts.flatten and coll.items.len == initial_count) {
+ var child_it = self.asNode().childrenIterator();
+ while (child_it.next()) |child| {
+ if (comptime elements) {
+ if (child.is(Element)) |el| {
+ try coll.append(allocator, el);
+ }
+ } else {
+ try coll.append(allocator, child);
+ }
+ }
+ }
}
pub fn assign(self: *Slot, nodes: []const *Node) void {