@zerolith/forms 0.0.0-canary.20261102.g8292c77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +32 -0
- package/dist/form.d.ts +91 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +246 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/problem.d.ts +11 -0
- package/dist/problem.d.ts.map +1 -0
- package/dist/problem.js +17 -0
- package/dist/problem.js.map +1 -0
- package/dist/schema.d.ts +62 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +55 -0
- package/dist/schema.js.map +1 -0
- package/package.json +50 -0
- package/src/form.ts +397 -0
- package/src/index.ts +11 -0
- package/src/problem.ts +18 -0
- package/src/schema.ts +122 -0
package/dist/problem.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server side of the problem document: writing a `Problem` as a
|
|
3
|
+
* WinterTC `Response` with the problem details media type.
|
|
4
|
+
*/
|
|
5
|
+
import { PROBLEM_MEDIA_TYPE } from "zerolith";
|
|
6
|
+
/**
|
|
7
|
+
* Writes a problem as a `Response` with the problem details media type.
|
|
8
|
+
* The status comes from `init`, or the problem, or defaults to 400.
|
|
9
|
+
*/
|
|
10
|
+
export function problemResponse(problem, init = {}) {
|
|
11
|
+
const status = init.status ?? problem.status ?? 400;
|
|
12
|
+
const headers = new Headers(init.headers);
|
|
13
|
+
headers.set("content-type", `${PROBLEM_MEDIA_TYPE}; charset=utf-8`);
|
|
14
|
+
const body = { ...problem, status };
|
|
15
|
+
return new Response(JSON.stringify(body), { ...init, status, headers });
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=problem.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"problem.js","sourceRoot":"","sources":["../src/problem.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,kBAAkB,EAAgB,MAAM,UAAU,CAAC;AAE5D;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,OAAgB,EAAE,IAAI,GAAiB,EAAE;IAErE,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,OAAO,CAAC,MAAM,IAAI,GAAG,CAAC;IACpD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC1C,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,GAAG,kBAAkB,iBAAiB,CAAC,CAAC;IACpE,MAAM,IAAI,GAAY,EAAE,GAAG,OAAO,EAAE,MAAM,EAAE,CAAC;IAC7C,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;AAC5E,CAAC"}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation through Standard Schema, the interface that zod, valibot,
|
|
3
|
+
* arktype, and others implement, so a schema written once validates a
|
|
4
|
+
* submission in the browser before it is sent and on the server when it
|
|
5
|
+
* arrives, producing the same problem document in both places.
|
|
6
|
+
*/
|
|
7
|
+
import type { Problem } from "zerolith";
|
|
8
|
+
/** One segment of an issue path in object form. */
|
|
9
|
+
export interface StandardPathSegment {
|
|
10
|
+
/** The key representing the path segment; `problemFromIssues` converts it to a string and joins the path's keys with dots to name the field. */
|
|
11
|
+
readonly key: PropertyKey;
|
|
12
|
+
}
|
|
13
|
+
/** One validation failure. */
|
|
14
|
+
export interface StandardIssue {
|
|
15
|
+
/** The error message of the issue; `problemFromIssues` lists it under the field its path names, or in `detail` when the path is absent or empty. */
|
|
16
|
+
readonly message: string;
|
|
17
|
+
/** The path to the failing value; absent for an issue about the whole input. */
|
|
18
|
+
readonly path?: ReadonlyArray<PropertyKey | StandardPathSegment> | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** What `validate` resolves to: a value, or issues. */
|
|
21
|
+
export type StandardResult<Output> = {
|
|
22
|
+
readonly value: Output;
|
|
23
|
+
readonly issues?: undefined;
|
|
24
|
+
} | {
|
|
25
|
+
readonly issues: ReadonlyArray<StandardIssue>;
|
|
26
|
+
};
|
|
27
|
+
/** The Standard Schema V1 interface, as published at standardschema.dev. */
|
|
28
|
+
export interface StandardSchema<Input = unknown, Output = Input> {
|
|
29
|
+
/** The Standard properties: the `version` of the specification, the `vendor` name of the schema library, the optional inferred `types`, and the `validate` function that `validateForm` calls and awaits. */
|
|
30
|
+
readonly "~standard": {
|
|
31
|
+
readonly version: 1;
|
|
32
|
+
readonly vendor: string;
|
|
33
|
+
readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
|
|
34
|
+
readonly types?: {
|
|
35
|
+
readonly input: Input;
|
|
36
|
+
readonly output: Output;
|
|
37
|
+
} | undefined;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The output type of a schema. */
|
|
41
|
+
export type InferOutput<S extends StandardSchema> = S extends StandardSchema<unknown, infer Output> ? Output : never;
|
|
42
|
+
/** A form's values as a plain object: repeated names become arrays. */
|
|
43
|
+
export type FormValues = Record<string, FormDataEntryValue | FormDataEntryValue[]>;
|
|
44
|
+
/** What `validateForm` resolves to: the parsed value, or the problem to show. */
|
|
45
|
+
export type Validation<Output> = {
|
|
46
|
+
readonly value: Output;
|
|
47
|
+
readonly problem?: undefined;
|
|
48
|
+
} | {
|
|
49
|
+
readonly value?: undefined;
|
|
50
|
+
readonly problem: Problem;
|
|
51
|
+
};
|
|
52
|
+
/** Turns `FormData` into a plain object with no prototype, so a field named `constructor` or `__proto__` is a field like any other. A name that appears once maps to its value; a name that appears more than once maps to an array of its values, in order. */
|
|
53
|
+
export declare function formValues(data: FormData): FormValues;
|
|
54
|
+
/** Groups issues into a problem: path-bearing issues under `fields`, the rest in `detail`. */
|
|
55
|
+
export declare function problemFromIssues(issues: ReadonlyArray<StandardIssue>): Problem;
|
|
56
|
+
/**
|
|
57
|
+
* Validates a submission with a Standard Schema. `FormData` is converted
|
|
58
|
+
* with `formValues` first; any other value is validated as it is. Issues
|
|
59
|
+
* become a problem whose `fields` carry the messages per path.
|
|
60
|
+
*/
|
|
61
|
+
export declare function validateForm<S extends StandardSchema>(schema: S, data: FormData | Record<string, unknown>): Promise<Validation<InferOutput<S>>>;
|
|
62
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAe,OAAO,EAAE,MAAM,UAAU,CAAC;AAErD,mDAAmD;AACnD,MAAM,WAAW,mBAAmB;IAEhC,gJAAgJ;IAChJ,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAC;CAC7B;AAED,8BAA8B;AAC9B,MAAM,WAAW,aAAa;IAE1B,oJAAoJ;IACpJ,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,gFAAgF;IAChF,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,WAAW,GAAG,mBAAmB,CAAC,GAAG,SAAS,CAAC;CAChF;AAED,uDAAuD;AACvD,MAAM,MAAM,cAAc,CAAC,MAAM,IAC3B;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,aAAa,CAAC,CAAA;CAAE,CAAC;AAExD,4EAA4E;AAC5E,MAAM,WAAW,cAAc,CAAC,KAAK,GAAG,OAAO,EAAE,MAAM,GAAG,KAAK;IAE3D,6MAA6M;IAC7M,QAAQ,CAAC,WAAW,EAAE;QAClB,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC;QAChG,QAAQ,CAAC,KAAK,CAAC,EAAE;YAAE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;YAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAC;KACnF,CAAC;CACL;AAED,mCAAmC;AACnC,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,cAAc,IAAI,CAAC,SAAS,cAAc,CAAC,OAAO,EAAE,MAAM,MAAM,CAAC,GAAG,MAAM,GAAG,KAAK,CAAC;AAErH,uEAAuE;AACvE,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,GAAG,kBAAkB,EAAE,CAAC,CAAC;AAEnF,iFAAiF;AACjF,MAAM,MAAM,UAAU,CAAC,MAAM,IACvB;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GACxD;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAEhE,gQAAgQ;AAChQ,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,UAAU,CAoBrD;AAWD,8FAA8F;AAC9F,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,CAAC,aAAa,CAAC,GAAG,OAAO,CAoB/E;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,CAAC,SAAS,cAAc,EAAE,MAAM,EAAE,CAAC,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CASrJ"}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Turns `FormData` into a plain object with no prototype, so a field named `constructor` or `__proto__` is a field like any other. A name that appears once maps to its value; a name that appears more than once maps to an array of its values, in order. */
|
|
2
|
+
export function formValues(data) {
|
|
3
|
+
const values = Object.create(null);
|
|
4
|
+
for (const [name, value] of data) {
|
|
5
|
+
const current = values[name];
|
|
6
|
+
if (current === undefined) {
|
|
7
|
+
values[name] = value;
|
|
8
|
+
}
|
|
9
|
+
else if (Array.isArray(current)) {
|
|
10
|
+
current.push(value);
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
values[name] = [current, value];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return values;
|
|
17
|
+
}
|
|
18
|
+
function fieldName(path) {
|
|
19
|
+
if (path === undefined || path.length === 0) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
23
|
+
}
|
|
24
|
+
/** Groups issues into a problem: path-bearing issues under `fields`, the rest in `detail`. */
|
|
25
|
+
export function problemFromIssues(issues) {
|
|
26
|
+
const fields = Object.create(null);
|
|
27
|
+
const general = [];
|
|
28
|
+
for (const issue of issues) {
|
|
29
|
+
const name = fieldName(issue.path);
|
|
30
|
+
if (name === undefined) {
|
|
31
|
+
general.push(issue.message);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
(fields[name] ??= []).push(issue.message);
|
|
35
|
+
}
|
|
36
|
+
const problem = { type: "about:blank", title: "The submission has invalid fields", status: 400, fields: fields };
|
|
37
|
+
if (general.length > 0) {
|
|
38
|
+
problem.detail = general.join(" ");
|
|
39
|
+
}
|
|
40
|
+
return problem;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validates a submission with a Standard Schema. `FormData` is converted
|
|
44
|
+
* with `formValues` first; any other value is validated as it is. Issues
|
|
45
|
+
* become a problem whose `fields` carry the messages per path.
|
|
46
|
+
*/
|
|
47
|
+
export async function validateForm(schema, data) {
|
|
48
|
+
const input = data instanceof FormData ? formValues(data) : data;
|
|
49
|
+
const result = await schema["~standard"].validate(input);
|
|
50
|
+
if (result.issues !== undefined && result.issues.length > 0) {
|
|
51
|
+
return { problem: problemFromIssues(result.issues) };
|
|
52
|
+
}
|
|
53
|
+
return { value: result.value };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAoDA,gQAAgQ;AAChQ,MAAM,UAAU,UAAU,CAAC,IAAc;IAErC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAe,CAAC;IACjD,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,EAChC,CAAC;QACG,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,OAAO,KAAK,SAAS,EACzB,CAAC;YACG,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;QACzB,CAAC;aACI,IAAI,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAC/B,CAAC;YACG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACxB,CAAC;aAED,CAAC;YACG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACpC,CAAC;IACL,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,SAAS,SAAS,CAAC,IAAkE;IAEjF,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAC3C,CAAC;QACG,OAAO,SAAS,CAAC;IACrB,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACxG,CAAC;AAED,8FAA8F;AAC9F,MAAM,UAAU,iBAAiB,CAAC,MAAoC;IAElE,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAA6B,CAAC;IAC/D,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,KAAK,IAAI,MAAM,EAC1B,CAAC;QACG,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,IAAI,KAAK,SAAS,EACtB,CAAC;YACG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAC5B,SAAS;QACb,CAAC;QACD,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC9C,CAAC;IACD,MAAM,OAAO,GAAY,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,mCAAmC,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,MAAqB,EAAE,CAAC;IACzI,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EACtB,CAAC;QACG,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACvC,CAAC;IACD,OAAO,OAAO,CAAC;AACnB,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAA2B,MAAS,EAAE,IAAwC;IAE5G,MAAM,KAAK,GAAG,IAAI,YAAY,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACjE,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IACzD,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAC3D,CAAC;QACG,OAAO,EAAE,OAAO,EAAE,iBAAiB,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;IACzD,CAAC;IACD,OAAO,EAAE,KAAK,EAAG,MAAoC,CAAC,KAAK,EAAE,CAAC;AAClE,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerolith/forms",
|
|
3
|
+
"version": "0.0.0-canary.20261102.g8292c77",
|
|
4
|
+
"description": "Forms for zerolith: native forms that post FormData and work without JavaScript, enhanced with fetch, pending state, and RFC 9457 problem+json field errors when it runs, with Standard Schema validation shared by client and server",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "molexxxx",
|
|
7
|
+
"homepage": "https://zerolith.dev/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/molexxxx/zerolith.git",
|
|
11
|
+
"directory": "packages/forms"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/molexxxx/zerolith/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"zerolith",
|
|
18
|
+
"forms",
|
|
19
|
+
"progressive-enhancement",
|
|
20
|
+
"problem-details",
|
|
21
|
+
"standard-schema"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=24.0.0"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"zerolith": "^0.0.0-canary.20261102.g8292c77"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"zerolith": "^0.0.0-canary.20261102.g8292c77"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The enhancer. A form stays a native `<form>` that posts to its action and
|
|
3
|
+
* works without JavaScript; `createForm` takes over its submit event when
|
|
4
|
+
* JavaScript runs, sends the same request with `fetch`, and exposes the
|
|
5
|
+
* outcome as signals: pending state, a parsed result, or a problem whose
|
|
6
|
+
* `fields` feed the inputs that failed.
|
|
7
|
+
*/
|
|
8
|
+
import { PROBLEM_MEDIA_TYPE, type Problem, type ReadableSignal, batch, computed, isProblem, signal } from "zerolith";
|
|
9
|
+
import { type StandardSchema, validateForm } from "./schema.js";
|
|
10
|
+
|
|
11
|
+
/** Where a form is in its current submission. */
|
|
12
|
+
export type FormStatus = "idle" | "pending" | "success" | "failure" | "error";
|
|
13
|
+
|
|
14
|
+
/** What the default sender, or a custom `send`, receives. */
|
|
15
|
+
export interface Submission
|
|
16
|
+
{
|
|
17
|
+
/** The form that was submitted. */
|
|
18
|
+
readonly element: HTMLFormElement;
|
|
19
|
+
/** The button or input that submitted it, when there was one. */
|
|
20
|
+
readonly submitter: HTMLElement | null;
|
|
21
|
+
/** The form's data, including the submitter's name and value. */
|
|
22
|
+
readonly data: FormData;
|
|
23
|
+
/** The lowercase method, after the submitter's `formmethod` override. */
|
|
24
|
+
readonly method: string;
|
|
25
|
+
/** The resolved action URL, after the submitter's `formaction` override. */
|
|
26
|
+
readonly action: URL;
|
|
27
|
+
/** Aborted when the form is submitted again or reset. */
|
|
28
|
+
readonly signal: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The outcome of one submission. */
|
|
32
|
+
export interface SubmissionOutcome
|
|
33
|
+
{
|
|
34
|
+
/** The parsed body: JSON when the response says so, text otherwise, undefined for an empty body. */
|
|
35
|
+
readonly result: unknown;
|
|
36
|
+
/** The successful response, as `fetch` or a custom `send` returned it; its body has already been read into `result` unless it was redirected. */
|
|
37
|
+
readonly response: Response;
|
|
38
|
+
/** The form that was submitted, already reset when the `reset` option is set. */
|
|
39
|
+
readonly element: HTMLFormElement;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Options for `createForm`. */
|
|
43
|
+
export interface FormOptions
|
|
44
|
+
{
|
|
45
|
+
/** Validated on the client before anything is sent; failing issues become the problem's `fields`. */
|
|
46
|
+
schema?: StandardSchema;
|
|
47
|
+
/** Sends the submission. Defaults to a `fetch` of the form's method, action, and encoding that asks for JSON. */
|
|
48
|
+
send?: (submission: Submission) => Promise<Response>;
|
|
49
|
+
/** Headers added to the default request. */
|
|
50
|
+
headers?: HeadersInit;
|
|
51
|
+
/** The `fetch` the default sender uses; defaults to the global one. */
|
|
52
|
+
fetch?: typeof fetch;
|
|
53
|
+
/** Where a redirected response takes the page. Defaults to `location.assign`; pass the router's `navigate` to stay in the application. */
|
|
54
|
+
navigate?: (url: string) => void;
|
|
55
|
+
/** Resets the form element after a successful response. */
|
|
56
|
+
reset?: boolean;
|
|
57
|
+
/** Called after a successful response. */
|
|
58
|
+
onSuccess?: (outcome: SubmissionOutcome) => void;
|
|
59
|
+
/** Called with the problem from client-side validation or from the server. */
|
|
60
|
+
onFailure?: (problem: Problem, element: HTMLFormElement) => void;
|
|
61
|
+
/** Called when the request itself fails. */
|
|
62
|
+
onError?: (error: unknown, element: HTMLFormElement) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One input's slice of the current problem. */
|
|
66
|
+
export interface Field
|
|
67
|
+
{
|
|
68
|
+
/** Every message for the field, oldest first. */
|
|
69
|
+
readonly errors: ReadableSignal<readonly string[]>;
|
|
70
|
+
/** The first message, or undefined. */
|
|
71
|
+
readonly error: ReadableSignal<string | undefined>;
|
|
72
|
+
/** True while the field has a message; bind it to `aria-invalid`. */
|
|
73
|
+
readonly invalid: ReadableSignal<boolean>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The enhanced form. */
|
|
77
|
+
export interface Form
|
|
78
|
+
{
|
|
79
|
+
/** The current submission's stage: `pending` while in flight, then `success`, `failure` with a problem, or `error` when it did not complete; `idle` before the first submission and after `reset()`. */
|
|
80
|
+
readonly status: ReadableSignal<FormStatus>;
|
|
81
|
+
/** True while a submission is in flight. */
|
|
82
|
+
readonly pending: ReadableSignal<boolean>;
|
|
83
|
+
/** The problem from the last failed submission, or undefined. */
|
|
84
|
+
readonly problem: ReadableSignal<Problem | undefined>;
|
|
85
|
+
/** The parsed body of the last successful submission. */
|
|
86
|
+
readonly result: ReadableSignal<unknown>;
|
|
87
|
+
/** The error of the last submission that did not complete. */
|
|
88
|
+
readonly error: ReadableSignal<unknown>;
|
|
89
|
+
/** A field's view of the problem; the same object for the same name. */
|
|
90
|
+
field(name: string): Field;
|
|
91
|
+
/** The submit handler; bind it with `@submit`. */
|
|
92
|
+
submit(event: SubmitEvent): void;
|
|
93
|
+
/** Aborts any submission in flight and returns the form to idle. */
|
|
94
|
+
reset(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const EMPTY: readonly string[] = [];
|
|
98
|
+
|
|
99
|
+
/** Calls an application callback, reporting what it throws as an uncaught error would be, without changing the form's state. */
|
|
100
|
+
function guarded(callback: () => void): void
|
|
101
|
+
{
|
|
102
|
+
try
|
|
103
|
+
{
|
|
104
|
+
callback();
|
|
105
|
+
}
|
|
106
|
+
catch (thrown)
|
|
107
|
+
{
|
|
108
|
+
if (typeof reportError === "function")
|
|
109
|
+
{
|
|
110
|
+
reportError(thrown);
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
{
|
|
114
|
+
queueMicrotask(() =>
|
|
115
|
+
{
|
|
116
|
+
throw thrown;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The `FormData` implementation probed, and whether its constructor adds the submitter's entry, which browsers before Chrome 112 and Firefox 111 skip. */
|
|
123
|
+
let probed: { implementation: typeof FormData; keepsSubmitter: boolean } | undefined;
|
|
124
|
+
|
|
125
|
+
/** The form's data as the browser submits it, with the submitter's entry added where `new FormData(form, submitter)` leaves it out. */
|
|
126
|
+
function submittedData(element: HTMLFormElement, submitter: HTMLElement | null): FormData
|
|
127
|
+
{
|
|
128
|
+
const data = new FormData(element, submitter ?? undefined);
|
|
129
|
+
if (submitter === null)
|
|
130
|
+
{
|
|
131
|
+
return data;
|
|
132
|
+
}
|
|
133
|
+
if (probed?.implementation !== FormData)
|
|
134
|
+
{
|
|
135
|
+
const form = document.createElement("form");
|
|
136
|
+
const button = document.createElement("button");
|
|
137
|
+
button.name = "probe";
|
|
138
|
+
button.value = "kept";
|
|
139
|
+
form.append(button);
|
|
140
|
+
probed = { implementation: FormData, keepsSubmitter: new FormData(form, button).get("probe") === "kept" };
|
|
141
|
+
}
|
|
142
|
+
const name = submitter.getAttribute("name");
|
|
143
|
+
if (probed.keepsSubmitter || name === null || name === "")
|
|
144
|
+
{
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
if (submitter instanceof HTMLInputElement && submitter.type === "image")
|
|
148
|
+
{
|
|
149
|
+
data.append(`${name}.x`, "0");
|
|
150
|
+
data.append(`${name}.y`, "0");
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
{
|
|
154
|
+
data.append(name, (submitter as HTMLButtonElement | HTMLInputElement).value);
|
|
155
|
+
}
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The problem a failed response describes: its body when that is a problem
|
|
161
|
+
* document, or one built from the status. The server is a trust boundary,
|
|
162
|
+
* so `fields` keeps only own keys whose value is a message or a list of
|
|
163
|
+
* messages, a lone message becoming a list of one.
|
|
164
|
+
*/
|
|
165
|
+
function problemOf(body: unknown, response: Response): Problem
|
|
166
|
+
{
|
|
167
|
+
if (!isProblem(body))
|
|
168
|
+
{
|
|
169
|
+
return { status: response.status, title: response.statusText === "" ? `HTTP ${response.status}` : response.statusText };
|
|
170
|
+
}
|
|
171
|
+
if (body.fields === undefined)
|
|
172
|
+
{
|
|
173
|
+
return body;
|
|
174
|
+
}
|
|
175
|
+
const fields: Record<string, readonly string[]> = Object.create(null) as Record<string, readonly string[]>;
|
|
176
|
+
for (const [name, value] of Object.entries(body.fields as Record<string, unknown>))
|
|
177
|
+
{
|
|
178
|
+
if (typeof value === "string")
|
|
179
|
+
{
|
|
180
|
+
fields[name] = [value];
|
|
181
|
+
}
|
|
182
|
+
else if (Array.isArray(value))
|
|
183
|
+
{
|
|
184
|
+
const messages = value.filter((message): message is string => typeof message === "string");
|
|
185
|
+
if (messages.length > 0)
|
|
186
|
+
{
|
|
187
|
+
fields[name] = messages;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { ...body, fields };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function entries(data: FormData): [string, string][]
|
|
195
|
+
{
|
|
196
|
+
return [...data].map(([name, value]) => [name, typeof value === "string" ? value : value.name]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function defaultSend(submission: Submission, headers: HeadersInit | undefined, fetchImpl: typeof fetch): Promise<Response>
|
|
200
|
+
{
|
|
201
|
+
const { element, submitter, data, method, action, signal: abort } = submission;
|
|
202
|
+
const requestHeaders = new Headers(headers);
|
|
203
|
+
if (!requestHeaders.has("accept"))
|
|
204
|
+
{
|
|
205
|
+
requestHeaders.set("accept", `application/json, ${PROBLEM_MEDIA_TYPE}`);
|
|
206
|
+
}
|
|
207
|
+
const init: RequestInit = { method: method.toUpperCase(), headers: requestHeaders, signal: abort, credentials: "same-origin" };
|
|
208
|
+
if (method === "get")
|
|
209
|
+
{
|
|
210
|
+
const url = new URL(action);
|
|
211
|
+
url.search = new URLSearchParams(entries(data)).toString();
|
|
212
|
+
return fetchImpl(url, init);
|
|
213
|
+
}
|
|
214
|
+
const enctype = submitter !== null && submitter.hasAttribute("formenctype") ? (submitter as HTMLButtonElement).formEnctype : element.enctype;
|
|
215
|
+
if (enctype === "multipart/form-data")
|
|
216
|
+
{
|
|
217
|
+
init.body = data;
|
|
218
|
+
}
|
|
219
|
+
else if (enctype === "text/plain")
|
|
220
|
+
{
|
|
221
|
+
requestHeaders.set("content-type", "text/plain; charset=utf-8");
|
|
222
|
+
init.body = entries(data).map(([name, value]) => `${name}=${value}\r\n`).join("");
|
|
223
|
+
}
|
|
224
|
+
else
|
|
225
|
+
{
|
|
226
|
+
init.body = new URLSearchParams(entries(data));
|
|
227
|
+
}
|
|
228
|
+
return fetchImpl(action, init);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function parseBody(response: Response): Promise<unknown>
|
|
232
|
+
{
|
|
233
|
+
if (response.status === 204 || response.status === 205)
|
|
234
|
+
{
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
const type = response.headers.get("content-type") ?? "";
|
|
238
|
+
if (type.includes("json"))
|
|
239
|
+
{
|
|
240
|
+
return response.json();
|
|
241
|
+
}
|
|
242
|
+
const text = await response.text();
|
|
243
|
+
return text === "" ? undefined : text;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Creates the enhancer for one form. Bind `submit` to the form's `@submit`;
|
|
248
|
+
* the method, action, and encoding stay on the form element, so the same
|
|
249
|
+
* markup submits natively when the script has not run.
|
|
250
|
+
*/
|
|
251
|
+
export function createForm(options: FormOptions = {}): Form
|
|
252
|
+
{
|
|
253
|
+
const status = signal<FormStatus>("idle");
|
|
254
|
+
const problem = signal<Problem | undefined>(undefined);
|
|
255
|
+
const result = signal<unknown>(undefined);
|
|
256
|
+
const error = signal<unknown>(undefined);
|
|
257
|
+
const pending = computed(() => status.get() === "pending");
|
|
258
|
+
const fields = new Map<string, Field>();
|
|
259
|
+
let controller: AbortController | undefined;
|
|
260
|
+
|
|
261
|
+
const fail = (found: Problem, element: HTMLFormElement): void =>
|
|
262
|
+
{
|
|
263
|
+
batch(() =>
|
|
264
|
+
{
|
|
265
|
+
problem.set(found);
|
|
266
|
+
status.set("failure");
|
|
267
|
+
});
|
|
268
|
+
guarded(() => options.onFailure?.(found, element));
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const run = async (element: HTMLFormElement, submitter: HTMLElement | null, method: string, action: URL, current: AbortController): Promise<void> =>
|
|
272
|
+
{
|
|
273
|
+
const data = submittedData(element, submitter);
|
|
274
|
+
batch(() =>
|
|
275
|
+
{
|
|
276
|
+
status.set("pending");
|
|
277
|
+
problem.set(undefined);
|
|
278
|
+
error.set(undefined);
|
|
279
|
+
});
|
|
280
|
+
if (options.schema !== undefined)
|
|
281
|
+
{
|
|
282
|
+
const validation = await validateForm(options.schema, data);
|
|
283
|
+
if (current.signal.aborted)
|
|
284
|
+
{
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (validation.problem !== undefined)
|
|
288
|
+
{
|
|
289
|
+
fail(validation.problem, element);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const submission: Submission = { element, submitter, data, method, action, signal: current.signal };
|
|
294
|
+
const response = await (options.send ?? ((request) => defaultSend(request, options.headers, options.fetch ?? fetch)))(submission);
|
|
295
|
+
if (current.signal.aborted)
|
|
296
|
+
{
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const type = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
300
|
+
if (type.includes(PROBLEM_MEDIA_TYPE) || !response.ok)
|
|
301
|
+
{
|
|
302
|
+
const body: unknown = type.includes("json") ? await response.json().catch(() => undefined) : undefined;
|
|
303
|
+
if (current.signal.aborted)
|
|
304
|
+
{
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
fail(problemOf(body, response), element);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const parsed = response.redirected ? undefined : await parseBody(response);
|
|
311
|
+
if (current.signal.aborted)
|
|
312
|
+
{
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
batch(() =>
|
|
316
|
+
{
|
|
317
|
+
result.set(parsed);
|
|
318
|
+
status.set("success");
|
|
319
|
+
});
|
|
320
|
+
if (options.reset === true)
|
|
321
|
+
{
|
|
322
|
+
element.reset();
|
|
323
|
+
}
|
|
324
|
+
guarded(() => options.onSuccess?.({ result: parsed, response, element }));
|
|
325
|
+
if (response.redirected)
|
|
326
|
+
{
|
|
327
|
+
(options.navigate ?? ((url) => location.assign(url)))(response.url);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
status,
|
|
333
|
+
pending,
|
|
334
|
+
problem,
|
|
335
|
+
result,
|
|
336
|
+
error,
|
|
337
|
+
field(name: string): Field
|
|
338
|
+
{
|
|
339
|
+
let field = fields.get(name);
|
|
340
|
+
if (field === undefined)
|
|
341
|
+
{
|
|
342
|
+
const errors = computed<readonly string[]>(() =>
|
|
343
|
+
{
|
|
344
|
+
const found = problem.get()?.fields;
|
|
345
|
+
return found !== undefined && Object.hasOwn(found, name) ? found[name] ?? EMPTY : EMPTY;
|
|
346
|
+
});
|
|
347
|
+
field = {
|
|
348
|
+
errors,
|
|
349
|
+
error: computed(() => errors.get()[0]),
|
|
350
|
+
invalid: computed(() => errors.get().length > 0)
|
|
351
|
+
};
|
|
352
|
+
fields.set(name, field);
|
|
353
|
+
}
|
|
354
|
+
return field;
|
|
355
|
+
},
|
|
356
|
+
submit(event: SubmitEvent): void
|
|
357
|
+
{
|
|
358
|
+
const element = event.currentTarget as HTMLFormElement;
|
|
359
|
+
const submitter = event.submitter;
|
|
360
|
+
const method = submitter !== null && submitter.hasAttribute("formmethod") ? (submitter as HTMLButtonElement).formMethod : element.method;
|
|
361
|
+
if (method === "dialog")
|
|
362
|
+
{
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
const action = new URL(submitter?.hasAttribute("formaction") === true ? (submitter as HTMLButtonElement).formAction : element.action);
|
|
367
|
+
controller?.abort();
|
|
368
|
+
const current = new AbortController();
|
|
369
|
+
controller = current;
|
|
370
|
+
run(element, submitter, method, action, current).catch((thrown: unknown) =>
|
|
371
|
+
{
|
|
372
|
+
if (current.signal.aborted)
|
|
373
|
+
{
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
batch(() =>
|
|
377
|
+
{
|
|
378
|
+
error.set(thrown);
|
|
379
|
+
status.set("error");
|
|
380
|
+
});
|
|
381
|
+
guarded(() => options.onError?.(thrown, element));
|
|
382
|
+
});
|
|
383
|
+
},
|
|
384
|
+
reset(): void
|
|
385
|
+
{
|
|
386
|
+
controller?.abort();
|
|
387
|
+
controller = undefined;
|
|
388
|
+
batch(() =>
|
|
389
|
+
{
|
|
390
|
+
status.set("idle");
|
|
391
|
+
problem.set(undefined);
|
|
392
|
+
result.set(undefined);
|
|
393
|
+
error.set(undefined);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zerolith/forms: native forms, enhanced. The public surface is assembled here.
|
|
3
|
+
* The `Problem` type, `isProblem`, and `PROBLEM_MEDIA_TYPE` come from `zerolith`.
|
|
4
|
+
*/
|
|
5
|
+
export { problemResponse } from "./problem.js";
|
|
6
|
+
|
|
7
|
+
export type { FormValues, InferOutput, StandardIssue, StandardPathSegment, StandardResult, StandardSchema, Validation } from "./schema.js";
|
|
8
|
+
export { formValues, problemFromIssues, validateForm } from "./schema.js";
|
|
9
|
+
|
|
10
|
+
export type { Field, Form, FormOptions, FormStatus, Submission, SubmissionOutcome } from "./form.js";
|
|
11
|
+
export { createForm } from "./form.js";
|
package/src/problem.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server side of the problem document: writing a `Problem` as a
|
|
3
|
+
* WinterTC `Response` with the problem details media type.
|
|
4
|
+
*/
|
|
5
|
+
import { PROBLEM_MEDIA_TYPE, type Problem } from "zerolith";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Writes a problem as a `Response` with the problem details media type.
|
|
9
|
+
* The status comes from `init`, or the problem, or defaults to 400.
|
|
10
|
+
*/
|
|
11
|
+
export function problemResponse(problem: Problem, init: ResponseInit = {}): Response
|
|
12
|
+
{
|
|
13
|
+
const status = init.status ?? problem.status ?? 400;
|
|
14
|
+
const headers = new Headers(init.headers);
|
|
15
|
+
headers.set("content-type", `${PROBLEM_MEDIA_TYPE}; charset=utf-8`);
|
|
16
|
+
const body: Problem = { ...problem, status };
|
|
17
|
+
return new Response(JSON.stringify(body), { ...init, status, headers });
|
|
18
|
+
}
|