-
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.
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
}-
No
[Required]/[EmailAddress]bridge yet. The attributes a model already carries will be read at BUILD time and emitted as calls to these same rules — until then, rules are written by hand. There is deliberately no second validation engine for the generator to target. - 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