diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 9046f84e..95c2dd6b 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -849,6 +849,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/element/html/Video.zig"),
@import("../webapi/element/html/UL.zig"),
@import("../webapi/element/html/Unknown.zig"),
+ @import("../webapi/element/html/ValidityState.zig"),
@import("../webapi/element/Svg.zig"),
@import("../webapi/element/svg/Generic.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
diff --git a/src/browser/tests/element/html/button.html b/src/browser/tests/element/html/button.html
index 76e5be8b..7c274cd8 100644
--- a/src/browser/tests/element/html/button.html
+++ b/src/browser/tests/element/html/button.html
@@ -126,3 +126,19 @@
testing.expectFalse(button.outerHTML.includes('required'))
}
+
+
+
+
diff --git a/src/browser/tests/element/html/form-validity.html b/src/browser/tests/element/html/form-validity.html
new file mode 100644
index 00000000..becce81d
--- /dev/null
+++ b/src/browser/tests/element/html/form-validity.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/html/input-validity.html b/src/browser/tests/element/html/input-validity.html
new file mode 100644
index 00000000..0747d3ab
--- /dev/null
+++ b/src/browser/tests/element/html/input-validity.html
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/html/select-validity.html b/src/browser/tests/element/html/select-validity.html
new file mode 100644
index 00000000..80b0f9f6
--- /dev/null
+++ b/src/browser/tests/element/html/select-validity.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/html/textarea-validity.html b/src/browser/tests/element/html/textarea-validity.html
new file mode 100644
index 00000000..7f2a5e0e
--- /dev/null
+++ b/src/browser/tests/element/html/textarea-validity.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig
index efcec872..181be133 100644
--- a/src/browser/webapi/element/html/Button.zig
+++ b/src/browser/webapi/element/html/Button.zig
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
+
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -23,10 +25,14 @@ const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig");
+const Event = @import("../../Event.zig");
+const ValidityState = @import("ValidityState.zig");
const Button = @This();
_proto: *HtmlElement,
+_custom_validity: ?[]const u8 = null,
+_validity: ?*ValidityState = null,
pub fn asElement(self: *Button) *Element {
return self._proto._proto;
@@ -114,6 +120,55 @@ pub fn getLabels(self: *Button, frame: *Frame) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), frame);
}
+// Constraint validation
+// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api
+//
+// Per spec, only buttons with type="submit" participate in constraint validation,
+// and the only flag they can raise is customError. type="reset" and type="button"
+// are barred from constraint validation entirely.
+
+pub fn getWillValidate(self: *const Button) bool {
+ if (self.getDisabled()) return false;
+ return std.mem.eql(u8, self.getType(), "submit");
+}
+
+pub fn getValidity(self: *Button, frame: *Frame) !*ValidityState {
+ if (self._validity) |v| return v;
+ const v = try frame._factory.create(ValidityState{ ._owner = self.asElement() });
+ self._validity = v;
+ return v;
+}
+
+pub fn getValidationMessage(self: *const Button) []const u8 {
+ if (!self.getWillValidate()) return "";
+ return self._custom_validity orelse "";
+}
+
+pub fn checkValidity(self: *Button, frame: *Frame) !bool {
+ if (!self.getWillValidate()) return true;
+ if (self._custom_validity == null) return true;
+
+ const event = try Event.initTrusted(comptime .wrap("invalid"), .{ .cancelable = true }, frame._page);
+ try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
+ return false;
+}
+
+pub fn reportValidity(self: *Button, frame: *Frame) !bool {
+ return self.checkValidity(frame);
+}
+
+pub fn setCustomValidity(self: *Button, message: []const u8, frame: *Frame) !void {
+ if (message.len == 0) {
+ self._custom_validity = null;
+ } else {
+ self._custom_validity = try frame.dupeString(message);
+ }
+}
+
+pub fn hasCustomValidity(self: *const Button) bool {
+ return self._custom_validity != null;
+}
+
pub const JsApi = struct {
pub const bridge = js.Bridge(Button);
@@ -130,6 +185,12 @@ pub const JsApi = struct {
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 willValidate = bridge.accessor(Button.getWillValidate, null, .{});
+ pub const validity = bridge.accessor(Button.getValidity, null, .{});
+ pub const validationMessage = bridge.accessor(Button.getValidationMessage, null, .{});
+ pub const checkValidity = bridge.function(Button.checkValidity, .{});
+ pub const reportValidity = bridge.function(Button.reportValidity, .{});
+ pub const setCustomValidity = bridge.function(Button.setCustomValidity, .{});
};
pub const Build = struct {
diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig
index 2bebf658..7443cc0a 100644
--- a/src/browser/webapi/element/html/Form.zig
+++ b/src/browser/webapi/element/html/Form.zig
@@ -169,6 +169,33 @@ fn getFormOwner(element: *Element, frame: *Frame) ?*Form {
return null;
}
+/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-form-checkvalidity
+/// Returns true if every submittable element in the form is valid. Fires an
+/// `invalid` event on each failing element.
+pub fn checkValidity(self: *Form, frame: *Frame) !bool {
+ var iter = self.iterator(frame);
+ var all_valid = true;
+ while (iter.next()) |element| {
+ const ok = try checkElementValidity(element, frame);
+ if (!ok) all_valid = false;
+ }
+ return all_valid;
+}
+
+/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-form-reportvalidity
+/// Headless: identical to checkValidity (no UI to draw).
+pub fn reportValidity(self: *Form, frame: *Frame) !bool {
+ return self.checkValidity(frame);
+}
+
+fn checkElementValidity(element: *Element, frame: *Frame) !bool {
+ if (element.is(Input)) |input| return input.checkValidity(frame);
+ if (element.is(Select)) |select| return select.checkValidity(frame);
+ if (element.is(TextArea)) |textarea| return textarea.checkValidity(frame);
+ if (element.is(Button)) |button| return button.checkValidity(frame);
+ return true;
+}
+
pub const JsApi = struct {
pub const bridge = js.Bridge(Form);
pub const Meta = struct {
@@ -186,9 +213,12 @@ pub const JsApi = struct {
pub const length = bridge.accessor(Form.getLength, null, .{});
pub const submit = bridge.function(Form.submit, .{});
pub const requestSubmit = bridge.function(Form.requestSubmit, .{ .dom_exception = true });
+ pub const checkValidity = bridge.function(Form.checkValidity, .{});
+ pub const reportValidity = bridge.function(Form.reportValidity, .{});
};
const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Form" {
try testing.htmlRunner("element/html/form.html", .{});
+ try testing.htmlRunner("element/html/form-validity.html", .{});
}
diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig
index d38710f2..a2fda9dc 100644
--- a/src/browser/webapi/element/html/Input.zig
+++ b/src/browser/webapi/element/html/Input.zig
@@ -29,6 +29,7 @@ const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
+const ValidityState = @import("ValidityState.zig");
const String = lp.String;
@@ -82,6 +83,8 @@ _checked: bool = false,
_checked_dirty: bool = false,
_input_type: Type = .text,
_indeterminate: bool = false,
+_custom_validity: ?[]const u8 = null,
+_validity: ?*ValidityState = null,
_selection_start: u32 = 0,
_selection_end: u32 = 0,
@@ -164,7 +167,7 @@ pub fn getChecked(self: *const Input) bool {
pub fn setChecked(self: *Input, checked: bool, frame: *Frame) !void {
// If checking a radio button, uncheck others in the group first
if (checked and self._input_type == .radio) {
- try self.uncheckRadioGroup(frame);
+ self.uncheckRadioGroup(frame);
}
// This should _not_ call setAttribute. It updates the current state only
self._checked = checked;
@@ -211,6 +214,261 @@ fn hasDatalistAncestor(self: *const Input) bool {
return false;
}
+// Constraint validation API
+// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api
+
+pub fn getValidity(self: *Input, frame: *Frame) !*ValidityState {
+ if (self._validity) |v| return v;
+ const v = try frame._factory.create(ValidityState{ ._owner = self.asElement() });
+ self._validity = v;
+ return v;
+}
+
+pub fn getValidationMessage(self: *const Input, frame: *Frame) []const u8 {
+ if (!self.getWillValidate()) return "";
+ if (self._custom_validity) |msg| return msg;
+ if (self.suffersValueMissing(frame)) return "Please fill out this field.";
+ if (self.suffersTypeMismatch()) return switch (self._input_type) {
+ .email => "Please enter an email address.",
+ .url => "Please enter a URL.",
+ else => "Please enter a valid value.",
+ };
+ if (self.suffersPatternMismatch()) return "Please match the requested format.";
+ if (self.suffersTooLong()) return "Please shorten this text.";
+ if (self.suffersTooShort()) return "Please lengthen this text.";
+ if (self.suffersRangeUnderflow()) return "Value is too small.";
+ if (self.suffersRangeOverflow()) return "Value is too large.";
+ return "";
+}
+
+pub fn checkValidity(self: *Input, frame: *Frame) !bool {
+ if (!self.getWillValidate()) return true;
+ const v = ValidityState{ ._owner = self.asElement() };
+ if (v.getValid(frame)) return true;
+
+ const event = try Event.initTrusted(comptime .wrap("invalid"), .{ .cancelable = true }, frame._page);
+ try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
+ return false;
+}
+
+pub fn reportValidity(self: *Input, frame: *Frame) !bool {
+ // Headless: no UI to draw, so reportValidity matches checkValidity exactly.
+ return self.checkValidity(frame);
+}
+
+pub fn setCustomValidity(self: *Input, message: []const u8, frame: *Frame) !void {
+ if (message.len == 0) {
+ self._custom_validity = null;
+ } else {
+ self._custom_validity = try frame.dupeString(message);
+ }
+}
+
+pub fn hasCustomValidity(self: *const Input) bool {
+ return self._custom_validity != null;
+}
+
+pub fn suffersValueMissing(self: *const Input, frame: *Frame) bool {
+ if (!self.getWillValidate()) return false;
+ if (!self.getRequired()) return false;
+ return switch (self._input_type) {
+ .checkbox => !self._checked,
+ .radio => !self.radioGroupHasChecked(frame),
+ // TODO: file inputs aren't supported yet (#2175); treat as always-empty when required.
+ .file => true,
+ .text, .password, .email, .url, .tel, .search, .number, .date, .time, .@"datetime-local", .month, .week, .color => blk: {
+ const v = self._value orelse self._default_value orelse "";
+ break :blk v.len == 0;
+ },
+ // submit/reset/button/hidden/image/range never participate in valueMissing.
+ .submit, .reset, .button, .hidden, .image, .range => false,
+ };
+}
+
+pub fn suffersTypeMismatch(self: *const Input) bool {
+ const value = self._value orelse return false;
+ if (value.len == 0) return false;
+ return switch (self._input_type) {
+ .email => !isValidEmail(value),
+ .url => !isValidAbsoluteURL(value),
+ else => false,
+ };
+}
+
+pub fn suffersPatternMismatch(self: *const Input) bool {
+ _ = self;
+ // Pattern matching requires evaluating a JS RegExp anchored with ^(?: ... )$.
+ // Not yet implemented from Zig; returning false leaves well-formed inputs valid.
+ // TODO: route through the V8 RegExp constructor on the owner Frame.
+ return false;
+}
+
+pub fn suffersTooLong(self: *const Input) bool {
+ // Per spec, only the dirty value flag triggers tooLong / tooShort. We treat
+ // the presence of an explicit _value (vs. attribute-derived _default_value)
+ // as an approximation of dirty.
+ const value = self._value orelse return false;
+ const max = self.getMaxLength();
+ if (max < 0) return false;
+ return codepointCount(value) > @as(usize, @intCast(max));
+}
+
+pub fn suffersTooShort(self: *const Input) bool {
+ const value = self._value orelse return false;
+ if (value.len == 0) return false;
+ const min = self.getMinLength();
+ if (min < 0) return false;
+ return codepointCount(value) < @as(usize, @intCast(min));
+}
+
+pub fn suffersRangeUnderflow(self: *const Input) bool {
+ return numericRangeBreach(self, .underflow);
+}
+
+pub fn suffersRangeOverflow(self: *const Input) bool {
+ return numericRangeBreach(self, .overflow);
+}
+
+fn numericRangeBreach(self: *const Input, comptime kind: enum { underflow, overflow }) bool {
+ // Only number/range use floating-point comparison. date/time/month/week/
+ // datetime-local also have range constraints per spec, but their values
+ // require type-specific conversion (date → days since epoch, time → ms
+ // since midnight, etc.) before comparison — not yet implemented.
+ // TODO: implement range checks for date/time/month/week/datetime-local.
+ switch (self._input_type) {
+ .number, .range => {},
+ else => return false,
+ }
+
+ const value = self._value orelse return false;
+ if (value.len == 0) return false;
+ if (!isValidFloatingPoint(value)) return false;
+ const v = std.fmt.parseFloat(f64, value) catch return false;
+
+ const attr = switch (kind) {
+ .underflow => self.getMin(),
+ .overflow => self.getMax(),
+ };
+ if (attr.len == 0) return false;
+ if (!isValidFloatingPoint(attr)) return false;
+ const bound = std.fmt.parseFloat(f64, attr) catch return false;
+
+ return switch (kind) {
+ .underflow => v < bound,
+ .overflow => v > bound,
+ };
+}
+
+fn radioGroupHasChecked(self: *const Input, frame: *Frame) bool {
+ if (self._checked) return true;
+ var iter = self.radioGroupIterator() orelse return false;
+ const my_form = self.getForm(frame);
+ while (iter.next()) |other| {
+ if (other == self) continue;
+ if (!other._checked) continue;
+ if (sameFormOwner(my_form, other, frame)) return true;
+ }
+ return false;
+}
+
+const TreeWalker = @import("../../TreeWalker.zig");
+
+const RadioGroupIterator = struct {
+ walker: TreeWalker.Full,
+ name: []const u8,
+
+ fn next(self: *@This()) ?*Input {
+ while (self.walker.next()) |node| {
+ const other_element = node.is(Element) orelse continue;
+ const other_input = other_element.is(Input) orelse continue;
+ if (other_input._input_type != .radio) continue;
+ const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue;
+ if (!std.mem.eql(u8, self.name, other_name)) continue;
+ return other_input;
+ }
+ return null;
+ }
+};
+
+/// Walk same-named radio inputs in `self`'s tree. Returns null if the input
+/// has no `name` (or empty `name`) — such radios don't participate in a
+/// group. The `TreeWalker` only inspects nodes; the `@constCast` is safe
+/// because nothing in the iteration mutates the tree.
+fn radioGroupIterator(self: *const Input) ?RadioGroupIterator {
+ const element = self.asConstElement();
+ const name = element.getAttributeSafe(comptime .wrap("name")) orelse return null;
+ if (name.len == 0) return null;
+ const root = @constCast(element.asConstNode()).getRootNode(null);
+ return .{
+ .walker = TreeWalker.Full.init(root, .{}),
+ .name = name,
+ };
+}
+
+fn sameFormOwner(self_form: ?*const Form, other: *const Input, frame: *Frame) bool {
+ const other_form = other.getForm(frame);
+
+ // Check if same form context
+ if (self_form == null and other_form == null) {
+ return true;
+ }
+
+ if (self_form) |mf| {
+ if (other_form) |of| {
+ if (mf == of) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/// Liberal email validation: ASCII local part + "@" + dotted ASCII host. Mirrors
+/// the WHATWG "valid e-mail address" production loosely — sufficient for most
+/// constraint-validation tests; HTML browsers themselves are permissive here.
+fn isValidEmail(value: []const u8) bool {
+ const at = std.mem.indexOfScalar(u8, value, '@') orelse return false;
+ if (at == 0 or at == value.len - 1) return false;
+ const local = value[0..at];
+ const host = value[at + 1 ..];
+ for (local) |c| if (!isEmailLocalChar(c)) return false;
+ if (std.mem.indexOfScalar(u8, host, '.') == null) return false;
+ for (host) |c| if (!isEmailHostChar(c)) return false;
+ if (host[0] == '.' or host[host.len - 1] == '.') return false;
+ return true;
+}
+
+fn isEmailLocalChar(c: u8) bool {
+ if (std.ascii.isAlphanumeric(c)) return true;
+ return switch (c) {
+ '.', '!', '#', '$', '%', '&', '\'', '*', '+', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~', '-' => true,
+ else => false,
+ };
+}
+
+fn isEmailHostChar(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or c == '-' or c == '.';
+}
+
+/// Absolute URL check per the WHATWG URL parser: must include a scheme followed
+/// by "://" and a non-empty authority. Relative URLs are typeMismatches per spec.
+fn isValidAbsoluteURL(value: []const u8) bool {
+ const scheme_end = std.mem.indexOfScalar(u8, value, ':') orelse return false;
+ if (scheme_end == 0) return false;
+ if (!std.ascii.isAlphabetic(value[0])) return false;
+ for (value[1..scheme_end]) |c| {
+ if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '-' and c != '.') return false;
+ }
+ const rest = value[scheme_end + 1 ..];
+ if (!std.mem.startsWith(u8, rest, "//")) return false;
+ return rest.len > 2;
+}
+
+fn codepointCount(value: []const u8) usize {
+ return std.unicode.utf8CountCodepoints(value) catch value.len;
+}
+
pub fn getDisabled(self: *const Input) bool {
// TODO: Also check for disabled fieldset ancestors
// (but not if we're inside a