@zeno-lib/forms 0.2.0 → 0.2.1
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/dist/create-zeno-form.d.mts +118 -0
- package/dist/create-zeno-form.mjs +121 -0
- package/dist/form-element.d.mts +13 -0
- package/dist/form-element.mjs +31 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +10 -0
- package/dist/lib/action-result.d.mts +22 -0
- package/dist/lib/action-result.mjs +32 -0
- package/dist/lib/apply-validation-error.d.mts +6 -0
- package/dist/lib/apply-validation-error.mjs +41 -0
- package/dist/lib/aria.d.mts +4 -0
- package/dist/lib/aria.mjs +7 -0
- package/dist/lib/contexts.d.mts +5 -0
- package/dist/lib/contexts.mjs +7 -0
- package/dist/lib/formatted-number.d.mts +56 -0
- package/dist/lib/formatted-number.mjs +138 -0
- package/dist/lib/schema-defaults.d.mts +17 -0
- package/dist/lib/schema-defaults.mjs +75 -0
- package/dist/lib/schema-required.d.mts +25 -0
- package/dist/lib/schema-required.mjs +72 -0
- package/dist/lib/submit-action.d.mts +64 -0
- package/dist/lib/submit-action.mjs +43 -0
- package/dist/lib/use-form-dialog.d.mts +73 -0
- package/dist/lib/use-form-dialog.mjs +90 -0
- package/dist/lib/use-formatted-number.d.mts +61 -0
- package/dist/lib/use-formatted-number.mjs +104 -0
- package/dist/lib/use-is-invalid.d.mts +7 -0
- package/dist/lib/use-is-invalid.mjs +31 -0
- package/dist/lib/use-rebased-default-values.d.mts +10 -0
- package/dist/lib/use-rebased-default-values.mjs +21 -0
- package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
- package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
- package/dist/lib/validation-error.d.mts +12 -0
- package/dist/lib/validation-error.mjs +11 -0
- package/dist/lib/validation-logic.d.mts +5 -0
- package/dist/lib/validation-logic.mjs +114 -0
- package/dist/lib/validation-modes.d.mts +16 -0
- package/dist/lib/validation-modes.mjs +38 -0
- package/dist/tanstack.d.mts +1 -0
- package/dist/tanstack.mjs +2 -0
- package/package.json +23 -7
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/lib/schema-defaults.d.ts
|
|
2
|
+
type SchemaNode = {
|
|
3
|
+
readonly _zod?: {
|
|
4
|
+
readonly def?: SchemaDef;
|
|
5
|
+
};
|
|
6
|
+
};
|
|
7
|
+
type SchemaDef = {
|
|
8
|
+
readonly type?: string;
|
|
9
|
+
readonly innerType?: SchemaNode;
|
|
10
|
+
readonly shape?: Record<string, SchemaNode>;
|
|
11
|
+
readonly in?: SchemaNode;
|
|
12
|
+
readonly defaultValue?: unknown;
|
|
13
|
+
};
|
|
14
|
+
declare function extractZodDefaults(schema: SchemaNode): Record<string, unknown>;
|
|
15
|
+
declare function deepMergeDefaults(schemaDefaults: Record<string, unknown>, userDefaults: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { deepMergeDefaults, extractZodDefaults };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/lib/schema-defaults.ts
|
|
2
|
+
const MAX_WRAPPER_DEPTH = 16;
|
|
3
|
+
function getDef(node) {
|
|
4
|
+
return node?._zod?.def;
|
|
5
|
+
}
|
|
6
|
+
function extractDefaultForField(node) {
|
|
7
|
+
let current = node;
|
|
8
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth++) {
|
|
9
|
+
const def = getDef(current);
|
|
10
|
+
if (!def?.type) return { ok: false };
|
|
11
|
+
switch (def.type) {
|
|
12
|
+
case "default":
|
|
13
|
+
case "prefault": return {
|
|
14
|
+
ok: true,
|
|
15
|
+
value: def.defaultValue
|
|
16
|
+
};
|
|
17
|
+
case "optional":
|
|
18
|
+
case "nullable":
|
|
19
|
+
case "nonoptional":
|
|
20
|
+
case "readonly":
|
|
21
|
+
current = def.innerType;
|
|
22
|
+
continue;
|
|
23
|
+
case "pipe":
|
|
24
|
+
current = def.in;
|
|
25
|
+
continue;
|
|
26
|
+
case "string": return {
|
|
27
|
+
ok: true,
|
|
28
|
+
value: ""
|
|
29
|
+
};
|
|
30
|
+
case "array": return {
|
|
31
|
+
ok: true,
|
|
32
|
+
value: []
|
|
33
|
+
};
|
|
34
|
+
case "object": {
|
|
35
|
+
const nested = current ? extractZodDefaults(current) : {};
|
|
36
|
+
return Object.keys(nested).length > 0 ? {
|
|
37
|
+
ok: true,
|
|
38
|
+
value: nested
|
|
39
|
+
} : { ok: false };
|
|
40
|
+
}
|
|
41
|
+
default: return { ok: false };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ok: false };
|
|
45
|
+
}
|
|
46
|
+
function extractZodDefaults(schema) {
|
|
47
|
+
try {
|
|
48
|
+
const def = getDef(schema);
|
|
49
|
+
if (def?.type !== "object" || !def.shape) return {};
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const [key, field] of Object.entries(def.shape)) {
|
|
52
|
+
const extracted = extractDefaultForField(field);
|
|
53
|
+
if (extracted.ok) result[key] = extracted.value;
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isPlainObject(value) {
|
|
61
|
+
return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
62
|
+
}
|
|
63
|
+
function deepMergeDefaults(schemaDefaults, userDefaults) {
|
|
64
|
+
if (!userDefaults) return schemaDefaults;
|
|
65
|
+
const result = { ...schemaDefaults };
|
|
66
|
+
for (const key of Object.keys(userDefaults)) {
|
|
67
|
+
const userValue = userDefaults[key];
|
|
68
|
+
const schemaValue = schemaDefaults[key];
|
|
69
|
+
if (isPlainObject(schemaValue) && isPlainObject(userValue)) result[key] = deepMergeDefaults(schemaValue, userValue);
|
|
70
|
+
else result[key] = userValue;
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { deepMergeDefaults, extractZodDefaults };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/lib/schema-required.d.ts
|
|
2
|
+
type PathPart = PropertyKey | {
|
|
3
|
+
readonly key: PropertyKey;
|
|
4
|
+
};
|
|
5
|
+
type StandardIssue = {
|
|
6
|
+
readonly path?: readonly PathPart[];
|
|
7
|
+
readonly expected?: unknown;
|
|
8
|
+
};
|
|
9
|
+
type StandardSchemaLike = {
|
|
10
|
+
readonly "~standard": {
|
|
11
|
+
readonly validate: (value: unknown) => {
|
|
12
|
+
readonly value: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
readonly issues: readonly StandardIssue[];
|
|
15
|
+
} | Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Normalise a field name (`members[3].name` or `members.3.name`) to the key
|
|
20
|
+
* `getRequiredPaths` records (`members[0].name`).
|
|
21
|
+
*/
|
|
22
|
+
declare function toRequiredPathKey(name: string): string;
|
|
23
|
+
declare function getRequiredPaths(schema: StandardSchemaLike): Set<string>;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { type StandardSchemaLike, getRequiredPaths, toRequiredPathKey };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region src/lib/schema-required.ts
|
|
2
|
+
const MAX_PROBE_DEPTH = 8;
|
|
3
|
+
const INDEX_SEGMENT = /^\d+$/;
|
|
4
|
+
const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g;
|
|
5
|
+
function pathKey(part) {
|
|
6
|
+
if (typeof part === "object" && part !== null && "key" in part) return part.key;
|
|
7
|
+
return part;
|
|
8
|
+
}
|
|
9
|
+
function isIndex(key) {
|
|
10
|
+
return typeof key === "number" || typeof key === "string" && INDEX_SEGMENT.test(key);
|
|
11
|
+
}
|
|
12
|
+
function joinPath(keys) {
|
|
13
|
+
let out = "";
|
|
14
|
+
for (const key of keys) if (isIndex(key)) out += "[0]";
|
|
15
|
+
else out += out === "" ? String(key) : `.${String(key)}`;
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Normalise a field name (`members[3].name` or `members.3.name`) to the key
|
|
20
|
+
* `getRequiredPaths` records (`members[0].name`).
|
|
21
|
+
*/
|
|
22
|
+
function toRequiredPathKey(name) {
|
|
23
|
+
return name.replace(INDEX_IN_NAME, "[0]");
|
|
24
|
+
}
|
|
25
|
+
function probeContainer(expected) {
|
|
26
|
+
if (typeof expected !== "string") return;
|
|
27
|
+
const kind = expected.toLowerCase();
|
|
28
|
+
if (kind === "object") return {};
|
|
29
|
+
if (kind === "array") return [{}];
|
|
30
|
+
}
|
|
31
|
+
function fillAt(root, keys, value) {
|
|
32
|
+
let node = root;
|
|
33
|
+
for (const key of keys.slice(0, -1)) {
|
|
34
|
+
const next = node[key];
|
|
35
|
+
if (typeof next !== "object" || next === null) return false;
|
|
36
|
+
node = next;
|
|
37
|
+
}
|
|
38
|
+
const last = keys.at(-1);
|
|
39
|
+
if (node[last] !== void 0) return false;
|
|
40
|
+
node[last] = value;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function validateSync(schema, value) {
|
|
44
|
+
let result;
|
|
45
|
+
try {
|
|
46
|
+
result = schema["~standard"].validate(value);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (result instanceof Promise) return;
|
|
51
|
+
return "issues" in result && result.issues ? result.issues : [];
|
|
52
|
+
}
|
|
53
|
+
function getRequiredPaths(schema) {
|
|
54
|
+
const required = /* @__PURE__ */ new Set();
|
|
55
|
+
const probe = {};
|
|
56
|
+
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
|
|
57
|
+
const issues = validateSync(schema, probe);
|
|
58
|
+
if (!issues) return required;
|
|
59
|
+
let descended = false;
|
|
60
|
+
for (const issue of issues) {
|
|
61
|
+
const keys = issue.path?.map(pathKey) ?? [];
|
|
62
|
+
if (keys.length === 0) continue;
|
|
63
|
+
required.add(joinPath(keys));
|
|
64
|
+
const container = probeContainer(issue.expected);
|
|
65
|
+
if (container !== void 0 && fillAt(probe, keys, container)) descended = true;
|
|
66
|
+
}
|
|
67
|
+
if (!descended) break;
|
|
68
|
+
}
|
|
69
|
+
return required;
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
export { getRequiredPaths, toRequiredPathKey };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ActionError, ActionIssue, ActionResult } from "./action-result.mjs";
|
|
2
|
+
import { AnyFormApi } from "@tanstack/react-form";
|
|
3
|
+
//#region src/lib/submit-action.d.ts
|
|
4
|
+
declare function applyActionError(formApi: FormApiLike, error: ActionError): void;
|
|
5
|
+
type SchemaResult<TOutput> = {
|
|
6
|
+
readonly value: TOutput;
|
|
7
|
+
readonly issues?: undefined;
|
|
8
|
+
} | {
|
|
9
|
+
readonly issues: readonly ActionIssue[];
|
|
10
|
+
};
|
|
11
|
+
type SubmitActionSchema<TOutput> = {
|
|
12
|
+
readonly "~standard": {
|
|
13
|
+
readonly validate: (value: unknown) => SchemaResult<TOutput> | Promise<SchemaResult<TOutput>>;
|
|
14
|
+
readonly types?: {
|
|
15
|
+
readonly input: unknown;
|
|
16
|
+
readonly output: TOutput;
|
|
17
|
+
} | undefined;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
type FormApiLike = Pick<AnyFormApi, "fieldInfo" | "getFieldMeta" | "getFieldValue" | "setErrorMap" | "setFieldMeta" | "store">;
|
|
21
|
+
type SubmitProps<TValues> = {
|
|
22
|
+
readonly value: TValues;
|
|
23
|
+
readonly formApi: FormApiLike & {
|
|
24
|
+
reset: (values?: TValues, opts?: {
|
|
25
|
+
keepDefaultValues?: boolean;
|
|
26
|
+
}) => void;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
type Action<TInput, TData> = (input: TInput) => Promise<ActionResult<TData>>;
|
|
30
|
+
type ResetOption<TValues, TData> = "values" | ((data: TData) => TValues) | ([TData] extends [TValues] ? true : never);
|
|
31
|
+
type SubmitActionOptions<TValues, TData> = {
|
|
32
|
+
/**
|
|
33
|
+
* After a successful action, `formApi.reset(...)` so the saved values
|
|
34
|
+
* become the pristine baseline (clears `isDirty` and the unsaved-changes
|
|
35
|
+
* warning). Off by default.
|
|
36
|
+
*/
|
|
37
|
+
reset?: ResetOption<TValues, TData>;
|
|
38
|
+
};
|
|
39
|
+
type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<TValues, TData> & {
|
|
40
|
+
/**
|
|
41
|
+
* Parsed on the client before the call: `onSubmit` receives the form's
|
|
42
|
+
* input values, and the action gets the schema's output. A failure is
|
|
43
|
+
* applied to the form and the action is never called.
|
|
44
|
+
*/
|
|
45
|
+
schema: SubmitActionSchema<TOutput>;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Call a server action from `onSubmit` and route its result back into the
|
|
49
|
+
* form. Pass `onSubmit`'s own argument straight through:
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true, schema })
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* On `{ ok: false }` the field errors land on their fields (array paths like
|
|
56
|
+
* `owners[0].percentage` included) and clear as each field is edited. On
|
|
57
|
+
* `{ ok: true }` the form is optionally reset. Either way the result is
|
|
58
|
+
* returned, so the caller can redirect or toast on `result.ok`. A thrown
|
|
59
|
+
* error (network, auth, a bug) propagates unchanged.
|
|
60
|
+
*/
|
|
61
|
+
declare function submitAction<TValues, TOutput, TData>(props: SubmitProps<TValues>, action: Action<TOutput, TData>, options: SubmitActionSchemaOptions<TValues, TOutput, TData>): Promise<ActionResult<TData>>;
|
|
62
|
+
declare function submitAction<TValues, TData>(props: SubmitProps<TValues>, action: Action<NoInfer<TValues>, TData>, options?: SubmitActionOptions<TValues, TData>): Promise<ActionResult<TData>>;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { type FormApiLike, type SubmitActionOptions, type SubmitActionSchema, applyActionError, submitAction };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { applyValidationError } from "./apply-validation-error.mjs";
|
|
2
|
+
import { ValidationError } from "./validation-error.mjs";
|
|
3
|
+
import { toActionError } from "./action-result.mjs";
|
|
4
|
+
//#region src/lib/submit-action.ts
|
|
5
|
+
function applyActionError(formApi, error) {
|
|
6
|
+
const fields = {};
|
|
7
|
+
const formErrors = [...error.formErrors];
|
|
8
|
+
const fieldInfo = formApi.fieldInfo;
|
|
9
|
+
for (const [name, messages] of Object.entries(error.fieldErrors)) {
|
|
10
|
+
if (messages.length === 0) continue;
|
|
11
|
+
if (fieldInfo[name]?.instance) fields[name] = messages;
|
|
12
|
+
else formErrors.push(...messages);
|
|
13
|
+
}
|
|
14
|
+
applyValidationError(formApi, new ValidationError(fields, formErrors.length > 0 ? { formError: formErrors.join("\n") } : {}));
|
|
15
|
+
}
|
|
16
|
+
async function submitAction(props, action, options = {}) {
|
|
17
|
+
const { formApi, value } = props;
|
|
18
|
+
let input = value;
|
|
19
|
+
if (options.schema) {
|
|
20
|
+
const parsed = await options.schema["~standard"].validate(value);
|
|
21
|
+
if (parsed.issues) {
|
|
22
|
+
const error = toActionError(parsed.issues);
|
|
23
|
+
applyActionError(formApi, error);
|
|
24
|
+
return {
|
|
25
|
+
error,
|
|
26
|
+
ok: false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
input = parsed.value;
|
|
30
|
+
}
|
|
31
|
+
const result = await action(input);
|
|
32
|
+
if (!result.ok) {
|
|
33
|
+
applyActionError(formApi, result.error);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
const { reset } = options;
|
|
37
|
+
if (reset === true) formApi.reset(result.data);
|
|
38
|
+
else if (reset === "values") formApi.reset(value);
|
|
39
|
+
else if (typeof reset === "function") formApi.reset(reset(result.data));
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { applyActionError, submitAction };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/lib/use-form-dialog.d.ts
|
|
2
|
+
type FormDialogOpenOptions<TValues> = {
|
|
3
|
+
/**
|
|
4
|
+
* Values to edit in this session. Omit to start from the hook's
|
|
5
|
+
* `defaultValues` (a blank "create" form).
|
|
6
|
+
*/
|
|
7
|
+
defaultValues?: TValues;
|
|
8
|
+
/** Field `name` to focus once the dialog opens, e.g. `"email"`. */
|
|
9
|
+
focus?: string;
|
|
10
|
+
};
|
|
11
|
+
type UseFormDialogOptions<TValues> = {
|
|
12
|
+
/** Values a session starts from when `open()` gets none. */
|
|
13
|
+
defaultValues?: TValues;
|
|
14
|
+
};
|
|
15
|
+
type FormDialogController<TValues> = {
|
|
16
|
+
/** Whether the dialog is open. */
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
/** Open a new editing session, optionally with its own values and focus. */
|
|
19
|
+
open: (options?: FormDialogOpenOptions<TValues>) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Close without the unsaved-changes prompt. Close affordances inside the
|
|
22
|
+
* dialog (Cancel, ×, Escape, outside press) go through the guard instead.
|
|
23
|
+
*/
|
|
24
|
+
close: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* The current session's defaults. Pass them to `useForm({ defaultValues })`
|
|
27
|
+
* so the form's defaults follow the session (see the pitfall below).
|
|
28
|
+
*/
|
|
29
|
+
defaultValues: TValues | undefined;
|
|
30
|
+
/** Field to focus when the current session opened. */
|
|
31
|
+
focus: string | undefined;
|
|
32
|
+
/** Increments on every `open()`; lets the dialog reset once per session. */
|
|
33
|
+
session: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Open/close state for a dialog-hosted form, one "session" per `open()`.
|
|
37
|
+
*
|
|
38
|
+
* Why the hook owns the defaults: TanStack Form ignores new `defaultValues`
|
|
39
|
+
* once the form is touched, and a `form.reset(values)` is undone on the next
|
|
40
|
+
* render when `useForm` still receives the old `defaultValues` (the form is
|
|
41
|
+
* untouched again after the reset, so TanStack re-applies its options).
|
|
42
|
+
* Passing `dialog.defaultValues` to `useForm` keeps the two in agreement;
|
|
43
|
+
* the `FormDialog` component then calls `form.reset(defaults)` at the start
|
|
44
|
+
* of every session.
|
|
45
|
+
*/
|
|
46
|
+
declare function useFormDialog<TValues>(options?: UseFormDialogOptions<TValues>): FormDialogController<TValues>;
|
|
47
|
+
type UseLeaveGuardOptions = {
|
|
48
|
+
/** Whether leaving now would lose changes. */
|
|
49
|
+
hasUnsavedChanges: boolean;
|
|
50
|
+
};
|
|
51
|
+
type LeaveGuard = {
|
|
52
|
+
/**
|
|
53
|
+
* Run `proceed` now when there's nothing to lose, otherwise hold it until
|
|
54
|
+
* the user confirms. A newer request replaces a held one.
|
|
55
|
+
*/
|
|
56
|
+
requestLeave: (proceed: () => void) => void;
|
|
57
|
+
/** Whether a leave is waiting on the user's answer (show your confirm UI). */
|
|
58
|
+
isConfirming: boolean;
|
|
59
|
+
/** The user chose to discard: run the held leave. */
|
|
60
|
+
confirmLeave: () => void;
|
|
61
|
+
/** The user chose to stay: drop the held leave. */
|
|
62
|
+
cancelLeave: () => void;
|
|
63
|
+
};
|
|
64
|
+
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
65
|
+
declare function useLeaveGuard({ hasUnsavedChanges }: UseLeaveGuardOptions): LeaveGuard;
|
|
66
|
+
/**
|
|
67
|
+
* Find the element to focus for field `name` inside `root`: the control with
|
|
68
|
+
* `id={name}` (what the shipped fields render), else the first focusable
|
|
69
|
+
* element inside the field root marked `data-field={name}`.
|
|
70
|
+
*/
|
|
71
|
+
declare function findFieldElement(root: ParentNode | null | undefined, name: string): HTMLElement | null;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { type FormDialogController, type FormDialogOpenOptions, type LeaveGuard, type UseFormDialogOptions, type UseLeaveGuardOptions, findFieldElement, useFormDialog, useLeaveGuard };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
//#region src/lib/use-form-dialog.ts
|
|
4
|
+
/**
|
|
5
|
+
* Open/close state for a dialog-hosted form, one "session" per `open()`.
|
|
6
|
+
*
|
|
7
|
+
* Why the hook owns the defaults: TanStack Form ignores new `defaultValues`
|
|
8
|
+
* once the form is touched, and a `form.reset(values)` is undone on the next
|
|
9
|
+
* render when `useForm` still receives the old `defaultValues` (the form is
|
|
10
|
+
* untouched again after the reset, so TanStack re-applies its options).
|
|
11
|
+
* Passing `dialog.defaultValues` to `useForm` keeps the two in agreement;
|
|
12
|
+
* the `FormDialog` component then calls `form.reset(defaults)` at the start
|
|
13
|
+
* of every session.
|
|
14
|
+
*/
|
|
15
|
+
function useFormDialog(options = {}) {
|
|
16
|
+
const latestDefaults = useRef(options.defaultValues);
|
|
17
|
+
latestDefaults.current = options.defaultValues;
|
|
18
|
+
const [state, setState] = useState(() => ({
|
|
19
|
+
defaultValues: options.defaultValues,
|
|
20
|
+
focus: void 0,
|
|
21
|
+
isOpen: false,
|
|
22
|
+
session: 0
|
|
23
|
+
}));
|
|
24
|
+
const open = useCallback((openOptions) => {
|
|
25
|
+
setState((previous) => ({
|
|
26
|
+
defaultValues: openOptions?.defaultValues ?? latestDefaults.current,
|
|
27
|
+
focus: openOptions?.focus,
|
|
28
|
+
isOpen: true,
|
|
29
|
+
session: previous.session + 1
|
|
30
|
+
}));
|
|
31
|
+
}, []);
|
|
32
|
+
return {
|
|
33
|
+
close: useCallback(() => {
|
|
34
|
+
setState((previous) => previous.isOpen ? {
|
|
35
|
+
...previous,
|
|
36
|
+
isOpen: false
|
|
37
|
+
} : previous);
|
|
38
|
+
}, []),
|
|
39
|
+
defaultValues: state.defaultValues,
|
|
40
|
+
focus: state.focus,
|
|
41
|
+
isOpen: state.isOpen,
|
|
42
|
+
open,
|
|
43
|
+
session: state.session
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
47
|
+
function useLeaveGuard({ hasUnsavedChanges }) {
|
|
48
|
+
const pending = useRef(null);
|
|
49
|
+
const [isConfirming, setIsConfirming] = useState(false);
|
|
50
|
+
const requestLeave = useCallback((proceed) => {
|
|
51
|
+
if (!hasUnsavedChanges) {
|
|
52
|
+
proceed();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
pending.current = proceed;
|
|
56
|
+
setIsConfirming(true);
|
|
57
|
+
}, [hasUnsavedChanges]);
|
|
58
|
+
const confirmLeave = useCallback(() => {
|
|
59
|
+
const proceed = pending.current;
|
|
60
|
+
pending.current = null;
|
|
61
|
+
setIsConfirming(false);
|
|
62
|
+
proceed?.();
|
|
63
|
+
}, []);
|
|
64
|
+
return {
|
|
65
|
+
cancelLeave: useCallback(() => {
|
|
66
|
+
pending.current = null;
|
|
67
|
+
setIsConfirming(false);
|
|
68
|
+
}, []),
|
|
69
|
+
confirmLeave,
|
|
70
|
+
isConfirming,
|
|
71
|
+
requestLeave
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const FOCUSABLE = "input:not([type=\"hidden\"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
75
|
+
function attributeSelector(attribute, value) {
|
|
76
|
+
return `[${attribute}="${value.replace(/["\\]/g, "\\$&")}"]`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Find the element to focus for field `name` inside `root`: the control with
|
|
80
|
+
* `id={name}` (what the shipped fields render), else the first focusable
|
|
81
|
+
* element inside the field root marked `data-field={name}`.
|
|
82
|
+
*/
|
|
83
|
+
function findFieldElement(root, name) {
|
|
84
|
+
if (!root) return null;
|
|
85
|
+
const byId = root.querySelector(attributeSelector("id", name));
|
|
86
|
+
if (byId) return byId;
|
|
87
|
+
return root.querySelector(attributeSelector("data-field", name))?.querySelector(FOCUSABLE) ?? null;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
export { findFieldElement, useFormDialog, useLeaveGuard };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ChangeEvent, ClipboardEvent } from "react";
|
|
2
|
+
//#region src/lib/use-formatted-number.d.ts
|
|
3
|
+
type UseFormattedNumberOptions = {
|
|
4
|
+
/** The stored value. `null`/`undefined` renders an empty input. */
|
|
5
|
+
value: number | null | undefined;
|
|
6
|
+
/** Called with the parsed value when the user types, pastes, or blurs. */
|
|
7
|
+
onValueChange: (value: number | null) => void;
|
|
8
|
+
/** Called after the blur re-format (wire the field's `handleBlur` here). */
|
|
9
|
+
onBlur?: () => void;
|
|
10
|
+
/** BCP 47 locale for separators. Defaults to the runtime locale. */
|
|
11
|
+
locale?: string;
|
|
12
|
+
/** Digits allowed after the decimal separator. Defaults to `2`. */
|
|
13
|
+
maximumFractionDigits?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Pad a non-integer value to `maximumFractionDigits` on blur (`12.5` →
|
|
16
|
+
* `12.50`). Integers stay bare (`12`). Defaults to `false`.
|
|
17
|
+
*/
|
|
18
|
+
padFraction?: boolean;
|
|
19
|
+
/** Insert thousands separators. Defaults to `true`. */
|
|
20
|
+
useGrouping?: boolean;
|
|
21
|
+
/** Accept a leading minus. Defaults to `true`. */
|
|
22
|
+
allowNegative?: boolean;
|
|
23
|
+
/** Drop integer digits beyond this count while typing. */
|
|
24
|
+
maxIntegerDigits?: number;
|
|
25
|
+
/** Clamp the stored value on blur. */
|
|
26
|
+
min?: number;
|
|
27
|
+
/** Clamp the stored value on blur. */
|
|
28
|
+
max?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Map the stored value to the number shown in the input, e.g.
|
|
31
|
+
* `(v) => v * 100` to edit a `0–1` fraction as a percentage. Pair with
|
|
32
|
+
* `fromDisplay`.
|
|
33
|
+
*/
|
|
34
|
+
toDisplay?: (value: number) => number;
|
|
35
|
+
/** Inverse of `toDisplay`. */
|
|
36
|
+
fromDisplay?: (display: number) => number;
|
|
37
|
+
};
|
|
38
|
+
type FormattedNumberInputProps = {
|
|
39
|
+
inputMode: "decimal" | "numeric";
|
|
40
|
+
onBlur: () => void;
|
|
41
|
+
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
|
42
|
+
onPaste: (event: ClipboardEvent<HTMLInputElement>) => void;
|
|
43
|
+
type: "text";
|
|
44
|
+
value: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Headless, locale-aware formatted number input.
|
|
48
|
+
*
|
|
49
|
+
* Keeps the input's text in local state so the user can type freely
|
|
50
|
+
* (`"1’2"`, `"12."`), re-inserting group separators as they type while
|
|
51
|
+
* holding the caret in place, and parses the text into a `number | null`
|
|
52
|
+
* for the form. Pasted text is parsed leniently (`"CHF 1'234.50"`,
|
|
53
|
+
* `"1.234,50"`). On blur the value is clamped to `min`/`max` and the text
|
|
54
|
+
* re-formatted. External value changes (e.g. `form.reset`) re-sync the text.
|
|
55
|
+
*
|
|
56
|
+
* Returns props to spread on an `<input>`; UI-free, so any input component
|
|
57
|
+
* can use it.
|
|
58
|
+
*/
|
|
59
|
+
declare function useFormattedNumber({ value, onValueChange, onBlur, locale, maximumFractionDigits, padFraction, useGrouping, allowNegative, maxIntegerDigits, min, max, toDisplay, fromDisplay }: UseFormattedNumberOptions): FormattedNumberInputProps;
|
|
60
|
+
//#endregion
|
|
61
|
+
export { type FormattedNumberInputProps, type UseFormattedNumberOptions, useFormattedNumber };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { formatNumber, mapCaret, parseLocaleNumber, parseTypedNumber, roundTo } from "./formatted-number.mjs";
|
|
3
|
+
import { useLayoutEffect, useReducer, useRef, useState } from "react";
|
|
4
|
+
//#region src/lib/use-formatted-number.ts
|
|
5
|
+
const identity = (value) => value;
|
|
6
|
+
function clamp(value, min, max) {
|
|
7
|
+
let next = value;
|
|
8
|
+
if (min !== void 0 && next < min) next = min;
|
|
9
|
+
if (max !== void 0 && next > max) next = max;
|
|
10
|
+
return next;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Headless, locale-aware formatted number input.
|
|
14
|
+
*
|
|
15
|
+
* Keeps the input's text in local state so the user can type freely
|
|
16
|
+
* (`"1’2"`, `"12."`), re-inserting group separators as they type while
|
|
17
|
+
* holding the caret in place, and parses the text into a `number | null`
|
|
18
|
+
* for the form. Pasted text is parsed leniently (`"CHF 1'234.50"`,
|
|
19
|
+
* `"1.234,50"`). On blur the value is clamped to `min`/`max` and the text
|
|
20
|
+
* re-formatted. External value changes (e.g. `form.reset`) re-sync the text.
|
|
21
|
+
*
|
|
22
|
+
* Returns props to spread on an `<input>`; UI-free, so any input component
|
|
23
|
+
* can use it.
|
|
24
|
+
*/
|
|
25
|
+
function useFormattedNumber({ value, onValueChange, onBlur, locale, maximumFractionDigits = 2, padFraction = false, useGrouping = true, allowNegative = true, maxIntegerDigits, min, max, toDisplay = identity, fromDisplay = identity }) {
|
|
26
|
+
const current = value ?? null;
|
|
27
|
+
const display = (stored) => {
|
|
28
|
+
if (stored === null) return "";
|
|
29
|
+
const shown = roundTo(toDisplay(stored), maximumFractionDigits);
|
|
30
|
+
return formatNumber(shown, {
|
|
31
|
+
locale,
|
|
32
|
+
maximumFractionDigits,
|
|
33
|
+
minimumFractionDigits: padFraction && !Number.isInteger(shown) ? maximumFractionDigits : 0,
|
|
34
|
+
useGrouping
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const [text, setText] = useState(() => display(current));
|
|
38
|
+
const [synced, setSynced] = useState(current);
|
|
39
|
+
if (!Object.is(current, synced)) {
|
|
40
|
+
setSynced(current);
|
|
41
|
+
setText(display(current));
|
|
42
|
+
}
|
|
43
|
+
const [, forceRender] = useReducer((n) => n + 1, 0);
|
|
44
|
+
const pendingCaret = useRef(null);
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
const pending = pendingCaret.current;
|
|
47
|
+
if (!pending) return;
|
|
48
|
+
pendingCaret.current = null;
|
|
49
|
+
if (pending.input.ownerDocument.activeElement === pending.input) pending.input.setSelectionRange(pending.at, pending.at);
|
|
50
|
+
});
|
|
51
|
+
const fromDisplayRounded = (shown) => fromDisplay === identity ? shown : roundTo(fromDisplay(shown), maximumFractionDigits + 4);
|
|
52
|
+
const commit = (nextText, nextValue) => {
|
|
53
|
+
setText(nextText);
|
|
54
|
+
setSynced(nextValue);
|
|
55
|
+
if (!Object.is(nextValue, current)) onValueChange(nextValue);
|
|
56
|
+
};
|
|
57
|
+
const onChange = (event) => {
|
|
58
|
+
const input = event.target;
|
|
59
|
+
const raw = input.value;
|
|
60
|
+
const typed = parseTypedNumber(raw, {
|
|
61
|
+
allowNegative,
|
|
62
|
+
locale,
|
|
63
|
+
maxIntegerDigits,
|
|
64
|
+
maximumFractionDigits,
|
|
65
|
+
useGrouping
|
|
66
|
+
});
|
|
67
|
+
const caret = input.selectionStart ?? raw.length;
|
|
68
|
+
pendingCaret.current = {
|
|
69
|
+
at: mapCaret(raw, caret, typed.text, locale),
|
|
70
|
+
input
|
|
71
|
+
};
|
|
72
|
+
commit(typed.text, typed.value === null ? null : fromDisplayRounded(typed.value));
|
|
73
|
+
forceRender();
|
|
74
|
+
};
|
|
75
|
+
const onPaste = (event) => {
|
|
76
|
+
const input = event.currentTarget;
|
|
77
|
+
if (!(input.value === "" || input.selectionStart === 0 && input.selectionEnd === input.value.length)) return;
|
|
78
|
+
const parsed = parseLocaleNumber(event.clipboardData.getData("text"), locale);
|
|
79
|
+
if (parsed === null) return;
|
|
80
|
+
const shown = roundTo(allowNegative ? parsed : Math.abs(parsed), maximumFractionDigits);
|
|
81
|
+
if (maxIntegerDigits !== void 0 && Math.abs(Math.trunc(shown)) >= 10 ** maxIntegerDigits) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
const stored = fromDisplayRounded(shown);
|
|
84
|
+
commit(display(stored), stored);
|
|
85
|
+
};
|
|
86
|
+
const handleBlur = () => {
|
|
87
|
+
if (current === null) setText("");
|
|
88
|
+
else {
|
|
89
|
+
const clamped = clamp(current, min, max);
|
|
90
|
+
commit(display(clamped), clamped);
|
|
91
|
+
}
|
|
92
|
+
onBlur?.();
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
inputMode: maximumFractionDigits > 0 ? "decimal" : "numeric",
|
|
96
|
+
onBlur: handleBlur,
|
|
97
|
+
onChange,
|
|
98
|
+
onPaste,
|
|
99
|
+
type: "text",
|
|
100
|
+
value: text
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { useFormattedNumber };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { AnyFieldApi } from "@tanstack/react-form";
|
|
2
|
+
//#region src/lib/use-is-invalid.d.ts
|
|
3
|
+
declare function useIsInvalid(field: AnyFieldApi): boolean;
|
|
4
|
+
declare function useHideFieldErrors(field: AnyFieldApi): boolean;
|
|
5
|
+
declare function useIsFieldRequired(field: AnyFieldApi): boolean;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { useHideFieldErrors, useIsFieldRequired, useIsInvalid };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { getFormHideFieldErrors, getFormValidationMode, isFieldRequired } from "./validation-modes.mjs";
|
|
3
|
+
import { useSelector } from "@tanstack/react-form";
|
|
4
|
+
//#region src/lib/use-is-invalid.ts
|
|
5
|
+
function hasFieldLevelError(errorMap, errorSourceMap, cause) {
|
|
6
|
+
if (!(errorMap && errorSourceMap)) return false;
|
|
7
|
+
return errorSourceMap[cause] === "field" && errorMap[cause] !== void 0;
|
|
8
|
+
}
|
|
9
|
+
function modeAllowsDisplay(mode, isDirty, isBlurred, wasSubmitted) {
|
|
10
|
+
if (mode === "change") return isDirty || wasSubmitted;
|
|
11
|
+
if (mode === "submit") return wasSubmitted;
|
|
12
|
+
return isBlurred || wasSubmitted;
|
|
13
|
+
}
|
|
14
|
+
function useIsInvalid(field) {
|
|
15
|
+
const wasSubmitted = useSelector(field.form.store, (state) => state.submissionAttempts > 0);
|
|
16
|
+
if (field.state.meta.isValid) return false;
|
|
17
|
+
const { isDirty, isBlurred, errorMap, errorSourceMap } = field.state.meta;
|
|
18
|
+
const sourceMap = errorSourceMap;
|
|
19
|
+
if (hasFieldLevelError(errorMap, sourceMap, "onChange")) return isDirty || wasSubmitted;
|
|
20
|
+
if (hasFieldLevelError(errorMap, sourceMap, "onBlur")) return isBlurred || wasSubmitted;
|
|
21
|
+
if (hasFieldLevelError(errorMap, sourceMap, "onSubmit")) return wasSubmitted;
|
|
22
|
+
return modeAllowsDisplay(getFormValidationMode(field.form), isDirty, isBlurred, wasSubmitted);
|
|
23
|
+
}
|
|
24
|
+
function useHideFieldErrors(field) {
|
|
25
|
+
return getFormHideFieldErrors(field.form);
|
|
26
|
+
}
|
|
27
|
+
function useIsFieldRequired(field) {
|
|
28
|
+
return isFieldRequired(field.form, field.name);
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { useHideFieldErrors, useIsFieldRequired, useIsInvalid };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
|
+
//#region src/lib/use-rebased-default-values.d.ts
|
|
3
|
+
declare function useRebasedDefaultValues<T>(callerDefaults: T): {
|
|
4
|
+
defaultValues: T;
|
|
5
|
+
onMount: (props: {
|
|
6
|
+
formApi: AnyFormApi;
|
|
7
|
+
}) => void;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { useRebasedDefaultValues };
|