@zeno-lib/forms 0.1.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.
Files changed (63) hide show
  1. package/dist/create-zeno-form.d.mts +118 -0
  2. package/dist/create-zeno-form.mjs +121 -0
  3. package/dist/form-element.d.mts +13 -0
  4. package/dist/form-element.mjs +31 -0
  5. package/dist/index.d.mts +11 -0
  6. package/dist/index.mjs +10 -0
  7. package/dist/lib/action-result.d.mts +22 -0
  8. package/dist/lib/action-result.mjs +32 -0
  9. package/dist/lib/apply-validation-error.d.mts +6 -0
  10. package/dist/lib/apply-validation-error.mjs +41 -0
  11. package/dist/lib/aria.d.mts +4 -0
  12. package/dist/lib/aria.mjs +7 -0
  13. package/dist/lib/contexts.d.mts +5 -0
  14. package/dist/lib/contexts.mjs +7 -0
  15. package/dist/lib/formatted-number.d.mts +56 -0
  16. package/dist/lib/formatted-number.mjs +138 -0
  17. package/dist/lib/schema-defaults.d.mts +17 -0
  18. package/dist/lib/schema-defaults.mjs +75 -0
  19. package/dist/lib/schema-required.d.mts +25 -0
  20. package/dist/lib/schema-required.mjs +72 -0
  21. package/dist/lib/submit-action.d.mts +64 -0
  22. package/dist/lib/submit-action.mjs +43 -0
  23. package/dist/lib/use-form-dialog.d.mts +73 -0
  24. package/dist/lib/use-form-dialog.mjs +90 -0
  25. package/dist/lib/use-formatted-number.d.mts +61 -0
  26. package/dist/lib/use-formatted-number.mjs +104 -0
  27. package/dist/lib/use-is-invalid.d.mts +7 -0
  28. package/dist/lib/use-is-invalid.mjs +31 -0
  29. package/dist/lib/use-rebased-default-values.d.mts +10 -0
  30. package/dist/lib/use-rebased-default-values.mjs +21 -0
  31. package/dist/lib/use-unsaved-changes-warning.d.mts +6 -0
  32. package/dist/lib/use-unsaved-changes-warning.mjs +24 -0
  33. package/dist/lib/validation-error.d.mts +12 -0
  34. package/dist/lib/validation-error.mjs +11 -0
  35. package/dist/lib/validation-logic.d.mts +5 -0
  36. package/dist/lib/validation-logic.mjs +114 -0
  37. package/dist/lib/validation-modes.d.mts +16 -0
  38. package/dist/lib/validation-modes.mjs +38 -0
  39. package/dist/tanstack.d.mts +1 -0
  40. package/dist/tanstack.mjs +2 -0
  41. package/package.json +25 -7
  42. package/src/create-form.tsx +10 -0
  43. package/src/fields/checkbox-group-field.tsx +160 -0
  44. package/src/fields/index.ts +5 -0
  45. package/src/fields/money-field.tsx +123 -0
  46. package/src/fields/multi-select-field.tsx +193 -0
  47. package/src/fields/percentage-field.tsx +73 -0
  48. package/src/fields/year-field.tsx +56 -0
  49. package/src/form-dialog.test.tsx +161 -0
  50. package/src/form-dialog.tsx +253 -0
  51. package/src/formatted-number-fields.test.tsx +236 -0
  52. package/src/index.ts +13 -0
  53. package/src/lib/action-result.ts +61 -0
  54. package/src/lib/apply-validation-error.test.ts +15 -4
  55. package/src/lib/apply-validation-error.ts +38 -6
  56. package/src/lib/formatted-number.test.ts +157 -0
  57. package/src/lib/formatted-number.ts +307 -0
  58. package/src/lib/submit-action.test-d.ts +83 -0
  59. package/src/lib/submit-action.ts +178 -0
  60. package/src/lib/use-form-dialog.ts +184 -0
  61. package/src/lib/use-formatted-number.ts +235 -0
  62. package/src/multi-value-fields.test.tsx +116 -0
  63. package/src/submit-action.test.tsx +421 -0
@@ -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 };