Skip to content

Validation modes you can write yourself - #103

Open
KamilSztandur wants to merge 12 commits into
mainfrom
after-first-unfocus
Open

KamilSztandur wants to merge 12 commits into
mainfrom
after-first-unfocus

Conversation

@KamilSztandur

@KamilSztandur KamilSztandur commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The form decides when a field validates itself with one of three modes: on submit, on every keystroke, or when the field is left. Real forms keep asking for rules that are not on that list. The one that started this PR: an email field should stay quiet while it is typed for the first time, validate when the user leaves it, and from then on re-check every keystroke so the error disappears the moment the address is right. Today the only way to get that, or any other rule the package does not ship, is to switch modes by hand from listeners, which is fragile and easy to get wrong.

Solution

ValidationMode is now a class instead of an enum, and ValidationMode.custom(name, fn) makes a mode from your own rule. The four shipped modes are constants of the same class, written with the same two inputs your rule gets, so a custom mode is not a second-class citizen: set it on the form and it reaches every field and subform, set it on one field and that field keeps it.

// 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: which event is happening (valueChanged, unfocus, dependencyChanged) and whether the user has ever left the field after editing it. That is all a rule needs, and all it gets.

The email case ships as a fourth built-in mode, ValidationMode.afterFirstUnfocus: like onUnfocus until the field is first left after an edit, like onUserInteraction from then on.

How it is nicer now

A rule you can say in one sentence is one function, with the package's guarantees still around it: an untouched field validates nothing whatever the rule says, validate() ignores the mode, a round runs sync before async, the mode reaches the whole tree. Nothing to switch by hand, nothing to keep in sync. The docs have a page for it, with examples and how to test a rule without a form.

Design decisions

  • A rule never sees the status, the errors or the value. Every edit clears the errors and a debounced round sets the status to pending, so a rule that asked "is there an error" would stop validating at the exact moment the user started fixing the value. That is the trap the earlier five-mode design fell into, and the context is shaped so nobody can fall into it again.
  • The untouched-field rule stays in the field, in front of every mode. It is enforced in one place, not left to whoever writes a rule.
  • Modes are compared by name. Two custom modes with the same name are the same mode, so one built in build() is not a mode change and leaves a round in flight alone. A name must therefore be unique; a built-in name throws.
  • ValidationMode.values is gone. With custom modes there is no finite list, so the getter would lie.
  • The context is an object, not parameters, so a field can be added to it later without breaking every rule out there.
  • afterFirstUnfocus flips to live only on an unfocus. validate() from a submit button does not count, reset() flips it back, and leaving a field the user never edited does not count.

Breaking changes

For an app that uses only the shipped modes: an exhaustive switch over ValidationMode needs a default case, and ValidationMode.values no longer exists. Everything else compiles unchanged. This is a 0.3.0.

Demo

The "Try it" island on the Validation modes page, in afterFirstUnfocus: two letters typed with no error, the error appears on leaving the field, a third letter clears it at once, backspace brings it back at once.

Where it shows

The package: ValidationMode, ValidationContext and ValidationEvent in validation_mode.dart, one bit in the field controller. Docs: a new page "Your own validation mode", the mode table and gate table on the modes page, README section, FAQ, internals, the skill and its validation reference, the mode-switching demo. Tests cover the custom modes (what they see, that they cannot reach an untouched field, equality by name, broadcast to subforms) and both halves of afterFirstUnfocus.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Docs preview: https://advanced-forms-k85euu9u2-leancode.vercel.app

Built from 4caeb4a; the landing page is at /, the docs under /docs.

@KamilSztandur

Copy link
Copy Markdown
Collaborator Author

@mhnowak Here it is :)

@KamilSztandur KamilSztandur changed the title Validation mode: quiet until the first unfocus, live after it New validation mode: "onUnfocus" until first unfocused, "onUserInteraction" afterwards Oct 12, 2026
@Albert221

Albert221 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

IMO instead of introducing yet another enum value for the validation mode, the ValidationMode could become a strategy, that given all of the state we have about a given field and the surrounding form can form a decision whether the field should be validated. And that would mean that all custom behaviors our users might want to have for their forms are possible to implement as first-class citizens of the package. Following open-closed principle

KamilSztandur and others added 2 commits October 24, 2026 14:32
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@KamilSztandur KamilSztandur changed the title New validation mode: "onUnfocus" until first unfocused, "onUserInteraction" afterwards Validation modes: afterFirstUnfocus, and a custom mode of your own Oct 24, 2026
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 <noreply@anthropic.com>
@KamilSztandur KamilSztandur changed the title Validation modes: afterFirstUnfocus, and a custom mode of your own Validation modes you can write yourself Oct 24, 2026
KamilSztandur and others added 7 commits October 24, 2026 14:40
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Comment on lines +56 to +58
if (_builtIn.any((mode) => mode.name == name)) {
throw ArgumentError.value(name, 'name', 'is a built-in mode');
}

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.

I'm not a fan of such a solution. I'd implement different strategies as subclasses (and a custom strategy would be, e.g., extends CustomValidationMode). Then, to detect a change in the mode, you just compare types

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It wouldn't be able to tell difference between different custom modes, if we only compare by types, I think. Comparing by == instead means every custom mode writes its own equality (so it's easy to make a bug for devs using it).

I believe a name is the smallest explicit identity. Let me know if you disagree and have strong opinion on it

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.

We would be able — we would have two different types

@PiotrRogulski PiotrRogulski Oct 24, 2026 •

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.

I feel like relying on runtime checks for forbidden names might be super inconvenient. With types, no such restriction would exist

@mateusz-pietras mateusz-pietras Oct 25, 2026 •

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.

Yeah, +1 on strategy pattern, I think it fits here perfectly.

Comment thread docs/internals/index.mdx Outdated
Comment thread docs/internals/index.mdx Outdated
Comment thread docs/focus.mdx Outdated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Oct 24, 2026
@KamilSztandur KamilSztandur mentioned this pull request Oct 24, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@KamilSztandur
KamilSztandur added this pull request to stack #112 October 24, 2026 13:50
Comment thread docs/internals/index.mdx
Comment on lines +66 to +67
`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.

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 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants