el-form-react-components
Version:
AutoForm and UI components for React - generate forms instantly from schemas (Zod, Yup, Valibot). Includes Tailwind CSS styling and customizable field components.
714 lines (713 loc) • 23.4 kB
JavaScript
// src/AutoForm.tsx
import { useForm } from "el-form-react-hooks";
import { z } from "zod";
import { jsx, jsxs } from "react/jsx-runtime";
var DefaultErrorComponent = ({
errors,
touched
}) => {
const errorEntries = Object.entries(errors).filter(
([field]) => touched[field]
);
if (errorEntries.length === 0) return null;
return /* @__PURE__ */ jsxs("div", { className: "el-form-error-summary", children: [
/* @__PURE__ */ jsx("h3", { children: "\u26A0\uFE0F Please fix the following errors:" }),
/* @__PURE__ */ jsx("ul", { children: errorEntries.map(([field, error]) => /* @__PURE__ */ jsxs("li", { children: [
/* @__PURE__ */ jsx("span", { style: { color: "#ef4444", marginRight: "0.5rem" }, children: "\u2022" }),
/* @__PURE__ */ jsxs("span", { style: { textTransform: "capitalize" }, children: [
field,
":"
] }),
/* @__PURE__ */ jsx("span", { style: { marginLeft: "0.25rem" }, children: String(error) })
] }, field)) })
] });
};
var DefaultField = ({
name,
label,
type = "text",
placeholder,
value,
onChange,
onBlur,
error,
touched,
options,
className,
inputClassName,
labelClassName,
errorClassName
}) => {
const fieldId = `field-${name}`;
if (type === "checkbox") {
return /* @__PURE__ */ jsxs("div", { className: className || "flex items-center gap-x-2", children: [
/* @__PURE__ */ jsx(
"input",
{
id: fieldId,
name,
type: "checkbox",
checked: !!value,
onChange,
onBlur,
className: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
}
),
/* @__PURE__ */ jsx(
"label",
{
htmlFor: fieldId,
className: labelClassName || "text-sm font-medium text-gray-900",
children: label
}
)
] });
}
const inputClasses = inputClassName || `
w-full px-3 py-2 border rounded-md text-sm text-gray-900 placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
${touched && error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300"}
`.trim().replace(/\s+/g, " ");
return /* @__PURE__ */ jsxs("div", { className: className || "space-y-1", children: [
label && /* @__PURE__ */ jsx(
"label",
{
htmlFor: fieldId,
className: labelClassName || "block text-sm font-medium text-gray-700",
children: label
}
),
type === "textarea" ? /* @__PURE__ */ jsx(
"textarea",
{
id: fieldId,
name,
value: value || "",
onChange,
onBlur,
placeholder,
className: `${inputClasses} resize-none`,
rows: 4
}
) : type === "select" && options ? /* @__PURE__ */ jsxs(
"select",
{
id: fieldId,
name,
value: value || "",
onChange,
onBlur,
className: inputClasses,
children: [
/* @__PURE__ */ jsx("option", { value: "", children: placeholder || "Select an option" }),
options.map((option) => /* @__PURE__ */ jsx("option", { value: option.value, children: option.label }, option.value))
]
}
) : /* @__PURE__ */ jsx(
"input",
{
id: fieldId,
name,
type,
value: value || "",
onChange,
onBlur,
placeholder,
className: inputClasses
}
),
touched && error && /* @__PURE__ */ jsx("div", { className: errorClassName || "text-red-500 text-xs mt-1", children: error })
] });
};
var ArrayField = ({
fieldConfig,
value = [],
path,
onAddItem,
onRemoveItem,
onValueChange,
register,
formState
}) => {
const arrayValue = Array.isArray(value) ? value : [];
const createEmptyItem = () => {
if (!fieldConfig.fields) return {};
if (fieldConfig.fields.length === 1 && fieldConfig.fields[0].name === "value") {
const fieldType = fieldConfig.fields[0].type;
if (fieldType === "number") {
return 0;
} else if (fieldType === "checkbox") {
return false;
} else {
return "";
}
}
const emptyItem = {};
fieldConfig.fields.forEach((field) => {
if (field.type === "array") {
emptyItem[field.name] = [];
} else if (field.type === "number") {
emptyItem[field.name] = 0;
} else {
emptyItem[field.name] = "";
}
});
return emptyItem;
};
const handleAddItem = () => {
onAddItem(path, createEmptyItem());
};
const handleRemoveItem = (index) => {
onRemoveItem(path, index);
};
const renderNestedField = (nestedFieldConfig, itemIndex, itemPath) => {
if (nestedFieldConfig.name === "value") {
const fieldValue2 = arrayValue[itemIndex] || "";
return /* @__PURE__ */ jsx("div", { className: "space-y-1", children: /* @__PURE__ */ jsx(
"input",
{
type: nestedFieldConfig.type || "text",
value: fieldValue2,
onChange: (e) => {
const newValue = nestedFieldConfig.type === "number" ? e.target.value ? Number(e.target.value) : 0 : e.target.value;
onValueChange(itemPath, newValue);
},
placeholder: nestedFieldConfig.placeholder || "Enter value",
className: "w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
}
) }, itemPath);
}
const fieldPath = `${itemPath}.${nestedFieldConfig.name}`;
const fieldValue = arrayValue[itemIndex]?.[nestedFieldConfig.name] || "";
if (nestedFieldConfig.type === "array") {
return /* @__PURE__ */ jsx(
ArrayField,
{
fieldConfig: nestedFieldConfig,
value: fieldValue,
path: fieldPath,
onAddItem,
onRemoveItem,
onValueChange,
register,
formState
},
fieldPath
);
}
return /* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
nestedFieldConfig.label && /* @__PURE__ */ jsx("label", { className: "block text-sm font-medium text-gray-700", children: nestedFieldConfig.label }),
/* @__PURE__ */ jsx(
"input",
{
type: nestedFieldConfig.type || "text",
value: fieldValue,
onChange: (e) => {
const newValue = nestedFieldConfig.type === "number" ? e.target.value ? Number(e.target.value) : 0 : e.target.value;
onValueChange(fieldPath, newValue);
},
placeholder: nestedFieldConfig.placeholder,
className: "w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
}
)
] }, fieldPath);
};
return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
/* @__PURE__ */ jsx("label", { className: "block text-sm font-medium text-gray-700", children: fieldConfig.label || fieldConfig.name }),
/* @__PURE__ */ jsxs(
"button",
{
type: "button",
onClick: handleAddItem,
className: "px-3 py-1 bg-green-600 text-white text-xs rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500",
children: [
"+ Add ",
fieldConfig.label || fieldConfig.name
]
}
)
] }),
/* @__PURE__ */ jsx("div", { className: "space-y-4", children: arrayValue.map((_, index) => {
const itemPath = `${path}[${index}]`;
return /* @__PURE__ */ jsxs(
"div",
{
className: "p-4 border border-gray-200 rounded-lg bg-gray-50",
children: [
/* @__PURE__ */ jsxs("div", { className: "flex justify-between items-center mb-3", children: [
/* @__PURE__ */ jsxs("h4", { className: "text-sm font-medium text-gray-700", children: [
fieldConfig.label || fieldConfig.name,
" #",
index + 1
] }),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: () => handleRemoveItem(index),
className: "px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500",
children: "Remove"
}
)
] }),
/* @__PURE__ */ jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-3", children: fieldConfig.fields?.map(
(nestedField) => renderNestedField(nestedField, index, itemPath)
) })
]
},
index
);
}) }),
arrayValue.length === 0 && /* @__PURE__ */ jsxs("div", { className: "text-gray-500 text-sm italic text-center py-4", children: [
"No ",
fieldConfig.label?.toLowerCase() || fieldConfig.name,
' added yet. Click "Add" to create one.'
] })
] });
};
function generateFieldsFromSchema(schema) {
if (!(schema instanceof z.ZodObject)) {
return [];
}
const shape = schema.shape;
const fields = [];
for (const key in shape) {
if (Object.prototype.hasOwnProperty.call(shape, key)) {
const zodType = shape[key];
const typeName = zodType._def.typeName;
const fieldConfig = {
name: key,
label: key.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase()),
type: "text"
// Default to text
};
if (typeName === "ZodString") {
const checks = zodType._def.checks || [];
if (checks.some((c) => c.kind === "email")) {
fieldConfig.type = "email";
} else if (checks.some((c) => c.kind === "url")) {
fieldConfig.type = "url";
}
} else if (typeName === "ZodNumber") {
fieldConfig.type = "number";
} else if (typeName === "ZodBoolean") {
fieldConfig.type = "checkbox";
} else if (typeName === "ZodEnum") {
fieldConfig.type = "select";
fieldConfig.options = zodType._def.values.map((v) => ({
value: v,
label: v
}));
} else if (typeName === "ZodDate") {
fieldConfig.type = "date";
} else if (typeName === "ZodArray") {
fieldConfig.type = "array";
const arrayElementType = zodType._def.type;
if (arrayElementType instanceof z.ZodObject) {
fieldConfig.fields = generateFieldsFromSchema(arrayElementType);
} else {
const elementTypeName = arrayElementType._def.typeName;
let elementType = "text";
if (elementTypeName === "ZodString") {
elementType = "text";
} else if (elementTypeName === "ZodNumber") {
elementType = "number";
} else if (elementTypeName === "ZodBoolean") {
elementType = "checkbox";
}
fieldConfig.fields = [
{
name: "value",
type: elementType,
label: "Value"
}
];
}
}
fields.push(fieldConfig);
}
}
return fields;
}
function mergeFields(autoFields, manualFields) {
const manualFieldsMap = new Map(
manualFields.map((field) => [field.name, field])
);
const mergedFields = autoFields.map((autoField) => {
const manualField = manualFieldsMap.get(autoField.name);
if (manualField) {
return { ...autoField, ...manualField };
}
return autoField;
});
manualFields.forEach((manualField) => {
if (!autoFields.some((autoField) => autoField.name === manualField.name)) {
mergedFields.push(manualField);
}
});
return mergedFields;
}
function AutoForm({
schema,
fields,
initialValues = {},
layout = "flex",
columns = 12,
onSubmit,
onError,
children,
customErrorComponent,
componentMap,
validators,
fieldValidators,
validateOn = "onChange",
submitButtonProps,
resetButtonProps
}) {
const formValidators = validators || {
[validateOn === "manual" ? "onSubmit" : validateOn]: schema
};
const formApi = useForm({
validators: formValidators,
fieldValidators,
defaultValues: initialValues,
validateOn
});
const {
register,
handleSubmit,
formState,
reset,
setValue,
addArrayItem,
removeArrayItem
} = formApi;
const autoGeneratedFields = generateFieldsFromSchema(schema);
const fieldsToRender = fields ? mergeFields(autoGeneratedFields, fields) : autoGeneratedFields;
const ErrorComponent = customErrorComponent || DefaultErrorComponent;
const renderField = (fieldConfig) => {
const fieldName = fieldConfig.name;
const getColSpanClass = (colSpan) => {
const spanMap = {
1: "col-span-1",
2: "col-span-2",
3: "col-span-3",
4: "col-span-4",
5: "col-span-5",
6: "col-span-6",
7: "col-span-7",
8: "col-span-8",
9: "col-span-9",
10: "col-span-10",
11: "col-span-11",
12: "col-span-12"
};
return spanMap[colSpan || 1];
};
const getFlexClass = (colSpan) => {
const flexMap = {
1: "w-1/12",
2: "w-2/12",
3: "w-3/12",
4: "w-4/12",
5: "w-5/12",
6: "w-6/12",
7: "w-7/12",
8: "w-8/12",
9: "w-9/12",
10: "w-10/12",
11: "w-11/12",
12: "w-full"
};
return flexMap[colSpan || 12];
};
const fieldContainerClasses = layout === "grid" ? getColSpanClass(fieldConfig.colSpan) : `flex-none ${getFlexClass(fieldConfig.colSpan)}`;
if (fieldConfig.type === "array") {
const fieldProps2 = register(String(fieldName));
const fieldValue2 = "value" in fieldProps2 ? fieldProps2.value : [];
const arrayValue = Array.isArray(fieldValue2) ? fieldValue2 : [];
return /* @__PURE__ */ jsx("div", { className: fieldContainerClasses, children: /* @__PURE__ */ jsx(
ArrayField,
{
fieldConfig,
value: arrayValue,
path: fieldConfig.name,
onAddItem: addArrayItem,
onRemoveItem: removeArrayItem,
onValueChange: setValue,
register,
formState
}
) }, fieldConfig.name);
}
const fieldProps = register(String(fieldName));
const error = formState.errors[fieldName];
const touched = formState.touched[fieldName];
const fieldValue = "checked" in fieldProps ? fieldProps.checked : "value" in fieldProps ? fieldProps.value : void 0;
const FieldComponent = fieldConfig.component || fieldConfig.type && componentMap?.[fieldConfig.type] || DefaultField;
return /* @__PURE__ */ jsx("div", { className: fieldContainerClasses, children: /* @__PURE__ */ jsx(
FieldComponent,
{
name: fieldConfig.name,
label: fieldConfig.label || fieldConfig.name,
type: fieldConfig.type,
placeholder: fieldConfig.placeholder,
value: fieldValue,
onChange: fieldProps.onChange,
onBlur: fieldProps.onBlur,
error,
touched,
options: fieldConfig.options,
className: fieldConfig.className,
inputClassName: fieldConfig.inputClassName,
labelClassName: fieldConfig.labelClassName,
errorClassName: fieldConfig.errorClassName
}
) }, fieldConfig.name);
};
const getGridClass = (cols) => {
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
9: "grid-cols-9",
10: "grid-cols-10",
11: "grid-cols-11",
12: "grid-cols-12"
};
return gridMap[cols];
};
const containerClasses = layout === "grid" ? `grid ${getGridClass(columns)} gap-4` : `flex flex-wrap gap-4`;
const defaultForm = /* @__PURE__ */ jsxs(
"form",
{
onSubmit: handleSubmit(
(data) => onSubmit(data),
onError || ((errors) => console.error("Form validation errors:", errors))
),
className: "w-full",
children: [
/* @__PURE__ */ jsx(
ErrorComponent,
{
errors: formState.errors,
touched: formState.touched
}
),
/* @__PURE__ */ jsxs("div", { className: containerClasses, children: [
fieldsToRender.map(renderField),
/* @__PURE__ */ jsxs(
"div",
{
className: `
flex gap-3 mt-6
${layout === "grid" ? "col-span-full" : "w-full"}
`.trim().replace(/\s+/g, " "),
children: [
/* @__PURE__ */ jsx(
"button",
{
type: "submit",
disabled: formState.isSubmitting,
className: "el-form-submit-button px-5 py-2.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed transition-colors duration-200",
...submitButtonProps,
children: formState.isSubmitting ? "Submitting..." : "Submit"
}
),
/* @__PURE__ */ jsx(
"button",
{
type: "button",
onClick: () => reset(),
className: "px-5 py-2.5 bg-gray-600 text-white rounded-md text-sm font-medium hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors duration-200",
...resetButtonProps,
children: "Reset"
}
)
]
}
)
] })
]
}
);
if (children) {
return /* @__PURE__ */ jsx("div", { className: "w-full", children: /* @__PURE__ */ jsxs(
"form",
{
onSubmit: handleSubmit(
(data) => onSubmit(data),
onError || ((errors) => console.error("Form validation errors:", errors))
),
className: "w-full",
children: [
/* @__PURE__ */ jsx(
ErrorComponent,
{
errors: formState.errors,
touched: formState.touched
}
),
children(formApi),
/* @__PURE__ */ jsx("div", { className: containerClasses, children: fieldsToRender.map(renderField) })
]
}
) });
}
return defaultForm;
}
// src/FieldComponents.tsx
import { useFormContext } from "el-form-react-hooks";
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
function createField(name) {
return {
name,
// Type-safe value getter
getValue: (form) => form.formState.values[name],
// Type-safe error getter
getError: (form) => form.formState.errors[name],
// Type-safe touched getter
getTouched: (form) => form.formState.touched[name],
// Register function
register: (form) => form.register(String(name))
};
}
function TextField({
name,
label,
placeholder,
className = "",
type = "text",
...props
}) {
const form = useFormContext();
const field = createField(name);
const error = field.getError(form.form);
const touched = field.getTouched(form.form);
const registration = field.register(form.form);
const inputClasses = `
w-full px-3 py-2 border rounded-md text-sm text-gray-900 placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
${touched && error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300"} ${className}
`.trim().replace(/\s+/g, " ");
return /* @__PURE__ */ jsxs2("div", { className: "space-y-1", children: [
label && /* @__PURE__ */ jsx2(
"label",
{
htmlFor: String(name),
className: "block text-sm font-medium text-gray-700",
children: label
}
),
/* @__PURE__ */ jsx2(
"input",
{
...registration,
...props,
id: String(name),
type,
placeholder,
className: inputClasses
}
),
touched && error && /* @__PURE__ */ jsx2("div", { className: "text-red-500 text-xs mt-1", children: error })
] });
}
function TextareaField({
name,
label,
placeholder,
className = "",
rows = 4,
...props
}) {
const form = useFormContext();
const field = createField(name);
const error = field.getError(form.form);
const touched = field.getTouched(form.form);
const registration = field.register(form.form);
const textareaClasses = `
w-full px-3 py-2 border rounded-md text-sm text-gray-900 placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none
${touched && error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300"} ${className}
`.trim().replace(/\s+/g, " ");
return /* @__PURE__ */ jsxs2("div", { className: "space-y-1", children: [
label && /* @__PURE__ */ jsx2(
"label",
{
htmlFor: String(name),
className: "block text-sm font-medium text-gray-700",
children: label
}
),
/* @__PURE__ */ jsx2(
"textarea",
{
...registration,
...props,
id: String(name),
placeholder,
rows,
className: textareaClasses
}
),
touched && error && /* @__PURE__ */ jsx2("div", { className: "text-red-500 text-xs mt-1", children: error })
] });
}
function SelectField({
name,
label,
placeholder,
className = "",
options = [],
...props
}) {
const form = useFormContext();
const field = createField(name);
const error = field.getError(form.form);
const touched = field.getTouched(form.form);
const registration = field.register(form.form);
const selectClasses = `
w-full px-3 py-2 border rounded-md text-sm text-gray-900
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
${touched && error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300"} ${className}
`.trim().replace(/\s+/g, " ");
return /* @__PURE__ */ jsxs2("div", { className: "space-y-1", children: [
label && /* @__PURE__ */ jsx2(
"label",
{
htmlFor: String(name),
className: "block text-sm font-medium text-gray-700",
children: label
}
),
/* @__PURE__ */ jsxs2(
"select",
{
...registration,
...props,
id: String(name),
className: selectClasses,
children: [
placeholder && /* @__PURE__ */ jsx2("option", { value: "", disabled: true, children: placeholder }),
options.map((option) => /* @__PURE__ */ jsx2("option", { value: option.value, children: option.label }, option.value))
]
}
),
touched && error && /* @__PURE__ */ jsx2("div", { className: "text-red-500 text-xs mt-1", children: error })
] });
}
export {
AutoForm,
SelectField,
TextField,
TextareaField,
createField
};
//# sourceMappingURL=index.mjs.map