@menapo/forms 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +45 -2
- package/dist/array.d.ts +5 -0
- package/dist/array.js +101 -0
- package/dist/checks.d.ts +4 -0
- package/dist/checks.js +53 -0
- package/dist/factories/fn.d.ts +8 -0
- package/dist/factories/fn.js +5 -0
- package/dist/factories/route.d.ts +5 -0
- package/dist/factories/route.js +31 -0
- package/dist/form-BZ2OH06z.js +48 -0
- package/dist/glue/preact.d.ts +4 -0
- package/dist/glue/preact.js +59 -0
- package/dist/glue/react.d.ts +4 -0
- package/dist/glue/react.js +55 -0
- package/dist/glue/solid.d.ts +4 -0
- package/dist/glue/solid.js +48 -0
- package/dist/glue/svelte.svelte.d.ts +4 -0
- package/dist/glue/svelte.svelte.js +62 -0
- package/dist/glue/vanilla.d.ts +4 -0
- package/dist/glue/vanilla.js +88 -0
- package/dist/glue/vue.d.ts +4 -0
- package/dist/glue/vue.js +47 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +30 -0
- package/dist/instance-C1aijv06.js +683 -0
- package/dist/names-D_TE3BpM.js +62 -0
- package/dist/paths-DqNafpnl.js +73 -0
- package/dist/route/files.d.ts +5 -0
- package/dist/route/files.js +34 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.js +79 -0
- package/dist/source-DdFTPjID.js +15 -0
- package/dist/types-CdCFdlFp.d.ts +200 -0
- package/dist/view--JGPLHEo.js +308 -0
- package/dist/view-5_kyMWP3.d.ts +331 -0
- package/dist/vite.d.ts +20 -0
- package/dist/vite.js +1154 -0
- package/package.json +98 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region ../glue/src/naming.ts
|
|
2
|
+
/** React's names whose DOM name is not their lowercase form. */
|
|
3
|
+
const DOM = {
|
|
4
|
+
htmlFor: "for",
|
|
5
|
+
className: "class",
|
|
6
|
+
onDoubleClick: "onDblClick"
|
|
7
|
+
};
|
|
8
|
+
/** React's name as the DOM spells it, still camel-cased: `htmlFor` is `for`, `onDoubleClick` `onDblClick`. */
|
|
9
|
+
const dom = (key) => DOM[key] ?? key;
|
|
10
|
+
/** React: names as written. */
|
|
11
|
+
const react = (key) => key;
|
|
12
|
+
/** Preact and Solid take React's names but the DOM's attributes, and lowercase an event's name themselves. */
|
|
13
|
+
const jsx = dom;
|
|
14
|
+
/** Svelte and the DOM itself: lowercase (`onclick`, `tabindex`, `for`). */
|
|
15
|
+
const lower = (key) => dom(key).toLowerCase();
|
|
16
|
+
/**
|
|
17
|
+
* Vue derives an event's name from the rest of an `on…` key, hyphenating
|
|
18
|
+
* capitals (`onKeyDown` would listen for `key-down`), so a handler is
|
|
19
|
+
* `onKeydown`; other names stay, but for the DOM's attributes.
|
|
20
|
+
*/
|
|
21
|
+
const vue = (key) => {
|
|
22
|
+
const name = dom(key);
|
|
23
|
+
return /^on[A-Z]/.test(name) ? name.slice(0, 3) + name.slice(3).toLowerCase() : name;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/core/names.ts
|
|
27
|
+
const KEYS = [
|
|
28
|
+
"id",
|
|
29
|
+
"name",
|
|
30
|
+
"htmlFor",
|
|
31
|
+
"tabIndex",
|
|
32
|
+
"method",
|
|
33
|
+
"encType",
|
|
34
|
+
"type",
|
|
35
|
+
"required",
|
|
36
|
+
"minLength",
|
|
37
|
+
"maxLength",
|
|
38
|
+
"min",
|
|
39
|
+
"max",
|
|
40
|
+
"accept",
|
|
41
|
+
"multiple",
|
|
42
|
+
"onSubmit",
|
|
43
|
+
"onReset",
|
|
44
|
+
"onClick"
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* `jsx`: the framework takes `children` in spread props (React, Preact,
|
|
48
|
+
* Solid), so `<p {...name.error} />` shows the message. Vue and Svelte
|
|
49
|
+
* render a child instead; Svelte's spread refuses `textContent`.
|
|
50
|
+
*/
|
|
51
|
+
function names(naming, react, jsx = react) {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const key of KEYS) out[key] = naming(key);
|
|
54
|
+
out.value = react ? "defaultValue" : "value";
|
|
55
|
+
out.checked = react ? "defaultChecked" : "checked";
|
|
56
|
+
out.input = react ? ["onChange"] : [naming("onInput"), naming("onChange")];
|
|
57
|
+
out.blur = naming(react ? "onBlur" : "onFocusOut");
|
|
58
|
+
out.text = jsx ? "children" : void 0;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { vue as a, react as i, jsx as n, lower as r, names as t };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/core/paths.ts
|
|
2
|
+
/**
|
|
3
|
+
* Paths are the `name` attributes (D7): `address.city`, `invites.0.email`.
|
|
4
|
+
* Values are immutable: `set` copies the objects and arrays on the way to
|
|
5
|
+
* the leaf and shares the rest, so a view comparing by identity sees what
|
|
6
|
+
* changed. A numeric segment indexes an array.
|
|
7
|
+
*/
|
|
8
|
+
const INDEX = /^\d+$/;
|
|
9
|
+
/** The value at `path`, or `undefined` when a step is missing. */
|
|
10
|
+
function get(root, path) {
|
|
11
|
+
if (path === "") return root;
|
|
12
|
+
if (!path.includes(".")) return root !== null && typeof root === "object" ? root[path] : void 0;
|
|
13
|
+
let at = root;
|
|
14
|
+
for (const key of path.split(".")) {
|
|
15
|
+
if (at === null || typeof at !== "object") return void 0;
|
|
16
|
+
at = at[key];
|
|
17
|
+
}
|
|
18
|
+
return at;
|
|
19
|
+
}
|
|
20
|
+
/** A copy of `root` with `value` at `path`. */
|
|
21
|
+
function set(root, path, value) {
|
|
22
|
+
return put(root, path.split("."), 0, value);
|
|
23
|
+
}
|
|
24
|
+
function put(at, keys, i, value) {
|
|
25
|
+
const key = keys[i];
|
|
26
|
+
const copy = Array.isArray(at) || at == null && INDEX.test(key) ? Array.isArray(at) ? [...at] : [] : { ...at };
|
|
27
|
+
copy[key] = i === keys.length - 1 ? value : put(at?.[key], keys, i + 1, value);
|
|
28
|
+
return copy;
|
|
29
|
+
}
|
|
30
|
+
const plain = (value) => value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
31
|
+
/** Equal values: arrays and plain objects by content, the rest (a `File`, a `Date`) by identity. */
|
|
32
|
+
function equal(a, b) {
|
|
33
|
+
if (Object.is(a, b)) return true;
|
|
34
|
+
if (Array.isArray(a)) {
|
|
35
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
36
|
+
for (let i = 0; i < a.length; i++) if (!equal(a[i], b[i])) return false;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (!plain(a) || !plain(b)) return false;
|
|
40
|
+
const keys = Object.keys(a);
|
|
41
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
42
|
+
for (const key of keys) if (!equal(a[key], b[key])) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
/** Each leaf of `value` with its path: plain objects are walked, anything else is a leaf. */
|
|
46
|
+
function leaves(value, path = "", out = []) {
|
|
47
|
+
if (plain(value) && Object.keys(value).length) for (const key of Object.keys(value)) leaves(value[key], path ? `${path}.${key}` : key, out);
|
|
48
|
+
else if (path) out.push([path, value]);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** `{ ...a, ...b }` one level deep, with `b`'s `undefined` keys left out: defaults over defaults (D11). */
|
|
52
|
+
function merge(a, b) {
|
|
53
|
+
const out = { ...a };
|
|
54
|
+
if (b && typeof b === "object") for (const key in b) {
|
|
55
|
+
const value = b[key];
|
|
56
|
+
if (value !== void 0) out[key] = value;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** An issue's path as a name: `["invites", 2, "email"]` and `[{ key: "invites" }, …]` alike. */
|
|
61
|
+
function nameOf(path) {
|
|
62
|
+
if (!path) return "";
|
|
63
|
+
let out = "";
|
|
64
|
+
for (const segment of path) {
|
|
65
|
+
const key = segment !== null && typeof segment === "object" ? segment.key : segment;
|
|
66
|
+
out += (out ? "." : "") + String(key);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** The path's pattern in a field spec: indexes are `*` (`invites.*.email`). */
|
|
71
|
+
const patternOf = (path) => path.includes(".") ? path.replace(/(^|\.)\d+(?=\.|$)/g, "$1*") : path;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { nameOf as a, merge as i, get as n, patternOf as o, leaves as r, set as s, equal as t };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { FileLimits } from "@menapo/api/client";
|
|
2
|
+
import { StandardSchemaV1 } from "@standard-schema/spec";
|
|
3
|
+
//#region src/route/files.d.ts
|
|
4
|
+
export declare function withFiles(schema: StandardSchemaV1 | undefined, files: Readonly<Record<string, FileLimits>>): StandardSchemaV1;
|
|
5
|
+
//#endregion
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { checkFile } from "@menapo/api/client";
|
|
2
|
+
//#region src/route/files.ts
|
|
3
|
+
/**
|
|
4
|
+
* Uploads in the browser (D15): an operation's `files` checked with api's
|
|
5
|
+
* own `checkFile` (its rule and words, without the server) beside the
|
|
6
|
+
* input's schema, as one Standard Schema the plugin's schema module
|
|
7
|
+
* exports for a route form whose operation takes files. Internal: only the
|
|
8
|
+
* plugin's generated module imports it, by file.
|
|
9
|
+
*/
|
|
10
|
+
function withFiles(schema, files) {
|
|
11
|
+
const validate = (value) => {
|
|
12
|
+
const values = value ?? {};
|
|
13
|
+
const issues = [];
|
|
14
|
+
const rest = { ...values };
|
|
15
|
+
for (const [name, limits] of Object.entries(files)) {
|
|
16
|
+
delete rest[name];
|
|
17
|
+
for (const message of checkFile(values[name], limits)) issues.push({
|
|
18
|
+
message,
|
|
19
|
+
path: [name]
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const join = (result) => issues.length || result.issues ? { issues: [...result.issues ?? [], ...issues] } : { value: values };
|
|
23
|
+
if (!schema) return join({ value: rest });
|
|
24
|
+
const result = schema["~standard"].validate(rest);
|
|
25
|
+
return "then" in result ? result.then(join) : join(result);
|
|
26
|
+
};
|
|
27
|
+
return { "~standard": {
|
|
28
|
+
version: 1,
|
|
29
|
+
vendor: "menapo",
|
|
30
|
+
validate
|
|
31
|
+
} };
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { withFiles };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { _ as OutputOf, h as InputOf, p as FormState, u as Form, x as Spec } from "./types-CdCFdlFp.js";
|
|
2
|
+
//#region src/server/index.d.ts
|
|
3
|
+
/** Form data as the input (D7): names are paths, types come from the spec. */
|
|
4
|
+
export declare function decode(data: FormData, spec?: Spec): Record<string, unknown>;
|
|
5
|
+
export type ActionResult<F> = {
|
|
6
|
+
readonly ok: true;
|
|
7
|
+
readonly data: OutputOf<F>;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly state: FormState;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Handles a post of `form`: `extra` adds what the URL holds (`{ id }`).
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const r = await action(editProject, request, { id: params.id });
|
|
17
|
+
* if (r.ok) redirect(303, `/projects/${params.id}`);
|
|
18
|
+
* return fail(400, { form: r.state });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function action<F extends Form<never, unknown, unknown> | {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
}>(form: F, request: Request, extra?: Partial<InputOf<F>>): Promise<ActionResult<F>>;
|
|
24
|
+
//#endregion
|
|
25
|
+
export type { FormState };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { a as nameOf, i as merge, n as get, o as patternOf, r as leaves, s as set } from "./paths-DqNafpnl.js";
|
|
2
|
+
//#region src/server/index.ts
|
|
3
|
+
const isFile = (value) => typeof Blob !== "undefined" && value instanceof Blob;
|
|
4
|
+
/** One posted value, by its field's spec: text stays text unless the input's type says otherwise. */
|
|
5
|
+
function coerce(value, type) {
|
|
6
|
+
if (typeof value !== "string") return value.size === 0 && !value.name ? void 0 : value;
|
|
7
|
+
if (type === "number" || type === "integer") return value === "" ? void 0 : Number.isNaN(Number(value)) ? value : Number(value);
|
|
8
|
+
if (type === "boolean") return value === "on" || value === "true";
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
/** Form data as the input (D7): names are paths, types come from the spec. */
|
|
12
|
+
function decode(data, spec = {}) {
|
|
13
|
+
let out = {};
|
|
14
|
+
for (const name of new Set(data.keys())) {
|
|
15
|
+
const field = spec[patternOf(name)];
|
|
16
|
+
const all = data.getAll(name);
|
|
17
|
+
const value = field?.t === "array" || field?.m ? all.map((item) => coerce(item, void 0)).filter((item) => item !== void 0) : coerce(all[0], field?.t);
|
|
18
|
+
out = set(out, name, value);
|
|
19
|
+
}
|
|
20
|
+
for (const [pattern, field] of Object.entries(spec)) if (field.t === "boolean" && !pattern.includes("*") && get(out, pattern) === void 0) out = set(out, pattern, false);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** The state to render again: the values without files (they can't round-trip, D15), errors by path. */
|
|
24
|
+
function state(values, issues, error, fields) {
|
|
25
|
+
const errors = {};
|
|
26
|
+
const unmatched = [];
|
|
27
|
+
for (const issue of issues) {
|
|
28
|
+
const path = nameOf(issue.path);
|
|
29
|
+
if (path && (fields.has(path) || get(values, path) !== void 0)) errors[path] ??= String(issue.message);
|
|
30
|
+
else unmatched.push(String(issue.message));
|
|
31
|
+
}
|
|
32
|
+
let kept = {};
|
|
33
|
+
for (const [path, value] of leaves(values)) if (!isFile(value) && !(Array.isArray(value) && value.some(isFile))) kept = set(kept, path, value);
|
|
34
|
+
const message = error instanceof Error ? issues.length && !unmatched.length ? void 0 : error.message : unmatched.length ? unmatched.join(" ") : void 0;
|
|
35
|
+
return {
|
|
36
|
+
values: kept,
|
|
37
|
+
errors,
|
|
38
|
+
...message !== void 0 && { error: message }
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Handles a post of `form`: `extra` adds what the URL holds (`{ id }`).
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const r = await action(editProject, request, { id: params.id });
|
|
46
|
+
* if (r.ok) redirect(303, `/projects/${params.id}`);
|
|
47
|
+
* return fail(400, { form: r.state });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
async function action(form, request, extra) {
|
|
51
|
+
const F = form;
|
|
52
|
+
const posted = decode(await request.formData(), F.f);
|
|
53
|
+
const input = merge(merge(F.o.defaults, posted), extra);
|
|
54
|
+
const fields = new Set(Object.keys(posted));
|
|
55
|
+
let output = input;
|
|
56
|
+
const schema = F.s;
|
|
57
|
+
if (schema) {
|
|
58
|
+
const result = await schema["~standard"].validate(input);
|
|
59
|
+
if (result.issues) return {
|
|
60
|
+
ok: false,
|
|
61
|
+
state: state(input, result.issues, void 0, fields)
|
|
62
|
+
};
|
|
63
|
+
output = result.value;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
data: await F.x(F, input, output, { signal: request.signal })
|
|
69
|
+
};
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const issues = error?.issues;
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
state: state(input, Array.isArray(issues) ? issues : [], error, fields)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { action, decode };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region ../glue/src/source.ts
|
|
2
|
+
/** Equal own values: a render passing equal props does not set them again. */
|
|
3
|
+
function same(a, b) {
|
|
4
|
+
if (a === b) return true;
|
|
5
|
+
if (!a || !b) return false;
|
|
6
|
+
const keys = Object.keys(a);
|
|
7
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
8
|
+
for (const key of keys) if (!Object.is(a[key], b[key])) return false;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
let ids = 0;
|
|
12
|
+
/** An id for frameworks that give none (Svelte, vanilla): unique on the page, not across a server and its client. */
|
|
13
|
+
const nextId = () => `m${++ids}`;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { same as n, nextId as t };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//#region src/core/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The public types (PLAN §5.3): the input's fields (D7), what a view
|
|
4
|
+
* reads, and the options a definition and a hook take. Types flow from the
|
|
5
|
+
* operation (query's `Register`, D3) or the schema; nothing here is
|
|
6
|
+
* declared twice.
|
|
7
|
+
*/
|
|
8
|
+
/** Attributes for one element, spelled as the framework spells them (glue's naming). */
|
|
9
|
+
type Attrs = Readonly<Record<string, unknown>>;
|
|
10
|
+
type Leaf = string | number | boolean | bigint | symbol | null | undefined | Date | Blob;
|
|
11
|
+
/**
|
|
12
|
+
* `f.fields` (D7): one entry per key of the input. A leaf is a field, an
|
|
13
|
+
* object its own fields (`f.fields.address.city`), an array a field with
|
|
14
|
+
* its items and edits (`f.fields.invites.items[0].email`).
|
|
15
|
+
*/
|
|
16
|
+
type Fields<T> = { readonly [K in keyof T & string]-?: FieldOf<T[K]>; };
|
|
17
|
+
/** What `f.fields.<key>` is for a value of type `V`. */
|
|
18
|
+
type FieldOf<V> = NonNullable<V> extends Leaf ? FieldView<V> : NonNullable<V> extends readonly (infer E)[] ? ArrayField<V, E> : Fields<NonNullable<V>>;
|
|
19
|
+
/** Defaults: any part of the input, nested. */
|
|
20
|
+
type Defaults<T> = T extends Leaf ? T : T extends readonly unknown[] ? T : { readonly [K in keyof T]?: Defaults<T[K]> | undefined; };
|
|
21
|
+
/** What `/server`'s `action()` hands back to the page: serialisable, files left out (§5.6). */
|
|
22
|
+
interface FormState {
|
|
23
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
24
|
+
/** Field errors by path. */
|
|
25
|
+
readonly errors: Readonly<Record<string, string>>;
|
|
26
|
+
/** The form-level error's message. */
|
|
27
|
+
readonly error?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
/** `useForm`'s options; Vue, Svelte and Solid also take a getter returning them, so they are tracked. */
|
|
30
|
+
interface UseFormOptions<I, O> {
|
|
31
|
+
/** Shallow-merged over the definition's; pristine fields follow a change (D11). */
|
|
32
|
+
readonly defaults?: Defaults<I> | null | undefined;
|
|
33
|
+
/** After a successful submit, with its output. */
|
|
34
|
+
readonly onSuccess?: ((data: O) => void) | undefined;
|
|
35
|
+
/** What `/server`'s `action()` returned for a post without JS (§5.6). */
|
|
36
|
+
readonly state?: FormState | null | undefined;
|
|
37
|
+
}
|
|
38
|
+
/** An error shown by a field, for the summary's links. */
|
|
39
|
+
interface ShownError {
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly message: string;
|
|
42
|
+
/** `#<the control's id>`. */
|
|
43
|
+
readonly href: string;
|
|
44
|
+
}
|
|
45
|
+
/** One radio, or one checkbox of an array field. */
|
|
46
|
+
interface OptionParts {
|
|
47
|
+
readonly control: Attrs;
|
|
48
|
+
readonly label: Attrs;
|
|
49
|
+
}
|
|
50
|
+
interface FieldParts {
|
|
51
|
+
/** `id`, `name`, the initial value, `aria-invalid`, `aria-describedby` and constraint attributes. */
|
|
52
|
+
readonly control: Attrs;
|
|
53
|
+
readonly label: Attrs;
|
|
54
|
+
readonly error: Attrs;
|
|
55
|
+
readonly hint: Attrs;
|
|
56
|
+
/** A fieldset of radios or checkboxes: `aria-describedby` its error. */
|
|
57
|
+
readonly group: Attrs;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A field as a view reads it: tracked per field, so a component renders for
|
|
61
|
+
* what it shows. Its parts are attribute objects to spread on elements.
|
|
62
|
+
*/
|
|
63
|
+
interface FieldView<V> {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly value: V;
|
|
67
|
+
/** The error shown, as the schema or the server worded it (D16). */
|
|
68
|
+
readonly message: string | undefined;
|
|
69
|
+
readonly touched: boolean;
|
|
70
|
+
readonly dirty: boolean;
|
|
71
|
+
/** A `checks` call or an async schema is running for it. */
|
|
72
|
+
readonly validating: boolean;
|
|
73
|
+
/** For controls that emit no native events (a custom select); marks the field touched. */
|
|
74
|
+
set(value: V): void;
|
|
75
|
+
option(value: V extends readonly (infer E)[] ? E : V): OptionParts;
|
|
76
|
+
/** `id`, `name`, the initial value, `aria-invalid`, `aria-describedby` and constraint attributes. */
|
|
77
|
+
readonly control: Attrs;
|
|
78
|
+
readonly label: Attrs;
|
|
79
|
+
/** The error's element: its `id`, and in React, Preact and Solid the message as its children. */
|
|
80
|
+
readonly error: Attrs;
|
|
81
|
+
/**
|
|
82
|
+
* The hint's element: its `id`, and the schema's `description` as its
|
|
83
|
+
* children in React, Preact and Solid. Read, it is linked from the
|
|
84
|
+
* control's `aria-describedby`.
|
|
85
|
+
*/
|
|
86
|
+
readonly hint: Attrs;
|
|
87
|
+
/** A fieldset of radios or checkboxes: `aria-describedby` its error. */
|
|
88
|
+
readonly group: Attrs;
|
|
89
|
+
}
|
|
90
|
+
/** An array's field (D14): itself a field (checkboxes, a group), its items, and the edits. */
|
|
91
|
+
interface ArrayField<V, E> extends FieldView<V> {
|
|
92
|
+
/** One stable key per item, for rendering rows (`key={key}`). */
|
|
93
|
+
readonly keys: readonly string[];
|
|
94
|
+
/** Per item: its fields, or for an array of leaves its field. */
|
|
95
|
+
readonly items: readonly FieldOf<E>[];
|
|
96
|
+
append(item: E): void;
|
|
97
|
+
insert(index: number, item: E): void;
|
|
98
|
+
remove(index: number): void;
|
|
99
|
+
move(from: number, to: number): void;
|
|
100
|
+
}
|
|
101
|
+
/** The parts a field's `Parts` builds, each when first read. */
|
|
102
|
+
interface FieldParts {
|
|
103
|
+
readonly control: Attrs;
|
|
104
|
+
readonly label: Attrs;
|
|
105
|
+
readonly error: Attrs;
|
|
106
|
+
readonly hint: Attrs;
|
|
107
|
+
readonly group: Attrs;
|
|
108
|
+
}
|
|
109
|
+
interface FormParts {
|
|
110
|
+
/** `id`, `method: "post"`, `encType` with files, `data-pending` and the delegated handlers (D6). */
|
|
111
|
+
readonly form: Attrs;
|
|
112
|
+
/** `type: "submit"`, and `aria-disabled` while pending (D12). */
|
|
113
|
+
readonly submit: Attrs;
|
|
114
|
+
/** The error summary: `id`, `tabIndex: -1`, and a click handler for its links. */
|
|
115
|
+
readonly summary: Attrs;
|
|
116
|
+
}
|
|
117
|
+
/** What a definition's types are read from. */
|
|
118
|
+
interface Typed<I, O, E> {
|
|
119
|
+
readonly "~types"?: {
|
|
120
|
+
readonly input: I;
|
|
121
|
+
readonly output: O;
|
|
122
|
+
readonly error: E;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** A form definition: `form("PATCH /projects/:id")` or `form(schema, submit)`. */
|
|
126
|
+
type Form<I, O, E> = Typed<I, O, E> & {
|
|
127
|
+
readonly name: string;
|
|
128
|
+
};
|
|
129
|
+
type InputOf<F> = F extends Typed<infer I, unknown, unknown> ? I : never;
|
|
130
|
+
type OutputOf<F> = F extends Typed<unknown, infer O, unknown> ? O : never;
|
|
131
|
+
type ErrorOf<F> = F extends Typed<unknown, unknown, infer E> ? E : never;
|
|
132
|
+
/** What `useForm` returns: `FormView<typeof editProject>`. */
|
|
133
|
+
interface FormView<F> extends Typed<InputOf<F>, OutputOf<F>, ErrorOf<F>> {
|
|
134
|
+
/** `id`, `method: "post"`, `encType` with files, `data-pending` and the delegated handlers (D6). */
|
|
135
|
+
readonly form: Attrs;
|
|
136
|
+
/** `type: "submit"`, and `aria-disabled` while pending (D12). */
|
|
137
|
+
readonly submit: Attrs;
|
|
138
|
+
/** The error summary: `id`, `tabIndex: -1`, and a click handler for its links. */
|
|
139
|
+
readonly summary: Attrs;
|
|
140
|
+
/** The input's fields, from the schema's keys and the defaults': `const { name } = f.fields`. */
|
|
141
|
+
readonly fields: Fields<InputOf<F>>;
|
|
142
|
+
/** The errors shown, in the schema's order. */
|
|
143
|
+
readonly errors: readonly ShownError[];
|
|
144
|
+
/** The form-level error: api's `ApiError` for a route form, else an `Error`. */
|
|
145
|
+
readonly error: ErrorOf<F> | undefined;
|
|
146
|
+
readonly pending: boolean;
|
|
147
|
+
/** An upload's progress, 0 → 1. */
|
|
148
|
+
readonly progress: number;
|
|
149
|
+
/** Any field changed since the defaults or the last success. */
|
|
150
|
+
readonly dirty: boolean;
|
|
151
|
+
/** The last success's output. */
|
|
152
|
+
readonly data: OutputOf<F> | undefined;
|
|
153
|
+
/** The submit flow from code, as the form's own `requestSubmit()`: the output, or `undefined` after setting errors. */
|
|
154
|
+
requestSubmit(): Promise<OutputOf<F> | undefined>;
|
|
155
|
+
/** Back to the defaults, or to `values`. */
|
|
156
|
+
reset(values?: Defaults<InputOf<F>>): void;
|
|
157
|
+
/** Plain code: calls `listener` after each change this view shows; returns the unsubscribe. */
|
|
158
|
+
subscribe(listener: () => void): () => void;
|
|
159
|
+
/** Plain code: what a framework's unmount does. */
|
|
160
|
+
stop(): void;
|
|
161
|
+
}
|
|
162
|
+
/** Per-field async checks (D8): a message, or `undefined` when the value is fine. */
|
|
163
|
+
type Checks<I> = { readonly [K in keyof I]?: (value: I[K], options: {
|
|
164
|
+
readonly signal: AbortSignal;
|
|
165
|
+
}) => string | undefined | Promise<string | undefined>; };
|
|
166
|
+
interface FormOptions<I> {
|
|
167
|
+
/** The definition's own defaults, under each hook's. */
|
|
168
|
+
readonly defaults?: Defaults<I> | undefined;
|
|
169
|
+
/** Server-side rules checked while typing, debounced 300 ms and aborted when superseded. */
|
|
170
|
+
readonly checks?: Checks<I> | undefined;
|
|
171
|
+
}
|
|
172
|
+
/** A query, as a route form's `refresh` reads it (query's `Refreshable`). */
|
|
173
|
+
interface Refreshable {
|
|
174
|
+
refresh(): void;
|
|
175
|
+
}
|
|
176
|
+
/** A route form's options: a definition's, and query's `mutation()` options passed through (D2). */
|
|
177
|
+
interface RouteFormOptions<I> extends FormOptions<I> {
|
|
178
|
+
readonly optimistic?: ((input: I) => void) | undefined;
|
|
179
|
+
readonly refresh?: readonly Refreshable[] | undefined;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A field's spec (D13), from the input's JSON Schema, by path pattern:
|
|
183
|
+
* type, required, lengths, bounds, `accept`, `multiple`, and an upload's
|
|
184
|
+
* size limit. A small literal per form.
|
|
185
|
+
*/
|
|
186
|
+
interface FieldSpec {
|
|
187
|
+
readonly t?: "string" | "number" | "integer" | "boolean" | "array" | "object" | "file";
|
|
188
|
+
readonly r?: 1;
|
|
189
|
+
readonly mn?: number;
|
|
190
|
+
readonly mx?: number;
|
|
191
|
+
readonly lo?: number;
|
|
192
|
+
readonly hi?: number;
|
|
193
|
+
readonly a?: string;
|
|
194
|
+
readonly m?: 1;
|
|
195
|
+
/** The schema's `description`: the hint's text. */
|
|
196
|
+
readonly d?: string;
|
|
197
|
+
}
|
|
198
|
+
type Spec = Readonly<Record<string, FieldSpec>>;
|
|
199
|
+
//#endregion
|
|
200
|
+
export { UseFormOptions as S, OutputOf as _, FieldOf as a, ShownError as b, FieldView as c, FormOptions as d, FormParts as f, OptionParts as g, InputOf as h, Defaults as i, Fields as l, FormView as m, Attrs as n, FieldParts as o, FormState as p, Checks as r, FieldSpec as s, ArrayField as t, Form as u, Refreshable as v, Spec as x, RouteFormOptions as y };
|