add RadioNodeList

This commit is contained in:
Karl Seguin
2025-12-15 10:31:44 +08:00
parent 6040cd3338
commit ac0601b141
6 changed files with 477 additions and 9 deletions

View File

@@ -0,0 +1,213 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<!-- Test fixtures for RadioNodeList -->
<form id="test_form">
<input type="radio" name="color" value="red">
<input type="radio" name="color" value="green">
<input type="radio" name="color" value="blue">
<input type="text" name="single_field" value="test">
</form>
<script id="multiple_returns_radio_node_list">
{
const form = $('#test_form');
const result = form.elements.namedItem('color');
testing.expectEqual('RadioNodeList', result.constructor.name);
}
</script>
<script id="single_returns_element">
{
const form = $('#test_form');
const result = form.elements.namedItem('single_field');
testing.expectEqual('HTMLInputElement', result.constructor.name);
testing.expectEqual('single_field', result.name);
}
</script>
<script id="none_returns_null">
{
const form = $('#test_form');
const result = form.elements.namedItem('nonexistent');
testing.expectEqual(null, result);
}
</script>
<script id="length">
{
const form = $('#test_form');
const radios = form.elements.namedItem('color');
testing.expectEqual(3, radios.length);
}
</script>
<script id="indexed_access">
{
const form = $('#test_form');
const radios = form.elements.namedItem('color');
testing.expectEqual('red', radios[0].value);
testing.expectEqual('green', radios[1].value);
testing.expectEqual('blue', radios[2].value);
}
</script>
<script id="value_getter_no_checked">
{
const form = $('#test_form');
const radios = form.elements.namedItem('color');
testing.expectEqual('', radios.value);
}
</script>
<script id="value_getter_with_checked">
{
const form = $('#test_form');
const inputs = form.querySelectorAll('input[name="color"]');
inputs[1].checked = true;
const radios = form.elements.namedItem('color');
testing.expectEqual('green', radios.value);
}
</script>
<script id="value_getter_on_default">
{
const form = document.createElement('form');
const r1 = document.createElement('input');
r1.type = 'radio';
r1.name = 'test';
r1.checked = true;
// no value attribute
const r2 = document.createElement('input');
r2.type = 'radio';
r2.name = 'test';
// no value attribute
form.appendChild(r1);
form.appendChild(r2);
document.body.appendChild(form);
// Multiple elements with same name returns RadioNodeList
const radios = form.elements.namedItem('test');
testing.expectEqual('RadioNodeList', radios.constructor.name);
testing.expectEqual('on', radios.value); // Checked radio with no value returns "on"
form.remove();
}
</script>
<script id="value_setter">
{
const form = $('#test_form');
const radios = form.elements.namedItem('color');
const inputs = form.querySelectorAll('input[name="color"]');
radios.value = 'blue';
testing.expectEqual(false, inputs[0].checked);
testing.expectEqual(false, inputs[1].checked);
testing.expectEqual(true, inputs[2].checked);
testing.expectEqual('blue', radios.value);
}
</script>
<script id="value_setter_on">
{
const form = document.createElement('form');
const r1 = document.createElement('input');
r1.type = 'radio';
r1.name = 'test';
// no value attribute
const r2 = document.createElement('input');
r2.type = 'radio';
r2.name = 'test';
r2.value = 'on';
const r3 = document.createElement('input');
r3.type = 'radio';
r3.name = 'test';
r3.value = 'other';
form.appendChild(r1);
form.appendChild(r2);
form.appendChild(r3);
document.body.appendChild(form);
const radios = form.elements.namedItem('test');
radios.value = 'on';
// Should check first match (r1 with no value attribute)
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
form.remove();
}
</script>
<script id="live_collection">
{
const form = document.createElement('form');
const r1 = document.createElement('input');
r1.type = 'radio';
r1.name = 'dynamic';
r1.value = 'a';
const r2 = document.createElement('input');
r2.type = 'radio';
r2.name = 'dynamic';
r2.value = 'b';
form.appendChild(r1);
form.appendChild(r2);
document.body.appendChild(form);
const radios = form.elements.namedItem('dynamic');
testing.expectEqual('RadioNodeList', radios.constructor.name);
testing.expectEqual(2, radios.length);
const r3 = document.createElement('input');
r3.type = 'radio';
r3.name = 'dynamic';
r3.value = 'c';
form.appendChild(r3);
testing.expectEqual(3, radios.length);
testing.expectEqual('c', radios[2].value);
r1.remove();
testing.expectEqual(2, radios.length);
testing.expectEqual('b', radios[0].value);
form.remove();
}
</script>
<!-- Test non-radio elements with same name -->
<form id="mixed_form">
<input type="text" name="mixed" value="text1">
<input type="text" name="mixed" value="text2">
</form>
<script id="non_radio_elements">
{
const form = $('#mixed_form');
const result = form.elements.namedItem('mixed');
// Should still return RadioNodeList even for non-radio elements
testing.expectEqual('RadioNodeList', result.constructor.name);
testing.expectEqual(2, result.length);
// getValue should return "" for non-radio elements
testing.expectEqual('', result.value);
}
</script>

View File

@@ -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)
}
</script>
@@ -297,3 +308,22 @@
testing.expectEqual(0, form.elements.length)
}
</script>
<form id="duplicate_names">
<input type="text" name="choice" value="a">
<input type="text" name="choice" value="b">
<input type="text" name="choice" value="c">
</form>
<script id="duplicate_names_handling">
{
const form = $('#duplicate_names')
testing.expectEqual(3, form.elements.length)
testing.expectEqual('a', form.elements[0].value)
testing.expectEqual('b', form.elements[1].value)
testing.expectEqual('c', form.elements[2].value)
testing.expectEqual('', form.elements['choice'].value)
}
</script>

View File

@@ -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,

View File

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

View File

@@ -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),
};
}

View File

@@ -0,0 +1,133 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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", .{});
}