mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
Merge branch 'main' into agent
This commit is contained in:
@@ -231,6 +231,31 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Computed display:none for a single element (own property, no ancestor walk).
|
||||
/// Also honors the HTML `hidden` attribute, matching the UA stylesheet rule
|
||||
/// `[hidden] { display: none }`.
|
||||
pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true;
|
||||
return self.isElementHidden(el, .{});
|
||||
}
|
||||
|
||||
/// Computed visibility:hidden for an element, considering only the `visibility`
|
||||
/// chain (walks ancestors since `visibility` inherits by default). Ignores
|
||||
/// display:none: an ancestor with display:none means the element isn't
|
||||
/// rendered, but its computed `visibility` still reflects inherited visibility.
|
||||
pub fn hasVisibilityHiddenInherited(self: *StyleManager, el: *Element) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
var current: ?*Element = el;
|
||||
while (current) |elem| {
|
||||
if (self.isElementHidden(elem, .{ .check_display = false, .check_visibility = true })) {
|
||||
return true;
|
||||
}
|
||||
current = elem.parentElement();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a single element (not ancestors) is hidden.
|
||||
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
|
||||
// Track best match per property (value + priority)
|
||||
@@ -246,11 +271,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
var opacity_priority: u64 = 0;
|
||||
|
||||
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
|
||||
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
|
||||
if (property._value.eql(comptime .wrap("none"))) {
|
||||
return true; // Early exit for hiding value
|
||||
if (options.check_display) {
|
||||
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
|
||||
if (property._value.eql(comptime .wrap("none"))) {
|
||||
return true; // Early exit for hiding value
|
||||
}
|
||||
display_none = false;
|
||||
display_priority = INLINE_PRIORITY;
|
||||
}
|
||||
display_none = false;
|
||||
} else {
|
||||
// Pin to INLINE_PRIORITY so rule-matching skips display entirely.
|
||||
display_priority = INLINE_PRIORITY;
|
||||
}
|
||||
|
||||
@@ -685,8 +715,9 @@ const VisibilityRule = struct {
|
||||
};
|
||||
|
||||
const CheckVisibilityOptions = struct {
|
||||
check_opacity: bool = false,
|
||||
check_display: bool = true,
|
||||
check_visibility: bool = false,
|
||||
check_opacity: bool = false,
|
||||
};
|
||||
|
||||
// Inline styles always win over stylesheets - use max u64 as sentinel
|
||||
|
||||
@@ -318,19 +318,18 @@ fn consumeWhiteSpace(self: *Tokenizer, newline: bool) Token {
|
||||
} else {
|
||||
self.advance(1);
|
||||
}
|
||||
self.skipWhitespace();
|
||||
return .{ .white_space = self.sliceFrom(start_position) };
|
||||
}
|
||||
|
||||
fn skipWhitespace(self: *Tokenizer) void {
|
||||
while (!self.isEof()) {
|
||||
const b = self.nextByteUnchecked();
|
||||
switch (b) {
|
||||
' ', '\t' => {
|
||||
self.advance(1);
|
||||
},
|
||||
'\n', '\x0C', '\r' => {
|
||||
self.consumeNewline();
|
||||
},
|
||||
switch (self.nextByteUnchecked()) {
|
||||
' ', '\t' => self.advance(1),
|
||||
'\n', '\r', '\x0C' => self.consumeNewline(),
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
return .{ .white_space = self.sliceFrom(start_position) };
|
||||
}
|
||||
|
||||
fn consumeComment(self: *Tokenizer) []const u8 {
|
||||
@@ -642,13 +641,74 @@ fn consumeNumeric(self: *Tokenizer) Token {
|
||||
} };
|
||||
}
|
||||
|
||||
// Consume a url token per CSS Syntax Level 3 §4.3.6, called after `url(`.
|
||||
// Returns null if the value is quoted so the caller emits <function-token>
|
||||
// ("url") and the string is tokenized by the main loop as its own token.
|
||||
fn consumeUnquotedUrl(self: *Tokenizer) ?Token {
|
||||
// TODO: true url parser
|
||||
if (self.nextByte()) |it| {
|
||||
return self.consumeString(it == '\'');
|
||||
self.skipWhitespace();
|
||||
if (self.isEof()) return .{ .url = "" };
|
||||
|
||||
switch (self.nextByteUnchecked()) {
|
||||
'"', '\'' => return null,
|
||||
else => {},
|
||||
}
|
||||
|
||||
return null;
|
||||
const start_pos = self.position;
|
||||
while (!self.isEof()) {
|
||||
switch (self.nextByteUnchecked()) {
|
||||
')' => {
|
||||
const value = self.sliceFrom(start_pos);
|
||||
self.advance(1);
|
||||
return .{ .url = value };
|
||||
},
|
||||
' ', '\t', '\n', '\r', '\x0C' => {
|
||||
const value_end = self.position;
|
||||
self.skipWhitespace();
|
||||
if (self.isEof()) return .{ .url = self.slice(start_pos, value_end) };
|
||||
if (self.nextByteUnchecked() == ')') {
|
||||
self.advance(1);
|
||||
return .{ .url = self.slice(start_pos, value_end) };
|
||||
}
|
||||
self.consumeBadUrlRemnants();
|
||||
return .{ .bad_url = self.sliceFrom(start_pos) };
|
||||
},
|
||||
'"', '\'', '(' => {
|
||||
self.consumeBadUrlRemnants();
|
||||
return .{ .bad_url = self.sliceFrom(start_pos) };
|
||||
},
|
||||
'\\' => {
|
||||
if (self.hasNewlineAt(1)) {
|
||||
self.consumeBadUrlRemnants();
|
||||
return .{ .bad_url = self.sliceFrom(start_pos) };
|
||||
}
|
||||
self.advance(1);
|
||||
self.consumeEscape();
|
||||
},
|
||||
else => self.consumeChar(),
|
||||
}
|
||||
}
|
||||
return .{ .url = self.sliceFrom(start_pos) };
|
||||
}
|
||||
|
||||
fn consumeBadUrlRemnants(self: *Tokenizer) void {
|
||||
while (!self.isEof()) {
|
||||
switch (self.nextByteUnchecked()) {
|
||||
')' => {
|
||||
self.advance(1);
|
||||
return;
|
||||
},
|
||||
'\n', '\r', '\x0C' => self.consumeNewline(),
|
||||
'\\' => {
|
||||
if (self.hasNewlineAt(1)) {
|
||||
self.advance(1);
|
||||
} else {
|
||||
self.advance(1);
|
||||
self.consumeEscape();
|
||||
}
|
||||
},
|
||||
else => self.consumeChar(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn consumeIdentLike(self: *Tokenizer) Token {
|
||||
@@ -823,3 +883,69 @@ test "smoke" {
|
||||
.close_curly_bracket,
|
||||
});
|
||||
}
|
||||
|
||||
test "url: unquoted" {
|
||||
try expectTokensEqual("url(foo.png)", &.{.{ .url = "foo.png" }});
|
||||
}
|
||||
|
||||
test "url: unquoted with surrounding whitespace" {
|
||||
try expectTokensEqual("url( foo.png )", &.{.{ .url = "foo.png" }});
|
||||
}
|
||||
|
||||
test "url: empty" {
|
||||
try expectTokensEqual("url()", &.{.{ .url = "" }});
|
||||
}
|
||||
|
||||
test "url: quoted double emits function + string" {
|
||||
try expectTokensEqual("url(\"foo.png\")", &.{
|
||||
.{ .function = "url" },
|
||||
.{ .string = "foo.png" },
|
||||
.close_parenthesis,
|
||||
});
|
||||
}
|
||||
|
||||
test "url: quoted single emits function + string" {
|
||||
try expectTokensEqual("url('foo.png')", &.{
|
||||
.{ .function = "url" },
|
||||
.{ .string = "foo.png" },
|
||||
.close_parenthesis,
|
||||
});
|
||||
}
|
||||
|
||||
test "url: unquoted token bounds the next rule" {
|
||||
try expectTokensEqual(".a{background:url(x.png)}.b{color:red}", &.{
|
||||
.{ .delim = '.' },
|
||||
.{ .ident = "a" },
|
||||
.curly_bracket_block,
|
||||
.{ .ident = "background" },
|
||||
.colon,
|
||||
.{ .url = "x.png" },
|
||||
.close_curly_bracket,
|
||||
.{ .delim = '.' },
|
||||
.{ .ident = "b" },
|
||||
.curly_bracket_block,
|
||||
.{ .ident = "color" },
|
||||
.colon,
|
||||
.{ .ident = "red" },
|
||||
.close_curly_bracket,
|
||||
});
|
||||
}
|
||||
|
||||
test "url: bad url with internal whitespace" {
|
||||
try expectTokensEqual("url(foo bar)", &.{.{ .bad_url = "foo bar)" }});
|
||||
}
|
||||
|
||||
test "url: EOF mid-token emits url" {
|
||||
try expectTokensEqual("url(foo", &.{.{ .url = "foo" }});
|
||||
}
|
||||
|
||||
test "url: escape inside unquoted value" {
|
||||
try expectTokensEqual("url(foo\\20 bar)", &.{.{ .url = "foo\\20 bar" }});
|
||||
}
|
||||
|
||||
test "url: bad-url swallows through escape but stops at )" {
|
||||
try expectTokensEqual("url(foo\"\\)bar)x", &.{
|
||||
.{ .bad_url = "foo\"\\)bar)" },
|
||||
.{ .ident = "x" },
|
||||
});
|
||||
}
|
||||
|
||||
41
src/browser/tests/cdp/ax_tree.html
Normal file
41
src/browser/tests/cdp/ax_tree.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AX Tree Fixture</title>
|
||||
<style>
|
||||
.display-none { display: none; }
|
||||
.visibility-hidden { visibility: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Visible</h1>
|
||||
|
||||
<div id="d-none" class="display-none">
|
||||
<p id="p-under-display-none">under-display-none</p>
|
||||
</div>
|
||||
|
||||
<div id="v-hidden" class="visibility-hidden">
|
||||
<p id="p-under-visibility">under-visibility-hidden</p>
|
||||
</div>
|
||||
|
||||
<div id="has-hidden-attr" hidden>
|
||||
<p id="p-under-hidden-attr">under-hidden-attr</p>
|
||||
</div>
|
||||
|
||||
<div id="has-aria-hidden" aria-hidden="true">
|
||||
<p id="p-under-aria-hidden">under-aria-hidden</p>
|
||||
</div>
|
||||
|
||||
<p id="visible-para">visible-para</p>
|
||||
|
||||
<label for="search-input">Search</label>
|
||||
<input type="search" id="search-input">
|
||||
|
||||
<label id="wrap-label">
|
||||
Wrap
|
||||
<input type="text" id="wrapped-input">
|
||||
</label>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -78,6 +78,20 @@ pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 {
|
||||
pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 {
|
||||
const normalized = normalizePropertyName(property_name, &page.buf);
|
||||
const wrapped = String.wrap(normalized);
|
||||
|
||||
// Computed styles must reflect stylesheet rules, not just the element's
|
||||
// inline `style=` attribute. Limited to display/visibility — what aria
|
||||
// tree builders (Playwright ariaSnapshot) consult on every element.
|
||||
if (self._is_computed) {
|
||||
if (self._element) |element| {
|
||||
if (wrapped.eql(comptime .wrap("display"))) {
|
||||
if (page._style_manager.hasDisplayNone(element)) return "none";
|
||||
} else if (wrapped.eql(comptime .wrap("visibility"))) {
|
||||
if (page._style_manager.hasVisibilityHiddenInherited(element)) return "hidden";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prop = self.findProperty(wrapped) orelse {
|
||||
// Only return default values for computed styles
|
||||
if (self._is_computed) {
|
||||
|
||||
@@ -110,6 +110,10 @@ pub fn getForm(self: *Button, page: *Page) ?*Form {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *Button, page: *Page) !js.Array {
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Button);
|
||||
|
||||
@@ -125,6 +129,7 @@ pub const JsApi = struct {
|
||||
pub const form = bridge.accessor(Button.getForm, null, .{});
|
||||
pub const value = bridge.accessor(Button.getValue, Button.setValue, .{});
|
||||
pub const @"type" = bridge.accessor(Button.getType, Button.setType, .{});
|
||||
pub const labels = bridge.accessor(Button.getLabels, null, .{});
|
||||
};
|
||||
|
||||
pub const Build = struct {
|
||||
|
||||
@@ -498,6 +498,13 @@ pub fn setSelectionRange(
|
||||
try self.dispatchSelectionChangeEvent(page);
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *Input, page: *Page) !js.Array {
|
||||
if (self._input_type == .hidden) {
|
||||
return page.js.local.?.newArray(0);
|
||||
}
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub fn getForm(self: *Input, page: *Page) ?*Form {
|
||||
const element = self.asElement();
|
||||
|
||||
@@ -897,6 +904,7 @@ pub const JsApi = struct {
|
||||
pub const size = bridge.accessor(Input.getSize, Input.setSize, .{});
|
||||
pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{});
|
||||
pub const form = bridge.accessor(Input.getForm, null, .{});
|
||||
pub const labels = bridge.accessor(Input.getLabels, null, .{});
|
||||
pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{});
|
||||
pub const placeholder = bridge.accessor(Input.getPlaceholder, Input.setPlaceholder, .{});
|
||||
pub const min = bridge.accessor(Input.getMin, Input.setMin, .{});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const std = @import("std");
|
||||
const js = @import("../../../js/js.zig");
|
||||
const Page = @import("../../../Page.zig");
|
||||
const Node = @import("../../Node.zig");
|
||||
@@ -51,6 +52,83 @@ fn isLabelable(el: *Element) bool {
|
||||
};
|
||||
}
|
||||
|
||||
/// First ancestor `<label>` element of `control`, if any.
|
||||
pub fn findWrappingLabel(control: *Element) ?*Element {
|
||||
var current: ?*Node = control.asNode()._parent;
|
||||
while (current) |n| : (current = n._parent) {
|
||||
const el = n.is(Element) orelse continue;
|
||||
if (el.getTag() == .label) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// First `<label for="id">` descendant of `root`, if any.
|
||||
pub fn findLabelByFor(root: *Node, id: []const u8) ?*Element {
|
||||
var it = TreeWalker.Full.Elements.init(root, .{});
|
||||
while (it.next()) |el| {
|
||||
if (el.getTag() != .label) continue;
|
||||
const for_attr = el.getAttributeSafe(comptime .wrap("for")) orelse continue;
|
||||
if (std.mem.eql(u8, for_attr, id)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Lazy `for`-attribute → `<label>` index. Built in one tree walk on first
|
||||
/// lookup; subsequent lookups are O(1). Use when the same document is queried
|
||||
/// multiple times (e.g. one AX tree walk resolves names for every labellable
|
||||
/// control).
|
||||
pub const LabelByForIndex = struct {
|
||||
map: std.StringHashMapUnmanaged(*Element) = .empty,
|
||||
populated: bool = false,
|
||||
|
||||
pub fn lookup(self: *LabelByForIndex, root: *Node, id: []const u8, allocator: std.mem.Allocator) !?*Element {
|
||||
if (!self.populated) {
|
||||
var it = TreeWalker.Full.Elements.init(root, .{});
|
||||
while (it.next()) |el| {
|
||||
if (el.getTag() != .label) continue;
|
||||
const for_attr = el.getAttributeSafe(comptime .wrap("for")) orelse continue;
|
||||
if (for_attr.len == 0) continue;
|
||||
const gop = try self.map.getOrPut(allocator, for_attr);
|
||||
if (!gop.found_existing) gop.value_ptr.* = el;
|
||||
}
|
||||
self.populated = true;
|
||||
}
|
||||
return self.map.get(id);
|
||||
}
|
||||
};
|
||||
|
||||
/// Collects the `<label>` elements associated with a labellable form control.
|
||||
/// Matches HTMLInputElement.labels (and the equivalent on button/select/etc).
|
||||
/// Includes every `<label for="id">` reference plus the nearest ancestor
|
||||
/// `<label>` wrapping the control.
|
||||
pub fn getControlLabels(control: *Element, page: *Page) !js.Array {
|
||||
const local = page.js.local orelse return error.NotHandled;
|
||||
var arr = local.newArray(0);
|
||||
var idx: u32 = 0;
|
||||
|
||||
if (control.getAttributeSafe(comptime .wrap("id"))) |id_value| {
|
||||
if (id_value.len > 0) {
|
||||
const doc = control.asNode().ownerDocument(page);
|
||||
const search_root: *Node = if (doc) |d| d.asNode() else control.asNode();
|
||||
var it = TreeWalker.Full.Elements.init(search_root, .{});
|
||||
while (it.next()) |el| {
|
||||
if (el.getTag() != .label) continue;
|
||||
const for_attr = el.getAttributeSafe(comptime .wrap("for")) orelse continue;
|
||||
if (!std.mem.eql(u8, for_attr, id_value)) continue;
|
||||
_ = try arr.set(idx, el, .{});
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (findWrappingLabel(control)) |wrap_label| {
|
||||
_ = try arr.set(idx, wrap_label, .{});
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Label);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const js = @import("../../../js/js.zig");
|
||||
const Page = @import("../../../Page.zig");
|
||||
const Node = @import("../../Node.zig");
|
||||
const Element = @import("../../Element.zig");
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
@@ -14,6 +15,10 @@ pub fn asNode(self: *Meter) *Node {
|
||||
return self.asElement().asNode();
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *Meter, page: *Page) !js.Array {
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Meter);
|
||||
|
||||
@@ -22,4 +27,6 @@ pub const JsApi = struct {
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const labels = bridge.accessor(Meter.getLabels, null, .{});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const js = @import("../../../js/js.zig");
|
||||
const Page = @import("../../../Page.zig");
|
||||
const Node = @import("../../Node.zig");
|
||||
const Element = @import("../../Element.zig");
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
@@ -14,6 +15,10 @@ pub fn asNode(self: *Output) *Node {
|
||||
return self.asElement().asNode();
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *Output, page: *Page) !js.Array {
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Output);
|
||||
|
||||
@@ -22,4 +27,6 @@ pub const JsApi = struct {
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const labels = bridge.accessor(Output.getLabels, null, .{});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const js = @import("../../../js/js.zig");
|
||||
const Page = @import("../../../Page.zig");
|
||||
const Node = @import("../../Node.zig");
|
||||
const Element = @import("../../Element.zig");
|
||||
const HtmlElement = @import("../Html.zig");
|
||||
@@ -14,6 +15,10 @@ pub fn asNode(self: *Progress) *Node {
|
||||
return self.asElement().asNode();
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *Progress, page: *Page) !js.Array {
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Progress);
|
||||
|
||||
@@ -22,4 +27,6 @@ pub const JsApi = struct {
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const labels = bridge.accessor(Progress.getLabels, null, .{});
|
||||
};
|
||||
|
||||
@@ -238,6 +238,10 @@ pub fn getForm(self: *Select, page: *Page) ?*Form {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *Select, page: *Page) !js.Array {
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Select);
|
||||
|
||||
@@ -258,6 +262,7 @@ pub const JsApi = struct {
|
||||
pub const form = bridge.accessor(Select.getForm, null, .{});
|
||||
pub const size = bridge.accessor(Select.getSize, Select.setSize, .{});
|
||||
pub const length = bridge.accessor(Select.getLength, null, .{});
|
||||
pub const labels = bridge.accessor(Select.getLabels, null, .{});
|
||||
};
|
||||
|
||||
pub const Build = struct {
|
||||
|
||||
@@ -280,6 +280,10 @@ pub fn getForm(self: *TextArea, page: *Page) ?*Form {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getLabels(self: *TextArea, page: *Page) !js.Array {
|
||||
return @import("Label.zig").getControlLabels(self.asElement(), page);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(TextArea);
|
||||
|
||||
@@ -289,6 +293,7 @@ pub const JsApi = struct {
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
|
||||
pub const labels = bridge.accessor(TextArea.getLabels, null, .{});
|
||||
pub const onselectionchange = bridge.accessor(TextArea.getOnSelectionChange, TextArea.setOnSelectionChange, .{});
|
||||
pub const value = bridge.accessor(TextArea.getValue, TextArea.setValue, .{});
|
||||
pub const defaultValue = bridge.accessor(TextArea.getDefaultValue, TextArea.setDefaultValue, .{});
|
||||
|
||||
@@ -22,6 +22,7 @@ const jsonStringify = std.json.Stringify;
|
||||
const log = @import("../log.zig");
|
||||
const Page = @import("../browser/Page.zig");
|
||||
const DOMNode = @import("../browser/webapi/Node.zig");
|
||||
const Label = @import("../browser/webapi/element/html/Label.zig");
|
||||
|
||||
const Node = @import("Node.zig");
|
||||
|
||||
@@ -35,6 +36,8 @@ pub const Writer = struct {
|
||||
root: *const Node,
|
||||
registry: *Node.Registry,
|
||||
page: *Page,
|
||||
visibility_cache: *DOMNode.Element.VisibilityCache,
|
||||
label_index: *Label.LabelByForIndex,
|
||||
|
||||
pub const Opts = struct {};
|
||||
|
||||
@@ -51,13 +54,13 @@ pub const Writer = struct {
|
||||
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);
|
||||
if (try self.writeNode(node.id, root, false, w)) {
|
||||
try self.writeNodeChildren(root, false, w);
|
||||
}
|
||||
return w.endArray();
|
||||
}
|
||||
|
||||
fn writeNodeChildren(self: *const Writer, parent: AXNode, w: anytype) !void {
|
||||
fn writeNodeChildren(self: *const Writer, parent: AXNode, in_aria_hidden: bool, w: anytype) !void {
|
||||
// Add ListMarker for listitem elements
|
||||
if (parent.dom.is(DOMNode.Element)) |parent_el| {
|
||||
if (parent_el.getTag() == .li) {
|
||||
@@ -65,6 +68,11 @@ pub const Writer = struct {
|
||||
}
|
||||
}
|
||||
|
||||
const child_in_aria_hidden = in_aria_hidden or blk: {
|
||||
const parent_el = parent.dom.is(DOMNode.Element) orelse break :blk false;
|
||||
break :blk hasAriaHiddenTrue(parent_el);
|
||||
};
|
||||
|
||||
var it = parent.dom.childrenIterator();
|
||||
const ignore_text = ignoreText(parent.dom);
|
||||
while (it.next()) |dom_node| {
|
||||
@@ -77,14 +85,22 @@ pub const Writer = struct {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
.element => {},
|
||||
.element => {
|
||||
// Prune hidden subtrees entirely (display:none,
|
||||
// visibility:hidden, aria-hidden, hidden, inert). Matches
|
||||
// Chromium: these elements aren't exposed to the AX tree.
|
||||
const child_el = dom_node.as(DOMNode.Element);
|
||||
if (child_in_aria_hidden or isHidden(child_el, self.page, self.visibility_cache)) {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
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);
|
||||
if (try self.writeNode(node.id, axn, child_in_aria_hidden, w)) {
|
||||
try self.writeNodeChildren(axn, child_in_aria_hidden, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +228,12 @@ pub const Writer = struct {
|
||||
// w.objectField("value");
|
||||
// self.writeAXValue(.{ .type = .computedString, .value = value.value }, w);
|
||||
.contents => "contents",
|
||||
.label_element, .label_wrap => "TODO", // TODO
|
||||
.label_element => blk: {
|
||||
try w.objectField("attribute");
|
||||
try w.write("for");
|
||||
break :blk "relatedElement";
|
||||
},
|
||||
.label_wrap => "relatedElement",
|
||||
};
|
||||
try w.objectField("type");
|
||||
try w.write(source_type);
|
||||
@@ -438,7 +459,7 @@ pub const Writer = struct {
|
||||
}
|
||||
|
||||
// write a node. returns true if children must be written.
|
||||
fn writeNode(self: *const Writer, id: u32, axn: AXNode, w: anytype) !bool {
|
||||
fn writeNode(self: *const Writer, id: u32, axn: AXNode, in_aria_hidden: bool, w: anytype) !bool {
|
||||
// ignore empty texts
|
||||
try w.beginObject();
|
||||
|
||||
@@ -451,7 +472,7 @@ pub const Writer = struct {
|
||||
try w.objectField("role");
|
||||
try self.writeAXValue(.{ .role = try axn.getRole() }, w);
|
||||
|
||||
const ignore = axn.isIgnore(self.page);
|
||||
const ignore = axn.isIgnore(self.page, self.visibility_cache, in_aria_hidden);
|
||||
try w.objectField("ignored");
|
||||
try w.write(ignore);
|
||||
|
||||
@@ -473,7 +494,7 @@ pub const Writer = struct {
|
||||
try w.objectField("type");
|
||||
try w.write(@tagName(.computedString));
|
||||
try w.objectField("value");
|
||||
const source = try axn.writeName(w, self.page);
|
||||
const source = try axn.writeName(w, self.page, self.label_index);
|
||||
if (source) |s| {
|
||||
try self.writeAXSource(s, w);
|
||||
}
|
||||
@@ -502,6 +523,11 @@ pub const Writer = struct {
|
||||
const write_children = axn.ignoreChildren() == false;
|
||||
const skip_text = ignoreText(axn.dom);
|
||||
|
||||
const child_in_aria_hidden = in_aria_hidden or blk: {
|
||||
const self_el = n.is(DOMNode.Element) orelse break :blk false;
|
||||
break :blk hasAriaHiddenTrue(self_el);
|
||||
};
|
||||
|
||||
try w.objectField("childIds");
|
||||
try w.beginArray();
|
||||
if (write_children) {
|
||||
@@ -513,6 +539,14 @@ pub const Writer = struct {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip hidden element children so childIds matches the
|
||||
// subtree-pruning done in writeNodeChildren.
|
||||
if (child.is(DOMNode.Element)) |child_el| {
|
||||
if (child_in_aria_hidden or isHidden(child_el, self.page, self.visibility_cache)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const child_node = try registry.register(child);
|
||||
try w.write(child_node.id);
|
||||
}
|
||||
@@ -776,7 +810,7 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons
|
||||
|
||||
const w: TextCaptureWriter = .{ .aw = &aw, .writer = &aw.writer };
|
||||
|
||||
const source = try self.writeName(w, page);
|
||||
const source = try self.writeName(w, page, null);
|
||||
if (source != null) {
|
||||
// Remove literal quotes inserted by writeString.
|
||||
var raw_text = std.mem.trim(u8, aw.written(), "\"");
|
||||
@@ -787,7 +821,7 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons
|
||||
return null;
|
||||
}
|
||||
|
||||
fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource {
|
||||
fn writeName(axnode: AXNode, w: anytype, page: *Page, label_index: ?*Label.LabelByForIndex) !?AXSource {
|
||||
const node = axnode.dom;
|
||||
|
||||
return switch (node._type) {
|
||||
@@ -839,6 +873,12 @@ fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource {
|
||||
return .aria_label;
|
||||
}
|
||||
|
||||
if (isLabellableTag(el.getTag())) {
|
||||
if (try writeLabelName(node, el, page, label_index, w)) |source| {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
|
||||
try w.write(alt);
|
||||
return .alt;
|
||||
@@ -948,7 +988,58 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, page: *Pa
|
||||
}
|
||||
}
|
||||
|
||||
fn isHidden(elt: *DOMNode.Element) bool {
|
||||
fn hasAriaHiddenTrue(elt: *DOMNode.Element) bool {
|
||||
if (elt.getAttributeSafe(comptime .wrap("aria-hidden"))) |value| {
|
||||
return std.mem.eql(u8, value, "true");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isLabellableTag(tag: DOMNode.Element.Tag) bool {
|
||||
return switch (tag) {
|
||||
.button, .meter, .output, .progress, .select, .textarea, .input => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn writeLabelName(
|
||||
node: *DOMNode,
|
||||
el: *DOMNode.Element,
|
||||
page: *Page,
|
||||
label_index: ?*Label.LabelByForIndex,
|
||||
w: anytype,
|
||||
) !?AXSource {
|
||||
if (el.getAttributeSafe(comptime .wrap("id"))) |id_value| {
|
||||
if (id_value.len > 0) {
|
||||
if (node.ownerDocument(page)) |doc| {
|
||||
const match: ?*DOMNode.Element = if (label_index) |idx|
|
||||
try idx.lookup(doc.asNode(), id_value, page.call_arena)
|
||||
else
|
||||
Label.findLabelByFor(doc.asNode(), id_value);
|
||||
if (match) |label_el| {
|
||||
if (try writeLabelInnerText(label_el, page, w)) return .label_element;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Label.findWrappingLabel(el)) |wrap_label| {
|
||||
if (try writeLabelInnerText(wrap_label, page, w)) return .label_wrap;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn writeLabelInnerText(label_el: *DOMNode.Element, page: *Page, w: anytype) !bool {
|
||||
var buf: std.Io.Writer.Allocating = .init(page.call_arena);
|
||||
try label_el.getInnerText(&buf.writer);
|
||||
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
|
||||
if (text.len == 0) return false;
|
||||
try writeString(text, w);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn isHidden(elt: *DOMNode.Element, page: *Page, cache: *DOMNode.Element.VisibilityCache) bool {
|
||||
if (elt.getAttributeSafe(comptime .wrap("aria-hidden"))) |value| {
|
||||
if (std.mem.eql(u8, value, "true")) {
|
||||
return true;
|
||||
@@ -963,8 +1054,11 @@ fn isHidden(elt: *DOMNode.Element) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO Check if aria-hidden ancestor exists
|
||||
// TODO Check CSS visibility (if you have access to computed styles)
|
||||
// CSS display:none and visibility:hidden (both inherited from ancestors via
|
||||
// style computation). Matches Chromium's AX tree which prunes both.
|
||||
if (page._style_manager.isHidden(elt, cache, .{ .check_visibility = true })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1011,12 +1105,12 @@ fn ignoreChildren(self: AXNode) bool {
|
||||
};
|
||||
}
|
||||
|
||||
fn isIgnore(self: AXNode, page: *Page) bool {
|
||||
fn isIgnore(self: AXNode, page: *Page, cache: *DOMNode.Element.VisibilityCache, in_aria_hidden: bool) bool {
|
||||
const node = self.dom;
|
||||
const role_attr = self.role_attr;
|
||||
|
||||
// Don't ignore non-Element node: CData, Document...
|
||||
const elt = node.is(DOMNode.Element) orelse return false;
|
||||
const elt = node.is(DOMNode.Element) orelse return in_aria_hidden;
|
||||
// Ignore non-HTML elements: svg...
|
||||
if (elt._type != .html) {
|
||||
return true;
|
||||
@@ -1053,7 +1147,11 @@ fn isIgnore(self: AXNode, page: *Page) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if (isHidden(elt)) {
|
||||
if (in_aria_hidden) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isHidden(elt, page, cache)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1064,11 +1162,11 @@ fn isIgnore(self: AXNode, page: *Page) bool {
|
||||
const has_aria_labelledby = elt.hasAttributeSafe(.wrap("aria-labelledby"));
|
||||
|
||||
if (!has_role and !has_aria_label and !has_aria_labelledby) {
|
||||
// Check if it has any non-ignored children
|
||||
// Check if it has any non-ignored children.
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
const axn = AXNode.fromNode(child);
|
||||
if (!axn.isIgnore(page)) {
|
||||
if (!axn.isIgnore(page, cache, in_aria_hidden)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1184,10 +1282,16 @@ test "AXNode: writer" {
|
||||
var doc = page.window._document;
|
||||
|
||||
const node = try registry.register(doc.asNode());
|
||||
// Cache inserts go through page.call_arena, so give the caches the same
|
||||
// allocator and let the page arena clean them up.
|
||||
var visibility_cache: DOMNode.Element.VisibilityCache = .empty;
|
||||
var label_index: Label.LabelByForIndex = .{};
|
||||
const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{
|
||||
.root = node,
|
||||
.registry = ®istry,
|
||||
.page = page,
|
||||
.visibility_cache = &visibility_cache,
|
||||
.label_index = &label_index,
|
||||
}, .{});
|
||||
defer testing.allocator.free(json);
|
||||
|
||||
@@ -1241,3 +1345,86 @@ test "AXNode: writer" {
|
||||
}
|
||||
return error.HeadingNodeNotFound;
|
||||
}
|
||||
|
||||
test "AXNode: writer prunes hidden and resolves labels" {
|
||||
var registry = Node.Registry.init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var page = try testing.pageTest("cdp/ax_tree.html", .{});
|
||||
defer page._session.removePage();
|
||||
var doc = page.window._document;
|
||||
|
||||
const node = try registry.register(doc.asNode());
|
||||
var visibility_cache: DOMNode.Element.VisibilityCache = .empty;
|
||||
var label_index: Label.LabelByForIndex = .{};
|
||||
const json = try std.json.Stringify.valueAlloc(testing.allocator, Writer{
|
||||
.root = node,
|
||||
.registry = ®istry,
|
||||
.page = page,
|
||||
.visibility_cache = &visibility_cache,
|
||||
.label_index = &label_index,
|
||||
}, .{});
|
||||
defer testing.allocator.free(json);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
|
||||
defer parsed.deinit();
|
||||
const nodes = parsed.value.array.items;
|
||||
|
||||
// No hidden-subtree text should have leaked into the tree.
|
||||
const hidden_texts = [_][]const u8{
|
||||
"under-display-none",
|
||||
"under-visibility-hidden",
|
||||
"under-hidden-attr",
|
||||
"under-aria-hidden",
|
||||
};
|
||||
for (nodes) |node_val| {
|
||||
const obj = node_val.object;
|
||||
const name_obj = obj.get("name") orelse continue;
|
||||
const value = name_obj.object.get("value") orelse continue;
|
||||
if (value != .string) continue;
|
||||
for (hidden_texts) |bad| {
|
||||
try testing.expect(std.mem.indexOf(u8, value.string, bad) == null);
|
||||
}
|
||||
}
|
||||
|
||||
// The visible paragraph's text leaks into the tree as a StaticText child.
|
||||
var found_visible = false;
|
||||
for (nodes) |node_val| {
|
||||
const obj = node_val.object;
|
||||
const name_obj = obj.get("name") orelse continue;
|
||||
const value = name_obj.object.get("value") orelse continue;
|
||||
if (value == .string and std.mem.indexOf(u8, value.string, "visible-para") != null) {
|
||||
found_visible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
try testing.expect(found_visible);
|
||||
|
||||
// The search input gets its name from <label for=search-input>.
|
||||
var search_named = false;
|
||||
for (nodes) |node_val| {
|
||||
const obj = node_val.object;
|
||||
const role_obj = obj.get("role") orelse continue;
|
||||
const role_val = role_obj.object.get("value") orelse continue;
|
||||
if (!std.mem.eql(u8, role_val.string, "searchbox")) continue;
|
||||
const name_val = obj.get("name").?.object.get("value").?;
|
||||
if (name_val == .string and std.mem.indexOf(u8, name_val.string, "Search") != null) {
|
||||
search_named = true;
|
||||
}
|
||||
}
|
||||
try testing.expect(search_named);
|
||||
|
||||
// The wrapped input gets its name from its ancestor <label>.
|
||||
var wrapped_named = false;
|
||||
for (nodes) |node_val| {
|
||||
const obj = node_val.object;
|
||||
const role_obj = obj.get("role") orelse continue;
|
||||
const role_val = role_obj.object.get("value") orelse continue;
|
||||
if (!std.mem.eql(u8, role_val.string, "textbox")) continue;
|
||||
const name_val = obj.get("name").?.object.get("value").?;
|
||||
if (name_val == .string and std.mem.indexOf(u8, name_val.string, "Wrap") != null) {
|
||||
wrapped_named = true;
|
||||
}
|
||||
}
|
||||
try testing.expect(wrapped_named);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ const Browser = @import("../browser/Browser.zig");
|
||||
const Session = @import("../browser/Session.zig");
|
||||
const Page = @import("../browser/Page.zig");
|
||||
const Mime = @import("../browser/Mime.zig");
|
||||
const Element = @import("../browser/webapi/Element.zig");
|
||||
const Label = @import("../browser/webapi/element/html/Label.zig");
|
||||
|
||||
const InterceptState = @import("domains/fetch.zig").InterceptState;
|
||||
|
||||
@@ -527,10 +529,16 @@ pub const BrowserContext = struct {
|
||||
pub fn axnodeWriter(self: *BrowserContext, root: *const Node, opts: AXNode.Writer.Opts) !AXNode.Writer {
|
||||
const page = self.session.currentPage() orelse return error.PageNotLoaded;
|
||||
_ = opts;
|
||||
const cache = try page.call_arena.create(Element.VisibilityCache);
|
||||
cache.* = .empty;
|
||||
const label_index = try page.call_arena.create(Label.LabelByForIndex);
|
||||
label_index.* = .{};
|
||||
return .{
|
||||
.page = page,
|
||||
.root = root,
|
||||
.registry = &self.node_registry,
|
||||
.visibility_cache = cache,
|
||||
.label_index = label_index,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user