mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge pull request #2286 from navidemad/fix-b6-validity-api
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
src/browser/tests/element/html/form-validity.html
Normal file
54
src/browser/tests/element/html/form-validity.html
Normal 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>
|
||||
191
src/browser/tests/element/html/input-validity.html
Normal file
191
src/browser/tests/element/html/input-validity.html
Normal 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>
|
||||
91
src/browser/tests/element/html/select-validity.html
Normal file
91
src/browser/tests/element/html/select-validity.html
Normal 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>
|
||||
100
src/browser/tests/element/html/textarea-validity.html
Normal file
100
src/browser/tests/element/html/textarea-validity.html
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
133
src/browser/webapi/element/html/ValidityState.zig
Normal file
133
src/browser/webapi/element/html/ValidityState.zig
Normal 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, .{});
|
||||
};
|
||||
Reference in New Issue
Block a user