Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Unreleased

* `ValidationMode` is a class with the four modes as constants, and `ValidationMode.custom` builds one of your own from a function over `ValidationContext`.
* New `ValidationMode.afterFirstUnfocus`: a field stays quiet while the user types, validates when first left, and from then on validates every edit.

## 0.2.3

* Fixed `validate()` returning a stale answer when called again before the first call had finished; every call now re-runs the sync validators and only an async check in flight is shared.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions docs/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

</Accordion>
<Accordion title="My subscribeToFields dependency does not re-validate.">
Expand Down
4 changes: 2 additions & 2 deletions docs/focus.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 22 additions & 14 deletions docs/internals/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,34 +33,42 @@ codes is never used for the throw path.

## The gate reads no field status

The whole trigger behaviour is one function of the mode, the event, and whether the user has edited the field:
The whole trigger behaviour is one function of the mode, the event, and two bits the user's actions set: whether they
have edited the field, and whether they have left it since:

```dart
bool validatesOn(
ValidationEvent event, {
required ValidationMode mode,
required bool hasInteracted,
}) =>
hasInteracted &&
switch (mode) {
ValidationMode.manual => false,
ValidationMode.onUserInteraction => event != ValidationEvent.unfocus,
ValidationMode.onUnfocus => event != ValidationEvent.valueChanged,
};
abstract interface class ValidationStrategy {
bool validatesOn(ValidationContext context); // context: event + hasUnfocused
}

// In the field, before any strategy is asked:
bool _shouldValidateOn(ValidationEvent event) =>
_hasInteracted &&
_value.validationMode.validatesOn(
ValidationContext(event: event, hasUnfocused: _hasUnfocused),
);
```

The four built-in modes are `ValidationMode` constants wrapping one function each; `ValidationMode.custom` wraps
yours.

It reads no field *status* on purpose. A debounced round overwrites the status with `pending` and the write path clears
both error slots, so any gate that looked at "does this field currently have an error" would disarm itself exactly as
the user started fixing the value.
the user started fixing the value. The two bits are not status: an event sets each one, and only `reset()` clears
them. That is what lets `afterFirstUnfocus` switch from the `onUnfocus` row to the `onUserInteraction` row without a
latch that a round could erase.

## Why three modes and not Flutter's five
## Why not Flutter's five modes

An earlier design took Flutter's whole `AutovalidateMode`. Cutting to three deleted, in one move: a latch flipped by
settling checks, a gate that read `status`, a sweep on registration and on mode change, escalation at submit, and a
live parent link with its own lifecycle. Escalation was what forced inheritance to be a live link, and the live link is
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.
Expand Down
2 changes: 1 addition & 1 deletion docs/validation/configuring.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 51 additions & 10 deletions docs/validation/modes.mdx
Original file line number Diff line number Diff line change
@@ -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.
---

