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 +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Node = @import("../Node.zig"); +const Element = @import("../Element.zig"); +const Input = @import("../element/html/Input.zig"); + +const NodeList = @import("NodeList.zig"); +const HTMLFormControlsCollection = @import("HTMLFormControlsCollection.zig"); + +const RadioNodeList = @This(); + +_proto: *NodeList, +_name: []const u8, +_form_collection: *HTMLFormControlsCollection, + +pub fn getLength(self: *RadioNodeList) !u32 { + var i: u32 = 0; + var it = try self._form_collection.iterator(); + while (it.next()) |element| { + if (self.matches(element)) { + i += 1; + } + } + return i; +} + +pub fn getAtIndex(self: *RadioNodeList, index: usize, page: *Page) !?*Node { + var i: usize = 0; + var current: usize = 0; + while (self._form_collection.getAtIndex(i, page)) |element| : (i += 1) { + if (!self.matches(element)) { + continue; + } + if (current == index) { + return element.asNode(); + } + current += 1; + } + return null; +} + +pub fn getValue(self: *RadioNodeList) ![]const u8 { + var it = try self._form_collection.iterator(); + while (it.next()) |element| { + const input = element.is(Input) orelse continue; + if (input._input_type != .radio) { + continue; + } + if (!input.getChecked()) { + continue; + } + return element.getAttributeSafe("value") orelse "on"; + } + return ""; +} + +pub fn setValue(self: *RadioNodeList, value: []const u8, page: *Page) !void { + var it = try self._form_collection.iterator(); + while (it.next()) |element| { + const input = element.is(Input) orelse continue; + if (input._input_type != .radio) { + continue; + } + + const input_value = element.getAttributeSafe("value"); + const matches_value = blk: { + if (std.mem.eql(u8, value, "on")) { + break :blk input_value == null or (input_value != null and std.mem.eql(u8, input_value.?, "on")); + } else { + break :blk input_value != null and std.mem.eql(u8, input_value.?, value); + } + }; + + if (matches_value) { + try input.setChecked(true, page); + return; + } + } +} + +fn matches(self: *const RadioNodeList, element: *Element) bool { + if (element.getAttributeSafe("id")) |id| { + if (std.mem.eql(u8, id, self._name)) { + return true; + } + } + if (element.getAttributeSafe("name")) |elem_name| { + if (std.mem.eql(u8, elem_name, self._name)) { + return true; + } + } + return false; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(RadioNodeList); + + pub const Meta = struct { + pub const name = "RadioNodeList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const length = bridge.accessor(RadioNodeList.getLength, null, .{}); + pub const @"[]" = bridge.indexed(RadioNodeList.getAtIndex, .{ .null_as_undefined = true }); + pub const item = bridge.function(RadioNodeList.getAtIndex, .{}); + pub const value = bridge.accessor(RadioNodeList.getValue, RadioNodeList.setValue, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: RadioNodeList" { + try testing.htmlRunner("collections/radio_node_list.html", .{}); +}