feat(display): form.interact(path?) to reveal a subtree's errors - #545
Merged
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Author
|
I tested/reviewed this beforehand, all gucci here. merging now. |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #544.
The problem
The default display gate opens on
submissionAttempts > 0 || blurredAfterInteraction, andblurredAfterInteractiononly 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()setstouched, 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 ofblurredAfterInteraction, and programmaticsetValuenever setsinteractedeither.form.interact(path?)Simulates a complete focus → edit → blur over every schema leaf under
path(the whole form when omitted):touched,interacted,blurredAfterInteraction), so the gate opens with no change toisGateOpen.originals, so it reaches leaves that arev-if'd away or were never mounted; the flags are sticky, so a subtree stays revealed across remount.focused/blurredflags at theirnull"no element" value — the display gate reads neither.useWizardneeds zero changes:activeFormFacadeforwards it, sowizard.activeForm.interact(...)andwizard.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
dirtytodirty || interacted. A valid seeded subtree now greens afterinteractinstead ofinteractbeing a silent no-op on it.interactedis set only by real input orinteract(never hydration or programmaticsetValue), so the excluded cases are unchanged: a tab-through and the post-submit flood of untouched valid fields both stayidle(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. Soform.fields(['members', 0]).emailtype-checked and readundefined, while a container's own rolled-up state (displayState,valid, …) was untypeable through the only spelling av-forindex permits. Both call-forms now resolve toFieldState; descending into children stays dot/bracket access (form.fields.members[i].email). New type-level test guards it.Docs, demo, housekeeping
touch()'s JSDoc to stop implying it drives the default display.form.interact(['members', i])button).showDelaydefault in the display-timing prose (100→120; ships120).tests/fixtures/doc-snippets/.generated/**— a gitignore-shaped trap that poisonedpnpm lintwith 200+ spurious errors aftercheck:doc-snippetsruns (same class the config already handles for pagefind / repl-cache).dist/index.mjs,dist/zod.mjs) to 64 KB with recorded notes. These entries measure raw dist with noNODE_ENVdefine, 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, fullpnpm test(365 files / 4685 tests),check:site,check:size,check:eager,check:bundled-types,check:doc-snippetsall green. New coverage:test/composables/interact.test.ts(both Zod adapters, wizard forwarding, unmount stickiness, disabled no-op, a real edit+blur afterinteractstill 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