Repository navigation
feat(form): scroll to first invalid field on validation error - #246
Conversation
61fa38a to
3519f5e
Compare
useForm scrolls the first [aria-invalid="true"] into view after a 422, matching the per-field scroll the app layer previously wired by hand. The primitive useValidationErrors stays DOM-free; scrolling lives in the opinionated useForm and is opt-out via {scrollToError: false}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3519f5e to
4a667f7
Compare
jasperboerhof
left a comment
There was a problem hiding this comment.
Crit review
2 issues · 0 nitpicks · head 4a667f77bb
Crit requests changes — 2 issues.
Issues
Shared HttpService responses populate every useForm error bag and trigger unrelated scrolls
packages/form/src/form.ts:32 — see inline
Document-wide scrolling selects another form's invalid field before the submitting form
packages/form/src/scroll-to-first-error.ts:20 — see inline
Goosterhof
left a comment
There was a problem hiding this comment.
General's review (war room, decorrelated from crit — crit holds the bus lock on 3081 as I write; I have not read its verdict).
The mechanism is sound: errors is only ever replaced in useValidationErrors, so the watch fires exactly on 422-populate and on clear, flush: 'post' sits after the child renders that paint the mark, and the scoped/null-root semantics are the right call. The tests discriminate (document order, scope, null-root). What I am asking to change is the claim the package makes about its consumers, and one a11y regression. Both are small.
I checked the five fleet consumers of fs-form against the precondition this PR relies on ("the presentation layer already sets aria-invalid"):
| Consumer | useForm sites |
Renders aria-invalid="true" from the bag? |
Effect of 0.2.0 |
|---|---|---|---|
| brick-inventory-orchestrator | 9 | yes — ui-inputs :invalid (56 sites) |
works |
| isms | 1 | yes — ui-inputs :invalid |
works |
| town-crier | 3 | yes — inline :aria-invalid |
works |
| emmie | 3 (incl. every createFormModal) |
no — marks .form-error by id, and already scrolls in FieldError.vue (EMMIE-0525) |
silent no-op; double-scroll the day it adopts aria-invalid |
| ublgenie | 15 | no — editorial Input/Field set no aria-invalid |
silent no-op |
So the PR's Why ("consumer form owners re-implement scroll-to-first-error by hand today … lets those hand-rolled watchers be deleted") is true of exactly one implementation in the fleet — emmie's, the use case this came from — and that one keys on a marker this PR does not look for. As written, the originating territory cannot delete its watcher by bumping. Details inline.
Blocking (2): (1) make the claim true — either a scrollTarget selector option (default [aria-invalid="true"]) so emmie passes .form-error today, or at minimum state the precondition in the docs and drop the "delete your watchers" line; (2) honour prefers-reduced-motion now, not as a follow-up — ui-inputs already zeroes its transitions under it, so 0.2.0 would be the first Armory surface to animate against the user's setting.
Non-blocking (3): focus-vs-scroll (the react-hook-form precedent cited focuses), the default-on cross-form hazard with a shared HttpService (dialog over page — emmie's exact shape), and two test-hygiene points.
Version: ^0.1.1 does not admit 0.2.0, so no consumer flips behaviour without an explicit bump — good; the fleet bump is a war-room wave after this lands.
Honor prefers-reduced-motion (JS scrollIntoView bypasses the CSS media query, so behavior falls back to 'auto' explicitly). Add scrollTarget option (default [aria-invalid="true"]) so a consumer can name its own error mark instead of the ui-inputs default. Docs: state the aria-invalid precondition, make scrollRoot required for a dialog over a page form on the same HttpService, and correct the claim that a co-mounted form scrolls to its own field (document order can pick another form's). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks both — addressed in the incoming commit. Summary of what changed and where we're holding the line. Fixed
Keeping the default
|
jasperboerhof
left a comment
There was a problem hiding this comment.
Crit review
2 issues · 1 nitpick · head d37f8fb01f
Crit requests changes — 2 issues.
Issues
The scroll watcher throws when a DOM runtime lacks global matchMedia
packages/form/src/scroll-to-first-error.ts:36 — see inline
Clearing the error bag can scroll an unrelated invalid field
packages/form/src/scroll-to-first-error.ts:29 — see inline
1 nitpick
Malformed 422 field values can leave consumers with incorrect or stale messages
packages/form/src/validation-errors.ts:15 — toFieldErrorMap casts every error entry to string[] without validation. mapFieldErrors reads messages[0] before assigning the new bag. A string stores its first character. A null entry throws, leaving consumers with the previous validation message.
nitpick because pre-existing — this pull request did not write those lines
Settled, not re-filed: 1
Review findings on PR script-development#246 (head d37f8fb): - The scroll watcher called global matchMedia unconditionally, throwing on a DOM runtime that lacks it (e.g. jsdom) before scrollIntoView ran. Guard with `typeof matchMedia === 'function'`; fall back to 'smooth'. - clearErrors replaces the bag with an empty object, and the document-wide query could then match an independently-invalid co-mounted field and scroll to it. Return early when the bag is empty, before querying. Adds a regression test for each. The deferred shared-service / document-wide scope concerns are unchanged, as agreed on the PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jasperboerhof
left a comment
There was a problem hiding this comment.
Crit review
2 issues · 0 nitpicks · head 014f8eef1a
Crit requests changes — 2 issues.
Issues
Default scrolling throws when DOM-free useForm consumers populate validation errors
packages/form/src/scroll-to-first-error.ts:34 — see inline
Late-bound scrollRoot values never trigger scrolling for existing validation errors
packages/form/src/scroll-to-first-error.ts:34 — see inline
crit-ai
left a comment
There was a problem hiding this comment.
Crit review
0 issues · 1 nitpick · head 014f8eef1a
Crit approves — nothing blocking at this head.
1 nitpick
Malformed 422 field values bypass validation and can hide actionable form errors
packages/form/src/validation-errors.ts:15 — mapFieldErrors indexes each field value without confirming a string array. A string value stores only its first character. A null value throws before errors.value updates. guarded swallows that throw while handleSubmit swallows the 422.
nitpick because pre-existing — this pull request did not write those lines
Settled, not re-filed: 1
An HttpService is shared, so a 422 fills every mounted form's error bag and a watcher on that bag cannot tell whose refusal it saw. On by default, a form with no scrollRoot queries the whole document and can scroll the page to a neighbouring form's field. Off unless asked for, with scrollRoot alongside it, so the documented mitigation is the behaviour rather than a caveat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merging with the shared-bag limitation documented, not fixed, and the feature made opt-in. The two crits from the first round are correct, and this PR does not close them. Closing them properly means moving the trigger off the bag and onto the submit, so the scroll runs only for the action What changed instead is the default. |
crit-ai
left a comment
There was a problem hiding this comment.
Crit review
1 issue · 1 nitpick · head 55fb70e070
Crit requests changes — 1 issue.
Issues
Documented scroll examples omit scrollToError, leaving the watcher unregistered.
docs/packages/form.md:79 — see inline
1 nitpick
Malformed 422 field values produce incorrect messages or preserve stale errors.
packages/form/src/validation-errors.ts:15 — mapFieldErrors indexes every messages value without confirming that it is a string array. A string value yields its first character, while null throws inside guarded. guarded retains the prior error bag after that throw. Users can see an incorrect or stale validation message after a malformed 422 response.
nitpick because pre-existing — this pull request did not write those lines
crit-ai
left a comment
There was a problem hiding this comment.
Crit review
0 issues · 1 nitpick · head 34e9a90b08
Crit requests changes — 1 open thread.
1 nitpick
Unchecked 422 field values truncate messages or leave the validation error bag empty.
packages/form/src/validation-errors.ts:15 — toFieldErrorMap casts each errors value to string[] without checking it. mapFieldErrors reads messages[0], turning a string value into its first character. guarded catches null-value failures, while useFormSubmit swallows the original 422. Non-Laravel callers can lose validation errors after a rejected submission.
nitpick because pre-existing — this pull request did not write those lines
Still open
docs/packages/form.md — Scroll examples omit the required opt-in, so copied forms never register the watcher.
Settled, not re-filed: 1
The default flipped to `false` but this section still read as if the scroll were on: one example claimed it in a comment, two others passed `scrollRoot` or `scrollTarget` alone and so registered no watcher. A reader copying any of them got nothing, while the closing paragraph said the opposite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
crit-ai
left a comment
There was a problem hiding this comment.
Crit review
1 issue · 1 nitpick · head 4b9bd428fb
Crit requests changes — 1 issue.
Issues
The primitive docs offer useForm-only scroll options to useValidationErrors
docs/packages/form.md:158 — not on the diff, so not inline
The primitive documentation gives useValidationErrors the same options as useForm. UseValidationErrorsOptions defines only keyMapper. useForm alone installs useScrollToFirstError. Consumers passing object literals receive excess-property errors or no scrolling.
1 nitpick
Validate 422 field messages before indexing their first element
packages/form/src/validation-errors.ts:15 — The cast treats each errors value as string[] without runtime validation. mapFieldErrors indexes strings as message arrays. mapFieldErrors throws for null values. Users receive truncated messages or no field feedback.
nitpick because pre-existing — this pull request did not write those lines
Settled, not re-filed: 2
UseFormOptions was an alias of UseValidationErrorsOptions until this branch added the three scroll options to it, which quietly falsified "same options as useForm" in the primitive's entry. Only useForm installs the scroll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
crit-ai
left a comment
There was a problem hiding this comment.
Crit review
1 issue · 0 nitpicks · head f841a5b420
Crit requests changes — 1 issue.
Issues
The 0.2.0 fs-form release lacks a changeset entry for its new public options.
packages/form/package.json:3 — see inline
Settled, not re-filed: 3
What
useFormnow scrolls the first invalid field ([aria-invalid="true"]) into view after a 422populates the error bag, so the user lands on the first thing to fix. Two optional knobs:
scrollToError: falseturns it off, andscrollRootscopes it to one form on a multi-form page.Why
useFormsurfaces validation errors but leaves the viewport wherever the user submitted — on a longform the first error is often off-screen. Consumer form owners re-implement scroll-to-first-error by
hand today (watch
errors->nextTick->scrollIntoView) at every form. Moving it intouseFormgives every consumer the behaviour once and lets those hand-rolled watchers be deleted.
Design
useForm, notuseValidationErrors. The primitive stays pure and DOM-free (unchanged).Scrolling is opinionated presentation behaviour, so it sits in the composite that already owns
submitting. A zero-DOM consumer usesuseValidationErrorsdirectly, or passesscrollToError: false.The wiring helper is internal (not exported).
aria-invalid, derives nothing. It targets[aria-invalid="true"], which thepresentation layer already sets from the error bag.
useFormreads that attribute — it computes noids and marks no fields itself, so it stays agnostic about how fields render.
flush: 'post'runs the scroll after the DOM update that paints the mark. The watcher isregistered in
setup()(viauseForm), so it stops on unmount.shouldFocusError); mirrorsfs-dialog'scloseOnBackdropClick(defaulttrue, opt outfalse).scrollRoot(optional) scopes the query to one form's subtree, so on a page with several forms a422 in one never scrolls to another's field. Omit it and the query is document-wide (back-compat, and
right for a single form). A passed-but-
nullref is a no-op — it never silently falls back to adocument-wide search, so opted-in scoping is never re-widened.
API
UseFormOptionsgains two optional fields;useValidationErrors/useFormSubmitare unchanged.Tests
Six cases in
form.spec.ts, in the existing happy-dom + mock-service style: scrolls on a 422, opt-outfalse, no re-scroll onclearErrors, no-op when nothing is marked invalid, scoped toscrollRoot,ignores an invalid field outside
scrollRoot, and no document fallback whenscrollRootis null.tsc, oxlint, oxfmt clean;scroll-to-first-error.tsandform.tsare at 100% coverage AND 100%mutation (package 94.83%, above the 90% threshold).
Version
Bumped
fs-form0.1.1->0.2.0(minor = new option, perdocs/contributing.md; version bumps areauthor-managed here).
fs-formhas no dependent packages, so no peer-range cascade.Possible follow-ups (not in this PR)
prefers-reduced-motion— the scroll isbehavior: 'smooth'; a reduced-motion-aware behaviour isa reasonable a11y follow-up.
invalid field is a further a11y step.
🤖 Generated with Claude Code