Skip to content

Commit 3519f5e

Browse files
Confmcclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 2f4e8bb commit 3519f5e

7 files changed

Lines changed: 247 additions & 11 deletions

File tree

docs/packages/form.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ const {errors, submitting, handleSubmit} = useForm<Field>(http, {keyMapper: came
7171
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.
7272
:::
7373

74+
## Scroll to the First Error
75+
76+
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.
77+
78+
```typescript
79+
useForm<Field>(http); // scrolls on error (default)
80+
useForm<Field>(http, {scrollToError: false}); // opt out
81+
```
82+
83+
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:
84+
85+
```typescript
86+
const formEl = ref<HTMLElement | null>(null);
87+
useForm<Field>(http, {scrollRoot: formEl}); // scopes the scroll to formEl's subtree
88+
```
89+
90+
`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.
91+
92+
`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.
93+
7494
## Composing the Primitives
7595

7696
`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);
106126

107127
The one-call entry point. Returns everything from both primitives.
108128

109-
| Parameter | Type | Description |
110-
| ------------------- | ------------------------- | ---------------------------------------------------- |
111-
| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe |
112-
| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) |
129+
| Parameter | Type | Description |
130+
| ----------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
131+
| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe |
132+
| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) |
133+
| `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)) |
134+
| `options.scrollRoot` | `Ref<HTMLElement \| null>` | Scope the `scrollToError` query to a form's subtree; omit for document-wide (see [Scroll to the First Error](#scroll-to-the-first-error)) |
113135

114136
**Returns:**
115137

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/form/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-form",
3-
"version": "0.1.1",
3+
"version": "0.2.0",
44
"description": "Reactive form-submit helpers: double-submit guard plus 422 validation-error binding for fs-http",
55
"homepage": "https://packages.script.nl/packages/form",
66
"license": "MIT",

packages/form/src/form.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {HttpService} from '@script-development/fs-http';
33
import type {UseForm, UseFormOptions} from './types';
44

55
import {useFormSubmit} from './form-submit';
6+
import {useScrollToFirstError} from './scroll-to-first-error';
67
import {useValidationErrors} from './validation-errors';
78

