From 9647d130400a655e7512bbb29c561d9c2d11900f Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Mon, 12 Oct 2026 17:01:45 +0200 Subject: [PATCH 01/12] Validation mode: quiet until the first unfocus, live after it A common ask: no error while the user types an email for the first time, validate when they leave the field, and from then on validate every keystroke so the error disappears as soon as the value is fixed. Neither onUnfocus nor onUserInteraction does this on its own. ValidationMode.afterFirstUnfocus is the onUnfocus row of the gate until the field is first left after an edit, and the onUserInteraction row from then on. The field remembers that first unfocus in one bit, set by the event and cleared only by reset(). The gate still reads no status. validate() from a submit button does not count as the first unfocus, and leaving a field the user never edited does not either. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 + README.md | 3 +- docs/faq.mdx | 5 +- docs/focus.mdx | 4 +- docs/internals/index.mdx | 16 ++- docs/validation/configuring.mdx | 2 +- docs/validation/modes.mdx | 15 ++- docs_app/content/landing/modes.mdx | 3 + .../flutter-examples/manifest.generated.ts | 2 +- lib/src/field/advanced_field_controller.dart | 25 +++- lib/src/validation_mode.dart | 20 ++- skills/advanced-forms-build-forms/SKILL.md | 1 + .../references/validation.md | 6 +- .../field/field_validation_modes_test.dart | 114 +++++++++++++++++- 14 files changed, 199 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f824bb7..35b0fd7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ +## Unreleased + +* New `ValidationMode.afterFirstUnfocus`: a field stays quiet while the user types, validates when first left, and from then on validates every edit. + ## 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. +======= +## Unreleased ## 0.2.3 diff --git a/README.md b/README.md index ee398dc1..a35c323e 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..d7884a7a 100644 --- a/docs/internals/index.mdx +++ b/docs/internals/index.mdx @@ -33,27 +33,34 @@ 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, + required bool hasUnfocused, }) => hasInteracted && switch (mode) { ValidationMode.manual => false, ValidationMode.onUserInteraction => event != ValidationEvent.unfocus, ValidationMode.onUnfocus => event != ValidationEvent.valueChanged, + ValidationMode.afterFirstUnfocus => hasUnfocused + ? event != ValidationEvent.unfocus + : event != ValidationEvent.valueChanged, }; ``` 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 +68,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..8a1a2074 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,6 +36,13 @@ 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. @@ -107,6 +115,9 @@ 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.', }; return Column( diff --git a/docs_app/content/landing/modes.mdx b/docs_app/content/landing/modes.mdx index 35a241f1..ff8e92fa 100644 --- a/docs_app/content/landing/modes.mdx +++ b/docs_app/content/landing/modes.mdx @@ -68,6 +68,9 @@ 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.', }; return Column( diff --git a/docs_app/lib/flutter-examples/manifest.generated.ts b/docs_app/lib/flutter-examples/manifest.generated.ts index 5e097873..5de17d2c 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -39,6 +39,6 @@ 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"] }, + "f2988f505b37": { 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/src/field/advanced_field_controller.dart b/lib/src/field/advanced_field_controller.dart index 746672c4..82b4b3d8 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); } @@ -389,6 +403,7 @@ class AdvancedFieldController event, mode: _value.validationMode, hasInteracted: _hasInteracted, + 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..e805c656 100644 --- a/lib/src/validation_mode.dart +++ b/lib/src/validation_mode.dart @@ -16,6 +16,13 @@ enum ValidationMode { /// answer. Requires a widget to bind the field's `focusNode`, or a call to /// `handleUnfocus()` — nothing validates in this mode otherwise. 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. + afterFirstUnfocus, } @internal @@ -34,17 +41,21 @@ enum ValidationEvent { /// | `manual` | — | — | — | /// | `onUserInteraction` | validate | — | sync only | /// | `onUnfocus` | — | validate | sync only | +/// | `afterFirstUnfocus`, not yet left | — | validate | sync only | +/// | `afterFirstUnfocus`, left once | 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. +/// would disarm itself as the user started fixing the value. `hasUnfocused` +/// is not a status: it is set by an event and only `reset()` clears it. @internal bool validatesOn( ValidationEvent event, { required ValidationMode mode, required bool hasInteracted, + required bool hasUnfocused, }) => - // Checked before the mode, so an untouched field stays quiet in all three. + // Checked before the mode, so an untouched field stays quiet in all four. hasInteracted && switch (mode) { ValidationMode.manual => false, @@ -52,4 +63,9 @@ bool validatesOn( ValidationMode.onUserInteraction => event != ValidationEvent.unfocus, // The value is still being typed, so an edit waits for the unfocus. ValidationMode.onUnfocus => event != ValidationEvent.valueChanged, + // The onUnfocus row until the field is first left, the + // onUserInteraction row after that. + ValidationMode.afterFirstUnfocus => hasUnfocused + ? event != ValidationEvent.unfocus + : event != ValidationEvent.valueChanged, }; diff --git a/skills/advanced-forms-build-forms/SKILL.md b/skills/advanced-forms-build-forms/SKILL.md index 779e95c0..31e96028 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..03518fcc 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,92 @@ 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('the interaction guarantee', () { for (final mode in ValidationMode.values) { test( From 808d08ea7ba59861dfc6341aab154aca290932a0 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Mon, 12 Oct 2026 17:24:17 +0200 Subject: [PATCH 02/12] Docs: the mode switcher scrolls sideways on narrow screens With four modes the segmented button no longer fits a phone, and Flutter broke the labels mid-word ("afterFir / stUnfoc / us"). It already did that to onUserInteraction with three. The row now keeps its full width where it fits and scrolls horizontally where it does not, in both copies of the demo. Co-Authored-By: Claude Fable 5.1 --- docs/validation/modes.mdx | 25 +++++++++++++------ docs_app/content/landing/modes.mdx | 25 +++++++++++++------ .../flutter-examples/manifest.generated.ts | 2 +- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/validation/modes.mdx b/docs/validation/modes.mdx index 8a1a2074..8bfffba0 100644 --- a/docs/validation/modes.mdx +++ b/docs/validation/modes.mdx @@ -123,14 +123,23 @@ class _ValidationModesDemoState extends State { 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 ff8e92fa..b0c7bd24 100644 --- a/docs_app/content/landing/modes.mdx +++ b/docs_app/content/landing/modes.mdx @@ -76,14 +76,23 @@ class _ValidationModesDemoState extends State { 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 5de17d2c..9e71cc2f 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -36,9 +36,9 @@ 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"] }, + "b470bdb0ef6c": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","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"] }, - "f2988f505b37": { 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"] }, } From 222a6d6e71533a0adae793e7bde3fda0322c45f5 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 14:32:28 +0200 Subject: [PATCH 03/12] ValidationMode is a class, and custom() makes a mode of your own The four modes become constants of a final class instead of enum members, and ValidationMode.custom(name, fn) adds one the package does not ship. The function sees a ValidationContext: the event, and whether the user has ever left the field after editing it. It never sees the status or the errors, because 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. The untouched-field rule stays in the field, in front of every mode, so a custom mode cannot reach a field the user has not edited. Modes are compared by name, so one built in build() is not a mode change; a built-in name throws. ValidationMode.values is gone, because with custom modes there is no finite list, and an exhaustive switch over the modes needs a default case. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 + README.md | 1 + docs/faq.mdx | 5 +- docs/internals/index.mdx | 25 ++- docs/validation/modes.mdx | 28 +++- docs_app/content/landing/modes.mdx | 8 +- .../flutter-examples/manifest.generated.ts | 2 +- lib/advanced_forms.dart | 3 +- lib/src/field/advanced_field_controller.dart | 17 +- lib/src/validation_mode.dart | 151 ++++++++++++------ skills/advanced-forms-build-forms/SKILL.md | 1 + .../references/validation.md | 13 ++ .../field/field_validation_modes_test.dart | 102 +++++++++++- 13 files changed, 278 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b0fd7c..cc38718f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased * New `ValidationMode.afterFirstUnfocus`: a field stays quiet while the user types, validates when first left, and from then on validates every edit. +* `ValidationMode` is a class with the four modes as constants, and `ValidationMode.custom(name, fn)` builds one of your own; an untouched field still validates nothing. +* An exhaustive `switch` over `ValidationMode` needs a default case, and `ValidationMode.values` is gone: with custom modes there is no finite list. ## 0.2.4 diff --git a/README.md b/README.md index a35c323e..ebc3eb8f 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ Three rules cover every case: | `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 | +| `ValidationMode.custom(name, fn)` | Your own rule. `fn` sees which event is happening and whether the user has ever left the field; it never sees the status, and an untouched field still validates nothing | ```dart final form = AdvancedFormController( diff --git a/docs/faq.mdx b/docs/faq.mdx index 58cc9e0c..51a422a7 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -26,8 +26,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`; 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). +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/internals/index.mdx b/docs/internals/index.mdx index d7884a7a..d6828fee 100644 --- a/docs/internals/index.mdx +++ b/docs/internals/index.mdx @@ -37,23 +37,18 @@ The whole trigger behaviour is one function of the mode, the event, and two bits have edited the field, and whether they have left it since: ```dart -bool validatesOn( - ValidationEvent event, { - required ValidationMode mode, - required bool hasInteracted, - required bool hasUnfocused, -}) => - hasInteracted && - switch (mode) { - ValidationMode.manual => false, - ValidationMode.onUserInteraction => event != ValidationEvent.unfocus, - ValidationMode.onUnfocus => event != ValidationEvent.valueChanged, - ValidationMode.afterFirstUnfocus => hasUnfocused - ? event != ValidationEvent.unfocus - : 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 two bits are not status: an event sets each one, and only `reset()` clears diff --git a/docs/validation/modes.mdx b/docs/validation/modes.mdx index 8bfffba0..46b4a2ea 100644 --- a/docs/validation/modes.mdx +++ b/docs/validation/modes.mdx @@ -47,6 +47,26 @@ waits. Like `onUnfocus`, it needs the widget to bind `field.focusNode` (see [Con "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(name, fn)` makes one of your own. The +function sees a `ValidationContext`: which event is happening and whether the user has ever left the field after +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.everUnfocused && c.event == ValidationEvent.valueChanged, +); + +form.setValidationMode(quietDependencies); +``` + +The name is what modes are compared by, so it must be unique across your app: two `custom` modes with the same name +are the same mode, and a built-in name throws. + ## Try it Switch the mode while you type. The form is the same; only the trigger changes. @@ -118,6 +138,7 @@ class _ValidationModesDemoState extends State { 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( @@ -132,7 +153,12 @@ class _ValidationModesDemoState extends State { child: SegmentedButton( showSelectedIcon: false, segments: [ - for (final mode in ValidationMode.values) + for (final mode in [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, + ]) ButtonSegment(value: mode, label: Text(mode.name)), ], selected: {_mode}, diff --git a/docs_app/content/landing/modes.mdx b/docs_app/content/landing/modes.mdx index b0c7bd24..a2cb0807 100644 --- a/docs_app/content/landing/modes.mdx +++ b/docs_app/content/landing/modes.mdx @@ -71,6 +71,7 @@ class _ValidationModesDemoState extends State { 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( @@ -85,7 +86,12 @@ class _ValidationModesDemoState extends State { child: SegmentedButton( showSelectedIcon: false, segments: [ - for (final mode in ValidationMode.values) + for (final mode in [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, + ]) ButtonSegment(value: mode, label: Text(mode.name)), ], selected: {_mode}, diff --git a/docs_app/lib/flutter-examples/manifest.generated.ts b/docs_app/lib/flutter-examples/manifest.generated.ts index 9e71cc2f..42e01a24 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -30,13 +30,13 @@ 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"] }, + "5587ce64d9ff": { 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"] }, "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"] }, - "b470bdb0ef6c": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","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"] }, 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 82b4b3d8..0ae131cf 100644 --- a/lib/src/field/advanced_field_controller.dart +++ b/lib/src/field/advanced_field_controller.dart @@ -106,7 +106,7 @@ class AdvancedFieldController // 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; + bool _everUnfocused = false; // null: follow the form's mode. Non-null: this field manages its own. ValidationMode? _ownMode; @@ -189,7 +189,7 @@ class AdvancedFieldController // Decided before the bit flips, so the first unfocus still validates. final validates = _shouldValidateOn(ValidationEvent.unfocus); if (_hasInteracted) { - _hasUnfocused = true; + _everUnfocused = true; } if (!validates) { return; @@ -317,7 +317,7 @@ class AdvancedFieldController void reset() { _rounds.invalidate(); _hasInteracted = false; - _hasUnfocused = false; + _everUnfocused = false; _clearTo(_initialValue); } @@ -399,11 +399,12 @@ class AdvancedFieldController super.dispose(); } - bool _shouldValidateOn(ValidationEvent event) => validatesOn( - event, - mode: _value.validationMode, - hasInteracted: _hasInteracted, - hasUnfocused: _hasUnfocused, + // 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 e805c656..a0f782a8 100644 --- a/lib/src/validation_mode.dart +++ b/lib/src/validation_mode.dart @@ -1,71 +1,122 @@ import 'package:meta/meta.dart'; +/// 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, +} + +/// 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. `validate()` neither consults nor changes it, and in every mode a -/// field the user has never edited validates nothing on its own. -enum ValidationMode { +/// 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 | +/// | `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. - manual, + static const manual = ValidationMode._('manual', _manual); /// Every edit validates the field, the async check after its debounce. - onUserInteraction, + 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. - onUnfocus, + 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. - afterFirstUnfocus, -} + static const afterFirstUnfocus = + ValidationMode._('afterFirstUnfocus', _afterFirstUnfocus); -@internal -enum ValidationEvent { - valueChanged, - unfocus, + static const _builtIn = [ + manual, + onUserInteraction, + onUnfocus, + afterFirstUnfocus, + ]; - /// A field this one depends on changed, so only the sync validator is owed. - dependencyChanged, -} + /// 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; -/// The one place that decides whether a field validates itself. -/// -/// | mode | valueChanged | unfocus | dependencyChanged | -/// |---|---|---|---| -/// | `manual` | — | — | — | -/// | `onUserInteraction` | validate | — | sync only | -/// | `onUnfocus` | — | validate | sync only | -/// | `afterFirstUnfocus`, not yet left | — | validate | sync only | -/// | `afterFirstUnfocus`, left once | 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. `hasUnfocused` -/// is not a status: it is set by an event and only `reset()` clears it. -@internal -bool validatesOn( - ValidationEvent event, { - required ValidationMode mode, - required bool hasInteracted, - required bool hasUnfocused, -}) => - // Checked before the mode, so an untouched field stays quiet in all four. - 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, - // The onUnfocus row until the field is first left, the - // onUserInteraction row after that. - ValidationMode.afterFirstUnfocus => hasUnfocused - ? event != ValidationEvent.unfocus - : event != ValidationEvent.valueChanged, - }; + 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 31e96028..9906732b 100644 --- a/skills/advanced-forms-build-forms/SKILL.md +++ b/skills/advanced-forms-build-forms/SKILL.md @@ -71,6 +71,7 @@ A round runs the sync validator first, then the async validator only if sync pas | `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 | | | `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 336d4e0c..fb0b946f 100644 --- a/skills/advanced-forms-build-forms/references/validation.md +++ b/skills/advanced-forms-build-forms/references/validation.md @@ -22,6 +22,19 @@ 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 or the errors, and it is never asked about a field the user has not edited. Modes are compared by `name`, so the name must be unique; a built-in name throws. + +```dart +// Live after the first unfocus, but a dependency change never re-checks it. +final quietDependencies = ValidationMode.custom( + 'quietDependencies', + (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +); +form.setValidationMode(quietDependencies); +``` + ## Unfocus the widget owns `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. diff --git a/test/src/field/field_validation_modes_test.dart b/test/src/field/field_validation_modes_test.dart index 03518fcc..315156cb 100644 --- a/test/src/field/field_validation_modes_test.dart +++ b/test/src/field/field_validation_modes_test.dart @@ -238,8 +238,108 @@ void main() { }); }); + 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', () { From c9c3bf4b3b45b69fc4c278ef54d8db5255bc533f Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 14:40:00 +0200 Subject: [PATCH 04/12] Docs: a page of its own for custom validation modes What the rule sees and never sees, what still holds, the name, three examples and how to test a rule without a form. The modes page points there, the README gets a section, and the skill reference carries the same rules in short form. Co-Authored-By: Claude Fable 5.1 --- README.md | 23 +++- docs/validation/custom-modes.mdx | 127 ++++++++++++++++++ docs/validation/meta.json | 1 + docs/validation/modes.mdx | 22 +-- skills/advanced-forms-build-forms/SKILL.md | 2 + .../references/validation.md | 13 +- 6 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 docs/validation/custom-modes.mdx diff --git a/README.md b/README.md index ebc3eb8f..22a8112e 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,6 @@ Three rules cover every case: | `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 | -| `ValidationMode.custom(name, fn)` | Your own rule. `fn` sees which event is happening and whether the user has ever left the field; it never sees the status, and an untouched field still validates nothing | ```dart final form = AdvancedFormController( @@ -240,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/validation/custom-modes.mdx b/docs/validation/custom-modes.mdx new file mode 100644 index 00000000..aefec8b6 --- /dev/null +++ b/docs/validation/custom-modes.mdx @@ -0,0 +1,127 @@ +--- +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 +final quietDependencies = ValidationMode.custom( + 'quietDependencies', + (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +); + +final form = AdvancedFormController(validationMode: quietDependencies); +``` + +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, +); +``` + +Live after the first unfocus, like `afterFirstUnfocus`, but a dependency change never re-checks the field: + +```dart +final quietDependencies = ValidationMode.custom( + 'quietDependencies', + (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +); +``` + +## Testing a rule + +The rule is a plain function, so test it without a form: + +```dart +test('quietDependencies ignores dependency changes', () { + const context = ValidationContext( + event: ValidationEvent.dependencyChanged, + everUnfocused: true, + ); + expect(quietDependencies.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 46b4a2ea..9d9f7774 100644 --- a/docs/validation/modes.mdx +++ b/docs/validation/modes.mdx @@ -49,23 +49,10 @@ validator only, because this field's own value did not change and its async verd ## A mode of your own -The four modes are instances of `ValidationMode`, and `ValidationMode.custom(name, fn)` makes one of your own. The -function sees a `ValidationContext`: which event is happening and whether the user has ever left the field after -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.everUnfocused && c.event == ValidationEvent.valueChanged, -); - -form.setValidationMode(quietDependencies); -``` - -The name is what modes are compared by, so it must be unique across your app: two `custom` modes with the same name -are the same mode, and a built-in name throws. +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 @@ -192,6 +179,7 @@ class _ValidationModesDemoState extends State { ## Next + diff --git a/skills/advanced-forms-build-forms/SKILL.md b/skills/advanced-forms-build-forms/SKILL.md index 9906732b..af0c1f87 100644 --- a/skills/advanced-forms-build-forms/SKILL.md +++ b/skills/advanced-forms-build-forms/SKILL.md @@ -73,6 +73,8 @@ A round runs the sync validator first, then the async validator only if sync pas | `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. `submit()` is your method: `validate()`, then send. `onPressed` is a block body that awaits it. A tear-off (`onPressed: form.submit`) or an arrow (`() async => form.submit()`) drops the future, so the result is lost and a throw becomes an unhandled error. diff --git a/skills/advanced-forms-build-forms/references/validation.md b/skills/advanced-forms-build-forms/references/validation.md index fb0b946f..5ae95df2 100644 --- a/skills/advanced-forms-build-forms/references/validation.md +++ b/skills/advanced-forms-build-forms/references/validation.md @@ -24,9 +24,18 @@ 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 or the errors, and it is never asked about a field the user has not edited. Modes are compared by `name`, so the name must be unique; a built-in name throws. +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]); + // Live after the first unfocus, but a dependency change never re-checks it. final quietDependencies = ValidationMode.custom( 'quietDependencies', @@ -35,6 +44,8 @@ final quietDependencies = ValidationMode.custom( form.setValidationMode(quietDependencies); ``` +Test a rule without a form: `mode.shouldValidate(const ValidationContext(event: ValidationEvent.unfocus, everUnfocused: false))`. + ## Unfocus the widget owns `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. From 967e0f93a366da065aaa4155665e044d4a8e77df Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 14:43:46 +0200 Subject: [PATCH 05/12] Docs: try three custom modes on a repeat-password field A Try it island on the custom-modes page: Password on the form's default mode, Repeat password switching between followsDependency, quietDependencies and onUnfocusOnly, each a one-line rule in the source. Co-Authored-By: Claude Fable 5.1 --- docs/validation/custom-modes.mdx | 135 ++++++++++++++++++ .../flutter-examples/manifest.generated.ts | 1 + 2 files changed, 136 insertions(+) diff --git a/docs/validation/custom-modes.mdx b/docs/validation/custom-modes.mdx index aefec8b6..a748700e 100644 --- a/docs/validation/custom-modes.mdx +++ b/docs/validation/custom-modes.mdx @@ -100,6 +100,141 @@ final quietDependencies = ValidationMode.custom( ); ``` +## 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, +); + +// Live after the first unfocus; a change in Password never re-checks it. +final quietDependencies = ValidationMode.custom( + 'quietDependencies', + (c) => c.everUnfocused && 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.', + 'quietDependencies' => + 'Quiet until you leave Repeat password, then every keystroke. ' + 'Changing Password does not re-check it.', + _ => 'Leaving Repeat password validates it. Changing Password does not.', + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 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 rule in [ + followsDependency, + quietDependencies, + onUnfocusOnly, + ]) + ButtonSegment(value: rule, label: Text(rule.name)), + ], + selected: {_rule}, + onSelectionChanged: (selection) => _setRule(selection.first), + ), + ), + ), + ), + 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: diff --git a/docs_app/lib/flutter-examples/manifest.generated.ts b/docs_app/lib/flutter-examples/manifest.generated.ts index 42e01a24..b07afc07 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -34,6 +34,7 @@ export const flutterExamples: Record = { "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"] }, + "776a5df6676f": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "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"] }, From 8465698912308a8c742535a12f13949b76b9b40d Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 14:47:52 +0200 Subject: [PATCH 06/12] Docs: "Available modes", and a plainer custom-mode example The page with the four shipped modes is titled Available modes, so the sidebar reads: Available modes, Configuring the mode, Your own validation mode. The quietDependencies example gives way to onOwnEdits, a username with an async availability check that must not re-run because the country changed, on the page, in the island and in the skill. Co-Authored-By: Claude Fable 5.1 --- docs/first-form.mdx | 2 +- docs/focus.mdx | 2 +- docs/index.mdx | 2 +- docs/validation/custom-modes.mdx | 44 +++++++++++-------- docs/validation/modes.mdx | 4 +- .../flutter-examples/manifest.generated.ts | 2 +- .../references/validation.md | 13 +++--- 7 files changed, 39 insertions(+), 30 deletions(-) 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 7d96ff63..1a8733b4 100644 --- a/docs/focus.mdx +++ b/docs/focus.mdx @@ -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/validation/custom-modes.mdx b/docs/validation/custom-modes.mdx index a748700e..b2c30f86 100644 --- a/docs/validation/custom-modes.mdx +++ b/docs/validation/custom-modes.mdx @@ -15,12 +15,13 @@ function, and the package treats it exactly like a built-in mode: set it on the subform; set it on one field, that field keeps it. ```dart -final quietDependencies = ValidationMode.custom( - 'quietDependencies', - (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +// 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: quietDependencies); +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, @@ -91,13 +92,18 @@ final onUnfocusOnly = ValidationMode.custom( ); ``` -Live after the first unfocus, like `afterFirstUnfocus`, but a dependency change never re-checks the field: +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 quietDependencies = ValidationMode.custom( - 'quietDependencies', - (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +final onOwnEdits = ValidationMode.custom( + 'onOwnEdits', + (c) => c.event == ValidationEvent.valueChanged, ); + +username + ..setValidationMode(onOwnEdits) + ..subscribeToFields([country]); ``` ## Try it @@ -132,10 +138,10 @@ final followsDependency = ValidationMode.custom( (c) => c.event == ValidationEvent.dependencyChanged, ); -// Live after the first unfocus; a change in Password never re-checks it. -final quietDependencies = ValidationMode.custom( - 'quietDependencies', - (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +// 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. @@ -186,9 +192,9 @@ class _CustomModesDemoState extends State { 'followsDependency' => 'Repeat password re-checks only when Password changes. Its own ' 'edits stay quiet until Save.', - 'quietDependencies' => - 'Quiet until you leave Repeat password, then every keystroke. ' - 'Changing Password does not re-check it.', + 'onOwnEdits' => + 'Every keystroke in Repeat password re-checks it. Changing Password ' + 'does not.', _ => 'Leaving Repeat password validates it. Changing Password does not.', }; @@ -206,7 +212,7 @@ class _CustomModesDemoState extends State { segments: [ for (final rule in [ followsDependency, - quietDependencies, + onOwnEdits, onUnfocusOnly, ]) ButtonSegment(value: rule, label: Text(rule.name)), @@ -240,12 +246,12 @@ class _CustomModesDemoState extends State { The rule is a plain function, so test it without a form: ```dart -test('quietDependencies ignores dependency changes', () { +test('onOwnEdits ignores dependency changes', () { const context = ValidationContext( event: ValidationEvent.dependencyChanged, everUnfocused: true, ); - expect(quietDependencies.shouldValidate(context), isFalse); + expect(onOwnEdits.shouldValidate(context), isFalse); }); ``` @@ -256,7 +262,7 @@ To test it through a field, edit the field first (`setValue`), then drive the ev ## Next - + diff --git a/docs/validation/modes.mdx b/docs/validation/modes.mdx index 9d9f7774..3740f666 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, when it loses focus, or on unfocus first and every keystroke after. +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. --- {/* diff --git a/docs_app/lib/flutter-examples/manifest.generated.ts b/docs_app/lib/flutter-examples/manifest.generated.ts index b07afc07..f5817077 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -23,6 +23,7 @@ export interface FlutterExample { export const flutterExamples: Record = { "047778d0ab54": { preview: "SignupForm", files: 3, docs: ["docs/first-form.mdx","docs_app/content/landing/signup.mdx"] }, + "088b30028624": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "0e5adec0d12e": { preview: "HeroSignupForm", files: 1, docs: ["docs_app/content/landing/hero.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsSwitchField","DocsTextField"] }, "16fd48df04c0": { preview: "TextInputDemo", files: 2, docs: ["docs/rendering/text.mdx"], helpers: ["DocsActions"] }, "18570b69c83d": { preview: "UsernameAvailabilityForm", files: 2, docs: ["docs/validation/async.mdx","docs_app/content/landing/async.mdx"], helpers: ["DocsFailureBanner","DocsFieldStatus","DocsHint","DocsSubmitButton","DocsTextField"] }, @@ -34,7 +35,6 @@ export const flutterExamples: Record = { "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"] }, - "776a5df6676f": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "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"] }, diff --git a/skills/advanced-forms-build-forms/references/validation.md b/skills/advanced-forms-build-forms/references/validation.md index 5ae95df2..6f7195b5 100644 --- a/skills/advanced-forms-build-forms/references/validation.md +++ b/skills/advanced-forms-build-forms/references/validation.md @@ -36,12 +36,15 @@ repeatPassword ..setValidationMode(followsDependency) ..subscribeToFields([password]); -// Live after the first unfocus, but a dependency change never re-checks it. -final quietDependencies = ValidationMode.custom( - 'quietDependencies', - (c) => c.everUnfocused && c.event == ValidationEvent.valueChanged, +// 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, ); -form.setValidationMode(quietDependencies); +username + ..setValidationMode(onOwnEdits) + ..subscribeToFields([country]); ``` Test a rule without a form: `mode.shouldValidate(const ValidationContext(event: ValidationEvent.unfocus, everUnfocused: false))`. From 2521e159a445acd49ceba0371079e81ba2eff9e2 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:02:22 +0200 Subject: [PATCH 07/12] Docs: pick the mode with pills that wrap The segmented button clipped its last label on narrow screens even with the horizontal scroll. Both mode demos and the custom-modes demo now use one ChoiceChip per mode in a Wrap, so they flow onto a second row instead. Co-Authored-By: Claude Fable 5.1 --- docs/validation/custom-modes.mdx | 31 ++++++---------- docs/validation/modes.mdx | 37 ++++++++----------- .../flutter-examples/manifest.generated.ts | 4 +- 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/docs/validation/custom-modes.mdx b/docs/validation/custom-modes.mdx index b2c30f86..c9ea6459 100644 --- a/docs/validation/custom-modes.mdx +++ b/docs/validation/custom-modes.mdx @@ -201,27 +201,18 @@ class _CustomModesDemoState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 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 rule in [ - followsDependency, - onOwnEdits, - onUnfocusOnly, - ]) - ButtonSegment(value: rule, label: Text(rule.name)), - ], - selected: {_rule}, - onSelectionChanged: (selection) => _setRule(selection.first), + // One pill per mode; they wrap instead of squeezing into one row. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final rule in [followsDependency, onOwnEdits, onUnfocusOnly]) + ChoiceChip( + label: Text(rule.name), + selected: rule == _rule, + onSelected: (_) => _setRule(rule), ), - ), - ), + ], ), const SizedBox(height: 8), DocsHint(hint), diff --git a/docs/validation/modes.mdx b/docs/validation/modes.mdx index 3740f666..302d0647 100644 --- a/docs/validation/modes.mdx +++ b/docs/validation/modes.mdx @@ -131,28 +131,23 @@ class _ValidationModesDemoState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 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.manual, - ValidationMode.onUserInteraction, - ValidationMode.onUnfocus, - ValidationMode.afterFirstUnfocus, - ]) - ButtonSegment(value: mode, label: Text(mode.name)), - ], - selected: {_mode}, - onSelectionChanged: (selection) => _setMode(selection.first), + // One pill per mode; they wrap instead of squeezing into one row. + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final mode in [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, + ]) + ChoiceChip( + label: Text(mode.name), + selected: mode == _mode, + onSelected: (_) => _setMode(mode), ), - ), - ), + ], ), 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 f5817077..e415e944 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -23,7 +23,7 @@ export interface FlutterExample { export const flutterExamples: Record = { "047778d0ab54": { preview: "SignupForm", files: 3, docs: ["docs/first-form.mdx","docs_app/content/landing/signup.mdx"] }, - "088b30028624": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, + "0d51bf32ada7": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "0e5adec0d12e": { preview: "HeroSignupForm", files: 1, docs: ["docs_app/content/landing/hero.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsSwitchField","DocsTextField"] }, "16fd48df04c0": { preview: "TextInputDemo", files: 2, docs: ["docs/rendering/text.mdx"], helpers: ["DocsActions"] }, "18570b69c83d": { preview: "UsernameAvailabilityForm", files: 2, docs: ["docs/validation/async.mdx","docs_app/content/landing/async.mdx"], helpers: ["DocsFailureBanner","DocsFieldStatus","DocsHint","DocsSubmitButton","DocsTextField"] }, @@ -31,11 +31,11 @@ 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"] }, - "5587ce64d9ff": { 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"] }, "833e51744528": { preview: "OrderTotalForm", files: 2, docs: ["docs/validation/relations.mdx"], helpers: ["DocsTextField"] }, + "8348226431e5": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","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"] }, "c513edd2c993": { preview: "PasswordRulesForm", files: 2, docs: ["docs/validation/custom-validators.mdx"], helpers: ["DocsTextField"] }, From 355fdfc1330dd39afdc570acbb830c9dd7830fe8 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:06:52 +0200 Subject: [PATCH 08/12] Docs: a titled mode picker with a solid selected pill DocsModePicker carries the "Select your validation mode" title in the accent colour and renders the chosen mode as a solid accent pill with ink text, like the primary button. All three mode demos use it. Co-Authored-By: Claude Fable 5.1 --- docs/validation/custom-modes.mdx | 18 ++---- docs/validation/modes.mdx | 26 ++++----- docs_app/flutter/lib/support/fields.dart | 58 +++++++++++++++++++ .../flutter-examples/manifest.generated.ts | 4 +- 4 files changed, 76 insertions(+), 30 deletions(-) diff --git a/docs/validation/custom-modes.mdx b/docs/validation/custom-modes.mdx index c9ea6459..4c8f9d4d 100644 --- a/docs/validation/custom-modes.mdx +++ b/docs/validation/custom-modes.mdx @@ -201,18 +201,12 @@ class _CustomModesDemoState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // One pill per mode; they wrap instead of squeezing into one row. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final rule in [followsDependency, onOwnEdits, onUnfocusOnly]) - ChoiceChip( - label: Text(rule.name), - selected: rule == _rule, - onSelected: (_) => _setRule(rule), - ), - ], + DocsModePicker( + title: 'Select your validation mode', + options: [followsDependency, onOwnEdits, onUnfocusOnly], + selected: _rule, + label: (rule) => rule.name, + onSelected: _setRule, ), const SizedBox(height: 8), DocsHint(hint), diff --git a/docs/validation/modes.mdx b/docs/validation/modes.mdx index 302d0647..8ea14d62 100644 --- a/docs/validation/modes.mdx +++ b/docs/validation/modes.mdx @@ -131,23 +131,17 @@ class _ValidationModesDemoState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // One pill per mode; they wrap instead of squeezing into one row. - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final mode in [ - ValidationMode.manual, - ValidationMode.onUserInteraction, - ValidationMode.onUnfocus, - ValidationMode.afterFirstUnfocus, - ]) - ChoiceChip( - label: Text(mode.name), - selected: mode == _mode, - onSelected: (_) => _setMode(mode), - ), + DocsModePicker( + title: 'Select your validation mode', + options: const [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, ], + 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 e415e944..5e5519d5 100644 --- a/docs_app/lib/flutter-examples/manifest.generated.ts +++ b/docs_app/lib/flutter-examples/manifest.generated.ts @@ -23,7 +23,6 @@ export interface FlutterExample { export const flutterExamples: Record = { "047778d0ab54": { preview: "SignupForm", files: 3, docs: ["docs/first-form.mdx","docs_app/content/landing/signup.mdx"] }, - "0d51bf32ada7": { preview: "CustomModesDemo", files: 2, docs: ["docs/validation/custom-modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] }, "0e5adec0d12e": { preview: "HeroSignupForm", files: 1, docs: ["docs_app/content/landing/hero.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsSwitchField","DocsTextField"] }, "16fd48df04c0": { preview: "TextInputDemo", files: 2, docs: ["docs/rendering/text.mdx"], helpers: ["DocsActions"] }, "18570b69c83d": { preview: "UsernameAvailabilityForm", files: 2, docs: ["docs/validation/async.mdx","docs_app/content/landing/async.mdx"], helpers: ["DocsFailureBanner","DocsFieldStatus","DocsHint","DocsSubmitButton","DocsTextField"] }, @@ -35,9 +34,10 @@ export const flutterExamples: Record = { "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"] }, "833e51744528": { preview: "OrderTotalForm", files: 2, docs: ["docs/validation/relations.mdx"], helpers: ["DocsTextField"] }, - "8348226431e5": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","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"] }, From 3e833976a39f7359577f3e2d3a78aef61b56a119 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:07:22 +0200 Subject: [PATCH 09/12] Changelog: custom validation modes are the headline Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 12 +++++++++--- README.md | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc38718f..58d31066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ ## Unreleased -* New `ValidationMode.afterFirstUnfocus`: a field stays quiet while the user types, validates when first left, and from then on validates every edit. -* `ValidationMode` is a class with the four modes as constants, and `ValidationMode.custom(name, fn)` builds one of your own; an untouched field still validates nothing. -* An exhaustive `switch` over `ValidationMode` needs a default case, and `ValidationMode.values` is gone: with custom modes there is no finite list. +* 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 diff --git a/README.md b/README.md index 22a8112e..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, on unfocus, or on unfocus first and every keystroke after. 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. From 43821480aa6426b978d0f66de97486e9612af3f9 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:10:23 +0200 Subject: [PATCH 10/12] Docs: the landing copy of the modes demo follows the picker The const list was committed for the docs copy only, so the example manifest no longer matched the landing copy. Co-Authored-By: Claude Fable 5.1 --- docs_app/content/landing/modes.mdx | 33 ++++++++++-------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/docs_app/content/landing/modes.mdx b/docs_app/content/landing/modes.mdx index a2cb0807..e6781542 100644 --- a/docs_app/content/landing/modes.mdx +++ b/docs_app/content/landing/modes.mdx @@ -77,28 +77,17 @@ class _ValidationModesDemoState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 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.manual, - ValidationMode.onUserInteraction, - ValidationMode.onUnfocus, - ValidationMode.afterFirstUnfocus, - ]) - ButtonSegment(value: mode, label: Text(mode.name)), - ], - selected: {_mode}, - onSelectionChanged: (selection) => _setMode(selection.first), - ), - ), - ), + DocsModePicker( + title: 'Select your validation mode', + options: const [ + ValidationMode.manual, + ValidationMode.onUserInteraction, + ValidationMode.onUnfocus, + ValidationMode.afterFirstUnfocus, + ], + selected: _mode, + label: (mode) => mode.name, + onSelected: _setMode, ), const SizedBox(height: 8), DocsHint(hint), From 8a96ece6fd7a98d147f51a01497cf878e07a0a06 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:35:15 +0200 Subject: [PATCH 11/12] Changelog: drop a leftover conflict marker Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d31066..d06f72f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,6 @@ * 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. -======= -## Unreleased - ## 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. From 4caeb4a030de993b27c8e847e99a307f0deca378 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sat, 24 Oct 2026 15:50:12 +0200 Subject: [PATCH 12/12] Review: US spelling, no origin story, no talking Co-Authored-By: Claude Fable 5.1 --- docs/focus.mdx | 4 ++-- docs/internals/index.mdx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/focus.mdx b/docs/focus.mdx index 1a8733b4..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` and `afterFirstUnfocus`](./validation/modes.mdx) react 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 diff --git a/docs/internals/index.mdx b/docs/internals/index.mdx index d6828fee..ce13ffdf 100644 --- a/docs/internals/index.mdx +++ b/docs/internals/index.mdx @@ -33,7 +33,7 @@ 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 two bits the user's actions set: whether they +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 @@ -63,8 +63,8 @@ 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. +`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