Skip to content
Open
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
## Unreleased

* Validation modes are now yours to write. `ValidationMode` is a class, and `ValidationMode.custom(name, fn)` turns a one-line rule into a mode the package treats like its own.
* The rule gets a `ValidationContext`: which event is happening (`valueChanged`, `unfocus` or `dependencyChanged`) and whether the user has ever left the field after editing it.
* It never sees the status, the errors or the value, so a rule cannot switch itself off while the user is fixing a mistake.
* An untouched field still validates nothing, whatever the rule says; `validate()` still ignores the mode; sync still runs before async.
* Set it on the form and it reaches every field and subform; set it on one field and that field keeps it.
* Modes are compared by name, so a mode built in `build()` is not a mode change; a built-in name throws.
* The docs have a page for it with a live demo, and the README and the skill cover it too.
* New built-in mode `ValidationMode.afterFirstUnfocus`: quiet while the user types, validates when the field is first left, and from then on validates every edit, so an error disappears the moment the value is fixed.
* Breaking: an exhaustive `switch` over `ValidationMode` needs a default case, and `ValidationMode.values` is gone, because with custom modes there is no finite list.

## 0.2.4

* The skill is renamed to `advanced-forms-build-forms` to match the Agent Skills spec. Run `dart run skills@ get` again to get the new name.
* The skill is split into a short `SKILL.md` and reference files, so it uses fewer tokens.
* The docs recommend `dart run skills@ get` as the way to install the skill.
* The package now works with `equatable` 3.0.0.

## 0.2.3

