barbatos-valee-form
Forms and validation with Barbatos.Wpf.AquariusValee - Form.FromModel over DataAnnotations or ad-hoc DefineField, Field<T>, HandleSubmit, DependsOn/PropertyRange cross-field and async rules, and aqv:
SKILL.md.claude/skills/barbatos-valee-form/SKILL.md
GitHubForms and validation with Barbatos.Wpf.AquariusValee
vee-validate's useField/useForm ported to WPF: Field<T> holds one value and its validation
state, Form aggregates fields and drives submit, System.ComponentModel.DataAnnotations is the
schema language, and errors reach the screen through INotifyDataErrorInfo. It is not a DI
feature (there is no AddXxx/UseXxx - a ViewModel constructs and owns its Form), it does not
localize anything itself, and it does not use WPF's Binding.ValidationRules.
Namespaces Barbatos.Wpf.AquariusValee.Validation (Form, Field<T>, metas, rule delegates),
.Rules (extra attributes) and .Xaml (Valee attached properties, converters) all map to
xmlns:aqv="http://schemas.barbatos.co/aquariusvalee/2026/xaml".
Sources of truth
- README (concepts, rationale) and
API-REFERENCE (signatures). The
behaviour itself is in
Validation/Field.csandValidation/Form.cs- read them before trusting a doc sentence. - Sample
samples/Barbatos.Wpf.AquariusValee.Sample:Views/Login(ad-hoc +SetFieldError),Views/Signup(both modes side by side),Views/ProfileEdit(schema +Valee.Numericbounds from[Range]),Views/CustomRules(standalone fields, async rule, dynamic range, eagerly localized messages),Views/Showcase(FieldTemplateSelector,Valee.Mask,[PropertyRange]),MainWindow.xaml.cs(language switch). - Scaffolds:
dotnet new aqv-form(item template, schema mode) anddotnet new barbatos-wpf-app --IncludeValee true(SignUpViewModel.cs, ad-hoc). tests/Barbatos.Wpf.AquariusValee.UnitTestspins it, one class per area (FormTests,FieldTests,CrossFieldDependencyTests, ...).- reference.md - read when choosing an attribute, wiring
Valee.Mask/Valee.Numeric, building a template-driven form, or checking whatForm.FromModelreads from a model.
Choose the mode first - they are not interchangeable
Schema: Form.FromModel(model) |
Ad-hoc: form.DefineField(...) |
Standalone: new Field<T>(...) |
|
|---|---|---|---|
| Rules come from | attributes on the model's properties | attributes or delegates per call | same as ad-hoc |
[Compare], [PropertyRange], IValidatableObject |
work | do not | do not |
AddRule(ValidationAttribute) |
silently ignored | works | works |
AddRule(ValidationRule<T>) / async overload |
works | works | works |
| Values end up in | the model instance, live (GetModel<TModel>()) |
GetValues() and the typed fields |
the field |
HandleSubmit, focus-first-invalid |
yes | yes | no Form, so no |
Schema mode (adapted from Views/Signup/SignupModel.cs and SignupViewModel.cs):
public sealed class SignupModel
{
[Required(ErrorMessage = "Validation.Required")]
[EmailAddress(ErrorMessage = "Validation.InvalidEmail")]
public string Email { get; set; } = "";
[Required, MinLength(8)]
public string Password { get; set; } = "";
[Required, Compare(nameof(Password))]
public string ConfirmPassword { get; set; } = "";
}
SchemaForm = Form.FromModel(new SignupModel());
SchemaEmail = (Field<string>)SchemaForm.Fields["Email"];
Ad-hoc mode (from Views/Login/LoginViewModel.cs):
public Form LoginForm { get; } = new();
Email = LoginForm.DefineField("Email", "",
new RequiredAttribute { ErrorMessage = "Validation.Required" },
new EmailAddressAttribute { ErrorMessage = "Validation.InvalidEmail" });
FromModelmakes a field of every public instance property with a getter and a setter of any accessibility,private/initincluded (indexers skipped), so non-input state on the model also counts towardMeta.ValidandGetValues(). CastFields["Name"]toField<TProperty>with the property's exact type.- Attributes that read a sibling property need a real model: ad-hoc fields validate with
new ValidationContext(this)(theField<T>itself), so[Compare]finds no sibling and fails every pass, and[PropertyRange]throwsInvalidOperationException. DefineFieldoverloads take no rules or one kind (params ValidationAttribute[],ValidationRule<T>[]orAsyncValidationRule<T>[]); layer other kinds withAddRule(chainable). AValidationRule<T>returnsnullfor valid, otherwise the message.- Every
Field<T>constructor runs one silent pass with its rules, soMeta.Validis right from the start without showing errors.AddRulerules miss that pass (Meta.Validignores them until the next one), and an async rule given to the constructor is called once, at construction.
Submitting
LogInCommand = LoginForm.HandleSubmit(async values =>
{
await Task.Delay(TimeSpan.FromSeconds(1));
if ((string)values["Password"]! != "correct-password")
{
LoginForm.SetFieldError("Password", "Login.InvalidCredentials"); // server-driven
return;
}
});
HandleSubmit(onValid, onInvalid = null) returns an IAsyncRelayCommand that, as vee-validate:
(1) touches every field, (2) sets IsSubmitting and increments SubmitCount, (3) runs
ValidateAllAsync() (forced pass on every field, then IValidatableObject for a schema form),
(4) calls onValid(GetValues()), or onInvalid(GetValues(), GetErrors()) plus SubmitInvalid
with GetFirstInvalidField(), (5) clears IsSubmitting in a finally - even when onValid throws.
CanExecuteis gated only on!IsSubmitting, never onMeta.Valid. Submitting an empty form is what touches every field and surfaces every error. Do not bind the button'sIsEnabledto validity; show progress withIsSubmitting.Form.SetFieldError(name, message)shows a server error immediately (ArgumentExceptionfor an unknown name;nullclears). It marks the field validated, so the user's next edit re-runs the rules and replaces it.aqv:Valee.Formsubscribes toSubmitInvalidand focuses +BringIntoView()s the control bound to the first invalid field. Without it, subscribe to the headless event yourself.ValidateAllAsync()alone never raises it.ResetForm()restores initial values and clears touched/validated/errors andSubmitCount; after a save,field.Reset(savedValue)rebases the initial value soDirtytracks the saved one.
Field and form state
FieldMeta has Touched (blur or submit only), Validated (a pass that writes errors has
run), Valid (written by every pass, default true), Pending (async rule in flight) and Dirty
(live comparison with the initial value); setters are internal. Full table and FormMeta aggregation
in reference.md. Which pass may write visible errors
(the internal ValidationWriteMode):
| Pass | Writes Errors? |
|---|---|
Constructor, Reset |
never (silent) - updates Meta.Valid only |
HandleChange / Value setter (OnChange), a DependsOn source changing |
only if already Validated |
HandleBlur (OnBlur), ValidateAsync(), submit, RevalidateValidatedAsync() |
always |
So typing into a pristine field shows nothing until the first blur or submit; afterwards errors update per keystroke. That is the intended vee-validate behaviour, not a bug.
- Triggers per field:
Email.Triggers = ValidateTrigger.OnBlur;(None,OnChange,OnBlur,Default = OnChange | OnBlur).HandleBlur()always setsTouched. - Every failing rule adds its message (no bailing), except that the BCL runs
[Required]first and short-circuits the other attributes:[Required, MinLength(3)]on""reports one message. Delegate rules still run on the empty value. Validdefaults totrue; pair it withDirty/Touchedbefore any "looks good" UI.
Cross-field validation - three mechanisms
Ad-hoc: DependsOn + a closure (Views/Signup/SignupViewModel.cs):
AdHocConfirmPassword.AddRule(value => value == AdHocPassword.Value ? null : "Signup.PasswordMismatch");
AdHocConfirmPassword.DependsOn(AdHocPassword);
Call it once per source for bounds driven by other fields (Views/CustomRules: two rules reading
MinPrice.Value/MaxPrice.Value, then Price.DependsOn(MinPrice) and Price.DependsOn(MaxPrice));
[Range] cannot, its arguments are constants. DependsOn returns an IDisposable, re-validates
validated-only (no error appears on an untouched field), and uses Watch.On, which reacts only to
the source's value - never Errors/Meta - so mutual dependencies cannot loop.
Schema, one sibling: [Compare(nameof(Password))]. Right verdict whenever the confirm field
validates, but no automatic re-validation when Password changes - add
ConfirmPassword.DependsOn(Password) (the aqv-form template does), or fixing the password leaves
a stale "don't match" error.
Schema, dynamic bounds: [PropertyRange(nameof(MinPrice), nameof(MaxPrice))]. It implements
IDependsOnProperties, so FromModel wires DependsOn itself once all fields exist (any custom
attribute can opt in the same way). The message is ErrorMessage formatted with display name, min
and max ("The {0} field must be between {1} and {2}."); a placeholder-free key works like any
key but loses the live bounds. Localized text with bounds needs a resx resource
(ErrorMessageResourceType/ErrorMessageResourceName - the resource string is the template,
RulesTests) or ad-hoc DependsOn when the text must come from an app localizer.
Model-level: IValidatableObject on a schema model runs only in ValidateAllAsync/submit (and
RevalidateValidatedAsync), never per keystroke. Each result must name its members -
new ValidationResult("You must accept the terms.", [nameof(AcceptedTerms)]) - and the message is
appended to each named field; a result with no member names, or naming a non-field, is dropped.
It runs whether or not the properties' own attributes pass (an empty [Required] field does not
hold it back), after any class-level ValidationAttribute ([CustomValidation] on the class) -
a failing class-level attribute skips Validate, as in the BCL. Form does not call
Validator.TryValidateObject: its validateAllProperties: false still re-checks every
[Required] and stops there (FormFromModelTests). So Validate sees values its property
attributes reject - "", null, out of range - and must not assume they passed: skip a
comparison whose operand is empty rather than dereference it, or the submit throws.
Async rules
Adapted from Views/CustomRules/CustomRulesViewModel.cs, plus the optional try/catch
explained below:
Username.AddRule(async (value, cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(value))
return null; // rules run in order and never bail - guard
try
{
var available = await availabilityService.IsAvailableAsync(value, cancellationToken);
return available ? null : "Validation.UsernameTaken";
}
catch (HttpRequestException)
{
return "Validation.UsernameCheckFailed"; // a failure shown as a message - see below
}
});
- Forward the token. Every new pass cancels the previous pass's token and the latest pass wins
(
AsyncValidationRaceTests); forwarding turns a superseded call into cancelled work, not just a discarded result. There is no debounce: start the rule withawait Task.Delay(300, cancellationToken)if each keystroke should not hit the server. - A throwing rule is safe, but it is not a message (
FieldRuleExceptionTests). The pass writes nothing (Errors/Meta.Validkeep the previous pass's values) andMeta.Pendingis lowered. The exception propagates fromValidateAsync/ValidateAllAsync/RevalidateValidatedAsyncand theHandleSubmitcommand (IsSubmittingstill clears, neither callback runs); a pass nobody awaits - typing, blur,DependsOn, the silent constructor/Resetpass - rethrows it on the starting UI thread'sDispatcher, i.e.Application.DispatcherUnhandledException(a pass started off a UI thread - a pool thread, or one neither STA nor inside a dispatcher operation - leaves it on the task, forUnobservedTaskException). Catch inside the rule, as above, when a failed check should read as an error under the field. - Only Valee's own cancellation is swallowed: a superseded pass ends quietly whatever it
throws. An
OperationCanceledExceptionin a live pass - anHttpClienttimeout'sTaskCanceledException- is a failure like any other, so do not filter on the exception type to tell the two apart;cancellationToken.IsCancellationRequestedis the supersession signal. - Show progress with
{Binding Username.Meta.Pending}orForm.Meta.Pending. Add the rule withAddRuleafter construction, as the sample does, so the constructor's silent pass never calls the service (a laterReset/ResetFormsilent pass still does). Field<T>.Dispose()cancels in-flight work, clearsPendingand stops everyDependsOn;Form.Dispose()disposes all fields. Dispose when the owning ViewModel goes away mid-check.
XAML
<StackPanel aqv:Valee.Form="{Binding LoginForm}">
<TextBox aqv:Valee.Field="{Binding Email}" /> <!-- or aqv:Valee.For="Email" -->
<TextBlock Text="{Binding Email.ErrorMessage}" Foreground="Red" />
<PasswordBox aqv:Valee.PasswordField="{Binding Password}" />
<TextBlock Text="{Binding Password.ErrorMessage}" Foreground="Red" />
<Button Content="Log in" Command="{Binding LogInCommand}" />
</StackPanel>
Valee.Field(value: anyIField) bindsTextBox.Text,ToggleButton.IsChecked,Selector.SelectedItemorRangeBase.ValueTwoWay withUpdateSourceTrigger=PropertyChanged(WPF'sTextBox.Textdefault isLostFocus), and wiresLostFocustoHandleBlur. It throwsInvalidOperationExceptionon aPasswordBox, an unsupported element, or a non-IFieldvalue. Inside aDataTemplatewhose item is the field, useaqv:Valee.Field="{Binding}".Valee.PasswordField(PasswordBoxonly, valueField<string>) pushesPasswordChangedinto the field and never bindsPassword. So: no native adorner - always add an errorTextBlock- and it is one-way:Reset,ResetFormandSetFieldValuedo not clear the box.Valee.Formon a container enablesValee.Forand focus-first-invalid, and suppresses the native adorner on that element only (Formis itselfINotifyDataErrorInfo; the whole panel would turn red).Validation.HasErrorthere still works for a form-level banner.Valee.For="Email"finds the ancestorValee.Formby a visual-tree walk atLoaded, with no retry: theFormmust be non-null by then (build it in the ViewModel constructor). A missing form or field name is anInvalidOperationExceptionposted to the dispatcher.Popupcontent has no visual path to the panel - useValee.Fieldthere.- Error display is native: a
TextBlockonErrorMessageplus WPF's red adorner fromField<T>'sINotifyDataErrorInfo. No error control ships; do not write one.
Input helpers (TextBox only; details in reference.md):
aqv:Valee.Mask=">AAA-0000" shapes typing but does not validate - pair it with PatternAttribute.
A positional mask fits fixed layouts only; Showcase hand-rolls international phone input instead
(Views/Showcase/InputMask.cs). aqv:Valee.Numeric="Integer" (or Decimal) filters keystrokes
and clamps on LostFocus only to aqv:Valee.Minimum/aqv:Valee.Maximum, else to a numeric
[Range] on the field when bound through Valee.Field/Valee.For - keep the [Range], clamping
corrects but does not validate. FieldTemplateSelector renders a whole form from
ItemsSource="{Binding Form.Fields.Values}".
Localized messages
Valee takes no localization dependency; literal messages are fine. To localize, pick per message:
| Message | Store as | After a language switch |
|---|---|---|
| fixed text | a lookup key in ErrorMessage, SetFieldError or a rule's return value |
resolved by a converter when the binding re-reads |
text with runtime values ({0} is taken, "at least {MinPrice}") |
text resolved eagerly in the rule | await form.RevalidateValidatedAsync() |
BCL default messages ("The {0} field is required.") are English whatever the culture - .NET ships
no satellite resources for DataAnnotations - so every attribute a user can trip needs a key or a
resource of its own. The resx route (ErrorMessageResourceType/ErrorMessageResourceName) works
on Valee's Rules attributes as on BCL ones; never combine it with ErrorMessage (the BCL throws).
Wrap the localizing converter in SafeMessageConverter (its XAML content property) so an unknown
key or literal prose shows as written instead of blank - it returns the input when the inner
converter returns null/empty or throws:
<aqv:SafeMessageConverter x:Key="LocalizeConverter">
<i18n:LocalizeConverter />
</aqv:SafeMessageConverter>
<TextBlock Text="{Binding Email.ErrorMessage, Converter={StaticResource LocalizeConverter}}" />
"The error stays in the old language after switching" has three causes:
- Eager text. A rule that returned resolved text (and BCL default messages, which are
formatted when the rule runs) captured the old language. Call
RevalidateValidatedAsync()after changing culture: it re-runs onlyMeta.Validatedfields (no new errors on pristine ones) and re-appliesIValidatableObjectmessages for them. For standalone fields, callValidateAsync()on eachMeta.Validatedfield (CustomRulesViewModel.RefreshLocalizedMessagesAsync). - Key + converter, but the binding never re-read. A converter runs when
ErrorMessagechanges, and a culture switch does not change it. The sample rebuilds each tab's View against the same ViewModel (MainWindow.xaml.cs), so form state survives; anything that makes the binding re-evaluate works. - A key spliced into another message.
{0}is formatted with the raw[Display(Name)]when the rule runs, so a key there renders as the key. Keep key messages placeholder-free (the Showcase[PropertyRange]message drops{0}for this reason).
Rules: prefer the BCL
BCL ValidationAttribute first, then Barbatos.Wpf.AquariusValee.Rules, then
[CustomValidation]/IValidatableObject (schema) or a ValidationRule<T> (ad-hoc). Valee adds
attributes only for confirmed BCL gaps - Pattern (RegexOptions, anchored, timeout fails),
PropertyRange, Alpha* (AsciiOnly), Digits/NumericString/IntegerString, and loose
OneOf/NotOneOf (prefer the strict BCL AllowedValues/DeniedValues). No ExactLength: use
[Length(n, n)]. Semantics, including which skip "" or non-strings, are in
reference.md. Optional [Url]/[EmailAddress]/[Phone]
strings must be string? defaulting to null: those accept null but fail "".
Hard rules
- Create and mutate forms on the UI thread.
Field<T>andFormare not thread-safe (a worker's change races the UI's keystrokes over the same pass state), raise every notification on the calling thread, and a pass started off the UI thread leaves a rule's exception on its task instead ofDispatcherUnhandledException. Build theFormin the ViewModel constructor and never touch a field or form afterConfigureAwait(false)inonValid.Form.Metabindings no longer freeze when a worker does touch one: AquariusNextTick, whichFormMetacoalesces its notification through, posts from a worker toApplication.Current's dispatcher (earlier versions posted to the worker's own, which nothing pumps). With noApplication- a test or console host - it still falls back to that worker's dispatcher. - Never gate submit on
Meta.Valid; neverAddRule(attribute)on aFromModelfield; alwaysDependsOna[Compare]source; build theFormbefore the View loads; never put[DataType(...)]beside[Phone]/[CreditCard]/[EmailAddress]/[Url]/[EnumDataType](they areDataTypeAttributes already;FromModelthrows aTargetInvocationExceptionwrappingAmbiguousMatchException). - Lifetimes:
DependsOnsubscribes to the source, so a dependent field stays reachable from a longer-lived source (a store, a singleton ViewModel) until disposed.Valeeremembers the last control bound to each field (for focus-first-invalid) only through a weak reference, so aFormthat outlives its View does not keep the closed View alive (ValeeFieldLeakTests).
Symptom -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
| Nothing shows while typing in a fresh field | validated-only passes before the first blur/submit | by design; it appears after blur or submit |
| No error even after blur | plain {Binding X.Value} (no blur hook, LostFocus timing), Triggers without OnBlur, or a rule that throws (the pass writes nothing) |
use Valee.Field/Valee.For; check Triggers; catch inside the rule and return a message |
Error area blank though Meta.Valid is false |
field not validated yet (silent pass), or a localizing converter returned null for an unknown key | blur/submit; wrap the converter in SafeMessageConverter |
Raw key such as Validation.Required on screen |
key missing from the locale files, or no converter on that binding | register the key / add the converter |
| Message stays in the old language | eager text, an unrefreshed binding, or a key in {0} |
see Localized messages |
| Submit button disabled on an empty form | IsEnabled bound to Meta.Valid |
rely on HandleSubmit's CanExecute |
| "Passwords don't match" persists after fixing the password | [Compare] not wired for re-validation |
ConfirmPassword.DependsOn(Password) |
[Compare] in DefineField always fails; AddRule(attribute) on a schema field does nothing |
each works only in the other mode | DependsOn + closure; put the attribute on the model |
| Optional URL/email field invalid from the start, or again after clearing it | "" default / the box writes "" when cleared |
string? with null default / a rule that returns null for "" |
IValidatableObject message never appears |
no member names, a failing class-level attribute, or only per-keystroke validation ran | name members; it runs on submit/ValidateAllAsync once class-level attributes pass |
Unhandled exception (DispatcherUnhandledException) while typing or on blur; submit throws |
a rule threw or timed out - rethrown from a pass nobody awaits, propagated from an awaited one | catch inside the rule and return a message |
"checking..." never clears, Form.Meta.Pending stuck |
a rule that never completes (ignores the token and awaits forever) | forward the token; give the call a timeout |
Form.Meta never raises PropertyChanged in a test or a host with no Application |
the form was mutated on a worker thread: with no Application.Current, NextTick falls back to that thread's own dispatcher, which nothing pumps |
mutate on the UI (STA) thread and pump its dispatcher |
Unhandled InvalidOperationException mentioning Valee.For |
Form null at Loaded, misspelt field name, or element inside a Popup |
construct the form in the constructor; fix the name; use Valee.Field |
PasswordBox keeps its dots after ResetForm() |
Valee.PasswordField is one-way |
clear PasswordBox.Password in the View |
TargetInvocationException (inner AmbiguousMatchException) / InvalidOperationException from Form.FromModel |
two DataTypeAttributes on a property / an IDependsOnProperties name that does not exist |
drop the explicit [DataType] / use nameof |
Numeric box ignores [Range] |
box bound without Valee.Field/Valee.For, a non-numeric [Range] overload, or the RangeAttribute added with AddRule (only the constructor/DefineField copies its bounds) |
bind with Valee.Field; pass the [Range] to DefineField or set Valee.Minimum/Valee.Maximum |
Letters typed into a number box: no ErrorMessage, Value keeps the old number |
text the binding cannot convert never reaches a Field<int>, so no rule runs |
Valee.Numeric, or a Field<string> with [IntegerString] parsed on submit |
Numeric box rejects , or the value stops updating |
separator comes from FrameworkElement.Language (default en-US) |
set Language on the box or window |
| Service called with the initial value at startup | async rule passed to the constructor runs in the silent pass | AddRule after construction and guard empty input |
Checklist: adding a form to an app
- Pick the mode; build the
Formand typedField<T>properties in the ViewModel constructor on the UI thread (dotnet new aqv-formscaffolds the schema shape). - Rules: BCL first, Valee
Rulessecond, delegates last;string?for optional formatted strings. - Cross-field:
DependsOn,[Compare]+DependsOn, or[PropertyRange]; name members inIValidatableObjectresults. Async rules forward the token, guard empty input, and catch the failures that should show as a message. HandleSubmit, server errors viaSetFieldError, no validity gate on the button.- View:
aqv:Valee.Formon the container,Valee.Field/For/PasswordField, an errorTextBlockper field (mandatory forPasswordBox),SafeMessageConverterwhen localized. - Language switch: rebind key-based messages,
RevalidateValidatedAsync()for eager text. - Dispose forms that die while async checks or cross-lifetime
DependsOnare live.
Changing Barbatos.Wpf.AquariusValee itself
Load barbatos-package-authoring too. A new rule attribute goes in Rules/ (namespace
Barbatos.Wpf.AquariusValee.Rules, already mapped to aqv:) and follows the shipped ones:
public sealed class XxxAttribute : ValidationAttribute,
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)],
the default message passed to the base constructor (: base("The {0} field ...") - never
assigned to ErrorMessage, which would make the BCL reject ErrorMessageResourceName), and
IsValid returning true for null (and normally for "") so [Required] alone owns emptiness. A cross-field rule
implements IDependsOnProperties. Add its row to reference.md,
tests for null, empty, non-string and failing values, and README/API-REFERENCE entries
(barbatos-docs-sync). A new attached property follows the control rules in
.claude/rules/library-code.md.
Related skills
barbatos-aquarius-view- the View/ViewModel the form lives in (aq:Setup.Enable, reactivity).barbatos-aquarius-router- a form screen as a route, or a leave guard on unsavedMeta.Dirty.barbatos-wpf-testing- STA helpers and per-TFM runs for Valee tests.barbatos-package-authoring,barbatos-docs-sync,barbatos-templates- changing Valee itself. It depends on Aquarius (Watch.On,NextTick) andCommunityToolkit.Mvvm, never on Core; a change also updates README (and its menu), API-REFERENCE, the sample and both templates. Test one TFM at a time:dotnet test tests/Barbatos.Wpf.AquariusValee.UnitTests -f net8.0-windows.