@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.
@@ -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 };
@@ -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'