@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,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { evaluate } from "@tanstack/react-form";
|
|
3
|
+
import { useCallback, useRef } from "react";
|
|
4
|
+
//#region src/lib/use-rebased-default-values.ts
|
|
5
|
+
function useRebasedDefaultValues(callerDefaults) {
|
|
6
|
+
const liveForm = useRef(null);
|
|
7
|
+
const lastCallerDefaults = useRef(callerDefaults);
|
|
8
|
+
let defaultValues = callerDefaults;
|
|
9
|
+
if (evaluate(lastCallerDefaults.current, callerDefaults)) {
|
|
10
|
+
if (liveForm.current) defaultValues = liveForm.current.options.defaultValues;
|
|
11
|
+
} else lastCallerDefaults.current = callerDefaults;
|
|
12
|
+
const onMount = useCallback((props) => {
|
|
13
|
+
liveForm.current = props.formApi;
|
|
14
|
+
}, []);
|
|
15
|
+
return {
|
|
16
|
+
defaultValues,
|
|
17
|
+
onMount
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
export { useRebasedDefaultValues };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
|
+
//#region src/lib/use-unsaved-changes-warning.d.ts
|
|
3
|
+
type UnsavedChangesMode = "if-changed" | "if-touched";
|
|
4
|
+
declare function useUnsavedChangesWarning(form: AnyFormApi, enabled: boolean | UnsavedChangesMode): void;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { type UnsavedChangesMode, useUnsavedChangesWarning };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useSelector } from "@tanstack/react-form";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
//#region src/lib/use-unsaved-changes-warning.ts
|
|
5
|
+
function useUnsavedChangesWarning(form, enabled) {
|
|
6
|
+
let mode;
|
|
7
|
+
if (enabled === false) mode = null;
|
|
8
|
+
else if (enabled === true) mode = "if-changed";
|
|
9
|
+
else mode = enabled;
|
|
10
|
+
const dirty = useSelector(form.store, (state) => mode === "if-touched" ? state.isDirty : !state.isDefaultValue);
|
|
11
|
+
const isSubmitting = useSelector(form.store, (state) => state.isSubmitting);
|
|
12
|
+
const active = mode !== null && dirty && !isSubmitting;
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!active) return;
|
|
15
|
+
const handler = (event) => {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
event.returnValue = "";
|
|
18
|
+
};
|
|
19
|
+
window.addEventListener("beforeunload", handler);
|
|
20
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
21
|
+
}, [active]);
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { useUnsavedChangesWarning };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/lib/validation-error.d.ts
|
|
2
|
+
type FieldMessage = string | readonly string[];
|
|
3
|
+
declare class ValidationError extends Error {
|
|
4
|
+
readonly fields: Readonly<Record<string, FieldMessage>>;
|
|
5
|
+
readonly formError?: string;
|
|
6
|
+
constructor(fields: Readonly<Record<string, FieldMessage>>, options?: {
|
|
7
|
+
formError?: string;
|
|
8
|
+
message?: string;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type FieldMessage, ValidationError };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/lib/validation-error.ts
|
|
2
|
+
var ValidationError = class extends Error {
|
|
3
|
+
constructor(fields, options = {}) {
|
|
4
|
+
super(options.message ?? "Validation failed");
|
|
5
|
+
this.name = "ValidationError";
|
|
6
|
+
this.fields = fields;
|
|
7
|
+
this.formError = options.formError;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { ValidationError };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
//#region src/lib/validation-logic.ts
|
|
3
|
+
function pickLiveValidator(validators, isAsync) {
|
|
4
|
+
const fn = isAsync ? validators?.onChangeAsync ?? validators?.onBlurAsync : validators?.onChange ?? validators?.onBlur;
|
|
5
|
+
return fn ? {
|
|
6
|
+
cause: "change",
|
|
7
|
+
fn
|
|
8
|
+
} : void 0;
|
|
9
|
+
}
|
|
10
|
+
function shouldRunFormChange(form) {
|
|
11
|
+
if (form.state.submissionAttempts > 0) return true;
|
|
12
|
+
return Object.values(form.state.fieldMeta).some((meta) => meta?.isBlurred === true);
|
|
13
|
+
}
|
|
14
|
+
function fieldLevelLogic(props) {
|
|
15
|
+
const { event, validators, form } = props;
|
|
16
|
+
const run = props.runValidation;
|
|
17
|
+
if (!validators) return run({
|
|
18
|
+
form,
|
|
19
|
+
validators: []
|
|
20
|
+
});
|
|
21
|
+
const isAsync = event.async;
|
|
22
|
+
if (event.type === "mount") return run({
|
|
23
|
+
form,
|
|
24
|
+
validators: isAsync ? [] : [{
|
|
25
|
+
cause: "mount",
|
|
26
|
+
fn: validators.onMount
|
|
27
|
+
}]
|
|
28
|
+
});
|
|
29
|
+
if (event.type === "change") return run({
|
|
30
|
+
form,
|
|
31
|
+
validators: [{
|
|
32
|
+
cause: "change",
|
|
33
|
+
fn: isAsync ? validators.onChangeAsync : validators.onChange
|
|
34
|
+
}]
|
|
35
|
+
});
|
|
36
|
+
if (event.type === "blur") return run({
|
|
37
|
+
form,
|
|
38
|
+
validators: [{
|
|
39
|
+
cause: "blur",
|
|
40
|
+
fn: isAsync ? validators.onBlurAsync : validators.onBlur
|
|
41
|
+
}]
|
|
42
|
+
});
|
|
43
|
+
if (event.type === "submit") return run({
|
|
44
|
+
form,
|
|
45
|
+
validators: [
|
|
46
|
+
{
|
|
47
|
+
cause: "change",
|
|
48
|
+
fn: isAsync ? validators.onChangeAsync : validators.onChange
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
cause: "blur",
|
|
52
|
+
fn: isAsync ? validators.onBlurAsync : validators.onBlur
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
cause: "submit",
|
|
56
|
+
fn: isAsync ? validators.onSubmitAsync : validators.onSubmit
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
return run({
|
|
61
|
+
form,
|
|
62
|
+
validators: []
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function buildSubmitValidators(validators, isAsync, liveValidator) {
|
|
66
|
+
return [
|
|
67
|
+
...liveValidator ? [liveValidator] : [],
|
|
68
|
+
{
|
|
69
|
+
cause: "submit",
|
|
70
|
+
fn: isAsync ? validators.onSubmitAsync : validators.onSubmit
|
|
71
|
+
},
|
|
72
|
+
...isAsync ? [] : [{
|
|
73
|
+
cause: "server",
|
|
74
|
+
fn: () => void 0
|
|
75
|
+
}]
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
function blurThenChangeLogicImpl(props) {
|
|
79
|
+
if (props.event.fieldName !== void 0) return fieldLevelLogic(props);
|
|
80
|
+
const { event, validators, form } = props;
|
|
81
|
+
const run = props.runValidation;
|
|
82
|
+
if (!validators) return run({
|
|
83
|
+
form,
|
|
84
|
+
validators: []
|
|
85
|
+
});
|
|
86
|
+
const isAsync = event.async;
|
|
87
|
+
const liveValidator = pickLiveValidator(validators, isAsync);
|
|
88
|
+
if (event.type === "mount") return run({
|
|
89
|
+
form,
|
|
90
|
+
validators: isAsync ? [] : [{
|
|
91
|
+
cause: "mount",
|
|
92
|
+
fn: validators.onMount
|
|
93
|
+
}]
|
|
94
|
+
});
|
|
95
|
+
if (event.type === "blur") return run({
|
|
96
|
+
form,
|
|
97
|
+
validators: liveValidator ? [liveValidator] : []
|
|
98
|
+
});
|
|
99
|
+
if (event.type === "change") return run({
|
|
100
|
+
form,
|
|
101
|
+
validators: liveValidator && shouldRunFormChange(form) ? [liveValidator] : []
|
|
102
|
+
});
|
|
103
|
+
if (event.type === "submit") return run({
|
|
104
|
+
form,
|
|
105
|
+
validators: buildSubmitValidators(validators, isAsync, liveValidator)
|
|
106
|
+
});
|
|
107
|
+
return run({
|
|
108
|
+
form,
|
|
109
|
+
validators: []
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const blurThenChangeLogic = blurThenChangeLogicImpl;
|
|
113
|
+
//#endregion
|
|
114
|
+
export { blurThenChangeLogic };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
|
+
//#region src/lib/validation-modes.d.ts
|
|
3
|
+
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change";
|
|
4
|
+
declare const DEFAULT_VALIDATION_MODE: ValidationMode;
|
|
5
|
+
type ZenoFormState = {
|
|
6
|
+
validation: ValidationMode;
|
|
7
|
+
hideFieldErrors: boolean;
|
|
8
|
+
requiredIndicator: boolean;
|
|
9
|
+
requiredFields: ReadonlySet<string>;
|
|
10
|
+
};
|
|
11
|
+
declare function setFormZenoState(form: AnyFormApi, state: Partial<ZenoFormState>): void;
|
|
12
|
+
declare function getFormValidationMode(form: AnyFormApi): ValidationMode;
|
|
13
|
+
declare function getFormHideFieldErrors(form: AnyFormApi): boolean;
|
|
14
|
+
declare function isFieldRequired(form: AnyFormApi, name: string): boolean;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { DEFAULT_VALIDATION_MODE, type ValidationMode, getFormHideFieldErrors, getFormValidationMode, isFieldRequired, setFormZenoState };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { toRequiredPathKey } from "./schema-required.mjs";
|
|
3
|
+
//#region src/lib/validation-modes.ts
|
|
4
|
+
const DEFAULT_VALIDATION_MODE = "blur-then-change";
|
|
5
|
+
const DEFAULT_FORM_STATE = {
|
|
6
|
+
hideFieldErrors: false,
|
|
7
|
+
requiredFields: /* @__PURE__ */ new Set(),
|
|
8
|
+
requiredIndicator: true,
|
|
9
|
+
validation: DEFAULT_VALIDATION_MODE
|
|
10
|
+
};
|
|
11
|
+
const formZenoState = /* @__PURE__ */ new WeakMap();
|
|
12
|
+
function stateKey(form) {
|
|
13
|
+
return form.store;
|
|
14
|
+
}
|
|
15
|
+
function setFormZenoState(form, state) {
|
|
16
|
+
const key = stateKey(form);
|
|
17
|
+
const current = formZenoState.get(key) ?? DEFAULT_FORM_STATE;
|
|
18
|
+
formZenoState.set(key, {
|
|
19
|
+
...current,
|
|
20
|
+
...state
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function getFormZenoState(form) {
|
|
24
|
+
return formZenoState.get(stateKey(form)) ?? DEFAULT_FORM_STATE;
|
|
25
|
+
}
|
|
26
|
+
function getFormValidationMode(form) {
|
|
27
|
+
return getFormZenoState(form).validation;
|
|
28
|
+
}
|
|
29
|
+
function getFormHideFieldErrors(form) {
|
|
30
|
+
return getFormZenoState(form).hideFieldErrors;
|
|
31
|
+
}
|
|
32
|
+
function isFieldRequired(form, name) {
|
|
33
|
+
const state = getFormZenoState(form);
|
|
34
|
+
if (!state.requiredIndicator) return false;
|
|
35
|
+
return state.requiredFields.has(toRequiredPathKey(name));
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
export { DEFAULT_VALIDATION_MODE, getFormHideFieldErrors, getFormValidationMode, isFieldRequired, setFormZenoState };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@tanstack/react-form";
|
package/package.json
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeno-lib/forms",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"exports": {
|
|
5
|
-
".":
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"import": "./dist/index.mjs",
|
|
9
|
+
"default": "./dist/index.mjs"
|
|
10
|
+
},
|
|
6
11
|
"./create-form": "./src/create-form.tsx",
|
|
7
|
-
"./
|
|
8
|
-
"./
|
|
12
|
+
"./form-dialog": "./src/form-dialog.tsx",
|
|
13
|
+
"./lib/*": {
|
|
14
|
+
"types": "./dist/lib/*.d.mts",
|
|
15
|
+
"import": "./dist/lib/*.mjs",
|
|
16
|
+
"default": "./dist/lib/*.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./tanstack": {
|
|
19
|
+
"types": "./dist/tanstack.d.mts",
|
|
20
|
+
"import": "./dist/tanstack.mjs",
|
|
21
|
+
"default": "./dist/tanstack.mjs"
|
|
22
|
+
}
|
|
9
23
|
},
|
|
10
24
|
"files": [
|
|
25
|
+
"dist",
|
|
11
26
|
"src"
|
|
12
27
|
],
|
|
13
28
|
"publishConfig": {
|
|
@@ -41,15 +56,18 @@
|
|
|
41
56
|
"@types/node": "25.6.0",
|
|
42
57
|
"@types/react": "19.2.14",
|
|
43
58
|
"@types/react-dom": "19.2.3",
|
|
59
|
+
"lucide-react": "^1.23.0",
|
|
60
|
+
"tsdown": "^0.22.14",
|
|
44
61
|
"typescript": "7.0.2",
|
|
45
62
|
"vite": "8.0.16",
|
|
46
|
-
"vitest": "
|
|
63
|
+
"vitest": "5.0.2",
|
|
47
64
|
"zod": "^4.3.6",
|
|
65
|
+
"@zeno-lib/test": "^0.1.0",
|
|
48
66
|
"@zeno-lib/typescript": "^1.1.0",
|
|
49
|
-
"@zeno-lib/ui": "^0.0.1"
|
|
50
|
-
"@zeno-lib/test": "^0.1.0"
|
|
67
|
+
"@zeno-lib/ui": "^0.0.1"
|
|
51
68
|
},
|
|
52
69
|
"scripts": {
|
|
70
|
+
"build": "tsdown",
|
|
53
71
|
"test": "vitest --run",
|
|
54
72
|
"test:watch": "vitest watch",
|
|
55
73
|
"types:check": "tsc --noEmit"
|
package/src/create-form.tsx
CHANGED
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
import { createZenoForm } from "@zeno-lib/forms"
|
|
5
5
|
|
|
6
6
|
import { CheckboxField } from "./fields/checkbox-field"
|
|
7
|
+
import { CheckboxGroupField } from "./fields/checkbox-group-field"
|
|
7
8
|
import { ComboboxField } from "./fields/combobox-field"
|
|
8
9
|
import { DatePickerField } from "./fields/date-picker-field"
|
|
9
10
|
import { EmailField } from "./fields/email-field"
|
|
10
11
|
import { InputField } from "./fields/input-field"
|
|
12
|
+
import { MoneyField } from "./fields/money-field"
|
|
13
|
+
import { MultiSelectField } from "./fields/multi-select-field"
|
|
11
14
|
import { NumberField } from "./fields/number-field"
|
|
12
15
|
import { OtpField } from "./fields/otp-field"
|
|
13
16
|
import { PasswordField } from "./fields/password-field"
|
|
17
|
+
import { PercentageField } from "./fields/percentage-field"
|
|
14
18
|
import { RadioGroupField } from "./fields/radio-group-field"
|
|
15
19
|
import { ResetButton } from "./fields/reset-button"
|
|
16
20
|
import { SelectField } from "./fields/select-field"
|
|
@@ -18,24 +22,30 @@ import { SliderField } from "./fields/slider-field"
|
|
|
18
22
|
import { SubmitButton } from "./fields/submit-button"
|
|
19
23
|
import { SwitchField } from "./fields/switch-field"
|
|
20
24
|
import { TextAreaField } from "./fields/textarea-field"
|
|
25
|
+
import { YearField } from "./fields/year-field"
|
|
21
26
|
|
|
22
27
|
// Wire the dropped-in field components into the headless factory. Edit the
|
|
23
28
|
// field files under `./fields` freely — they use your own shadcn primitives.
|
|
24
29
|
const { useAppForm, useForm, withFieldGroup, withForm } = createZenoForm({
|
|
25
30
|
fieldComponents: {
|
|
26
31
|
CheckboxField,
|
|
32
|
+
CheckboxGroupField,
|
|
27
33
|
ComboboxField,
|
|
28
34
|
DatePickerField,
|
|
29
35
|
EmailField,
|
|
30
36
|
InputField,
|
|
37
|
+
MoneyField,
|
|
38
|
+
MultiSelectField,
|
|
31
39
|
NumberField,
|
|
32
40
|
OtpField,
|
|
33
41
|
PasswordField,
|
|
42
|
+
PercentageField,
|
|
34
43
|
RadioGroupField,
|
|
35
44
|
SelectField,
|
|
36
45
|
SliderField,
|
|
37
46
|
SwitchField,
|
|
38
47
|
TextAreaField,
|
|
48
|
+
YearField,
|
|
39
49
|
},
|
|
40
50
|
formComponents: {
|
|
41
51
|
ResetButton,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import {
|
|
6
|
+
useHideFieldErrors,
|
|
7
|
+
useIsFieldRequired,
|
|
8
|
+
useIsInvalid,
|
|
9
|
+
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
+
import type { ReactNode } from "react"
|
|
11
|
+
import { Checkbox } from "@/components/ui/checkbox"
|
|
12
|
+
import {
|
|
13
|
+
Field,
|
|
14
|
+
FieldContent,
|
|
15
|
+
FieldDescription,
|
|
16
|
+
FieldError,
|
|
17
|
+
FieldGroup,
|
|
18
|
+
FieldLabel,
|
|
19
|
+
FieldLegend,
|
|
20
|
+
FieldSet,
|
|
21
|
+
} from "@/components/ui/field"
|
|
22
|
+
import { cn } from "@/lib/utils"
|
|
23
|
+
import { RequiredIndicator } from "../lib/required-indicator"
|
|
24
|
+
|
|
25
|
+
type CheckboxGroupOption<V> = {
|
|
26
|
+
value: V
|
|
27
|
+
label: ReactNode
|
|
28
|
+
description?: ReactNode
|
|
29
|
+
disabled?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type CheckboxGroupFieldProps<V = string> = {
|
|
33
|
+
/**
|
|
34
|
+
* The options. Pass plain strings or numbers (value === label), or
|
|
35
|
+
* `{ value, label, description?, disabled? }` objects. The form value is the
|
|
36
|
+
* array of checked values, in the order of `items`.
|
|
37
|
+
*/
|
|
38
|
+
items: readonly (CheckboxGroupOption<V> | V)[]
|
|
39
|
+
description?: ReactNode
|
|
40
|
+
label?: ReactNode
|
|
41
|
+
/** Stack the options (`"vertical"`, default) or wrap them in a row. */
|
|
42
|
+
orientation?: "horizontal" | "vertical"
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
className?: string
|
|
45
|
+
/** Force the required `*` indicator on or off. Defaults to schema-derived. */
|
|
46
|
+
required?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toOption<V>(item: CheckboxGroupOption<V> | V): CheckboxGroupOption<V> {
|
|
50
|
+
if (typeof item === "object" && item !== null && "value" in item) {
|
|
51
|
+
return item as CheckboxGroupOption<V>
|
|
52
|
+
}
|
|
53
|
+
return { label: String(item), value: item as V }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A set of checkboxes bound to one array value (`[]` when none checked). */
|
|
57
|
+
function CheckboxGroupField<V = string>({
|
|
58
|
+
className,
|
|
59
|
+
description,
|
|
60
|
+
disabled,
|
|
61
|
+
items,
|
|
62
|
+
label,
|
|
63
|
+
orientation = "vertical",
|
|
64
|
+
required,
|
|
65
|
+
}: CheckboxGroupFieldProps<V>) {
|
|
66
|
+
const field = useFieldContext<V[] | null | undefined>()
|
|
67
|
+
const errorId = `${field.name}-error`
|
|
68
|
+
const descriptionId = `${field.name}-description`
|
|
69
|
+
const isInvalid = useIsInvalid(field)
|
|
70
|
+
const hideErrors = useHideFieldErrors(field)
|
|
71
|
+
const showError = isInvalid && !hideErrors
|
|
72
|
+
const schemaRequired = useIsFieldRequired(field)
|
|
73
|
+
const isRequired = required ?? schemaRequired
|
|
74
|
+
|
|
75
|
+
const options = items.map(toOption)
|
|
76
|
+
const checked = field.state.value ?? []
|
|
77
|
+
|
|
78
|
+
const toggle = (value: V, on: boolean) => {
|
|
79
|
+
const next = new Set(checked)
|
|
80
|
+
if (on) {
|
|
81
|
+
next.add(value)
|
|
82
|
+
} else {
|
|
83
|
+
next.delete(value)
|
|
84
|
+
}
|
|
85
|
+
// Keep the stored order stable: the order of `items`, not click order.
|
|
86
|
+
field.handleChange(
|
|
87
|
+
options.map((option) => option.value).filter((v) => next.has(v))
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<FieldSet
|
|
93
|
+
aria-describedby={describedBy(
|
|
94
|
+
[description, descriptionId],
|
|
95
|
+
[showError, errorId]
|
|
96
|
+
)}
|
|
97
|
+
aria-invalid={isInvalid || undefined}
|
|
98
|
+
className={className}
|
|
99
|
+
data-field={field.name}
|
|
100
|
+
data-invalid={isInvalid}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
>
|
|
103
|
+
{label && (
|
|
104
|
+
<FieldLegend variant="label">
|
|
105
|
+
{label}
|
|
106
|
+
{isRequired && <RequiredIndicator />}
|
|
107
|
+
</FieldLegend>
|
|
108
|
+
)}
|
|
109
|
+
{description && (
|
|
110
|
+
<FieldDescription id={descriptionId}>{description}</FieldDescription>
|
|
111
|
+
)}
|
|
112
|
+
<FieldGroup
|
|
113
|
+
className={cn(
|
|
114
|
+
orientation === "horizontal" && "flex-row flex-wrap gap-x-6 *:w-auto"
|
|
115
|
+
)}
|
|
116
|
+
data-slot="checkbox-group"
|
|
117
|
+
>
|
|
118
|
+
{options.map((option, index) => {
|
|
119
|
+
const id = `${field.name}-${index}`
|
|
120
|
+
return (
|
|
121
|
+
<Field
|
|
122
|
+
data-invalid={isInvalid}
|
|
123
|
+
key={String(option.value)}
|
|
124
|
+
orientation="horizontal"
|
|
125
|
+
>
|
|
126
|
+
<Checkbox
|
|
127
|
+
aria-invalid={isInvalid || undefined}
|
|
128
|
+
checked={checked.includes(option.value)}
|
|
129
|
+
disabled={disabled || option.disabled}
|
|
130
|
+
id={id}
|
|
131
|
+
name={field.name}
|
|
132
|
+
onBlur={field.handleBlur}
|
|
133
|
+
onCheckedChange={(on) => toggle(option.value, on === true)}
|
|
134
|
+
value={String(option.value)}
|
|
135
|
+
/>
|
|
136
|
+
{option.description ? (
|
|
137
|
+
<FieldContent>
|
|
138
|
+
<FieldLabel className="font-normal" htmlFor={id}>
|
|
139
|
+
{option.label}
|
|
140
|
+
</FieldLabel>
|
|
141
|
+
<FieldDescription>{option.description}</FieldDescription>
|
|
142
|
+
</FieldContent>
|
|
143
|
+
) : (
|
|
144
|
+
<FieldLabel className="w-auto font-normal" htmlFor={id}>
|
|
145
|
+
{option.label}
|
|
146
|
+
</FieldLabel>
|
|
147
|
+
)}
|
|
148
|
+
</Field>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
</FieldGroup>
|
|
152
|
+
{showError && (
|
|
153
|
+
<FieldError errors={field.state.meta.errors} id={errorId} />
|
|
154
|
+
)}
|
|
155
|
+
</FieldSet>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type { CheckboxGroupFieldProps, CheckboxGroupOption }
|
|
160
|
+
export { CheckboxGroupField }
|
package/src/fields/index.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/** biome-ignore-all lint/performance/noBarrelFile: single import surface for all field components */
|
|
2
2
|
export * from "./checkbox-field"
|
|
3
|
+
export * from "./checkbox-group-field"
|
|
3
4
|
export * from "./combobox-field"
|
|
4
5
|
export * from "./date-picker-field"
|
|
5
6
|
export * from "./email-field"
|
|
6
7
|
export * from "./input-field"
|
|
8
|
+
export * from "./money-field"
|
|
9
|
+
export * from "./multi-select-field"
|
|
7
10
|
export * from "./number-field"
|
|
8
11
|
export * from "./otp-field"
|
|
9
12
|
export * from "./password-field"
|
|
13
|
+
export * from "./percentage-field"
|
|
10
14
|
export * from "./radio-group-field"
|
|
11
15
|
export * from "./reset-button"
|
|
12
16
|
export * from "./select-field"
|
|
@@ -14,3 +18,4 @@ export * from "./slider-field"
|
|
|
14
18
|
export * from "./submit-button"
|
|
15
19
|
export * from "./switch-field"
|
|
16
20
|
export * from "./textarea-field"
|
|
21
|
+
export * from "./year-field"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
4
|
+
import { useFormattedNumber } from "@zeno-lib/forms/lib/use-formatted-number"
|
|
5
|
+
import { type ReactNode, useMemo } from "react"
|
|
6
|
+
import { InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
|
|
7
|
+
import { InputField, type InputFieldProps } from "./input-field"
|
|
8
|
+
|
|
9
|
+
type MoneyFieldProps = Omit<
|
|
10
|
+
InputFieldProps,
|
|
11
|
+
"defaultValue" | "inputMode" | "onChange" | "onPaste" | "type" | "value"
|
|
12
|
+
> & {
|
|
13
|
+
/** ISO 4217 code shown as an add-on, e.g. `"CHF"`, `"EUR"`, `"USD"`. */
|
|
14
|
+
currency?: string
|
|
15
|
+
/** How the currency add-on reads. Defaults to `"symbol"`. */
|
|
16
|
+
currencyDisplay?: "code" | "name" | "narrowSymbol" | "symbol"
|
|
17
|
+
/**
|
|
18
|
+
* Which side the currency add-on sits on. Defaults to the locale's own
|
|
19
|
+
* convention (`CHF 1’234` in `de-CH`, `1.234 €` in `de-DE`).
|
|
20
|
+
*/
|
|
21
|
+
currencyPosition?: "end" | "start"
|
|
22
|
+
/**
|
|
23
|
+
* BCP 47 locale driving the group/decimal separators (`de-CH` →
|
|
24
|
+
* `1’234.50`). Defaults to the runtime locale.
|
|
25
|
+
*/
|
|
26
|
+
locale?: string
|
|
27
|
+
/**
|
|
28
|
+
* Digits allowed after the decimal separator. Defaults to the currency's
|
|
29
|
+
* minor units (2 for CHF, 0 for JPY), or 2 without a currency.
|
|
30
|
+
*/
|
|
31
|
+
fractionDigits?: number
|
|
32
|
+
/** Accept negative amounts. Defaults to `false`. */
|
|
33
|
+
allowNegative?: boolean
|
|
34
|
+
/** Clamp the amount on blur. */
|
|
35
|
+
min?: number
|
|
36
|
+
/** Clamp the amount on blur. */
|
|
37
|
+
max?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type CurrencyParts = {
|
|
41
|
+
label: string
|
|
42
|
+
position: "end" | "start"
|
|
43
|
+
fractionDigits: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getCurrencyParts(
|
|
47
|
+
locale: string | undefined,
|
|
48
|
+
currency: string,
|
|
49
|
+
currencyDisplay: NonNullable<MoneyFieldProps["currencyDisplay"]>
|
|
50
|
+
): CurrencyParts {
|
|
51
|
+
const format = new Intl.NumberFormat(locale, {
|
|
52
|
+
currency,
|
|
53
|
+
currencyDisplay,
|
|
54
|
+
style: "currency",
|
|
55
|
+
})
|
|
56
|
+
const parts = format.formatToParts(1)
|
|
57
|
+
const currencyIndex = parts.findIndex((part) => part.type === "currency")
|
|
58
|
+
const integerIndex = parts.findIndex((part) => part.type === "integer")
|
|
59
|
+
return {
|
|
60
|
+
fractionDigits: format.resolvedOptions().maximumFractionDigits ?? 2,
|
|
61
|
+
label: parts[currencyIndex]?.value ?? currency,
|
|
62
|
+
position: currencyIndex > integerIndex ? "end" : "start",
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Amount input with locale-aware thousands separators and a currency add-on.
|
|
68
|
+
* The form value is `number | null` (empty input → `null`).
|
|
69
|
+
*/
|
|
70
|
+
function MoneyField({
|
|
71
|
+
allowNegative = false,
|
|
72
|
+
children,
|
|
73
|
+
currency,
|
|
74
|
+
currencyDisplay = "symbol",
|
|
75
|
+
currencyPosition,
|
|
76
|
+
fractionDigits,
|
|
77
|
+
locale,
|
|
78
|
+
max,
|
|
79
|
+
min,
|
|
80
|
+
...props
|
|
81
|
+
}: MoneyFieldProps) {
|
|
82
|
+
const field = useFieldContext<number | null | undefined>()
|
|
83
|
+
const parts = useMemo(
|
|
84
|
+
() =>
|
|
85
|
+
currency ? getCurrencyParts(locale, currency, currencyDisplay) : null,
|
|
86
|
+
[currency, currencyDisplay, locale]
|
|
87
|
+
)
|
|
88
|
+
const digits = fractionDigits ?? parts?.fractionDigits ?? 2
|
|
89
|
+
const position = currencyPosition ?? parts?.position ?? "end"
|
|
90
|
+
|
|
91
|
+
const inputProps = useFormattedNumber({
|
|
92
|
+
allowNegative,
|
|
93
|
+
locale,
|
|
94
|
+
max,
|
|
95
|
+
maximumFractionDigits: digits,
|
|
96
|
+
min,
|
|
97
|
+
onBlur: field.handleBlur,
|
|
98
|
+
onValueChange: (next) => field.handleChange(next),
|
|
99
|
+
padFraction: true,
|
|
100
|
+
value: field.state.value,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
let addon: ReactNode = null
|
|
104
|
+
if (parts) {
|
|
105
|
+
addon = (
|
|
106
|
+
<InputGroupAddon
|
|
107
|
+
align={position === "start" ? "inline-start" : "inline-end"}
|
|
108
|
+
>
|
|
109
|
+
<InputGroupText>{parts.label}</InputGroupText>
|
|
110
|
+
</InputGroupAddon>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<InputField {...props} {...inputProps}>
|
|
116
|
+
{addon}
|
|
117
|
+
{children}
|
|
118
|
+
</InputField>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type { MoneyFieldProps }
|
|
123
|
+
export { MoneyField }
|