* Fixed `validate()` returning a stale answer when called again before the first call had finished; every call now re-runs the sync validators and only an async check in flight is shared.
Expand Down
25 changes: 23 additions & 2 deletions 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. 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.
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 All @@ -238,7 +239,27 @@ final form = AdvancedFormController(
form.email.setValidationMode(ValidationMode.onUserInteraction);
```

`onUnfocus` needs the widget to bind the field's `focusNode`, or to call `field.handleUnfocus()` itself — which is what a picker or a dropdown does.
`onUnfocus` and `afterFirstUnfocus` need the widget to bind the field's `focusNode`, or to call `field.handleUnfocus()` itself — which is what a picker or a dropdown does.

### Your own validation mode

When none of the four modes says what a form needs, write the rule yourself. `ValidationMode.custom(name, fn)` makes a mode the package treats like a built-in one: set it on the form and it reaches every field and subform.

```dart
// Re-check only when another field changes, never on this field's own edits.
final followsDependency = ValidationMode.custom(
'followsDependency',
(c) => c.event == ValidationEvent.dependencyChanged,
);

repeatPassword
..setValidationMode(followsDependency)
..subscribeToFields([password]);
```

The rule gets a `ValidationContext` with two fields: `event` (`valueChanged`, `unfocus` or `dependencyChanged`) and `everUnfocused` (the user has left the field after editing it, at least once). It never sees the status or the errors, because every edit clears them and a rule reading them would stop validating as the user started fixing the value. The three rules above still hold: an untouched field validates nothing whatever the rule says, `validate()` ignores the mode, and a round runs sync before async.

Modes are compared by name, so a name must be unique across your app; a built-in name throws. The [docs](https://advanced-forms.leancode.co/docs/validation/custom-modes) have more examples and how to test a rule.

The field makes and owns that `focusNode`. Pass `focusNode:` to the constructor to bind one you already own instead — the field listens to it but never disposes it.

Expand Down
6 changes: 4 additions & 2 deletions docs/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ validate-on-submit button on `canSubmit` at all — it turns `false` while a che

The default mode is `manual`: nothing validates until the first `validate()`. That is deliberate — a form should not
shout before the user has done anything. For live feedback give the form `ValidationMode.onUserInteraction`; for
feedback when a field is left, `ValidationMode.onUnfocus` — which also needs the widget to bind `field.focusNode`. And
in every mode a field the user has never edited stays quiet on its own. See [Validation modes](./validation/modes.mdx).
feedback when a field is left, `ValidationMode.onUnfocus` — which also needs the widget to bind `field.focusNode`; for
the common "quiet at first, live once the user has left the field" pattern, `ValidationMode.afterFirstUnfocus`; for
anything else, `ValidationMode.custom`. And in every mode, your own included, a field the user has never edited stays
quiet on its own. See [Validation modes](./validation/modes.mdx).

</Accordion>
<Accordion title="My subscribeToFields dependency does not re-validate.">
Expand Down
2 changes: 1 addition & 1 deletion docs/first-form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,6 @@ value. For live feedback from the first keystroke, or a check when a field loses
<Cards>
<Card title="Core concepts" href="./concepts/index.mdx" description="What a field's state holds and what the statuses mean." />
<Card title="Rendering fields" href="./rendering/index.mdx" description="Checkboxes, dropdowns, chips and sliders, bound the same way." />
<Card title="Validation modes" href="./validation/modes.mdx" description="Validate on submit, on every keystroke, or on unfocus." />
<Card title="Available modes" href="./validation/modes.mdx" description="Validate on submit, on every keystroke, or on unfocus." />
<Card title="Lifecycle" href="./lifecycle/index.mdx" description="Who owns the controller, and who disposes what." />
</Cards>
6 changes: 3 additions & 3 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
through the field rather than through the widget tree.

## Jump to the first invalid field

Expand Down Expand Up @@ -164,6 +164,6 @@ it is optional.
## Next

<Cards>
<Card title="Validation modes" href="./validation/modes.mdx" description="onUnfocus, and what it needs from the widget." />
<Card title="Available modes" href="./validation/modes.mdx" description="onUnfocus, and what it needs from the widget." />
<Card title="Rendering fields" href="./rendering/index.mdx" description="Binding the node to every kind of widget." />
</Cards>
2 changes: 1 addition & 1 deletion docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ state-management package the rest of your app uses.
<Card title="Installation" href="./installation.mdx" description="Add the package. Requirements and the 0.1.x migration." />
<Card title="Your first form" href="./first-form.mdx" description="A controller, two fields, a submit — running on the page." />
<Card title="Core concepts" href="./concepts/index.mdx" description="Controllers, state, statuses, and the error type you choose." />
<Card title="Validation modes" href="./validation/modes.mdx" description="Three rules and one table decide when a field validates." />
<Card title="Available modes" href="./validation/modes.mdx" description="Three rules and one table decide when a field validates." />
</Cards>

## What you get
Expand Down
33 changes: 19 additions & 14 deletions docs/internals/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,34 +33,39 @@ codes is never used for the throw path.

## The gate reads no field status

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

```dart
bool validatesOn(
ValidationEvent event, {
required ValidationMode mode,
required bool hasInteracted,
}) =>
hasInteracted &&
switch (mode) {
ValidationMode.manual => false,
ValidationMode.onUserInteraction => event != ValidationEvent.unfocus,
ValidationMode.onUnfocus => event != ValidationEvent.valueChanged,
};
// In the field, before any mode is asked:
bool _shouldValidateOn(ValidationEvent event) =>
_hasInteracted &&
_value.validationMode.shouldValidate(
ValidationContext(event: event, everUnfocused: _everUnfocused),
);
```

The four built-in modes are `ValidationMode` constants wrapping one function each; `ValidationMode.custom` wraps
yours. The untouched-field rule sits in front of all of them, in the field, so no mode can reach a field the user has
not edited.

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

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

An earlier design took Flutter's whole `AutovalidateMode`. Cutting to three deleted, in one move: a latch flipped by
settling checks, a gate that read `status`, a sweep on registration and on mode change, escalation at submit, and a
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` 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.
Comment on lines +66 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing the point of this document, but I still don't see a reason to mention "bringing anything back", especially when singling out one validation mode 🤔


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
Loading
Loading