Skip to content
Edgar Mesquita edited this page Aug 13, 2026 · 3 revisions

Forms

🌐 This page in: English · Português

A form in eQuantic.UI is a model, not a widget. The values, the flags, the rules, the errors and the one submit that is allowed to be in flight all live in eQuantic.UI.Primitives — pure logic, zero dependencies, no pixels — and the components on top of it only know which of the two owns what.

Why a model layer at all? Because a form is mostly arithmetic about state: whether an error may be shown yet, whether anything is unsaved, whether a second click may submit again. Wire that into a widget and it can only be tested by clicking. Keep it here and it is tested by asserting — and the same C# runs on the server, in the browser through its transpiled twin, and in a native window.


Declaring a form

Since 0.2.0-preview.29

A page holds one FormController, declares its fields, and subscribes once. Every value, flag and error arrives through that single event, so nothing on the page tracks state a second time.

public sealed class SignUpPage : StatefulComponent
{
    private readonly FormController _form = new();

    public SignUpPage()
    {
        _form.Add("email", rules: [Rules.Required(), Rules.Email()]);
        var password = _form.Add("password", rules: [Rules.Required(), Rules.MinLength(8)]);
        _form.Add("confirm", rules: [Rules.Required(), Rules.Matches(password)]);

        _form.Changed += () => SetState(() => { });
    }
}

Add returns the field, which is what makes a cross-field rule possible: Rules.Matches(password) holds the other field and reads it at validation time, so it judges what is on screen rather than what was there when the form was built.


Quiet until you leave

Two pairs of properties carry the whole behaviour of a considerate form, and each pair is two different questions:

Question Used for
Touched has the user LEFT this field? whether an error may be shown
Dirty does the value differ from the one the form opened with? "discard changes?"
Error what is wrong right now always current, even before anyone typed
VisibleError what the field may SAY what a component binds to

The consequence is the timing you can watch in the sample: type a broken address and nothing turns red, leave the field and it does, fix it and the red goes on the keystroke that fixes it. A server-rendered form arrives quiet for the same reason — every rule has already run, and nobody has touched anything yet.

var email = form.Field("email")!;
form.Set("email", "ana@");
email.Error;          // "Enter a valid email address." — computed immediately
email.VisibleError;   // null — the caret is still in the box
form.Touch("email");
email.VisibleError;   // "Enter a valid email address."

The rules

Required, MinLength, MaxLength, Email, Range, Matches, and Custom for the one this list does not have. Every rule but Required passes on an empty value: a field that is optional and blank is not badly formatted, it is absent — and "Enter a valid email" under an empty optional box is the most common false alarm in form validation.

A rule is a predicate and its message, which is what lets it cross to the browser at all: System.ComponentModel.DataAnnotations validates by reflection, and reflection is exactly what the transpiler cannot carry.

form.Add("age", rules: [Rules.Range(18, 120, "You must be 18 or older.")]);
form.Add("slug", rules: [Rules.Custom("Lowercase letters and dashes only.",
    value => value.All(c => char.IsAsciiLetterLower(c) || c == '-'))]);

A form that changes shape

Since 0.2.0-preview.29

Real forms have more than one path through them, and conditional validation arrives as two composable pieces rather than a second engine.

Rules.When makes any rule conditional — it wraps, so every rule above is conditional for free. While the condition is false the rule holds vacuously: nothing was asked, so the field goes quiet instead of keeping the last answer it gave.

relevantWhen turns off the whole FIELD. A shipping address on an order being collected in store is not a field with a passing rule, it is a field nobody is asking: it holds no error, it cannot make the form invalid, and a page may leave it out entirely. What was typed survives the question going away and coming back.

var kind = _form.Add("kind", "personal");
// The rule comes and goes; the field stays.
_form.Add("taxNumber", rules: [Rules.When(() => kind.Value == "company", Rules.Required())]);

// The field itself is not being asked while the box is unchecked.
_form.Add("phone", relevantWhen: () => _callMe,
    rules: [Rules.Required("We need a number to call you on."), Rules.MinLength(9)]);

// …and the page draws it only while it applies:
if (_form.Field("phone") is { Relevant: true })
    card.Add(new FormInput(_form, "phone", "Phone"));

A condition reads whatever it closes over. When that is another field, nothing more is needed — changing any value re-runs every other field's rules. When it is state the form does not own (a checkbox on the page, a plan chosen on an earlier step), the page says so with Revalidate():

