From 4a667f77bbe27735d9838e21d687a7c8f8f16ea3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 7 Sep 2026 09:50:03 +0200 Subject: [PATCH 1/2] 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/2] 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(); + }); });