{/*
Expand All @@ -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:

Expand All @@ -35,10 +36,37 @@ The gate, in full — the row is the mode, the column is the event:
| `manual` | — | — | — |
| `onUserInteraction` | validate | — (the edit already did) | sync only |
| `onUnfocus` | — (still typing) | validate | sync only |
| `afterFirstUnfocus`, not yet left | — (still typing) | validate | sync only |
| `afterFirstUnfocus`, left once | validate | — (the edit already did) | sync only |

`afterFirstUnfocus` is the `onUnfocus` row until the user first leaves the field after editing it, and the
`onUserInteraction` row from then on. Only that unfocus flips it: a `validate()` from a submit button does not, and
`reset()` flips it back. A field the user tabbed through without editing has not been "left" in this sense, so it still
waits. Like `onUnfocus`, it needs the widget to bind `field.focusNode` (see [Configuring the mode](./configuring.mdx)).

"A dependency changed" is what [`subscribeToFields`](./cross-field.mdx) and `validateAll` produce; it re-runs the sync
validator only, because this field's own value did not change and its async verdict still stands.

## A mode of your own

The four modes are instances of `ValidationMode`, and `ValidationMode.custom` makes a fifth from a function. The
function sees a `ValidationContext`: which event happened and whether the user has left the field since editing it.
It never sees the status or the errors, on purpose (see [Under the hood](../internals/index.mdx)). An untouched field
still validates nothing, whatever the function says.

```dart
// Live after the first unfocus, but a dependency change never re-checks it.
final quietDependencies = ValidationMode.custom(
'quietDependencies',
(c) => c.hasUnfocused && c.event == ValidationEvent.valueChanged,
);

form.setValidationMode(quietDependencies);
```

Keep one instance per mode. `setValidationMode` with an equal mode leaves a round in flight alone, and two `custom`
calls are never equal.

## Try it

Switch the mode while you type. The form is the same; only the trigger changes.
Expand Down Expand Up @@ -107,19 +135,32 @@ class _ValidationModesDemoState extends State<ValidationModesDemo> {
ValidationMode.onUnfocus =>
'Leaving a field you edited validates it. Tabbing through an '
'untouched field costs nothing.',
ValidationMode.afterFirstUnfocus =>
'Quiet while you type. Leaving a field you edited validates it, '
'and from then on every keystroke does.',
_ => 'A mode of your own.',
};

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<ValidationMode>(
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<ValidationMode>(
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),
Expand Down
29 changes: 21 additions & 8 deletions docs_app/content/landing/modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,32 @@ class _ValidationModesDemoState extends State<ValidationModesDemo> {
ValidationMode.onUnfocus =>
'Leaving a field you edited validates it. Tabbing through an '
'untouched field costs nothing.',
ValidationMode.afterFirstUnfocus =>
'Quiet while you type. Leaving a field you edited validates it, '
'and from then on every keystroke does.',
_ => 'A mode of your own.',
};

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<ValidationMode>(
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<ValidationMode>(
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),
Expand Down
2 changes: 1 addition & 1 deletion docs_app/lib/flutter-examples/manifest.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const flutterExamples: Record<string, FlutterExample> = {
"24da91a8b29c": { preview: "PizzaOrderForm", files: 2, docs: ["docs/fields/index.mdx"], helpers: ["DocsActions","DocsChipsField","DocsDropdownField","DocsSubmitButton","DocsSwitchField","DocsTextField"] },
"3f3d204cc417": { preview: "SelectInputDemo", files: 3, docs: ["docs/rendering/select.mdx"], helpers: ["DocsActions"] },
"5276a0a4f33c": { preview: "FocusForm", files: 2, docs: ["docs/focus.mdx"], helpers: ["DocsActions","DocsSubmitButton","DocsTextField"] },
"5c78643d7005": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] },
"5cef6df3c4e9": { preview: "StateInspector", files: 2, docs: ["docs/concepts/state.mdx"], helpers: ["DocsActions","DocsTextField"] },
"6ca6c8909e84": { preview: "WizardForm", files: 3, docs: ["docs/subforms/wizard.mdx"], helpers: ["DocsActions","DocsDropdownField","DocsSubmitButton","DocsSwitchField","DocsTextField"] },
"73b97401fc82": { preview: "ProfileForm", files: 2, docs: ["docs/server-errors/index.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] },
Expand All @@ -39,6 +40,5 @@ export const flutterExamples: Record<string, FlutterExample> = {
"c513edd2c993": { preview: "PasswordRulesForm", files: 2, docs: ["docs/validation/custom-validators.mdx"], helpers: ["DocsTextField"] },
"e2356ccef99b": { preview: "GuestListForm", files: 2, docs: ["docs/subforms/index.mdx"], helpers: ["DocsActions","DocsSubmitButton","DocsTextField"] },
"f21af60e91f0": { preview: "BooleanInputDemo", files: 3, docs: ["docs/rendering/boolean.mdx"], helpers: ["DocsActions"] },
"f295a159666b": { preview: "ValidationModesDemo", files: 2, docs: ["docs/validation/modes.mdx","docs_app/content/landing/modes.mdx"], helpers: ["DocsActions","DocsHint","DocsSubmitButton","DocsTextField"] },
"f733c75e06e6": { preview: "PrefillDemo", files: 2, docs: ["docs/lifecycle/prefilled-forms.mdx"], helpers: ["DocsActions","DocsFormStatus","DocsTextField"] },
}
3 changes: 2 additions & 1 deletion lib/advanced_forms.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export 'src/field/advanced_text_field_controller.dart';
export 'src/field/builder/advanced_field_builder.dart';
export 'src/form/advanced_form_controller.dart';
export 'src/form/advanced_form_state.dart';
export 'src/validation_mode.dart' show ValidationMode;
export 'src/validation_mode.dart'
show ValidationContext, ValidationEvent, ValidationMode, ValidationStrategy;
export 'src/validators/validators.dart';
34 changes: 25 additions & 9 deletions lib/src/field/advanced_field_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ class AdvancedFieldController<T, E extends Object>
// 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;

Expand Down Expand Up @@ -170,16 +175,23 @@ class AdvancedFieldController<T, E extends Object>
}

/// 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<void> 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;
}

Expand Down Expand Up @@ -300,10 +312,12 @@ class AdvancedFieldController<T, E extends Object>
/// 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);
}

Expand Down Expand Up @@ -385,10 +399,12 @@ class AdvancedFieldController<T, E extends Object>
super.dispose();
}

bool _shouldValidateOn(ValidationEvent event) => validatesOn(
event,
mode: _value.validationMode,
hasInteracted: _hasInteracted,
// Checked before the strategy, so an untouched field stays quiet whatever
// the strategy would say.
bool _shouldValidateOn(ValidationEvent event) =>
_hasInteracted &&
_value.validationMode.validatesOn(
ValidationContext(event: event, hasUnfocused: _hasUnfocused),
);

// Publishes [value] with both errors cleared and the given status.
Expand Down
Loading
Loading