diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig
index 13c354431..e31f8ac67 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();