diff --git a/src/browser/tests/collections/radio_node_list.html b/src/browser/tests/collections/radio_node_list.html new file mode 100644 index 00000000..b90b6494 --- /dev/null +++ b/src/browser/tests/collections/radio_node_list.html @@ -0,0 +1,213 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index 9c5055e3..f148fae0 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -249,11 +249,22 @@ testing.expectEqual('b', form.elements[1].value) testing.expectEqual('c', form.elements[2].value) - // Note: In spec-compliant browsers, namedItem with duplicate names returns RadioNodeList - // RadioNodeList.value returns the checked radio's value (or "" if none checked) - // Our implementation currently returns the first element (TODO: implement RadioNodeList) - // For now, test that we can access by index which works in all browsers - testing.expectEqual('choice', form.elements[0].name) + // Ensure all radios are unchecked at start (cleanup from any previous tests) + form.elements[0].checked = false + form.elements[1].checked = false + form.elements[2].checked = false + + // namedItem with duplicate names returns RadioNodeList + const result = form.elements.namedItem('choice') + testing.expectEqual('RadioNodeList', result.constructor.name) + testing.expectEqual(3, result.length) + testing.expectEqual('', result.value) + + form.elements[1].checked = true + testing.expectEqual('b', result.value) + + result.value = 'c' + testing.expectEqual(true, form.elements[2].checked) } @@ -297,3 +308,22 @@ testing.expectEqual(0, form.elements.length) } + + + + diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index d0dd81ce..13cd911f 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -19,6 +19,7 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive; pub const ChildNodes = @import("collections/ChildNodes.zig"); pub const DOMTokenList = @import("collections/DOMTokenList.zig"); +pub const RadioNodeList = @import("collections/RadioNodeList.zig"); pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig"); pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig"); pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig"); @@ -35,6 +36,7 @@ pub fn registerTypes() []const type { @import("collections/HTMLAllCollection.zig").Iterator, HTMLOptionsCollection, HTMLFormControlsCollection, + RadioNodeList, DOMTokenList, DOMTokenList.KeyIterator, DOMTokenList.ValueIterator, diff --git a/src/browser/webapi/collections/HTMLFormControlsCollection.zig b/src/browser/webapi/collections/HTMLFormControlsCollection.zig index e7fd1420..513b5c55 100644 --- a/src/browser/webapi/collections/HTMLFormControlsCollection.zig +++ b/src/browser/webapi/collections/HTMLFormControlsCollection.zig @@ -20,12 +20,20 @@ const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); + +const NodeList = @import("NodeList.zig"); +const RadioNodeList = @import("RadioNodeList.zig"); const HTMLCollection = @import("HTMLCollection.zig"); const HTMLFormControlsCollection = @This(); _proto: *HTMLCollection, +pub const NamedItemResult = union(enum) { + element: *Element, + radio_node_list: *RadioNodeList, +}; + pub fn length(self: *HTMLFormControlsCollection, page: *Page) u32 { return self._proto.length(page); } @@ -34,12 +42,87 @@ pub fn getAtIndex(self: *HTMLFormControlsCollection, index: usize, page: *Page) return self._proto.getAtIndex(index, page); } -pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Page) ?*Element { - // TODO: When multiple elements have same name (radio buttons), - // should return RadioNodeList instead of first element - return self._proto.getByName(name, page); +pub fn namedItem(self: *HTMLFormControlsCollection, name: []const u8, page: *Page) !?NamedItemResult { + if (name.len == 0) { + return null; + } + + // We need special handling for radio, where multiple inputs can have the + // same name, but we also need to handle the [incorrect] case where non- + // radios share names. + + var count: u32 = 0; + var first_element: ?*Element = null; + + var it = try self.iterator(); + while (it.next()) |element| { + const is_match = blk: { + if (element.getAttributeSafe("id")) |id| { + if (std.mem.eql(u8, id, name)) { + break :blk true; + } + } + if (element.getAttributeSafe("name")) |elem_name| { + if (std.mem.eql(u8, elem_name, name)) { + break :blk true; + } + } + break :blk false; + }; + + if (is_match) { + if (first_element == null) { + first_element = element; + } + count += 1; + + if (count == 2) { + const radio_node_list = try page._factory.create(RadioNodeList{ + ._proto = undefined, + ._form_collection = self, + ._name = try page.dupeString(name), + }); + + radio_node_list._proto = try page._factory.create(NodeList{ .data = .{ .radio_node_list = radio_node_list } }); + + return .{ .radio_node_list = radio_node_list }; + } + } + } + + if (count == 0) { + return null; + } + + // case == 2 was handled inside the loop + std.debug.assert(count == 1); + + return .{ .element = first_element.? }; } +// used internally, by HTMLFormControlsCollection and RadioNodeList +pub fn iterator(self: *HTMLFormControlsCollection) !Iterator { + const form_collection = self._proto._data.form; + return .{ + .tw = form_collection._tw.clone(), + .nodes = form_collection, + }; +} + +// Used internally. Presents a nicer (more zig-like) iterator and strips away +// some of the abstraction. +pub const Iterator = struct { + tw: TreeWalker, + nodes: NodeLive, + + const NodeLive = @import("node_live.zig").NodeLive(.form); + const TreeWalker = @import("../TreeWalker.zig").FullExcludeSelf; + + pub fn next(self: *Iterator) ?*Element { + return self.nodes.nextTw(&self.tw); + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(HTMLFormControlsCollection); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 8ee8b104..cd8ec250 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -22,13 +22,17 @@ const log = @import("../../..//log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Node = @import("../Node.zig"); +const Element = @import("../Element.zig"); const ChildNodes = @import("ChildNodes.zig"); +const RadioNodeList = @import("RadioNodeList.zig"); const SelectorList = @import("../selector/List.zig"); +const HTMLFormControlsCollection = @import("HTMLFormControlsCollection.zig"); const Mode = enum { child_nodes, selector_list, + radio_node_list, }; const NodeList = @This(); @@ -36,12 +40,14 @@ const NodeList = @This(); data: union(Mode) { child_nodes: *ChildNodes, selector_list: *SelectorList, + radio_node_list: *RadioNodeList, }, pub fn length(self: *NodeList, page: *Page) !u32 { return switch (self.data) { .child_nodes => |impl| impl.length(page), .selector_list => |impl| @intCast(impl.getLength()), + .radio_node_list => |impl| impl.getLength(), }; } @@ -49,6 +55,7 @@ pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node { return switch (self.data) { .child_nodes => |impl| impl.getAtIndex(index, page), .selector_list => |impl| impl.getAtIndex(index), + .radio_node_list => |impl| impl.getAtIndex(index, page), }; } diff --git a/src/browser/webapi/collections/RadioNodeList.zig b/src/browser/webapi/collections/RadioNodeList.zig new file mode 100644 index 00000000..b126b2a1 --- /dev/null +++ b/src/browser/webapi/collections/RadioNodeList.zig @@ -0,0 +1,133 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier