diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index bb0c0b1d..913014cd 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -1367,7 +1367,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
Element.Html.Quote,
namespace,
attribute_iterator,
- .{ ._proto = undefined, ._tag_name = String.init(undefined, "q", .{}) catch unreachable, ._tag = .unknown },
+ .{ ._proto = undefined, ._tag_name = String.init(undefined, "q", .{}) catch unreachable, ._tag = .quote },
),
's' => return self.createHtmlElementT(
Element.Html.Generic,
@@ -1523,7 +1523,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
Element.Html.TableCol,
namespace,
attribute_iterator,
- .{ ._proto = undefined, ._tag_name = String.init(undefined, "col", .{}) catch unreachable, ._tag = .unknown },
+ .{ ._proto = undefined, ._tag_name = String.init(undefined, "col", .{}) catch unreachable, ._tag = .col },
),
asUint("dir") => return self.createHtmlElementT(
Element.Html.Directory,
@@ -1926,7 +1926,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
Element.Html.TableCol,
namespace,
attribute_iterator,
- .{ ._proto = undefined, ._tag_name = String.init(undefined, "colgroup", .{}) catch unreachable, ._tag = .unknown },
+ .{ ._proto = undefined, ._tag_name = String.init(undefined, "colgroup", .{}) catch unreachable, ._tag = .colgroup },
),
asUint("fieldset") => return self.createHtmlElementT(
Element.Html.FieldSet,
@@ -1952,6 +1952,12 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
attribute_iterator,
.{ ._proto = undefined },
),
+ asUint("noscript") => return self.createHtmlElementT(
+ Element.Html.Generic,
+ namespace,
+ attribute_iterator,
+ .{ ._proto = undefined, ._tag_name = String.init(undefined, "noscript", .{}) catch unreachable, ._tag = .noscript },
+ ),
else => {},
},
10 => switch (@as(u80, @bitCast(name[0..10].*))) {
diff --git a/src/browser/tests/cdp/dom3.html b/src/browser/tests/cdp/dom3.html
new file mode 100644
index 00000000..9dc6c0e1
--- /dev/null
+++ b/src/browser/tests/cdp/dom3.html
@@ -0,0 +1,25 @@
+
+
+
+ Test Page
+
+
+ Test Page
+
+
+
+
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index 828f726b..8bdf1a88 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -1219,6 +1219,8 @@ pub const Tag = enum {
caption,
circle,
code,
+ col,
+ colgroup,
custom,
data,
datalist,
@@ -1265,20 +1267,26 @@ pub const Tag = enum {
meta,
meter,
nav,
+ noscript,
+ object,
ol,
optgroup,
option,
+ output,
p,
path,
+ param,
polygon,
polyline,
progress,
+ quote,
rect,
s,
script,
section,
select,
slot,
+ source,
span,
strong,
style,
@@ -1298,6 +1306,7 @@ pub const Tag = enum {
thead,
title,
tr,
+ track,
ul,
video,
unknown,
diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig
new file mode 100644
index 00000000..e887d75e
--- /dev/null
+++ b/src/cdp/AXNode.zig
@@ -0,0 +1,1130 @@
+// 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 Allocator = std.mem.Allocator;
+const jsonStringify = std.json.Stringify;
+
+const log = @import("../log.zig");
+const Page = @import("../browser/Page.zig");
+const DOMNode = @import("../browser/webapi/Node.zig");
+const URL = @import("../browser/URL.zig");
+
+const AXNode = @This();
+const Node = @import("Node.zig");
+
+// Need a custom writer, because we can't just serialize the node as-is.
+// Sometimes we want to serializ the node without chidren, sometimes with just
+// its direct children, and sometimes the entire tree.
+// (For now, we only support direct children)
+pub const Writer = struct {
+ root: *const Node,
+ registry: *Node.Registry,
+ page: *Page,
+
+ pub const Opts = struct {};
+
+ pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void {
+ self.toJSON(self.root, w) catch |err| {
+ // The only error our jsonStringify method can return is
+ // @TypeOf(w).Error. In other words, our code can't return its own
+ // error, we can only return a writer error. Kinda sucks.
+ log.err(.cdp, "node toJSON stringify", .{ .err = err });
+ return error.WriteFailed;
+ };
+ }
+
+ fn toJSON(self: *const Writer, node: *const Node, w: anytype) !void {
+ try w.beginArray();
+ const root = AXNode.fromNode(node.dom);
+ if (try self.writeNode(node.id, root, w)) {
+ try self.writeNodeChildren(root, w);
+ }
+ return w.endArray();
+ }
+
+ fn writeNodeChildren(self: *const Writer, parent: AXNode, w: anytype) !void {
+ // Add ListMarker for listitem elements
+ if (parent.dom.is(DOMNode.Element)) |parent_el| {
+ if (parent_el.getTag() == .li) {
+ try self.writeListMarker(parent.dom, w);
+ }
+ }
+
+ var it = parent.dom.childrenIterator();
+ const ignore_text = ignoreText(parent.dom);
+ while (it.next()) |dom_node| {
+ switch (dom_node._type) {
+ .cdata => {
+ if (dom_node.is(DOMNode.CData.Text) == null) {
+ continue;
+ }
+ if (ignore_text) {
+ continue;
+ }
+ },
+ .element => {},
+ else => continue,
+ }
+
+ const node = try self.registry.register(dom_node);
+ const axn = AXNode.fromNode(node.dom);
+ if (try self.writeNode(node.id, axn, w)) {
+ try self.writeNodeChildren(axn, w);
+ }
+ }
+ }
+
+ fn writeListMarker(self: *const Writer, li_node: *DOMNode, w: anytype) !void {
+ // Find the parent list element
+ const parent = li_node._parent orelse return;
+ const parent_el = parent.is(DOMNode.Element) orelse return;
+ const list_type = parent_el.getTag();
+
+ // Only create markers for actual list elements
+ switch (list_type) {
+ .ul, .ol, .menu => {},
+ else => return,
+ }
+
+ // Write the ListMarker node
+ try w.beginObject();
+
+ // Use the next available ID for the marker
+ try w.objectField("nodeId");
+ const marker_id = self.registry.node_id;
+ self.registry.node_id += 1;
+ try w.write(marker_id);
+
+ try w.objectField("backendDOMNodeId");
+ try w.write(marker_id);
+
+ try w.objectField("role");
+ try self.writeAXValue(.{ .role = "ListMarker" }, w);
+
+ try w.objectField("ignored");
+ try w.write(false);
+
+ try w.objectField("name");
+ try w.beginObject();
+ try w.objectField("type");
+ try w.write("computedString");
+ try w.objectField("value");
+
+ // Write marker text directly based on list type
+ switch (list_type) {
+ .ul, .menu => try w.write("• "),
+ .ol => {
+ // Calculate the list item number by counting preceding li siblings
+ var count: usize = 1;
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ if (child == li_node) break;
+ if (child.is(DOMNode.Element.Html) == null) continue;
+ const child_el = child.as(DOMNode.Element);
+ if (child_el.getTag() == .li) count += 1;
+ }
+
+ // Sanity check: lists with >9999 items are unrealistic
+ if (count > 9999) return error.ListTooLong;
+
+ // Use a small stack buffer to format the number (max "9999. " = 6 chars)
+ var buf: [6]u8 = undefined;
+ const marker_text = try std.fmt.bufPrint(&buf, "{d}. ", .{count});
+ try w.write(marker_text);
+ },
+ else => unreachable,
+ }
+
+ try w.objectField("sources");
+ try w.beginArray();
+ try w.beginObject();
+ try w.objectField("type");
+ try w.write("contents");
+ try w.endObject();
+ try w.endArray();
+ try w.endObject();
+
+ try w.objectField("properties");
+ try w.beginArray();
+ try w.endArray();
+
+ // Get the parent node ID for the parentId field
+ const li_registered = try self.registry.register(li_node);
+ try w.objectField("parentId");
+ try w.write(li_registered.id);
+
+ try w.objectField("childIds");
+ try w.beginArray();
+ try w.endArray();
+
+ try w.endObject();
+ }
+
+ const AXValue = union(enum) {
+ role: []const u8,
+ string: []const u8,
+ computedString: []const u8,
+ integer: usize,
+ boolean: bool,
+ booleanOrUndefined: bool,
+ token: []const u8,
+ // TODO not implemented:
+ // tristate, idrefList, node, nodeList, number, tokenList,
+ // domRelation, internalRole, valueUndefined,
+ };
+
+ fn writeAXSource(_: *const Writer, source: AXSource, w: anytype) !void {
+ try w.objectField("sources");
+ try w.beginArray();
+ try w.beginObject();
+
+ // attribute, implicit, style, contents, placeholder, relatedElement
+ const source_type = switch (source) {
+ .aria_labelledby => blk: {
+ try w.objectField("attribute");
+ try w.write(@tagName(source));
+ break :blk "relatedElement";
+ },
+ .aria_label, .alt, .title, .placeholder, .value => blk: {
+ // No sure if it's correct for .value case.
+ try w.objectField("attribute");
+ try w.write(@tagName(source));
+ break :blk "attribute";
+ },
+ // Chrome sends the content AXValue *again* in the source.
+ // But It seems useless to me.
+ //
+ // w.objectField("value");
+ // self.writeAXValue(.{ .type = .computedString, .value = value.value }, w);
+ .contents => "contents",
+ .label_element, .label_wrap => "TODO", // TODO
+ };
+ try w.objectField("type");
+ try w.write(source_type);
+
+ try w.endObject();
+ try w.endArray();
+ }
+
+ fn writeAXValue(_: *const Writer, value: AXValue, w: anytype) !void {
+ try w.beginObject();
+ try w.objectField("type");
+ try w.write(@tagName(std.meta.activeTag(value)));
+
+ try w.objectField("value");
+ switch (value) {
+ inline else => |v| try w.write(v),
+ }
+
+ try w.endObject();
+ }
+
+ const AXProperty = struct {
+ // zig fmt: off
+ name: enum(u8) {
+ actions, busy, disabled, editable, focusable, focused, hidden,
+ hiddenRoot, invalid, keyshortcuts, settable, roledescription, live,
+ atomic, relevant, root, autocomplete, hasPopup, level,
+ multiselectable, orientation, multiline, readonly, required,
+ valuemin, valuemax, valuetext, checked, expanded, modal, pressed,
+ selected, activedescendant, controls, describedby, details,
+ errormessage, flowto, labelledby, owns, url,
+ activeFullscreenElement, activeModalDialog, activeAriaModalDialog,
+ ariaHiddenElement, ariaHiddenSubtree, emptyAlt, emptyText,
+ inertElement, inertSubtree, labelContainer, labelFor, notRendered,
+ notVisible, presentationalRole, probablyPresentational,
+ inactiveCarouselTabContent, uninteresting,
+ },
+ // zig fmt: on
+ value: AXValue,
+ };
+
+ fn writeAXProperties(self: *const Writer, axnode: AXNode, w: anytype) !void {
+ const dom_node = axnode.dom;
+ const page = self.page;
+ switch (dom_node._type) {
+ .document => |document| {
+ const uri = document.getURL(page);
+ try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w);
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ try self.writeAXProperty(.{ .name = .focused, .value = .{ .booleanOrUndefined = true } }, w);
+ return;
+ },
+ .cdata => return,
+ .element => |el| switch (el.getTag()) {
+ .h1 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 1 } }, w),
+ .h2 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 2 } }, w),
+ .h3 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 3 } }, w),
+ .h4 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 4 } }, w),
+ .h5 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 5 } }, w),
+ .h6 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 6 } }, w),
+ .img => {
+ const img = el.as(DOMNode.Element.Html.Image);
+ const uri = try img.getSrc(self.page);
+ if (uri.len == 0) return;
+ try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w);
+ },
+ .anchor => {
+ const a = el.as(DOMNode.Element.Html.Anchor);
+ const uri = try a.getHref(self.page);
+ if (uri.len == 0) return;
+ try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w);
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ },
+ .input => {
+ const input = el.as(DOMNode.Element.Html.Input);
+ const is_disabled = el.hasAttributeSafe("disabled");
+
+ switch (input._input_type) {
+ .text, .email, .tel, .url, .search, .password, .number => {
+ if (is_disabled) {
+ try self.writeAXProperty(.{ .name = .disabled, .value = .{ .boolean = true } }, w);
+ }
+ try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ try self.writeAXProperty(.{ .name = .editable, .value = .{ .token = "plaintext" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ try self.writeAXProperty(.{ .name = .multiline, .value = .{ .boolean = false } }, w);
+ try self.writeAXProperty(.{ .name = .readonly, .value = .{ .boolean = el.hasAttributeSafe("readonly") } }, w);
+ try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe("required") } }, w);
+ },
+ .button, .submit, .reset, .image => {
+ try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ },
+ .checkbox, .radio => {
+ try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ const is_checked = el.hasAttributeSafe("checked");
+ try self.writeAXProperty(.{ .name = .checked, .value = .{ .token = if (is_checked) "true" else "false" } }, w);
+ },
+ else => {},
+ }
+ },
+ .textarea => {
+ const is_disabled = el.hasAttributeSafe("disabled");
+
+ try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ try self.writeAXProperty(.{ .name = .editable, .value = .{ .token = "plaintext" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ try self.writeAXProperty(.{ .name = .multiline, .value = .{ .boolean = true } }, w);
+ try self.writeAXProperty(.{ .name = .readonly, .value = .{ .boolean = el.hasAttributeSafe("readonly") } }, w);
+ try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe("required") } }, w);
+ },
+ .select => {
+ const is_disabled = el.hasAttributeSafe("disabled");
+
+ try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ try self.writeAXProperty(.{ .name = .hasPopup, .value = .{ .token = "menu" } }, w);
+ try self.writeAXProperty(.{ .name = .expanded, .value = .{ .booleanOrUndefined = false } }, w);
+ },
+ .option => {
+ const option = el.as(DOMNode.Element.Html.Option);
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+
+ // Check if this option is selected by examining the parent select
+ const is_selected = blk: {
+ // First check if explicitly selected
+ if (option.getSelected()) break :blk true;
+
+ // Check if implicitly selected (first enabled option in select with no explicit selection)
+ const parent = dom_node._parent orelse break :blk false;
+ const parent_el = parent.as(DOMNode.Element);
+ if (parent_el.getTag() != .select) break :blk false;
+
+ const select = parent_el.as(DOMNode.Element.Html.Select);
+ const selected_idx = select.getSelectedIndex();
+
+ // Find this option's index
+ var idx: i32 = 0;
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ if (child.is(DOMNode.Element.Html.Option) == null) continue;
+ if (child == dom_node) {
+ break :blk idx == selected_idx;
+ }
+ idx += 1;
+ }
+ break :blk false;
+ };
+
+ if (is_selected) {
+ try self.writeAXProperty(.{ .name = .selected, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ },
+ .button => {
+ const is_disabled = el.hasAttributeSafe("disabled");
+ try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
+ if (!is_disabled) {
+ try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);
+ }
+ },
+ .hr => {
+ try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w);
+ try self.writeAXProperty(.{ .name = .orientation, .value = .{ .token = "horizontal" } }, w);
+ },
+ .li => {
+ // Calculate level by counting list ancestors (ul, ol, menu)
+ var level: usize = 0;
+ var current = dom_node._parent;
+ while (current) |node| {
+ if (node.is(DOMNode.Element) == null) {
+ current = node._parent;
+ continue;
+ }
+ const current_el = node.as(DOMNode.Element);
+ switch (current_el.getTag()) {
+ .ul, .ol, .menu => level += 1,
+ else => {},
+ }
+ current = node._parent;
+ }
+ try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = level } }, w);
+ },
+ else => {},
+ },
+ else => |tag| {
+ log.debug(.cdp, "invalid tag", .{ .tag = tag });
+ return error.InvalidTag;
+ },
+ }
+ }
+
+ fn writeAXProperty(self: *const Writer, value: AXProperty, w: anytype) !void {
+ try w.beginObject();
+ try w.objectField("name");
+ try w.write(@tagName(value.name));
+ try w.objectField("value");
+ try self.writeAXValue(value.value, w);
+ try w.endObject();
+ }
+
+ // write a node. returns true if children must be written.
+ fn writeNode(self: *const Writer, id: u32, axn: AXNode, w: anytype) !bool {
+ // ignore empty texts
+ try w.beginObject();
+
+ try w.objectField("nodeId");
+ try w.write(id);
+
+ try w.objectField("backendDOMNodeId");
+ try w.write(id);
+
+ try w.objectField("role");
+ try self.writeAXValue(.{ .role = try axn.getRole() }, w);
+
+ const ignore = axn.isIgnore(self.page);
+ try w.objectField("ignored");
+ try w.write(ignore);
+
+ if (ignore) {
+ // Ignore reasons
+ try w.objectField("ignoredReasons");
+ try w.beginArray();
+ try w.beginObject();
+ try w.objectField("name");
+ try w.write("uninteresting");
+ try w.objectField("value");
+ try self.writeAXValue(.{ .boolean = true }, w);
+ try w.endObject();
+ try w.endArray();
+ } else {
+ // Name
+ try w.objectField("name");
+ try w.beginObject();
+ try w.objectField("type");
+ try w.write(@tagName(.computedString));
+ try w.objectField("value");
+ const source = try axn.writeName(w, self.page);
+ if (source) |s| {
+ try self.writeAXSource(s, w);
+ }
+ try w.endObject();
+
+ // Value (for form controls)
+ try self.writeNodeValue(axn, w);
+
+ // Properties
+ try w.objectField("properties");
+ try w.beginArray();
+ try self.writeAXProperties(axn, w);
+ try w.endArray();
+ }
+
+ const n = axn.dom;
+
+ // Parent
+ if (n._parent) |p| {
+ const parent_node = try self.registry.register(p);
+ try w.objectField("parentId");
+ try w.write(parent_node.id);
+ }
+
+ // Children
+ const write_children = axn.ignoreChildren() == false;
+ const skip_text = ignoreText(axn.dom);
+
+ try w.objectField("childIds");
+ try w.beginArray();
+ if (write_children) {
+ var registry = self.registry;
+ var it = n.childrenIterator();
+ while (it.next()) |child| {
+ // ignore non-elements or text.
+ if (child.is(DOMNode.Element.Html) == null and (child.is(DOMNode.CData.Text) == null or skip_text)) {
+ continue;
+ }
+
+ const child_node = try registry.register(child);
+ try w.write(child_node.id);
+ }
+ }
+ try w.endArray();
+
+ try w.endObject();
+
+ return write_children;
+ }
+
+ fn writeNodeValue(self: *const Writer, axnode: AXNode, w: anytype) !void {
+ const node = axnode.dom;
+
+ if (node.is(DOMNode.Element.Html) == null) {
+ return;
+ }
+
+ const el = node.as(DOMNode.Element);
+
+ const value: ?[]const u8 = switch (el.getTag()) {
+ .input => blk: {
+ const input = el.as(DOMNode.Element.Html.Input);
+ const val = input.getValue();
+ if (val.len == 0) break :blk null;
+ break :blk val;
+ },
+ .textarea => blk: {
+ const textarea = el.as(DOMNode.Element.Html.TextArea);
+ const val = textarea.getValue();
+ if (val.len == 0) break :blk null;
+ break :blk val;
+ },
+ .select => blk: {
+ const select = el.as(DOMNode.Element.Html.Select);
+ const val = select.getValue(self.page);
+ if (val.len == 0) break :blk null;
+ break :blk val;
+ },
+ else => null,
+ };
+
+ if (value) |val| {
+ try w.objectField("value");
+ try self.writeAXValue(.{ .string = val }, w);
+ }
+ }
+};
+
+pub const AXRole = enum(u8) {
+ // zig fmt: off
+ none, article, banner, blockquote, button, caption, cell, checkbox, code,
+ columnheader, combobox, complementary, contentinfo, definition, deletion,
+ dialog, document, emphasis, figure, form, group, heading, image, insertion,
+ link, list, listbox, listitem, main, marquee, meter, navigation, option,
+ paragraph, presentation, progressbar, radio, region, row, rowgroup,
+ rowheader, searchbox, separator, slider, spinbutton, status, strong,
+ subscript, superscript, table, term, textbox, time, RootWebArea, LineBreak,
+ StaticText,
+ // zig fmt: on
+
+ fn fromNode(node: *DOMNode) !AXRole {
+ return switch (node._type) {
+ .document => return .RootWebArea, // Chrome specific.
+ .cdata => |cd| {
+ if (cd.is(DOMNode.CData.Text) == null) {
+ log.debug(.cdp, "invalid tag", .{ .tag = cd });
+ return error.InvalidTag;
+ }
+
+ return .StaticText;
+ },
+ .element => |el| switch (el.getTag()) {
+ // Navigation & Structure
+ .nav => .navigation,
+ .main => .main,
+ .aside => .complementary,
+ // TODO conditions:
+ // .banner Not descendant of article, aside, main, nav, section
+ // (none) When descendant of article, aside, main, nav, section
+ .header => .banner,
+ // TODO conditions:
+ // contentinfo Not descendant of article, aside, main, nav, section
+ // (none) When descendant of article, aside, main, nav, section
+ .footer => .contentinfo,
+ // TODO conditions:
+ // region Has accessible name (aria-label, aria-labelledby, or title) |
+ // (none) No accessible name |
+ .section => .region,
+ .article, .hgroup => .article,
+ .address => .group,
+
+ // Headings
+ .h1, .h2, .h3, .h4, .h5, .h6 => .heading,
+ .ul, .ol, .menu => .list,
+ .li => .listitem,
+ .dt => .term,
+ .dd => .definition,
+
+ // Forms & Inputs
+ // TODO conditions:
+ // form Has accessible name
+ // (none) No accessible name
+ .form => .form,
+ .input => {
+ const input = el.as(DOMNode.Element.Html.Input);
+ return switch (input._input_type) {
+ .tel, .url, .email, .text => .textbox,
+ .image, .reset, .button, .submit => .button,
+ .radio => .radio,
+ .range => .slider,
+ .number => .spinbutton,
+ .search => .searchbox,
+ .checkbox => .checkbox,
+ // zig fmt: off
+ .password, .@"datetime-local", .hidden, .month, .color,
+ .week, .time, .file, .date => .none,
+ // zig fmt: on
+ };
+ },
+ .textarea => .textbox,
+ .select => {
+ if (el.getAttributeSafe("multiple") != null) {
+ return .listbox;
+ }
+ if (el.getAttributeSafe("size")) |size| {
+ if (!std.ascii.eqlIgnoreCase(size, "1")) {
+ return .listbox;
+ }
+ }
+ return .combobox;
+ },
+ .option => .option,
+ .optgroup, .fieldset => .group,
+ .button => .button,
+ .output => .status,
+ .progress => .progressbar,
+ .meter => .meter,
+ .datalist => .listbox,
+
+ // Interactive Elements
+ .anchor, .area => {
+ if (el.getAttributeSafe("href") == null) {
+ return .none;
+ }
+
+ return .link;
+ },
+ .details => .group,
+ .summary => .button,
+ .dialog => .dialog,
+
+ // Media
+ .img => .image,
+ .figure => .figure,
+
+ // Tables
+ .table => .table,
+ .caption => .caption,
+ .thead, .tbody, .tfoot => .rowgroup,
+ .tr => .row,
+ .th => {
+ if (el.getAttributeSafe("scope")) |scope| {
+ if (std.ascii.eqlIgnoreCase(scope, "row")) {
+ return .rowheader;
+ }
+ }
+ return .columnheader;
+ },
+ .td => .cell,
+
+ // Text & Semantics
+ .p => .paragraph,
+ .hr => .separator,
+ .blockquote => .blockquote,
+ .code => .code,
+ .em => .emphasis,
+ .strong => .strong,
+ .s, .del => .deletion,
+ .ins => .insertion,
+ .sub => .subscript,
+ .sup => .superscript,
+ .time => .time,
+ .dfn => .term,
+
+ // Document Structure
+ .html => .none,
+ .body => .none,
+
+ // Deprecated/Obsolete Elements
+ .marquee => .marquee,
+
+ .br => .LineBreak,
+
+ else => .none,
+ },
+ else => |tag| {
+ log.debug(.cdp, "invalid tag", .{ .tag = tag });
+ return error.InvalidTag;
+ },
+ };
+ }
+};
+
+dom: *DOMNode,
+role_attr: ?[]const u8,
+
+pub fn fromNode(dom: *DOMNode) AXNode {
+ return .{
+ .dom = dom,
+ .role_attr = blk: {
+ if (dom.is(DOMNode.Element.Html) == null) {
+ break :blk null;
+ }
+ const elt = dom.as(DOMNode.Element);
+ break :blk elt.getAttributeSafe("role");
+ },
+ };
+}
+
+const AXSource = enum(u8) {
+ aria_labelledby,
+ aria_label,
+ label_element, //