Skip to content

feat(display): form.interact(path?) to reveal a subtree's errors - #545

Merged
ozzyfromspace merged 1 commit into
mainfrom
feat/form-interact
Jul 21, 2026
Merged

ozzyfromspace merged 1 commit into
mainfrom
feat/form-interact

Conversation

@ozzyfromspace

Copy link
Copy Markdown
Contributor

Closes #544.

The problem

The default display gate opens on submissionAttempts > 0 || blurredAfterInteraction, and blurredAfterInteraction only flips on a blur that follows an edit (the asymmetry that keeps a bare tab-through quiet). So a value that arrived without a DOM gesture — a server-seeded draft, a paste, a picker, an import — had no programmatic route to the gate short of a form-wide submit, which lights up every field on the page. form.touch() sets touched, a bit the stock reducer deliberately ignores, so its docs overpromised.

Confirmed at the source before writing anything: markFocused (create-form-store.ts) is the sole writer of blurredAfterInteraction, and programmatic setValue never sets interacted either.

form.interact(path?)

Simulates a complete focus → edit → blur over every schema leaf under path (the whole form when omitted):

await form.interact(['members', 2]) // arm one row; siblings stay idle
form.fields(['members', 2, 'email']).showErrors // ready on resolve
  • Flips the full interaction ladder (touched, interacted, blurredAfterInteraction), so the gate opens with no change to isGateOpen.
  • Walks originals, so it reaches leaves that are v-if'd away or were never mounted; the flags are sticky, so a subtree stays revealed across remount.
  • The returned promise resolves once the subtree's errors commit and never rejects, so a fire-and-forget call is safe.
  • No-op on a disabled form. Leaves the DOM-owned focused / blurred flags at their null "no element" value — the display gate reads neither.
  • useWizard needs zero changes: activeFormFacade forwards it, so wizard.activeForm.interact(...) and wizard.forms[key].interact(...) work for free (pinned by a test).

Two fold-ins from review

Earned success rewards engagement, not net change. Per discussion on the issue, the success term widened from dirty to dirty || interacted. A valid seeded subtree now greens after interact instead of interact being a silent no-op on it. interacted is set only by real input or interact (never hydration or programmatic setValue), so the excluded cases are unchanged: a tab-through and the post-submit flood of untouched valid fields both stay idle (regression-tested). Side effect worth noting: a real user who types and then reverts to the original value now earns the check, which follows directly from "engagement is what's rewarded."

form.fields([...]) was mistyped — unsound, not just incomplete. Probing the runtime showed the tuple and string call-forms return the same proxy, but the tuple overload's type promised a drillable { child: FieldState } map that doesn't exist at that call. So form.fields(['members', 0]).email type-checked and read undefined, while a container's own rolled-up state (displayState, valid, …) was untypeable through the only spelling a v-for index permits. Both call-forms now resolve to FieldState; descending into children stays dot/bracket access (form.fields.members[i].email). New type-level test guards it.

Docs, demo, housekeeping

  • Corrected touch()'s JSDoc to stop implying it drives the default display.
  • New "Reveal errors without a submit" docs section + a subtree demo (each row has its own form.interact(['members', i]) button).
  • Fixed a stale showDelay default in the display-timing prose (100120; ships 120).
  • eslint: ignore tests/fixtures/doc-snippets/.generated/** — a gitignore-shaped trap that poisoned pnpm lint with 200+ spurious errors after check:doc-snippets runs (same class the config already handles for pagefind / repl-cache).
  • size: raised the two 63 KB tripwires (dist/index.mjs, dist/zod.mjs) to 64 KB with recorded notes. These entries measure raw dist with no NODE_ENV define, so the __DEV__ warn (which folds out of consumer builds) counts. Baseline 62.89 KB → 63.02 KB here. Eager stayed within budget.

Verification

lint, typecheck, full pnpm test (365 files / 4685 tests), check:site, check:size, check:eager, check:bundled-types, check:doc-snippets all green. New coverage: test/composables/interact.test.ts (both Zod adapters, wizard forwarding, unmount stickiness, disabled no-op, a real edit+blur after interact still revalidates, the earned-success cases), test/types/fields-call-form-container.test.ts, and a subtree-scoping assertion in the demo smoke test.

🤖 Generated with Claude Code

The default display gate opens on `submissionAttempts > 0 ||
blurredAfterInteraction`, and `blurredAfterInteraction` only flips on a
blur that follows an edit. That left seeded, imported, and out-of-band
values with no programmatic route to the gate short of a form-wide
submit, which is too blunt for one row of a field array. `form.touch()`
sets `touched`, a bit the stock reducer deliberately ignores, so its
docs overpromised.

Add `form.interact(path?)`: it walks the schema leaves under `path`
(whole form when omitted) and flips the full interaction ladder
(`touched`, `interacted`, `blurredAfterInteraction`) as though the user
had focused, edited, and left each one, then runs that subtree's
validation. The gate opens through its front door with no change to
`isGateOpen`. It walks `originals`, so it reaches leaves that are
`v-if`'d away or never mounted; the flags are sticky, so a subtree
stays revealed across remount. The returned promise resolves once the
subtree's errors commit and never rejects. No-op on a disabled form;
leaves the DOM-owned `focused` / `blurred` flags untouched.

Widen the earned-success term from `dirty` to `dirty || interacted` so
success rewards engagement, not a net value change: a valid seeded
subtree greens after `interact`, and a user who edits and then reverts
still earns the check. The excluded cases are unchanged, since
`interacted` is set only by real input or `interact` (never hydration
or programmatic `setValue`): a tab-through and the post-submit flood of
untouched valid fields both stay idle.

Fix an unsound type on `form.fields([...])`: the tuple call-form
resolved to the drillable child map while the equivalent string
call-form resolved to the path's `FieldState`, though both spellings
return the same runtime proxy. So `form.fields(['members', 0]).email`
type-checked and read `undefined`, and a container's rolled-up state
was untypeable through the tuple form a v-for index forces. Both forms
now resolve to `FieldState`; descending stays dot/bracket access.

Correct `touch()`'s JSDoc to stop implying it reveals errors, add a
docs section + subtree demo, and fix a stale `showDelay` default in the
prose (100 -> 120). Ignore the doc-snippet `.generated` fixtures in
eslint (a gitignore-shaped trap that poisoned `pnpm lint` after
`check:doc-snippets`). Raise the two 63 KB size tripwires to 64 KB with
recorded notes.

Closes #544

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
attaform Ready Ready Preview, Comment Jul 21, 2026 11:05pm

@ozzyfromspace

Copy link
Copy Markdown
Contributor Author

I tested/reviewed this beforehand, all gucci here. merging now.

@ozzyfromspace
ozzyfromspace merged commit 8291251 into main Jul 21, 2026
15 checks passed
@ozzyfromspace
ozzyfromspace deleted the feat/form-interact branch July 21, 2026 23:18

This branch was successfully deployed

1 active deployment
Preview 02aecc07 Deployed Jul 21, 2026 by vercel[bot]
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.

feat: form.interact(path?) — programmatically simulate a full interaction so seeded/out-of-band values reveal their errors

1 participant