Merge pull request #2286 from navidemad/fix-b6-validity-api

This commit is contained in:
Pierre Tachoire
2026-05-01 20:53:53 +02:00
committed by GitHub
12 changed files with 1158 additions and 67 deletions

View File

@@ -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"),

View File

@@ -126,3 +126,19 @@
testing.expectFalse(button.outerHTML.includes('required'))
}
</script>
<script id="validity_state_identity">
testing.expectEqual($('#button1').validity, $('#button1').validity)
</script>
<script id="invalid_event_is_trusted">
{
const b = document.createElement('button')
document.body.appendChild(b)
b.setCustomValidity('nope')
let captured = null
b.addEventListener('invalid', (e) => { captured = e })
b.checkValidity()
testing.expectEqual(true, captured.isTrusted)
}
</script>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<form id="invalid-form">
<input type="text" required>
<input type="email" value="not-an-email">
</form>
<form id="valid-form">
<input type="text" required value="ok">
<input type="email" value="ok@example.com">
</form>
<form id="empty-form"></form>
<script id="surface">
{
const f = $('#valid-form');
testing.expectEqual('function', typeof f.checkValidity);
testing.expectEqual('function', typeof f.reportValidity);
}
</script>
<script id="invalid_form_returns_false">
{
testing.expectEqual(false, $('#invalid-form').checkValidity());
}
</script>
<script id="valid_form_returns_true">
{
testing.expectEqual(true, $('#valid-form').checkValidity());
testing.expectEqual(true, $('#empty-form').checkValidity());
}
</script>
<script id="invalid_event_per_element">
{
const form = $('#invalid-form');
let fired = 0;
for (const el of form.elements) {
el.addEventListener('invalid', () => { fired++; });
}
form.checkValidity();
testing.expectEqual(2, fired); // both inputs fail
}
</script>
<script id="report_validity_matches">
{
testing.expectEqual(false, $('#invalid-form').reportValidity());
testing.expectEqual(true, $('#valid-form').reportValidity());
}
</script>

