diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f824bb7..d06f72f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,21 @@ +## Unreleased + +* Validation modes are now yours to write. `ValidationMode` is a class, and `ValidationMode.custom(name, fn)` turns a one-line rule into a mode the package treats like its own. + * The rule gets a `ValidationContext`: which event is happening (`valueChanged`, `unfocus` or `dependencyChanged`) and whether the user has ever left the field after editing it. + * It never sees the status, the errors or the value, so a rule cannot switch itself off while the user is fixing a mistake. + * An untouched field still validates nothing, whatever the rule says; `validate()` still ignores the mode; sync still runs before async. + * Set it on the form and it reaches every field and subform; set it on one field and that field keeps it. + * Modes are compared by name, so a mode built in `build()` is not a mode change; a built-in name throws. + * The docs have a page for it with a live demo, and the README and the skill cover it too. +* New built-in mode `ValidationMode.afterFirstUnfocus`: quiet while the user types, validates when the field is first left, and from then on validates every edit, so an error disappears the moment the value is fixed. +* Breaking: an exhaustive `switch` over `ValidationMode` needs a default case, and `ValidationMode.values` is gone, because with custom modes there is no finite list. + ## 0.2.4 * The skill is renamed to `advanced-forms-build-forms` to match the Agent Skills spec. Run `dart run skills@ get` again to get the new name. * The skill is split into a short `SKILL.md` and reference files, so it uses fewer tokens. * The docs recommend `dart run skills@ get` as the way to install the skill. * The package now works with `equatable` 3.0.0. - ## 0.2.3 * Fixed `validate()` returning a stale answer when called again before the first call had finished; every call now re-runs the sync validators and only an async check in flight is shared. diff --git a/README.md b/README.md index ee398dc1..b6534ccd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - **Validators that compose** — `filled`, `notEmpty`, `notNull`, `mustBeTrue`, `atLeastLength`, `notLongerThan`, `exactly` and numeric checks, combined with `&` and `|`, or any `E? Function(T)` you write yourself. - **Async validation** — debounced server-side checks ("is this email taken?") with a timeout, failure handling and cached answers. - **Cross-field validation** — re-run a validator when the fields it depends on change, or derive one field's value from another. -- **Validation modes** — validate on submit, on every keystroke, or on unfocus. Set once, applied to the whole form. +- **Validation modes** — validate on submit, on every keystroke, on unfocus, or on unfocus first and every keystroke after. Set once, applied to the whole form. When none of them fits, write your own rule in one line with `ValidationMode.custom`. - **Subforms** — attach and detach nested form controllers; their fields join the parent's validate, reset, read-only and error handling. - **Form-level state** — `canSubmit`, `wasModified`, `validating` and `validationErrors`, ready to bind to a submit button. - **Read-only fields and server-side errors** — freeze a value, or push an error in from an API response. @@ -228,6 +228,7 @@ Three rules cover every case: | `manual` (default) | Nothing validates until submit. Editing a field still clears the error that described its old value | | `onUserInteraction` | Every keystroke validates the field being edited; the async check waits out its debounce | | `onUnfocus` | Leaving a field the user edited validates it. Tabbing through it, or leaving it unchanged, costs nothing | +| `afterFirstUnfocus` | Quiet while the user types, until the field is first left after an edit. From then on every edit validates it, so an error disappears the moment the value is fixed | ```dart final form = AdvancedFormController( @@ -238,7 +239,27 @@ final form = AdvancedFormController( form.email.setValidationMode(ValidationMode.onUserInteraction); ``` -`onUnfocus` needs the widget to bind the field's `focusNode`, or to call `field.handleUnfocus()` itself — which is what a picker or a dropdown does. +`onUnfocus` and `afterFirstUnfocus` need the widget to bind the field's `focusNode`, or to call `field.handleUnfocus()` itself — which is what a picker or a dropdown does. + +### Your own validation mode + +When none of the four modes says what a form needs, write the rule yourself. `ValidationMode.custom(name, fn)` makes a mode the package treats like a built-in one: set it on the form and it reaches every field and subform. + +```dart +// Re-check only when another field changes, never on this field's own edits. +final followsDependency = ValidationMode.custom( + 'followsDependency', + (c) => c.event == ValidationEvent.dependencyChanged, +); + +repeatPassword + ..setValidationMode(followsDependency) + ..subscribeToFields([password]); +``` + +The rule gets a `ValidationContext` with two fields: `event` (`valueChanged`, `unfocus` or `dependencyChanged`) and `everUnfocused` (the user has left the field after editing it, at least once). It never sees the status or the errors, because every edit clears them and a rule reading them would stop validating as the user started fixing the value. The three rules above still hold: an untouched field validates nothing whatever the rule says, `validate()` ignores the mode, and a round runs sync before async. + +Modes are compared by name, so a name must be unique across your app; a built-in name throws. The [docs](https://advanced-forms.leancode.co/docs/validation/custom-modes) have more examples and how to test a rule. The field makes and owns that `focusNode`. Pass `focusNode:` to the constructor to bind one you already own instead — the field listens to it but never disposes it. diff --git a/docs/faq.mdx b/docs/faq.mdx index f01e5336..51a422a7 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -25,8 +25,10 @@ validate-on-submit button on `canSubmit` at all — it turns `false` while a che The default mode is `manual`: nothing validates until the first `validate()`. That is deliberate — a form should not shout before the user has done anything. For live feedback give the form `ValidationMode.onUserInteraction`; for -feedback when a field is left, `ValidationMode.onUnfocus` — which also needs the widget to bind `field.focusNode`. And -in every mode a field the user has never edited stays quiet on its own. See [Validation modes](./validation/modes.mdx). +feedback when a field is left, `ValidationMode.onUnfocus` — which also needs the widget to bind `field.focusNode`; for +the common "quiet at first, live once the user has left the field" pattern, `ValidationMode.afterFirstUnfocus`; for +anything else, `ValidationMode.custom`. And in every mode, your own included, a field the user has never edited stays +quiet on its own. See [Validation modes](./validation/modes.mdx). diff --git a/docs/first-form.mdx b/docs/first-form.mdx index 95de7295..eea01fd0 100644 --- a/docs/first-form.mdx +++ b/docs/first-form.mdx @@ -163,6 +163,6 @@ value. For live feedback from the first keystroke, or a check when a field loses - + diff --git a/docs/focus.mdx b/docs/focus.mdx index b1ac3f49..5e429e7b 100644 --- a/docs/focus.mdx +++ b/docs/focus.mdx @@ -22,8 +22,8 @@ TextFormField( ``` Binding the node does two things: the field learns when it loses focus, which is what -[`ValidationMode.onUnfocus`](./validation/modes.mdx) reacts to, and your code can move focus by talking to the field -rather than to the widget tree. +[`ValidationMode.onUnfocus` and `afterFirstUnfocus`](./validation/modes.mdx) react to, and your code can move focus +through the field rather than through the widget tree. ## Jump to the first invalid field @@ -164,6 +164,6 @@ it is optional. ## Next - + diff --git a/docs/index.mdx b/docs/index.mdx index f743bd1a..772ac345 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -19,7 +19,7 @@ state-management package the rest of your app uses. - + ## What you get diff --git a/docs/internals/index.mdx b/docs/internals/index.mdx index b10a6067..ce13ffdf 100644 --- a/docs/internals/index.mdx +++ b/docs/internals/index.mdx @@ -33,27 +33,29 @@ codes is never used for the throw path. ## The gate reads no field status -The whole trigger behaviour is one function of the mode, the event, and whether the user has edited the field: +The whole trigger behavior is one function of the mode, the event, and two bits the user's actions set: whether they +have edited the field, and whether they have left it since: ```dart -bool validatesOn( - ValidationEvent event, { - required ValidationMode mode, - required bool hasInteracted, -}) => - hasInteracted && - switch (mode) { - ValidationMode.manual => false, - ValidationMode.onUserInteraction => event != ValidationEvent.unfocus, - ValidationMode.onUnfocus => event != ValidationEvent.valueChanged, - }; +// In the field, before any mode is asked: +bool _shouldValidateOn(ValidationEvent event) => + _hasInteracted && + _value.validationMode.shouldValidate( + ValidationContext(event: event, everUnfocused: _everUnfocused), + ); ``` +The four built-in modes are `ValidationMode` constants wrapping one function each; `ValidationMode.custom` wraps +yours. The untouched-field rule sits in front of all of them, in the field, so no mode can reach a field the user has +not edited. + It reads no field *status* on purpose. A debounced round overwrites the status with `pending` and the write path clears both error slots, so any gate that looked at "does this field currently have an error" would disarm itself exactly as -the user started fixing the value. +the user started fixing the value. The two bits are not status: an event sets each one, and only `reset()` clears +them. That is what lets `afterFirstUnfocus` switch from the `onUnfocus` row to the `onUserInteraction` row without a +latch that a round could erase. -## Why three modes and not Flutter's five +## Why not Flutter's five modes An earlier design took Flutter's whole `AutovalidateMode`. Cutting to three deleted, in one move: a latch flipped by settling checks, a gate that read `status`, a sweep on registration and on mode change, escalation at submit, and a @@ -61,6 +63,9 @@ live parent link with its own lifecycle. Escalation was what forced inheritance what produced the bug where late-attached subforms missed the push. Deleting escalation deleted the bug's cause rather than its symptom. +`afterFirstUnfocus` brings none of that back: the mode never changes by itself, and the bit it reads is set by an +unfocus, not derived from a verdict. + Mode and the enabled flag reach children **by broadcast**, never through a back-reference to the parent. "Validation disabled" is expressed as broadcasting an effective mode of `manual`, so there is one mechanism instead of two. A form with its own mode stops following its parent; a field's `setValidationMode` is a one-way opt-out. diff --git a/docs/validation/configuring.mdx b/docs/validation/configuring.mdx index 957cf397..84209d20 100644 --- a/docs/validation/configuring.mdx +++ b/docs/validation/configuring.mdx @@ -41,7 +41,7 @@ A subform behaves the same way: a `validationMode` given to its constructor, or makes a wizard step "live" after the user has tried to leave it while the steps ahead stay quiet — see [Wizards](../subforms/wizard.mdx). -## `onUnfocus` needs a focus node +## `onUnfocus` and `afterFirstUnfocus` need a focus node The field only learns that focus left it through its `focusNode`. Bind it to the widget, or — for a picker that manages focus itself — call `field.handleUnfocus()` when the interaction ends: diff --git a/docs/validation/custom-modes.mdx b/docs/validation/custom-modes.mdx new file mode 100644 index 00000000..4c8f9d4d --- /dev/null +++ b/docs/validation/custom-modes.mdx @@ -0,0 +1,253 @@ +--- +title: Your own validation mode +description: When none of the four modes says what your form needs, write the rule yourself with ValidationMode.custom. What the rule sees, what it never sees, and what still holds. +--- + +{/* +AI-Provenance: + model: Claude Fable 5.1 + harness: Claude Code +*/} + +The four modes cover most forms: validate on submit, on every keystroke, when a field is left, or live once it was +first left. When a form needs a rule none of them states, `ValidationMode.custom` lets you write that rule as a +function, and the package treats it exactly like a built-in mode: set it on the form, it reaches every field and +subform; set it on one field, that field keeps it. + +```dart +// Re-check a field on its own edits only, never because another field changed. +final onOwnEdits = ValidationMode.custom( + 'onOwnEdits', + (c) => c.event == ValidationEvent.valueChanged, +); + +final form = AdvancedFormController(validationMode: onOwnEdits); +``` + +Reach for the built-in modes first. A custom mode is for the case you can state in one sentence and no mode covers, +not for tuning a mode that almost fits. + +## What the rule sees + +The function gets a `ValidationContext` and answers one question: should the field validate itself right now? + +| Field | Meaning | +| --- | --- | +| `event` | What is happening: `valueChanged` (the user edited the value), `unfocus` (focus left the field) or `dependencyChanged` (a field this one depends on changed) | +| `everUnfocused` | The user has left this field after editing it, at least once. `reset()` clears it | + +That is the whole input. The context carries no status, no errors and no value, on purpose. Every edit clears the +errors and a debounced async check sets the status to `pending`, so a rule that asked "does this field have an error" +would stop validating at the exact moment the user started fixing the value. Do not work around this by capturing the +field in the closure and reading its state from there: it is the same trap with extra steps. + +The four modes are written with the same two inputs. `afterFirstUnfocus`, for example, is +`c.everUnfocused ? c.event != unfocus : c.event != valueChanged`. + +## What still holds + +A custom mode changes which events validate a field. It changes nothing else: + +- **An untouched field validates nothing on its own.** The field checks that before it asks any mode, so a rule that + always answers `true` still cannot reach a field the user has not edited. `validate()` is what checks those. +- **`validate()` ignores the mode** and never changes it, custom or not. +- **A round runs the sync validator first**, and the async validator only if sync passed. A round started by an edit + waits out the debounce; one started by an unfocus runs at once. +- **The mode reaches the whole tree.** Set on the form, it is broadcast to every field and subform, including ones + registered or attached later. A field or subform with its own mode keeps it, as with the built-in modes (see + [Configuring the mode](./configuring.mdx)). +- **Switching validation off** still beats every mode: `setValidationEnabled(false)` makes the tree behave as `manual`. + +## The name + +Modes are compared by name. Two `custom` modes with the same name are the same mode, so one created in `build()` is +not a mode change and leaves a round in flight alone. The other way round, two different rules under one name are +indistinguishable to the package, so a name must be unique across your app. A built-in name (`manual`, +`onUserInteraction`, `onUnfocus`, `afterFirstUnfocus`) throws. + +`toString()` prints `ValidationMode.`, which is what you see in a test failure. + +## Examples + +Re-check a field only when another field changes, never on its own edits. A "repeat password" field that should go +red when the password above it changes, but stay quiet while it is being typed: + +```dart +final followsDependency = ValidationMode.custom( + 'followsDependency', + (c) => c.event == ValidationEvent.dependencyChanged, +); + +repeatPassword + ..setValidationMode(followsDependency) + ..subscribeToFields([password]); +``` + +Validate on leaving the field, but ignore dependency changes until the user comes back: + +```dart +final onUnfocusOnly = ValidationMode.custom( + 'onUnfocusOnly', + (c) => c.event == ValidationEvent.unfocus, +); +``` + +Re-check on every keystroke, but never because another field changed. A username field with an async availability +check that should not hit the server again just because the user picked a different country: + +```dart +final onOwnEdits = ValidationMode.custom( + 'onOwnEdits', + (c) => c.event == ValidationEvent.valueChanged, +); + +username + ..setValidationMode(onOwnEdits) + ..subscribeToFields([country]); +``` + +## Try it + +Three rules on the Repeat password field; Password stays on the form's default. Type in both fields, then change +Password and watch what each rule does with the mismatch. + + + +```dart title="password_form_controller.dart" +class PasswordFormController extends AdvancedFormController { + PasswordFormController() { + registerFields([password, repeatPassword]); + repeatPassword.subscribeToFields([password]); + } + + final password = AdvancedTextFieldController( + validator: atLeastLength(4, 'At least 4 characters'), + ); + + late final repeatPassword = AdvancedTextFieldController( + validator: (value) => + value == password.fieldValue ? null : 'Passwords do not match', + ); +} +``` + +```dart title="custom_modes_demo.dart" +// Re-check only when Password changes, never on this field's own edits. +final followsDependency = ValidationMode.custom( + 'followsDependency', + (c) => c.event == ValidationEvent.dependencyChanged, +); + +// Re-check on every keystroke, never because Password changed. +final onOwnEdits = ValidationMode.custom( + 'onOwnEdits', + (c) => c.event == ValidationEvent.valueChanged, +); + +// Only leaving the field validates it. +final onUnfocusOnly = ValidationMode.custom( + 'onUnfocusOnly', + (c) => c.event == ValidationEvent.unfocus, +); + +class CustomModesDemo extends StatefulWidget { + const CustomModesDemo({super.key}); + + @override + State createState() => _CustomModesDemoState(); +} + +class _CustomModesDemoState extends State { + final _form = PasswordFormController(); + var _rule = followsDependency; + + @override + void initState() { + super.initState(); + _form.repeatPassword.setValidationMode(_rule); + } + + @override + void dispose() { + _form.dispose(); + super.dispose(); + } + + void _setRule(ValidationMode rule) { + setState(() => _rule = rule); + // The rule is a mode like any other; this field keeps its own. + _form.repeatPassword.setValidationMode(rule); + } + + Future _submit() async { + final ok = await _form.validate(); + if (mounted) { + ExampleLog.of(context).add(ok ? 'Saved.' : 'Not saved: invalid.'); + } + } + + @override + Widget build(BuildContext context) { + final hint = switch (_rule.name) { + 'followsDependency' => + 'Repeat password re-checks only when Password changes. Its own ' + 'edits stay quiet until Save.', + 'onOwnEdits' => + 'Every keystroke in Repeat password re-checks it. Changing Password ' + 'does not.', + _ => 'Leaving Repeat password validates it. Changing Password does not.', + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DocsModePicker( + title: 'Select your validation mode', + options: [followsDependency, onOwnEdits, onUnfocusOnly], + selected: _rule, + label: (rule) => rule.name, + onSelected: _setRule, + ), + const SizedBox(height: 8), + DocsHint(hint), + DocsTextField(field: _form.password, label: 'Password'), + DocsTextField(field: _form.repeatPassword, label: 'Repeat password'), + DocsActions( + children: [ + DocsSubmitButton(form: _form, label: 'Save', onPressed: _submit), + TextButton(onPressed: _form.resetAll, child: const Text('Reset')), + ], + ), + ], + ); + } +} +``` + + + +## Testing a rule + +The rule is a plain function, so test it without a form: + +```dart +test('onOwnEdits ignores dependency changes', () { + const context = ValidationContext( + event: ValidationEvent.dependencyChanged, + everUnfocused: true, + ); + expect(onOwnEdits.shouldValidate(context), isFalse); +}); +``` + +To test it through a field, edit the field first (`setValue`), then drive the event: `setValue` again for +`valueChanged`, `handleUnfocus()` for `unfocus`, `revalidateSync()` for `dependencyChanged`. See +[Testing](../testing.mdx). + +## Next + + + + + + diff --git a/docs/validation/meta.json b/docs/validation/meta.json index c166f4e7..a9f6bb3a 100644 --- a/docs/validation/meta.json +++ b/docs/validation/meta.json @@ -4,6 +4,7 @@ "pages": [ "modes", "configuring", + "custom-modes", "validate", "validators", "custom-validators", diff --git a/docs/validation/modes.mdx b/docs/validation/modes.mdx index ed0226bc..8ea14d62 100644 --- a/docs/validation/modes.mdx +++ b/docs/validation/modes.mdx @@ -1,6 +1,6 @@ --- -title: Validation modes -description: Three rules and one table decide when a field validates itself — on submit, on every keystroke, or when it loses focus. +title: Available modes +description: The four modes that ship with the package, and the three rules and one table they come from — validate on submit, on every keystroke, when a field is left, or live once it was first left. --- {/* @@ -20,13 +20,14 @@ A validator is a function: value in, error out, `null` means valid. *When* it ru `validate()` obeys none of them: it ignores the mode, never changes it, and runs every field — touched or not. -## The three modes +## The four modes | `ValidationMode` | What the user sees | | --- | --- | | `manual` (default) | Nothing validates until `validate()` is called — usually on submit. Editing a field still clears the error that described its old value | | `onUserInteraction` | Every edit validates the field being edited; the async check waits out its debounce | | `onUnfocus` | Leaving a field the user edited validates it. Tabbing through an untouched field costs nothing; a debounced async check is flushed on the way out | +| `afterFirstUnfocus` | Quiet while the user types, until the field is first left after an edit. From then on every edit validates it, so an error disappears the moment the value is fixed | The gate, in full — the row is the mode, the column is the event: @@ -35,10 +36,24 @@ The gate, in full — the row is the mode, the column is the event: | `manual` | — | — | — | | `onUserInteraction` | validate | — (the edit already did) | sync only | | `onUnfocus` | — (still typing) | validate | sync only | +| `afterFirstUnfocus`, not yet left | — (still typing) | validate | sync only | +| `afterFirstUnfocus`, left once | validate | — (the edit already did) | sync only | + +`afterFirstUnfocus` is the `onUnfocus` row until the user first leaves the field after editing it, and the +`onUserInteraction` row from then on. Only that unfocus flips it: a `validate()` from a submit button does not, and +`reset()` flips it back. A field the user tabbed through without editing has not been "left" in this sense, so it still +waits. Like `onUnfocus`, it needs the widget to bind `field.focusNode` (see [Configuring the mode](./configuring.mdx)). "A dependency changed" is what [`subscribeToFields`](./cross-field.mdx) and `validateAll` produce; it re-runs the sync validator only, because this field's own value did not change and its async verdict still stands. +## A mode of your own + +The four modes are constants of `ValidationMode`, and `ValidationMode.custom(name, fn)` makes one from your own rule. +The rule sees the same two inputs the built-in modes are written with: which event is happening and whether the user +has ever left the field after editing it. Everything else on this page still holds for it, the untouched-field rule +included. [Your own validation mode](./custom-modes.mdx) has the whole story. + ## Try it Switch the mode while you type. The form is the same; only the trigger changes. @@ -107,19 +122,26 @@ class _ValidationModesDemoState extends State { ValidationMode.onUnfocus => 'Leaving a field you edited validates it. Tabbing through an ' 'untouched field costs nothing.', + ValidationMode.afterFirstUnfocus => + 'Quiet while you type. Leaving a field you edited validates it, ' + 'and from then on every keystroke does.', + _ => 'A mode of your own.', }; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SegmentedButton( - showSelectedIcon: false, - segments: [ - for (final mode in ValidationMode.values) - ButtonSegment(value: mode, label: Text(mode.name)), + DocsModePicker( + title: 'Select your validation mode', + options: const [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, ], - selected: {_mode}, - onSelectionChanged: (selection) => _setMode(selection.first), + selected: _mode, + label: (mode) => mode.name, + onSelected: _setMode, ), const SizedBox(height: 8), DocsHint(hint), @@ -146,6 +168,7 @@ class _ValidationModesDemoState extends State { ## Next + diff --git a/docs_app/content/landing/modes.mdx b/docs_app/content/landing/modes.mdx index 35a241f1..e6781542 100644 --- a/docs_app/content/landing/modes.mdx +++ b/docs_app/content/landing/modes.mdx @@ -68,19 +68,26 @@ class _ValidationModesDemoState extends State { ValidationMode.onUnfocus => 'Leaving a field you edited validates it. Tabbing through an ' 'untouched field costs nothing.', + ValidationMode.afterFirstUnfocus => + 'Quiet while you type. Leaving a field you edited validates it, ' + 'and from then on every keystroke does.', + _ => 'A mode of your own.', }; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SegmentedButton( - showSelectedIcon: false, - segments: [ - for (final mode in ValidationMode.values) - ButtonSegment(value: mode, label: Text(mode.name)), + DocsModePicker( + title: 'Select your validation mode', + options: const [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, ], - selected: {_mode}, - onSelectionChanged: (selection) => _setMode(selection.first), + selected: _mode, + label: (mode) => mode.name, + onSelected: _setMode, ), const SizedBox(height: 8), DocsHint(hint), diff --git a/docs_app/flutter/lib/support/fields.dart b/docs_app/flutter/lib/support/fields.dart index ff9344cb..49649e54 100644 --- a/docs_app/flutter/lib/support/fields.dart +++ b/docs_app/flutter/lib/support/fields.dart @@ -609,3 +609,61 @@ class _Spinner extends StatelessWidget { ); } } + +/// A titled row of single-choice pills, wrapping onto more rows as needed. +/// The selected pill is solid accent with ink text, like the primary button. +class DocsModePicker extends StatelessWidget { + const DocsModePicker({ + super.key, + required this.title, + required this.options, + required this.selected, + required this.label, + required this.onSelected, + }); + + final String title; + final List options; + final T selected; + final String Function(T option) label; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final option in options) + ChoiceChip( + label: Text(label(option)), + showCheckmark: false, + selected: option == selected, + selectedColor: scheme.primary, + labelStyle: TextStyle( + color: option == selected + ? scheme.onPrimary + : scheme.onSurface, + fontWeight: FontWeight.w600, + ), + onSelected: (_) => onSelected(option), + ), + ], + ), + ], + ); + } +} diff --git a/docs_app/lib/flutter-examples/manifest.generated.ts b/docs_app/lib/flutter-examples/manifest.generated.ts index 5e097873..5e5519d5 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -36,9 +36,10 @@ export const flutterExamples: Record = { "833e51744528": { preview: "OrderTotalForm", files: 2, docs: ["docs/validation/relations.mdx"], helpers: ["DocsTextField"] }, "8c0ab0393167": { preview: "SliderInputDemo", files: 2, docs: ["docs/rendering/scalar.mdx"], helpers: ["DocsActions"] }, "9e1503fddf0c": { preview: "PasswordForm", files: 2, docs: ["docs/validation/cross-field.mdx"], helpers: ["DocsTextField"] }, + "ba54e62caa0f": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsModePicker","DocsSubmitButton","DocsTextField"] }, + "ba93cc55fa17": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsModePicker","DocsSubmitButton","DocsTextField"] }, "c513edd2c993": { preview: "PasswordRulesForm", files: 2, docs: ["docs/validation/custom-validators.mdx"], helpers: ["DocsTextField"] }, "e2356ccef99b": { preview: "GuestListForm", files: 2, docs: ["docs/subforms/index.mdx"], helpers: ["DocsActions","DocsSubmitButton","DocsTextField"] }, "f21af60e91f0": { preview: "BooleanInputDemo", files: 3, docs: ["docs/rendering/boolean.mdx"], helpers: ["DocsActions"] }, - "f295a159666b": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "f733c75e06e6": { preview: "PrefillDemo", files: 2, docs: ["docs/lifecycle/prefilled-forms.mdx"], helpers: ["DocsActions","DocsFormStatus","DocsTextField"] }, } diff --git a/lib/advanced_forms.dart b/lib/advanced_forms.dart index 97982196..5865a7b4 100644 --- a/lib/advanced_forms.dart +++ b/lib/advanced_forms.dart @@ -14,5 +14,6 @@ export 'src/field/advanced_text_field_controller.dart'; export 'src/field/builder/advanced_field_builder.dart'; export 'src/form/advanced_form_controller.dart'; export 'src/form/advanced_form_state.dart'; -export 'src/validation_mode.dart' show ValidationMode; +export 'src/validation_mode.dart' + show ValidationContext, ValidationEvent, ValidationMode; export 'src/validators/validators.dart'; diff --git a/lib/src/field/advanced_field_controller.dart b/lib/src/field/advanced_field_controller.dart index 746672c4..0ae131cf 100644 --- a/lib/src/field/advanced_field_controller.dart +++ b/lib/src/field/advanced_field_controller.dart @@ -103,6 +103,11 @@ class AdvancedFieldController // While false, the field skips validation triggered by its mode. bool _hasInteracted = false; + // Set when the user first leaves the field after editing it. Only reset() + // clears it. Tracked in every mode, so a later switch to + // afterFirstUnfocus already knows which fields the user has left. + bool _everUnfocused = false; + // null: follow the form's mode. Non-null: this field manages its own. ValidationMode? _ownMode; @@ -170,16 +175,23 @@ class AdvancedFieldController } /// Called when focus moves away from the field. This is what - /// [ValidationMode.onUnfocus] reacts to. With [focusNode] bound, focus loss - /// calls this for you; pickers and dropdowns must call it themselves. Runs pending debounce immediately and + /// [ValidationMode.onUnfocus] reacts to, and what switches + /// [ValidationMode.afterFirstUnfocus] to validating every edit. With + /// [focusNode] bound, focus loss calls this for you; pickers and dropdowns + /// must call it themselves. Runs pending debounce immediately and /// validates read-only fields, same as [validate]. @override Future handleUnfocus() async { // User left the field — finish any debounced validation now. - // Only onUserInteraction mode debounces validation. + // Only a round an edit started is debounced. _rounds.flushDebounce(); - if (!_shouldValidateOn(ValidationEvent.unfocus)) { + // Decided before the bit flips, so the first unfocus still validates. + final validates = _shouldValidateOn(ValidationEvent.unfocus); + if (_hasInteracted) { + _everUnfocused = true; + } + if (!validates) { return; } @@ -300,10 +312,12 @@ class AdvancedFieldController /// the verdict and [lastFailure]. Keeps [AdvancedFieldState.validationMode] /// and [AdvancedFieldState.readOnly]. /// - /// The field counts as untouched again. + /// The field counts as untouched again, and [ValidationMode.afterFirstUnfocus] + /// waits for a new first unfocus. void reset() { _rounds.invalidate(); _hasInteracted = false; + _everUnfocused = false; _clearTo(_initialValue); } @@ -385,10 +399,12 @@ class AdvancedFieldController super.dispose(); } - bool _shouldValidateOn(ValidationEvent event) => validatesOn( - event, - mode: _value.validationMode, - hasInteracted: _hasInteracted, + // Checked before the mode, so an untouched field stays quiet whatever the + // mode would say. This is the one place the rule lives. + bool _shouldValidateOn(ValidationEvent event) => + _hasInteracted && + _value.validationMode.shouldValidate( + ValidationContext(event: event, everUnfocused: _everUnfocused), ); // Publishes [value] with both errors cleared and the given status. diff --git a/lib/src/validation_mode.dart b/lib/src/validation_mode.dart index 6a854b6c..a0f782a8 100644 --- a/lib/src/validation_mode.dart +++ b/lib/src/validation_mode.dart @@ -1,55 +1,122 @@ import 'package:meta/meta.dart'; -/// When a field validates itself, with nobody calling `validate()`. -/// -/// Set it on the `AdvancedFormController` and it reaches every field and -/// subform. `validate()` neither consults nor changes it, and in every mode a -/// field the user has never edited validates nothing on its own. -enum ValidationMode { - /// Nothing validates until `validate()` is called. The default. - manual, - - /// Every edit validates the field, the async check after its debounce. - onUserInteraction, - - /// Leaving an edited field validates it; an unchanged value reuses its last - /// answer. Requires a widget to bind the field's `focusNode`, or a call to - /// `handleUnfocus()` — nothing validates in this mode otherwise. - onUnfocus, -} - -@internal +/// What happened to a field, as its validation mode sees it. enum ValidationEvent { + /// The user edited the value. valueChanged, + + /// Focus left the field. unfocus, /// A field this one depends on changed, so only the sync validator is owed. dependencyChanged, } -/// The one place that decides whether a field validates itself. +/// What a custom mode gets to see. +/// +/// No status and no errors, on purpose: every edit clears the errors and a +/// debounced round sets the status to `pending`, so a mode reading either +/// would disarm itself as the user started fixing the value. +@immutable +final class ValidationContext { + /// Describes [event] on a field the user has left before, or not. + const ValidationContext({required this.event, required this.everUnfocused}); + + /// What is happening now. + final ValidationEvent event; + + /// The user has left the field after editing it, at least once. Only + /// `reset()` clears it. + final bool everUnfocused; +} + +/// When a field validates itself, with nobody calling `validate()`. +/// +/// Set it on the `AdvancedFormController` and it reaches every field and +/// subform. In every mode, your own included, a field the user has never +/// edited validates nothing on its own, and `validate()` neither consults nor +/// changes the mode. /// /// | mode | valueChanged | unfocus | dependencyChanged | /// |---|---|---|---| /// | `manual` | — | — | — | /// | `onUserInteraction` | validate | — | sync only | /// | `onUnfocus` | — | validate | sync only | -/// -/// Reads no field status on purpose: a debounced round overwrites the status -/// with `pending` and the write path clears both errors, so a status check -/// would disarm itself as the user started fixing the value. -@internal -bool validatesOn( - ValidationEvent event, { - required ValidationMode mode, - required bool hasInteracted, -}) => - // Checked before the mode, so an untouched field stays quiet in all three. - hasInteracted && - switch (mode) { - ValidationMode.manual => false, - // The edit already validated, so unfocus has nothing left to run. - ValidationMode.onUserInteraction => event != ValidationEvent.unfocus, - // The value is still being typed, so an edit waits for the unfocus. - ValidationMode.onUnfocus => event != ValidationEvent.valueChanged, - }; +/// | `afterFirstUnfocus`, not yet left | — | validate | sync only | +/// | `afterFirstUnfocus`, left once | validate | — | sync only | +@immutable +final class ValidationMode { + const ValidationMode._(this.name, this._shouldValidate); + + /// A mode of your own. A built-in [name] throws. + factory ValidationMode.custom( + String name, + bool Function(ValidationContext context) shouldValidate, + ) { + if (_builtIn.any((mode) => mode.name == name)) { + throw ArgumentError.value(name, 'name', 'is a built-in mode'); + } + return ValidationMode._(name, shouldValidate); + } + + /// Nothing validates until `validate()` is called. The default. + static const manual = ValidationMode._('manual', _manual); + + /// Every edit validates the field, the async check after its debounce. + static const onUserInteraction = + ValidationMode._('onUserInteraction', _onUserInteraction); + + /// Leaving an edited field validates it; an unchanged value reuses its last + /// answer. Requires a widget to bind the field's `focusNode`, or a call to + /// `handleUnfocus()` — nothing validates in this mode otherwise. + static const onUnfocus = ValidationMode._('onUnfocus', _onUnfocus); + + /// Quiet while the user types, like [onUnfocus], until the field first + /// loses focus after an edit; from then on every edit validates it, like + /// [onUserInteraction]. Needs the same `focusNode` binding or + /// `handleUnfocus()` call as [onUnfocus]. `validate()` does not count as + /// that first unfocus, and `reset()` makes the field wait for it again. + static const afterFirstUnfocus = + ValidationMode._('afterFirstUnfocus', _afterFirstUnfocus); + + static const _builtIn = [ + manual, + onUserInteraction, + onUnfocus, + afterFirstUnfocus, + ]; + + /// Identifies the mode: two modes are equal when their names are equal, so + /// a name must be unique across your app. The built-in names are `manual`, + /// `onUserInteraction`, `onUnfocus` and `afterFirstUnfocus`. + final String name; + + final bool Function(ValidationContext context) _shouldValidate; + + /// Whether the field should validate itself on [context]. + bool shouldValidate(ValidationContext context) => _shouldValidate(context); + + @override + bool operator ==(Object other) => + other is ValidationMode && other.name == name; + + @override + int get hashCode => name.hashCode; + + @override + String toString() => 'ValidationMode.$name'; + + static bool _manual(ValidationContext _) => false; + + // The edit already validated, so unfocus has nothing left to run. + static bool _onUserInteraction(ValidationContext c) => + c.event != ValidationEvent.unfocus; + + // The value is still being typed, so an edit waits for the unfocus. + static bool _onUnfocus(ValidationContext c) => + c.event != ValidationEvent.valueChanged; + + // The onUnfocus row until the field is first left, onUserInteraction after. + static bool _afterFirstUnfocus(ValidationContext c) => + c.everUnfocused ? _onUserInteraction(c) : _onUnfocus(c); +} diff --git a/skills/advanced-forms-build-forms/SKILL.md b/skills/advanced-forms-build-forms/SKILL.md index 779e95c0..af0c1f87 100644 --- a/skills/advanced-forms-build-forms/SKILL.md +++ b/skills/advanced-forms-build-forms/SKILL.md @@ -70,6 +70,10 @@ A round runs the sync validator first, then the async validator only if sync pas | `manual` (default) | — | — | — | | `onUserInteraction` | validate (async debounced) | — | sync only | | `onUnfocus` | — | validate | sync only | +| `afterFirstUnfocus` | — until the field was first left after an edit, validate after | validate, the first time | sync only | +| `ValidationMode.custom(name, fn)` | whatever `fn` says, given the event and whether the field was ever left; never for an untouched field | | | + +A rule none of the four modes states is a custom mode: [validation](references/validation.md#a-mode-of-your-own). Reach for the built-in modes first. `await form.validate()` checks every field and subform, untouched included, and returns `false` when anything is invalid. It neither reads nor changes the mode. `state.isValid` and `form.value.canSubmit` mean no error is recorded, including on a field nobody has checked. Read values only after `validate()` returns `true` — that is what licenses a `!` on a nullable value. A second call re-runs the sync validators and awaits async checks already in flight. diff --git a/skills/advanced-forms-build-forms/references/validation.md b/skills/advanced-forms-build-forms/references/validation.md index 099c69ec..6f7195b5 100644 --- a/skills/advanced-forms-build-forms/references/validation.md +++ b/skills/advanced-forms-build-forms/references/validation.md @@ -22,9 +22,38 @@ class SignupFormController extends AdvancedFormController { } ``` +## A mode of your own + +Reach for one of the four modes first. `ValidationMode.custom(name, fn)` is for a rule none of them states. `fn` gets a `ValidationContext` with `event` (`valueChanged`, `unfocus`, `dependencyChanged`) and `everUnfocused` (the user has left the field after editing it, at least once). It never sees the status, the errors or the value, and it is never asked about a field the user has not edited. Do not capture the field in the closure to read its error: every edit clears the errors, so such a rule stops validating as the user starts fixing the value. Modes are compared by `name`, so the name must be unique; a built-in name throws. Set the mode on the form (reaches every field and subform) or on one field (that field keeps it), exactly like a built-in mode. + +```dart +// Re-check only when another field changes, never on this field's own edits. +final followsDependency = ValidationMode.custom( + 'followsDependency', + (c) => c.event == ValidationEvent.dependencyChanged, +); +repeatPassword + ..setValidationMode(followsDependency) + ..subscribeToFields([password]); + +// Re-check on every keystroke, never because another field changed +// (an async availability check that should not re-run on a country change). +final onOwnEdits = ValidationMode.custom( + 'onOwnEdits', + (c) => c.event == ValidationEvent.valueChanged, +); +username + ..setValidationMode(onOwnEdits) + ..subscribeToFields([country]); +``` + +Test a rule without a form: `mode.shouldValidate(const ValidationContext(event: ValidationEvent.unfocus, everUnfocused: false))`. + ## Unfocus the widget owns -`onUnfocus` listens to the field's `focusNode`, so the widget binds it ([binding](binding.md)). A picker, date dialog, or custom dropdown that owns focus calls `field.handleUnfocus()` — otherwise nothing validates in that mode. It returns `Future`. In any mode it flushes a pending debounce. +`onUnfocus` and `afterFirstUnfocus` listen to the field's `focusNode`, so the widget binds it ([binding](binding.md)). A picker, date dialog, or custom dropdown that owns focus calls `field.handleUnfocus()` — otherwise nothing validates in those modes. It returns `Future`. In any mode it flushes a pending debounce. + +`afterFirstUnfocus` is the "quiet at first, live once left" pattern: no error while the user types, the first unfocus after an edit validates, and from then on every edit does. Only an unfocus flips it; `validate()` from submit does not, and `reset()` flips it back. ## Built-in validators @@ -92,7 +121,7 @@ final username = AdvancedTextFieldController( ); ``` -- **Debounce** delays a round a keystroke started. It applies in `onUserInteraction`. In `manual` and `onUnfocus` the check starts from `validate()` or from leaving the field, so `debounce` changes nothing. +- **Debounce** delays a round a keystroke started. It applies in `onUserInteraction`, and in `afterFirstUnfocus` once the field was first left. In `manual` and `onUnfocus` the check starts from `validate()` or from leaving the field, so `debounce` changes nothing. - A value change cancels the in-flight round. A stale answer never lands. - A settled answer is reused while the value is unchanged, so a second submit on an untouched form makes no network call. Any `setValue` drops the cached answer. When the check depends on state outside the value, invalidate with `field.clearErrors()`. - `state.isPending` / `isValidating` / `isInProgress` — a spinner is `isInProgress`. diff --git a/test/src/field/field_validation_modes_test.dart b/test/src/field/field_validation_modes_test.dart index bf8b1958..315156cb 100644 --- a/test/src/field/field_validation_modes_test.dart +++ b/test/src/field/field_validation_modes_test.dart @@ -19,8 +19,9 @@ void main() { group('validation modes', () { /// Drives [event] on a field the user has already edited, and reports - /// whether the field validated itself. - bool validates(ValidationMode mode, String event) { + /// whether the field validated itself. With [leftOnce] the user has also + /// left the field once before. + bool validates(ValidationMode mode, String event, {bool leftOnce = false}) { var calls = 0; final subject = AdvancedFieldController( initialValue: 0, @@ -34,6 +35,9 @@ void main() { // itself is pinned by its own group. ..setValue(1); addTearDown(subject.dispose); + if (leftOnce) { + subject.handleUnfocus(); + } calls = 0; switch (event) { @@ -59,6 +63,9 @@ void main() { (ValidationMode.onUnfocus, 'a value change', false), (ValidationMode.onUnfocus, 'an unfocus', true), (ValidationMode.onUnfocus, 'a dependency change', true), + (ValidationMode.afterFirstUnfocus, 'a value change', false), + (ValidationMode.afterFirstUnfocus, 'an unfocus', true), + (ValidationMode.afterFirstUnfocus, 'a dependency change', true), ]; for (final (mode, event, expected) in table) { @@ -68,6 +75,23 @@ void main() { ); } + // The same cells once the user has left the field. + const leftOnceTable = <(ValidationMode, String, bool)>[ + (ValidationMode.afterFirstUnfocus, 'a value change', true), + (ValidationMode.afterFirstUnfocus, 'an unfocus', false), + (ValidationMode.afterFirstUnfocus, 'a dependency change', true), + (ValidationMode.onUnfocus, 'a value change', false), + (ValidationMode.onUserInteraction, 'an unfocus', false), + ]; + + for (final (mode, event, expected) in leftOnceTable) { + test( + '${mode.name}, left once: $event ' + '${expected ? 'validates' : 'validates nothing'}', + () => expect(validates(mode, event, leftOnce: true), expected), + ); + } + test('manual still clears the error an edit made stale', () { field ..setValidationMode(ValidationMode.manual) @@ -128,8 +152,194 @@ void main() { }); }); + group('afterFirstUnfocus', () { + setUp(() => field.setValidationMode(ValidationMode.afterFirstUnfocus)); + + test('typing stays quiet until the field is left, then every edit shows', + () async { + validator.validationResult = TestError.malformed; + + field.setValue(10); + expect(field.value.error, isNull); + + await field.handleUnfocus(); + expect(field.value.error, TestError.malformed); + + validator.validationResult = null; + field.setValue(11); + expect(field.value.error, isNull); + + validator.validationResult = TestError.malformed; + field.setValue(12); + expect(field.value.error, TestError.malformed); + }); + + test('validate() does not stand in for the first unfocus', () async { + validator.validationResult = TestError.malformed; + field.setValue(10); + + expect(await field.validate(), isFalse); + field.setValue(11); + + expect(field.value.error, isNull); + }); + + test('leaving the field untouched does not count', () async { + validator.validationResult = TestError.malformed; + + await field.handleUnfocus(); + field.setValue(10); + + expect(field.value.error, isNull); + }); + + test('reset() makes the field wait for its first unfocus again', () async { + validator.validationResult = TestError.malformed; + field.setValue(10); + await field.handleUnfocus(); + + field + ..reset() + ..setValue(11); + + expect(field.value.error, isNull); + }); + + test('a field left under another mode is already past its first unfocus', + () async { + validator.validationResult = TestError.malformed; + field + ..setValidationMode(ValidationMode.manual) + ..setValue(10); + await field.handleUnfocus(); + expect(field.value.error, isNull); + + field + ..setValidationMode(ValidationMode.afterFirstUnfocus) + ..setValue(11); + + expect(field.value.error, TestError.malformed); + }); + + test('an edit after the first unfocus debounces the async check', () async { + final (:field, :validated) = makeAsyncField(); + addTearDown(field.dispose); + field + ..setValidationMode(ValidationMode.afterFirstUnfocus) + ..setValue(10); + await field.handleUnfocus(); + await pumpEventQueue(); + + field.setValue(11); + expect(field.value.isPending, isTrue); + await Future.delayed(const Duration(milliseconds: 120)); + + expect(validated, [10, 11]); + }); + }); + + group('custom strategies', () { + test('a custom mode sees the event and whether the field was left', () { + final seen = <(ValidationEvent, bool)>[]; + final spy = ValidationMode.custom('spy', (c) { + seen.add((c.event, c.everUnfocused)); + return false; + }); + field + ..setValidationMode(spy) + ..setValue(10) + ..revalidateSync() + ..handleUnfocus() + ..setValue(11); + + expect(seen, const [ + (ValidationEvent.valueChanged, false), + (ValidationEvent.dependencyChanged, false), + (ValidationEvent.unfocus, false), + (ValidationEvent.valueChanged, true), + ]); + }); + + test('an untouched field stays quiet even if the strategy says yes', () { + validator.validationResult = TestError.malformed; + field + ..setValidationMode(ValidationMode.custom('always', (_) => true)) + ..revalidateSync(); + + expect(field.value.error, isNull); + }); + + test('a custom mode validates when it says so', () { + validator.validationResult = TestError.malformed; + field + ..setValidationMode(ValidationMode.custom('always', (_) => true)) + ..setValue(10); + + expect(field.value.error, TestError.malformed); + }); + + test('the name identifies a custom mode', () { + bool live(ValidationContext _) => true; + expect( + ValidationMode.custom('a', live), + ValidationMode.custom('a', live), + ); + expect( + ValidationMode.custom('a', live), + isNot(ValidationMode.custom('b', live)), + ); + expect(ValidationMode.custom('a', live), isNot(ValidationMode.manual)); + expect( + () => ValidationMode.custom('manual', live), + throwsArgumentError, + ); + expect( + ValidationMode.afterFirstUnfocus.toString(), + 'ValidationMode.afterFirstUnfocus', + ); + }); + + test('re-applying an equal custom mode leaves a round in flight alone', + () async { + final (:field, :validated) = makeAsyncField(); + addTearDown(field.dispose); + bool live(ValidationContext _) => true; + + field + ..setValidationMode(ValidationMode.custom('live', live)) + ..setValue(10) + ..setValidationMode(ValidationMode.custom('live', live)); + + expect(field.value.isPending, isTrue); + await Future.delayed(const Duration(milliseconds: 120)); + expect(validated, [10]); + }); + + test('a form broadcasts a custom mode to a subform field', () { + final form = AdvancedFormController(); + final section = AdvancedFormController(); + addTearDown(form.dispose); + addTearDown(section.dispose); + final inner = AdvancedFieldController(initialValue: 0); + section.registerFields([inner]); + form.addSubform(section); + final live = ValidationMode.custom('live', (_) => true); + + form.setValidationMode(live); + + expect(inner.value.validationMode, live); + }); + }); + group('the interaction guarantee', () { - for (final mode in ValidationMode.values) { + final modes = [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, + ValidationMode.custom('always', (_) => true), + ]; + for (final mode in modes) { test( '${mode.name}: an untouched field validates nothing on a dependency ' 'change', () {