From 41407921628477d4650cfe9c2c73e511b4bede47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 25 Aug 2026 15:34:37 +0200 Subject: [PATCH] interactive: read the role attribute as a token list ARIA `role` is a space-separated fallback list (`role="switch checkbox"`), but interactive.zig and AXNode compared the raw attribute whole, so a fallback list or padded value was neither interactive nor findable by role, and the semantic tree reported the raw string as the role. One explicitRole helper takes the first token; a blank attribute now falls through to the implicit role. --- src/browser/interactive.zig | 29 ++++++++++++++++++++++++++--- src/cdp/AXNode.zig | 28 ++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index d6f092842..f7ac32589 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -295,7 +295,7 @@ pub fn classifyInteractivity( } // 2. ARIA interactive role - if (el.getAttributeSafe(comptime .wrap("role"))) |role| { + if (explicitRole(el)) |role| { if (isInteractiveRole(role)) return .aria; } @@ -367,9 +367,15 @@ pub fn isContentRole(role: []const u8) bool { return content_roles.has(lowered); } +// ARIA `role` is a space-separated fallback list; the first token wins. +pub fn explicitRole(el: *Element) ?[]const u8 { + const attr = el.getAttributeSafe(comptime .wrap("role")) orelse return null; + var it = std.mem.tokenizeAny(u8, attr, " \t\n\r"); + return it.next(); +} + fn getRole(el: *Element) ?[]const u8 { - // Explicit role attribute takes precedence - if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role; + if (explicitRole(el)) |role| return role; // Implicit role from tag return switch (el.getTag()) { @@ -563,6 +569,23 @@ test "browser.interactive: aria role" { try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type); } +test "browser.interactive: aria role token list" { + const elements = try testInteractive( + \\
Fallback
+ \\
Padded
+ \\
Empty
+ \\ + ); + defer testing.test_session.closeAllPages(); + try testing.expectEqual(3, elements.len); + try testing.expectEqual("switch", elements[0].role.?); + try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type); + try testing.expectEqual("button", elements[1].role.?); + try testing.expectEqual(InteractivityType.aria, elements[1].interactivity_type); + try testing.expectEqual("button", elements[2].tag_name); + try testing.expectEqual("button", elements[2].role.?); +} + test "browser.interactive: contenteditable" { const elements = try testInteractive("
Edit me
"); defer testing.test_session.closeAllPages(); diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index e3fa42bfa..8e6bd4142 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const Frame = @import("../browser/Frame.zig"); const DOMNode = @import("../browser/webapi/Node.zig"); const Label = @import("../browser/webapi/element/html/Label.zig"); +const interactive = @import("../browser/interactive.zig"); const Node = @import("Node.zig"); @@ -915,13 +916,7 @@ 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(comptime .wrap("role")); - }, + .role_attr = if (dom.is(DOMNode.Element.Html) != null) interactive.explicitRole(dom.as(DOMNode.Element)) else null, }; } @@ -1429,7 +1424,6 @@ fn isIgnore(self: AXNode, frame: *Frame, cache: *DOMNode.Element.VisibilityCache pub fn getRole(self: AXNode) ![]const u8 { if (self.role_attr) |role_value| { - // TODO the role can have multiple comma separated values. return role_value; } @@ -1790,6 +1784,24 @@ test "AXNode: Writer query filters by role" { try testing.expectEqual("Visible", name_val); } +test "AXNode: role attribute token list" { + var page = try testing.pageTest("cdp/accname.html", .{}); + defer page.close(); + const frame = page.frame().?; + + const div = try frame.window._document.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, div.asNode(), + \\

+ ); + + var child = div.asNode().firstChild().?; + try testing.expectEqual("switch", try AXNode.fromNode(child).getRole()); + child = child.nextSibling().?; + try testing.expectEqual("heading", try AXNode.fromNode(child).getRole()); + child = child.nextSibling().?; + try testing.expectEqual("heading", try AXNode.fromNode(child).getRole()); +} + test "AXNode: writer maps password input to textbox" { var registry = Node.Registry.init(testing.allocator); defer registry.deinit();