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 of that fieldset) @@ -263,6 +521,20 @@ pub fn setMaxLength(self: *Input, max_length: i32, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("maxlength"), .wrap(value), frame); } +pub fn getMinLength(self: *const Input) i32 { + const attr = self.asConstElement().getAttributeSafe(comptime .wrap("minlength")) orelse return -1; + return std.fmt.parseInt(i32, attr, 10) catch -1; +} + +pub fn setMinLength(self: *Input, min_length: i32, frame: *Frame) !void { + if (min_length < 0) { + return error.IndexSizeError; + } + var buf: [32]u8 = undefined; + const value = std.fmt.bufPrint(&buf, "{d}", .{min_length}) catch unreachable; + try self.asElement().setAttributeSafe(comptime .wrap("minlength"), .wrap(value), frame); +} + pub fn getSize(self: *const Input) i32 { const attr = self.asConstElement().getAttributeSafe(comptime .wrap("size")) orelse return 20; const parsed = std.fmt.parseInt(i32, attr, 10) catch return 20; @@ -507,8 +779,8 @@ pub fn getLabels(self: *Input, frame: *Frame) !js.Array { return @import("Label.zig").getControlLabels(self.asElement(), frame); } -pub fn getForm(self: *Input, frame: *Frame) ?*Form { - const element = self.asElement(); +pub fn getForm(self: *const Input, frame: *Frame) ?*Form { + const element = self.asConstElement(); // If form attribute exists, ONLY use that (even if it references nothing) if (element.getAttributeSafe(comptime .wrap("form"))) |form_id| { @@ -520,7 +792,7 @@ pub fn getForm(self: *Input, frame: *Frame) ?*Form { } // No form attribute - traverse ancestors looking for a
- var node = element.asNode()._parent; + var node = element.asConstNode()._parent; while (node) |n| { if (n.is(Element.Html.Form)) |form| { return form; @@ -918,55 +1190,13 @@ fn maxWeeksInYear(year: u32) u32 { return 52; } -fn uncheckRadioGroup(self: *Input, frame: *Frame) !void { - const element = self.asElement(); - - const name = element.getAttributeSafe(comptime .wrap("name")) orelse return; - if (name.len == 0) { - return; - } - +fn uncheckRadioGroup(self: *Input, frame: *Frame) void { + var iter = self.radioGroupIterator() orelse return; const my_form = self.getForm(frame); - - // Walk from the root of the tree containing this element - // This handles both document-attached and orphaned elements - const root = element.asNode().getRootNode(null); - - const TreeWalker = @import("../../TreeWalker.zig"); - var walker = TreeWalker.Full.init(root, .{}); - - while (walker.next()) |node| { - const other_element = node.is(Element) orelse continue; - const other_input = other_element.is(Input) orelse continue; - - // Skip self - if (other_input == self) { - continue; - } - - if (other_input._input_type != .radio) { - continue; - } - - const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue; - if (!std.mem.eql(u8, name, other_name)) { - continue; - } - - // Check if same form context - const other_form = other_input.getForm(frame); - if (my_form == null and other_form == null) { - other_input._checked = false; - continue; - } - - if (my_form) |mf| { - if (other_form) |of| { - if (mf == of) { - other_input._checked = false; - } - } - } + while (iter.next()) |other| { + if (other == self) continue; + if (!sameFormOwner(my_form, other, frame)) continue; + other._checked = false; } } @@ -1000,6 +1230,7 @@ pub const JsApi = struct { pub const readOnly = bridge.accessor(Input.getReadonly, Input.setReadonly, .{}); pub const alt = bridge.accessor(Input.getAlt, Input.setAlt, .{}); pub const maxLength = bridge.accessor(Input.getMaxLength, Input.setMaxLength, .{ .dom_exception = true }); + pub const minLength = bridge.accessor(Input.getMinLength, Input.setMinLength, .{ .dom_exception = true }); 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, .{}); @@ -1012,6 +1243,11 @@ pub const JsApi = struct { pub const multiple = bridge.accessor(Input.getMultiple, Input.setMultiple, .{}); pub const autocomplete = bridge.accessor(Input.getAutocomplete, Input.setAutocomplete, .{}); pub const willValidate = bridge.accessor(Input.getWillValidate, null, .{}); + pub const validity = bridge.accessor(Input.getValidity, null, .{}); + pub const validationMessage = bridge.accessor(Input.getValidationMessage, null, .{}); + pub const checkValidity = bridge.function(Input.checkValidity, .{}); + pub const reportValidity = bridge.function(Input.reportValidity, .{}); + pub const setCustomValidity = bridge.function(Input.setCustomValidity, .{}); pub const select = bridge.function(Input.select, .{}); pub const selectionStart = bridge.accessor(Input.getSelectionStart, Input.setSelectionStart, .{}); @@ -1045,7 +1281,7 @@ pub const Build = struct { // If this is a checked radio button, uncheck others in its group if (self._checked and self._input_type == .radio) { - try self.uncheckRadioGroup(frame); + self.uncheckRadioGroup(frame); } } @@ -1072,7 +1308,7 @@ pub const Build = struct { self._checked = true; // If setting a radio button to checked, uncheck others in the group if (self._input_type == .radio) { - try self.uncheckRadioGroup(frame); + self.uncheckRadioGroup(frame); } } }, @@ -1117,6 +1353,7 @@ test "WebApi: HTML.Input" { try testing.htmlRunner("element/html/input_image_submit.html", .{}); try testing.htmlRunner("element/html/input_radio.html", .{}); try testing.htmlRunner("element/html/input-attrs.html", .{}); + try testing.htmlRunner("element/html/input-validity.html", .{}); } test "isValidFloatingPoint" { diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index a840c533..385de1b8 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -24,12 +24,16 @@ const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const collections = @import("../../collections.zig"); const Form = @import("Form.zig"); +const Event = @import("../../Event.zig"); +const ValidityState = @import("ValidityState.zig"); pub const Option = @import("Option.zig"); const Select = @This(); _proto: *HtmlElement, _selected_index_set: bool = false, +_custom_validity: ?[]const u8 = null, +_validity: ?*ValidityState = null, pub fn asElement(self: *Select) *Element { return self._proto._proto; @@ -50,21 +54,14 @@ pub fn asConstNode(self: *const Select) *const Node { // option in tree order. Returns null if there is no candidate (zero options // or every option disabled), in which case the select has no selectedness // and contributes no entry to a FormData set. -pub fn effectiveOption(self: *Select) ?*Option { +pub fn effectiveOption(self: *const Select) ?*Option { var first_option: ?*Option = null; - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { + var maybe_child = self.asConstNode().firstChild(); + while (maybe_child) |child| : (maybe_child = child.nextSibling()) { const option = child.is(Option) orelse continue; - if (option.getDisabled()) { - continue; - } - - if (option.getSelected()) { - return option; - } - if (first_option == null) { - first_option = option; - } + if (option.getDisabled()) continue; + if (option.getSelected()) return option; + if (first_option == null) first_option = option; } return first_option; } @@ -250,6 +247,67 @@ pub fn getLabels(self: *Select, 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 + +pub fn getWillValidate(self: *const Select) bool { + return !self.getDisabled(); +} + +pub fn getValidity(self: *Select, 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 Select) []const u8 { + if (!self.getWillValidate()) return ""; + if (self._custom_validity) |msg| return msg; + if (self.suffersValueMissing()) return "Please select an item in the list."; + return ""; +} + +pub fn checkValidity(self: *Select, 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: *Select, frame: *Frame) !bool { + return self.checkValidity(frame); +} + +pub fn setCustomValidity(self: *Select, 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 Select) bool { + return self._custom_validity != null; +} + +pub fn suffersValueMissing(self: *const Select) bool { + if (!self.getWillValidate()) return false; + if (!self.getRequired()) return false; + // No selectable option ⇒ no value to submit. + const opt = self.effectiveOption() orelse return true; + // The selected option's `value` attribute (`opt._value`) is what matters + // for the missing-value check; an explicit `value=""` is the canonical + // placeholder pattern. When `value=` is absent the option's text would + // be submitted, so it is not "missing" in the constraint-validation + // sense. + if (opt._value) |v| return v.len == 0; + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Select); @@ -271,6 +329,12 @@ pub const JsApi = struct { pub const size = bridge.accessor(Select.getSize, Select.setSize, .{}); pub const length = bridge.accessor(Select.getLength, null, .{}); pub const labels = bridge.accessor(Select.getLabels, null, .{}); + pub const willValidate = bridge.accessor(Select.getWillValidate, null, .{}); + pub const validity = bridge.accessor(Select.getValidity, null, .{}); + pub const validationMessage = bridge.accessor(Select.getValidationMessage, null, .{}); + pub const checkValidity = bridge.function(Select.checkValidity, .{}); + pub const reportValidity = bridge.function(Select.reportValidity, .{}); + pub const setCustomValidity = bridge.function(Select.setCustomValidity, .{}); }; pub const Build = struct { @@ -283,4 +347,5 @@ const std = @import("std"); const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Select" { try testing.htmlRunner("element/html/select.html", .{}); + try testing.htmlRunner("element/html/select-validity.html", .{}); } diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index 89910e92..d75a56cb 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -27,6 +27,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 TextArea = @This(); @@ -38,6 +39,8 @@ _selection_end: u32 = 0, _selection_direction: Selection.SelectionDirection = .none, _on_selectionchange: ?js.Function.Global = null, +_custom_validity: ?[]const u8 = null, +_validity: ?*ValidityState = null, pub fn getOnSelectionChange(self: *TextArea) ?js.Function.Global { return self._on_selectionchange; @@ -139,6 +142,34 @@ pub fn setRequired(self: *TextArea, required: bool, frame: *Frame) !void { } } +pub fn getMaxLength(self: *const TextArea) i32 { + const attr = self.asConstElement().getAttributeSafe(comptime .wrap("maxlength")) orelse return -1; + return std.fmt.parseInt(i32, attr, 10) catch -1; +} + +pub fn setMaxLength(self: *TextArea, max_length: i32, frame: *Frame) !void { + if (max_length < 0) { + return error.IndexSizeError; + } + var buf: [32]u8 = undefined; + const value = std.fmt.bufPrint(&buf, "{d}", .{max_length}) catch unreachable; + try self.asElement().setAttributeSafe(comptime .wrap("maxlength"), .wrap(value), frame); +} + +pub fn getMinLength(self: *const TextArea) i32 { + const attr = self.asConstElement().getAttributeSafe(comptime .wrap("minlength")) orelse return -1; + return std.fmt.parseInt(i32, attr, 10) catch -1; +} + +pub fn setMinLength(self: *TextArea, min_length: i32, frame: *Frame) !void { + if (min_length < 0) { + return error.IndexSizeError; + } + var buf: [32]u8 = undefined; + const value = std.fmt.bufPrint(&buf, "{d}", .{min_length}) catch unreachable; + try self.asElement().setAttributeSafe(comptime .wrap("minlength"), .wrap(value), frame); +} + pub fn select(self: *TextArea, frame: *Frame) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; try self.setSelectionRange(0, len, null, frame); @@ -284,6 +315,78 @@ pub fn getLabels(self: *TextArea, 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 + +pub fn getWillValidate(self: *const TextArea) bool { + return !self.getDisabled(); +} + +pub fn getValidity(self: *TextArea, 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 TextArea) []const u8 { + if (!self.getWillValidate()) return ""; + if (self._custom_validity) |msg| return msg; + if (self.suffersValueMissing()) return "Please fill out this field."; + if (self.suffersTooLong()) return "Please shorten this text."; + if (self.suffersTooShort()) return "Please lengthen this text."; + return ""; +} + +pub fn checkValidity(self: *TextArea, 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: *TextArea, frame: *Frame) !bool { + return self.checkValidity(frame); +} + +pub fn setCustomValidity(self: *TextArea, 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 TextArea) bool { + return self._custom_validity != null; +} + +pub fn suffersValueMissing(self: *const TextArea) bool { + if (!self.getWillValidate()) return false; + if (!self.getRequired()) return false; + return self.getValue().len == 0; +} + +pub fn suffersTooLong(self: *const TextArea) bool { + const value = self._value orelse return false; + const max = self.getMaxLength(); + if (max < 0) return false; + const count = std.unicode.utf8CountCodepoints(value) catch value.len; + return count > @as(usize, @intCast(max)); +} + +pub fn suffersTooShort(self: *const TextArea) bool { + const value = self._value orelse return false; + if (value.len == 0) return false; + const min = self.getMinLength(); + if (min < 0) return false; + const count = std.unicode.utf8CountCodepoints(value) catch value.len; + return count < @as(usize, @intCast(min)); +} + pub const JsApi = struct { pub const bridge = js.Bridge(TextArea); @@ -294,12 +397,20 @@ pub const JsApi = struct { }; pub const labels = bridge.accessor(TextArea.getLabels, null, .{}); + pub const willValidate = bridge.accessor(TextArea.getWillValidate, null, .{}); + pub const validity = bridge.accessor(TextArea.getValidity, null, .{}); + pub const validationMessage = bridge.accessor(TextArea.getValidationMessage, null, .{}); + pub const checkValidity = bridge.function(TextArea.checkValidity, .{}); + pub const reportValidity = bridge.function(TextArea.reportValidity, .{}); + pub const setCustomValidity = bridge.function(TextArea.setCustomValidity, .{}); pub const onselectionchange = bridge.accessor(TextArea.getOnSelectionChange, TextArea.setOnSelectionChange, .{}); pub const value = bridge.accessor(TextArea.getValue, TextArea.setValue, .{}); pub const defaultValue = bridge.accessor(TextArea.getDefaultValue, TextArea.setDefaultValue, .{}); pub const disabled = bridge.accessor(TextArea.getDisabled, TextArea.setDisabled, .{}); pub const name = bridge.accessor(TextArea.getName, TextArea.setName, .{}); pub const required = bridge.accessor(TextArea.getRequired, TextArea.setRequired, .{}); + pub const maxLength = bridge.accessor(TextArea.getMaxLength, TextArea.setMaxLength, .{ .dom_exception = true }); + pub const minLength = bridge.accessor(TextArea.getMinLength, TextArea.setMinLength, .{ .dom_exception = true }); pub const form = bridge.accessor(TextArea.getForm, null, .{}); pub const select = bridge.function(TextArea.select, .{}); @@ -320,4 +431,5 @@ pub const Build = struct { const testing = @import("../../../../testing.zig"); test "WebApi: HTML.TextArea" { try testing.htmlRunner("element/html/textarea.html", .{}); + try testing.htmlRunner("element/html/textarea-validity.html", .{}); } diff --git a/src/browser/webapi/element/html/ValidityState.zig b/src/browser/webapi/element/html/ValidityState.zig new file mode 100644 index 00000000..769f3fc1 --- /dev/null +++ b/src/browser/webapi/element/html/ValidityState.zig @@ -0,0 +1,133 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#the-constraint-validation-api + +const js = @import("../../../js/js.zig"); + +const Element = @import("../../Element.zig"); +const Input = @import("Input.zig"); +const Select = @import("Select.zig"); +const TextArea = @import("TextArea.zig"); +const Button = @import("Button.zig"); +const Frame = @import("../../../Frame.zig"); + +const ValidityState = @This(); + +// The form control whose validity flags this object reflects. Stored as a +// generic *Element; each getter dispatches on the concrete element type +// because the flag definitions are per-type in the HTML spec. +_owner: *Element, + +pub fn getValueMissing(self: *const ValidityState, frame: *Frame) bool { + if (self._owner.is(Input)) |input| return input.suffersValueMissing(frame); + if (self._owner.is(Select)) |select| return select.suffersValueMissing(); + if (self._owner.is(TextArea)) |textarea| return textarea.suffersValueMissing(); + return false; +} + +pub fn getTypeMismatch(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.suffersTypeMismatch(); + return false; +} + +pub fn getPatternMismatch(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.suffersPatternMismatch(); + return false; +} + +pub fn getTooLong(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.suffersTooLong(); + if (self._owner.is(TextArea)) |textarea| return textarea.suffersTooLong(); + return false; +} + +pub fn getTooShort(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.suffersTooShort(); + if (self._owner.is(TextArea)) |textarea| return textarea.suffersTooShort(); + return false; +} + +pub fn getRangeUnderflow(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.suffersRangeUnderflow(); + return false; +} + +pub fn getRangeOverflow(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.suffersRangeOverflow(); + return false; +} + +pub fn getStepMismatch(self: *const ValidityState) bool { + _ = self; + // Step matching is not implemented yet (PR #2280 begins step rounding for + // ). Returning false keeps well-formed values valid. + return false; +} + +pub fn getBadInput(self: *const ValidityState) bool { + _ = self; + // badInput flips when the UA cannot convert a user-typed string (e.g. the + // user typed "abc" into ). Headless Lightpanda receives + // values via attributes or JS assignment, never raw keystrokes — there is + // no user input to be "bad". Always false. + return false; +} + +pub fn getCustomError(self: *const ValidityState) bool { + if (self._owner.is(Input)) |input| return input.hasCustomValidity(); + if (self._owner.is(Select)) |select| return select.hasCustomValidity(); + if (self._owner.is(TextArea)) |textarea| return textarea.hasCustomValidity(); + if (self._owner.is(Button)) |button| return button.hasCustomValidity(); + return false; +} + +pub fn getValid(self: *const ValidityState, frame: *Frame) bool { + return !self.getValueMissing(frame) and + !self.getTypeMismatch() and + !self.getPatternMismatch() and + !self.getTooLong() and + !self.getTooShort() and + !self.getRangeUnderflow() and + !self.getRangeOverflow() and + !self.getStepMismatch() and + !self.getBadInput() and + !self.getCustomError(); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(ValidityState); + + pub const Meta = struct { + pub const name = "ValidityState"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const valueMissing = bridge.accessor(ValidityState.getValueMissing, null, .{}); + pub const typeMismatch = bridge.accessor(ValidityState.getTypeMismatch, null, .{}); + pub const patternMismatch = bridge.accessor(ValidityState.getPatternMismatch, null, .{}); + pub const tooLong = bridge.accessor(ValidityState.getTooLong, null, .{}); + pub const tooShort = bridge.accessor(ValidityState.getTooShort, null, .{}); + pub const rangeUnderflow = bridge.accessor(ValidityState.getRangeUnderflow, null, .{}); + pub const rangeOverflow = bridge.accessor(ValidityState.getRangeOverflow, null, .{}); + pub const stepMismatch = bridge.accessor(ValidityState.getStepMismatch, null, .{}); + pub const badInput = bridge.accessor(ValidityState.getBadInput, null, .{}); + pub const customError = bridge.accessor(ValidityState.getCustomError, null, .{}); + pub const valid = bridge.accessor(ValidityState.getValid, null, .{}); +};