diff --git a/docs/packages/form.md b/docs/packages/form.md index 770605c..fcc0c0d 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -71,6 +71,36 @@ 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 + +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); // no scroll — the default +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. + +**Marks with a class instead of `aria-invalid`?** Point `scrollTarget` at your own selector: + +```typescript +useForm(http, {scrollToError: true, scrollTarget: '.field-error'}); +``` + +### Forms that share a page + +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, {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. + +`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 `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 +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) | +| 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:** @@ -122,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)` diff --git a/package-lock.json b/package-lock.json index af87914..1b13164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9254,7 +9254,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 bc816a5..d1d0d53 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..4c2e004 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`, `scrollTarget` — see `UseFormOptions`. */ export const useForm = ( httpService: HttpService, options: UseFormOptions = {}, ): UseForm => { + const {scrollToError = false, scrollRoot, scrollTarget} = options; const validation = useValidationErrors(httpService, options); const {handleSubmit, submitting} = useFormSubmit(validation); + 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 new file mode 100644 index 0000000..72ba21b --- /dev/null +++ b/packages/form/src/scroll-to-first-error.ts @@ -0,0 +1,43 @@ +import type {Ref} from 'vue'; + +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 invalid field into view (the mark the + * 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. + * - `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, + target: string = DEFAULT_TARGET, +): void => { + 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 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/src/types.ts b/packages/form/src/types.ts index ec90eb0..59eb9c5 100644 --- a/packages/form/src/types.ts +++ b/packages/form/src/types.ts @@ -36,8 +36,34 @@ 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 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; + /** + * Scopes the `scrollToError` query to one form's subtree — pass it when forms + * 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; +}; /** * 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..36e8961 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,48 @@ 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() { + // 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', + }); + }, + }), + {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, {scrollToError: true, 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) => { @@ -65,6 +107,7 @@ const makeAxiosError = (status: number): AxiosError => ({isAxiosError: true, res afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe('useForm', () => { @@ -150,3 +193,223 @@ 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, {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()})]); + }, + }), + {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 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}); + + 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('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, {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'); + 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); + + 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(); + }); + + 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 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({ + 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, {scrollToError: true}); + 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, {scrollToError: true, 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(); + }); +});