Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions docs/packages/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@ const {errors, submitting, handleSubmit} = useForm<Field>(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 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<Field>(http); // scrolls on error (default)
useForm<Field>(http, {scrollToError: false}); // opt out
```

**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<Field>(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<HTMLElement | null>(null);
useForm<Field>(http, {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). 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:
Expand Down Expand Up @@ -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: `true`; see [Scroll to the First Error](#scroll-to-the-first-error)) |
| `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)) |
| `options.scrollTarget` | `string` | Selector for the invalid-field mark (default `[aria-invalid="true"]`); pass your own when inputs mark errors with a class |

**Returns:**

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/form/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/form/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 = <T extends string = string>(
httpService: HttpService,
options: UseFormOptions = {},
): UseForm<T> => {
const {scrollToError = true, scrollRoot, scrollTarget} = options;
const validation = useValidationErrors<T>(httpService, options);
const {handleSubmit, submitting} = useFormSubmit(validation);

if (scrollToError) useScrollToFirstError(validation.errors, scrollRoot, scrollTarget);

return {errors: validation.errors, clearErrors: validation.clearErrors, handleSubmit, submitting};
};
41 changes: 41 additions & 0 deletions packages/form/src/scroll-to-first-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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 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<ValidationErrors>,
root?: Ref<HTMLElement | null>,
target: string = DEFAULT_TARGET,
): void => {
watch(
errors,
() => {
const scope = root === undefined ? document : root.value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document-wide scrolling selects another form's invalid field before the submitting form

When scrollRoot is omitted, useScrollToFirstError queries document for the first invalid element. That query ignores which useForm instance received the 422 response. An earlier invalid control in form B can scroll the user away from form A after A's 422.

crit · finding bd0c2be21ef3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, same root cause as the sibling thread on form.ts:32 — document-wide querySelector has no notion of which useForm submitted, so an earlier invalid control from an unrelated mounted form wins the scroll. scrollRoot fixes it but is opt-in; nothing here forces a multi-form page to pass it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — default-on + document-wide + a shared HttpService is the modal-over-page shape, and that is emmie's exact shape. createFormModal in emmie's services/dialog.ts calls useForm(userHttpService) per dialog; the page beneath may hold its own useForm on the same service. A 422 from the dialog fills both bags; if the page form shares a field key, the page's control is first in document order and this scrolls the page behind the backdrop. The docs name the shared-service case, but frame it as "scrolls to its own matching field" — the failure is that the other form's field wins the querySelector race. scrollRoot fixes it, but only if every consumer knows to pass it; emmie's hand-rolled version scoped by closest('form') automatically and would regress on migration.

Cheapest hardening: skip targets that are not rendered (el.checkVisibility?.() ?? true), and say in the docs that a dialog-hosted form must pass scrollRoot. A closest('form') fallback is not available to you here — useForm has no element — which is the real argument for making the scope explicit rather than "omit = document".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause as the two crit threads above (shared httpService / document-wide query race) — agree scrollRoot needs to be documented as required for dialog-hosted forms, not left implicit.

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'},
);
};
29 changes: 27 additions & 2 deletions packages/form/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,33 @@ export type UseFormSubmit = {
submitting: Ref<boolean>;
};

/** 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. 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 (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<HTMLElement | null>;
/**
* 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
Expand Down
Loading
Loading