card.Add(new Checkbox(_callMe, () => SetState(() =>
{
    _callMe = !_callMe;
    _form.Revalidate();   // the condition lives outside the form, so the form is told
}), "Call me instead of emailing"));

The surface

Since 0.2.0-preview.29

Two components bind the model to pixels, and they are deliberately thin. FormInput owns three wires and nothing else — typing calls Set, leaving calls Touch, and what it draws is VisibleError. FormSubmit reads the controller for everything else.

card.Add(new FormInput(_form, "email", "Email", placeholder: "you@example.com",
    helper: "We never share it."));
card.Add(new FormInput(_form, "password", "Password", helper: "At least 8 characters")
    { Obscure = true });

var actions = new Row(gap: Space.S2);
actions.Add(new FormSubmit(_form, "Create account", Submit));
actions.Add(new Button("Reset", Variant.Ghost) { OnPressed = () => _form.Reset() });

The submit button stays pressable while the form is invalid, and that is a choice worth defending: a disabled submit is the most common way to make a form feel broken, because the field that is wrong is usually one the user never visited. Pressing it is what reveals the answer — SubmitAsync touches every field first.


The model already said it

Since 0.2.0-preview.29

A model annotated with System.ComponentModel.DataAnnotations already describes most of a form. Mark it [FormModel] and the build writes the controller:

[FormModel]
public sealed class SignUp
{
    [Required, EmailAddress] public string Email { get; set; } = "";
    [Required, MinLength(8)] public string Password { get; set; } = "";
    [Required, Compare(nameof(Password))] public string Confirm { get; set; } = "";
}

private readonly FormController _form = SignUpForm.Create();   // generated

The reading happens at build time, and it has to: DataAnnotations validates by REFLECTION, which is exactly what the transpiler cannot carry into a browser. What gets emitted is ORDINARY calls to the same Rules above — there is no second validation engine, so the generated form and a hand-written one are the same object. A model can adopt the bridge without the page changing, and a form can outgrow it without a rewrite:

public FormScreen()
{
    // …the field no annotation can describe, added to the form the generator built.
    _form.Add("Phone", relevantWhen: () => _callMe, rules: [Rules.Required(), Rules.MinLength(9)]);
}

Fields are named after the PROPERTY ("Email"), which is also what a server's model state reports — so ApplyServerErrors lands on the right field with nothing in between.

Carried: Required, EmailAddress, MinLength, MaxLength, StringLength (both bounds), Range, RegularExpression and Compare. ErrorMessage wins over the rule's own message, because an app that wrote one wrote it to be shown.

Said out loud rather than dropped: a property type no text box round-trips (EQ3103), an annotation with no rule to become (EQ3104), a [Compare] naming a property that is not there (EQ3105). All warnings: a form that carries most of a model is still worth having, and the server enforces the rest either way. It is opt-in, because a model annotated for an API is not automatically a form.

Submitting, and the server's verdict

SubmitAsync gives the caller three guarantees it would otherwise re-implement on every page: an invalid form never reaches the handler (every error is revealed instead), a second submit is refused while the first is in flight (the double click that charges a card twice), and a throw becomes SubmitError rather than an unhandled exception — the network failing is a thing forms do, not a crash.

Client-side rules are a courtesy. The validation that counts runs where the data lives, and ApplyServerErrors is how its answer gets back onto the field it belongs to:

[ServerAction]
public async Task<List<FieldError>> Register(string email) =>
    await _users.Exists(email)
        ? [new FieldError("email", "That address is already registered.")]
        : [];

private async Task Submit()
{
    var verdict = await Register(_form.Field("email")!.Value);
    if (verdict.Count > 0) { _form.ApplyServerErrors(verdict); return; }
    _form.Accept();   // the current values become the new baseline: nothing dirty, nothing shouting
}

Fences

  • Dates, enums and anything else a text box cannot round-trip stay out of the DataAnnotations bridge: they are reported (EQ3103) and left for a hand-written field, because a date needs a picker and a culture before it needs a rule.
  • Messages are plain strings, not resource keys. An app that localizes its messages passes localized strings in, because it is the app that owns its resx. See Localization for the SDK's own chrome, which is the only text this framework translates on your behalf.

Related

Clone this wiki locally