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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 {