Barbatos.Wpf.AquariusValee
2.3.1
dotnet add package Barbatos.Wpf.AquariusValee --version 2.3.1
NuGet\Install-Package Barbatos.Wpf.AquariusValee -Version 2.3.1
<PackageReference Include="Barbatos.Wpf.AquariusValee" Version="2.3.1" />
<PackageVersion Include="Barbatos.Wpf.AquariusValee" Version="2.3.1" />
<PackageReference Include="Barbatos.Wpf.AquariusValee" />
paket add Barbatos.Wpf.AquariusValee --version 2.3.1
#r "nuget: Barbatos.Wpf.AquariusValee, 2.3.1"
#:package Barbatos.Wpf.AquariusValee@2.3.1
#addin nuget:?package=Barbatos.Wpf.AquariusValee&version=2.3.1
#tool nuget:?package=Barbatos.Wpf.AquariusValee&version=2.3.1
Barbatos.Wpf.AquariusValee
A vee-validate-style form-validation system - for WPF
Reactive Field<T>/Form validation state, System.ComponentModel.DataAnnotations as the
primary schema mechanism, INotifyDataErrorInfo-native error display, and
Valee.Field/Valee.Form/Valee.For binding sugar - built directly on top of
Barbatos.Wpf.Aquarius.
📖 Documentation Menu
- Getting Started
- Field
- Form
- Validation triggers
- Cross-field validation
- Async rules and
Meta.Pending - XAML integration
- Rules
- Deliberately Out of Scope
- Ecosystem
- API Reference
- Community
Getting Started
Introduction
What is Barbatos.Wpf.AquariusValee?
AquariusValee ports vee-validate's form-validation
concepts to WPF: reactive per-field state (touched/dirty/valid/validated/pending),
form-level aggregation, submit gating, and cross-field validation - combined with
System.ComponentModel.DataAnnotations as the primary "schema" mechanism, since most of
vee-validate's own built-in rules already map directly onto existing ValidationAttributes.
Prerequisites
The rest of this document assumes basic familiarity with C#, XAML, WPF data binding, and
System.ComponentModel.DataAnnotations.Familiar with vee-validate?
Field/Form,Meta, and the trigger/validation-mode vocabulary below are deliberately similar - a head start if you've used it before, though nothing here requires that background. Where this port genuinely diverges from vee-validate's real behavior, it's called out explicitly with the reason why.
Quick Start
dotnet add package Barbatos.Wpf.AquariusValee
<Window ...
xmlns:aqv="http://schemas.barbatos.co/aquariusvalee/2026/xaml">
A hand-authored form, no model class:
public sealed partial class LoginViewModel : ObservableObject
{
public Form LoginForm { get; } = new();
public Field<string> Email { get; }
public Field<string> Password { get; }
public IAsyncRelayCommand LogInCommand { get; }
public LoginViewModel()
{
Email = LoginForm.DefineField(nameof(Email), "", new RequiredAttribute(), new EmailAddressAttribute());
Password = LoginForm.DefineField(nameof(Password), "", new RequiredAttribute());
LogInCommand = LoginForm.HandleSubmit(async values =>
{
await SignInAsync((string)values[nameof(Email)]!, (string)values[nameof(Password)]!);
});
}
}
<TextBox aqv:Valee.Field="{Binding 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}" />
See samples/Barbatos.Wpf.AquariusValee.Sample in the repo for a complete, working app: a login
form, a signup form demonstrating both cross-field validation styles side by side, a
Form.FromModel-driven profile editor, a schema-less custom-rules example with a real async
rule, and a wider "attribute showcase" tab tying together UIHint/DataType-driven templates
with the rest of the DataAnnotations review below. Every tab's static text, and every
ValidationRule<T>/Form.SetFieldError message the sample writes by hand, is also localized
into English and Vietnamese via Barbatos.i18n -
purely a sample-level demonstration of the two packages working together side by side, not a
dependency AquariusValee itself takes on.
Field
Barbatos.Wpf.AquariusValee.Validation.Field<T> - one field's reactive value and validation
state, the WPF/C# counterpart of vee-validate's useField. Usable standalone
(new Field<string>(...), e.g. a single validated search box with no surrounding form concept)
or via Form.DefineField/Form.FromModel.
public Field<string> Email { get; } = new("Email", "", new RequiredAttribute(), new EmailAddressAttribute());
Value(T) - the current value. Setting it is equivalent to callingHandleChange.Meta(FieldMeta) - see below.Errors(IReadOnlyList<string>) /ErrorMessage(string?, the first error).HandleChange(T value, bool shouldValidate = true)- a no-op (including no re-validation) ifvalueequals the current value.HandleBlur()- always setsMeta.Touched = true, regardless of trigger configuration; additionally validates ifTriggersincludesOnBlur.ValidateAsync()- an explicit, always-visible validation pass.SetError(string? message)- a manual, server-driven error (e.g. "email already taken") that bypasses rule execution entirely; a subsequent real validation pass supersedes it.AppendError(string message)- adds an error without clearing existing ones.Reset()/Reset(T value)- the second overload also rebases the initial value (soMeta.Dirtybecomesfalseagainst the new value, not the original one).AddRule(...)- three overloads (ValidationAttribute,ValidationRule<T>,AsyncValidationRule<T>) to layer an additional rule onto an already-constructed field.DependsOn<TSource>(Field<TSource> source)- see Cross-field validation.
FieldMeta
| Property | Meaning |
|---|---|
Touched |
Flips to true only via HandleBlur() or a form submit's touch-all-fields step - never via a value change. |
Dirty |
A live comparison of the current value against the initial value, computed fresh on every read - not a flag latched the first time the value changes. Typing a value and then retyping the original value un-dirties the field again. |
Valid |
Defaults to true - "no errors yet" is not the same as "confirmed valid." Updated on every validation pass regardless of mode, so it stays accurate even while Errors itself is being withheld (see the next row). Combine with Dirty/Touched before showing any "this looks good" UI. |
Validated |
Whether a real (non-silent) validation pass has run at least once - independent of Touched, and what actually gates whether a background pass may write visible Errors. A field validated only via ValidateAsync() reports Validated=true, Touched=false; a blurred field with change-triggered validation off reports Touched=true, Validated=false. |
Pending |
true while an asynchronous rule is in flight - see Async rules. |
This split (Valid always fresh, Errors/Validated gated) is what lets a freshly-constructed
field report accurate validity immediately without ever flashing a red error before the user has
interacted with anything.
Form
Barbatos.Wpf.AquariusValee.Validation.Form - aggregates a set of Field<T>s, the WPF/C#
counterpart of vee-validate's useForm. Two ways to build one:
Schema mode: Form.FromModel
Reflects an existing ValidationAttribute-decorated model class - one Field<T> per public
settable property, [Display(Name = "...")] becomes each field's Label. Each field's value
stays live-synced back onto the model instance as it changes; Form.GetModel<TModel>() returns
that same instance, always up to date.
public sealed class SignupModel
{
[Required, EmailAddress]
public string Email { get; set; } = "";
[Required, MinLength(8)]
public string Password { get; set; } = "";
[Required, Compare(nameof(Password))]
public string ConfirmPassword { get; set; } = "";
}
var form = Form.FromModel(new SignupModel());
IValidatableObject on the model (DataAnnotations' own native cross-field mechanism) is honored
too - checked once per HandleSubmit/explicit ValidateAllAsync(), and any resulting message is
layered onto every field named in ValidationResult.MemberNames, on top of that field's own rule
errors.
CompareAttribute (and anything else that reflects into a sibling property) only works this
way - against a real model instance. Validator.TryValidateProperty resolves the "other
property" by reflecting ValidationContext.ObjectInstance, which only has something to find when
that instance is a genuine model object. See Cross-field validation for
the ad-hoc-mode equivalent.
Ad-hoc mode: Form.DefineField
No model class - build a form field by field. Each call also returns the strongly-typed
Field<T>, so a ViewModel keeps its own typed reference for use in code/XAML; there is no need
to subclass Form or look fields up by name in ordinary use.
public Form LoginForm { get; } = new();
public Field<string> Email { get; }
public LoginViewModel()
{
Email = LoginForm.DefineField(nameof(Email), "", new RequiredAttribute(), new EmailAddressAttribute());
}
Four overloads, matching Field<T>'s own constructors: no rules, params ValidationAttribute[],
params ValidationRule<T>[], params AsyncValidationRule<T>[].
FormMeta
Mirrors vee-validate's own aggregation exactly: Touched/Pending are true if any field is;
Valid requires every field to be valid (and no stray form-level error - see
IValidatableObject above); Dirty is true if any field is dirty. Every property is a plain
on-demand getter, computed fresh from the form's fields on every read - never cached, so a
synchronous read immediately after await-ing a validation pass is always accurate.
HandleSubmit
public IAsyncRelayCommand HandleSubmit(
Func<IReadOnlyDictionary<string, object?>, Task> onValid,
Action<IReadOnlyDictionary<string, object?>, IReadOnlyDictionary<string, IReadOnlyList<string>>>? onInvalid = null)
Returns a real ICommand (a CommunityToolkit.Mvvm.Input.AsyncRelayCommand), bindable directly
to Button.Command. Mirrors vee-validate's own handleSubmit stage order exactly:
- Touch every field, regardless of which one the user actually edited.
IsSubmitting = true,SubmitCount++.- Validate every field (plus
IValidatableObject, for aFromModel-built form). - Call
onValidonly if the whole form is valid; otherwiseonInvalidwith the current values/errors. IsSubmitting = false, in afinally.
CanExecute is gated only on !IsSubmitting, never on Meta.Valid. Clicking Submit on an
invalid, untouched form is exactly what's supposed to force every field's Touched to true and
surface its errors - that's the whole point of a submit attempt, matching vee-validate's own real
behavior.
Other Form members: SetFieldValue<T>, SetFieldError, ResetForm, GetValues, GetErrors,
ValidateAllAsync, Fields (IReadOnlyDictionary<string, IField>, for name-based lookup -
IField is the non-generic view every Field<T> also implements),
GetFirstInvalidField() (in DefineField/FromModel-reflection definition order).
Focusing the first invalid field on submit
Form.SubmitInvalid (event Action<IField>?) fires with GetFirstInvalidField()'s result
whenever a HandleSubmit-driven submit turns out invalid - Form itself has no WPF-control
dependency, so it cannot focus anything on its own. Valee.Form (see
XAML integration below) is what actually subscribes to this and moves
keyboard focus - so this behavior is entirely automatic wherever Valee.Form is set somewhere
in the visual tree, no extra code needed in the ViewModel or the View:
<StackPanel aqv:Valee.Form="{Binding LoginForm}">
<TextBox aqv:Valee.For="Email" />
...
</StackPanel>
A form with no Valee.Form anywhere in its tree simply doesn't get this behavior - subscribe to
Form.SubmitInvalid yourself (it is a plain, headless C# event) if you need the same behavior
without using Valee.Form.
Validation triggers
[Flags]
public enum ValidateTrigger { None = 0, OnChange = 1, OnBlur = 2, Default = OnChange | OnBlur }
vee-validate has four independent booleans (validateOnBlur/Change/Input/ModelUpdate)
because a DOM <input> genuinely fires two separate native events - input (every keystroke)
and change (on commit/blur) - that a WPF TextBox does not have as two distinct native events
in the same way. Porting four booleans would cargo-cult a distinction WPF doesn't make. Instead:
OnChangefires wheneverField.Valueactually changes (HandleChange), regardless of which WPF event caused it - a field doesn't need to know whether the XAML side choseUpdateSourceTrigger=PropertyChanged(live, per-keystroke) orLostFocus(commit-on-blur).OnBlurfires purely fromValee.Field's ownLostFocushook, independent of whether the value even changed.Default = OnChange | OnBlurmatches vee-validate's own<Field>-component default.
Set per field: Email.Triggers = ValidateTrigger.OnBlur;.
Cross-field validation
Two mechanisms, matching whichever Form mode you're in - they are not interchangeable, pick
the one that matches your form:
Schema mode (
Form.FromModel) -[Compare(nameof(Password))]on the model property,[PropertyRange(nameof(Min), nameof(Max))]for a dynamic bound (see below), orIValidatableObject.Validate(...)for anything a single attribute can't express. All three are DataAnnotations-native (the latter two ship with this package), and all work because validation reflects into the real, live model instance.Ad-hoc mode (
Form.DefineField) -Field<T>.DependsOn<TSource>(Field<TSource> source)plus a hand-written rule that reads the other field's.Valuedirectly:ConfirmPassword = LoginForm.DefineField(nameof(ConfirmPassword), "", new RequiredAttribute()); ConfirmPassword.AddRule(value => value == Password.Value ? null : "Passwords must match."); ConfirmPassword.DependsOn(Password);DependsOnre-validates the dependent field (using theValidatedOnlywrite mode - seeField) whenever the source field's value changes, so a sibling changing doesn't suddenly surface an error on a field the user hasn't touched yet. Built on Aquarius'sWatch.Onspecifically (notWatch.Effect) -Watch.Ononly reacts to the source's value changing, never toErrors/Metachanges a validation pass itself causes, which is what keeps two mutually-dependent fields from infinite-looping.DependsOnisn't limited to one dependency or to equality checks - call it once per source to validate against a dynamic bound driven by other fields, e.g. a price that must stay between a min and a max the user can also edit:Price = form.DefineField(nameof(Price), 50, value => value < MinPrice.Value ? $"Must be at least {MinPrice.Value}." : null, value => value > MaxPrice.Value ? $"Must be at most {MaxPrice.Value}." : null); Price.DependsOn(MinPrice); Price.DependsOn(MaxPrice);[Range(MinPrice, MaxPrice)]cannot express this at all - C# attribute constructor arguments must be compile-time constants - so for an ad-hoc field,DependsOnplus a closure like above is the only option. Changing eitherMinPriceorMaxPricere-validatesPriceagainst the current value of both, even thoughPriceitself wasn't touched. See the sample's "Custom rules" tab for a live demo.
The schema-mode equivalent: PropertyRangeAttribute and IDependsOnProperties
For a Form.FromModel field, the declarative equivalent is
PropertyRangeAttribute, which reflects two named sibling properties'
current values the same way [Compare] reflects one:
public int MinPrice { get; set; } = 10;
public int MaxPrice { get; set; } = 100;
[PropertyRange(nameof(MinPrice), nameof(MaxPrice))]
public int Price { get; set; } = 50;
No manual DependsOn call is needed here - PropertyRangeAttribute implements
IDependsOnProperties, and Form.FromModel scans every property's attributes for it after every
field already exists, calling Field<T>.DependsOn<TSource> automatically for each named
dependency. Any custom ValidationAttribute can opt into the same auto-wiring by implementing
IDependsOnProperties itself - it is not specific to PropertyRangeAttribute.
Two things this attribute-based form gives up, both because a ValidationAttribute is plain
reflected metadata with no dependency-injection access, unlike a hand-written rule closure:
- It only works in
Form.FromModelmode. It reflectsValidationContext.ObjectInstance- the real model - which an ad-hocField<T>never has (seeField.AddRule(ValidationAttribute)'s own remarks). - Its message cannot resolve an i18n key.
ErrorMessageis a plainstring.Format-style template ({0}= display name,{1}/{2}= the live min/max), not a localizer lookup - there is no service container reachable from inside an attribute. The sample's Showcase tab uses this attribute and explicitly does not translate this one message, contrasting with "Custom rules"' fully-localized ad-hoc version of the identical Price/MinPrice/MaxPrice idea.
Pick ad-hoc DependsOn when the message needs full i18n/DI; pick PropertyRangeAttribute when
the field already lives on a FromModel model and a plain-text message is acceptable.
Async rules and Meta.Pending
public Field<T> AddRule(AsyncValidationRule<T> rule);
// delegate Task<string?> AsyncValidationRule<in T>(T value, CancellationToken cancellationToken);
Meta.Pending is true while an async rule is in flight. Race-safety uses a per-field
CancellationTokenSource, cancelled and replaced on every new validation call - always forward
the token to whatever you await inside the delegate; that's what turns a superseded call into
genuinely cancelled work, not just a discarded result (an upgrade over vee-validate's own
"let the stale call finish, ignore its result" approach - real cancellation is idiomatic .NET and
wasn't available to vee-validate, since JS Promises aren't cancellable).
Username.AddRule(async (value, cancellationToken) =>
{
var available = await availabilityService.IsAvailableAsync(value, cancellationToken);
return available ? null : "That username is already taken.";
});
XAML integration
Valee.Field / Valee.PasswordField
<TextBox aqv:Valee.Field="{Binding Email}" />
<PasswordBox aqv:Valee.PasswordField="{Binding Password}" />
Valee.Field binds a control's natural value property (TextBox.Text,
ToggleButton.IsChecked, Selector.SelectedItem, RangeBase.Value) to an IField/Field<T>
and wires LostFocus to HandleBlur - something a plain Binding cannot express at all, since
WPF has no built-in "touched" concept. It also sets UpdateSourceTrigger=PropertyChanged -
worth knowing, since WPF's own default for TextBox.Text is LostFocus; without this attached
property, a plain {Binding Field.Value} would only push a value (and therefore only trigger
OnChange validation) on blur, not per keystroke.
Because Field<T> implements INotifyDataErrorInfo directly, this binding already gets WPF's
native red-border validation adorner for free - see
Already native: error display.
Valee.PasswordField is PasswordBox-specific: wires PasswordChanged/LostFocus but never
creates a real Binding on Password, mirroring Aquarius's own Directives.Model refusing to
bind it (Password is deliberately not a DependencyProperty, for security reasons). A direct
consequence: there's no BindingExpression for the native adorner to attach to here - show this
field's error with a plain bound TextBlock instead ({Binding Password.ErrorMessage}).
Valee.Form / Valee.For
Sugar over repeating Valee.Field="{Binding LoginForm.Fields[Email]}" on every control:
<StackPanel aqv:Valee.Form="{Binding LoginForm}">
<TextBox aqv:Valee.For="Email" />
<PasswordBox aqv:Valee.PasswordField="{Binding LoginForm.Fields[Password]}" />
</StackPanel>
Valee.For resolves the ambient Valee.Form via a one-shot ancestor walk at
FrameworkElement.Loaded, mirroring Barbatos.Wpf.AquariusRouter's own RouterView pattern.
The bound Form must already be non-null by the time the View loads - construct it
synchronously in the ViewModel's constructor, not populated later/asynchronously. There is no
retry if it's still null at that moment (the same documented constraint Router.Current
carries: "must be set before first navigation").
Setting Valee.Form also wires the automatic "focus the first invalid field on submit" behavior
Form itself implements INotifyDataErrorInfo (Form.HasErrors aggregates every field), and
aqv:Valee.Form="{Binding LoginForm}" is a real WPF Binding under the hood -
ValidatesOnNotifyDataErrors defaults to true for any Binding, not just ones on editing
controls. Left alone, that would make WPF's own native validation adorner (a red border) wrap the
whole panel the instant a single field anywhere in the form is invalid, not just the offending
control. Setting Valee.Form suppresses that adorner on its own element (an empty
Validation.ErrorTemplate) - Validation.HasError/GetErrors on that element are left intact for
anyone who wants them (e.g. a form-level "there are errors" banner), only the visual is
suppressed. Each individual field's own adorner (from Valee.Field/Valee.PasswordField, see
error display) is unaffected.
Valee.Mask - guiding what gets typed
A validation attribute gives a verdict after the fact. Valee.Mask shapes the value as it is
being typed - separators appear on their own, characters that cannot go in the next slot are
ignored, and the caret always sits where the next character will actually land:
<TextBox aqv:Valee.Field="{Binding Sku}" aqv:Valee.Mask=">AAA-0000" />
| Mask character | Meaning |
|---|---|
0 |
a required digit |
A |
a required letter |
* |
a required letter or digit |
> / < |
upper-case / lower-case everything that follows |
\ |
treat the next character as a literal (\0 is a literal zero) |
| anything else | a literal, inserted automatically once the slots before it are filled |
Typing abc1234 into >AAA-0000 produces A, AB, ABC- (the dash arrives by itself, caret
after it), ABC-1, … ABC-1234. A @ aimed at a letter slot, a letter aimed at a digit slot, or
anything past the end of the mask is swallowed before it can appear on screen. Backspacing over a
separator deletes through it rather than letting it reappear, and pasted text is filtered through
the mask instead of rejected. The formatted text is what reaches the bound Field<T>, so the
model sees "ABC-1234".
A mask guides; an attribute decides
Keep the real rule on the model - a mask is deliberately permissive (a half-typed "AB" is a fine
intermediate state, and the user can always tab away early), so pair it with
PatternAttribute or any
other attribute for the actual verdict:
[Pattern(@"[A-Z]{3}-\d{4}", Options = RegexOptions.IgnoreCase)]
public string Sku { get; set; } = "";
The two cannot be collapsed into one, and it is worth being precise about why, because "just use the regex to filter keystrokes" is the obvious first instinct:
- A regex is not positional. It cannot answer "what may I type at index 3?" -
[A-Z]{3}and[A-Z]+give no fixed index for the dash that follows. - Running the full pattern per keystroke rejects every prefix. Against
^[A-Z]{3}-\d{4}$, the string"A"does not match, nor"AB", nor"ABC"- so all eight keystrokes ofABC-1234would be blocked and the field could never be filled at all. - .NET has no partial-match API to fix that with (Java's
Matcher.hitEnd()has noSystem.Text.RegularExpressionsequivalent).
This is the same split WinForms' MaskedTextBox, imask.js and Cleave.js all make, for the same
reason.
When a mask is the wrong tool
A mask needs one fixed layout. Where the layout genuinely varies - a phone number, whose length and
grouping differ by country - do not force one; a hand-written PreviewTextInput/TextChanged pair
that accepts digits and formats loosely is the honest answer. The sample's
Views/Showcase/InputMask.cs keeps exactly one such field for that reason, and uses Valee.Mask
for every other.
Valee.Numeric - number-only boxes, and snapping back into range
<TextBox aqv:Valee.Field="{Binding Age}" aqv:Valee.Numeric="Integer" />
<TextBox aqv:Valee.Field="{Binding Price}" aqv:Valee.Numeric="Decimal" aqv:Valee.Minimum="0" />
Typing is filtered per keystroke (and paste with it): letters, punctuation, a second decimal
separator, a minus sign anywhere but the front - all dropped before they appear. Half-finished
input is deliberately still allowed, because -, . and -. are all legal states on the way to
a real number and rejecting them would make the field impossible to fill. A number's grammar is
simple enough to decide prefix-by-prefix, which is exactly why this can filter typing where
PatternAttribute cannot.
Bounds are optional and independent - set only a floor, only a ceiling, or neither:
| Declared | Behavior |
|---|---|
| neither | characters are filtered, nothing is ever rewritten |
Minimum only |
anything below it is raised; no upper limit |
Maximum only |
anything above it is lowered; no lower limit |
| both | value is kept inside the range |
A Minimum of zero or above also removes the minus key entirely - no value that could satisfy it
is negative.
Bounds come from [Range] on their own
If you don't declare them, they are taken from the bound field's
IField.Minimum/IField.Maximum, which Form.FromModel fills in from the property's own
[Range]. A model that already says:
[Range(13, 120, ErrorMessage = "Age must be between 13 and 120.")]
public int Age { get; set; } = 29;
needs nothing repeated in XAML - aqv:Valee.Numeric="Integer" alone will snap 150 down to
120. Valee.Minimum/Valee.Maximum override the discovered values when you do set them, which
is also how you bound a field that has no [Range] at all.
Two things this deliberately does not do
- It never clamps while typing. With a 10-100 range, snapping mid-keystroke would rewrite the
first
5of50into10, leaving the user with100. Correction waits forLostFocus, the way every numeric editor behaves. - It does not validate. Clamping silently rewrites what was typed, so keep
[Range](or an equivalent rule) on the model regardless: that is what reports a genuinely bad value, and what still runs when a value arrives from anywhere other than this text box.
Culture note. The accepted decimal separator is read from the element's
FrameworkElement.Language, because that is the culture WPF's own binding will use to parse the text back. Reading it fromCultureInfo.CurrentCultureinstead would let the box accept a character the binding then refuses, and the value would silently stop updating.Languagedefaults toen-USregardless of thread culture - set it if you want comma separators.
FieldTemplateSelector - auto-selecting a control per field
<Window.Resources>
<aqv:FieldTemplateSelector x:Key="FieldTemplateSelector" />
<DataTemplate x:Key="MultilineText">
<TextBox aqv:Valee.Field="{Binding}" AcceptsReturn="True" TextWrapping="Wrap" Height="60" />
</DataTemplate>
<DataTemplate x:Key="Password">
<PasswordBox aqv:Valee.PasswordField="{Binding}" />
</DataTemplate>
<DataTemplate x:Key="Default">
<TextBox aqv:Valee.Field="{Binding}" />
</DataTemplate>
</Window.Resources>
<ItemsControl ItemsSource="{Binding ProfileForm.Fields.Values}"
ItemTemplateSelector="{StaticResource FieldTemplateSelector}" />
Picks a DataTemplate resource keyed on IField.UIHint first (mirrors [UIHint("...")] on a
FromModel-reflected property), then IField.DataTypeHint's enum name (mirrors
[DataType(...)] - e.g. DataType.Password, DataType.MultilineText), then a resource literally
named "Default". Only the resource-key convention is prescribed - which real control each key
maps to is entirely up to your own DataTemplates, the same way ASP.NET Dynamic Data never
shipped opinions about what a "MultilineText" template should look like either. [Editable(false)]
becomes IField.IsReadOnly - a hint for your own templates to react to (e.g. bind IsEnabled);
Valee does not enforce it itself.
UIHint/DataTypeHint pick which control renders a field - a completely separate concern from
guidance text telling the user how to fill it in. For the latter, [Display(Prompt = "...")]
becomes IField.Prompt - a plain string, not rendered by Valee itself, for your own template to
show as a watermark, tooltip, or (as the sample does) a small helper line under the field:
[Phone]
[Display(Name = "Phone number", Prompt = "e.g. +1 555 123 4567")]
public string? PhoneNumber { get; set; }
<TextBlock Text="{Binding Prompt}" FontSize="11" Foreground="Gray" />
Already native: error display
A plain <TextBlock Text="{Binding Email.ErrorMessage}" /> already shows a field's current error
- no dedicated
ErrorMessagecontrol is provided, matching this repo's own established "patterns that are already native" precedent (see Aquarius's README). Likewise, a plain<TextBox Text="{Binding Email.Value}" />(noValee.Fieldat all) already gets the native red adorner for free viaField<T>'s ownINotifyDataErrorInfo-Valee.Field's only genuinely non-redundant job is the touched/trigger-timing wiring described above.
SafeMessageConverter - localization, without becoming dependent on a localizer
AquariusValee has no dependency on any localization library, and never requires one. A plain literal message is an ordinary, fully supported way to use it:
[Required(ErrorMessage = "Enter a valid email address.")]
[Display(Name = "Email address", Prompt = "you@example.com")]
public string Email { get; set; } = "";
Localizing is an application choice layered on top: store lookup keys in ErrorMessage/
[Display] and resolve them at display time with your localizer's converter. That introduces one
sharp edge worth knowing about, because it is silent: a typical localizing converter returns
null for a key it does not recognise, and null bound to TextBlock.Text renders as nothing at
all. One typo, one key nobody registered, or one message deliberately written as ordinary prose
becomes an invisible error message - strictly worse than an untranslated one.
SafeMessageConverter is the guard rail. Wrap your localizing converter in it (it is the XAML
content property, so just nest it) and anything untranslatable is shown as written instead of
disappearing:
<UserControl.Resources>
<aqv:SafeMessageConverter x:Key="Message">
<i18n:LocalizeConverter />
</aqv:SafeMessageConverter>
</UserControl.Resources>
...
<TextBlock Text="{Binding ErrorMessage, Converter={StaticResource Message}}" Foreground="Red" />
Inner is an ordinary IValueConverter, so this is not tied to any one localization package -
Barbatos.i18n, an IStringLocalizer-backed converter, ResX, or a hand-written one all work. An
inner converter that throws is treated the same as one that returned nothing. Leave Inner
unset and it is a pass-through, which is exactly right for an app that does not localize at all.
Refreshing messages after a language change
A message stored as a lookup key and resolved by a converter updates for free when the binding
re-reads. A rule that resolves its text eagerly - unavoidable when the message interpolates a
runtime value, e.g. $"Must be at least {MinPrice.Value}." - captured the old language at the
moment it ran, and no later culture switch re-runs it. Call
Form.RevalidateValidatedAsync()
after switching:
localizationCultureManager.SetCulture(newCulture);
await myForm.RevalidateValidatedAsync();
It re-runs only fields already Meta.Validated, so nothing sprouts an error on a field the user
has never touched. vee-validate has the same characteristic and documents the same remedy.
Rules
Barbatos.Wpf.AquariusValee.Rules - a small number of custom ValidationAttribute subclasses,
only for gaps confirmed (by direct comparison against vee-validate's real built-in rule library)
to have no System.ComponentModel.DataAnnotations equivalent:
| Attribute | Purpose |
|---|---|
AlphaAttribute |
Letters only. Unicode-aware via char.IsLetter by default (broader than vee-validate's own ~20 hand-picked locale tables, since .NET's Unicode category data already covers essentially every script at once) - set AsciiOnly to restrict to a-z/A-Z. |
AlphaNumericAttribute |
Letters and digits only. |
AlphaDashAttribute |
Letters, digits, _, and - only (slugs/usernames). |
AlphaSpacesAttribute |
Letters and whitespace only (a person's full name). |
OneOfAttribute / NotOneOfAttribute |
Value must (not) equal one of a fixed set of literals, using vee-validate's own loose, cross-type matching (1 and "1" match each other) - see the next section for when to reach for these instead of the BCL's own, stricter equivalent. |
DigitsAttribute |
An exact-length numeric string (a PIN/OTP code). |
NumericStringAttribute / IntegerStringAttribute |
"Does this raw string look like a number/integer" - for input not yet parsed into a numeric type. |
PatternAttribute |
Regex matching with a real RegexOptions property - see below. |
All of them treat a null/empty value as valid (pair with [Required] if emptiness itself should
fail) - matching the isEmpty-short-circuit convention vee-validate's own equivalent rules use.
PropertyRangeAttribute (documented under
Cross-field validation)
lives in this namespace too, but is a different shape - property-only and ValidationContext-aware.
PatternAttribute - regex, and why [RegularExpression] isn't always enough
The BCL's RegularExpressionAttribute covers the ordinary case and needs no extra package - use
it whenever a plain, case-sensitive pattern is all you need. It has one hard limitation, though,
confirmed by inspection: it exposes no RegexOptions property at all. IgnoreCase,
Multiline, CultureInvariant, IgnorePatternWhitespace and friends are reachable only by
embedding inline flags like (?i) into the pattern string - workable, but easy to typo and
invisible to anyone skimming the attribute.
[Pattern(@"[A-Z]{3}-\d{4}",
Options = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
ErrorMessage = "Use the format ABC-1234.")]
public string Sku { get; set; } = "";
Two more differences from the BCL attribute worth knowing:
- Anchored by default.
MustMatchEntireValuedefaults totrue, so[Pattern("[a-z]+")]rejectsabc123- no forgotten^...$trap. Set it tofalsefor a "contains a match" search. - A regex timeout fails the field instead of throwing.
MatchTimeoutInMilliseconds(2000 by default, the same bound the BCL attribute uses) means a catastrophically backtracking pattern/input pair degrades into an invalid field rather than an exception on the UI thread.
A full review of System.ComponentModel.DataAnnotations - what's native, what's custom, what doesn't apply
Every public type in the namespace was reviewed (not just the ones vee-validate happens to have an equivalent for) to decide whether it's a validation concern this package should surface at all. Three buckets:
Directly usable as-is - no Barbatos wrapper needed, just pass it to DefineField/put it on a
FromModel property: RequiredAttribute, MinLengthAttribute/MaxLengthAttribute,
RangeAttribute, RegularExpressionAttribute, StringLengthAttribute, CompareAttribute
(cross-field, FromModel mode only - see Cross-field validation),
EmailAddressAttribute, PhoneAttribute, UrlAttribute, CreditCardAttribute,
Base64StringAttribute (well-formed Base64 text), EnumDataTypeAttribute (a string/underlying
value matches one of a given enum's members - confirmed via new EnumDataTypeAttribute(typeof(DayOfWeek))),
CustomValidationAttribute (points at your own public static ValidationResult? Method(object value, ValidationContext context)
- a genuine middle ground between a full DataAnnotations attribute and a
ValidationRule<T>delegate, useful when you want the exact same check reusable as a real attribute elsewhere too), and, since .NET 8,AllowedValuesAttribute/DeniedValuesAttribute(strict-equalityone_of/not_one_of- confirmed empirically stricter than this package's ownOneOfAttribute/NotOneOfAttribute:1and"1"do not match there) andLengthAttribute(min and max in one attribute -new LengthAttribute(n, n)is the exact-length case, confirmed to already cover both strings and collections correctly, which is why this package ships noExactLengthAttributeof its own).
Two confirmed, non-obvious BCL gotchas worth knowing before you rely on any of the above:
UrlAttribute/EmailAddressAttribute/PhoneAttribute(and, by the sameDataTypeAttribute-derived family resemblance, presumably others in that family) treat anullvalue as valid (deferring emptiness to a separate[Required]) but treat an empty string as a genuine format failure - different fromRequiredAttributeitself, and different from the length-based attributes. Astringproperty with an optional[Url]/[EmailAddress]/[Phone]and a non-nullable""default will read as permanently invalid until the user types something - give it astring?property with anulldefault instead if the field is meant to be optional (seeProfileEditModel.Websitein the sample for a worked example).Validator.TryValidateValue/TryValidatePropertyspecial-caseRequiredAttributeto run first and short-circuit every other attribute the instant it fails (see Field's own remarks) -confirmed→CompareAttributealso has one documented caveat of its own:CompareAttributecompares typed values viaEquals, while vee-validate'sconfirmedcoerces both sides throughString()first.
A separate hint/metadata system, not validation - reflected for UIHint/DataType hints, not for pass/fail rules:
DisplayAttribute (→ Label), UIHintAttribute (→ IField.UIHint), DataTypeAttribute (→
IField.DataTypeHint), EditableAttribute (→ IField.IsReadOnly). DisplayFormatAttribute is
purely presentation (DataFormatString/NullDisplayText) with no validation role at all - not
reflected by this package; apply it with an IValueConverter in your own template if needed.
Deliberately not applicable to form validation at all - these describe an entity/ORM
(Entity Framework) or scaffolding (ASP.NET Dynamic Data) concern, not a value's validity, and
none of them are even ValidationAttribute subclasses (so Validator never looks at them
regardless): KeyAttribute, ConcurrencyCheckAttribute, TimestampAttribute,
AssociationAttribute, MetadataTypeAttribute, AssociatedMetadataTypeTypeDescriptionProvider,
ScaffoldColumnAttribute, DisplayColumnAttribute, FilterUIHintAttribute. Left alone rather
than forced into this package's scope.
Deliberately Out of Scope
- File-upload rules (vee-validate's
ext/mimes/size/image/dimensions) - no BCL or WPF-native equivalent; would needOpenFileDialog/FileInfo/BitmapDecoder-specific code, a meaningfully different problem from the rest of this package. - A dedicated field-array/repeating-section helper (vee-validate's
useFieldArray) - useObservableCollection<Form>(one realFormper row) instead. vee-validate's ownFieldArrayis complex specifically because it addresses fields by a string path that shifts identity on every insert/remove/reorder - a problem that doesn't exist here, since each row can just be a real C# object with real identity. - A
generateMessage-style global message hook -ValidationAttributealready hasErrorMessageResourceType/ErrorMessageResourceNamefor resx-based localization, and the BCL's own built-in attributes already localize their default text viaCurrentUICulturewith zero code. "Bulk-localize the whole app" is already solved beneath this package.
Ecosystem
Ships as a single package - Validation, Rules, and Xaml are all included; there is
nothing else to install beyond Aquarius itself.
Repository layout
src/Barbatos.Wpf.AquariusValee- the library.samples/Barbatos.Wpf.AquariusValee.Sample- a complete sample application exercising every feature area above, localized into English/Vietnamese viaBarbatos.i18n(see its ownWpfProgram.cs/App.xaml.csfor the DI + startup-culture wiring).tests/Barbatos.Wpf.AquariusValee.UnitTests- the unit test suite.
API Reference
Due to the extensive nature of the library's interfaces, classes, and properties, the full API Reference has been moved to a dedicated document modeled after Microsoft's official .NET documentation format.
👉 Read the Full API Reference 👈
Community
See the root README for maintainers, support, and license information - shared across every package in this repository.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0-windows7.0 is compatible. net9.0-windows was computed. net9.0-windows7.0 is compatible. net10.0-windows was computed. net10.0-windows7.0 is compatible. |
-
net10.0-windows7.0
- Barbatos.Wpf.Aquarius (>= 2.3.1)
- CommunityToolkit.Mvvm (>= 8.4.2)
-
net8.0-windows7.0
- Barbatos.Wpf.Aquarius (>= 2.3.1)
- CommunityToolkit.Mvvm (>= 8.4.2)
-
net9.0-windows7.0
- Barbatos.Wpf.Aquarius (>= 2.3.1)
- CommunityToolkit.Mvvm (>= 8.4.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.