@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,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,5 @@
1
+ import { ValidationLogicFn } from "@tanstack/react-form";
2
+ //#region src/lib/validation-logic.d.ts
3
+ declare const blurThenChangeLogic: ValidationLogicFn;
4
+ //#endregion
5
+ export { blurThenChangeLogic };
@@ -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";
@@ -0,0 +1,2 @@
1
+ export * from "@tanstack/react-form";
2
+ export {};
package/package.json CHANGED
@@ -1,13 +1,28 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
+ "type": "module",
4
5
  "exports": {
5
- ".": "./src/index.ts",
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
- "./lib/*": "./src/lib/*.ts",
8
- "./tanstack": "./src/tanstack.ts"
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": "4.1.11",
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"
@@ -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 }
@@ -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 }