diff --git a/CLAUDE.md b/CLAUDE.md
index 3e4f94e..620d502 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,7 +44,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th
**Precedent + latency.** kendo WR-0078 (PR [#1538](https://github.com/script-development/kendo/pull/1538)) independently re-derived this mechanism against fs-http source and guarded **both** kendo central + tenant middleware (try/catch + fail-safe swallow, 100% coverage, 2026-06-15). See the war-room `deferred.md [adr] fs-packages-fs-http-async-aware-middleware-rejection-doctrine` entry (promoted, n=2). **entreezuil / ublgenie / emmie / BIO carry the latent exposure** until their middleware is likewise guarded.
-## Packages (11)
+## Packages (12)
| Package | Vue | Description |
| ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------- |
@@ -57,6 +57,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th
| fs-cached-adapter-store | Yes | Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface |
| fs-toast | Yes | Component-agnostic toast queue (FIFO) |
| fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware |
+| fs-form | Yes | One-call `useForm`: double-submit guard + `submitting` loading flag + 422 validation-error binding (guarded fs-http middleware); `keyMapper` seam for raw/camel field keys. `useValidationErrors`/`useFormSubmit` primitives still exported |
| fs-translation | Yes | Type-safe reactive i18n with dot-notation keys |
| fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router |
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 985d450..a475b57 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -40,6 +40,7 @@ export default defineConfig({
{text: 'fs-loading', link: '/packages/loading'},
{text: 'fs-toast', link: '/packages/toast'},
{text: 'fs-dialog', link: '/packages/dialog'},
+ {text: 'fs-form', link: '/packages/form'},
{text: 'fs-translation', link: '/packages/translation'},
],
},
diff --git a/docs/packages/form.md b/docs/packages/form.md
new file mode 100644
index 0000000..770605c
--- /dev/null
+++ b/docs/packages/form.md
@@ -0,0 +1,129 @@
+# fs-form
+
+Reactive form-submit helpers: a double-submit guard plus 422 validation-error binding for `fs-http` — from a single composable.
+
+```bash
+npm install @script-development/fs-form
+```
+
+**Peer dependencies:** `vue ^3.5.39`, `@script-development/fs-http ^0.5.0`
+
+## What It Does
+
+`fs-form` is the extracted, shared version of a composable pair that two territories independently ran side-by-side. The one-call entry point is **`useForm`**, which wires both halves together:
+
+- a reactive **field-error bag** populated from backend 422 responses (first message per field),
+- a **`submitting`** flag that is the form's loading state (`true` while a submit is in flight),
+- a validation-aware **`handleSubmit`** that prevents double-submit, clears prior errors, **swallows a 422** (the errors were already surfaced, so the populated form is preserved), and re-throws everything else.
+
+The two building blocks — `useValidationErrors` and `useFormSubmit` — remain exported for the cases where you want one half without the other.
+
+## Basic Usage
+
+```vue
+
+
+
+
+
+```
+
+One call, everything wired. On a 422 the field errors populate and `handleSubmit` swallows the rejection — the form (and its typed input) stays put. On any other failure the rejection propagates to your caller / async error boundary.
+
+## Key Mapping
+
+Laravel returns validation keys in the backend's casing (e.g. `first_name`). If your app addresses fields in camelCase, pass a per-key `(key: string) => string` converter:
+
+```typescript
+const camel = (key: string) => key.replace(/_(\w)/g, (_, c: string) => c.toUpperCase());
+
+const {errors, submitting, handleSubmit} = useForm(http, {keyMapper: camel});
+// backend `first_name` → bag key `firstName`
+```
+
+`keyMapper` defaults to identity — keys are used verbatim.
+
+::: tip Why a keyMapper seam?
+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.
+:::
+
+## 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:
+
+```typescript
+import {useFormSubmit, useValidationErrors} from '@script-development/fs-form';
+
+// submit guard only — no validation middleware registered
+const {handleSubmit, submitting} = useFormSubmit({clearErrors: () => {}});
+
+// or the two, wired by hand (exactly what useForm does internally)
+const validation = useValidationErrors(http);
+const {handleSubmit, submitting} = useFormSubmit(validation);
+```
+
+## Scoping & Backend Contract
+
+**One error-scope per form.** `useValidationErrors` (and therefore `useForm`) registers a 422 observer on the `HttpService` you pass and keeps its own error bag. If two forms share **one** `HttpService` instance, a 422 from either fills **both** bags — cross-form bleed, with green types. Give each form its own error scope: one form per `HttpService` instance, or don't share a service across concurrently-mounted forms. (The single-form-per-scope shape matches the source territories; a multi-form-per-service layout is the case to watch.)
+
+**Laravel 422 contract.** `fs-form` targets **Laravel**'s validation-error response shape — `{ message?: string, errors: Record }` — and binds the first message per field. It deliberately does **not** defensively handle other backends' error shapes: a field whose value is not a `string[]` is passed through unguarded (Laravel guarantees arrays, so the cast is safe against every intended consumer). If you point `fs-form` at a non-Laravel backend, revisit the parse in `useValidationErrors` first.
+
+## Middleware Safety (Principle #8)
+
+`useValidationErrors` wraps its response-error middleware body with `fs-http`'s `guarded()`. A throwing `keyMapper` — or any parse hiccup — is caught and surfaced loudly (via `guarded`'s default `console.error`) **without** rejecting a resolved request or masking the real API error. `fs-form` is a compliant `fs-http` consumer out of the box per the [Middleware Sync Contract](../architecture#middleware-sync-contract).
+
+## Cleanup
+
+`useValidationErrors` (and therefore `useForm`) registers `onUnmounted(unregister)` for you, so a component-scoped instance cleans up its middleware automatically. If you construct one **outside** a component setup, unmount cleanup does not fire — scope it to a component.
+
+## API Reference
+
+### `useForm(httpService, options?)`
+
+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) |
+
+**Returns:**
+
+| Property | Type | Description |
+| --------------- | ------------------------------------------------ | -------------------------------------------------- |
+| `errors` | `Ref>` | Reactive `Partial>` field bag |
+| `clearErrors()` | `() => void` | Empty the bag |
+| `handleSubmit` | `(action: () => Promise) => Promise` | Runs `action` with double-submit + 422-swallow |
+| `submitting` | `Ref` | `true` while a submit is in flight (loading state) |
+
+### `useValidationErrors(httpService, options?)`
+
+Primitive: registers the 422 middleware and owns the error bag. Same `httpService` / `options` as `useForm`; returns `{errors, clearErrors}`.
+
+### `useFormSubmit(validationErrors)`
+
+Primitive: the submit guard. Takes anything exposing `clearErrors` (typically the `useValidationErrors` return); returns `{handleSubmit, submitting}`.
diff --git a/package-lock.json b/package-lock.json
index 5b6d282..1a0919d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2183,9 +2183,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2203,9 +2200,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2223,9 +2217,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2243,9 +2234,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2263,9 +2251,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2283,9 +2268,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2303,9 +2285,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2323,9 +2302,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2530,9 +2506,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2550,9 +2523,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2570,9 +2540,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2590,9 +2557,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2610,9 +2574,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2630,9 +2591,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2650,9 +2608,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2670,9 +2625,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3433,6 +3385,10 @@
"resolved": "packages/dialog",
"link": true
},
+ "node_modules/@script-development/fs-form": {
+ "resolved": "packages/form",
+ "link": true
+ },
"node_modules/@script-development/fs-helpers": {
"resolved": "packages/helpers",
"link": true
@@ -10481,6 +10437,25 @@
"vue": "^3.5.39"
}
},
+ "packages/form": {
+ "name": "@script-development/fs-form",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@script-development/fs-http": "^0.5.0",
+ "@vue/test-utils": "^2.4.11",
+ "axios": "^1.18.1",
+ "happy-dom": "^20.10.3",
+ "vue": "^3.5.39"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ },
+ "peerDependencies": {
+ "@script-development/fs-http": "^0.5.0",
+ "vue": "^3.5.39"
+ }
+ },
"packages/helpers": {
"name": "@script-development/fs-helpers",
"version": "0.1.2",
diff --git a/packages/form/package.json b/packages/form/package.json
new file mode 100644
index 0000000..67f0aa5
--- /dev/null
+++ b/packages/form/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "@script-development/fs-form",
+ "version": "0.1.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",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/script-development/fs-packages.git",
+ "directory": "packages/form"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.mts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org"
+ },
+ "scripts": {
+ "build": "tsdown",
+ "lint:pkg": "publint && attw --pack",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage",
+ "test:mutation": "stryker run",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@script-development/fs-http": "^0.5.0",
+ "@vue/test-utils": "^2.4.11",
+ "axios": "^1.18.1",
+ "happy-dom": "^20.10.3",
+ "vue": "^3.5.39"
+ },
+ "peerDependencies": {
+ "@script-development/fs-http": "^0.5.0",
+ "vue": "^3.5.39"
+ },
+ "engines": {
+ "node": ">=24.0.0"
+ }
+}
diff --git a/packages/form/src/form-submit.ts b/packages/form/src/form-submit.ts
new file mode 100644
index 0000000..a62487c
--- /dev/null
+++ b/packages/form/src/form-submit.ts
@@ -0,0 +1,42 @@
+import {isAxiosError} from '@script-development/fs-http';
+import {ref} from 'vue';
+
+import type {UseFormSubmit} from './types';
+
+const HTTP_UNPROCESSABLE_ENTITY = 422;
+
+/**
+ * Wrap a form-submit action with double-submit prevention and validation-aware
+ * error handling. While an action is in flight `submitting` is `true` and
+ * re-entrant calls are ignored. Before each attempt `clearErrors()` resets the
+ * previous field errors.
+ *
+ * A 422 rejection is swallowed (the field errors were already surfaced by
+ * `useValidationErrors`' middleware, so the populated form is preserved); every
+ * other rejection is re-thrown to the caller / async error boundary.
+ *
+ * @param validationErrors anything exposing `clearErrors` — typically the object
+ * returned by `useValidationErrors`.
+ */
+export const useFormSubmit = (validationErrors: {clearErrors: () => void}): UseFormSubmit => {
+ const submitting = ref(false);
+
+ const handleSubmit = async (action: () => Promise): Promise => {
+ if (submitting.value) return;
+
+ submitting.value = true;
+ validationErrors.clearErrors();
+
+ try {
+ await action();
+ } catch (error) {
+ if (isAxiosError(error) && error.response?.status === HTTP_UNPROCESSABLE_ENTITY) return;
+
+ throw error;
+ } finally {
+ submitting.value = false;
+ }
+ };
+
+ return {handleSubmit, submitting};
+};
diff --git a/packages/form/src/form.ts b/packages/form/src/form.ts
new file mode 100644
index 0000000..7626e1b
--- /dev/null
+++ b/packages/form/src/form.ts
@@ -0,0 +1,31 @@
+import type {HttpService} from '@script-development/fs-http';
+
+import type {UseForm, UseFormOptions} from './types';
+
+import {useFormSubmit} from './form-submit';
+import {useValidationErrors} from './validation-errors';
+
+/**
+ * One-call form composable. Wires `useValidationErrors` and `useFormSubmit`
+ * together so a page gets the field-error bag, the in-flight `submitting`
+ * (loading) flag, and a validation-aware `handleSubmit` from a single call
+ * instead of composing two.
+ *
+ * A 422 populates `errors` via the internal response middleware and is
+ * swallowed by `handleSubmit`, so the form is preserved; any other rejection
+ * propagates. Reach for the underlying `useValidationErrors` / `useFormSubmit`
+ * primitives directly when you need one half without the other (e.g. a
+ * 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).
+ */
+export const useForm = (
+ httpService: HttpService,
+ options: UseFormOptions = {},
+): UseForm => {
+ const validation = useValidationErrors(httpService, options);
+ const {handleSubmit, submitting} = useFormSubmit(validation);
+
+ return {errors: validation.errors, clearErrors: validation.clearErrors, handleSubmit, submitting};
+};
diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts
new file mode 100644
index 0000000..12b643c
--- /dev/null
+++ b/packages/form/src/index.ts
@@ -0,0 +1,11 @@
+export {useForm} from './form';
+export {useFormSubmit} from './form-submit';
+export {useValidationErrors} from './validation-errors';
+export type {
+ UseForm,
+ UseFormOptions,
+ UseFormSubmit,
+ UseValidationErrors,
+ UseValidationErrorsOptions,
+ ValidationErrors,
+} from './types';
diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts
new file mode 100644
index 0000000..ec90eb0
--- /dev/null
+++ b/packages/form/src/types.ts
@@ -0,0 +1,47 @@
+import type {Ref} from 'vue';
+
+/** Field-error bag: the first backend validation message per field key. */
+export type ValidationErrors = Partial>;
+
+/** Reactive validation-error state returned by `useValidationErrors`. */
+export type UseValidationErrors = {
+ /** Current field errors. Populated from a 422 response, cleared on demand. */
+ errors: Ref>;
+ /** Clear all field errors. */
+ clearErrors: () => void;
+};
+
+/** Options for `useValidationErrors`. */
+export type UseValidationErrorsOptions = {
+ /**
+ * Maps each raw backend field key to the key stored in the error bag.
+ * Defaults to identity — keys are used verbatim (e.g. `first_name`). Pass a
+ * snake→camel converter (such as a per-key wrapper over `fs-helpers`'
+ * `deepCamelKeys`) when your app addresses fields in camelCase.
+ * @default (key) => key
+ */
+ keyMapper?: (key: string) => string;
+};
+
+/** Form-submit helper returned by `useFormSubmit`. */
+export type UseFormSubmit = {
+ /**
+ * Run a submit action with double-submit prevention. A 422 (validation)
+ * rejection is swallowed — the field errors have already been surfaced by
+ * `useValidationErrors`' response middleware, so the form is preserved. Any
+ * other rejection is re-thrown to the caller / error boundary.
+ */
+ handleSubmit: (action: () => Promise) => Promise;
+ /** `true` while a submit action is in flight — the form's loading state. */
+ submitting: Ref;
+};
+
+/** Options for `useForm` (currently the validation options). */
+export type UseFormOptions = UseValidationErrorsOptions;
+
+/**
+ * Everything `useForm` returns: the field-error bag and `clearErrors` from
+ * `useValidationErrors`, plus `handleSubmit` and the `submitting` loading flag
+ * from `useFormSubmit` — wired together so a page composes one call, not two.
+ */
+export type UseForm = UseValidationErrors & UseFormSubmit;
diff --git a/packages/form/src/validation-errors.ts b/packages/form/src/validation-errors.ts
new file mode 100644
index 0000000..5fb4fa0
--- /dev/null
+++ b/packages/form/src/validation-errors.ts
@@ -0,0 +1,64 @@
+import type {HttpService} from '@script-development/fs-http';
+import type {Ref} from 'vue';
+
+import {guarded} from '@script-development/fs-http';
+import {onUnmounted, ref} from 'vue';
+
+import type {UseValidationErrors, UseValidationErrorsOptions, ValidationErrors} from './types';
+
+const HTTP_UNPROCESSABLE_ENTITY = 422;
+
+const toFieldErrorMap = (data: unknown): Record => {
+ const errors = (data as {errors?: unknown} | null | undefined)?.errors;
+ if (typeof errors !== 'object' || errors === null) return {};
+
+ return errors as Record;
+};
+
+const mapFieldErrors = (
+ fieldErrors: Record,
+ keyMapper: (key: string) => string,
+): ValidationErrors =>
+ Object.fromEntries(
+ Object.entries(fieldErrors).map(([key, messages]) => [keyMapper(key), messages[0]]),
+ ) as ValidationErrors;
+
+const identity = (key: string): string => key;
+
+/**
+ * Register a 422-only response-error middleware on `httpService` that binds
+ * backend validation errors into a reactive field-error bag, keyed to the first
+ * message per field. Automatically unregisters on component unmount.
+ *
+ * The middleware body is wrapped with fs-http's `guarded()` so a throwing
+ * `keyMapper` (or any parse hiccup) cannot reject a resolved request nor mask
+ * the real API error — fs-form is a well-behaved fs-http consumer per the
+ * Middleware Sync Contract (Architectural Principle #8).
+ *
+ * @param httpService the fs-http service whose error responses to observe.
+ * @param options `keyMapper` remaps raw backend field keys (default identity).
+ */
+export const useValidationErrors = (
+ httpService: HttpService,
+ options: UseValidationErrorsOptions = {},
+): UseValidationErrors => {
+ const {keyMapper = identity} = options;
+ const errors = ref>({}) as Ref>;
+
+ const clearErrors = (): void => {
+ errors.value = {};
+ };
+
+ const unregister = httpService.registerResponseErrorMiddleware(
+ guarded((error) => {
+ const response = error.response;
+ if (response?.status !== HTTP_UNPROCESSABLE_ENTITY) return;
+
+ errors.value = mapFieldErrors(toFieldErrorMap(response.data), keyMapper);
+ }),
+ );
+
+ onUnmounted(unregister);
+
+ return {errors, clearErrors};
+};
diff --git a/packages/form/stryker.config.mjs b/packages/form/stryker.config.mjs
new file mode 100644
index 0000000..8617354
--- /dev/null
+++ b/packages/form/stryker.config.mjs
@@ -0,0 +1,13 @@
+/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
+export default {
+ testRunner: 'vitest',
+ vitest: {configFile: 'vitest.config.ts'},
+ mutate: ['src/**/*.ts', '!src/**/types.ts'],
+ thresholds: {high: 95, low: 90, break: 90},
+ reporters: ['clear-text', 'progress', 'json', 'html'],
+ jsonReporter: {fileName: 'reports/mutation/mutation.json'},
+ htmlReporter: {fileName: 'reports/mutation/mutation.html'},
+ incremental: true,
+ incrementalFile: '.stryker-incremental.json',
+ cleanTempDir: 'always',
+};
diff --git a/packages/form/tests/form-submit.spec.ts b/packages/form/tests/form-submit.spec.ts
new file mode 100644
index 0000000..c53571d
--- /dev/null
+++ b/packages/form/tests/form-submit.spec.ts
@@ -0,0 +1,114 @@
+// @vitest-environment happy-dom
+import type {AxiosError} from 'axios';
+
+import {describe, expect, it, vi} from 'vitest';
+
+import {useFormSubmit} from '../src';
+
+const makeAxiosError = (status: number): AxiosError => ({isAxiosError: true, response: {status}}) as AxiosError;
+
+const deferred = () => {
+ let resolve!: () => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return {promise, resolve, reject};
+};
+
+describe('useFormSubmit', () => {
+ it('clears prior errors before running the action', async () => {
+ const clearErrors = vi.fn();
+ const {handleSubmit} = useFormSubmit({clearErrors});
+
+ await handleSubmit(async () => {});
+
+ expect(clearErrors).toHaveBeenCalledOnce();
+ });
+
+ it('toggles submitting true during the action and false after', async () => {
+ const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()});
+ const gate = deferred();
+
+ expect(submitting.value).toBe(false);
+
+ const inFlight = handleSubmit(() => gate.promise);
+ expect(submitting.value).toBe(true);
+
+ gate.resolve();
+ await inFlight;
+ expect(submitting.value).toBe(false);
+ });
+
+ it('ignores a re-entrant submit while one is already in flight', async () => {
+ const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()});
+ const gate = deferred();
+ const action = vi.fn(() => gate.promise);
+
+ const first = handleSubmit(action);
+ expect(submitting.value).toBe(true);
+
+ // second call must early-return without invoking the action again
+ await handleSubmit(action);
+ expect(action).toHaveBeenCalledOnce();
+
+ gate.resolve();
+ await first;
+ });
+
+ it('swallows a 422 rejection so the form is preserved', async () => {
+ const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()});
+
+ await expect(
+ handleSubmit(async () => {
+ throw makeAxiosError(422);
+ }),
+ ).resolves.toBeUndefined();
+ expect(submitting.value).toBe(false);
+ });
+
+ it('re-throws a non-422 axios rejection', async () => {
+ const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()});
+ const error = makeAxiosError(500);
+
+ await expect(
+ handleSubmit(async () => {
+ throw error;
+ }),
+ ).rejects.toBe(error);
+ expect(submitting.value).toBe(false);
+ });
+
+ it('re-throws an axios error that carries no response object', async () => {
+ const {handleSubmit} = useFormSubmit({clearErrors: vi.fn()});
+ const error = {isAxiosError: true} as AxiosError;
+
+ await expect(
+ handleSubmit(async () => {
+ throw error;
+ }),
+ ).rejects.toBe(error);
+ });
+
+ it('re-throws a non-axios rejection', async () => {
+ const {handleSubmit} = useFormSubmit({clearErrors: vi.fn()});
+ const error = new Error('network down');
+
+ await expect(
+ handleSubmit(async () => {
+ throw error;
+ }),
+ ).rejects.toBe(error);
+ });
+
+ it('resets submitting to false even when the action re-throws', async () => {
+ const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()});
+
+ await handleSubmit(async () => {
+ throw makeAxiosError(500);
+ }).catch(() => null);
+
+ expect(submitting.value).toBe(false);
+ });
+});
diff --git a/packages/form/tests/form.spec.ts b/packages/form/tests/form.spec.ts
new file mode 100644
index 0000000..bf5c5c3
--- /dev/null
+++ b/packages/form/tests/form.spec.ts
@@ -0,0 +1,152 @@
+// @vitest-environment happy-dom
+import type {AxiosResponseError, HttpService, ResponseErrorMiddlewareFunc} from '@script-development/fs-http';
+import type {AxiosError} from 'axios';
+
+import {mount} from '@vue/test-utils';
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {defineComponent} from 'vue';
+
+import type {UseForm, UseFormOptions} from '../src';
+
+import {useForm} from '../src';
+
+const createMockHttpService = () => {
+ const errorMiddlewares: ResponseErrorMiddlewareFunc[] = [];
+
+ const triggerError = (status: number, data: unknown): void => {
+ const error = {isAxiosError: true, response: {status, data}} as AxiosError;
+ for (const middleware of errorMiddlewares) middleware(error);
+ };
+
+ const httpService = {
+ getRequest: vi.fn(),
+ postRequest: vi.fn(),
+ putRequest: vi.fn(),
+ patchRequest: vi.fn(),
+ deleteRequest: vi.fn(),
+ downloadRequest: vi.fn(),
+ previewRequest: vi.fn(),
+ registerRequestMiddleware: vi.fn(() => () => {}),
+ registerResponseMiddleware: vi.fn(() => () => {}),
+ registerResponseErrorMiddleware: vi.fn((fn: ResponseErrorMiddlewareFunc) => {
+ errorMiddlewares.push(fn);
+ return () => {
+ const index = errorMiddlewares.indexOf(fn);
+ if (index > -1) errorMiddlewares.splice(index, 1);
+ };
+ }),
+ } as unknown as HttpService;
+
+ return {httpService, triggerError, errorMiddlewares};
+};
+
+const mountForm = (httpService: HttpService, options?: UseFormOptions) => {
+ let result!: UseForm;
+ const wrapper = mount(
+ defineComponent({
+ setup() {
+ result = useForm(httpService, options);
+ return () => null;
+ },
+ }),
+ );
+ return {wrapper, result: () => result};
+};
+
+const deferred = () => {
+ let resolve!: () => void;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ return {promise, resolve};
+};
+
+const makeAxiosError = (status: number): AxiosError => ({isAxiosError: true, response: {status}}) as AxiosError;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('useForm', () => {
+ it('exposes the validation bag, clearErrors, handleSubmit and submitting from one call', () => {
+ const {httpService} = createMockHttpService();
+ const {result} = mountForm(httpService);
+
+ expect(result().errors.value).toEqual({});
+ expect(result().submitting.value).toBe(false);
+ expect(typeof result().clearErrors).toBe('function');
+ expect(typeof result().handleSubmit).toBe('function');
+ });
+
+ it('binds a 422 into the error bag via the internal middleware', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountForm<'email'>(httpService);
+
+ triggerError(422, {errors: {email: ['Taken']}});
+
+ expect(result().errors.value).toEqual({email: 'Taken'});
+ });
+
+ it('passes keyMapper through to the internal validation layer', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const camel = (key: string) => key.replace(/_(\w)/g, (_, c: string) => c.toUpperCase());
+ const {result} = mountForm(httpService, {keyMapper: camel});
+
+ triggerError(422, {errors: {street_name: ['Required']}});
+
+ expect(result().errors.value).toEqual({streetName: 'Required'});
+ });
+
+ it('toggles submitting around handleSubmit and swallows a 422', async () => {
+ const {httpService} = createMockHttpService();
+ const {result} = mountForm(httpService);
+ const gate = deferred();
+
+ const inFlight = result().handleSubmit(() => gate.promise);
+ expect(result().submitting.value).toBe(true);
+
+ gate.resolve();
+ await inFlight;
+ expect(result().submitting.value).toBe(false);
+
+ await expect(
+ result().handleSubmit(async () => {
+ throw makeAxiosError(422);
+ }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('re-throws a non-422 rejection through handleSubmit', async () => {
+ const {httpService} = createMockHttpService();
+ const {result} = mountForm(httpService);
+ const error = makeAxiosError(500);
+
+ await expect(
+ result().handleSubmit(async () => {
+ throw error;
+ }),
+ ).rejects.toBe(error);
+ });
+
+ it('clearErrors empties a populated bag', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountForm<'email'>(httpService);
+
+ triggerError(422, {errors: {email: ['Taken']}});
+ expect(result().errors.value).toEqual({email: 'Taken'});
+
+ result().clearErrors();
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('unregisters the internal middleware on unmount', () => {
+ const {httpService, errorMiddlewares} = createMockHttpService();
+ const {wrapper} = mountForm(httpService);
+
+ expect(errorMiddlewares).toHaveLength(1);
+
+ wrapper.unmount();
+
+ expect(errorMiddlewares).toHaveLength(0);
+ });
+});
diff --git a/packages/form/tests/validation-errors.spec.ts b/packages/form/tests/validation-errors.spec.ts
new file mode 100644
index 0000000..3e11d94
--- /dev/null
+++ b/packages/form/tests/validation-errors.spec.ts
@@ -0,0 +1,206 @@
+// @vitest-environment happy-dom
+import type {AxiosResponseError, HttpService, ResponseErrorMiddlewareFunc} from '@script-development/fs-http';
+import type {AxiosError} from 'axios';
+
+import {mount} from '@vue/test-utils';
+import {afterEach, describe, expect, it, vi} from 'vitest';
+import {defineComponent} from 'vue';
+
+import type {UseValidationErrors, UseValidationErrorsOptions} from '../src';
+
+import {useValidationErrors} from '../src';
+
+type ErrorMiddleware = ResponseErrorMiddlewareFunc;
+
+const createMockHttpService = () => {
+ const errorMiddlewares: ErrorMiddleware[] = [];
+
+ const triggerError = (status: number, data: unknown): void => {
+ const error = {isAxiosError: true, response: {status, data}} as AxiosError;
+ for (const middleware of errorMiddlewares) middleware(error);
+ };
+
+ const triggerBare = (): void => {
+ const error = {isAxiosError: true, message: 'boom'} as AxiosError;
+ for (const middleware of errorMiddlewares) middleware(error);
+ };
+
+ const registerResponseErrorMiddleware = vi.fn((fn: ErrorMiddleware) => {
+ errorMiddlewares.push(fn);
+ return () => {
+ const index = errorMiddlewares.indexOf(fn);
+ if (index > -1) errorMiddlewares.splice(index, 1);
+ };
+ });
+
+ const httpService = {
+ getRequest: vi.fn(),
+ postRequest: vi.fn(),
+ putRequest: vi.fn(),
+ patchRequest: vi.fn(),
+ deleteRequest: vi.fn(),
+ downloadRequest: vi.fn(),
+ previewRequest: vi.fn(),
+ registerRequestMiddleware: vi.fn(() => () => {}),
+ registerResponseMiddleware: vi.fn(() => () => {}),
+ registerResponseErrorMiddleware,
+ } as unknown as HttpService;
+
+ return {httpService, triggerError, triggerBare, registerResponseErrorMiddleware, errorMiddlewares};
+};
+
+// Mount useValidationErrors inside a real component so onUnmounted fires.
+const mountComposable = (httpService: HttpService, options?: UseValidationErrorsOptions) => {
+ let result!: UseValidationErrors;
+ const wrapper = mount(
+ defineComponent({
+ setup() {
+ result = useValidationErrors(httpService, options);
+ return () => null;
+ },
+ }),
+ );
+ return {wrapper, result: () => result};
+};
+
+const VALIDATION_BODY = {message: 'The given data was invalid.', errors: {first_name: ['Required', 'Too short']}};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('useValidationErrors', () => {
+ it('registers exactly one response-error middleware', () => {
+ const {httpService, registerResponseErrorMiddleware} = createMockHttpService();
+ mountComposable(httpService);
+
+ expect(registerResponseErrorMiddleware).toHaveBeenCalledOnce();
+ });
+
+ it('binds the first message per field on a 422', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, VALIDATION_BODY);
+
+ expect(result().errors.value).toEqual({first_name: 'Required'});
+ });
+
+ it('ignores non-422 responses', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(500, VALIDATION_BODY);
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('yields an empty bag when the 422 body has no errors object', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, {message: 'nope'});
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('yields an empty bag when errors is null', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, {errors: null});
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('yields an empty bag when errors is not an object', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, {errors: 'not-an-object'});
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('yields an empty bag when the 422 body is a non-object', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, 'plain string body');
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('yields an empty bag when the 422 body is null', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, null);
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('handles an error with no response at all', () => {
+ const {httpService, triggerBare} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerBare();
+
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('applies a custom keyMapper to field keys', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const keyMapper = (key: string) => key.replace(/_(\w)/g, (_, c: string) => c.toUpperCase());
+ const {result} = mountComposable(httpService, {keyMapper});
+
+ triggerError(422, VALIDATION_BODY);
+
+ expect(result().errors.value).toEqual({firstName: 'Required'});
+ });
+
+ it('uses raw keys by default (identity keyMapper)', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, {errors: {street_name: ['Required']}});
+
+ expect(result().errors.value).toEqual({street_name: 'Required'});
+ });
+
+ it('clearErrors empties a populated bag', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const {result} = mountComposable(httpService);
+
+ triggerError(422, VALIDATION_BODY);
+ expect(result().errors.value).toEqual({first_name: 'Required'});
+
+ result().clearErrors();
+ expect(result().errors.value).toEqual({});
+ });
+
+ it('unregisters the middleware on unmount', () => {
+ const {httpService, errorMiddlewares} = createMockHttpService();
+ const {wrapper} = mountComposable(httpService);
+
+ expect(errorMiddlewares).toHaveLength(1);
+
+ wrapper.unmount();
+
+ expect(errorMiddlewares).toHaveLength(0);
+ });
+
+ it('swallows a throwing keyMapper via guarded() instead of rejecting', () => {
+ const {httpService, triggerError} = createMockHttpService();
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const keyMapper = () => {
+ throw new Error('mapper blew up');
+ };
+ const {result} = mountComposable(httpService, {keyMapper});
+
+ // The middleware body throws inside guarded(); it must not propagate.
+ expect(() => triggerError(422, VALIDATION_BODY)).not.toThrow();
+ expect(result().errors.value).toEqual({});
+ expect(consoleError).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/form/tsconfig.json b/packages/form/tsconfig.json
new file mode 100644
index 0000000..850007d
--- /dev/null
+++ b/packages/form/tsconfig.json
@@ -0,0 +1 @@
+{"extends": "../../tsconfig.base.json", "compilerOptions": {"outDir": "dist", "rootDir": "src"}, "include": ["src"]}
diff --git a/packages/form/tsdown.config.ts b/packages/form/tsdown.config.ts
new file mode 100644
index 0000000..d4ab38f
--- /dev/null
+++ b/packages/form/tsdown.config.ts
@@ -0,0 +1,3 @@
+import {defineConfig} from 'tsdown';
+
+export default defineConfig({entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true});
diff --git a/packages/form/vitest.config.ts b/packages/form/vitest.config.ts
new file mode 100644
index 0000000..803b93f
--- /dev/null
+++ b/packages/form/vitest.config.ts
@@ -0,0 +1,12 @@
+import {defineProject} from 'vitest/config';
+
+export default defineProject({
+ test: {
+ name: 'form',
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.ts'],
+ thresholds: {lines: 100, branches: 100, functions: 100, statements: 100},
+ },
+ },
+});