diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 404a11ed..82e50bbc 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -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 diff --git a/src/browser/css/Tokenizer.zig b/src/browser/css/Tokenizer.zig index 2e183ef3..4348745b 100644 --- a/src/browser/css/Tokenizer.zig +++ b/src/browser/css/Tokenizer.zig @@ -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 +// ("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" }, + }); +} diff --git a/src/browser/tests/cdp/ax_tree.html b/src/browser/tests/cdp/ax_tree.html new file mode 100644 index 00000000..79df111e --- /dev/null +++ b/src/browser/tests/cdp/ax_tree.html @@ -0,0 +1,41 @@ + + + + AX Tree Fixture + + + +
+

Visible

+ + + +
+

under-visibility-hidden

+
+ + + + + +

visible-para

+ + + + + +
+ + diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 8644a75d..63eb9db3 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -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) { diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig index 89bb3575..85549d29 100644 --- a/src/browser/webapi/element/html/Button.zig +++ b/src/browser/webapi/element/html/Button.zig @@ -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 { diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 2c8eb2f2..082cd56a 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -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, .{}); diff --git a/src/browser/webapi/element/html/Label.zig b/src/browser/webapi/element/html/Label.zig index 1069f2c6..5513f34d 100644 --- a/src/browser/webapi/element/html/Label.zig +++ b/src/browser/webapi/element/html/Label.zig @@ -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 `