diff --git a/src/browser/forms.zig b/src/browser/forms.zig new file mode 100644 index 00000000..ebf183e8 --- /dev/null +++ b/src/browser/forms.zig @@ -0,0 +1,460 @@ +// Copyright (C) 2023-2026 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 Page = @import("Page.zig"); +const TreeWalker = @import("webapi/TreeWalker.zig"); +const Element = @import("webapi/Element.zig"); +const Node = @import("webapi/Node.zig"); + +const Allocator = std.mem.Allocator; + +pub const SelectOption = struct { + value: []const u8, + text: []const u8, + + pub fn jsonStringify(self: *const SelectOption, jw: anytype) !void { + try jw.beginObject(); + try jw.objectField("value"); + try jw.write(self.value); + try jw.objectField("text"); + try jw.write(self.text); + try jw.endObject(); + } +}; + +pub const FormField = struct { + backendNodeId: ?u32 = null, + node: *Node, + tag_name: []const u8, + name: ?[]const u8, + input_type: ?[]const u8, + required: bool, + disabled: bool, + value: ?[]const u8, + placeholder: ?[]const u8, + options: []SelectOption, + + pub fn jsonStringify(self: *const FormField, jw: anytype) !void { + try jw.beginObject(); + + if (self.backendNodeId) |id| { + try jw.objectField("backendNodeId"); + try jw.write(id); + } + + try jw.objectField("tagName"); + try jw.write(self.tag_name); + + if (self.name) |v| { + try jw.objectField("name"); + try jw.write(v); + } + + if (self.input_type) |v| { + try jw.objectField("inputType"); + try jw.write(v); + } + + try jw.objectField("required"); + try jw.write(self.required); + + try jw.objectField("disabled"); + try jw.write(self.disabled); + + if (self.value) |v| { + try jw.objectField("value"); + try jw.write(v); + } + + if (self.placeholder) |v| { + try jw.objectField("placeholder"); + try jw.write(v); + } + + if (self.options.len > 0) { + try jw.objectField("options"); + try jw.beginArray(); + for (self.options) |opt| { + try opt.jsonStringify(jw); + } + try jw.endArray(); + } + + try jw.endObject(); + } +}; + +pub const FormInfo = struct { + backendNodeId: ?u32 = null, + node: *Node, + action: ?[]const u8, + method: ?[]const u8, + fields: []FormField, + + pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void { + try jw.beginObject(); + + if (self.backendNodeId) |id| { + try jw.objectField("backendNodeId"); + try jw.write(id); + } + + if (self.action) |v| { + try jw.objectField("action"); + try jw.write(v); + } + + if (self.method) |v| { + try jw.objectField("method"); + try jw.write(v); + } + + try jw.objectField("fields"); + try jw.beginArray(); + for (self.fields) |field| { + try field.jsonStringify(jw); + } + try jw.endArray(); + + try jw.endObject(); + } +}; + +/// Populate backendNodeId on each form and its fields by registering +/// their nodes in the given registry. Works with both CDP and MCP registries. +pub fn registerNodes(forms_data: []FormInfo, registry: anytype) !void { + for (forms_data) |*form| { + const form_registered = try registry.register(form.node); + form.backendNodeId = form_registered.id; + for (form.fields) |*field| { + const field_registered = try registry.register(field.node); + field.backendNodeId = field_registered.id; + } + } +} + +/// Collect all forms and their fields under `root`. +/// Uses Form.getElements() to include fields outside the
that +/// reference it via the form="id" attribute, matching browser behavior. +/// `arena` must be an arena allocator — returned slices borrow its memory. +pub fn collectForms( + arena: Allocator, + root: *Node, + page: *Page, +) ![]FormInfo { + var forms: std.ArrayList(FormInfo) = .empty; + + var tw = TreeWalker.Full.init(root, .{}); + while (tw.next()) |node| { + const form = node.is(Element.Html.Form) orelse continue; + const el = form.asElement(); + + const fields = try collectFormFields(arena, form, page); + if (fields.len == 0) continue; + + const action_attr = el.getAttributeSafe(comptime .wrap("action")); + const method_str = form.getMethod(); + + try forms.append(arena, .{ + .node = node, + .action = if (action_attr) |a| if (a.len > 0) a else null else null, + .method = method_str, + .fields = fields, + }); + } + + return forms.items; +} + +fn collectFormFields( + arena: Allocator, + form: *Element.Html.Form, + page: *Page, +) ![]FormField { + var fields: std.ArrayList(FormField) = .empty; + + var elements = try form.getElements(page); + var it = try elements.iterator(); + while (it.next()) |el| { + const node = el.asNode(); + + const is_disabled = el.isDisabled(); + + if (el.is(Element.Html.Input)) |input| { + if (input._input_type == .hidden) continue; + if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue; + + try fields.append(arena, .{ + .node = node, + .tag_name = "input", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = input._input_type.toString(), + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .disabled = is_disabled, + .value = input.getValue(), + .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), + .options = &.{}, + }); + continue; + } + + if (el.is(Element.Html.TextArea)) |textarea| { + try fields.append(arena, .{ + .node = node, + .tag_name = "textarea", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = null, + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .disabled = is_disabled, + .value = textarea.getValue(), + .placeholder = el.getAttributeSafe(comptime .wrap("placeholder")), + .options = &.{}, + }); + continue; + } + + if (el.is(Element.Html.Select)) |select| { + const options = try collectSelectOptions(arena, node, page); + + try fields.append(arena, .{ + .node = node, + .tag_name = "select", + .name = el.getAttributeSafe(comptime .wrap("name")), + .input_type = null, + .required = el.getAttributeSafe(comptime .wrap("required")) != null, + .disabled = is_disabled, + .value = select.getValue(page), + .placeholder = null, + .options = options, + }); + continue; + } + + // Button elements from getElements() - skip (not fillable) + } + + return fields.items; +} + +fn collectSelectOptions( + arena: Allocator, + select_node: *Node, + page: *Page, +) ![]SelectOption { + var options: std.ArrayList(SelectOption) = .empty; + const Option = Element.Html.Option; + + var tw = TreeWalker.Full.init(select_node, .{}); + while (tw.next()) |node| { + const el = node.is(Element) orelse continue; + const option = el.is(Option) orelse continue; + + try options.append(arena, .{ + .value = option.getValue(page), + .text = option.getText(page), + }); + } + + return options.items; +} + +const testing = @import("../testing.zig"); + +fn testForms(html: []const u8) ![]FormInfo { + const page = try testing.test_session.createPage(); + + const doc = page.window._document; + const div = try doc.createElement("div", null, page); + try page.parseHtmlAsChildren(div.asNode(), html); + + return collectForms(page.call_arena, div.asNode(), page); +} + +test "browser.forms: login form" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\ + \\ + \\ + \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual("/login", forms[0].action.?); + try testing.expectEqual("post", forms[0].method.?); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expectEqual("email", forms[0].fields[0].name.?); + try testing.expectEqual("email", forms[0].fields[0].input_type.?); + try testing.expect(forms[0].fields[0].required); + try testing.expect(!forms[0].fields[0].disabled); + try testing.expectEqual("password", forms[0].fields[1].name.?); +} + +test "browser.forms: form with select" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual("select", forms[0].fields[0].tag_name); + try testing.expectEqual(2, forms[0].fields[0].options.len); + try testing.expectEqual("red", forms[0].fields[0].options[0].value); + try testing.expectEqual("Red", forms[0].fields[0].options[0].text); +} + +test "browser.forms: form with textarea" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual("textarea", forms[0].fields[0].tag_name); + try testing.expectEqual("Your message", forms[0].fields[0].placeholder.?); +} + +test "browser.forms: empty form skipped" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\

