@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.
- 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 +25 -7
- package/src/create-form.tsx +10 -0
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/index.ts +5 -0
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +13 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +15 -4
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
2
|
+
import { DeepKeys, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn } from "@tanstack/react-form";
|
|
3
|
+
import { ComponentProps, ReactNode } from "react";
|
|
4
|
+
//#region src/create-zeno-form.d.ts
|
|
5
|
+
type StandardSchema<T> = {
|
|
6
|
+
readonly "~standard": {
|
|
7
|
+
readonly validate: (value: unknown) => unknown;
|
|
8
|
+
readonly types?: {
|
|
9
|
+
readonly input: T;
|
|
10
|
+
readonly output: T;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
type PartialFormData<T> = T extends readonly unknown[] ? T : T extends Date | RegExp | ((...args: never[]) => unknown) ? T : T extends object ? { [K in keyof T]?: PartialFormData<T[K]>; } : T;
|
|
15
|
+
type ZenoFormExtras<TFormData> = {
|
|
16
|
+
/**
|
|
17
|
+
* If `true`, shipped fields skip rendering their inline `<FieldError>`
|
|
18
|
+
* message. Fields still flip `data-invalid` / `aria-invalid`.
|
|
19
|
+
*/
|
|
20
|
+
hideFieldErrors?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Show a `*` next to the label of every field the schema treats as required.
|
|
23
|
+
* Defaults to `true`. Required-ness is detected by probing the schema.
|
|
24
|
+
*/
|
|
25
|
+
requiredIndicator?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Warn the user before they navigate away with unsaved changes.
|
|
28
|
+
* `"if-changed"` (or `true`) warns while values differ from defaults;
|
|
29
|
+
* `"if-touched"` warns after any edit.
|
|
30
|
+
*/
|
|
31
|
+
unsavedChangesWarning?: boolean | "if-changed" | "if-touched";
|
|
32
|
+
/**
|
|
33
|
+
* Initial values, relaxed to a deep partial so schema-provided defaults can
|
|
34
|
+
* be omitted. See `lib/schema-defaults.ts`.
|
|
35
|
+
*/
|
|
36
|
+
defaultValues?: PartialFormData<TFormData>;
|
|
37
|
+
};
|
|
38
|
+
type SchemaPathExtras<TFormData> = {
|
|
39
|
+
schema: StandardSchema<TFormData>;
|
|
40
|
+
validators?: ValidationMode;
|
|
41
|
+
validationLogic?: never;
|
|
42
|
+
};
|
|
43
|
+
type ManualPathExtras = {
|
|
44
|
+
schema?: never;
|
|
45
|
+
};
|
|
46
|
+
type SchemaFormOptions<TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta> = Omit<FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>, "defaultValues" | "validators" | "validationLogic"> & SchemaPathExtras<TFormData> & ZenoFormExtras<TFormData>;
|
|
47
|
+
type ManualFormOptions<TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta> = Omit<FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>, "defaultValues"> & ManualPathExtras & ZenoFormExtras<TFormData>;
|
|
48
|
+
type UseFormOptions<TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta> = SchemaFormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> | ManualFormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>;
|
|
49
|
+
type FormDataOf<F> = F extends {
|
|
50
|
+
state: {
|
|
51
|
+
values: infer V;
|
|
52
|
+
};
|
|
53
|
+
} ? V : never;
|
|
54
|
+
type AnyAppForm = {
|
|
55
|
+
AppField: unknown;
|
|
56
|
+
state: {
|
|
57
|
+
values: unknown;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
type AnyFieldValidators = {
|
|
61
|
+
onMount?: unknown;
|
|
62
|
+
onChange?: unknown;
|
|
63
|
+
onChangeAsync?: unknown;
|
|
64
|
+
onChangeAsyncDebounceMs?: number;
|
|
65
|
+
onBlur?: unknown;
|
|
66
|
+
onBlurAsync?: unknown;
|
|
67
|
+
onBlurAsyncDebounceMs?: number;
|
|
68
|
+
onSubmit?: unknown;
|
|
69
|
+
onSubmitAsync?: unknown;
|
|
70
|
+
onSubmitAsyncDebounceMs?: number;
|
|
71
|
+
onDynamic?: unknown;
|
|
72
|
+
onDynamicAsync?: unknown;
|
|
73
|
+
};
|
|
74
|
+
type AnyFieldListeners = {
|
|
75
|
+
onChange?: unknown;
|
|
76
|
+
onChangeDebounceMs?: number;
|
|
77
|
+
onBlur?: unknown;
|
|
78
|
+
onBlurDebounceMs?: number;
|
|
79
|
+
onMount?: unknown;
|
|
80
|
+
onSubmit?: unknown;
|
|
81
|
+
};
|
|
82
|
+
type WithValidators = {
|
|
83
|
+
validators?: AnyFieldValidators;
|
|
84
|
+
listeners?: AnyFieldListeners;
|
|
85
|
+
};
|
|
86
|
+
type AnyComponent = React.ComponentType<any>;
|
|
87
|
+
type FieldWrappers<FC extends Record<string, AnyComponent>, T> = { [K in keyof FC]: <N extends DeepKeys<T>>(props: ComponentProps<FC[K]> & {
|
|
88
|
+
name: N;
|
|
89
|
+
} & WithValidators) => ReactNode; };
|
|
90
|
+
type CreateZenoFormConfig<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>> = {
|
|
91
|
+
fieldComponents: FC;
|
|
92
|
+
formComponents: FMC;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Build the wired Zeno form API from the caller's field + form components.
|
|
96
|
+
*
|
|
97
|
+
* The shadcn-style field components are dropped into the consumer's repo via the
|
|
98
|
+
* registry and injected here, so `@zeno-lib/forms` (npm) stays UI-free. Returns
|
|
99
|
+
* the `createFormHook` primitives plus the schema-aware `useForm` and the typed
|
|
100
|
+
* `useAppFields` wrappers (prop types are inferred from the injected components).
|
|
101
|
+
*/
|
|
102
|
+
declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>>(config: CreateZenoFormConfig<FC, FMC>): {
|
|
103
|
+
useAppFields: <TForm extends AnyAppForm>(form: TForm) => FieldWrappers<FC, FormDataOf<TForm>>;
|
|
104
|
+
useAppForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta>(props: FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC>;
|
|
105
|
+
useForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined, TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TSubmitMeta = never>(options: UseFormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").FormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & import("@tanstack/react-form").ReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & NoInfer<FMC> & {
|
|
106
|
+
AppField: import("@tanstack/react-form").FieldComponent<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, NoInfer<FC>>;
|
|
107
|
+
AppForm: import("react").ComponentType<import("react").PropsWithChildren<{}>>;
|
|
108
|
+
} & FieldWrappers<FC, FormDataOf<import("@tanstack/react-form").AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC>>> & FMC;
|
|
109
|
+
withFieldGroup: <TFieldGroupData, TSubmitMeta, TRenderProps extends object = {}>({ render, props, defaultValues }: import("@tanstack/react-form").WithFieldGroupProps<TFieldGroupData, FC, FMC, TSubmitMeta, TRenderProps>) => <TFormData, TFields extends import("@tanstack/react-form").DeepKeysOfType<TFormData, TFieldGroupData | null | undefined> | import("@tanstack/react-form").FieldsMap<TFormData, TFieldGroupData>, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TFormSubmitMeta>(params: import("react").PropsWithChildren<NoInfer<TRenderProps> & {
|
|
110
|
+
form: import("@tanstack/react-form").AppFieldExtendedReactFieldGroupApi<unknown, TFormData, string | import("@tanstack/react-form").FieldsMap<unknown, TFormData>, any, any, any, any, any, any, any, any, any, any, unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, FC, FMC> | import("@tanstack/react-form").AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, FC, FMC>;
|
|
111
|
+
fields: TFields;
|
|
112
|
+
}>) => ReturnType<import("react").FunctionComponent>;
|
|
113
|
+
withForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta, TRenderProps extends object = {}>({ render, props }: import("@tanstack/react-form").WithFormProps<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC, TRenderProps>) => import("react").FunctionComponent<import("react").PropsWithChildren<NoInfer<[unknown] extends [TRenderProps] ? any : TRenderProps> & {
|
|
114
|
+
form: import("@tanstack/react-form").AppFieldExtendedReactFormApi<[unknown] extends [TFormData] ? any : TFormData, [FormValidateOrFn<TFormData> | undefined] extends [TOnMount] ? [TOnMount] extends [TOnMount & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnMount : TOnMount, [FormValidateOrFn<TFormData> | undefined] extends [TOnChange] ? [TOnChange] extends [TOnChange & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChange : TOnChange, [FormValidateOrFn<TFormData> | undefined] extends [TOnChangeAsync] ? [TOnChangeAsync] extends [TOnChangeAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChangeAsync : TOnChangeAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlur] ? [TOnBlur] extends [TOnBlur & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlur : TOnBlur, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlurAsync] ? [TOnBlurAsync] extends [TOnBlurAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlurAsync : TOnBlurAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmit] ? [TOnSubmit] extends [TOnSubmit & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmit : TOnSubmit, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmitAsync] ? [TOnSubmitAsync] extends [TOnSubmitAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmitAsync : TOnSubmitAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamic] ? [TOnDynamic] extends [TOnDynamic & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamic : TOnDynamic, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamicAsync] ? [TOnDynamicAsync] extends [TOnDynamicAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamicAsync : TOnDynamicAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnServer] ? [TOnServer] extends [TOnServer & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnServer : TOnServer, [unknown] extends [TSubmitMeta] ? any : TSubmitMeta, [unknown] extends [FC] ? any : FC, [unknown] extends [FMC] ? any : FMC>;
|
|
115
|
+
}>>;
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
export { createZenoForm };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
3
|
+
import { fieldContext, formContext } from "./lib/contexts.mjs";
|
|
4
|
+
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults.mjs";
|
|
5
|
+
import { getRequiredPaths } from "./lib/schema-required.mjs";
|
|
6
|
+
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values.mjs";
|
|
7
|
+
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning.mjs";
|
|
8
|
+
import { ValidationError } from "./lib/validation-error.mjs";
|
|
9
|
+
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
10
|
+
import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
|
|
11
|
+
import { createFormHook } from "@tanstack/react-form";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { jsx } from "react/jsx-runtime";
|
|
14
|
+
//#region src/create-zeno-form.tsx
|
|
15
|
+
function buildValidatorsFromSchema(schema, mode) {
|
|
16
|
+
switch (mode) {
|
|
17
|
+
case "blur": return { onBlur: schema };
|
|
18
|
+
case "submit": return { onSubmit: schema };
|
|
19
|
+
default: return { onChange: schema };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function withMountListener(listeners, onMount) {
|
|
23
|
+
const user = listeners?.onMount;
|
|
24
|
+
return {
|
|
25
|
+
...listeners,
|
|
26
|
+
onMount: (props) => {
|
|
27
|
+
onMount(props);
|
|
28
|
+
user?.(props);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the wired Zeno form API from the caller's field + form components.
|
|
34
|
+
*
|
|
35
|
+
* The shadcn-style field components are dropped into the consumer's repo via the
|
|
36
|
+
* registry and injected here, so `@zeno-lib/forms` (npm) stays UI-free. Returns
|
|
37
|
+
* the `createFormHook` primitives plus the schema-aware `useForm` and the typed
|
|
38
|
+
* `useAppFields` wrappers (prop types are inferred from the injected components).
|
|
39
|
+
*/
|
|
40
|
+
function createZenoForm(config) {
|
|
41
|
+
const { useAppForm, withFieldGroup, withForm } = createFormHook({
|
|
42
|
+
fieldComponents: config.fieldComponents,
|
|
43
|
+
fieldContext,
|
|
44
|
+
formComponents: config.formComponents,
|
|
45
|
+
formContext
|
|
46
|
+
});
|
|
47
|
+
function useAppFields(form) {
|
|
48
|
+
return useMemo(() => {
|
|
49
|
+
const Field = form.AppField;
|
|
50
|
+
const wrappers = {};
|
|
51
|
+
for (const key of Object.keys(config.fieldComponents)) {
|
|
52
|
+
const Impl = config.fieldComponents[key];
|
|
53
|
+
wrappers[key] = ({ name, validators, listeners, ...props }) => /* @__PURE__ */ jsx(Field, {
|
|
54
|
+
listeners,
|
|
55
|
+
name,
|
|
56
|
+
validators,
|
|
57
|
+
children: () => /* @__PURE__ */ jsx(Impl, { ...props })
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return wrappers;
|
|
61
|
+
}, [form]);
|
|
62
|
+
}
|
|
63
|
+
function useForm(options) {
|
|
64
|
+
const { schema, validators: validatorsInput, validationLogic: userValidationLogic, hideFieldErrors = false, requiredIndicator = true, unsavedChangesWarning = false, defaultValues: userDefaultValues, onSubmit: userOnSubmit, ...rest } = options;
|
|
65
|
+
let schemaMode;
|
|
66
|
+
let resolvedValidators;
|
|
67
|
+
let resolvedValidationLogic;
|
|
68
|
+
if (schema === void 0) {
|
|
69
|
+
schemaMode = void 0;
|
|
70
|
+
resolvedValidators = typeof validatorsInput === "object" && validatorsInput !== null ? validatorsInput : void 0;
|
|
71
|
+
resolvedValidationLogic = userValidationLogic;
|
|
72
|
+
} else {
|
|
73
|
+
const mode = typeof validatorsInput === "string" ? validatorsInput : DEFAULT_VALIDATION_MODE;
|
|
74
|
+
schemaMode = mode;
|
|
75
|
+
resolvedValidators = buildValidatorsFromSchema(schema, mode);
|
|
76
|
+
resolvedValidationLogic = mode === "blur-then-change" ? blurThenChangeLogic : void 0;
|
|
77
|
+
}
|
|
78
|
+
const wrappedOnSubmit = userOnSubmit ? (async (props) => {
|
|
79
|
+
try {
|
|
80
|
+
await userOnSubmit(props);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ValidationError) {
|
|
83
|
+
applyValidationError(props.formApi, error);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}) : void 0;
|
|
89
|
+
const requiredFields = useMemo(() => schema && requiredIndicator ? getRequiredPaths(schema) : /* @__PURE__ */ new Set(), [schema, requiredIndicator]);
|
|
90
|
+
const schemaDefaults = useMemo(() => schema ? extractZodDefaults(schema) : void 0, [schema]);
|
|
91
|
+
const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, userDefaultValues) : userDefaultValues, [schemaDefaults, userDefaultValues]);
|
|
92
|
+
const rebased = useRebasedDefaultValues(mergedDefaultValues);
|
|
93
|
+
const listeners = withMountListener(rest.listeners, rebased.onMount);
|
|
94
|
+
const form = useAppForm({
|
|
95
|
+
...rest,
|
|
96
|
+
listeners,
|
|
97
|
+
...rebased.defaultValues === void 0 ? {} : { defaultValues: rebased.defaultValues },
|
|
98
|
+
...wrappedOnSubmit ? { onSubmit: wrappedOnSubmit } : {},
|
|
99
|
+
...resolvedValidators ? { validators: resolvedValidators } : {},
|
|
100
|
+
...resolvedValidationLogic ? { validationLogic: resolvedValidationLogic } : {}
|
|
101
|
+
});
|
|
102
|
+
setFormZenoState(form, {
|
|
103
|
+
hideFieldErrors,
|
|
104
|
+
requiredFields,
|
|
105
|
+
requiredIndicator,
|
|
106
|
+
...schemaMode === void 0 ? {} : { validation: schemaMode }
|
|
107
|
+
});
|
|
108
|
+
useUnsavedChangesWarning(form, unsavedChangesWarning);
|
|
109
|
+
const fields = useAppFields(form);
|
|
110
|
+
return useMemo(() => Object.assign(form, fields, config.formComponents), [form, fields]);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
useAppFields,
|
|
114
|
+
useAppForm,
|
|
115
|
+
useForm,
|
|
116
|
+
withFieldGroup,
|
|
117
|
+
withForm
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
export { createZenoForm };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ComponentProps, ReactNode } from "react";
|
|
2
|
+
//#region src/form-element.d.ts
|
|
3
|
+
type FormProviderProps = {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
form: {
|
|
6
|
+
handleSubmit: () => unknown;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
declare function FormProvider({ children, form }: FormProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
type FormProps = Omit<ComponentProps<"form">, "onSubmit">;
|
|
11
|
+
declare function Form({ children, className, ...props }: FormProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { Form, FormProvider };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { FormProvider as FormProvider$1, useFormContext } from "./lib/contexts.mjs";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/form-element.tsx
|
|
5
|
+
function FormProvider({ children, form }) {
|
|
6
|
+
return /* @__PURE__ */ jsx(FormProvider$1, {
|
|
7
|
+
value: form,
|
|
8
|
+
children
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
function Form({ children, className, ...props }) {
|
|
12
|
+
const form = useFormContext();
|
|
13
|
+
return /* @__PURE__ */ jsx("form", {
|
|
14
|
+
className,
|
|
15
|
+
noValidate: true,
|
|
16
|
+
onSubmit: async (event) => {
|
|
17
|
+
event.preventDefault();
|
|
18
|
+
event.stopPropagation();
|
|
19
|
+
const node = event.currentTarget;
|
|
20
|
+
await Promise.resolve(form.handleSubmit()).catch(() => void 0);
|
|
21
|
+
if (!form.state.isValid) {
|
|
22
|
+
const invalid = node.querySelector("[aria-invalid=\"true\"]");
|
|
23
|
+
(invalid ? [invalid, ...invalid.querySelectorAll("*")] : []).find((el) => el.tabIndex >= 0)?.focus();
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
...props,
|
|
27
|
+
children
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { Form, FormProvider };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ValidationMode } from "./lib/validation-modes.mjs";
|
|
2
|
+
import { createZenoForm } from "./create-zeno-form.mjs";
|
|
3
|
+
import { Form, FormProvider } from "./form-element.mjs";
|
|
4
|
+
import { ActionError, ActionIssue, ActionResult, toActionError, toFieldName } from "./lib/action-result.mjs";
|
|
5
|
+
import { FieldMessage, ValidationError } from "./lib/validation-error.mjs";
|
|
6
|
+
import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
7
|
+
import { useFieldContext, useFormContext } from "./lib/contexts.mjs";
|
|
8
|
+
import { SubmitActionOptions, SubmitActionSchema, applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
|
+
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
11
|
+
export { type ActionError, type ActionIssue, type ActionResult, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsInvalid };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
2
|
+
import { useFieldContext, useFormContext } from "./lib/contexts.mjs";
|
|
3
|
+
import { ValidationError } from "./lib/validation-error.mjs";
|
|
4
|
+
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
5
|
+
import { createZenoForm } from "./create-zeno-form.mjs";
|
|
6
|
+
import { Form, FormProvider } from "./form-element.mjs";
|
|
7
|
+
import { toActionError, toFieldName } from "./lib/action-result.mjs";
|
|
8
|
+
import { applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
|
+
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsInvalid };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/lib/action-result.d.ts
|
|
2
|
+
type ActionError = {
|
|
3
|
+
readonly fieldErrors: Readonly<Record<string, readonly string[]>>;
|
|
4
|
+
readonly formErrors: readonly string[];
|
|
5
|
+
};
|
|
6
|
+
type ActionResult<TData> = {
|
|
7
|
+
readonly ok: true;
|
|
8
|
+
readonly data: TData;
|
|
9
|
+
} | {
|
|
10
|
+
readonly ok: false;
|
|
11
|
+
readonly error: ActionError;
|
|
12
|
+
};
|
|
13
|
+
type ActionIssue = {
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly path?: ReadonlyArray<PropertyKey | {
|
|
16
|
+
readonly key: PropertyKey;
|
|
17
|
+
}> | undefined;
|
|
18
|
+
};
|
|
19
|
+
declare function toFieldName(path: ActionIssue["path"]): string;
|
|
20
|
+
declare function toActionError(issues: readonly ActionIssue[]): ActionError;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { type ActionError, type ActionIssue, type ActionResult, toActionError, toFieldName };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/lib/action-result.ts
|
|
2
|
+
function toFieldName(path) {
|
|
3
|
+
let name = "";
|
|
4
|
+
for (const segment of path ?? []) {
|
|
5
|
+
const key = typeof segment === "object" ? segment.key : segment;
|
|
6
|
+
if (typeof key === "number") name += `[${key}]`;
|
|
7
|
+
else {
|
|
8
|
+
const part = typeof key === "symbol" ? key.description ?? "" : key;
|
|
9
|
+
name += name === "" ? part : `.${part}`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
function toActionError(issues) {
|
|
15
|
+
const fieldErrors = {};
|
|
16
|
+
const formErrors = [];
|
|
17
|
+
for (const issue of issues) {
|
|
18
|
+
const name = toFieldName(issue.path);
|
|
19
|
+
if (name === "") formErrors.push(issue.message);
|
|
20
|
+
else {
|
|
21
|
+
const messages = fieldErrors[name] ?? [];
|
|
22
|
+
messages.push(issue.message);
|
|
23
|
+
fieldErrors[name] = messages;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
fieldErrors,
|
|
28
|
+
formErrors
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { toActionError, toFieldName };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ValidationError } from "./validation-error.mjs";
|
|
2
|
+
import { AnyFormApi } from "@tanstack/react-form";
|
|
3
|
+
//#region src/lib/apply-validation-error.d.ts
|
|
4
|
+
declare function applyValidationError(formApi: AnyFormApi, error: ValidationError): void;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { applyValidationError };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/lib/apply-validation-error.ts
|
|
2
|
+
function clearWhenEdited(formApi, name, entry) {
|
|
3
|
+
const rejected = formApi.getFieldValue(name);
|
|
4
|
+
const subscription = formApi.store.subscribe(() => {
|
|
5
|
+
if (formApi.getFieldMeta(name)?.errorMap?.onChange !== entry) {
|
|
6
|
+
subscription.unsubscribe();
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (Object.is(formApi.getFieldValue(name), rejected)) return;
|
|
10
|
+
subscription.unsubscribe();
|
|
11
|
+
formApi.setFieldMeta(name, (prev) => ({
|
|
12
|
+
...prev,
|
|
13
|
+
errorMap: {
|
|
14
|
+
...prev.errorMap,
|
|
15
|
+
onChange: void 0
|
|
16
|
+
}
|
|
17
|
+
}));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function applyValidationError(formApi, error) {
|
|
21
|
+
for (const [name, message] of Object.entries(error.fields)) {
|
|
22
|
+
const errors = (Array.isArray(message) ? message : [message]).map((m) => ({ message: m }));
|
|
23
|
+
const entry = errors.length === 1 ? errors[0] : errors;
|
|
24
|
+
formApi.setFieldMeta(name, (prev) => ({
|
|
25
|
+
...prev,
|
|
26
|
+
errorMap: {
|
|
27
|
+
...prev.errorMap,
|
|
28
|
+
onChange: entry
|
|
29
|
+
},
|
|
30
|
+
errors,
|
|
31
|
+
isValid: false
|
|
32
|
+
}));
|
|
33
|
+
clearWhenEdited(formApi, name, entry);
|
|
34
|
+
}
|
|
35
|
+
if (error.formError) formApi.setErrorMap({ onSubmit: {
|
|
36
|
+
fields: {},
|
|
37
|
+
form: error.formError
|
|
38
|
+
} });
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { applyValidationError };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
//#region src/lib/contexts.d.ts
|
|
2
|
+
declare const fieldContext: import("react").Context<import("@tanstack/react-form").AnyFieldApi>, formContext: import("react").Context<import("@tanstack/react-form").AnyFormApi>, useFieldContext: <TData>() => import("@tanstack/react-form").FieldApi<any, string, TData, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>, useFormContext: () => import("@tanstack/react-form").ReactFormExtendedApi<Record<string, never>, any, any, any, any, any, any, any, any, any, any, any>;
|
|
3
|
+
declare const FormProvider: import("react").Provider<import("@tanstack/react-form").AnyFormApi>;
|
|
4
|
+
//#endregion
|
|
5
|
+
export { FormProvider, fieldContext, formContext, useFieldContext, useFormContext };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createFormHookContexts } from "@tanstack/react-form";
|
|
3
|
+
//#region src/lib/contexts.ts
|
|
4
|
+
const { fieldContext, formContext, useFieldContext, useFormContext } = createFormHookContexts();
|
|
5
|
+
const FormProvider = formContext.Provider;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { FormProvider, fieldContext, formContext, useFieldContext, useFormContext };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//#region src/lib/formatted-number.d.ts
|
|
2
|
+
type NumberSeparators = {
|
|
3
|
+
/** Thousands separator, e.g. `’` for `de-CH`, `,` for `en-US`. */
|
|
4
|
+
group: string;
|
|
5
|
+
/** Decimal separator, e.g. `.` for `de-CH`, `,` for `de-DE`. */
|
|
6
|
+
decimal: string;
|
|
7
|
+
};
|
|
8
|
+
type FormatNumberOptions = {
|
|
9
|
+
locale?: string;
|
|
10
|
+
/** Maximum digits after the decimal separator. `0` means integers only. */
|
|
11
|
+
maximumFractionDigits: number;
|
|
12
|
+
/** Minimum digits after the decimal separator (pads with zeros). */
|
|
13
|
+
minimumFractionDigits?: number;
|
|
14
|
+
/** Insert group (thousands) separators. Defaults to `true`. */
|
|
15
|
+
useGrouping?: boolean;
|
|
16
|
+
};
|
|
17
|
+
type TypedNumber = {
|
|
18
|
+
/** The re-formatted text to show in the input. */
|
|
19
|
+
text: string;
|
|
20
|
+
/** The parsed number, or `null` when the text holds no digits. */
|
|
21
|
+
value: number | null;
|
|
22
|
+
};
|
|
23
|
+
type ParseTypedOptions = {
|
|
24
|
+
locale?: string;
|
|
25
|
+
maximumFractionDigits: number;
|
|
26
|
+
useGrouping?: boolean;
|
|
27
|
+
allowNegative?: boolean;
|
|
28
|
+
/** Drop integer digits beyond this count (e.g. `4` for a year). */
|
|
29
|
+
maxIntegerDigits?: number;
|
|
30
|
+
};
|
|
31
|
+
declare function getNumberSeparators(locale?: string): NumberSeparators;
|
|
32
|
+
/** Format a number for display (on blur, on reset, on first render). */
|
|
33
|
+
declare function formatNumber(value: number | null | undefined, { locale, maximumFractionDigits, minimumFractionDigits, useGrouping }: FormatNumberOptions): string;
|
|
34
|
+
/**
|
|
35
|
+
* Re-format what the user is typing. Only the locale's decimal separator
|
|
36
|
+
* starts the fraction; every other non-digit is dropped and group separators
|
|
37
|
+
* are re-inserted. Keeps a trailing decimal separator (`"12."`) so typing
|
|
38
|
+
* isn't fought, and truncates fraction digits beyond `maximumFractionDigits`.
|
|
39
|
+
*/
|
|
40
|
+
declare function parseTypedNumber(raw: string, { locale, maximumFractionDigits, useGrouping, allowNegative, maxIntegerDigits }: ParseTypedOptions): TypedNumber;
|
|
41
|
+
/**
|
|
42
|
+
* Parse free-form text (pasted, or a value from another system) into a
|
|
43
|
+
* number. Tolerates currency codes and symbols, any whitespace or apostrophe
|
|
44
|
+
* as grouping, `.` or `,` as the decimal separator, and `−`/`-` for negatives.
|
|
45
|
+
* Returns `null` when no digits are found.
|
|
46
|
+
*/
|
|
47
|
+
declare function parseLocaleNumber(text: string, locale?: string): number | null;
|
|
48
|
+
/**
|
|
49
|
+
* Where the caret should land in `next` after the user edited `raw` with the
|
|
50
|
+
* caret at `caret`.
|
|
51
|
+
*/
|
|
52
|
+
declare function mapCaret(raw: string, caret: number, next: string, locale?: string): number;
|
|
53
|
+
/** Round to `digits` decimals without the `1.005 → 1.00` float surprise. */
|
|
54
|
+
declare function roundTo(value: number, digits: number): number;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { type FormatNumberOptions, type NumberSeparators, type ParseTypedOptions, type TypedNumber, formatNumber, getNumberSeparators, mapCaret, parseLocaleNumber, parseTypedNumber, roundTo };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//#region src/lib/formatted-number.ts
|
|
2
|
+
const separatorCache = /* @__PURE__ */ new Map();
|
|
3
|
+
const MINUS_SIGNS = /[-−–]/;
|
|
4
|
+
const LEADING_MINUS = /^[^\d]*[-−–]/;
|
|
5
|
+
const NON_DIGIT = /\D/g;
|
|
6
|
+
const LEADING_ZEROS = /^0+(?=\d)/;
|
|
7
|
+
const DIGIT = /\d/;
|
|
8
|
+
const DIGITS = /\d/g;
|
|
9
|
+
const PARENTHESIZED = /^\s*\(.*\)\s*$/;
|
|
10
|
+
const NOT_NUMERIC = /[^\d.,]/g;
|
|
11
|
+
const UNICODE_MINUS = /\u2212/g;
|
|
12
|
+
function getNumberSeparators(locale) {
|
|
13
|
+
const key = locale ?? "";
|
|
14
|
+
const cached = separatorCache.get(key);
|
|
15
|
+
if (cached) return cached;
|
|
16
|
+
const parts = new Intl.NumberFormat(locale, { numberingSystem: "latn" }).formatToParts(1234567.5);
|
|
17
|
+
const separators = {
|
|
18
|
+
decimal: parts.find((p) => p.type === "decimal")?.value ?? ".",
|
|
19
|
+
group: parts.find((p) => p.type === "group")?.value ?? ","
|
|
20
|
+
};
|
|
21
|
+
separatorCache.set(key, separators);
|
|
22
|
+
return separators;
|
|
23
|
+
}
|
|
24
|
+
function normalizeMinus(text) {
|
|
25
|
+
return text.replace(UNICODE_MINUS, "-");
|
|
26
|
+
}
|
|
27
|
+
/** Format a number for display (on blur, on reset, on first render). */
|
|
28
|
+
function formatNumber(value, { locale, maximumFractionDigits, minimumFractionDigits = 0, useGrouping = true }) {
|
|
29
|
+
if (value == null || !Number.isFinite(value)) return "";
|
|
30
|
+
return normalizeMinus(new Intl.NumberFormat(locale, {
|
|
31
|
+
maximumFractionDigits,
|
|
32
|
+
minimumFractionDigits: Math.min(minimumFractionDigits, maximumFractionDigits),
|
|
33
|
+
numberingSystem: "latn",
|
|
34
|
+
useGrouping
|
|
35
|
+
}).format(value));
|
|
36
|
+
}
|
|
37
|
+
function groupIntegerDigits(digits, locale, useGrouping) {
|
|
38
|
+
if (!useGrouping || digits.length <= 3) return digits;
|
|
39
|
+
return new Intl.NumberFormat(locale, {
|
|
40
|
+
numberingSystem: "latn",
|
|
41
|
+
useGrouping: true
|
|
42
|
+
}).format(BigInt(digits));
|
|
43
|
+
}
|
|
44
|
+
function toNumber(negative, intDigits, fracDigits) {
|
|
45
|
+
if (intDigits === "" && fracDigits === "") return null;
|
|
46
|
+
const value = Number(`${intDigits || "0"}.${fracDigits || "0"}`);
|
|
47
|
+
return negative && value !== 0 ? -value : value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Re-format what the user is typing. Only the locale's decimal separator
|
|
51
|
+
* starts the fraction; every other non-digit is dropped and group separators
|
|
52
|
+
* are re-inserted. Keeps a trailing decimal separator (`"12."`) so typing
|
|
53
|
+
* isn't fought, and truncates fraction digits beyond `maximumFractionDigits`.
|
|
54
|
+
*/
|
|
55
|
+
function parseTypedNumber(raw, { locale, maximumFractionDigits, useGrouping = true, allowNegative = true, maxIntegerDigits }) {
|
|
56
|
+
const { decimal } = getNumberSeparators(locale);
|
|
57
|
+
const negative = allowNegative && LEADING_MINUS.test(raw);
|
|
58
|
+
const decimalIndex = maximumFractionDigits > 0 ? raw.indexOf(decimal) : -1;
|
|
59
|
+
const intRaw = decimalIndex === -1 ? raw : raw.slice(0, decimalIndex);
|
|
60
|
+
const fracRaw = decimalIndex === -1 ? "" : raw.slice(decimalIndex + 1);
|
|
61
|
+
let intDigits = intRaw.replace(NON_DIGIT, "").replace(LEADING_ZEROS, "");
|
|
62
|
+
if (maxIntegerDigits !== void 0) intDigits = intDigits.slice(0, maxIntegerDigits);
|
|
63
|
+
const fracDigits = fracRaw.replace(NON_DIGIT, "").slice(0, maximumFractionDigits);
|
|
64
|
+
const hasDecimal = decimalIndex !== -1;
|
|
65
|
+
return {
|
|
66
|
+
text: `${negative ? "-" : ""}${intDigits === "" && hasDecimal ? "0" : groupIntegerDigits(intDigits, locale, useGrouping)}${hasDecimal ? `${decimal}${fracDigits}` : ""}`,
|
|
67
|
+
value: toNumber(negative, intDigits, fracDigits)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function detectDecimalSeparator(text, localeDecimal) {
|
|
71
|
+
const lastDot = text.lastIndexOf(".");
|
|
72
|
+
const lastComma = text.lastIndexOf(",");
|
|
73
|
+
if (lastDot !== -1 && lastComma !== -1) return lastDot > lastComma ? "." : ",";
|
|
74
|
+
let candidate = null;
|
|
75
|
+
if (lastDot !== -1) candidate = ".";
|
|
76
|
+
else if (lastComma !== -1) candidate = ",";
|
|
77
|
+
if (candidate === null) return null;
|
|
78
|
+
if (text.split(candidate).length - 1 > 1) return null;
|
|
79
|
+
if (candidate === localeDecimal) return candidate;
|
|
80
|
+
return (text.slice(text.lastIndexOf(candidate) + 1).match(DIGITS)?.length ?? 0) === 3 ? null : candidate;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse free-form text (pasted, or a value from another system) into a
|
|
84
|
+
* number. Tolerates currency codes and symbols, any whitespace or apostrophe
|
|
85
|
+
* as grouping, `.` or `,` as the decimal separator, and `−`/`-` for negatives.
|
|
86
|
+
* Returns `null` when no digits are found.
|
|
87
|
+
*/
|
|
88
|
+
function parseLocaleNumber(text, locale) {
|
|
89
|
+
if (!DIGIT.test(text)) return null;
|
|
90
|
+
const { decimal } = getNumberSeparators(locale);
|
|
91
|
+
const negative = LEADING_MINUS.test(text) || PARENTHESIZED.test(text);
|
|
92
|
+
const kept = text.replace(NOT_NUMERIC, "");
|
|
93
|
+
const decimalSeparator = detectDecimalSeparator(kept, decimal);
|
|
94
|
+
let intPart = kept;
|
|
95
|
+
let fracPart = "";
|
|
96
|
+
if (decimalSeparator) {
|
|
97
|
+
const index = kept.lastIndexOf(decimalSeparator);
|
|
98
|
+
intPart = kept.slice(0, index);
|
|
99
|
+
fracPart = kept.slice(index + 1);
|
|
100
|
+
}
|
|
101
|
+
return toNumber(negative, intPart.replace(NON_DIGIT, ""), fracPart.replace(NON_DIGIT, ""));
|
|
102
|
+
}
|
|
103
|
+
function countSignificant(text, end, decimal) {
|
|
104
|
+
let count = 0;
|
|
105
|
+
for (let i = 0; i < end && i < text.length; i++) {
|
|
106
|
+
const char = text[i];
|
|
107
|
+
if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) count++;
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
function caretForSignificant(text, significant, decimal) {
|
|
112
|
+
if (significant <= 0) return 0;
|
|
113
|
+
let count = 0;
|
|
114
|
+
for (let i = 0; i < text.length; i++) {
|
|
115
|
+
const char = text[i];
|
|
116
|
+
if (DIGIT.test(char) || char === decimal || char === "-") {
|
|
117
|
+
count++;
|
|
118
|
+
if (count === significant) return i + 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return text.length;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Where the caret should land in `next` after the user edited `raw` with the
|
|
125
|
+
* caret at `caret`.
|
|
126
|
+
*/
|
|
127
|
+
function mapCaret(raw, caret, next, locale) {
|
|
128
|
+
const { decimal } = getNumberSeparators(locale);
|
|
129
|
+
return caretForSignificant(next, countSignificant(raw, caret, decimal), decimal);
|
|
130
|
+
}
|
|
131
|
+
/** Round to `digits` decimals without the `1.005 → 1.00` float surprise. */
|
|
132
|
+
function roundTo(value, digits) {
|
|
133
|
+
const factor = 10 ** digits;
|
|
134
|
+
const rounded = Math.round((Math.abs(value) + Number.EPSILON) * factor);
|
|
135
|
+
return Math.sign(value) * rounded / factor;
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
export { formatNumber, getNumberSeparators, mapCaret, parseLocaleNumber, parseTypedNumber, roundTo };
|