From 4a667f77bbe27735d9838e21d687a7c8f8f16ea3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 7 Sep 2026 09:50:03 +0200 Subject: [PATCH 1/6] feat(form): scroll to first invalid field on validation error 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 --- docs/packages/form.md | 30 +++- package-lock.json | 2 +- packages/form/package.json | 2 +- packages/form/src/form.ts | 6 +- packages/form/src/scroll-to-first-error.ts | 25 ++++ packages/form/src/types.ts | 18 ++- packages/form/tests/form.spec.ts | 159 ++++++++++++++++++++- 7 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 packages/form/src/scroll-to-first-error.ts diff --git a/docs/packages/form.md b/docs/packages/form.md index 770605c..23bf647 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -71,6 +71,26 @@ const {errors, submitting, handleSubmit} = useForm(http, {keyMapper: came The two source territories diverged on exactly one axis: one camelCased the error keys, the other used them raw. `keyMapper` (default identity) is the single injection point that absorbs that divergence, so the package fits both without forking. ::: +## 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. + +```typescript +useForm(http); // scrolls on error (default) +useForm(http, {scrollToError: false}); // opt out +``` + +By default the query is **document-wide** — the first `[aria-invalid="true"]` in document order — which is right for a single form. When several forms share a page, pass each form's root as `scrollRoot` so a 422 in one never scrolls to another's field: + +```typescript +const formEl = ref(null); +useForm(http, {scrollRoot: formEl}); // scopes the scroll to formEl's subtree +``` + +`scrollRoot` keeps a form's scroll within its own subtree, but it does **not** isolate forms that share one `HttpService`: a 422 fills every such form's error bag (see [Scoping & Backend Contract](#scoping--backend-contract) below), so a co-mounted form still scrolls to its _own_ matching field on an unrelated submit. Give concurrently-mounted forms separate `HttpService` instances to avoid that. + +`useValidationErrors` never scrolls (the DOM-free primitive). A consumer that already scrolls on error should opt out with `scrollToError: false` to avoid a double scroll. + ## Composing the Primitives `useForm` is `useValidationErrors` + `useFormSubmit` wired together. Reach for the primitives directly when you want one half without the other — e.g. a validation-less confirm action needs the submit guard but no 422 middleware: @@ -106,10 +126,12 @@ const {handleSubmit, submitting} = useFormSubmit(validation); The one-call entry point. Returns everything from both primitives. -| Parameter | Type | Description | -| ------------------- | ------------------------- | ---------------------------------------------------- | -| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe | -| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) | +| Parameter | Type | Description | +| ----------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe | +| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) | +| `options.scrollToError` | `boolean` | Scroll the first invalid field into view on a 422 (default: `true`; see [Scroll to the First Error](#scroll-to-the-first-error)) | +| `options.scrollRoot` | `Ref` | Scope the `scrollToError` query to a form's subtree; omit for document-wide (see [Scroll to the First Error](#scroll-to-the-first-error)) | **Returns:** diff --git a/package-lock.json b/package-lock.json index ca0f2ac..26129c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9255,7 +9255,7 @@ }, "packages/form": { "name": "@script-development/fs-form", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@script-development/fs-http": "^0.5.0 || ^0.6.0", diff --git a/packages/form/package.json b/packages/form/package.json index f7c433b..0ae2ccd 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/fs-form", - "version": "0.1.1", + "version": "0.2.0", "description": "Reactive form-submit helpers: double-submit guard plus 422 validation-error binding for fs-http", "homepage": "https://packages.script.nl/packages/form", "license": "MIT", diff --git a/packages/form/src/form.ts b/packages/form/src/form.ts index 7626e1b..003861a 100644 --- a/packages/form/src/form.ts +++ b/packages/form/src/form.ts @@ -3,6 +3,7 @@ import type {HttpService} from '@script-development/fs-http'; import type {UseForm, UseFormOptions} from './types'; import {useFormSubmit} from './form-submit'; +import {useScrollToFirstError} from './scroll-to-first-error'; import {useValidationErrors} from './validation-errors'; /** @@ -18,14 +19,17 @@ import {useValidationErrors} from './validation-errors'; * validation-less confirm action). * * @param httpService the fs-http service whose 422 responses to observe. - * @param options `keyMapper` remaps raw backend field keys (default identity). + * @param options `keyMapper`, `scrollToError`, `scrollRoot` — see `UseFormOptions`. */ export const useForm = ( httpService: HttpService, options: UseFormOptions = {}, ): UseForm => { + const {scrollToError = true, scrollRoot} = options; const validation = useValidationErrors(httpService, options); const {handleSubmit, submitting} = useFormSubmit(validation); + if (scrollToError) useScrollToFirstError(validation.errors, scrollRoot); + return {errors: validation.errors, clearErrors: validation.clearErrors, handleSubmit, submitting}; }; diff --git a/packages/form/src/scroll-to-first-error.ts b/packages/form/src/scroll-to-first-error.ts new file mode 100644 index 0000000..60c05a9 --- /dev/null +++ b/packages/form/src/scroll-to-first-error.ts @@ -0,0 +1,25 @@ +import type {Ref} from 'vue'; + +import {watch} from 'vue'; + +import type {ValidationErrors} from './types'; + +/** + * On every `errors` change, scroll the first `[aria-invalid="true"]` into view (the + * mark the presentation layer sets); a no-op when nothing is marked. `root` scopes + * the query to one form's subtree — omitted: document-wide; `null`: no scroll, never + * falling back to document. + * + * `flush: 'post'` fires after the mark paints. Call inside `setup()` (as `useForm` + * does) so the watcher stops on unmount. + */ +export const useScrollToFirstError = (errors: Ref, root?: Ref): void => { + watch( + errors, + () => { + const scope = root === undefined ? document : root.value; + scope?.querySelector('[aria-invalid="true"]')?.scrollIntoView({behavior: 'smooth', block: 'center'}); + }, + {flush: 'post'}, + ); +}; diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts index ec90eb0..70bb2fc 100644 --- a/packages/form/src/types.ts +++ b/packages/form/src/types.ts @@ -36,8 +36,22 @@ export type UseFormSubmit = { submitting: Ref; }; -/** Options for `useForm` (currently the validation options). */ -export type UseFormOptions = UseValidationErrorsOptions; +/** Options for `useForm`: the validation options plus `useForm`-only behaviour. */ +export type UseFormOptions = UseValidationErrorsOptions & { + /** + * On a 422, scroll the first `[aria-invalid="true"]` field into view (the mark + * the presentation layer sets). `false` opts out; `useValidationErrors` is the + * DOM-free alternative. + * @default true + */ + scrollToError?: boolean; + /** + * Scopes the `scrollToError` query to one form's subtree — pass it when forms + * share a page. Omitted: document-wide. Provided but `null`: no scroll (never + * falls back to document). + */ + scrollRoot?: Ref; +}; /** * Everything `useForm` returns: the field-error bag and `clearErrors` from diff --git a/packages/form/tests/form.spec.ts b/packages/form/tests/form.spec.ts index bf5c5c3..5b72183 100644 --- a/packages/form/tests/form.spec.ts +++ b/packages/form/tests/form.spec.ts @@ -3,8 +3,8 @@ import type {AxiosResponseError, HttpService, ResponseErrorMiddlewareFunc} from import type {AxiosError} from 'axios'; import {mount} from '@vue/test-utils'; -import {afterEach, describe, expect, it, vi} from 'vitest'; -import {defineComponent} from 'vue'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {defineComponent, h, nextTick, ref} from 'vue'; import type {UseForm, UseFormOptions} from '../src'; @@ -53,6 +53,47 @@ const mountForm = (httpService: HttpService, options? return {wrapper, result: () => result}; }; +// Mounts (attached to the document) a form that renders one field marked `aria-invalid` +// while the error bag is non-empty — the shape the scroll watcher queries for. +const mountFieldForm = (httpService: HttpService, options?: UseFormOptions, markInvalid = true) => { + let result!: UseForm; + const wrapper = mount( + defineComponent({ + setup() { + result = useForm(httpService, options); + return () => + h('input', { + 'aria-invalid': markInvalid && Object.keys(result.errors.value).length > 0 ? 'true' : 'false', + }); + }, + }), + {attachTo: document.body}, + ); + return {wrapper, result: () => result}; +}; + +// Mounts (attached) a form whose invalid field sits inside (or outside) a `scrollRoot` element, +// to prove the query is scoped to that root's subtree. +const mountScopedForm = (httpService: HttpService, fieldInsideRoot: boolean) => { + const scrollRoot = ref(null); + let result!: UseForm; + const wrapper = mount( + defineComponent({ + setup() { + result = useForm(httpService, {scrollRoot}); + const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false'); + return () => + h('div', [ + h('div', {ref: scrollRoot}, [fieldInsideRoot ? h('input', {'aria-invalid': marked()}) : null]), + fieldInsideRoot ? null : h('input', {'aria-invalid': marked()}), + ]); + }, + }), + {attachTo: document.body}, + ); + return {wrapper, result: () => result}; +}; + const deferred = () => { let resolve!: () => void; const promise = new Promise((res) => { @@ -150,3 +191,117 @@ describe('useForm', () => { expect(errorMiddlewares).toHaveLength(0); }); }); + +describe('useForm scroll-to-error', () => { + let scrollIntoView: ReturnType; + + beforeEach(() => { + scrollIntoView = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {}); + }); + + it('scrolls the first invalid field into view after a 422', async () => { + const {httpService, triggerError} = createMockHttpService(); + const {wrapper} = mountFieldForm(httpService); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).toHaveBeenCalledWith({behavior: 'smooth', block: 'center'}); + wrapper.unmount(); + }); + + it('scrolls to the first invalid field in document order when several are marked', async () => { + const {httpService, triggerError} = createMockHttpService(); + let result!: UseForm; + const wrapper = mount( + defineComponent({ + setup() { + result = useForm(httpService); + const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false'); + return () => + h('div', [h('input', {'aria-invalid': marked()}), h('input', {'aria-invalid': marked()})]); + }, + }), + {attachTo: document.body}, + ); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + const invalid = document.querySelectorAll('[aria-invalid="true"]'); + expect(invalid).toHaveLength(2); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView.mock.contexts[0]).toBe(invalid[0]); + wrapper.unmount(); + }); + + it('does not scroll when scrollToError is false', async () => { + const {httpService, triggerError} = createMockHttpService(); + const {wrapper} = mountFieldForm(httpService, {scrollToError: false}); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('does not scroll again once the error bag is cleared', async () => { + const {httpService, triggerError} = createMockHttpService(); + const {wrapper, result} = mountFieldForm(httpService); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + result().clearErrors(); + await nextTick(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + + it('no-ops when a 422 marks no field invalid', async () => { + const {httpService, triggerError} = createMockHttpService(); + const {wrapper} = mountFieldForm(httpService, undefined, false); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('scopes the scroll to scrollRoot when provided', async () => { + const {httpService, triggerError} = createMockHttpService(); + const {wrapper} = mountScopedForm(httpService, true); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).toHaveBeenCalledWith({behavior: 'smooth', block: 'center'}); + wrapper.unmount(); + }); + + it('does not scroll to an invalid field outside scrollRoot', async () => { + const {httpService, triggerError} = createMockHttpService(); + const {wrapper} = mountScopedForm(httpService, false); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('does not fall back to the document when scrollRoot is null', async () => { + const {httpService, triggerError} = createMockHttpService(); + const scrollRoot = ref(null); // passed but never bound -> stays null + const {wrapper} = mountFieldForm(httpService, {scrollRoot}); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + wrapper.unmount(); + }); +}); From d37f8fb01fb3be6c2ea478d009b80024ed72da07 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 7 Sep 2026 15:40:47 +0200 Subject: [PATCH 2/6] feat(form): reduced-motion + scrollTarget for scroll-to-error 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 --- docs/packages/form.md | 17 +++++-- packages/form/src/form.ts | 6 +-- packages/form/src/scroll-to-first-error.ts | 28 ++++++++--- packages/form/src/types.ts | 21 +++++++-- packages/form/tests/form.spec.ts | 55 ++++++++++++++++++++++ 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/docs/packages/form.md b/docs/packages/form.md index 23bf647..a24e6fb 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -73,21 +73,31 @@ The two source territories diverged on exactly one axis: one camelCased the erro ## 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. +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 and calls `scrollIntoView({block: 'center'})` after the mark is painted. `useForm` derives no ids and marks no fields itself, so the feature is **inert unless the presentation layer marks the errored control** — `@script-development/ui-inputs` renders `aria-invalid` from `:invalid` out of the box. ```typescript useForm(http); // scrolls on error (default) useForm(http, {scrollToError: false}); // opt out ``` -By default the query is **document-wide** — the first `[aria-invalid="true"]` in document order — which is right for a single form. When several forms share a page, pass each form's root as `scrollRoot` so a 422 in one never scrolls to another's field: +**Reduced motion is honoured.** The scroll is `behavior: 'smooth'`, except under `prefers-reduced-motion: reduce`, where it falls back to `'auto'` — a JS `scrollIntoView` behavior is not subject to the CSS media query, so it is checked explicitly. + +**Marks with a class instead of `aria-invalid`?** Point `scrollTarget` at your own selector: + +```typescript +useForm(http, {scrollTarget: '.field-error'}); +``` + +### Forms that share a page + +By default the query is **document-wide** — the first matching element in document order — which is right for a single form. When forms share a page, pass each form's root as `scrollRoot`: ```typescript const formEl = ref(null); useForm(http, {scrollRoot: formEl}); // scopes the scroll to formEl's subtree ``` -`scrollRoot` keeps a form's scroll within its own subtree, but it does **not** isolate forms that share one `HttpService`: a 422 fills every such form's error bag (see [Scoping & Backend Contract](#scoping--backend-contract) below), so a co-mounted form still scrolls to its _own_ matching field on an unrelated submit. Give concurrently-mounted forms separate `HttpService` instances to avoid that. +`scrollRoot` is **required** for a dialog opened over a page form on the same `HttpService`. A 422 fills every such form's error bag (see [Scoping & Backend Contract](#scoping--backend-contract) below), so both forms mark their fields; a document-wide query then scrolls to whichever comes first in document order — often the _page's_ field, behind the backdrop, not the dialog's. Scope each form with `scrollRoot`, or give concurrently-mounted forms separate `HttpService` instances. `useValidationErrors` never scrolls (the DOM-free primitive). A consumer that already scrolls on error should opt out with `scrollToError: false` to avoid a double scroll. @@ -132,6 +142,7 @@ The one-call entry point. Returns everything from both primitives. | `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) | | `options.scrollToError` | `boolean` | Scroll the first invalid field into view on a 422 (default: `true`; see [Scroll to the First Error](#scroll-to-the-first-error)) | | `options.scrollRoot` | `Ref` | Scope the `scrollToError` query to a form's subtree; omit for document-wide (see [Scroll to the First Error](#scroll-to-the-first-error)) | +| `options.scrollTarget` | `string` | Selector for the invalid-field mark (default `[aria-invalid="true"]`); pass your own when inputs mark errors with a class | **Returns:** diff --git a/packages/form/src/form.ts b/packages/form/src/form.ts index 003861a..5826b9e 100644 --- a/packages/form/src/form.ts +++ b/packages/form/src/form.ts @@ -19,17 +19,17 @@ import {useValidationErrors} from './validation-errors'; * validation-less confirm action). * * @param httpService the fs-http service whose 422 responses to observe. - * @param options `keyMapper`, `scrollToError`, `scrollRoot` — see `UseFormOptions`. + * @param options `keyMapper`, `scrollToError`, `scrollRoot`, `scrollTarget` — see `UseFormOptions`. */ export const useForm = ( httpService: HttpService, options: UseFormOptions = {}, ): UseForm => { - const {scrollToError = true, scrollRoot} = options; + const {scrollToError = true, scrollRoot, scrollTarget} = options; const validation = useValidationErrors(httpService, options); const {handleSubmit, submitting} = useFormSubmit(validation); - if (scrollToError) useScrollToFirstError(validation.errors, scrollRoot); + if (scrollToError) useScrollToFirstError(validation.errors, scrollRoot, scrollTarget); return {errors: validation.errors, clearErrors: validation.clearErrors, handleSubmit, submitting}; }; diff --git a/packages/form/src/scroll-to-first-error.ts b/packages/form/src/scroll-to-first-error.ts index 60c05a9..b6e89b6 100644 --- a/packages/form/src/scroll-to-first-error.ts +++ b/packages/form/src/scroll-to-first-error.ts @@ -4,21 +4,37 @@ import {watch} from 'vue'; import type {ValidationErrors} from './types'; +/** The mark `@script-development/ui-inputs` renders from `:invalid`. */ +const DEFAULT_TARGET = '[aria-invalid="true"]'; + /** - * On every `errors` change, scroll the first `[aria-invalid="true"]` into view (the - * mark the presentation layer sets); a no-op when nothing is marked. `root` scopes - * the query to one form's subtree — omitted: document-wide; `null`: no scroll, never - * falling back to document. + * On every `errors` change, scroll the first invalid field into view (the mark the + * presentation layer sets); a no-op when nothing is marked. + * + * - `root` scopes the query to one form's subtree — omitted: document-wide; `null`: no + * scroll, never falling back to document. + * - `target` is the selector for the mark (default `[aria-invalid="true"]`); pass your + * own when your inputs mark errors with a class instead. + * - `behavior` is `'auto'` under `prefers-reduced-motion: reduce` — a JS `scrollIntoView` + * behavior is not subject to the CSS media query, so it is honoured here explicitly. * * `flush: 'post'` fires after the mark paints. Call inside `setup()` (as `useForm` * does) so the watcher stops on unmount. */ -export const useScrollToFirstError = (errors: Ref, root?: Ref): void => { +export const useScrollToFirstError = ( + errors: Ref, + root?: Ref, + target: string = DEFAULT_TARGET, +): void => { watch( errors, () => { const scope = root === undefined ? document : root.value; - scope?.querySelector('[aria-invalid="true"]')?.scrollIntoView({behavior: 'smooth', block: 'center'}); + const field = scope?.querySelector(target); + if (!field) return; + + const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; + field.scrollIntoView({behavior, block: 'center'}); }, {flush: 'post'}, ); diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts index 70bb2fc..0ac3c31 100644 --- a/packages/form/src/types.ts +++ b/packages/form/src/types.ts @@ -39,18 +39,29 @@ export type UseFormSubmit = { /** Options for `useForm`: the validation options plus `useForm`-only behaviour. */ export type UseFormOptions = UseValidationErrorsOptions & { /** - * On a 422, scroll the first `[aria-invalid="true"]` field into view (the mark - * the presentation layer sets). `false` opts out; `useValidationErrors` is the - * DOM-free alternative. + * On a 422, scroll the first invalid field into view. Requires the presentation + * layer to mark the errored control (the default target is `[aria-invalid="true"]`, + * which `@script-development/ui-inputs` renders from `:invalid`); the feature is inert + * if nothing is marked. `false` opts out; `useValidationErrors` is the DOM-free + * alternative. * @default true */ scrollToError?: boolean; /** * Scopes the `scrollToError` query to one form's subtree — pass it when forms - * share a page. Omitted: document-wide. Provided but `null`: no scroll (never - * falls back to document). + * share a page (a dialog over a page form on the same `HttpService` **must** pass + * it). Omitted: document-wide. Provided but `null`: no scroll (never falls back to + * document). */ scrollRoot?: Ref; + /** + * CSS selector for the invalid-field mark, used by `scrollToError`. Defaults to + * `'[aria-invalid="true"]'` (what `@script-development/ui-inputs` renders). Pass your + * own when your inputs mark errors differently (e.g. a class) — the package derives + * no ids and marks nothing itself. + * @default '[aria-invalid="true"]' + */ + scrollTarget?: string; }; /** diff --git a/packages/form/tests/form.spec.ts b/packages/form/tests/form.spec.ts index 5b72183..a26a5b1 100644 --- a/packages/form/tests/form.spec.ts +++ b/packages/form/tests/form.spec.ts @@ -304,4 +304,59 @@ describe('useForm scroll-to-error', () => { expect(scrollIntoView).not.toHaveBeenCalled(); wrapper.unmount(); }); + + it('scrolls with auto behavior under prefers-reduced-motion', async () => { + const {httpService, triggerError} = createMockHttpService(); + vi.spyOn(window, 'matchMedia').mockReturnValue({matches: true} as MediaQueryList); + const {wrapper} = mountFieldForm(httpService); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).toHaveBeenCalledWith({behavior: 'auto', block: 'center'}); + wrapper.unmount(); + }); + + it('scrolls after a child paints the mark from a prop (flush: post across the boundary)', async () => { + const {httpService, triggerError} = createMockHttpService(); + const Field = defineComponent({ + props: {invalid: {type: Boolean, required: true}}, + setup: (props) => () => h('input', {'aria-invalid': props.invalid ? 'true' : 'false'}), + }); + const wrapper = mount( + defineComponent({ + setup() { + const {errors} = useForm(httpService); + return () => h(Field, {invalid: Object.keys(errors.value).length > 0}); + }, + }), + {attachTo: document.body}, + ); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + + it('targets a custom scrollTarget selector instead of aria-invalid', async () => { + const {httpService, triggerError} = createMockHttpService(); + let result!: UseForm; + const wrapper = mount( + defineComponent({ + setup() { + result = useForm(httpService, {scrollTarget: '.field-error'}); + return () => h('input', {class: Object.keys(result.errors.value).length > 0 ? 'field-error' : ''}); + }, + }), + {attachTo: document.body}, + ); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); }); From 014f8eef1a8cd7b11a772df9f3389141ff9a3094 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 10 Sep 2026 16:28:36 +0200 Subject: [PATCH 3/6] fix(form): guard matchMedia and skip scroll on an empty error bag Review findings on PR #246 (head d37f8fb01): - 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) --- packages/form/src/scroll-to-first-error.ts | 8 +++-- packages/form/tests/form.spec.ts | 40 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/form/src/scroll-to-first-error.ts b/packages/form/src/scroll-to-first-error.ts index b6e89b6..72ba21b 100644 --- a/packages/form/src/scroll-to-first-error.ts +++ b/packages/form/src/scroll-to-first-error.ts @@ -9,7 +9,7 @@ const DEFAULT_TARGET = '[aria-invalid="true"]'; /** * On every `errors` change, scroll the first invalid field into view (the mark the - * presentation layer sets); a no-op when nothing is marked. + * presentation layer sets); a no-op when the error bag is empty or nothing is marked. * * - `root` scopes the query to one form's subtree — omitted: document-wide; `null`: no * scroll, never falling back to document. @@ -29,12 +29,14 @@ export const useScrollToFirstError = ( watch( errors, () => { + if (!Object.keys(errors.value).length) return; + const scope = root === undefined ? document : root.value; const field = scope?.querySelector(target); if (!field) return; - const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; - field.scrollIntoView({behavior, block: 'center'}); + const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + field.scrollIntoView({behavior: reduced ? 'auto' : 'smooth', block: 'center'}); }, {flush: 'post'}, ); diff --git a/packages/form/tests/form.spec.ts b/packages/form/tests/form.spec.ts index a26a5b1..4158516 100644 --- a/packages/form/tests/form.spec.ts +++ b/packages/form/tests/form.spec.ts @@ -106,6 +106,7 @@ const makeAxiosError = (status: number): AxiosError => ({isAxiosError: true, res afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe('useForm', () => { @@ -260,6 +261,33 @@ describe('useForm scroll-to-error', () => { wrapper.unmount(); }); + it('does not scroll to an independently invalid field when the bag is cleared', async () => { + const {httpService, triggerError} = createMockHttpService(); + let result!: UseForm; + const wrapper = mount( + defineComponent({ + setup() { + result = useForm(httpService); + // This form's own field is marked only while its bag holds errors; the sibling + // stays invalid on its own, unrelated to this form's bag. + const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false'); + return () => + h('div', [h('input', {'aria-invalid': marked()}), h('input', {'aria-invalid': 'true'})]); + }, + }), + {attachTo: document.body}, + ); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + result.clearErrors(); + await nextTick(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + wrapper.unmount(); + }); + it('no-ops when a 422 marks no field invalid', async () => { const {httpService, triggerError} = createMockHttpService(); const {wrapper} = mountFieldForm(httpService, undefined, false); @@ -317,6 +345,18 @@ describe('useForm scroll-to-error', () => { wrapper.unmount(); }); + it('scrolls with smooth behavior when the runtime has no matchMedia', async () => { + const {httpService, triggerError} = createMockHttpService(); + vi.stubGlobal('matchMedia', undefined); + const {wrapper} = mountFieldForm(httpService); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).toHaveBeenCalledWith({behavior: 'smooth', block: 'center'}); + wrapper.unmount(); + }); + it('scrolls after a child paints the mark from a prop (flush: post across the boundary)', async () => { const {httpService, triggerError} = createMockHttpService(); const Field = defineComponent({ From 55fb70e070816b1b4c7ec54a1959b723b4cd0aa7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 11 Sep 2026 12:22:54 +0200 Subject: [PATCH 4/6] fix(form): make scroll-to-error opt-in 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) --- docs/packages/form.md | 18 +++++++++--------- packages/form/src/form.ts | 2 +- packages/form/src/types.ts | 13 +++++++------ packages/form/tests/form.spec.ts | 25 +++++++++++++++++++------ 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/docs/packages/form.md b/docs/packages/form.md index a24e6fb..f7db9ad 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -77,7 +77,7 @@ On a 422, `useForm` scrolls the first invalid field into view so the user lands ```typescript useForm(http); // scrolls on error (default) -useForm(http, {scrollToError: false}); // opt out +useForm(http, {scrollToError: true, scrollRoot}); // opt in, scoped to your form ``` **Reduced motion is honoured.** The scroll is `behavior: 'smooth'`, except under `prefers-reduced-motion: reduce`, where it falls back to `'auto'` — a JS `scrollIntoView` behavior is not subject to the CSS media query, so it is checked explicitly. @@ -99,7 +99,7 @@ useForm(http, {scrollRoot: formEl}); // scopes the scroll to formEl's sub `scrollRoot` is **required** for a dialog opened over a page form on the same `HttpService`. A 422 fills every such form's error bag (see [Scoping & Backend Contract](#scoping--backend-contract) below), so both forms mark their fields; a document-wide query then scrolls to whichever comes first in document order — often the _page's_ field, behind the backdrop, not the dialog's. Scope each form with `scrollRoot`, or give concurrently-mounted forms separate `HttpService` instances. -`useValidationErrors` never scrolls (the DOM-free primitive). A consumer that already scrolls on error should opt out with `scrollToError: false` to avoid a double scroll. +`useValidationErrors` never scrolls (the DOM-free primitive), and `useForm` does not either unless `scrollToError` is passed. A consumer that already scrolls on error simply leaves it off. ## Composing the Primitives @@ -136,13 +136,13 @@ const {handleSubmit, submitting} = useFormSubmit(validation); The one-call entry point. Returns everything from both primitives. -| Parameter | Type | Description | -| ----------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe | -| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) | -| `options.scrollToError` | `boolean` | Scroll the first invalid field into view on a 422 (default: `true`; see [Scroll to the First Error](#scroll-to-the-first-error)) | -| `options.scrollRoot` | `Ref` | Scope the `scrollToError` query to a form's subtree; omit for document-wide (see [Scroll to the First Error](#scroll-to-the-first-error)) | -| `options.scrollTarget` | `string` | Selector for the invalid-field mark (default `[aria-invalid="true"]`); pass your own when inputs mark errors with a class | +| Parameter | Type | Description | +| ----------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe | +| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) | +| `options.scrollToError` | `boolean` | Scroll the first invalid field into view on a 422 (default: `false`; pass `scrollRoot` with it — see [Scroll to the First Error](#scroll-to-the-first-error)) | +| `options.scrollRoot` | `Ref` | Scope the `scrollToError` query to a form's subtree; omit for document-wide (see [Scroll to the First Error](#scroll-to-the-first-error)) | +| `options.scrollTarget` | `string` | Selector for the invalid-field mark (default `[aria-invalid="true"]`); pass your own when inputs mark errors with a class | **Returns:** diff --git a/packages/form/src/form.ts b/packages/form/src/form.ts index 5826b9e..4c2e004 100644 --- a/packages/form/src/form.ts +++ b/packages/form/src/form.ts @@ -25,7 +25,7 @@ export const useForm = ( httpService: HttpService, options: UseFormOptions = {}, ): UseForm => { - const {scrollToError = true, scrollRoot, scrollTarget} = options; + const {scrollToError = false, scrollRoot, scrollTarget} = options; const validation = useValidationErrors(httpService, options); const {handleSubmit, submitting} = useFormSubmit(validation); diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts index 0ac3c31..59eb9c5 100644 --- a/packages/form/src/types.ts +++ b/packages/form/src/types.ts @@ -39,12 +39,13 @@ export type UseFormSubmit = { /** Options for `useForm`: the validation options plus `useForm`-only behaviour. */ export type UseFormOptions = UseValidationErrorsOptions & { /** - * On a 422, scroll the first invalid field into view. Requires the presentation - * layer to mark the errored control (the default target is `[aria-invalid="true"]`, - * which `@script-development/ui-inputs` renders from `:invalid`); the feature is inert - * if nothing is marked. `false` opts out; `useValidationErrors` is the DOM-free - * alternative. - * @default true + * On a 422, scroll the first invalid field into view. Off unless you ask for it: an + * `HttpService` is shared, so a 422 fills every mounted form's bag and this cannot tell + * whose refusal it was — turning it on without a `scrollRoot` lets one form's refusal + * scroll the page to another form's field. Requires the presentation layer to mark the + * errored control (the default target is `[aria-invalid="true"]`, which + * `@script-development/ui-inputs` renders from `:invalid`). + * @default false */ scrollToError?: boolean; /** diff --git a/packages/form/tests/form.spec.ts b/packages/form/tests/form.spec.ts index 4158516..36e8961 100644 --- a/packages/form/tests/form.spec.ts +++ b/packages/form/tests/form.spec.ts @@ -60,7 +60,8 @@ const mountFieldForm = (httpService: HttpService, options?: UseFormOptions, mark const wrapper = mount( defineComponent({ setup() { - result = useForm(httpService, options); + // The scroll is opt-in; these cases are about what it does once asked for. + result = useForm(httpService, {scrollToError: true, ...options}); return () => h('input', { 'aria-invalid': markInvalid && Object.keys(result.errors.value).length > 0 ? 'true' : 'false', @@ -80,7 +81,7 @@ const mountScopedForm = (httpService: HttpService, fieldInsideRoot: boolean) => const wrapper = mount( defineComponent({ setup() { - result = useForm(httpService, {scrollRoot}); + result = useForm(httpService, {scrollToError: true, scrollRoot}); const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false'); return () => h('div', [ @@ -217,7 +218,7 @@ describe('useForm scroll-to-error', () => { const wrapper = mount( defineComponent({ setup() { - result = useForm(httpService); + result = useForm(httpService, {scrollToError: true}); const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false'); return () => h('div', [h('input', {'aria-invalid': marked()}), h('input', {'aria-invalid': marked()})]); @@ -236,6 +237,18 @@ describe('useForm scroll-to-error', () => { wrapper.unmount(); }); + it('does not scroll unless the caller asks for it', async () => { + const {httpService, triggerError} = createMockHttpService(); + // No `scrollToError`, so the default decides. + const {wrapper} = mountFieldForm(httpService, {scrollToError: undefined}); + + triggerError(422, {errors: {email: ['Taken']}}); + await nextTick(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + it('does not scroll when scrollToError is false', async () => { const {httpService, triggerError} = createMockHttpService(); const {wrapper} = mountFieldForm(httpService, {scrollToError: false}); @@ -267,7 +280,7 @@ describe('useForm scroll-to-error', () => { const wrapper = mount( defineComponent({ setup() { - result = useForm(httpService); + result = useForm(httpService, {scrollToError: true}); // This form's own field is marked only while its bag holds errors; the sibling // stays invalid on its own, unrelated to this form's bag. const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false'); @@ -366,7 +379,7 @@ describe('useForm scroll-to-error', () => { const wrapper = mount( defineComponent({ setup() { - const {errors} = useForm(httpService); + const {errors} = useForm(httpService, {scrollToError: true}); return () => h(Field, {invalid: Object.keys(errors.value).length > 0}); }, }), @@ -386,7 +399,7 @@ describe('useForm scroll-to-error', () => { const wrapper = mount( defineComponent({ setup() { - result = useForm(httpService, {scrollTarget: '.field-error'}); + result = useForm(httpService, {scrollToError: true, scrollTarget: '.field-error'}); return () => h('input', {class: Object.keys(result.errors.value).length > 0 ? 'field-error' : ''}); }, }), From 4b9bd428fb292824578f921b4bcbfd4565a70c13 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 11 Sep 2026 14:33:29 +0200 Subject: [PATCH 5/6] docs(form): opt in to the scroll in every example 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) --- docs/packages/form.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/packages/form.md b/docs/packages/form.md index f7db9ad..623af87 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -73,10 +73,10 @@ The two source territories diverged on exactly one axis: one camelCased the erro ## 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 and calls `scrollIntoView({block: 'center'})` after the mark is painted. `useForm` derives no ids and marks no fields itself, so the feature is **inert unless the presentation layer marks the errored control** — `@script-development/ui-inputs` renders `aria-invalid` from `:invalid` out of the box. +Pass `scrollToError` and a 422 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 and calls `scrollIntoView({block: 'center'})` after the mark is painted. It is **off by default**, and every example below has to opt in. `useForm` derives no ids and marks no fields itself, so it is also **inert unless the presentation layer marks the errored control** — `@script-development/ui-inputs` renders `aria-invalid` from `:invalid` out of the box. ```typescript -useForm(http); // scrolls on error (default) +useForm(http); // no scroll — the default useForm(http, {scrollToError: true, scrollRoot}); // opt in, scoped to your form ``` @@ -85,16 +85,16 @@ useForm(http, {scrollToError: true, scrollRoot}); // opt in, scoped to yo **Marks with a class instead of `aria-invalid`?** Point `scrollTarget` at your own selector: ```typescript -useForm(http, {scrollTarget: '.field-error'}); +useForm(http, {scrollToError: true, scrollTarget: '.field-error'}); ``` ### Forms that share a page -By default the query is **document-wide** — the first matching element in document order — which is right for a single form. When forms share a page, pass each form's root as `scrollRoot`: +Without `scrollRoot` the query is **document-wide** — the first matching element in document order — which is right for a single form. When forms share a page, pass each form's root as `scrollRoot`: ```typescript const formEl = ref(null); -useForm(http, {scrollRoot: formEl}); // scopes the scroll to formEl's subtree +useForm(http, {scrollToError: true, scrollRoot: formEl}); // scopes the scroll to formEl's subtree ``` `scrollRoot` is **required** for a dialog opened over a page form on the same `HttpService`. A 422 fills every such form's error bag (see [Scoping & Backend Contract](#scoping--backend-contract) below), so both forms mark their fields; a document-wide query then scrolls to whichever comes first in document order — often the _page's_ field, behind the backdrop, not the dialog's. Scope each form with `scrollRoot`, or give concurrently-mounted forms separate `HttpService` instances. From f841a5b4206e1a42f7c4d9a120d5a9996dedc0ce Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 11 Sep 2026 15:00:38 +0200 Subject: [PATCH 6/6] docs(form): stop offering useForm's scroll options to the primitive 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) --- docs/packages/form.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/packages/form.md b/docs/packages/form.md index 623af87..fcc0c0d 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -155,7 +155,7 @@ The one-call entry point. Returns everything from both primitives. ### `useValidationErrors(httpService, options?)` -Primitive: registers the 422 middleware and owns the error bag. Same `httpService` / `options` as `useForm`; returns `{errors, clearErrors}`. +Primitive: registers the 422 middleware and owns the error bag. Takes the same `httpService` and the same `keyMapper`, but **none of the scroll options** — only `useForm` installs the scroll. Returns `{errors, clearErrors}`. ### `useFormSubmit(validationErrors)`