diff --git a/.changeset/fi-file-fields-phase-a.md b/.changeset/fi-file-fields-phase-a.md
new file mode 100644
index 000000000..94aae601b
--- /dev/null
+++ b/.changeset/fi-file-fields-phase-a.md
@@ -0,0 +1,5 @@
+---
+"@jayoncode/form-intelligence": minor
+---
+
+File fields: Phase A foundation (`File[]` DOM read/clear, non-persistent omit from drafts/history/offline), Phase B ergonomics (`accept` / `maxSize` / `minSize` / `maxFiles` / `minFiles`, `form.toFormData()` / `form.payload()`, file-shaped `bind()`), and Phase C opt-in `@jayoncode/form-intelligence/upload` (`uploadTransport` multipart progress + abort). Raise `core-login` entry-chunk gzip budget 27→28 KB (ADR-013) for file orchestration on the createForm graph; upload XHR remains `/upload`-only.
diff --git a/apps/browser-session-playground/package.json b/apps/browser-session-playground/package.json
index 679db05c0..2b7ae2768 100644
--- a/apps/browser-session-playground/package.json
+++ b/apps/browser-session-playground/package.json
@@ -20,7 +20,7 @@
"@jayoncode/browser-lifecycle": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.30.1"
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
diff --git a/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts b/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts
index 502a1ee63..25fc1003b 100644
--- a/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts
+++ b/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts
@@ -47,6 +47,7 @@ export function createFormIntelligenceSidebar(
{ text: "Validation", link: `${base}/modules/validation` },
{ text: "Submission", link: `${base}/modules/submission` },
{ text: "CAPTCHA", link: `${base}/modules/captcha` },
+ { text: "Upload transport", link: `${base}/modules/upload` },
{ text: "State", link: `${base}/modules/state` },
{ text: "Workflow", link: `${base}/modules/workflow` },
{ text: "Rules", link: `${base}/modules/rules` },
diff --git a/apps/docs/docs/packages/form-intelligence/overview.md b/apps/docs/docs/packages/form-intelligence/overview.md
index 357da92ae..2830ce726 100644
--- a/apps/docs/docs/packages/form-intelligence/overview.md
+++ b/apps/docs/docs/packages/form-intelligence/overview.md
@@ -38,6 +38,7 @@ Form Intelligence makes those workflows **declarative** on one `createForm()` in
- Sync + async validation (including multiple async checks per field)
- **HTML constraints** on DOM-backed forms (`required`, `minlength`, `type="email"`, … → validators on attach)
+- **File inputs** — `File[]` from `input.files`, file validators, `toFormData()` / `payload()`; optional [`/upload`](/packages/form-intelligence/modules/upload) transport; excluded from drafts/history (not an upload framework)
- Declarative `when()` rules (show / require / populate / gate submit)
- Autosave, draft restore, wizard steps, offline submit queue
- Headless `bind()` + optional framework/schema adapters
diff --git a/apps/form-intelligence-playground/package.json b/apps/form-intelligence-playground/package.json
index 0ecc3e878..2713ec709 100644
--- a/apps/form-intelligence-playground/package.json
+++ b/apps/form-intelligence-playground/package.json
@@ -22,7 +22,7 @@
"@jayoncode/object-diff": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.30.1"
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
diff --git a/apps/object-diff-playground/package.json b/apps/object-diff-playground/package.json
index 4cb555883..8524d68eb 100644
--- a/apps/object-diff-playground/package.json
+++ b/apps/object-diff-playground/package.json
@@ -21,7 +21,7 @@
"@jayoncode/storage": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.30.1"
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
diff --git a/apps/storage-playground/package.json b/apps/storage-playground/package.json
index 0c4e47d69..9a2180eb3 100644
--- a/apps/storage-playground/package.json
+++ b/apps/storage-playground/package.json
@@ -20,7 +20,7 @@
"@jayoncode/storage": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "react-router-dom": "^6.30.1"
+ "react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
diff --git a/packages/form-intelligence/docs/adapters.md b/packages/form-intelligence/docs/adapters.md
index 9d0f6222a..aa9ccff9e 100644
--- a/packages/form-intelligence/docs/adapters.md
+++ b/packages/form-intelligence/docs/adapters.md
@@ -64,6 +64,51 @@ Merge precedence for the same validator **kind**: **Field > Schema > HTML**. Cus
Deferred: `min` / `max` / `step` / `multiple` / date-time constraints, MutationObserver re-extraction.
+## File inputs (Phase A + B)
+
+Form Intelligence **orchestrates** native file fields; it does **not** manage uploads.
+
+| Rule | Behavior |
+| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Read | `input.files` → canonical `File[]` in form values |
+| Never read | `input.value` (fake path string) |
+| State → DOM | Never restore a file selection; clear only |
+| Persistence | File fields are browser-owned ephemeral values — omitted from drafts, autosave, offline queue, and history |
+| Validation | `required` (presence) plus `accept`, `maxSize`, `minSize`, `maxFiles`, `minFiles` |
+| Submit | `onSubmit` values, or `form.toFormData()` / `form.payload()` — apps own the network transport (optional helper: [`uploadTransport`](/packages/form-intelligence/modules/upload)) |
+
+```ts
+import { createForm, required, accept, maxSize, maxFiles } from "@jayoncode/form-intelligence";
+
+const form = createForm({
+ target: "#profile",
+ schema: {
+ name: { required: true },
+ avatar: "file",
+ },
+ validators: {
+ avatar: [required, accept("image/*"), maxSize("5MB"), maxFiles(1)],
+ },
+ async onSubmit() {
+ await fetch("/api/profile", { method: "POST", body: form.toFormData() });
+ },
+});
+```
+
+```html
+
+```
+
+`form.payload()` returns `{ kind: "json", values }` when no files are selected, or `{ kind: "multipart", formData }` when any file selection is non-empty.
+
+`field().bind()` returns a file-shaped binding (`kind: "file"`, `files`, no controlled `value`) for file fields — do not spread it onto the input as controlled props.
+
+For abortable multipart upload with progress, use the opt-in [`@jayoncode/form-intelligence/upload`](/packages/form-intelligence/modules/upload) plugin. Form Intelligence still does **not** ship cloud SDKs, chunking, or resumable protocols.
+
```ts
createForm({
target: "#register",
diff --git a/packages/form-intelligence/docs/entrypoints.md b/packages/form-intelligence/docs/entrypoints.md
index ef7452823..b74f159f9 100644
--- a/packages/form-intelligence/docs/entrypoints.md
+++ b/packages/form-intelligence/docs/entrypoints.md
@@ -17,6 +17,7 @@ Tree-shakeable imports for `@jayoncode/form-intelligence`. Prefer the **narrowes
| DevTools inspector | `@jayoncode/form-intelligence/devtools` |
| Derived UI projection (`showError`, `canSubmit`, `status`, …) | `@jayoncode/form-intelligence/ui` |
| CAPTCHA Security Stage (`captcha`, providers) | `@jayoncode/form-intelligence/captcha` |
+| Opt-in multipart upload (`uploadTransport`) | `@jayoncode/form-intelligence/upload` |
| Browser-lifecycle / keyboard plugins | `@jayoncode/form-intelligence/plugins` |
| Error helpers (`toNormalizedErrors`, …) | `@jayoncode/form-intelligence/validation` |
@@ -44,6 +45,7 @@ Formatter masks use a `format*` prefix on `/format` only (`formatPhone`, `format
| `…/devtools` | `enableFormDevTools`, `getFormDevTools` | **DevTools: this path only** |
| `…/ui` | Derived UI projection: `ui()`, `createUiProjection`, `showError`, `canSubmit`, `status`, `explain()` — [UI projection → free helpers](/packages/form-intelligence/modules/ui-projection#free-helpers--policy-store-advanced) | **Projection: this path** |
| `…/captcha` | CAPTCHA Security Stage: `captcha()`, `turnstile()`, `recaptcha()`, `hcaptcha()`, `mockCaptcha()` | **CAPTCHA: this path only** |
+| `…/upload` | Opt-in multipart upload transport: `uploadTransport()` — [Upload transport](/packages/form-intelligence/modules/upload) | **Upload: this path only** |
| `…/analytics` | `FormAnalyticsTracker`, `createAnalyticsPlugin` — [Integrations → analytics module API](/packages/form-intelligence/modules/integrations#analytics-module-api) | Prefer `workflow.analytics` |
| `…/adapters` | Adapter types, `createFormController` | Controller also on main |
| `…/dependency` | Dependency engine, `detectDependencyCycles` | `dependencies` also on main |
diff --git a/packages/form-intelligence/docs/migration.md b/packages/form-intelligence/docs/migration.md
index ff6bb1065..05bcaf106 100644
--- a/packages/form-intelligence/docs/migration.md
+++ b/packages/form-intelligence/docs/migration.md
@@ -69,7 +69,7 @@ import { createBrowserLifecyclePlugin } from "@jayoncode/form-intelligence/plugi
### Bundle budget (`core-login`)
-Entry-chunk gzip budget is **27 KB** (ADR-013). Measure with `pnpm --filter @jayoncode/form-intelligence check:size` after `tsc -b`. See [Performance](/packages/form-intelligence/modules/performance).
+Entry-chunk gzip budget is **28 KB** (ADR-013). Measure with `pnpm --filter @jayoncode/form-intelligence check:size` after `tsc -b`. See [Performance](/packages/form-intelligence/modules/performance).
### Controllers / accessibility
diff --git a/packages/form-intelligence/docs/overview.md b/packages/form-intelligence/docs/overview.md
index 69ab83ae0..41c94ef96 100644
--- a/packages/form-intelligence/docs/overview.md
+++ b/packages/form-intelligence/docs/overview.md
@@ -33,6 +33,7 @@ Form Intelligence makes those workflows **declarative** on one `createForm()` in
- Sync + async validation (including multiple async checks per field)
- **HTML constraints** on DOM-backed forms (`required`, `minlength`, `type="email"`, … → validators on attach)
+- **File inputs** — `File[]` from `input.files`, file validators, `toFormData()` / `payload()`; optional [`/upload`](/packages/form-intelligence/modules/upload) transport; excluded from drafts/history (not an upload framework)
- Declarative `when()` rules (show / require / populate / gate submit)
- Autosave, draft restore, wizard steps, offline submit queue
- Headless `bind()` + optional framework/schema adapters
diff --git a/packages/form-intelligence/docs/performance.md b/packages/form-intelligence/docs/performance.md
index 57a00e134..8aea700b5 100644
--- a/packages/form-intelligence/docs/performance.md
+++ b/packages/form-intelligence/docs/performance.md
@@ -18,11 +18,11 @@ Budgets below measure **entry chunks**, not “all features in one bundle.”
Measured as **entry chunk** gzip (esbuild minify + `splitting: true`) so dynamic `import()` of offline/analytics/object-diff/integrations stays out of the login graph.
-| Fixture | maxGzipKb | Notes |
-| ---------------- | --------- | ----------------------------------------------------------------------------------- |
-| `core-login` | 27 | `createForm` login graph; forbids DevTools / offline queue class / analytics module |
-| `workflow-rules` | 3 | Rules-only subpath |
-| `format-only` | 2 | Format subpath |
+| Fixture | maxGzipKb | Notes |
+| ---------------- | --------- | ----------------------------------------------------------------------------------------- |
+| `core-login` | 28 | `createForm` login graph; forbids DevTools / offline queue class / analytics / upload XHR |
+| `workflow-rules` | 3 | Rules-only subpath |
+| `format-only` | 2 | Format subpath |
### Ratcheting (ADR-013)
@@ -49,6 +49,13 @@ Measured as **entry chunk** gzip (esbuild minify + `splitting: true`) so dynamic
- core-login entry ≈ **26.1 KB** gzip with merge + extract on the always-on graph
- Raised budget **26 → 27**; DevTools / offline / captcha forbid list unchanged
+### File fields + upload stage (2026-07)
+
+- Phase A/B file orchestration (`File[]`, `toFormData` / `payload`, ephemeral omit) lives on the main `createForm` graph
+- Opt-in `/upload` XHR stays out of core (`xhrMultipartUpload` / `uploadTransport` forbid needles); submit override WeakMap mirrors Security Stage under `/submission`
+- core-login entry ≈ **27.8 KB** gzip
+- Raised budget **27 → 28**; DevTools / offline / captcha / upload XHR forbid list unchanged
+
## Timing budgets (Vitest)
See `tests/performance/performance-budgets.test.ts`.
diff --git a/packages/form-intelligence/docs/plugins.md b/packages/form-intelligence/docs/plugins.md
index 389cef94d..0eea14397 100644
--- a/packages/form-intelligence/docs/plugins.md
+++ b/packages/form-intelligence/docs/plugins.md
@@ -17,6 +17,7 @@ Add cross-cutting behavior — analytics, guards, or integrations — without fo
| Middleware stage maps | `@jayoncode/form-intelligence/middleware` |
| UI policies (`ui()`) | `@jayoncode/form-intelligence/ui` |
| CAPTCHA (`captcha()`, `turnstile()`) | `@jayoncode/form-intelligence/captcha` |
+| Upload transport (`uploadTransport()`) | `@jayoncode/form-intelligence/upload` |
| DevTools inspector | `@jayoncode/form-intelligence/devtools` |
Full map: [Entrypoints](/packages/form-intelligence/modules/entrypoints).
diff --git a/packages/form-intelligence/docs/upload.md b/packages/form-intelligence/docs/upload.md
new file mode 100644
index 000000000..b3d712768
--- /dev/null
+++ b/packages/form-intelligence/docs/upload.md
@@ -0,0 +1,102 @@
+# Upload transport
+
+Opt-in multipart upload with progress and abort — without turning Form Intelligence into an upload framework.
+
+**Related:** [Adapters → File inputs](/packages/form-intelligence/modules/adapters#file-inputs) · [Submission](/packages/form-intelligence/modules/submission) · [Entrypoints](/packages/form-intelligence/modules/entrypoints)
+
+## Import path
+
+```ts
+import { uploadTransport } from "@jayoncode/form-intelligence/upload";
+```
+
+| Need | Import |
+| ------------------------ | ------------------------------------- |
+| Plugin + XHR helper | `@jayoncode/form-intelligence/upload` |
+| File values / validators | main package (see Adapters) |
+
+Core stays free of upload networking unless this plugin is registered.
+
+## Basic usage
+
+```ts
+import { createForm } from "@jayoncode/form-intelligence";
+import { uploadTransport } from "@jayoncode/form-intelligence/upload";
+
+createForm({
+ target: "#profile",
+ initialValues: { name: "", avatar: [] },
+ schema: { avatar: "file" },
+ plugins: [
+ uploadTransport({
+ url: "/api/upload",
+ onProgress: (p) => {
+ // p.loaded / p.total / p.percent (percent may be null)
+ },
+ }),
+ ],
+ onSubmit(values, meta) {
+ // Optional: runs after a successful upload when files were sent.
+ // meta.upload → { status, responseText, response }
+ },
+});
+```
+
+By default (`whenFilesOnly: true`), JSON-only submits call `onSubmit` as usual. Submits that include files POST multipart `FormData` via XHR (so upload progress works).
+
+Cancel with `form.cancelSubmit()` — the transport honors the submit `AbortSignal`. A late XHR `onload` after cancel is treated as abort (no `onComplete` / `onSubmit`).
+
+## Options
+
+| Option | Default | Description |
+| --------------- | -------- | ----------------------------------------------------------------------------------------------------- |
+| `url` | — | Target URL for the built-in XHR transport (required unless `transport` / `buildRequest` supplies one) |
+| `method` | `"POST"` | HTTP method |
+| `headers` | — | Request headers (`Content-Type` is omitted so the browser sets the multipart boundary) |
+| `buildRequest` | — | Per-submit `{ url, method?, headers? }` from values + FormData |
+| `transport` | — | Custom uploader `(formData, { values, signal, onProgress })` — use for presigned / direct-to-cloud |
+| `whenFilesOnly` | `true` | Only intercept multipart payloads; otherwise always upload `FormData` |
+| `onProgress` | — | `{ loaded, total, percent }` |
+| `onComplete` | — | Called with `{ status, responseText, response }` |
+| `onError` | — | Called before the error is rethrown into the submit pipeline |
+
+## Events
+
+| Event | Payload |
+| ----------------- | ---------------- |
+| `upload:progress` | `UploadProgress` |
+| `upload:complete` | `UploadResult` |
+| `upload:error` | `unknown` |
+
+```ts
+form.on("upload:progress", (progress) => {
+ /* … */
+});
+```
+
+## Custom transport
+
+Presigned URLs and cloud SDKs stay **app-owned**:
+
+```ts
+uploadTransport({
+ transport: async (formData, { signal, onProgress }) => {
+ // Your PUT/POST to a presigned URL, S3 SDK, etc.
+ // Call onProgress when you can; honor signal.
+ return { status: 200, responseText: "", response: null };
+ },
+});
+```
+
+## Explicit non-goals
+
+- Chunking / resumable / tus
+- Built-in S3 / Azure / GCS clients
+- Background uploads / service workers
+- Persisting file selections in drafts (see Phase A ephemeral rules)
+
+## Related
+
+- [Adapters → File inputs](/packages/form-intelligence/modules/adapters#file-inputs)
+- [Submission](/packages/form-intelligence/modules/submission)
+- [Plugins](/packages/form-intelligence/modules/plugins)
diff --git a/packages/form-intelligence/engineering/003-modular-packages.md b/packages/form-intelligence/engineering/003-modular-packages.md
index c9755fc05..4c1ae182d 100644
--- a/packages/form-intelligence/engineering/003-modular-packages.md
+++ b/packages/form-intelligence/engineering/003-modular-packages.md
@@ -272,7 +272,7 @@ Fixtures:
| Fixture | Import surface | Budget (gzip) |
| ---------------- | ----------------------------------- | ------------- |
-| `core-login` | `createForm` + validators from main | 27 KB |
+| `core-login` | `createForm` + validators from main | 28 KB |
| `workflow-rules` | `when` from `/rules` | 3 KB |
| `format-only` | formatters from `/format` | 2 KB |
diff --git a/packages/form-intelligence/package.json b/packages/form-intelligence/package.json
index eb7e566b8..b2fba1a35 100644
--- a/packages/form-intelligence/package.json
+++ b/packages/form-intelligence/package.json
@@ -106,6 +106,10 @@
"./captcha": {
"types": "./dist/captcha/index.d.ts",
"import": "./dist/captcha/index.js"
+ },
+ "./upload": {
+ "types": "./dist/upload/index.d.ts",
+ "import": "./dist/upload/index.js"
}
},
"repository": {
diff --git a/packages/form-intelligence/scripts/bundle-budgets.json b/packages/form-intelligence/scripts/bundle-budgets.json
index 2ec9811a2..48226779c 100644
--- a/packages/form-intelligence/scripts/bundle-budgets.json
+++ b/packages/form-intelligence/scripts/bundle-budgets.json
@@ -3,7 +3,7 @@
{
"id": "core-login",
"file": "core-login.mjs",
- "maxGzipKb": 27,
+ "maxGzipKb": 28,
"forbid": [
"OfflineSubmitQueue",
"createDevToolsPlugin",
@@ -14,7 +14,9 @@
"js.hcaptcha.com",
"turnstile",
"grecaptcha",
- "hcaptcha"
+ "hcaptcha",
+ "xhrMultipartUpload",
+ "uploadTransport"
]
},
{
diff --git a/packages/form-intelligence/scripts/check-entry-sizes.mjs b/packages/form-intelligence/scripts/check-entry-sizes.mjs
index aef52243b..075412e44 100644
--- a/packages/form-intelligence/scripts/check-entry-sizes.mjs
+++ b/packages/form-intelligence/scripts/check-entry-sizes.mjs
@@ -35,6 +35,7 @@ const entries = [
"history/index.js",
"ui/index.js",
"captcha/index.js",
+ "upload/index.js",
];
console.log("@jayoncode/form-intelligence dist entry sizes (raw / gzip):\n");
diff --git a/packages/form-intelligence/src/core/create-form.ts b/packages/form-intelligence/src/core/create-form.ts
index 54a618e7c..9d43a0255 100644
--- a/packages/form-intelligence/src/core/create-form.ts
+++ b/packages/form-intelligence/src/core/create-form.ts
@@ -46,6 +46,14 @@ import {
import { WhenRuleBuilder } from "../engines/workflow/when.js";
import { ConfigurationError } from "../errors/index.js";
import { createFieldHandle } from "../fields/field-handle.js";
+import {
+ coerceToCanonicalFileValue,
+ emptyFileValue,
+ isCanonicalFileValue,
+ mergePreservingNonPersistent,
+ omitPaths,
+} from "../fields/file.js";
+import { buildFormPayload, valuesToFormData } from "../fields/form-data.js";
import { formatFieldValue as runFieldFormatPipeline } from "../format/pipeline.js";
import { registerConfiguredModules } from "../modules/register-configured.js";
import { resolveHookResult } from "../plugins/hooks.js";
@@ -57,6 +65,7 @@ import { FormStateStore } from "../state/store.js";
import { evaluateSubmissionGuard } from "../submission/guard.js";
import { bindSecurityStageNotify, runSecurityStage } from "../submission/security-stage.js";
import { SubmissionOrchestrator } from "../submission/submit.js";
+import { resolveSubmitHandler } from "../submission/upload-stage.js";
import { ASYNC_VALIDATOR_OPTION_DEFAULTS } from "../types/async-validation.js";
import { explainDisabled } from "../ui/explain-disabled.js";
import { projectFieldUi } from "../ui/field-projection.js";
@@ -94,6 +103,7 @@ import type {
FieldOption,
FieldUiState,
} from "../engines/workflow/types.js";
+import type { ToFormDataOptions } from "../fields/form-data.js";
import type { MiddlewareInput } from "../plugins/middleware.js";
import type { FormCoreState } from "../state/store.js";
import type { SubmissionGuardResult } from "../submission/guard.js";
@@ -144,6 +154,8 @@ class FormInstanceImpl> implements FormI
/** Kind-merged Schema + Field + HTML used by the validation pipeline. */
private effectiveValidators: FormConfig["validators"];
private readonly fieldOptions = new Map>();
+ /** Browser-owned ephemeral paths (file fields) — omitted from drafts/history/offline. */
+ private readonly nonPersistentPaths = new Set();
private readonly store: FormStateStore;
private readonly moduleHost: FormModuleHost;
private readonly undoRedo = new UndoRedoController();
@@ -231,6 +243,23 @@ class FormInstanceImpl> implements FormI
: options.fieldPaths;
this.requiredBaseline = new Set(options.requiredBaseline ?? []);
+ for (const [path, value] of Object.entries(this.config.initialValues)) {
+ if (Array.isArray(value) && value.length === 0) {
+ const schemaDef =
+ config.schema && !isSchemaAdapter(config.schema)
+ ? (config.schema as Record)[path]
+ : undefined;
+ const isFileSchema =
+ schemaDef === "file" ||
+ (typeof schemaDef === "object" &&
+ schemaDef !== null &&
+ (schemaDef as { type?: string }).type === "file");
+ if (isFileSchema) {
+ this.nonPersistentPaths.add(path);
+ }
+ }
+ }
+
const draftKey = this.config.workflow?.draft?.storageKey ?? `${this.id}:draft`;
const draftConfig = this.config.workflow?.draft;
this.draftManager = new DraftManager(draftConfig, draftKey, { formId: this.id });
@@ -566,6 +595,7 @@ class FormInstanceImpl> implements FormI
}
void this.validate({ paths: [path], mode: "onBlur" });
},
+ isFileField: () => this.nonPersistentPaths.has(path),
});
}
@@ -694,7 +724,7 @@ class FormInstanceImpl> implements FormI
if (this.config.workflow?.offlineQueue?.enabled && isNavigatorOffline()) {
const offline = await this.ensureOfflineService();
try {
- offline.ensure().enqueue(cloneValue(this.core.values));
+ offline.ensure().enqueue(cloneValue(this.valuesForPersistence()));
} catch (error) {
this.config.onSubmitError?.(error);
this.setSubmitPhase("error");
@@ -743,11 +773,12 @@ class FormInstanceImpl> implements FormI
this.events.emit("submit");
try {
+ const onSubmit = resolveSubmitHandler(this as FormInstance, this.config.onSubmit);
const result = await this.submission.execute({
values: this.core.values,
submitCount: this.core.submitCount,
...(meta ? { meta } : {}),
- ...(this.config.onSubmit ? { onSubmit: this.config.onSubmit } : {}),
+ ...(onSubmit ? { onSubmit } : {}),
...(this.config.onSubmitError ? { onSubmitError: this.config.onSubmitError } : {}),
...(options ? { options } : {}),
});
@@ -1064,6 +1095,18 @@ class FormInstanceImpl> implements FormI
this.patchValues(path, value, options);
}
+ public markNonPersistent(path: FieldPath): void {
+ this.nonPersistentPaths.add(path);
+ }
+
+ public toFormData(options?: ToFormDataOptions): FormData {
+ return valuesToFormData(this.core.values as Record, options);
+ }
+
+ public payload(options?: ToFormDataOptions) {
+ return buildFormPayload(this.core.values, options);
+ }
+
public setError(path: FieldPath, message: string): void {
this.patchState({ errors: { ...this.core.errors, [path]: message } });
}
@@ -1180,7 +1223,7 @@ class FormInstanceImpl> implements FormI
version: 1,
kind: "checkpoint",
capturedAt: Date.now(),
- values: cloneValue(this.core.values),
+ values: this.valuesForPersistence(),
};
const withMeta = {
@@ -1205,7 +1248,11 @@ class FormInstanceImpl> implements FormI
}
const restoreMeta = options.restoreMeta !== false;
- const nextValues = cloneValue(checkpoint.values);
+ const nextValues = mergePreservingNonPersistent(
+ cloneValue(checkpoint.values),
+ this.core.values,
+ this.nonPersistentPaths,
+ );
this.store.replaceValues(nextValues);
this.store.patchCore({
@@ -1221,7 +1268,7 @@ class FormInstanceImpl> implements FormI
}
if (options.recordHistory) {
- this.undoRedo.record(cloneValue(nextValues));
+ this.undoRedo.record(cloneValue(omitPaths(nextValues, this.nonPersistentPaths)));
}
this.recomputeFieldUi();
@@ -1370,7 +1417,7 @@ class FormInstanceImpl> implements FormI
public saveDraft(): void {
const persistStep = this.config.workflow?.wizard?.persistStepInDraft === true;
this.draftManager.save(
- this.core.values,
+ this.valuesForPersistence(),
persistStep ? { currentStep: this.core.currentStep, persistWorkflow: true } : {},
);
this.events.emit("draft");
@@ -1400,7 +1447,13 @@ class FormInstanceImpl> implements FormI
return false;
}
- this.store.replaceValues(cloneValue(result.values));
+ this.store.replaceValues(
+ mergePreservingNonPersistent(
+ cloneValue(result.values),
+ this.core.values,
+ this.nonPersistentPaths,
+ ),
+ );
if (result.workflow?.currentStep !== undefined) {
this.store.patchCore({ currentStep: result.workflow.currentStep });
}
@@ -1420,7 +1473,12 @@ class FormInstanceImpl> implements FormI
return false;
}
- this.store.replaceValues(cloneValue(previous));
+ const restored = mergePreservingNonPersistent(
+ previous,
+ this.core.values,
+ this.nonPersistentPaths,
+ );
+ this.store.replaceValues(cloneValue(restored));
this.recomputeFieldUi();
this.notify();
this.events.emit("change");
@@ -1433,7 +1491,8 @@ class FormInstanceImpl> implements FormI
return false;
}
- this.store.replaceValues(cloneValue(next));
+ const restored = mergePreservingNonPersistent(next, this.core.values, this.nonPersistentPaths);
+ this.store.replaceValues(cloneValue(restored));
this.recomputeFieldUi();
this.notify();
this.events.emit("change");
@@ -1518,10 +1577,14 @@ class FormInstanceImpl> implements FormI
return this.store.subscribe(listener);
}
- public on(event: FormEvent, listener: () => void): () => void {
+ public on(event: FormEvent, listener: (payload?: unknown) => void): () => void {
return this.events.on(event, listener);
}
+ public emit(event: FormEvent, payload?: unknown): void {
+ this.events.emit(event, payload);
+ }
+
public destroy(): void {
if (this.store.isDestroyed()) {
return;
@@ -1607,7 +1670,7 @@ class FormInstanceImpl> implements FormI
}
private setupAutosave(): void {
- this.autosave.configure(this.config.workflow?.autosave, () => cloneValue(this.core.values), {
+ this.autosave.configure(this.config.workflow?.autosave, () => this.valuesForPersistence(), {
onStart: () => {
this.patchState({ isAutosaving: true });
this.events.emit("autosave");
@@ -1615,7 +1678,7 @@ class FormInstanceImpl> implements FormI
onSuccess: async (savedAt) => {
this.patchState({ isAutosaving: false, lastAutosaveAt: savedAt });
await this.pluginRegistry.hookBus.runOnAutosave({
- values: cloneValue(this.core.values),
+ values: this.valuesForPersistence(),
savedAt,
});
},
@@ -1626,7 +1689,7 @@ class FormInstanceImpl> implements FormI
if (this.config.workflow?.draft?.enabled) {
const persistStep = this.config.workflow?.wizard?.persistStepInDraft === true;
this.draftManager.save(
- this.core.values,
+ this.valuesForPersistence(),
persistStep ? { currentStep: this.core.currentStep, persistWorkflow: true } : {},
);
this.events.emit("draft");
@@ -1864,6 +1927,10 @@ class FormInstanceImpl> implements FormI
path: FieldPath,
fieldOptions?: FieldOptions,
): unknown {
+ if (this.nonPersistentPaths.has(path) && isCanonicalFileValue(value)) {
+ return value;
+ }
+
const ctx = {
path,
values: this.core.values,
@@ -1881,9 +1948,30 @@ class FormInstanceImpl> implements FormI
return runFieldFormatPipeline(next, fieldOptions);
}
+ private resolvePatchValue(path: FieldPath, value: unknown): unknown {
+ const coerced = coerceToCanonicalFileValue(value);
+ if (coerced !== null) {
+ this.nonPersistentPaths.add(path);
+ return coerced;
+ }
+
+ if (this.nonPersistentPaths.has(path)) {
+ if (value === null || value === undefined || value === "") {
+ return emptyFileValue();
+ }
+ }
+
+ return value;
+ }
+
+ private valuesForPersistence(): TValues {
+ return cloneValue(omitPaths(this.core.values, this.nonPersistentPaths));
+ }
+
private patchValuesSilent(path: FieldPath, value: unknown, options?: SetValueOptions): void {
+ const resolved = this.resolvePatchValue(path, value);
const fieldOptions = this.fieldOptions.get(path);
- const formatted = this.applyFieldFormatting(value, path, fieldOptions);
+ const formatted = this.applyFieldFormatting(resolved, path, fieldOptions);
const previous = getIn(this.core.values, path);
if (previous === formatted) {
return;
@@ -1904,15 +1992,16 @@ class FormInstanceImpl> implements FormI
}
private patchValues(path: FieldPath, value: unknown, options?: SetValueOptions): void {
+ const resolved = this.resolvePatchValue(path, value);
const fieldOpts = this.fieldOptions.get(path);
- const formatted = this.applyFieldFormatting(value, path, fieldOpts);
+ const formatted = this.applyFieldFormatting(resolved, path, fieldOpts);
const previous = getIn(this.core.values, path);
if (previous === formatted) {
return;
}
if (options?.recordHistory !== false) {
- this.undoRedo.record(cloneValue(this.core.values));
+ this.undoRedo.record(cloneValue(this.valuesForPersistence()));
}
this.store.setValueAt(path, formatted);
@@ -2047,7 +2136,7 @@ class FormInstanceImpl> implements FormI
nextArray: unknown[],
mutation?: ArrayFieldMutation,
): void {
- this.undoRedo.record(cloneValue(this.core.values));
+ this.undoRedo.record(cloneValue(this.valuesForPersistence()));
if (mutation) {
this.store.patchCore({
diff --git a/packages/form-intelligence/src/core/events.ts b/packages/form-intelligence/src/core/events.ts
index f6154737d..5b10fb80b 100644
--- a/packages/form-intelligence/src/core/events.ts
+++ b/packages/form-intelligence/src/core/events.ts
@@ -1,6 +1,6 @@
import type { FormEvent } from "../types/index.js";
-type EventListener = () => void;
+type EventListener = (payload?: unknown) => void;
export class FormEventBus {
private readonly listeners = new Map>();
@@ -15,14 +15,14 @@ export class FormEventBus {
};
}
- public emit(event: FormEvent): void {
+ public emit(event: FormEvent, payload?: unknown): void {
const bucket = this.listeners.get(event);
if (!bucket) {
return;
}
for (const listener of bucket) {
- listener();
+ listener(payload);
}
}
diff --git a/packages/form-intelligence/src/dom/enhance-form.ts b/packages/form-intelligence/src/dom/enhance-form.ts
index b93b7cfba..93be88767 100644
--- a/packages/form-intelligence/src/dom/enhance-form.ts
+++ b/packages/form-intelligence/src/dom/enhance-form.ts
@@ -1,5 +1,5 @@
import { findFieldContainer, findFieldControl, findFieldControls } from "./discover-fields.js";
-import { readControlValue, writeControlValue } from "./field-value.js";
+import { isFileInputElement, readControlValue, writeControlValue } from "./field-value.js";
import { shouldShowErrorWithPolicies } from "../ui/show-error.js";
import { hasUiPoliciesRegistered } from "../ui/store.js";
import { resolvePoliciesForForm } from "../ui/store.js";
@@ -211,6 +211,13 @@ export function attachDomEnhancer>(
continue;
}
+ // File inputs: never restore selection from state; clear-only via writeControlValue.
+ if (isFileInputElement(control)) {
+ const nextValue = form.get(path);
+ writeControlValue(control, nextValue);
+ continue;
+ }
+
const nextValue = form.get(path);
const currentValue = readControlValue(control);
if (currentValue !== nextValue) {
@@ -267,6 +274,14 @@ export function attachDomEnhancer>(
form.field(path);
+ if (isFileInputElement(control)) {
+ form.markNonPersistent(path);
+ const current = form.get(path);
+ if (current === "" || current === undefined || current === null) {
+ form.setValue(path, [], { markDirty: false, recordHistory: false });
+ }
+ }
+
const handleInput = (): void => {
form.setValue(path, readControlValue(control));
};
diff --git a/packages/form-intelligence/src/dom/field-value.ts b/packages/form-intelligence/src/dom/field-value.ts
index fd73858f6..04cf795cf 100644
--- a/packages/form-intelligence/src/dom/field-value.ts
+++ b/packages/form-intelligence/src/dom/field-value.ts
@@ -1,5 +1,18 @@
+import {
+ clearFileInput,
+ coerceToCanonicalFileValue,
+ emptyFileValue,
+ isEmptyFileValue,
+ isFileInputElement,
+ readFileInputValue,
+} from "../fields/file.js";
+
export function readControlValue(control: HTMLElement): unknown {
if (control instanceof HTMLInputElement) {
+ if (isFileInputElement(control)) {
+ return readFileInputValue(control);
+ }
+
if (control.type === "checkbox") {
return control.checked;
}
@@ -28,6 +41,14 @@ export function readControlValue(control: HTMLElement): unknown {
export function writeControlValue(control: HTMLElement, value: unknown): void {
if (control instanceof HTMLInputElement) {
+ if (isFileInputElement(control)) {
+ // State → DOM: never restore a file selection; clear only.
+ if (isEmptyFileValue(value)) {
+ clearFileInput(control);
+ }
+ return;
+ }
+
if (control.type === "checkbox") {
control.checked = Boolean(value);
return;
@@ -78,3 +99,5 @@ export function readNamedFieldValue(form: HTMLFormElement, name: string): unknow
return "";
}
+
+export { coerceToCanonicalFileValue, emptyFileValue, isFileInputElement };
diff --git a/packages/form-intelligence/src/fields/field-handle.ts b/packages/form-intelligence/src/fields/field-handle.ts
index 06c1eb51e..46d30d86a 100644
--- a/packages/form-intelligence/src/fields/field-handle.ts
+++ b/packages/form-intelligence/src/fields/field-handle.ts
@@ -1,3 +1,4 @@
+import { coerceToCanonicalFileValue, emptyFileValue, isCanonicalFileValue } from "./file.js";
import { computeFieldAria } from "../engines/accessibility/compute-aria.js";
import type { FieldAriaIds, FieldAriaResult } from "../engines/accessibility/types.js";
@@ -29,6 +30,8 @@ export interface FieldHandleContext {
emitBlur(): void;
emitFocus(): void;
validateOnBlur(): void;
+ /** True for browser-owned ephemeral file fields. */
+ isFileField?: () => boolean;
}
function resolveAria(context: FieldHandleContext): FieldAriaResult {
@@ -102,7 +105,31 @@ export function createFieldHandle>(
return context.validateField();
},
bind(): FieldBinding {
+ const useFile = context.isFileField?.() === true || isCanonicalFileValue(context.getValue());
+
+ if (useFile) {
+ return {
+ kind: "file",
+ name: context.path,
+ get files() {
+ const current = context.getValue();
+ return isCanonicalFileValue(current) ? current : emptyFileValue();
+ },
+ onChange: (files) => {
+ if (files === null || files === undefined) {
+ context.setValue(emptyFileValue());
+ return;
+ }
+ const coerced = coerceToCanonicalFileValue(files);
+ context.setValue(coerced ?? emptyFileValue());
+ },
+ onBlur,
+ onFocus,
+ };
+ }
+
return {
+ kind: "value",
name: context.path,
get value() {
return context.getValue();
diff --git a/packages/form-intelligence/src/fields/file.ts b/packages/form-intelligence/src/fields/file.ts
new file mode 100644
index 000000000..675bba9c2
--- /dev/null
+++ b/packages/form-intelligence/src/fields/file.ts
@@ -0,0 +1,137 @@
+import { getIn, parsePath, setIn } from "../utils/index.js";
+
+import type { FieldPath } from "../types/index.js";
+
+/** Canonical in-memory representation for file fields (ADR-FILE-001 Phase A). */
+export type CanonicalFileValue = File[];
+
+export function isFileInputElement(
+ control: HTMLElement,
+): control is HTMLInputElement & { type: "file" } {
+ return control instanceof HTMLInputElement && control.type === "file";
+}
+
+export function emptyFileValue(): CanonicalFileValue {
+ return [];
+}
+
+export function isCanonicalFileValue(value: unknown): value is CanonicalFileValue {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+ if (value.length === 0) {
+ return true;
+ }
+ if (typeof File === "undefined") {
+ return false;
+ }
+ return value.every((entry) => entry instanceof File);
+}
+
+export function isEmptyFileValue(value: unknown): boolean {
+ if (value === null || value === undefined || value === "") {
+ return true;
+ }
+ if (Array.isArray(value)) {
+ return value.length === 0;
+ }
+ if (typeof FileList !== "undefined" && value instanceof FileList) {
+ return value.length === 0;
+ }
+ return false;
+}
+
+/**
+ * Coerce DOM / bind payloads into the canonical `File[]` representation.
+ * Returns `null` when the value is not file-shaped.
+ */
+export function coerceToCanonicalFileValue(value: unknown): CanonicalFileValue | null {
+ if (typeof FileList !== "undefined" && value instanceof FileList) {
+ return Array.from(value);
+ }
+ if (typeof File !== "undefined" && value instanceof File) {
+ return [value];
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return [];
+ }
+ if (typeof File !== "undefined" && value.every((entry) => entry instanceof File)) {
+ return value as File[];
+ }
+ }
+ return null;
+}
+
+export function readFileInputValue(control: HTMLInputElement): CanonicalFileValue {
+ return control.files ? Array.from(control.files) : [];
+}
+
+/** Clear selection only — never restore files from state (ADR-FILE-001). */
+export function clearFileInput(control: HTMLInputElement): void {
+ control.value = "";
+}
+
+function deleteIn(values: Record, path: FieldPath): Record {
+ const segments = parsePath(path);
+ if (segments.length === 0) {
+ return values;
+ }
+
+ if (segments.length === 1) {
+ const key = segments[0];
+ if (key === undefined) {
+ return values;
+ }
+ const { [key]: _removed, ...rest } = values;
+ return rest;
+ }
+
+ const parentPath = segments.slice(0, -1).join(".");
+ const last = segments[segments.length - 1];
+ if (last === undefined) {
+ return values;
+ }
+ const parent = getIn(values, parentPath);
+ if (!parent || typeof parent !== "object" || Array.isArray(parent)) {
+ return values;
+ }
+ const { [last]: _removed, ...rest } = parent as Record;
+ return setIn(values, parentPath, rest);
+}
+
+/** Drop non-persistent paths from a values snapshot (drafts / offline / history). */
+export function omitPaths>(
+ values: TValues,
+ paths: ReadonlySet,
+): TValues {
+ if (paths.size === 0) {
+ return values;
+ }
+
+ let next: Record = { ...values };
+ for (const path of paths) {
+ next = deleteIn(next, path);
+ }
+ return next as TValues;
+}
+
+/**
+ * Apply a persistence/history snapshot while keeping current browser-owned
+ * ephemeral values for registered non-persistent paths.
+ */
+export function mergePreservingNonPersistent>(
+ snapshot: TValues,
+ current: TValues,
+ nonPersistentPaths: ReadonlySet,
+): TValues {
+ if (nonPersistentPaths.size === 0) {
+ return snapshot;
+ }
+
+ let next: Record = { ...snapshot };
+ for (const path of nonPersistentPaths) {
+ next = setIn(next, path, getIn(current, path));
+ }
+ return next as TValues;
+}
diff --git a/packages/form-intelligence/src/fields/form-data.ts b/packages/form-intelligence/src/fields/form-data.ts
new file mode 100644
index 000000000..c712fbc05
--- /dev/null
+++ b/packages/form-intelligence/src/fields/form-data.ts
@@ -0,0 +1,122 @@
+import { isCanonicalFileValue } from "./file.js";
+import { asFileList } from "../validation/validators/file-utils.js";
+
+import type { FieldPath } from "../types/index.js";
+
+export type FormPayloadKind = "json" | "multipart";
+
+export interface JsonFormPayload> {
+ readonly kind: "json";
+ readonly values: TValues;
+}
+
+export interface MultipartFormPayload {
+ readonly kind: "multipart";
+ readonly formData: FormData;
+}
+
+export type FormPayload> =
+ JsonFormPayload | MultipartFormPayload;
+
+export interface ToFormDataOptions {
+ /** Skip empty strings, empty file arrays, null, and undefined. Default true. */
+ readonly omitEmpty?: boolean;
+}
+
+function hasFilePayload(values: Record): boolean {
+ for (const value of Object.values(values)) {
+ if (typeof File !== "undefined" && value instanceof File) {
+ return true;
+ }
+ if (isCanonicalFileValue(value) && value.length > 0) {
+ return true;
+ }
+ if (typeof FileList !== "undefined" && value instanceof FileList && value.length > 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function appendValue(formData: FormData, key: string, value: unknown, omitEmpty: boolean): void {
+ if (value === null || value === undefined) {
+ return;
+ }
+
+ if (typeof File !== "undefined" && value instanceof File) {
+ formData.append(key, value);
+ return;
+ }
+
+ if (
+ isCanonicalFileValue(value) ||
+ (typeof FileList !== "undefined" && value instanceof FileList)
+ ) {
+ const files = asFileList(value);
+ if (files.length === 0) {
+ if (!omitEmpty) {
+ formData.append(key, "");
+ }
+ return;
+ }
+ for (const file of files) {
+ formData.append(key, file);
+ }
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ if (!omitEmpty) {
+ formData.append(key, "");
+ }
+ return;
+ }
+ for (const entry of value) {
+ appendValue(formData, key, entry, omitEmpty);
+ }
+ return;
+ }
+
+ if (typeof value === "boolean" || typeof value === "number") {
+ formData.append(key, String(value));
+ return;
+ }
+
+ if (typeof value === "string") {
+ if (omitEmpty && value === "") {
+ return;
+ }
+ formData.append(key, value);
+ return;
+ }
+
+ if (typeof value === "object") {
+ formData.append(key, JSON.stringify(value));
+ }
+}
+
+/** Build `FormData` from form values (top-level keys). Files append as binary parts. */
+export function valuesToFormData(
+ values: Record,
+ options: ToFormDataOptions = {},
+): FormData {
+ const omitEmpty = options.omitEmpty !== false;
+ const formData = new FormData();
+ for (const [key, value] of Object.entries(values)) {
+ appendValue(formData, key, value, omitEmpty);
+ }
+ return formData;
+}
+
+export function buildFormPayload>(
+ values: TValues,
+ options?: ToFormDataOptions,
+): FormPayload {
+ if (hasFilePayload(values)) {
+ return { kind: "multipart", formData: valuesToFormData(values, options) };
+ }
+ return { kind: "json", values };
+}
+
+export type { FieldPath };
diff --git a/packages/form-intelligence/src/index.ts b/packages/form-intelligence/src/index.ts
index 92f570a67..05ccfd415 100644
--- a/packages/form-intelligence/src/index.ts
+++ b/packages/form-intelligence/src/index.ts
@@ -65,6 +65,12 @@ export {
regex,
required,
url,
+ accept,
+ maxSize,
+ minSize,
+ maxFiles,
+ minFiles,
+ parseByteSize,
} from "./validation/validators/index.js";
export { parseTtl } from "./validation/async/parse-ttl.js";
export { clearSharedValidationCaches } from "./validation/async/memory-cache.js";
@@ -129,6 +135,8 @@ export type {
RestoreDraftOptions,
RestorePromptResult,
FieldBinding,
+ FileFieldBinding,
+ ValueFieldBinding,
FieldHandle,
FieldMetaState,
FieldOption,
@@ -189,6 +197,18 @@ export type {
WorkflowState,
} from "./types/index.js";
export { ASYNC_VALIDATOR_OPTION_DEFAULTS } from "./types/index.js";
-export type { AsyncValidator, AsyncValidatorWithOptions } from "./validation/validators/index.js";
+export type {
+ AsyncValidator,
+ AsyncValidatorWithOptions,
+ FileSizeInput,
+} from "./validation/validators/index.js";
+export type {
+ FormPayload,
+ FormPayloadKind,
+ JsonFormPayload,
+ MultipartFormPayload,
+ ToFormDataOptions,
+} from "./fields/form-data.js";
+export type { CanonicalFileValue } from "./fields/file.js";
export type { FormModule, FormModuleContext } from "./core/module-types.js";
export type { FormatPreset } from "./engines/formatter/presets.js";
diff --git a/packages/form-intelligence/src/schema/compiler.ts b/packages/form-intelligence/src/schema/compiler.ts
index 648eef2e9..c6d5de0a2 100644
--- a/packages/form-intelligence/src/schema/compiler.ts
+++ b/packages/form-intelligence/src/schema/compiler.ts
@@ -51,6 +51,8 @@ function validatorsForBuiltinType(type: BuiltInFieldType): Validator[] {
return [required, password()];
case "url":
return [required, url];
+ case "file":
+ return [];
case "text":
default:
return [];
@@ -125,7 +127,10 @@ export function compileSchema(
continue;
}
- initialValues[path] = "";
+ const isFile =
+ definition === "file" ||
+ (typeof definition === "object" && definition !== null && definition.type === "file");
+ initialValues[path] = isFile ? [] : "";
const compiled = compileFieldSchema(definition);
if (compiled.length > 0) {
validators[path] = compiled;
diff --git a/packages/form-intelligence/src/submission/index.ts b/packages/form-intelligence/src/submission/index.ts
index d1b0a44a6..07960efb5 100644
--- a/packages/form-intelligence/src/submission/index.ts
+++ b/packages/form-intelligence/src/submission/index.ts
@@ -38,3 +38,5 @@ export {
runSecurityStage,
} from "./security-stage.js";
export type { SecurityStageHandler, SecurityStageResult } from "./security-stage.js";
+export { registerUploadTransport, resolveSubmitHandler } from "./upload-stage.js";
+export type { UploadSubmitHandler } from "./upload-stage.js";
diff --git a/packages/form-intelligence/src/submission/upload-stage.ts b/packages/form-intelligence/src/submission/upload-stage.ts
new file mode 100644
index 000000000..71a91ee0a
--- /dev/null
+++ b/packages/form-intelligence/src/submission/upload-stage.ts
@@ -0,0 +1,57 @@
+import type { FormInstance, SubmitMeta } from "../types/index.js";
+
+/**
+ * Upload submit override — pipeline slot that wraps `onSubmit` when
+ * `@jayoncode/form-intelligence/upload` registers (ADR-FILE-002).
+ * Lives under `/submission` so core never imports the `/upload` XHR module.
+ */
+
+export type UploadSubmitHandler> = (
+ values: TValues,
+ meta: SubmitMeta | undefined,
+ originalOnSubmit: ((values: TValues, meta?: SubmitMeta) => void | Promise) | undefined,
+) => Promise;
+
+interface UploadTransportRegistration> {
+ readonly submit: UploadSubmitHandler;
+}
+
+const registrations = new WeakMap<
+ FormInstance>,
+ UploadTransportRegistration>
+>();
+
+export function registerUploadTransport>(
+ form: FormInstance,
+ submit: UploadSubmitHandler,
+): () => void {
+ const formLike = form as FormInstance>;
+ const registration: UploadTransportRegistration> = {
+ submit: submit as UploadSubmitHandler>,
+ };
+ registrations.set(formLike, registration);
+ return () => {
+ if (registrations.get(formLike) === registration) {
+ registrations.delete(formLike);
+ }
+ };
+}
+
+export function resolveSubmitHandler>(
+ form: FormInstance,
+ originalOnSubmit: ((values: TValues, meta?: SubmitMeta) => void | Promise) | undefined,
+): ((values: TValues, meta?: SubmitMeta) => void | Promise) | undefined {
+ const registration = registrations.get(form as FormInstance>);
+ if (!registration) {
+ return originalOnSubmit;
+ }
+
+ return async (values, meta) => {
+ await registration.submit(
+ values as Record,
+ meta,
+ originalOnSubmit as
+ ((values: Record, meta?: SubmitMeta) => void | Promise) | undefined,
+ );
+ };
+}
diff --git a/packages/form-intelligence/src/types/index.ts b/packages/form-intelligence/src/types/index.ts
index 5f5156908..060c4b624 100644
--- a/packages/form-intelligence/src/types/index.ts
+++ b/packages/form-intelligence/src/types/index.ts
@@ -62,7 +62,10 @@ export type FormEvent =
| "validate"
| "validated"
| "autosave"
- | "draft";
+ | "draft"
+ | "upload:progress"
+ | "upload:complete"
+ | "upload:error";
export type ValidatorResult = true | false | string | undefined;
@@ -96,7 +99,7 @@ export type {
DependencyNode,
} from "../engines/dependency/types.js";
-export type BuiltInFieldType = "text" | "email" | "password" | "url";
+export type BuiltInFieldType = "text" | "email" | "password" | "url" | "file";
export interface FieldValidateRules {
readonly required?: boolean;
@@ -187,7 +190,8 @@ export interface FieldHandle<_TValues extends Record> {
bind(): FieldBinding;
}
-export interface FieldBinding {
+export interface ValueFieldBinding {
+ readonly kind?: "value";
readonly name: string;
readonly value: unknown;
readonly onChange: (value: unknown) => void;
@@ -195,6 +199,19 @@ export interface FieldBinding {
readonly onFocus: () => void;
}
+export interface FileFieldBinding {
+ readonly kind: "file";
+ readonly name: string;
+ /** Canonical file selection (`File[]`). */
+ readonly files: File[];
+ readonly onChange: (files: File[] | FileList | null | undefined) => void;
+ readonly onBlur: () => void;
+ readonly onFocus: () => void;
+}
+
+/** Headless binding — file fields omit controlled `value` (ADR-FILE-001). */
+export type FieldBinding = ValueFieldBinding | FileFieldBinding;
+
export type { Formatter, Parser } from "../format/types.js";
export type { FormatPreset } from "../format/presets.js";
export type { DraftStorageAdapter, DraftStorageKind } from "../engines/draft/storage-adapter.js";
@@ -391,12 +408,21 @@ export interface SubmitSecurityMeta {
readonly captcha?: SubmitSecurityCaptcha;
}
+/** Populated by the opt-in upload transport plugin (`@jayoncode/form-intelligence/upload`). */
+export interface SubmitUploadMeta {
+ readonly status: number;
+ readonly responseText: string;
+ readonly response: unknown;
+}
+
export interface SubmitMeta {
readonly changedFields?: readonly FieldPath[];
readonly diff?: FormDiffResult;
readonly signal?: AbortSignal;
/** Populated by the Security Stage (e.g. CAPTCHA plugin). */
readonly security?: SubmitSecurityMeta;
+ /** Populated after a successful upload-transport submit. */
+ readonly upload?: SubmitUploadMeta;
}
export interface ValidateOptions {
@@ -568,6 +594,23 @@ export interface FormInstance> {
get(path: FieldPath): unknown;
errors(path?: FieldPath): string | undefined | Readonly>;
setValue(path: FieldPath, value: unknown, options?: SetValueOptions): void;
+ /**
+ * Mark a path as browser-owned ephemeral (non-persistent).
+ * File fields are registered automatically from DOM `type="file"` or file-shaped values.
+ * Drafts, autosave, offline queue, and history omit these paths (ADR-FILE-001).
+ */
+ markNonPersistent(path: FieldPath): void;
+ /**
+ * Build `FormData` from current values (files as binary parts).
+ * Applications remain responsible for the network transport (ADR-FILE-001).
+ */
+ toFormData(options?: import("../fields/form-data.js").ToFormDataOptions): FormData;
+ /**
+ * JSON when no files are present; multipart `FormData` when any file selection is non-empty.
+ */
+ payload(
+ options?: import("../fields/form-data.js").ToFormDataOptions,
+ ): import("../fields/form-data.js").FormPayload;
setError(path: FieldPath, message: string): void;
clearErrors(path?: FieldPath): void;
getFieldState(path: FieldPath): FieldState;
@@ -641,7 +684,9 @@ export interface FormInstance> {
* For declarative create-time listeners, prefer `createForm({ subscribe })`.
*/
subscribe(listener: () => void): () => void;
- on(event: FormEvent, listener: () => void): () => void;
+ on(event: FormEvent, listener: (payload?: unknown) => void): () => void;
+ /** Emit a form lifecycle event (plugins may use for `upload:*`). */
+ emit(event: FormEvent, payload?: unknown): void;
destroy(): void;
registerPlugin(plugin: FormPlugin): void;
workflow: {
diff --git a/packages/form-intelligence/src/upload/index.ts b/packages/form-intelligence/src/upload/index.ts
new file mode 100644
index 000000000..58fa2a625
--- /dev/null
+++ b/packages/form-intelligence/src/upload/index.ts
@@ -0,0 +1,16 @@
+/**
+ * Upload transport — opt-in multipart progress/abort (ADR-FILE-002).
+ *
+ * @packageDocumentation
+ */
+
+export { uploadTransport } from "./plugin.js";
+export { xhrMultipartUpload, UploadTransportError } from "./xhr-multipart.js";
+export { getLastUploadProgress } from "./registry.js";
+export type {
+ UploadProgress,
+ UploadResult,
+ UploadTransportContext,
+ UploadTransportFn,
+ UploadTransportOptions,
+} from "./types.js";
diff --git a/packages/form-intelligence/src/upload/plugin.ts b/packages/form-intelligence/src/upload/plugin.ts
new file mode 100644
index 000000000..80172c061
--- /dev/null
+++ b/packages/form-intelligence/src/upload/plugin.ts
@@ -0,0 +1,150 @@
+import { setLastUploadProgress } from "./registry.js";
+import { xhrMultipartUpload } from "./xhr-multipart.js";
+import { SubmitError } from "../errors/index.js";
+import { registerUploadTransport } from "../submission/upload-stage.js";
+
+import type { UploadProgress, UploadResult, UploadTransportOptions } from "./types.js";
+import type { FormInstance, FormPlugin, SubmitMeta } from "../types/index.js";
+
+function normalizeResult(result: UploadResult | void | undefined): UploadResult {
+ if (result && typeof result === "object" && "status" in result) {
+ return result;
+ }
+ return {
+ status: 200,
+ responseText: "",
+ response: null,
+ };
+}
+
+function abortError(): Error {
+ const error = new Error("Upload aborted.");
+ error.name = "AbortError";
+ return error;
+}
+
+/**
+ * Opt-in multipart upload transport with progress and abort (ADR-FILE-002).
+ *
+ * @example
+ * ```ts
+ * import { uploadTransport } from "@jayoncode/form-intelligence/upload";
+ *
+ * createForm({
+ * plugins: [
+ * uploadTransport({
+ * url: "/api/upload",
+ * onProgress: (p) => setPct(p.percent),
+ * }),
+ * ],
+ * });
+ * ```
+ */
+export function uploadTransport = Record>(
+ options: UploadTransportOptions,
+): FormPlugin {
+ if (!options.transport && !options.url && !options.buildRequest) {
+ throw new Error("uploadTransport requires `url`, `buildRequest`, or `transport`.");
+ }
+
+ const whenFilesOnly = options.whenFilesOnly !== false;
+
+ return {
+ name: "upload-transport",
+ setup(form) {
+ const formLike = form as FormInstance>;
+
+ const unregister = registerUploadTransport(
+ formLike,
+ async (values, meta, originalOnSubmit) => {
+ const payload = formLike.payload();
+ const shouldUpload = !whenFilesOnly || payload.kind === "multipart";
+
+ if (!shouldUpload) {
+ if (!originalOnSubmit) {
+ throw new SubmitError("Form does not define an onSubmit handler.");
+ }
+ await originalOnSubmit(values as TValues, meta);
+ return;
+ }
+
+ const formData = payload.kind === "multipart" ? payload.formData : formLike.toFormData();
+ const signal = meta?.signal ?? new AbortController().signal;
+
+ const reportProgress = (progress: UploadProgress): void => {
+ if (signal.aborted) {
+ return;
+ }
+ setLastUploadProgress(formLike, progress);
+ options.onProgress?.(progress);
+ formLike.emit("upload:progress", progress);
+ };
+
+ try {
+ let result: UploadResult;
+
+ if (options.transport) {
+ result = normalizeResult(
+ await options.transport(formData, {
+ values: values as TValues,
+ signal,
+ onProgress: reportProgress,
+ }),
+ );
+ } else {
+ const built = options.buildRequest?.({
+ values: values as TValues,
+ formData,
+ });
+ const url = built?.url ?? options.url;
+ if (!url) {
+ throw new Error(
+ "uploadTransport requires `url` or `buildRequest` returning a url.",
+ );
+ }
+ const method = built?.method ?? options.method;
+ const headers = built?.headers ?? options.headers;
+ result = await xhrMultipartUpload({
+ url,
+ formData,
+ signal,
+ onProgress: reportProgress,
+ ...(method !== undefined ? { method } : {}),
+ ...(headers !== undefined ? { headers } : {}),
+ });
+ }
+
+ if (signal.aborted) {
+ throw abortError();
+ }
+
+ options.onComplete?.(result);
+ formLike.emit("upload:complete", result);
+
+ const submitMeta: SubmitMeta = {
+ ...(meta ?? {}),
+ upload: {
+ status: result.status,
+ responseText: result.responseText,
+ response: result.response,
+ },
+ };
+
+ if (originalOnSubmit) {
+ if (signal.aborted) {
+ throw abortError();
+ }
+ await originalOnSubmit(values as TValues, submitMeta);
+ }
+ } catch (error) {
+ options.onError?.(error);
+ formLike.emit("upload:error", error);
+ throw error;
+ }
+ },
+ );
+
+ return unregister;
+ },
+ };
+}
diff --git a/packages/form-intelligence/src/upload/registry.ts b/packages/form-intelligence/src/upload/registry.ts
new file mode 100644
index 000000000..cc5b2a3e5
--- /dev/null
+++ b/packages/form-intelligence/src/upload/registry.ts
@@ -0,0 +1,19 @@
+import type { UploadProgress, UploadResult, UploadTransportFn } from "./types.js";
+import type { FormInstance } from "../types/index.js";
+
+const lastProgress = new WeakMap>, UploadProgress>();
+
+export function setLastUploadProgress(
+ form: FormInstance>,
+ progress: UploadProgress,
+): void {
+ lastProgress.set(form, progress);
+}
+
+export function getLastUploadProgress(
+ form: FormInstance>,
+): UploadProgress | undefined {
+ return lastProgress.get(form);
+}
+
+export type { UploadTransportFn, UploadResult, UploadProgress };
diff --git a/packages/form-intelligence/src/upload/types.ts b/packages/form-intelligence/src/upload/types.ts
new file mode 100644
index 000000000..9f6a46800
--- /dev/null
+++ b/packages/form-intelligence/src/upload/types.ts
@@ -0,0 +1,50 @@
+export interface UploadProgress {
+ readonly loaded: number;
+ readonly total: number;
+ /** 0–100 when `total` is known; otherwise `null`. */
+ readonly percent: number | null;
+}
+
+export interface UploadResult {
+ readonly status: number;
+ readonly responseText: string;
+ readonly response: unknown;
+}
+
+export interface UploadTransportContext<
+ TValues extends Record = Record,
+> {
+ readonly values: TValues;
+ readonly signal: AbortSignal;
+ readonly onProgress: (progress: UploadProgress) => void;
+}
+
+export type UploadTransportFn = Record> = (
+ formData: FormData,
+ context: UploadTransportContext,
+) => Promise | UploadResult | void;
+
+export interface UploadTransportOptions<
+ TValues extends Record = Record,
+> {
+ /** Target URL for the built-in XHR multipart transport. Required unless `transport` is set. */
+ readonly url?: string;
+ readonly method?: string;
+ readonly headers?: Readonly>;
+ /** Customize URL/method/headers per submit. */
+ readonly buildRequest?: (input: { readonly values: TValues; readonly formData: FormData }) => {
+ readonly url: string;
+ readonly method?: string;
+ readonly headers?: Readonly>;
+ };
+ /** Replace the built-in XHR transport (e.g. presigned PUT). */
+ readonly transport?: UploadTransportFn;
+ /**
+ * When true (default), only intercept submits that include files.
+ * JSON-only submits call `onSubmit` as usual.
+ */
+ readonly whenFilesOnly?: boolean;
+ readonly onProgress?: (progress: UploadProgress) => void;
+ readonly onComplete?: (result: UploadResult) => void;
+ readonly onError?: (error: unknown) => void;
+}
diff --git a/packages/form-intelligence/src/upload/xhr-multipart.ts b/packages/form-intelligence/src/upload/xhr-multipart.ts
new file mode 100644
index 000000000..f72590bcd
--- /dev/null
+++ b/packages/form-intelligence/src/upload/xhr-multipart.ts
@@ -0,0 +1,149 @@
+import type { UploadProgress, UploadResult } from "./types.js";
+
+export class UploadTransportError extends Error {
+ readonly status?: number;
+ readonly responseText?: string;
+
+ constructor(
+ message: string,
+ options: {
+ readonly status?: number;
+ readonly responseText?: string;
+ readonly cause?: unknown;
+ } = {},
+ ) {
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
+ this.name = "UploadTransportError";
+ if (options.status !== undefined) {
+ this.status = options.status;
+ }
+ if (options.responseText !== undefined) {
+ this.responseText = options.responseText;
+ }
+ }
+}
+
+function parseResponseBody(responseText: string): unknown {
+ const trimmed = responseText.trim();
+ if (!trimmed) {
+ return null;
+ }
+ try {
+ return JSON.parse(trimmed) as unknown;
+ } catch {
+ return responseText;
+ }
+}
+
+function abortError(): Error {
+ const error = new Error("Upload aborted.");
+ error.name = "AbortError";
+ return error;
+}
+
+/**
+ * Multipart POST/PUT via XHR so upload progress is available.
+ * Honors `AbortSignal` (wired from `form.cancelSubmit()`).
+ */
+export function xhrMultipartUpload(input: {
+ readonly url: string;
+ readonly method?: string;
+ readonly headers?: Readonly>;
+ readonly formData: FormData;
+ readonly signal?: AbortSignal;
+ readonly onProgress?: (progress: UploadProgress) => void;
+}): Promise {
+ const method = input.method ?? "POST";
+
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ let settled = false;
+
+ const finishReject = (error: Error): void => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ input.signal?.removeEventListener("abort", onAbort);
+ reject(error);
+ };
+
+ const finishResolve = (result: UploadResult): void => {
+ if (settled) {
+ return;
+ }
+ if (input.signal?.aborted) {
+ finishReject(abortError());
+ return;
+ }
+ settled = true;
+ input.signal?.removeEventListener("abort", onAbort);
+ resolve(result);
+ };
+
+ const onAbort = (): void => {
+ xhr.abort();
+ };
+
+ xhr.open(method, input.url, true);
+
+ if (input.headers) {
+ for (const [key, value] of Object.entries(input.headers)) {
+ // Let the browser set multipart boundary.
+ if (key.toLowerCase() === "content-type") {
+ continue;
+ }
+ xhr.setRequestHeader(key, value);
+ }
+ }
+
+ if (input.signal) {
+ if (input.signal.aborted) {
+ finishReject(abortError());
+ return;
+ }
+ input.signal.addEventListener("abort", onAbort, { once: true });
+ }
+
+ xhr.upload.onprogress = (event) => {
+ if (!input.onProgress || input.signal?.aborted || settled) {
+ return;
+ }
+ const total = event.lengthComputable ? event.total : 0;
+ input.onProgress({
+ loaded: event.loaded,
+ total,
+ percent:
+ event.lengthComputable && total > 0 ? Math.round((event.loaded / total) * 100) : null,
+ });
+ };
+
+ xhr.onload = () => {
+ const result: UploadResult = {
+ status: xhr.status,
+ responseText: xhr.responseText,
+ response: parseResponseBody(xhr.responseText),
+ };
+ if (xhr.status >= 200 && xhr.status < 300) {
+ finishResolve(result);
+ return;
+ }
+ finishReject(
+ new UploadTransportError(`Upload failed with status ${String(xhr.status)}.`, {
+ status: xhr.status,
+ responseText: xhr.responseText,
+ }),
+ );
+ };
+
+ xhr.onerror = () => {
+ finishReject(new UploadTransportError("Upload network error."));
+ };
+
+ xhr.onabort = () => {
+ finishReject(abortError());
+ };
+
+ xhr.send(input.formData);
+ });
+}
diff --git a/packages/form-intelligence/src/utils/index.ts b/packages/form-intelligence/src/utils/index.ts
index 8df103910..99fe56791 100644
--- a/packages/form-intelligence/src/utils/index.ts
+++ b/packages/form-intelligence/src/utils/index.ts
@@ -14,11 +14,36 @@ export function cloneValue(value: T): T {
return value;
}
+ // Browser-owned opaque values — keep by reference (ADR-FILE-001).
+ if (typeof File !== "undefined" && value instanceof File) {
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((entry) => cloneValue(entry)) as T;
+ }
+
+ if (isPlainObject(value)) {
+ const output: Record = {};
+ for (const [key, entry] of Object.entries(value)) {
+ output[key] = cloneValue(entry);
+ }
+ return output as T;
+ }
+
if (typeof structuredClone === "function") {
- return structuredClone(value);
+ try {
+ return structuredClone(value);
+ } catch {
+ return value;
+ }
}
- return JSON.parse(JSON.stringify(value)) as T;
+ try {
+ return JSON.parse(JSON.stringify(value)) as T;
+ } catch {
+ return value;
+ }
}
export function parsePath(path: FieldPath): string[] {
diff --git a/packages/form-intelligence/src/validation/index.ts b/packages/form-intelligence/src/validation/index.ts
index a6e39381a..41a1663d8 100644
--- a/packages/form-intelligence/src/validation/index.ts
+++ b/packages/form-intelligence/src/validation/index.ts
@@ -12,6 +12,12 @@ export {
phone,
currency,
password,
+ accept,
+ maxSize,
+ minSize,
+ maxFiles,
+ minFiles,
+ parseByteSize,
custom,
asyncValidator,
isAsyncValidator,
@@ -28,6 +34,7 @@ export type {
DateValidatorOptions,
CurrencyValidatorOptions,
PasswordValidatorOptions,
+ FileSizeInput,
} from "./validators/index.js";
export {
validatePaths,
diff --git a/packages/form-intelligence/src/validation/validator-kind.ts b/packages/form-intelligence/src/validation/validator-kind.ts
index 73ae75dbe..ca0dfa00e 100644
--- a/packages/form-intelligence/src/validation/validator-kind.ts
+++ b/packages/form-intelligence/src/validation/validator-kind.ts
@@ -1,7 +1,18 @@
import type { Validator } from "../types/index.js";
-/** Phase 1 kind registry for HTML / schema / field merge (ADR-VAL-002). */
-export type ValidatorKind = "required" | "email" | "url" | "minLength" | "maxLength" | "regex";
+/** Kind registry for mergeable validators (ADR-VAL-002 + ADR-FILE-001 Phase B). */
+export type ValidatorKind =
+ | "required"
+ | "email"
+ | "url"
+ | "minLength"
+ | "maxLength"
+ | "regex"
+ | "accept"
+ | "maxSize"
+ | "minSize"
+ | "maxFiles"
+ | "minFiles";
export const VALIDATOR_KIND_ORDER: readonly ValidatorKind[] = [
"required",
@@ -10,6 +21,11 @@ export const VALIDATOR_KIND_ORDER: readonly ValidatorKind[] = [
"minLength",
"maxLength",
"regex",
+ "accept",
+ "maxSize",
+ "minSize",
+ "maxFiles",
+ "minFiles",
] as const;
const VALIDATOR_KIND = Symbol.for("@jayoncode/form-intelligence/validatorKind");
diff --git a/packages/form-intelligence/src/validation/validators/file-utils.ts b/packages/form-intelligence/src/validation/validators/file-utils.ts
new file mode 100644
index 000000000..072a8d7ed
--- /dev/null
+++ b/packages/form-intelligence/src/validation/validators/file-utils.ts
@@ -0,0 +1,82 @@
+/**
+ * Parse a byte size as a number or human string (`"5MB"`, `"500kb"`, `"1 GiB"`).
+ */
+export function parseByteSize(input: number | string): number {
+ if (typeof input === "number") {
+ if (!Number.isFinite(input) || input < 0) {
+ throw new RangeError(`Invalid byte size: ${String(input)}`);
+ }
+ return Math.floor(input);
+ }
+
+ const trimmed = input.trim();
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/i.exec(trimmed);
+ if (!match) {
+ throw new RangeError(`Invalid byte size: ${input}`);
+ }
+
+ const amount = Number(match[1]);
+ const unit = (match[2] ?? "b").toLowerCase();
+ const multipliers: Record = {
+ b: 1,
+ kb: 1000,
+ kib: 1024,
+ mb: 1000 ** 2,
+ mib: 1024 ** 2,
+ gb: 1000 ** 3,
+ gib: 1024 ** 3,
+ };
+ const factor = multipliers[unit];
+ if (factor === undefined || !Number.isFinite(amount)) {
+ throw new RangeError(`Invalid byte size: ${input}`);
+ }
+ return Math.floor(amount * factor);
+}
+
+export function asFileList(value: unknown): File[] {
+ if (typeof FileList !== "undefined" && value instanceof FileList) {
+ return Array.from(value);
+ }
+ if (typeof File !== "undefined" && value instanceof File) {
+ return [value];
+ }
+ if (Array.isArray(value)) {
+ if (typeof File === "undefined") {
+ return [];
+ }
+ return value.filter((entry): entry is File => entry instanceof File);
+ }
+ return [];
+}
+
+/** Whether a single file matches an HTML-like `accept` token. */
+export function fileMatchesAcceptToken(file: File, token: string): boolean {
+ const normalized = token.trim().toLowerCase();
+ if (!normalized) {
+ return true;
+ }
+
+ const mime = file.type.toLowerCase();
+ const name = file.name.toLowerCase();
+
+ if (normalized.startsWith(".")) {
+ return name.endsWith(normalized);
+ }
+
+ if (normalized.endsWith("/*")) {
+ const prefix = normalized.slice(0, -1); // keep trailing "/"
+ return mime.startsWith(prefix);
+ }
+
+ return mime === normalized;
+}
+
+export function fileMatchesAccept(file: File, accept: string | readonly string[]): boolean {
+ const tokens = (typeof accept === "string" ? accept.split(",") : [...accept])
+ .map((token) => token.trim())
+ .filter(Boolean);
+ if (tokens.length === 0) {
+ return true;
+ }
+ return tokens.some((token) => fileMatchesAcceptToken(file, token));
+}
diff --git a/packages/form-intelligence/src/validation/validators/file.ts b/packages/form-intelligence/src/validation/validators/file.ts
new file mode 100644
index 000000000..d9d9e65c5
--- /dev/null
+++ b/packages/form-intelligence/src/validation/validators/file.ts
@@ -0,0 +1,81 @@
+import { tagValidator } from "../validator-kind.js";
+import { asFileList, fileMatchesAccept, parseByteSize } from "./file-utils.js";
+
+import type { Validator } from "../../types/index.js";
+
+export type FileSizeInput = number | string;
+
+/**
+ * Require every selected file to match HTML-like `accept` tokens
+ * (extensions `.png`, MIME `image/png`, or wildcards `image/*`).
+ */
+export const accept = (pattern: string | readonly string[]): Validator =>
+ tagValidator((value) => {
+ const files = asFileList(value);
+ if (files.length === 0) {
+ return true;
+ }
+ for (const file of files) {
+ if (!fileMatchesAccept(file, pattern)) {
+ return `File type not allowed (${file.name}).`;
+ }
+ }
+ return true;
+ }, "accept");
+
+/** Maximum size per file (bytes or `"5MB"`). */
+export const maxSize = (limit: FileSizeInput): Validator => {
+ const maxBytes = parseByteSize(limit);
+ return tagValidator((value) => {
+ const files = asFileList(value);
+ for (const file of files) {
+ if (file.size > maxBytes) {
+ return `File is too large (${file.name}).`;
+ }
+ }
+ return true;
+ }, "maxSize");
+};
+
+/** Minimum size per file (bytes or `"1KB"`). Empty selection passes. */
+export const minSize = (limit: FileSizeInput): Validator => {
+ const minBytes = parseByteSize(limit);
+ return tagValidator((value) => {
+ const files = asFileList(value);
+ if (files.length === 0) {
+ return true;
+ }
+ for (const file of files) {
+ if (file.size < minBytes) {
+ return `File is too small (${file.name}).`;
+ }
+ }
+ return true;
+ }, "minSize");
+};
+
+/** Maximum number of selected files. */
+export const maxFiles = (limit: number): Validator =>
+ tagValidator((value) => {
+ if (!Number.isFinite(limit) || limit < 0) {
+ return true;
+ }
+ const files = asFileList(value);
+ if (files.length > limit) {
+ return `Select at most ${String(limit)} file${limit === 1 ? "" : "s"}.`;
+ }
+ return true;
+ }, "maxFiles");
+
+/** Minimum number of selected files. Empty still fails when limit > 0. */
+export const minFiles = (limit: number): Validator =>
+ tagValidator((value) => {
+ if (!Number.isFinite(limit) || limit <= 0) {
+ return true;
+ }
+ const files = asFileList(value);
+ if (files.length < limit) {
+ return `Select at least ${String(limit)} file${limit === 1 ? "" : "s"}.`;
+ }
+ return true;
+ }, "minFiles");
diff --git a/packages/form-intelligence/src/validation/validators/index.ts b/packages/form-intelligence/src/validation/validators/index.ts
index 27e58d7be..e2849b68a 100644
--- a/packages/form-intelligence/src/validation/validators/index.ts
+++ b/packages/form-intelligence/src/validation/validators/index.ts
@@ -9,6 +9,9 @@ export { date } from "./date.js";
export { phone } from "./phone.js";
export { currency } from "./currency.js";
export { password } from "./password.js";
+export { accept, maxSize, minSize, maxFiles, minFiles } from "./file.js";
+export type { FileSizeInput } from "./file.js";
+export { parseByteSize, fileMatchesAccept, asFileList } from "./file-utils.js";
export {
custom,
asyncValidator,
diff --git a/packages/form-intelligence/tests/browser/file-fields-phase-b.browser.test.ts b/packages/form-intelligence/tests/browser/file-fields-phase-b.browser.test.ts
new file mode 100644
index 000000000..fb6b5d5bb
--- /dev/null
+++ b/packages/form-intelligence/tests/browser/file-fields-phase-b.browser.test.ts
@@ -0,0 +1,132 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it } from "vitest";
+
+import {
+ accept,
+ createForm,
+ maxFiles,
+ maxSize,
+ minFiles,
+ minSize,
+ parseByteSize,
+ required,
+} from "../../src/index.js";
+
+describe("ADR-FILE-001 Phase B — file validators and payload", () => {
+ it("parses human byte sizes", () => {
+ expect(parseByteSize(1024)).toBe(1024);
+ expect(parseByteSize("5MB")).toBe(5_000_000);
+ expect(parseByteSize("1 KiB")).toBe(1024);
+ });
+
+ it("validates accept, size, and file count", async () => {
+ const png = new File(["x"], "a.png", { type: "image/png" });
+ const txt = new File(["x"], "a.txt", { type: "text/plain" });
+ const big = new File([new Uint8Array(2_000)], "big.png", { type: "image/png" });
+
+ const form = createForm({
+ initialValues: { avatar: [] as File[] },
+ schema: { avatar: "file" },
+ validators: {
+ avatar: [required, accept("image/*"), maxSize(1000), maxFiles(1)],
+ },
+ onSubmit: () => undefined,
+ });
+
+ expect(await form.validate()).toBe(false);
+
+ form.setValue("avatar", [txt]);
+ expect(await form.validate()).toBe(false);
+ expect(String(form.errors("avatar"))).toMatch(/not allowed/i);
+
+ form.setValue("avatar", [big]);
+ expect(await form.validate()).toBe(false);
+ expect(String(form.errors("avatar"))).toMatch(/too large/i);
+
+ form.setValue("avatar", [png, png]);
+ expect(await form.validate()).toBe(false);
+ expect(String(form.errors("avatar"))).toMatch(/at most/i);
+
+ form.setValue("avatar", [png]);
+ expect(await form.validate()).toBe(true);
+
+ form.destroy();
+ });
+
+ it("supports minSize and minFiles", async () => {
+ const tiny = new File(["x"], "a.bin", { type: "application/octet-stream" });
+ const form = createForm({
+ initialValues: { docs: [] as File[] },
+ schema: { docs: "file" },
+ validators: {
+ docs: [minFiles(2), minSize(10)],
+ },
+ onSubmit: () => undefined,
+ });
+
+ form.setValue("docs", [tiny]);
+ expect(await form.validate()).toBe(false);
+
+ const larger = new File([new Uint8Array(20)], "b.bin", { type: "application/octet-stream" });
+ form.setValue("docs", [larger, larger]);
+ expect(await form.validate()).toBe(true);
+
+ form.destroy();
+ });
+
+ it("builds FormData and payload()", () => {
+ const file = new File(["hello"], "a.txt", { type: "text/plain" });
+ const form = createForm({
+ initialValues: { title: "Hi", avatar: [] as File[] },
+ schema: { title: "text", avatar: "file" },
+ onSubmit: () => undefined,
+ });
+
+ expect(form.payload().kind).toBe("json");
+
+ form.setValue("avatar", [file]);
+ const payload = form.payload();
+ expect(payload.kind).toBe("multipart");
+ if (payload.kind !== "multipart") {
+ throw new Error("expected multipart");
+ }
+ expect(payload.formData.get("title")).toBe("Hi");
+ expect(payload.formData.get("avatar")).toBeInstanceOf(File);
+
+ const fd = form.toFormData();
+ expect(fd.get("avatar")).toBeInstanceOf(File);
+
+ form.destroy();
+ });
+
+ it("returns file-shaped bind() without value", () => {
+ const form = createForm({
+ initialValues: { avatar: [] as File[] },
+ schema: { avatar: "file" },
+ onSubmit: () => undefined,
+ });
+
+ const binding = form.field("avatar").bind();
+ expect(binding.kind).toBe("file");
+ if (binding.kind !== "file") {
+ throw new Error("expected file binding");
+ }
+ expect(binding.files).toEqual([]);
+ expect("value" in binding).toBe(false);
+
+ const file = new File(["x"], "a.txt");
+ binding.onChange([file]);
+ expect(form.get("avatar")).toEqual([file]);
+
+ const titleForm = createForm({
+ initialValues: { title: "" },
+ onSubmit: () => undefined,
+ });
+ const text = titleForm.field("title").bind();
+ expect(text.kind === undefined || text.kind === "value").toBe(true);
+ titleForm.destroy();
+
+ form.destroy();
+ });
+});
diff --git a/packages/form-intelligence/tests/browser/file-fields.browser.test.ts b/packages/form-intelligence/tests/browser/file-fields.browser.test.ts
new file mode 100644
index 000000000..2a9663fbe
--- /dev/null
+++ b/packages/form-intelligence/tests/browser/file-fields.browser.test.ts
@@ -0,0 +1,152 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it, vi } from "vitest";
+
+import { createForm, required } from "../../src/index.js";
+
+function mountUploadForm(): HTMLFormElement {
+ document.body.innerHTML = `
+
+ `;
+ return document.querySelector("#upload") as HTMLFormElement;
+}
+
+describe("ADR-FILE-001 Phase A — file fields", () => {
+ it("reads input.files as canonical File[] and submits them", async () => {
+ const formElement = mountUploadForm();
+ const onSubmit = vi.fn();
+ const file = new File(["hello"], "avatar.png", { type: "image/png" });
+
+ const form = createForm({
+ target: formElement,
+ schema: {
+ title: "text",
+ avatar: "file",
+ },
+ onSubmit,
+ });
+
+ expect(form.get("avatar")).toEqual([]);
+
+ const avatar = formElement.querySelector('input[name="avatar"]') as HTMLInputElement;
+ Object.defineProperty(avatar, "files", {
+ configurable: true,
+ get: () => {
+ const list = {
+ 0: file,
+ length: 1,
+ item: (index: number) => (index === 0 ? file : null),
+ [Symbol.iterator]: function* () {
+ yield file;
+ },
+ };
+ return list as unknown as FileList;
+ },
+ });
+ avatar.dispatchEvent(new Event("change", { bubbles: true }));
+
+ expect(form.get("avatar")).toEqual([file]);
+
+ const title = formElement.querySelector('input[name="title"]') as HTMLInputElement;
+ title.value = "Profile";
+ title.dispatchEvent(new Event("input", { bubbles: true }));
+
+ formElement.requestSubmit();
+
+ await vi.waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled();
+ });
+
+ const [values] = onSubmit.mock.calls[0] as [Record];
+ expect(values.title).toBe("Profile");
+ expect(values.avatar).toEqual([file]);
+
+ form.destroy();
+ });
+
+ it("never restores a file selection from state; clears only", () => {
+ const formElement = mountUploadForm();
+ const file = new File(["x"], "a.txt", { type: "text/plain" });
+ const form = createForm({
+ target: formElement,
+ schema: { avatar: "file" },
+ onSubmit: vi.fn(),
+ });
+
+ const avatar = formElement.querySelector('input[name="avatar"]') as HTMLInputElement;
+ form.setValue("avatar", [file]);
+ // State has files; DOM cannot be restored — value stays "".
+ expect(avatar.value).toBe("");
+
+ form.setValue("avatar", []);
+ expect(avatar.value).toBe("");
+
+ form.destroy();
+ });
+
+ it("treats empty File[] as failing required presence", async () => {
+ const form = createForm({
+ initialValues: { avatar: [] as File[] },
+ validators: { avatar: [required] },
+ onSubmit: vi.fn(),
+ });
+ form.markNonPersistent("avatar");
+
+ const ok = await form.validate();
+ expect(ok).toBe(false);
+ expect(form.errors("avatar")).toBe("This field is required.");
+
+ form.setValue("avatar", [new File(["x"], "a.txt")]);
+ expect(await form.validate()).toBe(true);
+
+ form.destroy();
+ });
+
+ it("omits file fields from drafts", () => {
+ const storageKey = `fi-file-draft-${Math.random().toString(36).slice(2)}`;
+ const file = new File(["x"], "a.txt");
+ const form = createForm({
+ initialValues: { title: "", avatar: [] as File[] },
+ schema: { title: "text", avatar: "file" },
+ workflow: {
+ draft: { enabled: true, storageKey },
+ },
+ onSubmit: vi.fn(),
+ });
+
+ form.setValue("title", "Saved");
+ form.setValue("avatar", [file]);
+ form.saveDraft();
+
+ const raw = window.localStorage.getItem(storageKey);
+ expect(raw).toBeTruthy();
+ expect(raw).not.toContain("a.txt");
+ expect(JSON.parse(raw as string)).not.toHaveProperty("avatar");
+
+ form.destroy();
+ window.localStorage.removeItem(storageKey);
+ });
+
+ it("does not replay file values through undo", () => {
+ const file = new File(["x"], "a.txt");
+ const form = createForm({
+ initialValues: { title: "", avatar: [] as File[] },
+ schema: { title: "text", avatar: "file" },
+ onSubmit: vi.fn(),
+ });
+
+ form.setValue("title", "one");
+ form.setValue("avatar", [file]);
+ form.setValue("title", "two");
+
+ expect(form.undo()).toBe(true);
+ expect(form.get("title")).toBe("one");
+ expect(form.get("avatar")).toEqual([file]);
+
+ form.destroy();
+ });
+});
diff --git a/packages/form-intelligence/tests/unit/upload-transport.test.ts b/packages/form-intelligence/tests/unit/upload-transport.test.ts
new file mode 100644
index 000000000..d04c8ddca
--- /dev/null
+++ b/packages/form-intelligence/tests/unit/upload-transport.test.ts
@@ -0,0 +1,298 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createForm } from "../../src/index.js";
+import {
+ getLastUploadProgress,
+ uploadTransport,
+ UploadTransportError,
+ xhrMultipartUpload,
+} from "../../src/upload/index.js";
+
+function mockXhr(options: {
+ status?: number;
+ responseText?: string;
+ progress?: Array<{ loaded: number; total: number }>;
+ abortOnSend?: boolean;
+}): void {
+ const status = options.status ?? 200;
+ const responseText = options.responseText ?? '{"ok":true}';
+ const progress = options.progress ?? [{ loaded: 50, total: 100 }];
+
+ class FakeXHR {
+ static readonly UNSENT = 0;
+ static readonly OPENED = 1;
+ static readonly HEADERS_RECEIVED = 2;
+ static readonly LOADING = 3;
+ static readonly DONE = 4;
+
+ upload = {
+ onprogress: null as ((event: ProgressEvent) => void) | null,
+ };
+ status = 0;
+ responseText = "";
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onabort: (() => void) | null = null;
+
+ open(): void {}
+ setRequestHeader(): void {}
+ abort(): void {
+ this.onabort?.();
+ }
+ send(): void {
+ if (options.abortOnSend) {
+ queueMicrotask(() => this.onabort?.());
+ return;
+ }
+ queueMicrotask(() => {
+ for (const step of progress) {
+ this.upload.onprogress?.({
+ lengthComputable: true,
+ loaded: step.loaded,
+ total: step.total,
+ } as ProgressEvent);
+ }
+ this.status = status;
+ this.responseText = responseText;
+ this.onload?.();
+ });
+ }
+ }
+
+ vi.stubGlobal("XMLHttpRequest", FakeXHR);
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe("xhrMultipartUpload", () => {
+ it("reports progress and resolves on 2xx", async () => {
+ mockXhr({
+ progress: [
+ { loaded: 25, total: 100 },
+ { loaded: 100, total: 100 },
+ ],
+ });
+ const onProgress = vi.fn();
+
+ const result = await xhrMultipartUpload({
+ url: "/api/upload",
+ formData: new FormData(),
+ onProgress,
+ });
+
+ expect(result.status).toBe(200);
+ expect(result.response).toEqual({ ok: true });
+ expect(onProgress).toHaveBeenCalledWith({ loaded: 25, total: 100, percent: 25 });
+ expect(onProgress).toHaveBeenCalledWith({ loaded: 100, total: 100, percent: 100 });
+ });
+
+ it("rejects with UploadTransportError on non-2xx", async () => {
+ mockXhr({ status: 500, responseText: "nope" });
+
+ await expect(
+ xhrMultipartUpload({ url: "/api/upload", formData: new FormData() }),
+ ).rejects.toBeInstanceOf(UploadTransportError);
+ });
+
+ it("aborts when signal aborts", async () => {
+ mockXhr({});
+ const controller = new AbortController();
+ const promise = xhrMultipartUpload({
+ url: "/api/upload",
+ formData: new FormData(),
+ signal: controller.signal,
+ });
+ controller.abort();
+
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
+ });
+});
+
+describe("uploadTransport plugin", () => {
+ it("uploads multipart when files are present and passes meta.upload to onSubmit", async () => {
+ mockXhr({ responseText: '{"id":"1"}' });
+ const file = new File(["hello"], "hello.txt", { type: "text/plain" });
+ const onProgress = vi.fn();
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const progressEvents: unknown[] = [];
+
+ const form = createForm({
+ initialValues: { note: "hi", avatar: [file] },
+ plugins: [
+ uploadTransport({
+ url: "/api/upload",
+ onProgress,
+ onComplete,
+ }),
+ ],
+ onSubmit,
+ });
+ form.on("upload:progress", (payload) => {
+ progressEvents.push(payload);
+ });
+
+ await expect(form.submit()).resolves.toBe(true);
+
+ expect(onProgress).toHaveBeenCalled();
+ expect(onComplete).toHaveBeenCalledWith(
+ expect.objectContaining({ status: 200, response: { id: "1" } }),
+ );
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ note: "hi" }),
+ expect.objectContaining({
+ upload: expect.objectContaining({ status: 200, response: { id: "1" } }),
+ }),
+ );
+ expect(progressEvents.length).toBeGreaterThan(0);
+ expect(getLastUploadProgress(form)?.percent).toBe(50);
+ form.destroy();
+ });
+
+ it("uses onSubmit only when whenFilesOnly and no files", async () => {
+ const transport = vi.fn();
+ const onSubmit = vi.fn();
+
+ const form = createForm({
+ initialValues: { note: "hi", avatar: [] as File[] },
+ plugins: [uploadTransport({ url: "/api/upload", transport })],
+ onSubmit,
+ });
+
+ await expect(form.submit()).resolves.toBe(true);
+ expect(transport).not.toHaveBeenCalled();
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ form.destroy();
+ });
+
+ it("supports custom transport without url", async () => {
+ const file = new File(["x"], "x.bin");
+ const onSubmit = vi.fn();
+
+ const form = createForm({
+ initialValues: { file: [file] },
+ plugins: [
+ uploadTransport({
+ transport: async (formData, ctx) => {
+ expect(formData).toBeInstanceOf(FormData);
+ ctx.onProgress({ loaded: 1, total: 1, percent: 100 });
+ return { status: 201, responseText: "", response: { created: true } };
+ },
+ }),
+ ],
+ onSubmit,
+ });
+
+ await expect(form.submit()).resolves.toBe(true);
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ upload: expect.objectContaining({ status: 201 }),
+ }),
+ );
+ form.destroy();
+ });
+
+ it("cancels in-flight upload via form.cancelSubmit()", async () => {
+ let resolveSent!: () => void;
+ const sent = new Promise((resolve) => {
+ resolveSent = resolve;
+ });
+
+ class SlowXHR {
+ upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
+ status = 0;
+ responseText = "";
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onabort: (() => void) | null = null;
+ open(): void {}
+ setRequestHeader(): void {}
+ abort(): void {
+ this.onabort?.();
+ }
+ send(): void {
+ resolveSent();
+ }
+ }
+ vi.stubGlobal("XMLHttpRequest", SlowXHR);
+
+ const file = new File(["x"], "x.bin");
+ const onError = vi.fn();
+ const form = createForm({
+ initialValues: { file: [file] },
+ plugins: [uploadTransport({ url: "/api/upload", onError })],
+ });
+
+ const submitPromise = form.submit();
+ await sent;
+ form.cancelSubmit();
+
+ await expect(submitPromise).resolves.toBe(false);
+ expect(onError).toHaveBeenCalled();
+ form.destroy();
+ });
+
+ it("ignores successful onload after cancelSubmit (abort race)", async () => {
+ let sent = false;
+ let triggerLateOnload: (() => void) | null = null;
+
+ class RaceXHR {
+ upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
+ status = 0;
+ responseText = "";
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onabort: (() => void) | null = null;
+ open(): void {}
+ setRequestHeader(): void {}
+ abort(): void {
+ // Simulate late onload winning the race before abort settles.
+ this.status = 200;
+ this.responseText = '{"ok":true}';
+ this.onload?.();
+ this.onabort?.();
+ }
+ send(): void {
+ sent = true;
+ triggerLateOnload = () => {
+ this.status = 200;
+ this.responseText = '{"ok":true}';
+ this.onload?.();
+ };
+ }
+ }
+ vi.stubGlobal("XMLHttpRequest", RaceXHR);
+
+ const file = new File(["x"], "x.bin");
+ const onComplete = vi.fn();
+ const onSubmit = vi.fn();
+ const form = createForm({
+ initialValues: { file: [file] },
+ plugins: [uploadTransport({ url: "/api/upload", onComplete })],
+ onSubmit,
+ });
+
+ const submitPromise = form.submit();
+ await vi.waitFor(() => {
+ expect(sent).toBe(true);
+ });
+ // Keep triggerLateOnload referenced so the mock shape stays intentional.
+ void triggerLateOnload;
+ form.cancelSubmit();
+
+ await expect(submitPromise).resolves.toBe(false);
+ expect(onComplete).not.toHaveBeenCalled();
+ expect(onSubmit).not.toHaveBeenCalled();
+ form.destroy();
+ });
+
+ it("throws when configured without url/transport/buildRequest", () => {
+ expect(() => uploadTransport({} as never)).toThrow(/url/);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 257ea8081..eec3ebac2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,8 +74,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
- specifier: ^6.30.1
- version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^7.18.1
+ version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.12
@@ -129,8 +129,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
- specifier: ^6.30.1
- version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^7.18.1
+ version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies:
'@testing-library/jest-dom':
specifier: ^6.9.1
@@ -175,8 +175,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
- specifier: ^6.30.1
- version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^7.18.1
+ version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.12
@@ -240,8 +240,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
- specifier: ^6.30.1
- version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ specifier: ^7.18.1
+ version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.12
@@ -1064,10 +1064,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@remix-run/router@1.23.3':
- resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
- engines: {node: '>=14.0.0'}
-
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -1804,6 +1800,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
@@ -2913,18 +2913,22 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
- react-router-dom@6.30.4:
- resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
- engines: {node: '>=14.0.0'}
+ react-router-dom@7.18.1:
+ resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
+ engines: {node: '>=20.0.0'}
peerDependencies:
- react: '>=16.8'
- react-dom: '>=16.8'
+ react: '>=18'
+ react-dom: '>=18'
- react-router@6.30.4:
- resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
- engines: {node: '>=14.0.0'}
+ react-router@7.18.1:
+ resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==}
+ engines: {node: '>=20.0.0'}
peerDependencies:
- react: '>=16.8'
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
@@ -3037,6 +3041,9 @@ packages:
resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==}
engines: {node: '>=10'}
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -4264,8 +4271,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@remix-run/router@1.23.3': {}
-
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.62.2':
@@ -5053,6 +5058,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie@1.1.1: {}
+
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
@@ -6262,17 +6269,19 @@ snapshots:
react-refresh@0.17.0: {}
- react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ react-router-dom@7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
- '@remix-run/router': 1.23.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- react-router: 6.30.4(react@18.3.1)
+ react-router: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react-router@6.30.4(react@18.3.1):
+ react-router@7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
- '@remix-run/router': 1.23.3
+ cookie: 1.1.1
react: 18.3.1
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
@@ -6421,6 +6430,8 @@ snapshots:
seroval@1.5.5: {}
+ set-cookie-parser@2.7.2: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4