diff --git a/CHANGELOG.md b/CHANGELOG.md index c56ed204..d1e917e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +* `ValidationMode` is a class with the four modes as constants, and `ValidationMode.custom` builds one of your own from a function over `ValidationContext`. +* New `ValidationMode.afterFirstUnfocus`: a field stays quiet while the user types, validates when first left, and from then on validates every edit. + ## 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 db65184b..ccaee49b 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. - **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( diff --git a/docs/faq.mdx b/docs/faq.mdx index f01e5336..58cc9e0c 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -25,8 +25,9 @@ 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`. And in +every mode a field the user has never edited stays quiet on its own. See [Validation modes](./validation/modes.mdx). diff --git a/docs/focus.mdx b/docs/focus.mdx index b1ac3f49..7d96ff63 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 by +talking to the field rather than to the widget tree. ## Jump to the first invalid field diff --git a/docs/internals/index.mdx b/docs/internals/index.mdx index b10a6067..6056786b 100644 --- a/docs/internals/index.mdx +++ b/docs/internals/index.mdx @@ -33,27 +33,32 @@ 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 behaviour 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, - }; +abstract interface class ValidationStrategy { + bool validatesOn(ValidationContext context); // context: event + hasUnfocused +} + +// In the field, before any strategy is asked: +bool _shouldValidateOn(ValidationEvent event) => + _hasInteracted && + _value.validationMode.validatesOn( + ValidationContext(event: event, hasUnfocused: _hasUnfocused), + ); ``` +The four built-in modes are `ValidationMode` constants wrapping one function each; `ValidationMode.custom` wraps +yours. + 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 +66,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` came later and brought 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/modes.mdx b/docs/validation/modes.mdx index ed0226bc..75eab464 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. +description: Three rules and one table decide when a field validates itself — on submit, on every keystroke, when it loses focus, or on unfocus first and every keystroke after. --- {/* @@ -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,37 @@ 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 instances of `ValidationMode`, and `ValidationMode.custom` makes a fifth from a function. The +function sees a `ValidationContext`: which event happened and whether the user has left the field since editing it. +It never sees the status or the errors, on purpose (see [Under the hood](../internals/index.mdx)). An untouched field +still validates nothing, whatever the function says. + +```dart +// Live after the first unfocus, but a dependency change never re-checks it. +final quietDependencies = ValidationMode.custom( + 'quietDependencies', + (c) => c.hasUnfocused && c.event == ValidationEvent.valueChanged, +); + +form.setValidationMode(quietDependencies); +``` + +Keep one instance per mode. `setValidationMode` with an equal mode leaves a round in flight alone, and two `custom` +calls are never equal. + ## Try it Switch the mode while you type. The form is the same; only the trigger changes. @@ -107,19 +135,32 @@ 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)), - ], - selected: {_mode}, - onSelectionChanged: (selection) => _setMode(selection.first), + // Full width when it fits, a horizontal scroll when it does not. + LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final mode in ValidationMode.values) + ButtonSegment(value: mode, label: Text(mode.name)), + ], + selected: {_mode}, + onSelectionChanged: (selection) => _setMode(selection.first), + ), + ), + ), ), const SizedBox(height: 8), DocsHint(hint), diff --git a/docs_app/content/landing/modes.mdx b/docs_app/content/landing/modes.mdx index 35a241f1..6deccb90 100644 --- a/docs_app/content/landing/modes.mdx +++ b/docs_app/content/landing/modes.mdx @@ -68,19 +68,32 @@ 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)), - ], - selected: {_mode}, - onSelectionChanged: (selection) => _setMode(selection.first), + // Full width when it fits, a horizontal scroll when it does not. + LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final mode in ValidationMode.values) + ButtonSegment(value: mode, label: Text(mode.name)), + ], + selected: {_mode}, + onSelectionChanged: (selection) => _setMode(selection.first), + ), + ), + ), ), const SizedBox(height: 8), DocsHint(hint), diff --git a/docs_app/lib/flutter-examples/manifest.generated.ts b/docs_app/lib/flutter-examples/manifest.generated.ts index 5e097873..048c6d9f 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -30,6 +30,7 @@ export const flutterExamples: Record = { "24da91a8b29c": { preview: "PizzaOrderForm", files: 2, docs: ["docs/fields/index.mdx"], helpers: ["DocsActions","DocsChipsField","DocsDropdownField","DocsSubmitButton","DocsSwitchField","DocsTextField"] }, "3f3d204cc417": { preview: "SelectInputDemo", files: 3, docs: ["docs/rendering/select.mdx"], helpers: ["DocsActions"] }, "5276a0a4f33c": { preview: "FocusForm", files: 2, docs: ["docs/focus.mdx"], helpers: ["DocsActions","DocsSubmitButton","DocsTextField"] }, + "5c78643d7005": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "5cef6df3c4e9": { preview: "StateInspector", files: 2, docs: ["docs/concepts/state.mdx"], helpers: ["DocsActions","DocsTextField"] }, "6ca6c8909e84": { preview: "WizardForm", files: 3, docs: ["docs/subforms/wizard.mdx"], helpers: ["DocsActions","DocsDropdownField","DocsSubmitButton","DocsSwitchField","DocsTextField"] }, "73b97401fc82": { preview: "ProfileForm", files: 2, docs: ["docs/server-errors/index.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, @@ -39,6 +40,5 @@ export const flutterExamples: Record = { "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..57ff7b8d 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, ValidationStrategy; 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..0303c8ca 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 _hasUnfocused = 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) { + _hasUnfocused = 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; + _hasUnfocused = 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 strategy, so an untouched field stays quiet whatever + // the strategy would say. + bool _shouldValidateOn(ValidationEvent event) => + _hasInteracted && + _value.validationMode.validatesOn( + ValidationContext(event: event, hasUnfocused: _hasUnfocused), ); // 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..0881b865 100644 --- a/lib/src/validation_mode.dart +++ b/lib/src/validation_mode.dart @@ -1,55 +1,121 @@ 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 [ValidationStrategy] gets to see. +/// +/// Deliberately no status and no errors: a debounced round overwrites the +/// status with `pending` and the write path clears both errors, so a strategy +/// reading either would disarm itself as the user started fixing the value. +@immutable +final class ValidationContext { + /// Describes the [event] on a field the user has [hasUnfocused] or not. + const ValidationContext({required this.event, required this.hasUnfocused}); + + /// The event the field is asking about. + final ValidationEvent event; + + /// The user has left the field after editing it. Only `reset()` clears it. + final bool hasUnfocused; +} + +/// Decides whether a field validates itself on an event, with nobody calling +/// `validate()`. +/// +/// The field asks only once the user has edited it: an untouched field +/// validates nothing on its own under any strategy. `validate()` neither +/// consults nor changes the strategy. +abstract interface class ValidationStrategy { + /// Whether the field should validate itself now. + bool validatesOn(ValidationContext context); +} + +/// The built-in strategies, plus [ValidationMode.custom] for one of your own. +/// +/// Set it on the `AdvancedFormController` and it reaches every field and +/// subform. /// /// | 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 implements ValidationStrategy { + const ValidationMode._(this.name, this._validatesOn); + + /// A strategy of your own. [name] is what `toString()` shows. + /// + /// Keep one instance per mode: `setValidationMode` with an equal mode leaves + /// a round in flight alone, and two `custom` calls are never equal. + factory ValidationMode.custom( + String name, + bool Function(ValidationContext context) validatesOn, + ) => + ValidationMode._(name, validatesOn); + + /// Nothing validates until `validate()` is called. The default. + static const manual = ValidationMode._('manual', _never); + + /// Every edit validates the field, the async check after its debounce. + static const onUserInteraction = + ValidationMode._('onUserInteraction', _onEdit); + + /// 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', _onLeave); + + /// 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', _afterFirstLeave); + + /// The built-in modes, in the order above. + static const values = [ + manual, + onUserInteraction, + onUnfocus, + afterFirstUnfocus, + ]; + + /// The mode's name, as the enum member was called. + final String name; + + final bool Function(ValidationContext context) _validatesOn; + + @override + bool validatesOn(ValidationContext context) => _validatesOn(context); + + @override + String toString() => 'ValidationMode.$name'; + + static bool _never(ValidationContext _) => false; + + // The edit already validated, so unfocus has nothing left to run. + static bool _onEdit(ValidationContext c) => + c.event != ValidationEvent.unfocus; + + // The value is still being typed, so an edit waits for the unfocus. + static bool _onLeave(ValidationContext c) => + c.event != ValidationEvent.valueChanged; + + // The onUnfocus row until the field is first left, onUserInteraction after. + static bool _afterFirstLeave(ValidationContext c) => + c.hasUnfocused ? _onEdit(c) : _onLeave(c); +} diff --git a/skills/advanced_forms-build-forms/SKILL.md b/skills/advanced_forms-build-forms/SKILL.md index 42bade08..cf70ad6c 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -70,6 +70,7 @@ 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 | `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..336d4e0c 100644 --- a/skills/advanced_forms-build-forms/references/validation.md +++ b/skills/advanced_forms-build-forms/references/validation.md @@ -24,7 +24,9 @@ class SignupFormController extends AdvancedFormController { ## 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 +94,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..4274ab5a 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,6 +152,146 @@ 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.hasUnfocused)); + 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('two custom instances are different modes', () { + bool live(ValidationContext _) => true; + expect( + ValidationMode.custom('a', live), + isNot(ValidationMode.custom('a', live)), + ); + expect(ValidationMode.values, hasLength(4)); + expect( + ValidationMode.afterFirstUnfocus.toString(), + 'ValidationMode.afterFirstUnfocus', + ); + }); + }); + group('the interaction guarantee', () { for (final mode in ValidationMode.values) { test(