@otitoju/formcraft-core
Version:
🚀 Lightweight, TypeScript-first form management library for React and React Native with built-in validation, zero dependencies, and excellent developer experience
628 lines (613 loc) • 26.5 kB
JavaScript
import { useState, useRef, useCallback, useMemo, createContext, useContext, forwardRef } from 'react';
import { jsx, jsxs } from 'react/jsx-runtime';
class FormValidator {
static validate(value, rules) {
if (rules.required && this.isEmpty(value)) {
return typeof rules.required === "string" ? rules.required : "This field is required";
}
if (this.isEmpty(value)) {
return null; // Don't validate empty optional fields
}
if (rules.email && !this.isValidEmail(String(value))) {
return typeof rules.email === "string" ? rules.email : "Please enter a valid email";
}
if (rules.url && !this.isValidUrl(String(value))) {
return typeof rules.url === "string" ? rules.url : "Please enter a valid URL";
}
if (rules.number && !this.isValidNumber(value)) {
return typeof rules.number === "string" ? rules.number : "Please enter a valid number";
}
if (rules.min !== undefined) {
const minError = this.validateMin(value, rules.min);
if (minError)
return minError;
}
if (rules.max !== undefined) {
const maxError = this.validateMax(value, rules.max);
if (maxError)
return maxError;
}
if (rules.pattern) {
const pattern = typeof rules.pattern === "string" ? new RegExp(rules.pattern) : rules.pattern;
if (!pattern.test(String(value))) {
return "Please enter a valid format";
}
}
if (rules.custom) {
const customResult = rules.custom(value);
if (typeof customResult === "string") {
return customResult;
}
if (customResult === false) {
return "Invalid value";
}
}
return null;
}
static isEmpty(value) {
return value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
}
static isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
static isValidUrl(url) {
try {
new URL(url);
return true;
}
catch (_a) {
return false;
}
}
static isValidNumber(value) {
return !isNaN(Number(value)) && isFinite(Number(value));
}
static validateMin(value, min) {
if (typeof min === "number") {
if (typeof value === "string" && value.length < min) {
return `Minimum ${min} characters required`;
}
if (typeof value === "number" && value < min) {
return `Minimum value is ${min}`;
}
if (Array.isArray(value) && value.length < min) {
return `Minimum ${min} items required`;
}
}
return null;
}
static validateMax(value, max) {
if (typeof max === "number") {
if (typeof value === "string" && value.length > max) {
return `Maximum ${max} characters allowed`;
}
if (typeof value === "number" && value > max) {
return `Maximum value is ${max}`;
}
if (Array.isArray(value) && value.length > max) {
return `Maximum ${max} items allowed`;
}
}
return null;
}
}
// Zod-inspired schema builder
class Schema {
constructor() {
this.rules = {};
}
static string() {
return new Schema();
}
static number() {
const schema = new Schema();
schema.rules.number = true;
return schema;
}
static email() {
const schema = new Schema();
schema.rules.email = true;
return schema;
}
static url() {
const schema = new Schema();
schema.rules.url = true;
return schema;
}
required(message) {
this.rules.required = message || true;
return this;
}
min(value, message) {
this.rules.min = value;
if (message)
this.rules.min = message;
return this;
}
max(value, message) {
this.rules.max = value;
if (message)
this.rules.max = message;
return this;
}
pattern(regex, message) {
this.rules.pattern = regex;
return this;
}
custom(validator) {
this.rules.custom = validator;
return this;
}
getRules() {
return this.rules;
}
}
function useForm(options = {}) {
const { defaultValues = {}, validation = {}, onSubmit, mode = "onChange" } = options;
const [values, setValues] = useState(defaultValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [dirty, setDirty] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const fieldsRef = useRef(new Map());
const watchersRef = useRef(new Map());
const validateField = useCallback((name, value) => {
const fieldConfig = fieldsRef.current.get(name);
const validationRules = validation[name] || (fieldConfig === null || fieldConfig === void 0 ? void 0 : fieldConfig.validation);
if (!validationRules)
return null;
return FormValidator.validate(value, validationRules);
}, [validation]);
const validateAllFields = useCallback(() => {
const newErrors = {};
let isValid = true;
Object.keys(values).forEach((name) => {
const error = validateField(name, values[name]);
if (error) {
newErrors[name] = error;
isValid = false;
}
});
setErrors(newErrors);
return isValid;
}, [values, validateField]);
const setValue = useCallback((name, value) => {
setValues((prev) => ({ ...prev, [name]: value }));
setDirty((prev) => ({ ...prev, [name]: true }));
if (mode === "onChange") {
const error = validateField(String(name), value);
setErrors((prev) => ({
...prev,
[name]: error || "",
}));
}
// Notify watchers
const watchers = watchersRef.current.get(String(name));
if (watchers) {
watchers.forEach((callback) => callback());
}
}, [mode, validateField]);
const getValue = useCallback((name) => {
return values[name];
}, [values]);
const getValues = useCallback(() => {
return values;
}, [values]);
const setError = useCallback((name, message) => {
setErrors((prev) => ({ ...prev, [name]: message }));
}, []);
const clearError = useCallback((name) => {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[String(name)];
return newErrors;
});
}, []);
const clearErrors = useCallback(() => {
setErrors({});
}, []);
const reset = useCallback((newValues) => {
const resetValues = newValues || defaultValues;
setValues(resetValues);
setErrors({});
setTouched({});
setDirty({});
setIsSubmitting(false);
}, [defaultValues]);
const register = useCallback((name, config) => {
if (config) {
fieldsRef.current.set(String(name), config);
}
const fieldValidation = validation[String(name)];
const isRequired = typeof (fieldValidation === null || fieldValidation === void 0 ? void 0 : fieldValidation.required) === "boolean" ? fieldValidation.required : !!(fieldValidation === null || fieldValidation === void 0 ? void 0 : fieldValidation.required);
return {
name: String(name),
value: values[name] || "",
onChange: (value) => setValue(name, value),
onBlur: () => {
setTouched((prev) => ({ ...prev, [name]: true }));
if (mode === "onBlur") {
const error = validateField(String(name), values[name]);
setErrors((prev) => ({
...prev,
[name]: error || "",
}));
}
},
error: errors[String(name)],
required: isRequired,
};
}, [values, errors, setValue, mode, validateField, validation]);
const handleSubmit = useCallback((onValid) => {
return async (e) => {
if (e === null || e === void 0 ? void 0 : e.preventDefault) {
e.preventDefault();
}
setIsSubmitting(true);
try {
const isValid = validateAllFields();
if (isValid) {
await onValid(values);
if (onSubmit) {
await onSubmit(values);
}
}
}
catch (error) {
console.error("Form submission error:", error);
}
finally {
setIsSubmitting(false);
}
};
}, [values, validateAllFields, onSubmit]);
const watch = useCallback((name) => {
if (name) {
return values[name];
}
return values;
}, [values]);
const trigger = useCallback(async (name) => {
if (name) {
const error = validateField(String(name), values[name]);
setErrors((prev) => ({
...prev,
[name]: error || "",
}));
return !error;
}
return validateAllFields();
}, [validateField, values, validateAllFields]);
const formState = useMemo(() => ({
values,
errors,
touched,
dirty,
isValid: Object.keys(errors).length === 0,
isSubmitting,
isDirty: Object.keys(dirty).length > 0,
}), [values, errors, touched, dirty, isSubmitting]);
return {
register,
setValue,
getValue,
getValues,
setError,
clearError,
clearErrors,
reset,
handleSubmit,
watch,
trigger,
formState,
};
}
const FormContext$1 = createContext(null);
function FormProvider$1({ children, form, }) {
return jsx(FormContext$1.Provider, { value: form, children: children });
}
function useFormContext$1() {
const context = useContext(FormContext$1);
if (!context) {
throw new Error("useFormContext must be used within a FormProvider");
}
return context;
}
const Input$1 = forwardRef(({ name, label, showError = true, containerClassName = "", labelClassName = "", errorClassName = "", className = "", ...props }, ref) => {
const form = useFormContext$1();
const field = form.register(name);
return (jsxs("div", { className: `form-field ${containerClassName}`, children: [label && (jsxs("label", { htmlFor: name, className: `form-label ${labelClassName}`, children: [label, field.required && jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), jsx("input", { ...props, ref: ref, id: name, name: field.name, value: field.value, onChange: (e) => field.onChange(e.target.value), onBlur: field.onBlur, className: `form-input ${field.error ? "error" : ""} ${className}`, "aria-invalid": !!field.error, "aria-describedby": field.error ? `${name}-error` : undefined }), showError && field.error && (jsx("span", { id: `${name}-error`, className: `form-error ${errorClassName}`, role: "alert", children: field.error }))] }));
});
Input$1.displayName = "Input";
const Select$1 = forwardRef(({ name, label, options, placeholder, showError = true, containerClassName = "", labelClassName = "", errorClassName = "", className = "", ...props }, ref) => {
const form = useFormContext$1();
const field = form.register(name);
return (jsxs("div", { className: `form-field ${containerClassName}`, children: [label && (jsxs("label", { htmlFor: name, className: `form-label ${labelClassName}`, children: [label, field.required && jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), jsxs("select", { ...props, ref: ref, id: name, name: field.name, value: field.value, onChange: (e) => field.onChange(e.target.value), onBlur: field.onBlur, className: `form-select ${field.error ? "error" : ""} ${className}`, "aria-invalid": !!field.error, "aria-describedby": field.error ? `${name}-error` : undefined, children: [placeholder && (jsx("option", { value: "", disabled: true, children: placeholder })), options.map((option) => (jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }), showError && field.error && (jsx("span", { id: `${name}-error`, className: `form-error ${errorClassName}`, role: "alert", children: field.error }))] }));
});
Select$1.displayName = "Select";
const Textarea = forwardRef(({ name, label, showError = true, containerClassName = "", labelClassName = "", errorClassName = "", className = "", ...props }, ref) => {
const form = useFormContext$1();
const field = form.register(name);
return (jsxs("div", { className: `form-field ${containerClassName}`, children: [label && (jsxs("label", { htmlFor: name, className: `form-label ${labelClassName}`, children: [label, field.required && jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), jsx("textarea", { ...props, ref: ref, id: name, name: field.name, value: field.value, onChange: (e) => field.onChange(e.target.value), onBlur: field.onBlur, className: `form-textarea ${field.error ? "error" : ""} ${className}`, "aria-invalid": !!field.error, "aria-describedby": field.error ? `${name}-error` : undefined }), showError && field.error && (jsx("span", { id: `${name}-error`, className: `form-error ${errorClassName}`, role: "alert", children: field.error }))] }));
});
Textarea.displayName = "Textarea";
const Checkbox$1 = forwardRef(({ name, label, showError = true, containerClassName = "", labelClassName = "", errorClassName = "", className = "", ...props }, ref) => {
const form = useFormContext$1();
const field = form.register(name);
return (jsxs("div", { className: `form-field form-checkbox ${containerClassName}`, children: [jsxs("label", { className: `form-checkbox-label ${labelClassName}`, children: [jsx("input", { ...props, ref: ref, type: "checkbox", id: name, name: field.name, checked: !!field.value, onChange: (e) => field.onChange(e.target.checked), onBlur: field.onBlur, className: `form-checkbox-input ${field.error ? "error" : ""} ${className}`, "aria-invalid": !!field.error, "aria-describedby": field.error ? `${name}-error` : undefined }), label && (jsxs("span", { className: "form-checkbox-text", children: [label, field.required && jsx("span", { className: "text-red-500 ml-1", children: "*" })] }))] }), showError && field.error && (jsx("span", { id: `${name}-error`, className: `form-error ${errorClassName}`, role: "alert", children: field.error }))] }));
});
Checkbox$1.displayName = "Checkbox";
const FormContext = createContext(null);
function FormProvider({ children, form, }) {
return jsx(FormContext.Provider, { value: form, children: children });
}
function useFormContext() {
const context = useContext(FormContext);
if (!context) {
throw new Error("useFormContext must be used within a FormProvider");
}
return context;
}
// Import React Native components (will be mocked in tests)
let View$2, Text$2, TextInput, StyleSheet$2;
try {
const RN = require("react-native");
View$2 = RN.View;
Text$2 = RN.Text;
TextInput = RN.TextInput;
StyleSheet$2 = RN.StyleSheet;
}
catch (_a) {
// Fallback for web/testing environment
View$2 = ({ children, style, ...props }) => (jsx("div", { style: style, ...props, children: children }));
Text$2 = ({ children, style, ...props }) => (jsx("span", { style: style, ...props, children: children }));
TextInput = forwardRef(({ style, onChangeText, ...props }, ref) => (jsx("input", { ref: ref, style: style, onChange: (e) => onChangeText === null || onChangeText === void 0 ? void 0 : onChangeText(e.target.value), ...props })));
StyleSheet$2 = {
create: (styles) => styles,
};
}
const Input = forwardRef(({ name, label, showError = true, containerStyle, labelStyle, inputStyle, errorStyle, style, ...props }, ref) => {
const form = useFormContext();
const field = form.register(name);
return (jsxs(View$2, { style: [styles$2.container, containerStyle], children: [label && (jsxs(Text$2, { style: [styles$2.label, labelStyle], children: [label, field.required && jsx(Text$2, { style: styles$2.required, children: " *" })] })), jsx(TextInput, { ...props, ref: ref, value: field.value || "", onChangeText: field.onChange, onBlur: field.onBlur, style: [styles$2.input, field.error ? styles$2.inputError : null, inputStyle, style], accessibilityLabel: label, accessibilityInvalid: !!field.error }), showError && field.error && jsx(Text$2, { style: [styles$2.error, errorStyle], children: field.error })] }));
});
Input.displayName = "Input";
const styles$2 = StyleSheet$2.create({
container: {
marginBottom: 16,
},
label: {
fontSize: 16,
fontWeight: "500",
marginBottom: 8,
color: "#374151",
},
required: {
color: "#EF4444",
},
input: {
borderWidth: 1,
borderColor: "#D1D5DB",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
backgroundColor: "#FFFFFF",
},
inputError: {
borderColor: "#EF4444",
},
error: {
fontSize: 14,
color: "#EF4444",
marginTop: 4,
},
});
// Import React Native components (will be mocked in tests)
let View$1, Text$1, TouchableOpacity$1, Modal, FlatList, StyleSheet$1;
try {
const RN = require("react-native");
View$1 = RN.View;
Text$1 = RN.Text;
TouchableOpacity$1 = RN.TouchableOpacity;
Modal = RN.Modal;
FlatList = RN.FlatList;
StyleSheet$1 = RN.StyleSheet;
}
catch (_a) {
// Fallback for web/testing environment
View$1 = ({ children, style, ...props }) => (jsx("div", { style: style, ...props, children: children }));
Text$1 = ({ children, style, ...props }) => (jsx("span", { style: style, ...props, children: children }));
TouchableOpacity$1 = ({ children, onPress, style, disabled, ...props }) => (jsx("button", { onClick: onPress, style: style, disabled: disabled, ...props, children: children }));
Modal = ({ children, visible, onRequestClose, ...props }) => visible ? (jsx("div", { style: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, zIndex: 1000 }, ...props, children: children })) : null;
FlatList = ({ data, renderItem, keyExtractor, ...props }) => (jsx("div", { ...props, children: data === null || data === void 0 ? void 0 : data.map((item, index) => (jsx("div", { children: renderItem({ item, index }) }, keyExtractor ? keyExtractor(item, index) : index))) }));
StyleSheet$1 = {
create: (styles) => styles,
};
}
function Select({ name, label, options, placeholder = "Select an option", showError = true, containerStyle, labelStyle, selectStyle, errorStyle, modalStyle, optionStyle, }) {
const [isVisible, setIsVisible] = useState(false);
const form = useFormContext();
const field = form.register(name);
const selectedOption = options.find((option) => option.value === field.value);
const handleSelect = (option) => {
field.onChange(option.value);
setIsVisible(false);
};
return (jsxs(View$1, { style: [styles$1.container, containerStyle], children: [label && (jsxs(Text$1, { style: [styles$1.label, labelStyle], children: [label, field.required && jsx(Text$1, { style: styles$1.required, children: " *" })] })), jsx(TouchableOpacity$1, { style: [styles$1.select, field.error ? styles$1.selectError : null, selectStyle], onPress: () => setIsVisible(true), children: jsx(Text$1, { style: [styles$1.selectText, !selectedOption && styles$1.placeholder], children: selectedOption ? selectedOption.label : placeholder }) }), showError && field.error && jsx(Text$1, { style: [styles$1.error, errorStyle], children: field.error }), jsx(Modal, { visible: isVisible, onRequestClose: () => setIsVisible(false), children: jsx(TouchableOpacity$1, { style: styles$1.modalOverlay, onPress: () => setIsVisible(false), children: jsx(View$1, { style: [styles$1.modalContent, modalStyle], children: jsx(FlatList, { data: options, keyExtractor: (item) => String(item.value), renderItem: ({ item }) => (jsx(TouchableOpacity$1, { style: [styles$1.option, item.disabled && styles$1.optionDisabled], onPress: () => !item.disabled && handleSelect(item), disabled: item.disabled, children: jsx(Text$1, { style: [styles$1.optionText, item.disabled && styles$1.optionTextDisabled, optionStyle], children: item.label }) })) }) }) }) })] }));
}
const styles$1 = StyleSheet$1.create({
container: {
marginBottom: 16,
},
label: {
fontSize: 16,
fontWeight: "500",
marginBottom: 8,
color: "#374151",
},
required: {
color: "#EF4444",
},
select: {
borderWidth: 1,
borderColor: "#D1D5DB",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: "#FFFFFF",
},
selectError: {
borderColor: "#EF4444",
},
selectText: {
fontSize: 16,
color: "#374151",
},
placeholder: {
color: "#9CA3AF",
},
error: {
fontSize: 14,
color: "#EF4444",
marginTop: 4,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContent: {
backgroundColor: "#FFFFFF",
borderRadius: 12,
maxHeight: 300,
width: "80%",
elevation: 5,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
option: {
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#F3F4F6",
},
optionDisabled: {
opacity: 0.5,
},
optionText: {
fontSize: 16,
color: "#374151",
},
optionTextDisabled: {
color: "#9CA3AF",
},
});
// Import React Native components (will be mocked in tests)
let View, Text, TouchableOpacity, StyleSheet;
try {
const RN = require("react-native");
View = RN.View;
Text = RN.Text;
TouchableOpacity = RN.TouchableOpacity;
StyleSheet = RN.StyleSheet;
}
catch (_a) {
// Fallback for web/testing environment
View = ({ children, style, ...props }) => (jsx("div", { style: style, ...props, children: children }));
Text = ({ children, style, ...props }) => (jsx("span", { style: style, ...props, children: children }));
TouchableOpacity = ({ children, onPress, style, ...props }) => (jsx("button", { onClick: onPress, style: style, ...props, children: children }));
StyleSheet = {
create: (styles) => styles,
};
}
function Checkbox({ name, label, showError = true, containerStyle, labelStyle, checkboxStyle, errorStyle, }) {
const form = useFormContext();
const field = form.register(name);
return (jsxs(View, { style: [styles.container, containerStyle], children: [jsxs(TouchableOpacity, { style: styles.checkboxContainer, onPress: () => field.onChange(!field.value), children: [jsx(View, { style: [
styles.checkbox,
field.value && styles.checkboxChecked,
field.error && styles.checkboxError,
checkboxStyle,
], children: field.value && jsx(Text, { style: styles.checkmark, children: "\u2713" }) }), label && (jsxs(Text, { style: [styles.label, labelStyle], children: [label, field.required && jsx(Text, { style: styles.required, children: " *" })] }))] }), showError && field.error && jsx(Text, { style: [styles.error, errorStyle], children: field.error })] }));
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
checkboxContainer: {
flexDirection: "row",
alignItems: "center",
},
checkbox: {
width: 20,
height: 20,
borderWidth: 2,
borderColor: "#D1D5DB",
borderRadius: 4,
marginRight: 12,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#FFFFFF",
},
checkboxChecked: {
backgroundColor: "#3B82F6",
borderColor: "#3B82F6",
},
checkboxError: {
borderColor: "#EF4444",
},
checkmark: {
color: "#FFFFFF",
fontSize: 14,
fontWeight: "bold",
},
label: {
fontSize: 16,
color: "#374151",
flex: 1,
},
required: {
color: "#EF4444",
},
error: {
fontSize: 14,
color: "#EF4444",
marginTop: 4,
marginLeft: 32,
},
});
var index$1 = /*#__PURE__*/Object.freeze({
__proto__: null,
Checkbox: Checkbox$1,
FormProvider: FormProvider$1,
Input: Input$1,
Select: Select$1,
Textarea: Textarea,
WebCheckbox: Checkbox$1,
WebFormProvider: FormProvider$1,
WebInput: Input$1,
WebSelect: Select$1,
WebTextarea: Textarea,
useFormContext: useFormContext$1,
useWebFormContext: useFormContext$1
});
var index = /*#__PURE__*/Object.freeze({
__proto__: null,
Checkbox: Checkbox,
FormProvider: FormProvider,
Input: Input,
NativeCheckbox: Checkbox,
NativeFormProvider: FormProvider,
NativeInput: Input,
NativeSelect: Select,
Select: Select,
useFormContext: useFormContext,
useNativeFormContext: useFormContext
});
export { FormValidator, index as Native, Checkbox as NativeCheckbox, FormProvider as NativeFormProvider, Input as NativeInput, Select as NativeSelect, Schema, index$1 as Web, Checkbox$1 as WebCheckbox, FormProvider$1 as WebFormProvider, Input$1 as WebInput, Select$1 as WebSelect, Textarea as WebTextarea, useForm, useFormContext as useNativeFormContext, useFormContext$1 as useWebFormContext };
//# sourceMappingURL=index.esm.js.map