feat(form): scroll to first invalid field on validation error - #246
feat(form): scroll to first invalid field on validation error#246Confmc wants to merge 2 commits into
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
| const validation = useValidationErrors<T>(httpService, options); | ||
| const {handleSubmit, submitting} = useFormSubmit(validation); | ||
|
|
||
| if (scrollToError) useScrollToFirstError(validation.errors, scrollRoot); |
There was a problem hiding this comment.
Shared HttpService responses populate every useForm error bag and trigger unrelated scrolls
Each useValidationErrors middleware assigns its own errors ref for every 422 response. Every default-enabled watcher then scrolls, including a co-mounted form that did not submit. A 422 from form A can scroll form B while the user is correcting form A.
crit · finding 3cfd0ad041a2
There was a problem hiding this comment.
Confirmed. registerResponseErrorMiddleware is a global axios interceptor on the shared httpService instance, not scoped per request — every mounted useForm() sharing that service gets its errors ref rewritten on ANY 422, including one triggered by a sibling form's submit.
| watch( | ||
| errors, | ||
| () => { | ||
| const scope = root === undefined ? document : root.value; |
There was a problem hiding this comment.
Document-wide scrolling selects another form's invalid field before the submitting form
When scrollRoot is omitted, useScrollToFirstError queries document for the first invalid element. That query ignores which useForm instance received the 422 response. An earlier invalid control in form B can scroll the user away from form A after A's 422.
crit · finding bd0c2be21ef3
There was a problem hiding this comment.
Confirmed, same root cause as the sibling thread on form.ts:32 — document-wide querySelector has no notion of which useForm submitted, so an earlier invalid control from an unrelated mounted form wins the scroll. scrollRoot fixes it but is opt-in; nothing here forces a multi-form page to pass it.
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.
| errors, | ||
| () => { | ||
| const scope = root === undefined ? document : root.value; | ||
| scope?.querySelector('[aria-invalid="true"]')?.scrollIntoView({behavior: 'smooth', block: 'center'}); |
There was a problem hiding this comment.
Major — the selector is the whole contract, and two of five fleet consumers do not satisfy it. emmie renders no aria-invalid anywhere (frontend/apps/** has zero occurrences); its FieldError.vue toggles a .form-error class on the control by id and scrolls itself (EMMIE-0525 — your own implementation, scoped to closest('form')). ublgenie's editorial Input/Field set no aria-invalid either, across 15 useForm sites. On both, this line is a silent no-op after the bump, and on emmie it becomes a double scroll the day aria-invalid is added while FieldError still scrolls.
Two ways to make the PR's Why true, pick one:
scrollTarget?: string(default'[aria-invalid="true"]') — emmie passes'.form-error'and deletes theFieldErrorscroll today; keeps "derives nothing", the consumer names the marker. One line here, one intypes.ts, one test.- Minimum: keep the selector fixed, but say in
docs/packages/form.mdthat the feature is inert unless the consumer rendersaria-invalid="true"from the bag, and drop the "lets those hand-rolled watchers be deleted" line from the body — it is not true for the territory this came from.
I would also take a dev-only warning (errors non-empty, no match in scope) — ublgenie's FormField.vue does exactly that for the property-without-errors case, and a feature that silently does nothing is the failure mode ADR-0048 exists to name. That one is optional.
There was a problem hiding this comment.
Checked scroll-to-first-error.ts: the selector is hardcoded to [aria-invalid="true"] with no override, so this is confirmed as a silent no-op for any consumer whose field components don't set that attribute from the error bag. Agree scrollTarget with a documented default is the cleaner fix over a docs-only caveat.
There was a problem hiding this comment.
Update from the Commander (2026-09-07), narrowing this Major. Marcel is leading the emmie migration and emmie will add the aria-invalid mark itself, retiring FieldError.vue's hand-rolled scroll in the same change. So option 1 (scrollTarget) is not needed on emmie's account — keep the fixed selector.
What remains of this finding is option 2, docs-only: state the precondition as a precondition (the feature is inert unless the consumer renders aria-invalid="true" from the bag) and drop "lets those hand-rolled watchers be deleted" from the body — ublgenie (15 useForm sites, editorial inputs set no aria-invalid) still gets a silent no-op on bump and will decide mark-vs-scrollToError:false in its own PR. With that wording change this thread is settled from my side.
The prefers-reduced-motion thread below stands unchanged — that one belongs in the package, not in a consumer.
| errors, | ||
| () => { | ||
| const scope = root === undefined ? document : root.value; | ||
| scope?.querySelector('[aria-invalid="true"]')?.scrollIntoView({behavior: 'smooth', block: 'center'}); |
There was a problem hiding this comment.
Major — behavior: 'smooth' ignores prefers-reduced-motion. A JS-specified behavior is not overridden by the user's OS setting in any engine; only CSS scroll-behavior respects the media query, and this call bypasses CSS. ui-inputs already zeroes every ui-* transition under @media (prefers-reduced-motion: reduce) (docs/packages/ui-inputs.md), so this would be the first Armory surface that animates against the setting — and emmie and isms are the care/compliance consumers. It is listed as a follow-up in the body; follow-up lists on package PRs are where a11y goes to die, and the fix is one expression:
const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';Alternative that is even more in the PR's own spirit of "opinionated about nothing": pass no behavior at all and let the consumer's CSS scroll-behavior (which does honour the media query) decide. Either is fine; shipping smooth unconditionally is not. The existing test asserting {behavior: 'smooth', block: 'center'} will need a matchMedia stub — happy-dom provides one.
There was a problem hiding this comment.
Confirmed in code: scrollIntoView is called unconditionally with behavior: 'smooth', no matchMedia(prefers-reduced-motion) check anywhere in scroll-to-first-error.ts. Agree this is a real a11y regression, not a fair follow-up deferral.
| watch( | ||
| errors, | ||
| () => { | ||
| const scope = root === undefined ? document : root.value; |
There was a problem hiding this comment.
Minor — default-on + document-wide + a shared HttpService is the modal-over-page shape, and that is emmie's exact shape. createFormModal in emmie's services/dialog.ts calls useForm(userHttpService) per dialog; the page beneath may hold its own useForm on the same service. A 422 from the dialog fills both bags; if the page form shares a field key, the page's control is first in document order and this scrolls the page behind the backdrop. The docs name the shared-service case, but frame it as "scrolls to its own matching field" — the failure is that the other form's field wins the querySelector race. scrollRoot fixes it, but only if every consumer knows to pass it; emmie's hand-rolled version scoped by closest('form') automatically and would regress on migration.
Cheapest hardening: skip targets that are not rendered (el.checkVisibility?.() ?? true), and say in the docs that a dialog-hosted form must pass scrollRoot. A closest('form') fallback is not available to you here — useForm has no element — which is the real argument for making the scope explicit rather than "omit = document".
There was a problem hiding this comment.
Same root cause as the two crit threads above (shared httpService / document-wide query race) — agree scrollRoot needs to be documented as required for dialog-hosted forms, not left implicit.
|
|
||
| ## Scroll to the First Error | ||
|
|
||
| On a 422, `useForm` scrolls the first invalid field into view so the user lands on the first thing to fix — it targets the first `[aria-invalid="true"]` element (the marker the presentation layer sets from the error bag) and calls `scrollIntoView({behavior: 'smooth', block: 'center'})` after the mark is painted. `useForm` derives no ids and marks no fields itself. |
There was a problem hiding this comment.
Minor — "the marker the presentation layer sets from the error bag" is a claim about consumers the package does not check. It holds for ui-inputs consumers (BIO, isms) and town-crier; it does not hold for emmie or ublgenie today. State the precondition as a precondition ("requires the consumer to render aria-invalid=\"true\" on the errored control") rather than as a description of the world. Also: the react-hook-form precedent cited in the PR body is shouldFocusError — it focuses, which scrolls for free and gives keyboard/AT users a landing point; this scrolls without focusing, which the body acknowledges. Fine to defer focus, but do not cite the precedent as if this matches it.
| describe('useForm scroll-to-error', () => { | ||
| let scrollIntoView: ReturnType<typeof vi.spyOn>; | ||
|
|
||
| beforeEach(() => { |
There was a problem hiding this comment.
Minor — test hygiene, two points. (1) Every case here attaches to document.body and unmounts at the end of the test, so a failing assertion leaks a mounted [aria-invalid="true"] into every later case and the document-wide query then finds a stale node — put wrapper.unmount() in an afterEach. (2) Every fixture renders the mark in the same component that calls useForm; the fleet shape is a child component (ui-inputs TextInput, emmie FieldError) receiving the bag as a prop. The flush: 'post' claim is correct across that boundary, but no test proves it — add one with a child that renders aria-invalid from a prop, since that is the case a future refactor to flush: 'sync' or a manual nextTick would break.
There was a problem hiding this comment.
Confirmed on point 1: form.spec.ts calls wrapper.unmount() at the end of each test body, not in afterEach, so a failing assertion mid-test skips it and leaks a marked node into later tests' document-wide query.
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
|
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