From 5ae74d6924ca549c5eb7934fffb42d9153731e4d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 14 Nov 2025 17:56:09 +0800 Subject: [PATCH] improve form element support --- src/browser/tests/element/html/button.html | 73 +++++ src/browser/tests/element/html/input.html | 107 ++++++- src/browser/tests/element/html/option.html | 34 +++ src/browser/tests/element/html/select.html | 283 ++++++++++++++++++ src/browser/tests/element/html/textarea.html | 73 +++++ src/browser/webapi/Element.zig | 1 + src/browser/webapi/collections.zig | 2 + .../webapi/collections/HTMLCollection.zig | 10 + .../collections/HTMLOptionsCollection.zig | 106 +++++++ src/browser/webapi/collections/node_live.zig | 18 +- src/browser/webapi/element/Attribute.zig | 2 +- src/browser/webapi/element/html/Button.zig | 22 ++ src/browser/webapi/element/html/Input.zig | 54 +++- src/browser/webapi/element/html/Option.zig | 41 ++- src/browser/webapi/element/html/Select.zig | 177 ++++++++--- src/browser/webapi/element/html/TextArea.zig | 22 ++ 16 files changed, 975 insertions(+), 50 deletions(-) create mode 100644 src/browser/webapi/collections/HTMLOptionsCollection.zig diff --git a/src/browser/tests/element/html/button.html b/src/browser/tests/element/html/button.html index dc7d5855..76e5be8b 100644 --- a/src/browser/tests/element/html/button.html +++ b/src/browser/tests/element/html/button.html @@ -53,3 +53,76 @@ const buttonInvalidFormAttr = $('#button_invalid_form_attr') testing.expectEqual(null, buttonInvalidFormAttr.form) + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 9f762d42..dbd79aa3 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -15,7 +15,7 @@ - + + + + + diff --git a/src/browser/tests/element/html/option.html b/src/browser/tests/element/html/option.html index 30d02378..6e7f72c8 100644 --- a/src/browser/tests/element/html/option.html +++ b/src/browser/tests/element/html/option.html @@ -65,3 +65,37 @@ $('#opt4').disabled = false testing.expectEqual(false, $('#opt4').disabled) + + + + + + + + + diff --git a/src/browser/tests/element/html/select.html b/src/browser/tests/element/html/select.html index a6a835a6..ceb46c16 100644 --- a/src/browser/tests/element/html/select.html +++ b/src/browser/tests/element/html/select.html @@ -81,3 +81,286 @@ const selectNoForm = $('#select_no_form') testing.expectEqual(null, selectNoForm.form) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/textarea.html b/src/browser/tests/element/html/textarea.html index f20e182e..f820288e 100644 --- a/src/browser/tests/element/html/textarea.html +++ b/src/browser/tests/element/html/textarea.html @@ -76,3 +76,76 @@ const textareaInvalidFormAttr = $('#textarea_invalid_form_attr') testing.expectEqual(null, textareaInvalidFormAttr.form) + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 9f0fdd5f..0ea4783e 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -407,6 +407,7 @@ pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Pa } pub fn remove(self: *Element, page: *Page) void { + page.domChanged(); const node = self.asNode(); const parent = node._parent orelse return; page.removeNode(parent, node, .{ .will_be_reconnected = false }); diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index 0e091cbd..eead81b9 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -20,6 +20,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 HTMLAllCollection = @import("collections/HTMLAllCollection.zig"); +pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig"); pub fn registerTypes() []const type { return &.{ @@ -31,6 +32,7 @@ pub fn registerTypes() []const type { @import("collections/NodeList.zig").EntryIterator, @import("collections/HTMLAllCollection.zig"), @import("collections/HTMLAllCollection.zig").Iterator, + HTMLOptionsCollection, DOMTokenList, DOMTokenList.Iterator, }; diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index e3f42a90..54c99bff 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -29,6 +29,8 @@ const Mode = enum { tag_name, class_name, child_elements, + child_tag, + selected_options, }; const HTMLCollection = @This(); @@ -38,6 +40,8 @@ data: union(Mode) { tag_name: NodeLive(.tag_name), class_name: NodeLive(.class_name), child_elements: NodeLive(.child_elements), + child_tag: NodeLive(.child_tag), + selected_options: NodeLive(.selected_options), }, pub fn length(self: *HTMLCollection, page: *const Page) u32 { @@ -66,6 +70,8 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .tag_name => |*impl| .{ .tag_name = impl._tw.clone() }, .class_name => |*impl| .{ .class_name = impl._tw.clone() }, .child_elements => |*impl| .{ .child_elements = impl._tw.clone() }, + .child_tag => |*impl| .{ .child_tag = impl._tw.clone() }, + .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, }, }, page); } @@ -78,6 +84,8 @@ pub const Iterator = GenericIterator(struct { tag_name: TreeWalker.FullExcludeSelf, class_name: TreeWalker.FullExcludeSelf, child_elements: TreeWalker.Children, + child_tag: TreeWalker.Children, + selected_options: TreeWalker.Children, }, pub fn next(self: *@This(), _: *Page) ?*Element { @@ -86,6 +94,8 @@ pub const Iterator = GenericIterator(struct { .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .class_name => |*impl| impl.nextTw(&self.tw.class_name), .child_elements => |*impl| impl.nextTw(&self.tw.child_elements), + .child_tag => |*impl| impl.nextTw(&self.tw.child_tag), + .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), }; } }, null); diff --git a/src/browser/webapi/collections/HTMLOptionsCollection.zig b/src/browser/webapi/collections/HTMLOptionsCollection.zig new file mode 100644 index 00000000..4c9d59c4 --- /dev/null +++ b/src/browser/webapi/collections/HTMLOptionsCollection.zig @@ -0,0 +1,106 @@ +// 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 Element = @import("../Element.zig"); +const HTMLCollection = @import("HTMLCollection.zig"); +const NodeLive = @import("node_live.zig").NodeLive; + +const HTMLOptionsCollection = @This(); + +_proto: *HTMLCollection, +_select: *@import("../element/html/Select.zig"), + +pub fn deinit(self: *HTMLOptionsCollection) void { + const page = Page.current; + page._factory.destroy(self); +} + +// Forward length to HTMLCollection +pub fn length(self: *HTMLOptionsCollection, page: *Page) u32 { + return self._proto.length(page); +} + +// Forward indexed access to HTMLCollection +pub fn getAtIndex(self: *HTMLOptionsCollection, index: usize, page: *Page) ?*Element { + return self._proto.getAtIndex(index, page); +} + +pub fn getByName(self: *HTMLOptionsCollection, name: []const u8, page: *Page) ?*Element { + return self._proto.getByName(name, page); +} + +// Forward selectedIndex to the owning select element +pub fn getSelectedIndex(self: *const HTMLOptionsCollection) i32 { + return self._select.getSelectedIndex(); +} + +pub fn setSelectedIndex(self: *HTMLOptionsCollection, index: i32) !void { + return self._select.setSelectedIndex(index); +} + +const Option = @import("../element/html/Option.zig"); + +// Add a new option element +pub fn add(self: *HTMLOptionsCollection, element: *Option, before: ?*Option, page: *Page) !void { + const select_node = self._select.asNode(); + const element_node = element.asElement().asNode(); + + if (before) |before_option| { + const before_node = before_option.asElement().asNode(); + _ = try select_node.insertBefore(element_node, before_node, page); + } else { + _ = try select_node.appendChild(element_node, page); + } +} + +// Remove an option element by index +pub fn remove(self: *HTMLOptionsCollection, index: i32, page: *Page) void { + if (index < 0) { + return; + } + + if (self._proto.getAtIndex(@intCast(index), page)) |element| { + element.remove(page); + } +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(HTMLOptionsCollection); + + pub const Meta = struct { + pub const name = "HTMLOptionsCollection"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const finalizer = HTMLOptionsCollection.deinit; + pub const manage = false; + }; + + pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{}); + + // Indexed access + pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, .{ .null_as_undefined = true }); + + pub const selectedIndex = bridge.accessor(HTMLOptionsCollection.getSelectedIndex, HTMLOptionsCollection.setSelectedIndex, .{}); + pub const add = bridge.function(HTMLOptionsCollection.add, .{}); + pub const remove = bridge.function(HTMLOptionsCollection.remove, .{}); +}; diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 45eea51c..ee123b4c 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -36,6 +36,8 @@ const Mode = enum { tag_name, class_name, child_elements, + child_tag, + selected_options, }; const Filters = union(Mode) { @@ -43,6 +45,8 @@ const Filters = union(Mode) { tag_name: String, class_name: []const u8, child_elements, + child_tag: Element.Tag, + selected_options, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); @@ -71,7 +75,7 @@ pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { .tag, .tag_name, .class_name => TreeWalker.FullExcludeSelf, - .child_elements => TreeWalker.Children, + .child_elements, .child_tag, .selected_options => TreeWalker.Children, }; return struct { _tw: TW, @@ -213,6 +217,16 @@ pub fn NodeLive(comptime mode: Mode) type { return Selector.classAttributeContains(class_attr, self._filter); }, .child_elements => return node._type == .element, + .child_tag => { + const el = node.is(Element) orelse return false; + return el.getTag() == self._filter; + }, + .selected_options => { + const el = node.is(Element) orelse return false; + const Option = Element.Html.Option; + const opt = el.is(Option) orelse return false; + return opt.getSelected(); + }, } } @@ -236,6 +250,8 @@ pub fn NodeLive(comptime mode: Mode) type { .tag_name => HTMLCollection{ .data = .{ .tag_name = self } }, .class_name => HTMLCollection{ .data = .{ .class_name = self } }, .child_elements => HTMLCollection{ .data = .{ .child_elements = self } }, + .child_tag => HTMLCollection{ .data = .{ .child_tag = self } }, + .selected_options => HTMLCollection{ .data = .{ .selected_options = self } }, }; return page._factory.create(collection); } diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 66357754..3d37f173 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -174,7 +174,7 @@ pub const List = struct { if (is_id) { try page.document._elements_by_id.put(page.arena, entry._value.str(), element); } - page.attributeChange(element, result.normalized, value); + page.attributeChange(element, result.normalized, entry._value.str()); return entry; } diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index 2e1a4016..acf076e6 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -50,6 +50,26 @@ pub fn setDisabled(self: *Button, disabled: bool, page: *Page) !void { } } +pub fn getName(self: *const Button) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Button, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} + +pub fn getRequired(self: *const Button) bool { + return self.asConstElement().getAttributeSafe("required") != null; +} + +pub fn setRequired(self: *Button, required: bool, page: *Page) !void { + if (required) { + try self.asElement().setAttributeSafe("required", "", page); + } else { + try self.asElement().removeAttribute("required", page); + } +} + pub fn getForm(self: *Button, page: *Page) ?*Form { const element = self.asElement(); @@ -84,6 +104,8 @@ pub const JsApi = struct { }; pub const disabled = bridge.accessor(Button.getDisabled, Button.setDisabled, .{}); + pub const name = bridge.accessor(Button.getName, Button.setName, .{}); + pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{}); pub const form = bridge.accessor(Button.getForm, null, .{}); }; diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index d805ba6f..9c4593a9 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -109,6 +109,10 @@ pub fn getDefaultValue(self: *const Input) []const u8 { return self._default_value orelse ""; } +pub fn setDefaultValue(self: *Input, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("value", value, page); +} + pub fn getChecked(self: *const Input) bool { return self._checked; } @@ -126,6 +130,14 @@ pub fn getDefaultChecked(self: *const Input) bool { return self._default_checked; } +pub fn setDefaultChecked(self: *Input, checked: bool, page: *Page) !void { + if (checked) { + try self.asElement().setAttributeSafe("checked", "", page); + } else { + try self.asElement().removeAttribute("checked", page); + } +} + pub fn getDisabled(self: *const Input) bool { // TODO: Also check for disabled fieldset ancestors // (but not if we're inside a of that fieldset) @@ -140,6 +152,26 @@ pub fn setDisabled(self: *Input, disabled: bool, page: *Page) !void { } } +pub fn getName(self: *const Input) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Input, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} + +pub fn getRequired(self: *const Input) bool { + return self.asConstElement().getAttributeSafe("required") != null; +} + +pub fn setRequired(self: *Input, required: bool, page: *Page) !void { + if (required) { + try self.asElement().setAttributeSafe("required", "", page); + } else { + try self.asElement().removeAttribute("required", page); + } +} + pub fn getForm(self: *Input, page: *Page) ?*Form { const element = self.asElement(); @@ -218,10 +250,12 @@ pub const JsApi = struct { pub const @"type" = bridge.accessor(Input.getType, Input.setType, .{}); pub const value = bridge.accessor(Input.getValue, Input.setValue, .{}); - pub const defaultValue = bridge.accessor(Input.getDefaultValue, null, .{}); + pub const defaultValue = bridge.accessor(Input.getDefaultValue, Input.setDefaultValue, .{}); pub const checked = bridge.accessor(Input.getChecked, Input.setChecked, .{}); - pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, null, .{}); + pub const defaultChecked = bridge.accessor(Input.getDefaultChecked, Input.setDefaultChecked, .{}); pub const disabled = bridge.accessor(Input.getDisabled, Input.setDisabled, .{}); + pub const name = bridge.accessor(Input.getName, Input.setName, .{}); + pub const required = bridge.accessor(Input.getRequired, Input.setRequired, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); }; @@ -249,13 +283,20 @@ pub const Build = struct { } } - pub fn attributeChange(element: *Element, name: []const u8, value: []const u8, _: *Page) !void { + pub fn attributeChange(element: *Element, name: []const u8, value: []const u8, page: *Page) !void { const attribute = std.meta.stringToEnum(enum { type, value, checked }, name) orelse return; const self = element.as(Input); switch (attribute) { .type => self._input_type = Type.fromString(value), .value => self._default_value = value, - .checked => self._default_checked = true, + .checked => { + self._default_checked = true; + self._checked = true; + // If setting a radio button to checked, uncheck others in the group + if (self._input_type == .radio) { + try self.uncheckRadioGroup(page); + } + }, } } @@ -265,7 +306,10 @@ pub const Build = struct { switch (attribute) { .type => self._input_type = .text, .value => self._default_value = null, - .checked => self._default_checked = false, + .checked => { + self._default_checked = false; + self._checked = false; + }, } } }; diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 5123e088..b5718a1e 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -16,6 +16,7 @@ // 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"); @@ -35,6 +36,9 @@ _disabled: bool = false, pub fn asElement(self: *Option) *Element { return self._proto._proto; } +pub fn asConstElement(self: *const Option) *const Element { + return self._proto._proto; +} pub fn asNode(self: *Option) *Node { return self.asElement().asNode(); } @@ -45,7 +49,7 @@ pub fn getValue(self: *const Option) []const u8 { } pub fn setValue(self: *Option, value: []const u8, page: *Page) !void { - const owned = try page.arena.dupe(u8, value); + const owned = try page.dupeString(value); try self.asElement().setAttributeSafe("value", owned, page); self._value = owned; } @@ -59,10 +63,10 @@ pub fn getSelected(self: *const Option) bool { } pub fn setSelected(self: *Option, selected: bool, page: *Page) !void { - _ = page; // TODO: When setting selected=true, may need to unselect other options // in the parent