View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<form id="f">
<input id="empty-required" type="text" required>
<input id="filled-required" type="text" required value="x">
<input id="bad-email" type="email" value="not-an-email">
<input id="good-email" type="email" value="ok@example.com">
<input id="bad-url" type="url" value="not a url">
<input id="good-url" type="url" value="https://example.com">
<input id="too-low" type="number" min="10" value="3">
<input id="too-high" type="number" max="10" value="99">
<input id="ok-number" type="number" min="0" max="10" value="5">
<input id="cb-required" type="checkbox" required>
<input id="cb-checked" type="checkbox" required checked>
<input id="hidden-required" type="hidden" required>
<input id="disabled-required" type="text" required disabled>
<input id="too-long" type="text" maxlength="3">
<input id="too-short" type="text" minlength="3" value="ab">
</form>
<script id="surface">
{
const i = $('#filled-required');
testing.expectEqual(true, 'validity' in i);
testing.expectEqual(true, 'validationMessage' in i);
testing.expectEqual('function', typeof i.checkValidity);
testing.expectEqual('function', typeof i.reportValidity);
testing.expectEqual('function', typeof i.setCustomValidity);
testing.expectEqual('function', typeof ValidityState);
}
</script>
<script id="value_missing">
{
testing.expectEqual(true, $('#empty-required').validity.valueMissing);
testing.expectEqual(false, $('#empty-required').validity.valid);
testing.expectEqual(false, $('#filled-required').validity.valueMissing);
testing.expectEqual(true, $('#filled-required').validity.valid);
}
</script>
<script id="checkbox_required">
{
testing.expectEqual(true, $('#cb-required').validity.valueMissing);
testing.expectEqual(false, $('#cb-checked').validity.valueMissing);
}
</script>
<script id="type_mismatch_email">
{
testing.expectEqual(true, $('#bad-email').validity.typeMismatch);
testing.expectEqual(false, $('#good-email').validity.typeMismatch);
}
</script>
<script id="type_mismatch_url">
{
testing.expectEqual(true, $('#bad-url').validity.typeMismatch);
testing.expectEqual(false, $('#good-url').validity.typeMismatch);
}
</script>
<script id="range">
{
testing.expectEqual(true, $('#too-low').validity.rangeUnderflow);
testing.expectEqual(true, $('#too-high').validity.rangeOverflow);
testing.expectEqual(false, $('#ok-number').validity.rangeUnderflow);
testing.expectEqual(false, $('#ok-number').validity.rangeOverflow);
testing.expectEqual(true, $('#ok-number').validity.valid);
}
</script>
<script id="too_long">
{
const el = $('#too-long');
// Pristine value (default) does not trigger tooLong per spec
testing.expectEqual(false, el.validity.tooLong);
el.value = 'abcdef';
testing.expectEqual(true, el.validity.tooLong);
el.value = 'ab';
testing.expectEqual(false, el.validity.tooLong);
}
</script>
<script id="will_validate">
{
testing.expectEqual(true, $('#filled-required').willValidate);
testing.expectEqual(false, $('#hidden-required').willValidate);
testing.expectEqual(false, $('#disabled-required').willValidate);
// Disabled element doesn't suffer valueMissing
testing.expectEqual(false, $('#disabled-required').validity.valueMissing);
testing.expectEqual(true, $('#disabled-required').validity.valid);
}
</script>
<script id="custom_validity">
{
const i = $('#filled-required');
testing.expectEqual(false, i.validity.customError);
testing.expectEqual(true, i.validity.valid);
i.setCustomValidity('my custom error');
testing.expectEqual(true, i.validity.customError);
testing.expectEqual(false, i.validity.valid);
testing.expectEqual('my custom error', i.validationMessage);
i.setCustomValidity('');
testing.expectEqual(false, i.validity.customError);
testing.expectEqual(true, i.validity.valid);
}
</script>
<script id="check_validity_returns">
{
testing.expectEqual(false, $('#empty-required').checkValidity());
testing.expectEqual(true, $('#filled-required').checkValidity());
testing.expectEqual(false, $('#bad-email').checkValidity());
// Disabled element passes regardless of state
testing.expectEqual(true, $('#disabled-required').checkValidity());
}
</script>
<script id="invalid_event">
{
let fired = 0;
const el = $('#empty-required');
el.addEventListener('invalid', () => { fired++; });
el.checkValidity();
testing.expectEqual(1, fired);
// Event does not fire when valid
$('#filled-required').checkValidity();
testing.expectEqual(1, fired);
}
</script>
<script id="report_validity">
{
// reportValidity behaves identically to checkValidity in headless.
testing.expectEqual(false, $('#empty-required').reportValidity());
testing.expectEqual(true, $('#filled-required').reportValidity());
}
</script>
<script id="validation_message">
{
testing.expectEqual('Please fill out this field.', $('#empty-required').validationMessage);
testing.expectEqual('Please enter an email address.', $('#bad-email').validationMessage);
testing.expectEqual('Value is too small.', $('#too-low').validationMessage);
testing.expectEqual('', $('#filled-required').validationMessage);
// Disabled returns empty
testing.expectEqual('', $('#disabled-required').validationMessage);
}
</script>
<script id="validity_state_identity">
{
// Each access returns a ValidityState; both must reflect current flags.
const a = $('#empty-required').validity;
testing.expectEqual(true, a instanceof ValidityState);
testing.expectEqual(true, a.valueMissing);
// Repeated access returns the same instance (cached on the element).
testing.expectEqual(a, $('#empty-required').validity);
}
</script>
<script id="minmax_length">
{
testing.expectEqual(3, $('#too-long').maxLength);
testing.expectEqual(-1, $('#filled-required').maxLength);
testing.expectEqual(3, $('#too-short').minLength);
testing.expectEqual(-1, $('#filled-required').minLength);
testing.expectEqual(true, $('#too-short').validity.tooShort);
const el = $('#filled-required');
el.minLength = 2;
testing.expectEqual('2', el.getAttribute('minlength'));
el.maxLength = 7;
testing.expectEqual('7', el.getAttribute('maxlength'));
}
</script>
<script id="invalid_event_is_trusted">
{
let captured = null;
const el = $('#empty-required');
el.addEventListener('invalid', (e) => { captured = e; });
el.checkValidity();
testing.expectEqual(true, captured.isTrusted);
}
</script>

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<form id="f">
<select id="placeholder" required>
<option value="">choose…</option>
<option value="a">A</option>
</select>
<select id="filled" required>
<option value="a">A</option>
<option value="b">B</option>
</select>
<select id="disabled" required disabled>
<option value="">empty</option>
</select>
<select id="not-required">
<option value="">empty</option>
</select>
</form>
<script id="surface">
{
const s = $('#filled');
testing.expectEqual(true, 'validity' in s);
testing.expectEqual(true, 'validationMessage' in s);
testing.expectEqual('function', typeof s.checkValidity);
testing.expectEqual('function', typeof s.setCustomValidity);
}
</script>
<script id="placeholder_value_missing">
{
// First option's value is empty → required + selected = valueMissing
testing.expectEqual(true, $('#placeholder').validity.valueMissing);
testing.expectEqual(false, $('#placeholder').validity.valid);
testing.expectEqual(false, $('#placeholder').checkValidity());
}
</script>
<script id="filled_select_valid">
{
testing.expectEqual(false, $('#filled').validity.valueMissing);
testing.expectEqual(true, $('#filled').validity.valid);
testing.expectEqual(true, $('#filled').checkValidity());
}
</script>
<script id="not_required_passes">
{
testing.expectEqual(false, $('#not-required').validity.valueMissing);
testing.expectEqual(true, $('#not-required').validity.valid);
}
</script>
<script id="disabled_passes">
{
// Disabled is barred from constraint validation; willValidate=false.
testing.expectEqual(false, $('#disabled').willValidate);
testing.expectEqual(false, $('#disabled').validity.valueMissing);
testing.expectEqual(true, $('#disabled').validity.valid);
}
</script>
<script id="custom_validity">
{
const s = $('#filled');
s.setCustomValidity('pick again');
testing.expectEqual(true, s.validity.customError);
testing.expectEqual(false, s.validity.valid);
testing.expectEqual('pick again', s.validationMessage);
s.setCustomValidity('');
testing.expectEqual(true, s.validity.valid);
}
</script>
<script id="validity_state_identity">
{
const s = $('#filled');
testing.expectEqual(s.validity, s.validity);
}
</script>
<script id="invalid_event_is_trusted">
{
let captured = null;
const s = $('#placeholder');
s.addEventListener('invalid', (e) => { captured = e; });
s.checkValidity();
testing.expectEqual(true, captured.isTrusted);
}
</script>

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<form>
<textarea id="empty-required" required></textarea>
<textarea id="filled-required" required>x</textarea>
<textarea id="too-long" maxlength="3"></textarea>
<textarea id="too-short" minlength="5"></textarea>
<textarea id="disabled-required" required disabled></textarea>
</form>
<script id="surface">
{
const t = $('#empty-required');
testing.expectEqual(true, 'validity' in t);
testing.expectEqual('function', typeof t.checkValidity);
testing.expectEqual('function', typeof t.setCustomValidity);
}
</script>
<script id="value_missing">
{
testing.expectEqual(true, $('#empty-required').validity.valueMissing);
testing.expectEqual(false, $('#filled-required').validity.valueMissing);
}
</script>
<script id="too_long">
{
const t = $('#too-long');
testing.expectEqual(false, t.validity.tooLong);
t.value = 'abcdef';
testing.expectEqual(true, t.validity.tooLong);
t.value = 'ab';
testing.expectEqual(false, t.validity.tooLong);
}
</script>
<script id="too_short">
{
const t = $('#too-short');
// empty value never triggers tooShort
testing.expectEqual(false, t.validity.tooShort);
t.value = 'ab';
testing.expectEqual(true, t.validity.tooShort);
t.value = 'abcde';
testing.expectEqual(false, t.validity.tooShort);
}
</script>
<script id="disabled">
{
testing.expectEqual(false, $('#disabled-required').willValidate);
testing.expectEqual(true, $('#disabled-required').validity.valid);
testing.expectEqual(true, $('#disabled-required').checkValidity());
}
</script>
<script id="custom_validity">
{
const t = $('#filled-required');
t.setCustomValidity('nope');
testing.expectEqual(true, t.validity.customError);
testing.expectEqual(false, t.checkValidity());
t.setCustomValidity('');
testing.expectEqual(true, t.validity.valid);
}
</script>
<script id="minmax_length">
{
testing.expectEqual(3, $('#too-long').maxLength);
testing.expectEqual(-1, $('#filled-required').maxLength);
testing.expectEqual(5, $('#too-short').minLength);
testing.expectEqual(-1, $('#filled-required').minLength);
const t = $('#filled-required');
t.maxLength = 8;
testing.expectEqual('8', t.getAttribute('maxlength'));
t.minLength = 1;
testing.expectEqual('1', t.getAttribute('minlength'));
}
</script>
<script id="validity_state_identity">
{
const t = $('#empty-required');
testing.expectEqual(t.validity, t.validity);
}
</script>
<script id="invalid_event_is_trusted">
{
let captured = null;
const el = $('#empty-required');
el.addEventListener('invalid', (e) => { captured = e; });
el.checkValidity();
testing.expectEqual(true, captured.isTrusted);
}
</script>

View File

@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 {

View File

@@ -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", .{});
}

View File

@@ -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 <legend> 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 <form>
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" {

View File

@@ -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", .{});
}

View File

@@ -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", .{});
}

View File

@@ -0,0 +1,133 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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://www.gnu.org/licenses/>.
// 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
// <input type=range>). 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 <input type=number>). 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, .{});
};