diff --git a/src/browser/tests/element/pseudo_classes.html b/src/browser/tests/element/pseudo_classes.html new file mode 100644 index 00000000..8114cae0 --- /dev/null +++ b/src/browser/tests/element/pseudo_classes.html @@ -0,0 +1,82 @@ + + + +
+

First

+ + + Content +
+ + + + + + + + + + + + + + diff --git a/src/browser/tests/element/selector_invalid.html b/src/browser/tests/element/selector_invalid.html new file mode 100644 index 00000000..826ce7b7 --- /dev/null +++ b/src/browser/tests/element/selector_invalid.html @@ -0,0 +1,62 @@ + + + +
+

Test

+
+ + + + + + + + + + diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 481a3697..8ce759a8 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -53,9 +53,8 @@ pub fn collect( _ = tw.next(); } - const boundary = if (result.exclude_root) result.root else null; while (tw.next()) |node| { - if (matches(node, result.selector, boundary)) { + if (matches(node, result.selector, page)) { try nodes.put(allocator, node, {}); } } @@ -71,9 +70,8 @@ pub fn initOne(root: *Node, selector: Selector.Selector, page: *Page) ?*Node { if (result.exclude_root) { _ = tw.next(); } - const boundary = if (result.exclude_root) result.root else null; while (tw.next()) |node| { - if (matches(node, optimized_selector, boundary)) { + if (matches(node, optimized_selector, page)) { return node; } } @@ -175,7 +173,7 @@ fn optimizeSelector(root: *Node, selector: *const Selector.Selector, page: *Page .segments = selector.segments[0 .. seg_idx + 1], }; - if (!matches(id_node, prefix_selector, null)) { + if (!matches(id_node, prefix_selector, page)) { return null; } @@ -250,23 +248,23 @@ fn findIdSelector(selector: *const Selector.Selector) ?IdAnchor { return null; } -pub fn matches(node: *Node, selector: Selector.Selector, root: ?*Node) bool { +pub fn matches(node: *Node, selector: Selector.Selector, page: *Page) bool { const el = node.is(Node.Element) orelse return false; if (selector.segments.len == 0) { - return matchesCompound(el, selector.first); + return matchesCompound(el, selector.first, page); } const last_segment = selector.segments[selector.segments.len - 1]; - if (!matchesCompound(el, last_segment.compound)) { + if (!matchesCompound(el, last_segment.compound, page)) { return false; } - return matchSegments(node, selector, selector.segments.len - 1, root); + return matchSegments(node, selector, selector.segments.len - 1, null, page); } // Match segments backward, with support for backtracking on subsequent_sibling -fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node) bool { +fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, root: ?*Node, page: *Page) bool { const segment = selector.segments[segment_index]; const target_compound = if (segment_index == 0) selector.first @@ -274,9 +272,9 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, selector.segments[segment_index - 1].compound; const matched: ?*Node = switch (segment.combinator) { - .descendant => matchDescendant(node, target_compound, root), - .child => matchChild(node, target_compound, root), - .next_sibling => matchNextSibling(node, target_compound), + .descendant => matchDescendant(node, target_compound, root, page), + .child => matchChild(node, target_compound, root, page), + .next_sibling => matchNextSibling(node, target_compound, page), .subsequent_sibling => { // For subsequent_sibling, try all matching siblings with backtracking var sibling = node.previousSibling(); @@ -286,13 +284,13 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, continue; }; - if (matchesCompound(sibling_el, target_compound)) { + if (matchesCompound(sibling_el, target_compound, page)) { // If we're at the first segment, we found a match if (segment_index == 0) { return true; } // Try to match remaining segments from this sibling - if (matchSegments(s, selector, segment_index - 1, root)) { + if (matchSegments(s, selector, segment_index - 1, root, page)) { return true; } // This sibling didn't work, try the next one @@ -309,7 +307,7 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, if (segment_index == 0) { return true; } - return matchSegments(current, selector, segment_index - 1, root); + return matchSegments(current, selector, segment_index - 1, root, page); } // subsequent_sibling already handled its recursion above @@ -317,12 +315,12 @@ fn matchSegments(node: *Node, selector: Selector.Selector, segment_index: usize, } // Find an ancestor that matches the compound (any distance up the tree) -fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { +fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Page) ?*Node { var current = node._parent; while (current) |ancestor| { if (ancestor.is(Node.Element)) |ancestor_el| { - if (matchesCompound(ancestor_el, compound)) { + if (matchesCompound(ancestor_el, compound, page)) { return ancestor; } } @@ -341,7 +339,7 @@ fn matchDescendant(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Nod } // Find the direct parent if it matches the compound -fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { +fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node, page: *Page) ?*Node { const parent = node._parent orelse return null; // Don't match beyond the root boundary @@ -354,7 +352,7 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { const parent_el = parent.is(Node.Element) orelse return null; - if (matchesCompound(parent_el, compound)) { + if (matchesCompound(parent_el, compound, page)) { return parent; } @@ -362,7 +360,7 @@ fn matchChild(node: *Node, compound: Selector.Compound, root: ?*Node) ?*Node { } // Find the immediately preceding sibling if it matches the compound -fn matchNextSibling(node: *Node, compound: Selector.Compound) ?*Node { +fn matchNextSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Node { var sibling = node.previousSibling(); // For next_sibling (+), we need the immediately preceding element sibling @@ -374,7 +372,7 @@ fn matchNextSibling(node: *Node, compound: Selector.Compound) ?*Node { }; // Found an element - check if it matches - if (matchesCompound(sibling_el, compound)) { + if (matchesCompound(sibling_el, compound, page)) { return s; } // we found an element, it wasn't a match, we're done @@ -385,7 +383,7 @@ fn matchNextSibling(node: *Node, compound: Selector.Compound) ?*Node { } // Find any preceding sibling that matches the compound -fn matchSubsequentSibling(node: *Node, compound: Selector.Compound) ?*Node { +fn matchSubsequentSibling(node: *Node, compound: Selector.Compound, page: *Page) ?*Node { var sibling = node.previousSibling(); // For subsequent_sibling (~), check all preceding element siblings @@ -396,7 +394,7 @@ fn matchSubsequentSibling(node: *Node, compound: Selector.Compound) ?*Node { continue; }; - if (matchesCompound(sibling_el, compound)) { + if (matchesCompound(sibling_el, compound, page)) { return s; } @@ -406,17 +404,17 @@ fn matchSubsequentSibling(node: *Node, compound: Selector.Compound) ?*Node { return null; } -fn matchesCompound(el: *Node.Element, compound: Selector.Compound) bool { +fn matchesCompound(el: *Node.Element, compound: Selector.Compound, page: *Page) bool { // For compound selectors, ALL parts must match for (compound.parts) |part| { - if (!matchesPart(el, part)) { + if (!matchesPart(el, part, page)) { return false; } } return true; } -fn matchesPart(el: *Node.Element, part: Part) bool { +fn matchesPart(el: *Node.Element, part: Part, page: *Page) bool { switch (part) { .id => |id| { const element_id = el.getAttributeSafe("id") orelse return false; @@ -437,7 +435,7 @@ fn matchesPart(el: *Node.Element, part: Part) bool { return std.mem.eql(u8, element_tag, tag_name); }, .universal => return true, - .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo), + .pseudo_class => |pseudo| return matchesPseudoClass(el, pseudo, page), .attribute => |attr| return matchesAttribute(el, attr), } } @@ -497,13 +495,79 @@ fn attributeContainsWord(value: []const u8, word: []const u8) bool { return false; } -fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass) bool { +fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, page: *Page) bool { + const node = el.asNode(); switch (pseudo) { + // State pseudo-classes .modal => return false, .checked => { const input = el.is(Node.Element.Html.Input) orelse return false; return input.getChecked(); }, + .disabled => { + return el.getAttributeSafe("disabled") != null; + }, + .enabled => { + return el.getAttributeSafe("disabled") == null; + }, + .indeterminate => return false, + + // Form validation + .valid => return false, + .invalid => return false, + .required => { + return el.getAttributeSafe("required") != null; + }, + .optional => { + return el.getAttributeSafe("required") == null; + }, + .in_range => return false, + .out_of_range => return false, + .placeholder_shown => return false, + .read_only => { + return el.getAttributeSafe("readonly") != null; + }, + .read_write => { + return el.getAttributeSafe("readonly") == null; + }, + .default => return false, + + // User interaction + .hover => return false, + .active => return false, + .focus => { + const active = page.document._active_element orelse return false; + return active == el; + }, + .focus_within => { + const active = page.document._active_element orelse return false; + return node.contains(active.asNode()); + }, + .focus_visible => return false, + + // Link states + .link => return false, + .visited => return false, + .any_link => { + if (el.getTag() != .anchor) return false; + return el.getAttributeSafe("href") != null; + }, + .target => { + const element_id = el.getAttributeSafe("id") orelse return false; + const location = page.document._location orelse return false; + const hash = location.getHash(); + if (hash.len <= 1) return false; + return std.mem.eql(u8, element_id, hash[1..]); + }, + + // Tree structural + .root => { + const parent = node.parentNode() orelse return false; + return parent._type == .document; + }, + .empty => { + return node.firstChild() == null; + }, .first_child => return isFirstChild(el), .last_child => return isLastChild(el), .only_child => return isFirstChild(el) and isLastChild(el), @@ -514,19 +578,87 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass) bool { .nth_last_child => |pattern| return matchesNthLastChild(el, pattern), .nth_of_type => |pattern| return matchesNthOfType(el, pattern), .nth_last_of_type => |pattern| return matchesNthLastOfType(el, pattern), + + // Custom elements + .defined => { + const tag_name = el.getTagNameLower(); + if (std.mem.indexOfScalar(u8, tag_name, '-') == null) return true; + const registry = &page.window._custom_elements; + return registry.get(tag_name) != null; + }, + + // Functional + .lang => return false, .not => |selectors| { - // CSS Level 4: :not() matches if NONE of the selectors match - // Each selector in the list is evaluated independently for (selectors) |selector| { - if (matches(el.asNode(), selector, null)) { + if (matches(node, selector, page)) { return false; } } return true; }, + .is => |selectors| { + for (selectors) |selector| { + if (matches(node, selector, page)) { + return true; + } + } + return false; + }, + .where => |selectors| { + for (selectors) |selector| { + if (matches(node, selector, page)) { + return true; + } + } + return false; + }, + .has => |selectors| { + for (selectors) |selector| { + var child = node.firstChild(); + while (child) |c| { + const child_el = c.is(Node.Element) orelse { + child = c.nextSibling(); + continue; + }; + + if (matches(child_el.asNode(), selector, page)) { + return true; + } + + if (matchesHasDescendant(child_el, selector, page)) { + return true; + } + + child = c.nextSibling(); + } + } + return false; + }, } } +fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, page: *Page) bool { + var child = el.asNode().firstChild(); + while (child) |c| { + const child_el = c.is(Node.Element) orelse { + child = c.nextSibling(); + continue; + }; + + if (matches(child_el.asNode(), selector, page)) { + return true; + } + + if (matchesHasDescendant(child_el, selector, page)) { + return true; + } + + child = c.nextSibling(); + } + return false; +} + fn isFirstChild(el: *Node.Element) bool { const node = el.asNode(); var sibling = node.previousSibling(); diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 49813291..41075ca5 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -395,21 +395,144 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla return .{ .not = selectors.items }; } + if (std.mem.eql(u8, name, "is")) { + var selectors: std.ArrayList(Selector.Selector) = .empty; + _ = self.skipSpaces(); + while (true) { + if (self.peek() == ')') break; + if (self.peek() == 0) return error.InvalidPseudoClass; + + const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + try selectors.append(arena, selector); + + _ = self.skipSpaces(); + if (self.peek() == ',') { + self.input = self.input[1..]; + _ = self.skipSpaces(); + continue; + } + break; + } + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + if (selectors.items.len == 0) return error.InvalidPseudoClass; + return .{ .is = selectors.items }; + } + + if (std.mem.eql(u8, name, "where")) { + var selectors: std.ArrayList(Selector.Selector) = .empty; + + _ = self.skipSpaces(); + while (true) { + if (self.peek() == ')') break; + if (self.peek() == 0) return error.InvalidPseudoClass; + + const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + try selectors.append(arena, selector); + + _ = self.skipSpaces(); + if (self.peek() == ',') { + self.input = self.input[1..]; + _ = self.skipSpaces(); + continue; + } + break; + } + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + if (selectors.items.len == 0) return error.InvalidPseudoClass; + return .{ .where = selectors.items }; + } + + if (std.mem.eql(u8, name, "has")) { + var selectors: std.ArrayList(Selector.Selector) = .empty; + + _ = self.skipSpaces(); + while (true) { + if (self.peek() == ')') break; + if (self.peek() == 0) return error.InvalidPseudoClass; + + const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + try selectors.append(arena, selector); + + _ = self.skipSpaces(); + if (self.peek() == ',') { + self.input = self.input[1..]; + _ = self.skipSpaces(); + continue; + } + break; + } + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + if (selectors.items.len == 0) return error.InvalidPseudoClass; + return .{ .has = selectors.items }; + } + + if (std.mem.eql(u8, name, "lang")) { + _ = self.skipSpaces(); + const lang_start = self.input; + var lang_i: usize = 0; + while (lang_i < lang_start.len and lang_start[lang_i] != ')') : (lang_i += 1) {} + if (lang_i == 0 or self.peek() == 0) return error.InvalidPseudoClass; + + const lang = try arena.dupe(u8, std.mem.trim(u8, lang_start[0..lang_i], &std.ascii.whitespace)); + self.input = lang_start[lang_i..]; + + if (self.peek() != ')') return error.InvalidPseudoClass; + self.input = self.input[1..]; + + return .{ .lang = lang }; + } return error.UnknownPseudoClass; } switch (name.len) { + 4 => { + if (fastEql(name, "root")) return .root; + if (fastEql(name, "link")) return .link; + }, 5 => { if (fastEql(name, "modal")) return .modal; + if (fastEql(name, "hover")) return .hover; + if (fastEql(name, "focus")) return .focus; + if (fastEql(name, "empty")) return .empty; + if (fastEql(name, "valid")) return .valid; + }, + 6 => { + if (fastEql(name, "active")) return .active; + if (fastEql(name, "target")) return .target; }, 7 => { if (fastEql(name, "checked")) return .checked; + if (fastEql(name, "visited")) return .visited; + if (fastEql(name, "enabled")) return .enabled; + if (fastEql(name, "invalid")) return .invalid; + if (fastEql(name, "default")) return .default; + if (fastEql(name, "defined")) return .defined; + }, + 8 => { + if (fastEql(name, "disabled")) return .disabled; + if (fastEql(name, "required")) return .required; + if (fastEql(name, "optional")) return .optional; + if (fastEql(name, "any-link")) return .any_link; + if (fastEql(name, "in-range")) return .in_range; + }, + 9 => { + if (fastEql(name, "read-only")) return .read_only; }, 10 => { if (fastEql(name, "only-child")) return .only_child; if (fastEql(name, "last-child")) return .last_child; + if (fastEql(name, "read-write")) return .read_write; }, 11 => { if (fastEql(name, "first-child")) return .first_child; @@ -417,9 +540,16 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla 12 => { if (fastEql(name, "only-of-type")) return .only_of_type; if (fastEql(name, "last-of-type")) return .last_of_type; + if (fastEql(name, "focus-within")) return .focus_within; + if (fastEql(name, "out-of-range")) return .out_of_range; }, 13 => { if (fastEql(name, "first-of-type")) return .first_of_type; + if (fastEql(name, "focus-visible")) return .focus_visible; + if (fastEql(name, "indeterminate")) return .indeterminate; + }, + 17 => { + if (fastEql(name, "placeholder-shown")) return .placeholder_shown; }, else => {}, } diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 15579b65..5360cd3f 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -82,7 +82,7 @@ pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { const selectors = try Parser.parseList(arena, input, page); for (selectors) |selector| { - if (List.matches(el.asNode(), selector, null)) { + if (List.matches(el.asNode(), selector, page)) { return true; } } @@ -131,8 +131,41 @@ pub const AttributeMatcher = union(enum) { }; pub const PseudoClass = union(enum) { + // State pseudo-classes modal, checked, + disabled, + enabled, + indeterminate, + + // Form validation + valid, + invalid, + required, + optional, + in_range, + out_of_range, + placeholder_shown, + read_only, + read_write, + default, + + // User interaction + hover, + active, + focus, + focus_within, + focus_visible, + + // Link states + link, + visited, + any_link, + target, + + // Tree structural + root, + empty, first_child, last_child, only_child, @@ -143,7 +176,16 @@ pub const PseudoClass = union(enum) { nth_last_child: NthPattern, nth_of_type: NthPattern, nth_last_of_type: NthPattern, + + // Custom elements + defined, + + // Functional + lang: []const u8, not: []const Selector, // :not() - CSS Level 4: supports full selectors and comma-separated lists + is: []const Selector, // :is() - matches any of the selectors + where: []const Selector, // :where() - like :is() but with zero specificity + has: []const Selector, // :has() - element containing descendants matching selector }; pub const NthPattern = struct {