mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-12-16 16:28:58 +00:00
add RadioNodeList
This commit is contained in:
213
src/browser/tests/collections/radio_node_list.html
Normal file
213
src/browser/tests/collections/radio_node_list.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
133
src/browser/webapi/collections/RadioNodeList.zig
Normal file
133
src/browser/webapi/collections/RadioNodeList.zig
Normal 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", .{});
|
||||
}
|
||||
Reference in New Issue
Block a user