No fields here

+ \\
+ ); + try testing.expectEqual(0, forms.len); +} + +test "browser.forms: hidden inputs excluded" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual("username", forms[0].fields[0].name.?); +} + +test "browser.forms: multiple forms" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\
+ \\
+ \\ + \\ + \\
+ ); + try testing.expectEqual(2, forms.len); + try testing.expectEqual(1, forms[0].fields.len); + try testing.expectEqual(2, forms[1].fields.len); +} + +test "browser.forms: disabled fields flagged" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expect(!forms[0].fields[0].disabled); + try testing.expect(forms[0].fields[1].disabled); +} + +test "browser.forms: disabled fieldset" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\
+ \\ + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expect(forms[0].fields[0].disabled); + try testing.expect(!forms[0].fields[1].disabled); +} + +test "browser.forms: external field via form attribute" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\ + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); +} + +test "browser.forms: checkbox and radio return value attribute" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(2, forms[0].fields.len); + try testing.expectEqual("checkbox", forms[0].fields[0].input_type.?); + try testing.expectEqual("yes", forms[0].fields[0].value.?); + try testing.expectEqual("radio", forms[0].fields[1].input_type.?); + try testing.expectEqual("red", forms[0].fields[1].value.?); +} + +test "browser.forms: form without action or method" { + defer testing.reset(); + defer testing.test_session.removePage(); + const forms = try testForms( + \\
+ \\ + \\
+ ); + try testing.expectEqual(1, forms.len); + try testing.expectEqual(null, forms[0].action); + try testing.expectEqual("get", forms[0].method.?); + try testing.expectEqual(1, forms[0].fields.len); +} diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index 944205bd..75226ad1 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -162,7 +162,7 @@ pub fn collectInteractiveElements( .name = try getAccessibleName(el, arena), .interactivity_type = itype, .listener_types = listener_types, - .disabled = isDisabled(el), + .disabled = el.isDisabled(), .tab_index = html_el.getTabIndex(), .id = el.getAttributeSafe(comptime .wrap("id")), .class = el.getAttributeSafe(comptime .wrap("class")), @@ -412,36 +412,6 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 { // strip out trailing space return arr.items[0 .. arr.items.len - 1]; } -fn isDisabled(el: *Element) bool { - if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true; - return isDisabledByFieldset(el); -} - -/// Check if an element is disabled by an ancestor
. -/// Per spec, elements inside the first child of a disabled fieldset -/// are NOT disabled by that fieldset. -fn isDisabledByFieldset(el: *Element) bool { - const element_node = el.asNode(); - var current: ?*Node = element_node._parent; - while (current) |node| { - current = node._parent; - const ancestor = node.is(Element) orelse continue; - - if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) { - // Check if element is inside the first child of this fieldset - var child = ancestor.firstElementChild(); - while (child) |c| { - if (c.getTag() == .legend) { - if (c.asNode().contains(element_node)) return false; - break; - } - child = c.nextElementSibling(); - } - return true; - } - } - return false; -} fn getInputType(el: *Element) ?[]const u8 { if (el.is(Element.Html.Input)) |input| { diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html index 6634ccf2..36c74a66 100644 --- a/src/browser/tests/frames/frames.html +++ b/src/browser/tests/frames/frames.html @@ -120,9 +120,10 @@