diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html new file mode 100644 index 00000000..9c5055e3 --- /dev/null +++ b/src/browser/tests/element/html/form.html @@ -0,0 +1,299 @@ + + + + +
+
+ + + + + + +
+
+
+
+ + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
+ + +
+ + + + +
+ + + +
+ + + + + + + +
+ + + + +
+ + + diff --git a/src/browser/tests/legacy/xhr/form_data.html b/src/browser/tests/legacy/xhr/form_data.html index 94bf8a27..cda34c06 100644 --- a/src/browser/tests/legacy/xhr/form_data.html +++ b/src/browser/tests/legacy/xhr/form_data.html @@ -1,5 +1,42 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- diff --git a/src/browser/tests/net/form_data.html b/src/browser/tests/net/form_data.html index 71aae77c..515b8d93 100644 --- a/src/browser/tests/net/form_data.html +++ b/src/browser/tests/net/form_data.html @@ -250,3 +250,134 @@ testing.expectEqual(3, context.sum); } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 532a335c..e27ca505 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -339,7 +339,7 @@ pub fn isConnected(self: *const Node) bool { const GetRootNodeOpts = struct { composed: bool = false, }; -pub fn getRootNode(self: *const Node, opts_: ?GetRootNodeOpts) *const Node { +pub fn getRootNode(self: *Node, opts_: ?GetRootNodeOpts) *Node { const opts = opts_ orelse GetRootNodeOpts{}; var root = self; @@ -613,7 +613,7 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, Str } } -pub fn compareDocumentPosition(self: *const Node, other: *const Node) u16 { +pub fn compareDocumentPosition(self: *Node, other: *Node) u16 { const DISCONNECTED: u16 = 0x01; const PRECEDING: u16 = 0x02; const FOLLOWING: u16 = 0x04; diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig index 3e36e05f..d0dd81ce 100644 --- a/src/browser/webapi/collections.zig +++ b/src/browser/webapi/collections.zig @@ -21,6 +21,7 @@ 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 const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig"); pub fn registerTypes() []const type { return &.{ @@ -33,6 +34,7 @@ pub fn registerTypes() []const type { @import("collections/HTMLAllCollection.zig"), @import("collections/HTMLAllCollection.zig").Iterator, HTMLOptionsCollection, + HTMLFormControlsCollection, DOMTokenList, DOMTokenList.KeyIterator, DOMTokenList.ValueIterator, diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 3160524d..462f1b37 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -23,6 +23,7 @@ const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const NodeLive = @import("node_live.zig").NodeLive; +const Form = @import("../element/html/Form.zig"); const Mode = enum { tag, @@ -35,11 +36,13 @@ const Mode = enum { selected_options, links, anchors, + form, }; const HTMLCollection = @This(); -data: union(Mode) { +_type: Type = .{ .generic = {} }, +_data: union(Mode) { tag: NodeLive(.tag), tag_name: NodeLive(.tag_name), class_name: NodeLive(.class_name), @@ -50,22 +53,28 @@ data: union(Mode) { selected_options: NodeLive(.selected_options), links: NodeLive(.links), anchors: NodeLive(.anchors), + form: NodeLive(.form), }, +const Type = union(enum) { + generic: void, + form: *Form, +}; + pub fn length(self: *HTMLCollection, page: *const Page) u32 { - return switch (self.data) { + return switch (self._data) { inline else => |*impl| impl.length(page), }; } pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element { - return switch (self.data) { + return switch (self._data) { inline else => |*impl| impl.getAtIndex(index, page), }; } pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element { - return switch (self.data) { + return switch (self._data) { inline else => |*impl| impl.getByName(name, page), }; } @@ -73,7 +82,7 @@ pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { return Iterator.init(.{ .list = self, - .tw = switch (self.data) { + .tw = switch (self._data) { .tag => |*impl| .{ .tag = impl._tw.clone() }, .tag_name => |*impl| .{ .tag_name = impl._tw.clone() }, .class_name => |*impl| .{ .class_name = impl._tw.clone() }, @@ -84,6 +93,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator { .selected_options => |*impl| .{ .selected_options = impl._tw.clone() }, .links => |*impl| .{ .links = impl._tw.clone() }, .anchors => |*impl| .{ .anchors = impl._tw.clone() }, + .form => |*impl| .{ .form = impl._tw.clone() }, }, }, page); } @@ -102,10 +112,11 @@ pub const Iterator = GenericIterator(struct { selected_options: TreeWalker.Children, links: TreeWalker.FullExcludeSelf, anchors: TreeWalker.FullExcludeSelf, + form: TreeWalker.FullExcludeSelf, }, pub fn next(self: *@This(), _: *Page) ?*Element { - return switch (self.list.data) { + return switch (self.list._data) { .tag => |*impl| impl.nextTw(&self.tw.tag), .tag_name => |*impl| impl.nextTw(&self.tw.tag_name), .class_name => |*impl| impl.nextTw(&self.tw.class_name), @@ -116,6 +127,7 @@ pub const Iterator = GenericIterator(struct { .selected_options => |*impl| impl.nextTw(&self.tw.selected_options), .links => |*impl| impl.nextTw(&self.tw.links), .anchors => |*impl| impl.nextTw(&self.tw.anchors), + .form => |*impl| impl.nextTw(&self.tw.form), }; } }, null); diff --git a/src/browser/webapi/collections/HTMLFormControlsCollection.zig b/src/browser/webapi/collections/HTMLFormControlsCollection.zig new file mode 100644 index 00000000..e7fd1420 --- /dev/null +++ b/src/browser/webapi/collections/HTMLFormControlsCollection.zig @@ -0,0 +1,57 @@ +// 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 HTMLFormControlsCollection = @This(); + +_proto: *HTMLCollection, + +pub fn length(self: *HTMLFormControlsCollection, page: *Page) u32 { + return self._proto.length(page); +} + +pub fn getAtIndex(self: *HTMLFormControlsCollection, index: usize, page: *Page) ?*Element { + 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 const JsApi = struct { + pub const bridge = js.Bridge(HTMLFormControlsCollection); + + pub const Meta = struct { + pub const name = "HTMLFormControlsCollection"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const manage = false; + }; + + pub const length = bridge.accessor(HTMLFormControlsCollection.length, null, .{}); + pub const @"[int]" = bridge.indexed(HTMLFormControlsCollection.getAtIndex, .{ .null_as_undefined = true }); + pub const @"[str]" = bridge.namedIndexed(HTMLFormControlsCollection.namedItem, null, null, .{ .null_as_undefined = true }); + pub const namedItem = bridge.function(HTMLFormControlsCollection.namedItem, .{}); +}; diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index f3f4dd1a..f02b66fb 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -28,6 +28,7 @@ const Node = @import("../Node.zig"); const Element = @import("../Element.zig"); const TreeWalker = @import("../TreeWalker.zig"); const Selector = @import("../selector/Selector.zig"); +const Form = @import("../element/html/Form.zig"); const Allocator = std.mem.Allocator; @@ -42,6 +43,7 @@ const Mode = enum { selected_options, links, anchors, + form, }; const Filters = union(Mode) { @@ -55,6 +57,7 @@ const Filters = union(Mode) { selected_options, links, anchors, + form: *Form, fn TypeOf(comptime mode: Mode) type { @setEvalBranchQuota(2000); @@ -82,7 +85,7 @@ const Filters = union(Mode) { pub fn NodeLive(comptime mode: Mode) type { const Filter = Filters.TypeOf(mode); const TW = switch (mode) { - .tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors => TreeWalker.FullExcludeSelf, + .tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf, .child_elements, .child_tag, .selected_options => TreeWalker.Children, }; return struct { @@ -259,9 +262,46 @@ pub fn NodeLive(comptime mode: Mode) type { if (el.is(Anchor) == null) return false; return el.hasAttributeSafe("name"); }, + .form => { + const el = node.is(Element) orelse return false; + if (!isFormControl(el)) { + return false; + } + + if (el.getAttributeSafe("form")) |form_attr| { + const form_id = self._filter.asElement().getAttributeSafe("id") orelse return false; + return std.mem.eql(u8, form_attr, form_id); + } + + // No form attribute - match if descendant of our form + // This does an O(depth) ancestor walk for each control in the form. + // + // TODO: If profiling shows this is a bottleneck: + // When we first encounter the form element during tree walk, we could + // do a one-time reverse walk to find the LAST control that belongs to + // this form (checking both form controls and their form= attributes). + // Store that element in a new FormState. Then as we traverse + // forward: + // - Set is_within_form = true when we enter the form element + // - Return true immediately for any control while is_within_form + // - Set is_within_form = false when we reach that last element + // This trades one O(form_size) reverse walk for N O(depth) ancestor + // checks, where N = number of controls. For forms with many nested + // controls, this could be significantly faster. + return self._filter.asNode().contains(node); + }, } } + fn isFormControl(el: *Element) bool { + if (el._type != .html) return false; + const html = el._type.html; + return switch (html._type) { + .input, .button, .select, .text_area => true, + else => false, + }; + } + fn versionCheck(self: *Self, page: *const Page) bool { const current = page.version; if (current == self._cached_version) { @@ -278,16 +318,17 @@ pub fn NodeLive(comptime mode: Mode) type { const HTMLCollection = @import("HTMLCollection.zig"); pub fn runtimeGenericWrap(self: Self, page: *Page) !*HTMLCollection { const collection = switch (mode) { - .tag => HTMLCollection{ .data = .{ .tag = self } }, - .tag_name => HTMLCollection{ .data = .{ .tag_name = self } }, - .class_name => HTMLCollection{ .data = .{ .class_name = self } }, - .name => HTMLCollection{ .data = .{ .name = self } }, - .all_elements => HTMLCollection{ .data = .{ .all_elements = self } }, - .child_elements => HTMLCollection{ .data = .{ .child_elements = self } }, - .child_tag => HTMLCollection{ .data = .{ .child_tag = self } }, - .selected_options => HTMLCollection{ .data = .{ .selected_options = self } }, - .links => HTMLCollection{ .data = .{ .links = self } }, - .anchors => HTMLCollection{ .data = .{ .anchors = self } }, + .tag => HTMLCollection{ ._data = .{ .tag = self } }, + .tag_name => HTMLCollection{ ._data = .{ .tag_name = self } }, + .class_name => HTMLCollection{ ._data = .{ .class_name = self } }, + .name => HTMLCollection{ ._data = .{ .name = self } }, + .all_elements => HTMLCollection{ ._data = .{ .all_elements = self } }, + .child_elements => HTMLCollection{ ._data = .{ .child_elements = self } }, + .child_tag => HTMLCollection{ ._data = .{ .child_tag = self } }, + .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, + .links => HTMLCollection{ ._data = .{ .links = self } }, + .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, + .form => HTMLCollection{ ._type = .{ .form = self._filter }, ._data = .{ .form = self } }, }; return page._factory.create(collection); } diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 66f23dde..4e3186a2 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -23,6 +23,7 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const TreeWalker = @import("../../TreeWalker.zig"); +const collections = @import("../../collections.zig"); const Input = @import("Input.zig"); const Button = @import("Button.zig"); @@ -32,6 +33,9 @@ const TextArea = @import("TextArea.zig"); const Form = @This(); _proto: *HtmlElement, +fn asConstElement(self: *const Form) *const Element { + return self._proto._proto; +} pub fn asElement(self: *Form) *Element { return self._proto._proto; } @@ -39,90 +43,49 @@ pub fn asNode(self: *Form) *Node { return self.asElement().asNode(); } -// Untested / unused right now. Iterates over all the controls of a form, -// including those outside the
...
but with a form=$FORM_ID attribute -pub const Iterator = struct { - _form_id: ?[]const u8, - _walkers: union(enum) { - nested: TreeWalker.FullExcludeSelf, - names: TreeWalker.FullExcludeSelf, - }, +pub fn getName(self: *const Form) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} - pub fn init(form: *Form) Iterator { - const form_element = form.asElement(); - const form_id = form_element.getAttributeSafe("id"); +pub fn setName(self: *Form, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} - return .{ - ._form_id = form_id, - ._walkers = .{ - .nested = TreeWalker.FullExcludeSelf.init(form.asNode(), .{}), - }, - }; +pub fn getMethod(self: *const Form) []const u8 { + const method = self.asConstElement().getAttributeSafe("method") orelse return "get"; + + if (std.ascii.eqlIgnoreCase(method, "post")) { + return "post"; } - - pub fn next(self: *Iterator) ?FormControl { - switch (self._walkers) { - .nested => |*tw| { - // find controls nested directly in the form - while (tw.next()) |node| { - const element = node.is(Element) orelse continue; - const control = asFormControl(element) orelse continue; - // Skip if it has a form attribute (will be handled in phase 2) - if (element.getAttributeSafe("form") == null) { - return control; - } - } - if (self._form_id == null) { - return null; - } - - const doc = tw._root.getRootNode(); - self._walkers = .{ - .names = TreeWalker.FullExcludeSelf(doc, .{}), - }; - return self.next(); - }, - .names => |*tw| { - // find controls with a name matching the form id - while (tw.next()) |node| { - const input = node.is(Input) orelse continue; - if (input._type != .radio) { - continue; - } - const input_form = input.asElement().getAttributeSafe("form") orelse continue; - // must have a self._form_id, else we never would have transitioned - // from a nested walker to a namew walker - if (!std.mem.eql(u8, input_form, self._form_id.?)) { - continue; - } - return .{ .input = input }; - } - - return null; - }, - } + if (std.ascii.eqlIgnoreCase(method, "dialog")) { + return "dialog"; } -}; + // invalid, or it was get all along + return "get"; +} -pub const FormControl = union(enum) { - input: *Input, - button: *Button, - select: *Select, - textarea: *TextArea, -}; +pub fn setMethod(self: *Form, method: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("method", method, page); +} -fn asFormControl(element: *Element) ?FormControl { - if (element._type != .html) { - return null; - } - const html = element._type.html; - switch (html._type) { - .input => |cntrl| return .{ .input = cntrl }, - .button => |cntrl| return .{ .button = cntrl }, - .select => |cntrl| return .{ .select = cntrl }, - .textarea => |cntrl| return .{ .textarea = cntrl }, - else => return null, - } +pub fn getElements(self: *Form, page: *Page) !*collections.HTMLFormControlsCollection { + const form_id = self.asElement().getAttributeSafe("id"); + const root = if (form_id != null) + self.asNode().getRootNode(null) // Has ID: walk entire document to find form=ID controls + else + self.asNode(); // No ID: walk only form subtree (no external controls possible) + + const node_live = collections.NodeLive(.form).init(root, self, page); + const html_collection = try node_live.runtimeGenericWrap(page); + + return page._factory.create(collections.HTMLFormControlsCollection{ + ._proto = html_collection, + }); +} + +pub fn getLength(self: *Form, page: *Page) !u32 { + const elements = try self.getElements(page); + return elements.length(page); } pub const JsApi = struct { @@ -132,4 +95,14 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const name = bridge.accessor(Form.getName, Form.setName, .{}); + pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); + pub const elements = bridge.accessor(Form.getElements, null, .{}); + pub const length = bridge.accessor(Form.getLength, null, .{}); }; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTML.Form" { + try testing.htmlRunner("element/html/form.html", .{}); +} diff --git a/src/browser/webapi/net/FormData.zig b/src/browser/webapi/net/FormData.zig index 610c88bf..f8655246 100644 --- a/src/browser/webapi/net/FormData.zig +++ b/src/browser/webapi/net/FormData.zig @@ -22,6 +22,8 @@ const log = @import("../../../log.zig"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const Form = @import("../element/html/Form.zig"); +const Element = @import("../Element.zig"); const KeyValueList = @import("../KeyValueList.zig"); const Alloctor = std.mem.Allocator; @@ -31,7 +33,9 @@ const FormData = @This(); _arena: Alloctor, _list: KeyValueList, -pub fn init(page: *Page) !*FormData { +pub fn init(form_: ?*Form, submitter_: ?*Element, page: *Page) !*FormData { + _ = form_; + _ = submitter_; return page._factory.create(FormData{ ._arena = page.arena, ._list = KeyValueList.init(), @@ -127,6 +131,97 @@ pub const JsApi = struct { pub const forEach = bridge.function(FormData.forEach, .{}); }; +// fn collectForm(form: *Form, submitter_: ?*Element, page: *Page) !KeyValueList { +// const arena = page.arena; + +// // Don't use libdom's formGetCollection (aka dom_html_form_element_get_elements) +// // It doesn't work with dynamically added elements, because their form +// // property doesn't get set. We should fix that. +// // However, even once fixed, there are other form-collection features we +// // probably want to implement (like disabled fieldsets), so we might want +// // to stick with our own walker even if fix libdom to properly support +// // dynamically added elements. +// const node_list = try @import("../dom/css.zig").querySelectorAll(arena, @ptrCast(@alignCast(form)), "input,select,button,textarea"); +// const nodes = node_list.nodes.items; + +// var entries: kv.List = .{}; +// try entries.ensureTotalCapacity(arena, nodes.len); + +// var submitter_included = false; +// const submitter_name_ = try getSubmitterName(submitter_); + +// for (nodes) |node| { +// const element = parser.nodeToElement(node); + +// // must have a name +// const name = try parser.elementGetAttribute(element, "name") orelse continue; +// if (try parser.elementGetAttribute(element, "disabled") != null) { +// continue; +// } + +// const tag = try parser.elementTag(element); +// switch (tag) { +// .input => { +// const tpe = try parser.inputGetType(@ptrCast(element)); +// if (std.ascii.eqlIgnoreCase(tpe, "image")) { +// if (submitter_name_) |submitter_name| { +// if (std.mem.eql(u8, submitter_name, name)) { +// const key_x = try std.fmt.allocPrint(arena, "{s}.x", .{name}); +// const key_y = try std.fmt.allocPrint(arena, "{s}.y", .{name}); +// try entries.appendOwned(arena, key_x, "0"); +// try entries.appendOwned(arena, key_y, "0"); +// submitter_included = true; +// } +// } +// continue; +// } + +// if (std.ascii.eqlIgnoreCase(tpe, "checkbox") or std.ascii.eqlIgnoreCase(tpe, "radio")) { +// if (try parser.inputGetChecked(@ptrCast(element)) == false) { +// continue; +// } +// } +// if (std.ascii.eqlIgnoreCase(tpe, "submit")) { +// if (submitter_name_ == null or !std.mem.eql(u8, submitter_name_.?, name)) { +// continue; +// } +// submitter_included = true; +// } +// const value = try parser.inputGetValue(@ptrCast(element)); +// try entries.appendOwned(arena, name, value); +// }, +// .select => { +// const select: *parser.Select = @ptrCast(node); +// try collectSelectValues(arena, select, name, &entries, page); +// }, +// .textarea => { +// const textarea: *parser.TextArea = @ptrCast(node); +// const value = try parser.textareaGetValue(textarea); +// try entries.appendOwned(arena, name, value); +// }, +// .button => if (submitter_name_) |submitter_name| { +// if (std.mem.eql(u8, submitter_name, name)) { +// const value = (try parser.elementGetAttribute(element, "value")) orelse ""; +// try entries.appendOwned(arena, name, value); +// submitter_included = true; +// } +// }, +// else => unreachable, +// } +// } + +// if (submitter_included == false) { +// if (submitter_name_) |submitter_name| { +// // this can happen if the submitter is outside the form, but associated +// // with the form via a form=ID attribute +// const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse ""; +// try entries.appendOwned(arena, submitter_name, value); +// } +// } + +// return entries; +// } + const testing = @import("../../../testing.zig"); test "WebApi: FormData" { try testing.htmlRunner("net/form_data.html", .{});