More pseudo-seletors

:invalid, :valid and :indeterminate implementation

Fix Element.closest with :scope pseudo-selector.
This commit is contained in:
Karl Seguin
2026-02-19 22:28:14 +08:00
parent c3555bfcab
commit 21cd17873f
4 changed files with 140 additions and 4 deletions

View File

@@ -376,3 +376,67 @@
} }
</script> </script>
<form id="form-validity-test">
<input id="vi-required-empty" type="text" required>
<input id="vi-optional" type="text">
<input id="vi-hidden-required" type="hidden" required>
<fieldset id="vi-fieldset">
<input id="vi-nested-required" type="text" required>
<select id="vi-select-required" required>
<option value="">Pick one</option>
<option value="a">A</option>
</select>
</fieldset>
</form>
<input id="vi-checkbox" type="checkbox">
<script id=invalidPseudo>
{
// Inputs with required + empty value are :invalid
testing.expectEqual(true, document.getElementById('vi-required-empty').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-required-empty').matches(':valid'));
// Inputs without required are :valid
testing.expectEqual(false, document.getElementById('vi-optional').matches(':invalid'));
testing.expectEqual(true, document.getElementById('vi-optional').matches(':valid'));
// hidden inputs are not candidates for constraint validation
testing.expectEqual(false, document.getElementById('vi-hidden-required').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-hidden-required').matches(':valid'));
// select with required and empty selected value is :invalid
testing.expectEqual(true, document.getElementById('vi-select-required').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-select-required').matches(':valid'));
// fieldset containing invalid controls is :invalid
testing.expectEqual(true, document.getElementById('vi-fieldset').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-fieldset').matches(':valid'));
// form containing invalid controls is :invalid
testing.expectEqual(true, document.getElementById('form-validity-test').matches(':invalid'));
testing.expectEqual(false, document.getElementById('form-validity-test').matches(':valid'));
}
</script>
<script id=validAfterValueSet>
{
// After setting a value, a required input becomes :valid
const input = document.getElementById('vi-required-empty');
input.value = 'hello';
testing.expectEqual(false, input.matches(':invalid'));
testing.expectEqual(true, input.matches(':valid'));
input.value = '';
}
</script>
<script id=indeterminatePseudo>
{
const cb = document.getElementById('vi-checkbox');
testing.expectEqual(false, cb.matches(':indeterminate'));
cb.indeterminate = true;
testing.expectEqual(true, cb.matches(':indeterminate'));
cb.indeterminate = false;
testing.expectEqual(false, cb.matches(':indeterminate'));
}
</script>

View File

@@ -952,7 +952,7 @@ pub fn closest(self: *Element, selector: []const u8, page: *Page) !?*Element {
var current: ?*Element = self; var current: ?*Element = self;
while (current) |el| { while (current) |el| {
if (try el.matches(selector, page)) { if (try Selector.matchesWithScope(el, selector, self, page)) {
return el; return el;
} }

View File

@@ -531,11 +531,45 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N
.enabled => { .enabled => {
return el.getAttributeSafe(comptime .wrap("disabled")) == null; return el.getAttributeSafe(comptime .wrap("disabled")) == null;
}, },
.indeterminate => return false, .indeterminate => {
const input = el.is(Node.Element.Html.Input) orelse return false;
return switch (input._input_type) {
.checkbox => input.getIndeterminate(),
else => false,
};
},
// Form validation // Form validation
.valid => return false, .valid => {
.invalid => return false, if (el.is(Node.Element.Html.Input)) |input| {
return switch (input._input_type) {
.hidden, .submit, .reset, .button => false,
else => !input.getRequired() or input.getValue().len > 0,
};
}
if (el.is(Node.Element.Html.Select)) |select| {
return !select.getRequired() or select.getValue(page).len > 0;
}
if (el.is(Node.Element.Html.Form) != null or el.is(Node.Element.Html.FieldSet) != null) {
return !hasInvalidDescendant(node, page);
}
return false;
},
.invalid => {
if (el.is(Node.Element.Html.Input)) |input| {
return switch (input._input_type) {
.hidden, .submit, .reset, .button => false,
else => input.getRequired() and input.getValue().len == 0,
};
}
if (el.is(Node.Element.Html.Select)) |select| {
return select.getRequired() and select.getValue(page).len == 0;
}
if (el.is(Node.Element.Html.Form) != null or el.is(Node.Element.Html.FieldSet) != null) {
return hasInvalidDescendant(node, page);
}
return false;
},
.required => { .required => {
return el.getAttributeSafe(comptime .wrap("required")) != null; return el.getAttributeSafe(comptime .wrap("required")) != null;
}, },
@@ -684,6 +718,26 @@ fn matchesHasDescendant(el: *Node.Element, selector: Selector.Selector, scope: *
return false; return false;
} }
fn hasInvalidDescendant(parent: *Node, page: *Page) bool {
var child = parent.firstChild();
while (child) |c| {
if (c.is(Node.Element)) |child_el| {
if (child_el.is(Node.Element.Html.Input)) |input| {
const invalid = switch (input._input_type) {
.hidden, .submit, .reset, .button => false,
else => input.getRequired() and input.getValue().len == 0,
};
if (invalid) return true;
} else if (child_el.is(Node.Element.Html.Select)) |select| {
if (select.getRequired() and select.getValue(page).len == 0) return true;
}
}
if (hasInvalidDescendant(c, page)) return true;
child = c.nextSibling();
}
return false;
}
fn isFirstChild(el: *Node.Element) bool { fn isFirstChild(el: *Node.Element) bool {
const node = el.asNode(); const node = el.asNode();
var sibling = node.previousSibling(); var sibling = node.previousSibling();

View File

@@ -92,6 +92,24 @@ pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool {
return false; return false;
} }
// Like matches, but allows the caller to specify a scope node distinct from el.
// Used by closest() so that :scope always refers to the original context element.
pub fn matchesWithScope(el: *Node.Element, input: []const u8, scope: *Node.Element, page: *Page) !bool {
if (input.len == 0) {
return error.SyntaxError;
}
const arena = page.call_arena;
const selectors = try Parser.parseList(arena, input, page);
for (selectors) |selector| {
if (List.matches(el.asNode(), selector, scope.asNode(), page)) {
return true;
}
}
return false;
}
pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool { pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool {
if (class_name.len == 0 or class_name.len > class_attr.len) return false; if (class_name.len == 0 or class_name.len > class_attr.len) return false;