-
Notifications
You must be signed in to change notification settings - Fork 1
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.
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.
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."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 == '-'))]);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"));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.
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(); // generatedThe 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.
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
}-
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.
- Write-Once Components: how one component library reaches the browser and a native window.
- Design System: the input, the checkbox and the button the surface is built from.
-
Server Integration:
[ServerAction], which is how a form reaches the server.
🌐 English · Português
🏁 Start here
📱 Write-once
- Write-Once Components
- Declarative Surface
- Photon Engine
- Design System
- Capabilities
- Storage
- Forms
- Code Editor
- Markdown
- Mermaid
- Email Rendering
🏗️ Architecture
⚙️ Compilation
- Compiler
- Compile-Time Evaluation
- Supported C# Features
- External Type Resolution
- Build Flow
- Diagnostics
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development