From 7f1b1640e2012fde3bdb9635f7e9b8441f034836 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 15:44:50 +0200 Subject: [PATCH] webapi: class tokens split on all ASCII whitespace; table row collections Fixes 7 failing WPT /dom/nodes/getElementsByClassName-*.htm files: - Class attribute matching (getElementsByClassName and .class selectors) treated only the space character as a token separator, so class="a\nb" or class="a\tb" never matched "a". Per the spec the separators are ASCII whitespace (tab, LF, FF, CR, space); classAttributeContains now accepts all of them at token boundaries (deliberately not std.ascii.isWhitespace, which would also accept vertical tab). Fixes getElementsByClassName-02/04/15/22/25. - getElementsByClassName-20 exercised table.tBodies[0].rows[0].cells[0] and -21 table.deleteRow(1); the table interfaces were bare stubs. Added HTMLTableElement.tBodies (child tbody collection), HTMLTableSectionElement.rows (child tr collection), HTMLTableRowElement.cells (child td/th collection, a new `cells` NodeLive mode since child_tag filters a single tag), and HTMLTableElement.deleteRow with the spec's row ordering (thead rows, then table/tbody rows in tree order, then tfoot rows), -1 meaning the last row, and IndexSizeError for out-of-range indices. Coverage: getElementsByClassName-02/04/15/20/21/22/25.htm each 0/1 -> 1/1 (fully green). Co-Authored-By: Claude Fable 5 --- .../webapi/collections/HTMLCollection.zig | 5 ++ src/browser/webapi/collections/node_live.zig | 11 ++- src/browser/webapi/element/html/Table.zig | 68 +++++++++++++++++++ src/browser/webapi/element/html/TableRow.zig | 8 +++ .../webapi/element/html/TableSection.zig | 8 +++ src/browser/webapi/selector/Selector.zig | 13 +++- 6 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 11683dac5..3765c2533 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -32,6 +32,7 @@ const Mode = enum { all_elements, child_elements, child_tag, + cells, selected_options, links, anchors, @@ -49,6 +50,7 @@ _data: union(Mode) { all_elements: NodeLive(.all_elements), child_elements: NodeLive(.child_elements), child_tag: NodeLive(.child_tag), + cells: NodeLive(.cells), selected_options: NodeLive(.selected_options), links: NodeLive(.links), anchors: NodeLive(.anchors), @@ -92,6 +94,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator { .all_elements => |*impl| .{ .all_elements = impl._tw.clone() }, .child_elements => |*impl| .{ .child_elements = impl._tw.clone() }, .child_tag => |*impl| .{ .child_tag = impl._tw.clone() }, + .cells => |*impl| .{ .cells = impl._tw.clone() }, .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, .links => |*impl| .{ .links = impl._tw.clone() }, .anchors => |*impl| .{ .anchors = impl._tw.clone() }, @@ -112,6 +115,7 @@ pub const Iterator = GenericIterator(struct { all_elements: TreeWalker.FullExcludeSelf, child_elements: TreeWalker.Children, child_tag: TreeWalker.Children, + cells: TreeWalker.Children, selected_options: TreeWalker.Children, links: TreeWalker.FullExcludeSelf, anchors: TreeWalker.FullExcludeSelf, @@ -128,6 +132,7 @@ pub const Iterator = GenericIterator(struct { .all_elements => |*impl| impl.nextTw(&self.tw.all_elements), .child_elements => |*impl| impl.nextTw(&self.tw.child_elements), .child_tag => |*impl| impl.nextTw(&self.tw.child_tag), + .cells => |*impl| impl.nextTw(&self.tw.cells), .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), .links => |*impl| impl.nextTw(&self.tw.links), .anchors => |*impl| impl.nextTw(&self.tw.anchors), diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 82769cd07..69e30d178 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -39,6 +39,7 @@ const Mode = enum { all_elements, child_elements, child_tag, + cells, selected_options, links, anchors, @@ -59,6 +60,7 @@ const Filters = union(Mode) { all_elements, child_elements, child_tag: Element.Tag, + cells, selected_options, links, anchors, @@ -91,7 +93,7 @@ pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { .tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf, - .child_elements, .child_tag, .selected_options => TreeWalker.Children, + .child_elements, .child_tag, .cells, .selected_options => TreeWalker.Children, }; return struct { _tw: TW, @@ -283,6 +285,12 @@ pub fn NodeLive(comptime mode: Mode) type { const el = node.is(Element) orelse return false; return el.getTag() == self._filter; }, + .cells => { + // HTMLTableRowElement.cells: td and th children. + const el = node.is(Element) orelse return false; + const tag = el.getTag(); + return tag == .td or tag == .th; + }, .selected_options => { const el = node.is(Element) orelse return false; const Option = Element.Html.Option; @@ -373,6 +381,7 @@ pub fn NodeLive(comptime mode: Mode) type { .all_elements => HTMLCollection{ ._data = .{ .all_elements = self } }, .child_elements => HTMLCollection{ ._data = .{ .child_elements = self } }, .child_tag => HTMLCollection{ ._data = .{ .child_tag = self } }, + .cells => HTMLCollection{ ._data = .{ .cells = self } }, .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, .links => HTMLCollection{ ._data = .{ .links = self } }, .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, diff --git a/src/browser/webapi/element/html/Table.zig b/src/browser/webapi/element/html/Table.zig index 27664fe30..43ea812b0 100644 --- a/src/browser/webapi/element/html/Table.zig +++ b/src/browser/webapi/element/html/Table.zig @@ -1,7 +1,11 @@ +const std = @import("std"); + const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); +const Frame = @import("../../../Frame.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const collections = @import("../../collections.zig"); const Table = @This(); @@ -14,6 +18,67 @@ pub fn asNode(self: *Table) *Node { return self.asElement().asNode(); } +pub fn getTBodies(self: *Table, frame: *Frame) collections.NodeLive(.child_tag) { + return collections.NodeLive(.child_tag).init(self.asNode(), .tbody, frame); +} + +// The table's rows in spec order: rows of thead children first, then tr +// children of the table and rows of tbody children in tree order, then rows +// of tfoot children. +fn collectRows(self: *Table, frame: *Frame) !std.ArrayList(*Node) { + var rows: std.ArrayList(*Node) = .empty; + const arena = frame.call_arena; + + try self.appendSectionRows(.thead, &rows, arena); + + var it = self.asNode().childrenIterator(); + while (it.next()) |child| { + const el = child.is(Element) orelse continue; + switch (el.getTag()) { + .tr => try rows.append(arena, child), + .tbody => try appendChildRows(child, &rows, arena), + else => {}, + } + } + + try self.appendSectionRows(.tfoot, &rows, arena); + return rows; +} + +fn appendSectionRows(self: *Table, tag: Element.Tag, rows: *std.ArrayList(*Node), arena: std.mem.Allocator) !void { + var it = self.asNode().childrenIterator(); + while (it.next()) |child| { + const el = child.is(Element) orelse continue; + if (el.getTag() != tag) continue; + try appendChildRows(child, rows, arena); + } +} + +fn appendChildRows(section: *Node, rows: *std.ArrayList(*Node), arena: std.mem.Allocator) !void { + var it = section.childrenIterator(); + while (it.next()) |child| { + const el = child.is(Element) orelse continue; + if (el.getTag() == .tr) { + try rows.append(arena, child); + } + } +} + +pub fn deleteRow(self: *Table, index: i32, frame: *Frame) !void { + const rows = try self.collectRows(frame); + const len: i32 = @intCast(rows.items.len); + const idx: i32 = if (index == -1) len - 1 else index; + if (idx == -1 and index == -1) { + // deleteRow(-1) on a rowless table is a no-op. + return; + } + if (idx < 0 or idx >= len) { + return error.IndexSizeError; + } + const row = rows.items[@intCast(idx)]; + _ = try row.parentNode().?.removeChild(row, frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Table); @@ -22,4 +87,7 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const tBodies = bridge.accessor(Table.getTBodies, null, .{}); + pub const deleteRow = bridge.function(Table.deleteRow, .{ .ce_reactions = true }); }; diff --git a/src/browser/webapi/element/html/TableRow.zig b/src/browser/webapi/element/html/TableRow.zig index 33ede0d5d..9b63fd806 100644 --- a/src/browser/webapi/element/html/TableRow.zig +++ b/src/browser/webapi/element/html/TableRow.zig @@ -1,7 +1,9 @@ const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); +const Frame = @import("../../../Frame.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const collections = @import("../../collections.zig"); const TableRow = @This(); @@ -14,6 +16,10 @@ pub fn asNode(self: *TableRow) *Node { return self.asElement().asNode(); } +pub fn getCells(self: *TableRow, frame: *Frame) collections.NodeLive(.cells) { + return collections.NodeLive(.cells).init(self.asNode(), {}, frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(TableRow); @@ -22,4 +28,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const cells = bridge.accessor(TableRow.getCells, null, .{}); }; diff --git a/src/browser/webapi/element/html/TableSection.zig b/src/browser/webapi/element/html/TableSection.zig index b4ed51180..05024b490 100644 --- a/src/browser/webapi/element/html/TableSection.zig +++ b/src/browser/webapi/element/html/TableSection.zig @@ -2,8 +2,10 @@ const lp = @import("lightpanda"); const js = @import("../../../js/js.zig"); const Node = @import("../../Node.zig"); +const Frame = @import("../../../Frame.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const collections = @import("../../collections.zig"); const String = lp.String; @@ -20,6 +22,10 @@ pub fn asNode(self: *TableSection) *Node { return self.asElement().asNode(); } +pub fn getRows(self: *TableSection, frame: *Frame) collections.NodeLive(.child_tag) { + return collections.NodeLive(.child_tag).init(self.asNode(), .tr, frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(TableSection); @@ -28,4 +34,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const rows = bridge.accessor(TableSection.getRows, null, .{}); }; diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 9bb3a9c4c..ec5c09532 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -187,9 +187,9 @@ pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bo var search = class_attr; while (std.mem.indexOf(u8, search, class_name)) |pos| { - const is_start = pos == 0 or search[pos - 1] == ' '; + const is_start = pos == 0 or isClassWhitespace(search[pos - 1]); const end = pos + class_name.len; - const is_end = end == search.len or search[end] == ' '; + const is_end = end == search.len or isClassWhitespace(search[end]); if (is_start and is_end) return true; @@ -198,6 +198,15 @@ pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bo return false; } +// The class attribute tokens are separated by ASCII whitespace (which, +// unlike std.ascii.isWhitespace, does not include vertical tab). +fn isClassWhitespace(c: u8) bool { + return switch (c) { + '\t', '\n', 0x0C, '\r', ' ' => true, + else => false, + }; +} + pub const Part = union(enum) { id: []const u8, class: []const u8,