89
/**
@@ -18,14 +19,20 @@ import {useValidationErrors} from './validation-errors';
1819
* validation-less confirm action).
1920
*
2021
* @param httpService the fs-http service whose 422 responses to observe.
21-
* @param options `keyMapper` remaps raw backend field keys (default identity).
22+
* @param options `keyMapper` remaps raw backend field keys (default identity);
23+
* `scrollToError` scrolls the first invalid field into view on a
24+
* 422 (default `true`); `scrollRoot` scopes that scroll to a
25+
* form's own subtree.
2226
*/
2327
export const useForm = <T extends string = string>(
2428
httpService: HttpService,
2529
options: UseFormOptions = {},
2630
): UseForm<T> => {
31+
const {scrollToError = true, scrollRoot} = options;
2732
const validation = useValidationErrors<T>(httpService, options);
2833
const {handleSubmit, submitting} = useFormSubmit(validation);
2934

35+
if (scrollToError) useScrollToFirstError(validation.errors, scrollRoot);
36+
3037
return {errors: validation.errors, clearErrors: validation.clearErrors, handleSubmit, submitting};
3138
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type {Ref} from 'vue';
2+
3+
import {watch} from 'vue';
4+
5+
import type {ValidationErrors} from './types';
6+
7+
/**
8+
* Scroll the first invalid field into view whenever `errors` changes. Keys off
9+
* `[aria-invalid="true"]`, which the presentation layer sets from the error bag —
10+
* fs-form derives no ids and marks no fields itself. A no-op when nothing is
11+
* marked invalid: an empty bag (e.g. after `clearErrors`) leaves no marked field,
12+
* so the query finds none.
13+
*
14+
* `root` scopes the query to a form's own subtree; omit it and the query is
15+
* document-wide (first `[aria-invalid="true"]` in document order) — fine for a
16+
* single form, but scope per form when several share a page. When `root` is given
17+
* but its ref is `null` (the element is not mounted), nothing scrolls — it does
18+
* not fall back to the document, so opted-in scoping is never silently re-widened.
19+
*
20+
* `flush: 'post'` runs the callback after the DOM update that paints
21+
* `aria-invalid`, so the query sees the freshly-marked field. Call inside a
22+
* component `setup()` (as `useForm` does) so the watcher stops on unmount.
23+
*/
24+
export const useScrollToFirstError = (errors: Ref<ValidationErrors>, root?: Ref<HTMLElement | null>): void => {
25+
watch(
26+
errors,
27+
() => {
28+
const scope = root === undefined ? document : root.value;
29+
scope?.querySelector('[aria-invalid="true"]')?.scrollIntoView({behavior: 'smooth', block: 'center'});
30+
},
31+
{flush: 'post'},
32+
);
33+
};

packages/form/src/types.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,27 @@ export type UseFormSubmit = {
3636
submitting: Ref<boolean>;
3737
};
3838

39-
/** Options for `useForm` (currently the validation options). */
40-
export type UseFormOptions = UseValidationErrorsOptions;
39+
/** Options for `useForm`: the validation options plus `useForm`-only behaviour. */
40+
export type UseFormOptions = UseValidationErrorsOptions & {
41+
/**
42+
* After a 422 populates `errors`, scroll the first invalid field
43+
* (`[aria-invalid="true"]`) into view. The presentation layer marks invalid
44+
* controls with `aria-invalid`; `useForm` reads that attribute and derives no
45+
* ids and marks no fields itself. Set `false` to opt out — or use
46+
* `useValidationErrors` directly for a DOM-free error bag.
47+
* @default true
48+
*/
49+
scrollToError?: boolean;
50+
/**
51+
* Element ref that scopes the `scrollToError` query to one form's subtree.
52+
* Omit it and the query is document-wide (the first `[aria-invalid="true"]` in
53+
* document order) — fine for a single form, but pass the form's root ref when
54+
* several forms share a page, so a 422 in one never scrolls to another's field.
55+
* While the ref is `null` (element not mounted) nothing scrolls; passing a root
56+
* never falls back to a document-wide search.
57+
*/
58+
scrollRoot?: Ref<HTMLElement | null>;
59+
};
4160

4261
/**
4362
* Everything `useForm` returns: the field-error bag and `clearErrors` from

packages/form/tests/form.spec.ts

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type {AxiosResponseError, HttpService, ResponseErrorMiddlewareFunc} from
33
import type {AxiosError} from 'axios';
44

55
import {mount} from '@vue/test-utils';
6-
import {afterEach, describe, expect, it, vi} from 'vitest';
7-
import {defineComponent} from 'vue';
6+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
7+
import {defineComponent, h, nextTick, ref} from 'vue';
88

99
import type {UseForm, UseFormOptions} from '../src';
1010

@@ -53,6 +53,47 @@ const mountForm = <T extends string = string>(httpService: HttpService, options?
5353
return {wrapper, result: () => result};
5454
};
5555

56+
// Mounts (attached to the document) a form that renders one field marked `aria-invalid`
57+
// while the error bag is non-empty — the shape the scroll watcher queries for.
58+
const mountFieldForm = (httpService: HttpService, options?: UseFormOptions, markInvalid = true) => {
59+
let result!: UseForm;
60+
const wrapper = mount(
61+
defineComponent({
62+
setup() {
63+
result = useForm(httpService, options);
64+
return () =>
65+
h('input', {
66+
'aria-invalid': markInvalid && Object.keys(result.errors.value).length > 0 ? 'true' : 'false',
67+
});
68+
},
69+
}),
70+
{attachTo: document.body},
71+
);
72+
return {wrapper, result: () => result};
73+
};
74+
75+
// Mounts (attached) a form whose invalid field sits inside (or outside) a `scrollRoot` element,
76+
// to prove the query is scoped to that root's subtree.
77+
const mountScopedForm = (httpService: HttpService, fieldInsideRoot: boolean) => {
78+
const scrollRoot = ref<HTMLElement | null>(null);
79+
let result!: UseForm;
80+
const wrapper = mount(
81+
defineComponent({
82+
setup() {
83+
result = useForm(httpService, {scrollRoot});
84+
const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false');
85+
return () =>
86+
h('div', [
87+
h('div', {ref: scrollRoot}, [fieldInsideRoot ? h('input', {'aria-invalid': marked()}) : null]),
88+
fieldInsideRoot ? null : h('input', {'aria-invalid': marked()}),
89+
]);
90+
},
91+
}),
92+
{attachTo: document.body},
93+
);
94+
return {wrapper, result: () => result};
95+
};
96+
5697
const deferred = () => {
5798
let resolve!: () => void;
5899
const promise = new Promise<void>((res) => {
@@ -150,3 +191,117 @@ describe('useForm', () => {
150191
expect(errorMiddlewares).toHaveLength(0);
151192
});
152193
});
194+
195+
describe('useForm scroll-to-error', () => {
196+
let scrollIntoView: ReturnType<typeof vi.spyOn>;
197+
198+
beforeEach(() => {
199+
scrollIntoView = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {});
200+
});
201+
202+
it('scrolls the first invalid field into view after a 422', async () => {
203+
const {httpService, triggerError} = createMockHttpService();
204+
const {wrapper} = mountFieldForm(httpService);
205+
206+
triggerError(422, {errors: {email: ['Taken']}});
207+
await nextTick();
208+
209+
expect(scrollIntoView).toHaveBeenCalledWith({behavior: 'smooth', block: 'center'});
210+
wrapper.unmount();
211+
});
212+
213+
it('scrolls to the first invalid field in document order when several are marked', async () => {
214+
const {httpService, triggerError} = createMockHttpService();
215+
let result!: UseForm;
216+
const wrapper = mount(
217+
defineComponent({
218+
setup() {
219+
result = useForm(httpService);
220+
const marked = () => (Object.keys(result.errors.value).length > 0 ? 'true' : 'false');
221+
return () =>
222+
h('div', [h('input', {'aria-invalid': marked()}), h('input', {'aria-invalid': marked()})]);
223+
},
224+
}),
225+
{attachTo: document.body},
226+
);
227+
228+
triggerError(422, {errors: {email: ['Taken']}});
229+
await nextTick();
230+
231+
const invalid = document.querySelectorAll('[aria-invalid="true"]');
232+
expect(invalid).toHaveLength(2);
233+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
234+
expect(scrollIntoView.mock.contexts[0]).toBe(invalid[0]);
235+
wrapper.unmount();
236+
});
237+
238+
it('does not scroll when scrollToError is false', async () => {
239+
const {httpService, triggerError} = createMockHttpService();
240+
const {wrapper} = mountFieldForm(httpService, {scrollToError: false});
241+
242+
triggerError(422, {errors: {email: ['Taken']}});
243+
await nextTick();
244+
245+
expect(scrollIntoView).not.toHaveBeenCalled();
246+
wrapper.unmount();
247+
});
248+
249+
it('does not scroll again once the error bag is cleared', async () => {
250+
const {httpService, triggerError} = createMockHttpService();
251+
const {wrapper, result} = mountFieldForm(httpService);
252+
253+
triggerError(422, {errors: {email: ['Taken']}});
254+
await nextTick();
255+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
256+
257+
result().clearErrors();
258+
await nextTick();
259+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
260+
wrapper.unmount();
261+
});
262+
263+
it('no-ops when a 422 marks no field invalid', async () => {
264+
const {httpService, triggerError} = createMockHttpService();
265+
const {wrapper} = mountFieldForm(httpService, undefined, false);
266+
267+
triggerError(422, {errors: {email: ['Taken']}});
268+
await nextTick();
269+
270+
expect(scrollIntoView).not.toHaveBeenCalled();
271+
wrapper.unmount();
272+
});
273+
274+
it('scopes the scroll to scrollRoot when provided', async () => {
275+
const {httpService, triggerError} = createMockHttpService();
276+
const {wrapper} = mountScopedForm(httpService, true);
277+
278+
triggerError(422, {errors: {email: ['Taken']}});
279+
await nextTick();
280+
281+
expect(scrollIntoView).toHaveBeenCalledWith({behavior: 'smooth', block: 'center'});
282+
wrapper.unmount();
283+
});
284+
285+
it('does not scroll to an invalid field outside scrollRoot', async () => {
286+
const {httpService, triggerError} = createMockHttpService();
287+
const {wrapper} = mountScopedForm(httpService, false);
288+
289+
triggerError(422, {errors: {email: ['Taken']}});
290+
await nextTick();
291+
292+
expect(scrollIntoView).not.toHaveBeenCalled();
293+
wrapper.unmount();
294+
});
295+
296+
it('does not fall back to the document when scrollRoot is null', async () => {
297+
const {httpService, triggerError} = createMockHttpService();
298+
const scrollRoot = ref<HTMLElement | null>(null); // passed but never bound -> stays null
299+
const {wrapper} = mountFieldForm(httpService, {scrollRoot});
300+
301+
triggerError(422, {errors: {email: ['Taken']}});
302+
await nextTick();
303+
304+
expect(scrollIntoView).not.toHaveBeenCalled();
305+
wrapper.unmount();
306+
});
307+
});

0 commit comments

Comments
 (0)