@startsimpli/forms 0.1.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/index.d.mts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +130 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +124 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
- package/src/__tests__/useForm.test.ts +178 -0
- package/src/components/FormError.tsx +17 -0
- package/src/components/FormField.tsx +28 -0
- package/src/components/FormRow.tsx +14 -0
- package/src/components/FormSection.tsx +22 -0
- package/src/hooks/useForm.ts +116 -0
- package/src/index.ts +14 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ZodSchema } from 'zod';
|
|
2
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
3
|
+
import React$1 from 'react';
|
|
4
|
+
|
|
5
|
+
interface UseFormOptions<T> {
|
|
6
|
+
initial?: Partial<T>;
|
|
7
|
+
}
|
|
8
|
+
interface UseFormReturn<T extends Record<string, unknown>> {
|
|
9
|
+
/** Current form values. Partial because fields may not be set yet.
|
|
10
|
+
* For the fully validated T, use the data passed to handleSubmit's onValid callback. */
|
|
11
|
+
values: Partial<T>;
|
|
12
|
+
errors: Partial<Record<keyof T, string>>;
|
|
13
|
+
isSubmitting: boolean;
|
|
14
|
+
isDirty: boolean;
|
|
15
|
+
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
|
16
|
+
setError: (field: keyof T, message: string) => void;
|
|
17
|
+
clearError: (field: keyof T) => void;
|
|
18
|
+
/** Returns a submit handler. The onValid callback receives the fully validated T from schema.safeParse. */
|
|
19
|
+
handleSubmit: (onValid: (data: T) => Promise<void>) => (e?: React.FormEvent) => Promise<void>;
|
|
20
|
+
reset: (values?: Partial<T>) => void;
|
|
21
|
+
}
|
|
22
|
+
declare function useForm<T extends Record<string, unknown>>(schema: ZodSchema<T>, options?: UseFormOptions<T>): UseFormReturn<T>;
|
|
23
|
+
|
|
24
|
+
interface FormFieldProps {
|
|
25
|
+
label: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
required?: boolean;
|
|
28
|
+
children: React$1.ReactNode;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
declare function FormField({ label, error, required, children, className, }: FormFieldProps): react_jsx_runtime.JSX.Element;
|
|
32
|
+
|
|
33
|
+
interface FormSectionProps {
|
|
34
|
+
title?: string;
|
|
35
|
+
children: React$1.ReactNode;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
declare function FormSection({ title, children, className, }: FormSectionProps): react_jsx_runtime.JSX.Element;
|
|
39
|
+
|
|
40
|
+
interface FormErrorProps {
|
|
41
|
+
message: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
declare function FormError({ message, className }: FormErrorProps): react_jsx_runtime.JSX.Element;
|
|
45
|
+
|
|
46
|
+
interface FormRowProps {
|
|
47
|
+
children: React$1.ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
declare function FormRow({ children, className }: FormRowProps): react_jsx_runtime.JSX.Element;
|
|
51
|
+
|
|
52
|
+
export { FormError, type FormErrorProps, FormField, type FormFieldProps, FormRow, type FormRowProps, FormSection, type FormSectionProps, type UseFormOptions, type UseFormReturn, useForm };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ZodSchema } from 'zod';
|
|
2
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
3
|
+
import React$1 from 'react';
|
|
4
|
+
|
|
5
|
+
interface UseFormOptions<T> {
|
|
6
|
+
initial?: Partial<T>;
|
|
7
|
+
}
|
|
8
|
+
interface UseFormReturn<T extends Record<string, unknown>> {
|
|
9
|
+
/** Current form values. Partial because fields may not be set yet.
|
|
10
|
+
* For the fully validated T, use the data passed to handleSubmit's onValid callback. */
|
|
11
|
+
values: Partial<T>;
|
|
12
|
+
errors: Partial<Record<keyof T, string>>;
|
|
13
|
+
isSubmitting: boolean;
|
|
14
|
+
isDirty: boolean;
|
|
15
|
+
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
|
16
|
+
setError: (field: keyof T, message: string) => void;
|
|
17
|
+
clearError: (field: keyof T) => void;
|
|
18
|
+
/** Returns a submit handler. The onValid callback receives the fully validated T from schema.safeParse. */
|
|
19
|
+
handleSubmit: (onValid: (data: T) => Promise<void>) => (e?: React.FormEvent) => Promise<void>;
|
|
20
|
+
reset: (values?: Partial<T>) => void;
|
|
21
|
+
}
|
|
22
|
+
declare function useForm<T extends Record<string, unknown>>(schema: ZodSchema<T>, options?: UseFormOptions<T>): UseFormReturn<T>;
|
|
23
|
+
|
|
24
|
+
interface FormFieldProps {
|
|
25
|
+
label: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
required?: boolean;
|
|
28
|
+
children: React$1.ReactNode;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
declare function FormField({ label, error, required, children, className, }: FormFieldProps): react_jsx_runtime.JSX.Element;
|
|
32
|
+
|
|
33
|
+
interface FormSectionProps {
|
|
34
|
+
title?: string;
|
|
35
|
+
children: React$1.ReactNode;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
declare function FormSection({ title, children, className, }: FormSectionProps): react_jsx_runtime.JSX.Element;
|
|
39
|
+
|
|
40
|
+
interface FormErrorProps {
|
|
41
|
+
message: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
declare function FormError({ message, className }: FormErrorProps): react_jsx_runtime.JSX.Element;
|
|
45
|
+
|
|
46
|
+
interface FormRowProps {
|
|
47
|
+
children: React$1.ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
declare function FormRow({ children, className }: FormRowProps): react_jsx_runtime.JSX.Element;
|
|
51
|
+
|
|
52
|
+
export { FormError, type FormErrorProps, FormField, type FormFieldProps, FormRow, type FormRowProps, FormSection, type FormSectionProps, type UseFormOptions, type UseFormReturn, useForm };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
|
|
6
|
+
// src/hooks/useForm.ts
|
|
7
|
+
function mapZodErrors(error) {
|
|
8
|
+
const mapped = {};
|
|
9
|
+
for (const issue of error.issues) {
|
|
10
|
+
const key = issue.path[0];
|
|
11
|
+
if (key !== void 0 && !(key in mapped)) {
|
|
12
|
+
mapped[key] = issue.message;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return mapped;
|
|
16
|
+
}
|
|
17
|
+
function useForm(schema, options) {
|
|
18
|
+
const initialValues = options?.initial ?? {};
|
|
19
|
+
const initialRef = react.useRef(initialValues);
|
|
20
|
+
const [values, setValues] = react.useState(initialValues);
|
|
21
|
+
const [errors, setErrors] = react.useState({});
|
|
22
|
+
const [isSubmitting, setIsSubmitting] = react.useState(false);
|
|
23
|
+
const [isDirty, setIsDirty] = react.useState(false);
|
|
24
|
+
const setFieldValue = react.useCallback((field, value) => {
|
|
25
|
+
setValues((prev) => ({ ...prev, [field]: value }));
|
|
26
|
+
setIsDirty(true);
|
|
27
|
+
setErrors((prev) => {
|
|
28
|
+
if (!(field in prev)) return prev;
|
|
29
|
+
const next = { ...prev };
|
|
30
|
+
delete next[field];
|
|
31
|
+
return next;
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
const setError = react.useCallback((field, message) => {
|
|
35
|
+
setErrors((prev) => ({ ...prev, [field]: message }));
|
|
36
|
+
}, []);
|
|
37
|
+
const clearError = react.useCallback((field) => {
|
|
38
|
+
setErrors((prev) => {
|
|
39
|
+
if (!(field in prev)) return prev;
|
|
40
|
+
const next = { ...prev };
|
|
41
|
+
delete next[field];
|
|
42
|
+
return next;
|
|
43
|
+
});
|
|
44
|
+
}, []);
|
|
45
|
+
const handleSubmit = react.useCallback(
|
|
46
|
+
(onValid) => {
|
|
47
|
+
return async (e) => {
|
|
48
|
+
if (e) e.preventDefault();
|
|
49
|
+
const result = schema.safeParse(values);
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
setErrors(mapZodErrors(result.error));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
setIsSubmitting(true);
|
|
55
|
+
try {
|
|
56
|
+
await onValid(result.data);
|
|
57
|
+
} finally {
|
|
58
|
+
setIsSubmitting(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
[schema, values]
|
|
63
|
+
);
|
|
64
|
+
const reset = react.useCallback(
|
|
65
|
+
(newValues) => {
|
|
66
|
+
setValues(newValues ?? initialRef.current);
|
|
67
|
+
setErrors({});
|
|
68
|
+
setIsDirty(false);
|
|
69
|
+
},
|
|
70
|
+
[]
|
|
71
|
+
);
|
|
72
|
+
return {
|
|
73
|
+
values,
|
|
74
|
+
errors,
|
|
75
|
+
isSubmitting,
|
|
76
|
+
isDirty,
|
|
77
|
+
setFieldValue,
|
|
78
|
+
setError,
|
|
79
|
+
clearError,
|
|
80
|
+
handleSubmit,
|
|
81
|
+
reset
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function FormField({
|
|
85
|
+
label,
|
|
86
|
+
error,
|
|
87
|
+
required = false,
|
|
88
|
+
children,
|
|
89
|
+
className = ""
|
|
90
|
+
}) {
|
|
91
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `space-y-2 ${className}`.trim(), children: [
|
|
92
|
+
/* @__PURE__ */ jsxRuntime.jsxs("label", { className: "text-sm font-medium leading-none", children: [
|
|
93
|
+
label,
|
|
94
|
+
required && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-red-500 ml-0.5", children: "*" })
|
|
95
|
+
] }),
|
|
96
|
+
children,
|
|
97
|
+
error && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-red-600", children: error })
|
|
98
|
+
] });
|
|
99
|
+
}
|
|
100
|
+
function FormSection({
|
|
101
|
+
title,
|
|
102
|
+
children,
|
|
103
|
+
className = ""
|
|
104
|
+
}) {
|
|
105
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `space-y-4 ${className}`.trim(), children: [
|
|
106
|
+
title && /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "text-sm font-medium leading-none", children: title }),
|
|
107
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "space-y-4", children })
|
|
108
|
+
] });
|
|
109
|
+
}
|
|
110
|
+
function FormError({ message, className = "" }) {
|
|
111
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
112
|
+
"div",
|
|
113
|
+
{
|
|
114
|
+
role: "alert",
|
|
115
|
+
className: `text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200 ${className}`.trim(),
|
|
116
|
+
children: message
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
function FormRow({ children, className = "" }) {
|
|
121
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `grid gap-4 sm:grid-cols-2 ${className}`.trim(), children });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
exports.FormError = FormError;
|
|
125
|
+
exports.FormField = FormField;
|
|
126
|
+
exports.FormRow = FormRow;
|
|
127
|
+
exports.FormSection = FormSection;
|
|
128
|
+
exports.useForm = useForm;
|
|
129
|
+
//# sourceMappingURL=index.js.map
|
|
130
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hooks/useForm.ts","../src/components/FormField.tsx","../src/components/FormSection.tsx","../src/components/FormError.tsx","../src/components/FormRow.tsx"],"names":["useRef","useState","useCallback","jsxs","jsx"],"mappings":"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","file":"index.js","sourcesContent":["import { useState, useCallback, useRef } from 'react'\nimport type { ZodSchema, ZodError } from 'zod'\n\nexport interface UseFormOptions<T> {\n initial?: Partial<T>\n}\n\nexport interface UseFormReturn<T extends Record<string, unknown>> {\n /** Current form values. Partial because fields may not be set yet.\n * For the fully validated T, use the data passed to handleSubmit's onValid callback. */\n values: Partial<T>\n errors: Partial<Record<keyof T, string>>\n isSubmitting: boolean\n isDirty: boolean\n setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void\n setError: (field: keyof T, message: string) => void\n clearError: (field: keyof T) => void\n /** Returns a submit handler. The onValid callback receives the fully validated T from schema.safeParse. */\n handleSubmit: (onValid: (data: T) => Promise<void>) => (e?: React.FormEvent) => Promise<void>\n reset: (values?: Partial<T>) => void\n}\n\nfunction mapZodErrors<T extends Record<string, unknown>>(\n error: ZodError\n): Partial<Record<keyof T, string>> {\n const mapped: Partial<Record<keyof T, string>> = {}\n for (const issue of error.issues) {\n const key = issue.path[0]\n if (key !== undefined && !(key in mapped)) {\n mapped[key as keyof T] = issue.message\n }\n }\n return mapped\n}\n\nexport function useForm<T extends Record<string, unknown>>(\n schema: ZodSchema<T>,\n options?: UseFormOptions<T>\n): UseFormReturn<T> {\n const initialValues = options?.initial ?? {}\n const initialRef = useRef(initialValues)\n\n const [values, setValues] = useState<Partial<T>>(initialValues)\n const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})\n const [isSubmitting, setIsSubmitting] = useState(false)\n const [isDirty, setIsDirty] = useState(false)\n\n const setFieldValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {\n setValues(prev => ({ ...prev, [field]: value }))\n setIsDirty(true)\n // Clear error for this field when user changes it\n setErrors(prev => {\n if (!(field in prev)) return prev\n const next = { ...prev }\n delete next[field]\n return next\n })\n }, [])\n\n const setError = useCallback((field: keyof T, message: string) => {\n setErrors(prev => ({ ...prev, [field]: message }))\n }, [])\n\n const clearError = useCallback((field: keyof T) => {\n setErrors(prev => {\n if (!(field in prev)) return prev\n const next = { ...prev }\n delete next[field]\n return next\n })\n }, [])\n\n const handleSubmit = useCallback(\n (onValid: (data: T) => Promise<void>) => {\n return async (e?: React.FormEvent) => {\n if (e) e.preventDefault()\n\n const result = schema.safeParse(values)\n\n if (!result.success) {\n setErrors(mapZodErrors<T>(result.error))\n return\n }\n\n setIsSubmitting(true)\n try {\n await onValid(result.data)\n } finally {\n setIsSubmitting(false)\n }\n }\n },\n [schema, values]\n )\n\n const reset = useCallback(\n (newValues?: Partial<T>) => {\n setValues(newValues ?? initialRef.current)\n setErrors({})\n setIsDirty(false)\n },\n []\n )\n\n return {\n values,\n errors,\n isSubmitting,\n isDirty,\n setFieldValue,\n setError,\n clearError,\n handleSubmit,\n reset,\n }\n}\n","import React from 'react'\n\nexport interface FormFieldProps {\n label: string\n error?: string\n required?: boolean\n children: React.ReactNode\n className?: string\n}\n\nexport function FormField({\n label,\n error,\n required = false,\n children,\n className = '',\n}: FormFieldProps) {\n return (\n <div className={`space-y-2 ${className}`.trim()}>\n <label className=\"text-sm font-medium leading-none\">\n {label}\n {required && <span className=\"text-red-500 ml-0.5\">*</span>}\n </label>\n {children}\n {error && <p className=\"text-sm text-red-600\">{error}</p>}\n </div>\n )\n}\n","import React from 'react'\n\nexport interface FormSectionProps {\n title?: string\n children: React.ReactNode\n className?: string\n}\n\nexport function FormSection({\n title,\n children,\n className = '',\n}: FormSectionProps) {\n return (\n <div className={`space-y-4 ${className}`.trim()}>\n {title && (\n <h3 className=\"text-sm font-medium leading-none\">{title}</h3>\n )}\n <div className=\"space-y-4\">{children}</div>\n </div>\n )\n}\n","import React from 'react'\n\nexport interface FormErrorProps {\n message: string\n className?: string\n}\n\nexport function FormError({ message, className = '' }: FormErrorProps) {\n return (\n <div\n role=\"alert\"\n className={`text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200 ${className}`.trim()}\n >\n {message}\n </div>\n )\n}\n","import React from 'react'\n\nexport interface FormRowProps {\n children: React.ReactNode\n className?: string\n}\n\nexport function FormRow({ children, className = '' }: FormRowProps) {\n return (\n <div className={`grid gap-4 sm:grid-cols-2 ${className}`.trim()}>\n {children}\n </div>\n )\n}\n"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
// src/hooks/useForm.ts
|
|
5
|
+
function mapZodErrors(error) {
|
|
6
|
+
const mapped = {};
|
|
7
|
+
for (const issue of error.issues) {
|
|
8
|
+
const key = issue.path[0];
|
|
9
|
+
if (key !== void 0 && !(key in mapped)) {
|
|
10
|
+
mapped[key] = issue.message;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return mapped;
|
|
14
|
+
}
|
|
15
|
+
function useForm(schema, options) {
|
|
16
|
+
const initialValues = options?.initial ?? {};
|
|
17
|
+
const initialRef = useRef(initialValues);
|
|
18
|
+
const [values, setValues] = useState(initialValues);
|
|
19
|
+
const [errors, setErrors] = useState({});
|
|
20
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
21
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
22
|
+
const setFieldValue = useCallback((field, value) => {
|
|
23
|
+
setValues((prev) => ({ ...prev, [field]: value }));
|
|
24
|
+
setIsDirty(true);
|
|
25
|
+
setErrors((prev) => {
|
|
26
|
+
if (!(field in prev)) return prev;
|
|
27
|
+
const next = { ...prev };
|
|
28
|
+
delete next[field];
|
|
29
|
+
return next;
|
|
30
|
+
});
|
|
31
|
+
}, []);
|
|
32
|
+
const setError = useCallback((field, message) => {
|
|
33
|
+
setErrors((prev) => ({ ...prev, [field]: message }));
|
|
34
|
+
}, []);
|
|
35
|
+
const clearError = useCallback((field) => {
|
|
36
|
+
setErrors((prev) => {
|
|
37
|
+
if (!(field in prev)) return prev;
|
|
38
|
+
const next = { ...prev };
|
|
39
|
+
delete next[field];
|
|
40
|
+
return next;
|
|
41
|
+
});
|
|
42
|
+
}, []);
|
|
43
|
+
const handleSubmit = useCallback(
|
|
44
|
+
(onValid) => {
|
|
45
|
+
return async (e) => {
|
|
46
|
+
if (e) e.preventDefault();
|
|
47
|
+
const result = schema.safeParse(values);
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
setErrors(mapZodErrors(result.error));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setIsSubmitting(true);
|
|
53
|
+
try {
|
|
54
|
+
await onValid(result.data);
|
|
55
|
+
} finally {
|
|
56
|
+
setIsSubmitting(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
[schema, values]
|
|
61
|
+
);
|
|
62
|
+
const reset = useCallback(
|
|
63
|
+
(newValues) => {
|
|
64
|
+
setValues(newValues ?? initialRef.current);
|
|
65
|
+
setErrors({});
|
|
66
|
+
setIsDirty(false);
|
|
67
|
+
},
|
|
68
|
+
[]
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
values,
|
|
72
|
+
errors,
|
|
73
|
+
isSubmitting,
|
|
74
|
+
isDirty,
|
|
75
|
+
setFieldValue,
|
|
76
|
+
setError,
|
|
77
|
+
clearError,
|
|
78
|
+
handleSubmit,
|
|
79
|
+
reset
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function FormField({
|
|
83
|
+
label,
|
|
84
|
+
error,
|
|
85
|
+
required = false,
|
|
86
|
+
children,
|
|
87
|
+
className = ""
|
|
88
|
+
}) {
|
|
89
|
+
return /* @__PURE__ */ jsxs("div", { className: `space-y-2 ${className}`.trim(), children: [
|
|
90
|
+
/* @__PURE__ */ jsxs("label", { className: "text-sm font-medium leading-none", children: [
|
|
91
|
+
label,
|
|
92
|
+
required && /* @__PURE__ */ jsx("span", { className: "text-red-500 ml-0.5", children: "*" })
|
|
93
|
+
] }),
|
|
94
|
+
children,
|
|
95
|
+
error && /* @__PURE__ */ jsx("p", { className: "text-sm text-red-600", children: error })
|
|
96
|
+
] });
|
|
97
|
+
}
|
|
98
|
+
function FormSection({
|
|
99
|
+
title,
|
|
100
|
+
children,
|
|
101
|
+
className = ""
|
|
102
|
+
}) {
|
|
103
|
+
return /* @__PURE__ */ jsxs("div", { className: `space-y-4 ${className}`.trim(), children: [
|
|
104
|
+
title && /* @__PURE__ */ jsx("h3", { className: "text-sm font-medium leading-none", children: title }),
|
|
105
|
+
/* @__PURE__ */ jsx("div", { className: "space-y-4", children })
|
|
106
|
+
] });
|
|
107
|
+
}
|
|
108
|
+
function FormError({ message, className = "" }) {
|
|
109
|
+
return /* @__PURE__ */ jsx(
|
|
110
|
+
"div",
|
|
111
|
+
{
|
|
112
|
+
role: "alert",
|
|
113
|
+
className: `text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200 ${className}`.trim(),
|
|
114
|
+
children: message
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
function FormRow({ children, className = "" }) {
|
|
119
|
+
return /* @__PURE__ */ jsx("div", { className: `grid gap-4 sm:grid-cols-2 ${className}`.trim(), children });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { FormError, FormField, FormRow, FormSection, useForm };
|
|
123
|
+
//# sourceMappingURL=index.mjs.map
|
|
124
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hooks/useForm.ts","../src/components/FormField.tsx","../src/components/FormSection.tsx","../src/components/FormError.tsx","../src/components/FormRow.tsx"],"names":["jsxs","jsx"],"mappings":"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","file":"index.mjs","sourcesContent":["import { useState, useCallback, useRef } from 'react'\nimport type { ZodSchema, ZodError } from 'zod'\n\nexport interface UseFormOptions<T> {\n initial?: Partial<T>\n}\n\nexport interface UseFormReturn<T extends Record<string, unknown>> {\n /** Current form values. Partial because fields may not be set yet.\n * For the fully validated T, use the data passed to handleSubmit's onValid callback. */\n values: Partial<T>\n errors: Partial<Record<keyof T, string>>\n isSubmitting: boolean\n isDirty: boolean\n setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void\n setError: (field: keyof T, message: string) => void\n clearError: (field: keyof T) => void\n /** Returns a submit handler. The onValid callback receives the fully validated T from schema.safeParse. */\n handleSubmit: (onValid: (data: T) => Promise<void>) => (e?: React.FormEvent) => Promise<void>\n reset: (values?: Partial<T>) => void\n}\n\nfunction mapZodErrors<T extends Record<string, unknown>>(\n error: ZodError\n): Partial<Record<keyof T, string>> {\n const mapped: Partial<Record<keyof T, string>> = {}\n for (const issue of error.issues) {\n const key = issue.path[0]\n if (key !== undefined && !(key in mapped)) {\n mapped[key as keyof T] = issue.message\n }\n }\n return mapped\n}\n\nexport function useForm<T extends Record<string, unknown>>(\n schema: ZodSchema<T>,\n options?: UseFormOptions<T>\n): UseFormReturn<T> {\n const initialValues = options?.initial ?? {}\n const initialRef = useRef(initialValues)\n\n const [values, setValues] = useState<Partial<T>>(initialValues)\n const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})\n const [isSubmitting, setIsSubmitting] = useState(false)\n const [isDirty, setIsDirty] = useState(false)\n\n const setFieldValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {\n setValues(prev => ({ ...prev, [field]: value }))\n setIsDirty(true)\n // Clear error for this field when user changes it\n setErrors(prev => {\n if (!(field in prev)) return prev\n const next = { ...prev }\n delete next[field]\n return next\n })\n }, [])\n\n const setError = useCallback((field: keyof T, message: string) => {\n setErrors(prev => ({ ...prev, [field]: message }))\n }, [])\n\n const clearError = useCallback((field: keyof T) => {\n setErrors(prev => {\n if (!(field in prev)) return prev\n const next = { ...prev }\n delete next[field]\n return next\n })\n }, [])\n\n const handleSubmit = useCallback(\n (onValid: (data: T) => Promise<void>) => {\n return async (e?: React.FormEvent) => {\n if (e) e.preventDefault()\n\n const result = schema.safeParse(values)\n\n if (!result.success) {\n setErrors(mapZodErrors<T>(result.error))\n return\n }\n\n setIsSubmitting(true)\n try {\n await onValid(result.data)\n } finally {\n setIsSubmitting(false)\n }\n }\n },\n [schema, values]\n )\n\n const reset = useCallback(\n (newValues?: Partial<T>) => {\n setValues(newValues ?? initialRef.current)\n setErrors({})\n setIsDirty(false)\n },\n []\n )\n\n return {\n values,\n errors,\n isSubmitting,\n isDirty,\n setFieldValue,\n setError,\n clearError,\n handleSubmit,\n reset,\n }\n}\n","import React from 'react'\n\nexport interface FormFieldProps {\n label: string\n error?: string\n required?: boolean\n children: React.ReactNode\n className?: string\n}\n\nexport function FormField({\n label,\n error,\n required = false,\n children,\n className = '',\n}: FormFieldProps) {\n return (\n <div className={`space-y-2 ${className}`.trim()}>\n <label className=\"text-sm font-medium leading-none\">\n {label}\n {required && <span className=\"text-red-500 ml-0.5\">*</span>}\n </label>\n {children}\n {error && <p className=\"text-sm text-red-600\">{error}</p>}\n </div>\n )\n}\n","import React from 'react'\n\nexport interface FormSectionProps {\n title?: string\n children: React.ReactNode\n className?: string\n}\n\nexport function FormSection({\n title,\n children,\n className = '',\n}: FormSectionProps) {\n return (\n <div className={`space-y-4 ${className}`.trim()}>\n {title && (\n <h3 className=\"text-sm font-medium leading-none\">{title}</h3>\n )}\n <div className=\"space-y-4\">{children}</div>\n </div>\n )\n}\n","import React from 'react'\n\nexport interface FormErrorProps {\n message: string\n className?: string\n}\n\nexport function FormError({ message, className = '' }: FormErrorProps) {\n return (\n <div\n role=\"alert\"\n className={`text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200 ${className}`.trim()}\n >\n {message}\n </div>\n )\n}\n","import React from 'react'\n\nexport interface FormRowProps {\n children: React.ReactNode\n className?: string\n}\n\nexport function FormRow({ children, className = '' }: FormRowProps) {\n return (\n <div className={`grid gap-4 sm:grid-cols-2 ${className}`.trim()}>\n {children}\n </div>\n )\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startsimpli/forms",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Thin form state management with Zod validation for StartSimpli apps",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.mjs",
|
|
11
|
+
"require": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"dev": "tsup --watch",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"type-check": "tsc --noEmit",
|
|
25
|
+
"clean": "rm -rf dist"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": ">=18",
|
|
29
|
+
"zod": ">=3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@testing-library/react": "^16.0.0",
|
|
33
|
+
"@types/react": "^18.2.0",
|
|
34
|
+
"jsdom": "^24.0.0",
|
|
35
|
+
"react": "^18.2.0",
|
|
36
|
+
"react-dom": "^18.2.0",
|
|
37
|
+
"tsup": "^8.0.0",
|
|
38
|
+
"typescript": "^5.0.0",
|
|
39
|
+
"vitest": "^2.0.0",
|
|
40
|
+
"zod": "^3.22.0"
|
|
41
|
+
},
|
|
42
|
+
"module": "./dist/index.mjs"
|
|
43
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { useForm } from '../hooks/useForm'
|
|
4
|
+
|
|
5
|
+
const testSchema = z.object({
|
|
6
|
+
name: z.string().min(1, 'Name is required'),
|
|
7
|
+
email: z.string().email('Invalid email'),
|
|
8
|
+
age: z.number().min(0, 'Age must be positive').optional(),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
type TestData = z.infer<typeof testSchema>
|
|
12
|
+
|
|
13
|
+
describe('useForm', () => {
|
|
14
|
+
it('setFieldValue clears the error for that field', () => {
|
|
15
|
+
const { result } = renderHook(() => useForm(testSchema))
|
|
16
|
+
|
|
17
|
+
// Manually set an error on the name field
|
|
18
|
+
act(() => {
|
|
19
|
+
result.current.setError('name', 'Name is required')
|
|
20
|
+
})
|
|
21
|
+
expect(result.current.errors.name).toBe('Name is required')
|
|
22
|
+
|
|
23
|
+
// Setting the field value should clear the error
|
|
24
|
+
act(() => {
|
|
25
|
+
result.current.setFieldValue('name', 'Alice')
|
|
26
|
+
})
|
|
27
|
+
expect(result.current.errors.name).toBeUndefined()
|
|
28
|
+
expect(result.current.values.name).toBe('Alice')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('handleSubmit with invalid data sets field errors and does NOT call onValid', async () => {
|
|
32
|
+
const onValid = vi.fn()
|
|
33
|
+
const { result } = renderHook(() => useForm(testSchema))
|
|
34
|
+
|
|
35
|
+
// Submit with empty values — name and email are required
|
|
36
|
+
await act(async () => {
|
|
37
|
+
const submitFn = result.current.handleSubmit(onValid)
|
|
38
|
+
await submitFn()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(onValid).not.toHaveBeenCalled()
|
|
42
|
+
expect(result.current.errors.name).toBeDefined()
|
|
43
|
+
expect(result.current.errors.email).toBeDefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('handleSubmit with valid data calls onValid and sets isSubmitting during call', async () => {
|
|
47
|
+
let submittingDuringCall = false
|
|
48
|
+
const onValid = vi.fn(async () => {
|
|
49
|
+
// Capture isSubmitting state during the callback
|
|
50
|
+
submittingDuringCall = true
|
|
51
|
+
// Simulate async work
|
|
52
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const { result } = renderHook(() =>
|
|
56
|
+
useForm(testSchema, {
|
|
57
|
+
initial: { name: 'Alice', email: 'alice@example.com' },
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// isSubmitting should be false before submission
|
|
62
|
+
expect(result.current.isSubmitting).toBe(false)
|
|
63
|
+
|
|
64
|
+
await act(async () => {
|
|
65
|
+
const submitFn = result.current.handleSubmit(async (data) => {
|
|
66
|
+
// Check isSubmitting is true during execution
|
|
67
|
+
submittingDuringCall = result.current.isSubmitting
|
|
68
|
+
await onValid(data)
|
|
69
|
+
})
|
|
70
|
+
await submitFn()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(onValid).toHaveBeenCalledWith({
|
|
74
|
+
name: 'Alice',
|
|
75
|
+
email: 'alice@example.com',
|
|
76
|
+
})
|
|
77
|
+
// isSubmitting should be back to false after completion
|
|
78
|
+
expect(result.current.isSubmitting).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('reset() clears values and errors', () => {
|
|
82
|
+
const { result } = renderHook(() =>
|
|
83
|
+
useForm(testSchema, {
|
|
84
|
+
initial: { name: 'Bob', email: 'bob@test.com' },
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Modify values and set errors
|
|
89
|
+
act(() => {
|
|
90
|
+
result.current.setFieldValue('name', 'Changed')
|
|
91
|
+
result.current.setError('email', 'Bad email')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
expect(result.current.values.name).toBe('Changed')
|
|
95
|
+
expect(result.current.errors.email).toBe('Bad email')
|
|
96
|
+
expect(result.current.isDirty).toBe(true)
|
|
97
|
+
|
|
98
|
+
// Reset should restore initial values and clear errors
|
|
99
|
+
act(() => {
|
|
100
|
+
result.current.reset()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(result.current.values.name).toBe('Bob')
|
|
104
|
+
expect(result.current.values.email).toBe('bob@test.com')
|
|
105
|
+
expect(result.current.errors).toEqual({})
|
|
106
|
+
expect(result.current.isDirty).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('Zod error messages appear correctly in errors map', async () => {
|
|
110
|
+
const onValid = vi.fn()
|
|
111
|
+
const { result } = renderHook(() =>
|
|
112
|
+
useForm(testSchema, {
|
|
113
|
+
initial: { name: '', email: 'not-an-email' },
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
const submitFn = result.current.handleSubmit(onValid)
|
|
119
|
+
await submitFn()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(onValid).not.toHaveBeenCalled()
|
|
123
|
+
expect(result.current.errors.name).toBe('Name is required')
|
|
124
|
+
expect(result.current.errors.email).toBe('Invalid email')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('handleSubmit prevents default on form events', async () => {
|
|
128
|
+
const onValid = vi.fn()
|
|
129
|
+
const preventDefault = vi.fn()
|
|
130
|
+
const { result } = renderHook(() =>
|
|
131
|
+
useForm(testSchema, {
|
|
132
|
+
initial: { name: 'Test', email: 'test@test.com' },
|
|
133
|
+
})
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
await act(async () => {
|
|
137
|
+
const submitFn = result.current.handleSubmit(onValid)
|
|
138
|
+
await submitFn({ preventDefault } as unknown as React.FormEvent)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(preventDefault).toHaveBeenCalled()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('clearError removes a specific field error', () => {
|
|
145
|
+
const { result } = renderHook(() => useForm(testSchema))
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
result.current.setError('name', 'Error 1')
|
|
149
|
+
result.current.setError('email', 'Error 2')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
expect(result.current.errors.name).toBe('Error 1')
|
|
153
|
+
expect(result.current.errors.email).toBe('Error 2')
|
|
154
|
+
|
|
155
|
+
act(() => {
|
|
156
|
+
result.current.clearError('name')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(result.current.errors.name).toBeUndefined()
|
|
160
|
+
expect(result.current.errors.email).toBe('Error 2')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('reset with new values uses those instead of initial', () => {
|
|
164
|
+
const { result } = renderHook(() =>
|
|
165
|
+
useForm(testSchema, {
|
|
166
|
+
initial: { name: 'Original', email: 'orig@test.com' },
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
act(() => {
|
|
171
|
+
result.current.reset({ name: 'New', email: 'new@test.com' })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(result.current.values.name).toBe('New')
|
|
175
|
+
expect(result.current.values.email).toBe('new@test.com')
|
|
176
|
+
expect(result.current.isDirty).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FormErrorProps {
|
|
4
|
+
message: string
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function FormError({ message, className = '' }: FormErrorProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
role="alert"
|
|
12
|
+
className={`text-sm text-red-600 bg-red-50 p-3 rounded border border-red-200 ${className}`.trim()}
|
|
13
|
+
>
|
|
14
|
+
{message}
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FormFieldProps {
|
|
4
|
+
label: string
|
|
5
|
+
error?: string
|
|
6
|
+
required?: boolean
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
className?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FormField({
|
|
12
|
+
label,
|
|
13
|
+
error,
|
|
14
|
+
required = false,
|
|
15
|
+
children,
|
|
16
|
+
className = '',
|
|
17
|
+
}: FormFieldProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div className={`space-y-2 ${className}`.trim()}>
|
|
20
|
+
<label className="text-sm font-medium leading-none">
|
|
21
|
+
{label}
|
|
22
|
+
{required && <span className="text-red-500 ml-0.5">*</span>}
|
|
23
|
+
</label>
|
|
24
|
+
{children}
|
|
25
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FormRowProps {
|
|
4
|
+
children: React.ReactNode
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function FormRow({ children, className = '' }: FormRowProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={`grid gap-4 sm:grid-cols-2 ${className}`.trim()}>
|
|
11
|
+
{children}
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export interface FormSectionProps {
|
|
4
|
+
title?: string
|
|
5
|
+
children: React.ReactNode
|
|
6
|
+
className?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function FormSection({
|
|
10
|
+
title,
|
|
11
|
+
children,
|
|
12
|
+
className = '',
|
|
13
|
+
}: FormSectionProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className={`space-y-4 ${className}`.trim()}>
|
|
16
|
+
{title && (
|
|
17
|
+
<h3 className="text-sm font-medium leading-none">{title}</h3>
|
|
18
|
+
)}
|
|
19
|
+
<div className="space-y-4">{children}</div>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react'
|
|
2
|
+
import type { ZodSchema, ZodError } from 'zod'
|
|
3
|
+
|
|
4
|
+
export interface UseFormOptions<T> {
|
|
5
|
+
initial?: Partial<T>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UseFormReturn<T extends Record<string, unknown>> {
|
|
9
|
+
/** Current form values. Partial because fields may not be set yet.
|
|
10
|
+
* For the fully validated T, use the data passed to handleSubmit's onValid callback. */
|
|
11
|
+
values: Partial<T>
|
|
12
|
+
errors: Partial<Record<keyof T, string>>
|
|
13
|
+
isSubmitting: boolean
|
|
14
|
+
isDirty: boolean
|
|
15
|
+
setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void
|
|
16
|
+
setError: (field: keyof T, message: string) => void
|
|
17
|
+
clearError: (field: keyof T) => void
|
|
18
|
+
/** Returns a submit handler. The onValid callback receives the fully validated T from schema.safeParse. */
|
|
19
|
+
handleSubmit: (onValid: (data: T) => Promise<void>) => (e?: React.FormEvent) => Promise<void>
|
|
20
|
+
reset: (values?: Partial<T>) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mapZodErrors<T extends Record<string, unknown>>(
|
|
24
|
+
error: ZodError
|
|
25
|
+
): Partial<Record<keyof T, string>> {
|
|
26
|
+
const mapped: Partial<Record<keyof T, string>> = {}
|
|
27
|
+
for (const issue of error.issues) {
|
|
28
|
+
const key = issue.path[0]
|
|
29
|
+
if (key !== undefined && !(key in mapped)) {
|
|
30
|
+
mapped[key as keyof T] = issue.message
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return mapped
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useForm<T extends Record<string, unknown>>(
|
|
37
|
+
schema: ZodSchema<T>,
|
|
38
|
+
options?: UseFormOptions<T>
|
|
39
|
+
): UseFormReturn<T> {
|
|
40
|
+
const initialValues = options?.initial ?? {}
|
|
41
|
+
const initialRef = useRef(initialValues)
|
|
42
|
+
|
|
43
|
+
const [values, setValues] = useState<Partial<T>>(initialValues)
|
|
44
|
+
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
|
|
45
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
46
|
+
const [isDirty, setIsDirty] = useState(false)
|
|
47
|
+
|
|
48
|
+
const setFieldValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
|
49
|
+
setValues(prev => ({ ...prev, [field]: value }))
|
|
50
|
+
setIsDirty(true)
|
|
51
|
+
// Clear error for this field when user changes it
|
|
52
|
+
setErrors(prev => {
|
|
53
|
+
if (!(field in prev)) return prev
|
|
54
|
+
const next = { ...prev }
|
|
55
|
+
delete next[field]
|
|
56
|
+
return next
|
|
57
|
+
})
|
|
58
|
+
}, [])
|
|
59
|
+
|
|
60
|
+
const setError = useCallback((field: keyof T, message: string) => {
|
|
61
|
+
setErrors(prev => ({ ...prev, [field]: message }))
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const clearError = useCallback((field: keyof T) => {
|
|
65
|
+
setErrors(prev => {
|
|
66
|
+
if (!(field in prev)) return prev
|
|
67
|
+
const next = { ...prev }
|
|
68
|
+
delete next[field]
|
|
69
|
+
return next
|
|
70
|
+
})
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const handleSubmit = useCallback(
|
|
74
|
+
(onValid: (data: T) => Promise<void>) => {
|
|
75
|
+
return async (e?: React.FormEvent) => {
|
|
76
|
+
if (e) e.preventDefault()
|
|
77
|
+
|
|
78
|
+
const result = schema.safeParse(values)
|
|
79
|
+
|
|
80
|
+
if (!result.success) {
|
|
81
|
+
setErrors(mapZodErrors<T>(result.error))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setIsSubmitting(true)
|
|
86
|
+
try {
|
|
87
|
+
await onValid(result.data)
|
|
88
|
+
} finally {
|
|
89
|
+
setIsSubmitting(false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[schema, values]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const reset = useCallback(
|
|
97
|
+
(newValues?: Partial<T>) => {
|
|
98
|
+
setValues(newValues ?? initialRef.current)
|
|
99
|
+
setErrors({})
|
|
100
|
+
setIsDirty(false)
|
|
101
|
+
},
|
|
102
|
+
[]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
values,
|
|
107
|
+
errors,
|
|
108
|
+
isSubmitting,
|
|
109
|
+
isDirty,
|
|
110
|
+
setFieldValue,
|
|
111
|
+
setError,
|
|
112
|
+
clearError,
|
|
113
|
+
handleSubmit,
|
|
114
|
+
reset,
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { useForm } from './hooks/useForm'
|
|
2
|
+
export type { UseFormOptions, UseFormReturn } from './hooks/useForm'
|
|
3
|
+
|
|
4
|
+
export { FormField } from './components/FormField'
|
|
5
|
+
export type { FormFieldProps } from './components/FormField'
|
|
6
|
+
|
|
7
|
+
export { FormSection } from './components/FormSection'
|
|
8
|
+
export type { FormSectionProps } from './components/FormSection'
|
|
9
|
+
|
|
10
|
+
export { FormError } from './components/FormError'
|
|
11
|
+
export type { FormErrorProps } from './components/FormError'
|
|
12
|
+
|
|
13
|
+
export { FormRow } from './components/FormRow'
|
|
14
|
+
export type { FormRowProps } from './components/FormRow'
|