UNPKG

@shezy26/validajs

Version:

Universal form validation library with Laravel-style syntax for vanilla JS, React, and Vue

1,102 lines (940 loc) 29.8 kB
import { useState, useCallback } from 'react'; /** * ValidaJS - Laravel-style Validation Rules * Client-side implementation of Laravel validation rules */ function required(value) { if (value === null || value === undefined) return false; if (typeof value === "string") return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; return true; } function email(value) { if (!value) return true; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(String(value)); } function min(value, [minValue]) { if (!value) return true; minValue = Number(minValue); if (typeof value === "string") return value.length >= minValue; if (typeof value === "number") return value >= minValue; if (Array.isArray(value)) return value.length >= minValue; return true; } function max(value, [maxValue]) { if (!value) return true; maxValue = Number(maxValue); if (typeof value === "string") return value.length <= maxValue; if (typeof value === "number") return value <= maxValue; if (Array.isArray(value)) return value.length <= maxValue; return true; } function between(value, [min, max]) { if (!value) return true; min = Number(min); max = Number(max); // Try to convert to number first const numValue = Number(value); if (!isNaN(numValue) && isFinite(numValue)) { return numValue >= min && numValue <= max; } // For non-numeric strings, check length if (typeof value === "string") { const length = value.length; return length >= min && length <= max; } if (Array.isArray(value)) { const length = value.length; return length >= min && length <= max; } return true; } function numeric(value) { if (!value) return true; return !isNaN(parseFloat(value)) && isFinite(value); } function integer(value) { if (!value) return true; return Number.isInteger(Number(value)); } function alpha(value) { if (!value) return true; return /^[a-zA-Z]+$/.test(String(value)); } function alpha_dash(value) { if (!value) return true; return /^[a-zA-Z0-9_-]+$/.test(String(value)); } function alpha_num(value) { if (!value) return true; return /^[a-zA-Z0-9]+$/.test(String(value)); } function url(value) { if (!value) return true; try { new URL(value); return true; } catch { return false; } } function same(value, [otherField], allValues) { if (!value) return true; return value === allValues[otherField]; } function different(value, [otherField], allValues) { if (!value) return true; return value !== allValues[otherField]; } function confirmed(value, params, allValues, fieldName) { if (!value) return true; const confirmField = `${fieldName}_confirmation`; return value === allValues[confirmField]; } function in_rule(value, params) { if (!value) return true; return params.includes(String(value)); } function not_in(value, params) { if (!value) return true; return !params.includes(String(value)); } function boolean(value) { if (value === null || value === undefined) return true; const validValues = [true, false, 1, 0, "1", "0", "true", "false"]; return validValues.includes(value); } function accepted(value) { const acceptedValues = ["yes", "on", "1", 1, true, "true"]; return acceptedValues.includes(value); } function declined(value) { const declinedValues = ["no", "off", "0", 0, false, "false"]; return declinedValues.includes(value); } function size(value, [sizeValue]) { if (!value) return true; sizeValue = Number(sizeValue); if (typeof value === "string") return value.length === sizeValue; if (typeof value === "number") return value === sizeValue; if (Array.isArray(value)) return value.length === sizeValue; return true; } function digits(value, [length]) { if (!value) return true; const str = String(value); return /^\d+$/.test(str) && str.length === Number(length); } function digits_between(value, [min, max]) { if (!value) return true; const str = String(value); const length = str.length; return /^\d+$/.test(str) && length >= Number(min) && length <= Number(max); } function date(value) { if (!value) return true; const dateObj = new Date(value); return !isNaN(dateObj.getTime()); } function before(value, [compareDate]) { if (!value) return true; const dateObj = new Date(value); const compare = new Date(compareDate); return ( !isNaN(dateObj.getTime()) && !isNaN(compare.getTime()) && dateObj < compare ); } function after(value, [compareDate]) { if (!value) return true; const dateObj = new Date(value); const compare = new Date(compareDate); return ( !isNaN(dateObj.getTime()) && !isNaN(compare.getTime()) && dateObj > compare ); } function before_or_equal(value, [compareDate]) { if (!value) return true; const dateObj = new Date(value); const compare = new Date(compareDate); return ( !isNaN(dateObj.getTime()) && !isNaN(compare.getTime()) && dateObj <= compare ); } function after_or_equal(value, [compareDate]) { if (!value) return true; const dateObj = new Date(value); const compare = new Date(compareDate); return ( !isNaN(dateObj.getTime()) && !isNaN(compare.getTime()) && dateObj >= compare ); } function regex(value, [pattern]) { if (!value) return true; try { const regexObj = new RegExp(pattern); return regexObj.test(String(value)); } catch { console.warn(`Invalid regex pattern: ${pattern}`); return false; } } function string(value) { if (value === null || value === undefined) return true; return typeof value === "string"; } function nullable(value) { return true; } function array(value) { if (value === null || value === undefined) return true; return Array.isArray(value); } function starts_with(value, params) { if (!value) return true; const str = String(value); return params.some((prefix) => str.startsWith(prefix)); } function ends_with(value, params) { if (!value) return true; const str = String(value); return params.some((suffix) => str.endsWith(suffix)); } function lowercase(value) { if (!value) return true; return String(value) === String(value).toLowerCase(); } function uppercase(value) { if (!value) return true; return String(value) === String(value).toUpperCase(); } function ip(value) { if (!value) return true; const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; if (ipv4Regex.test(value)) { const parts = value.split("."); return parts.every((part) => Number(part) >= 0 && Number(part) <= 255); } const ipv6Regex = /^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$/; return ipv6Regex.test(value); } function ipv4(value) { if (!value) return true; const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; if (!ipv4Regex.test(value)) return false; const parts = value.split("."); return parts.every((part) => Number(part) >= 0 && Number(part) <= 255); } function ipv6(value) { if (!value) return true; const ipv6Regex = /^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$/; return ipv6Regex.test(value); } function json(value) { if (!value) return true; try { JSON.parse(value); return true; } catch { return false; } } function uuid(value) { if (!value) return true; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(String(value)); } function gt(value, [otherField], allValues) { if (!value) return true; const compareValue = allValues[otherField]; if (compareValue === undefined) return true; return Number(value) > Number(compareValue); } function gte(value, [otherField], allValues) { if (!value) return true; const compareValue = allValues[otherField]; if (compareValue === undefined) return true; return Number(value) >= Number(compareValue); } function lt(value, [otherField], allValues) { if (!value) return true; const compareValue = allValues[otherField]; if (compareValue === undefined) return true; return Number(value) < Number(compareValue); } function lte(value, [otherField], allValues) { if (!value) return true; const compareValue = allValues[otherField]; if (compareValue === undefined) return true; return Number(value) <= Number(compareValue); } function required_if(value, [otherField, compareValue], allValues) { const otherValue = allValues[otherField]; if (String(otherValue) === String(compareValue)) { return required(value); } return true; } function required_with(value, params, allValues) { const hasAnyField = params.some((field) => { const fieldValue = allValues[field]; return fieldValue !== null && fieldValue !== undefined && fieldValue !== ""; }); if (hasAnyField) { return required(value); } return true; } function required_without(value, params, allValues) { const hasAnyFieldMissing = params.some((field) => { const fieldValue = allValues[field]; return fieldValue === null || fieldValue === undefined || fieldValue === ""; }); if (hasAnyFieldMissing) { return required(value); } return true; } function ulid(value) { if (!value) return true; const ulidRegex = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i; return ulidRegex.test(String(value)); } function mac_address(value) { if (!value) return true; const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; return macRegex.test(String(value)); } function ascii(value) { if (!value) return true; return /^[\x00-\x7F]*$/.test(String(value)); } function hex_color(value) { if (!value) return true; return /^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(String(value)); } function password(value, [minLength = 8]) { if (!value) return true; const min = Number(minLength); if (value.length < min) return false; const hasLowercase = /[a-z]/.test(value); const hasUppercase = /[A-Z]/.test(value); const hasNumber = /[0-9]/.test(value); const hasSymbol = /[!@#$%^&*(),.?":{}|<>]/.test(value); return hasLowercase && hasUppercase && hasNumber && hasSymbol; } function max_digits(value, [maxDigits]) { if (!value) return true; const str = String(value).replace(/[^0-9]/g, ""); return str.length <= Number(maxDigits); } function min_digits(value, [minDigits]) { if (!value) return true; const str = String(value).replace(/[^0-9]/g, ""); return str.length >= Number(minDigits); } function decimal(value, [min, max]) { if (!value) return true; if (!numeric(value)) return false; const str = String(value); const parts = str.split("."); if (parts.length === 1) return min === undefined || Number(min) === 0; const decimalPlaces = parts[1].length; if (max !== undefined) { return decimalPlaces >= (Number(min) || 0) && decimalPlaces <= Number(max); } return decimalPlaces === Number(min); } function multiple_of(value, [multipleOf]) { if (!value) return true; const num = Number(value); const multiple = Number(multipleOf); if (isNaN(num) || isNaN(multiple)) return false; return num % multiple === 0; } function active_url(value) { if (!value) return true; try { const urlObj = new URL(value); return urlObj.protocol === "http:" || urlObj.protocol === "https:"; } catch { return false; } } function timezone(value) { if (!value) return true; try { Intl.DateTimeFormat(undefined, { timeZone: value }); return true; } catch { return false; } } function date_equals(value, [compareDate]) { if (!value) return true; const date1 = new Date(value); const date2 = new Date(compareDate); if (isNaN(date1.getTime()) || isNaN(date2.getTime())) return false; return date1.toDateString() === date2.toDateString(); } function date_format(value, [format]) { if (!value) return true; if (format === "Y-m-d") { return /^\d{4}-\d{2}-\d{2}$/.test(value) && date(value); } if (format === "d/m/Y" || format === "m/d/Y") { return /^\d{2}\/\d{2}\/\d{4}$/.test(value); } return date(value); } function not_regex(value, [pattern]) { if (!value) return true; try { const regexObj = new RegExp(pattern); return !regexObj.test(String(value)); } catch { console.warn(`Invalid regex pattern: ${pattern}`); return false; } } function doesnt_start_with(value, params) { if (!value) return true; const str = String(value); return !params.some((prefix) => str.startsWith(prefix)); } function doesnt_end_with(value, params) { if (!value) return true; const str = String(value); return !params.some((suffix) => str.endsWith(suffix)); } function present(value) { return value !== undefined; } function filled(value) { if (value === null || value === undefined) return true; if (typeof value === "string") return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; return true; } function prohibited(value) { if (value === null || value === undefined) return true; if (typeof value === "string") return value.trim().length === 0; if (Array.isArray(value)) return value.length === 0; return false; } function distinct(value) { if (value === null || value === undefined) return true; if (!Array.isArray(value)) return true; const unique = [...new Set(value)]; return unique.length === value.length; } function required_unless( value, [otherField, ...compareValues], allValues ) { const otherValue = allValues[otherField]; const shouldBeRequired = !compareValues.some( (v) => String(otherValue) === String(v) ); if (shouldBeRequired) { return required(value); } return true; } function required_with_all(value, params, allValues) { const hasAllFields = params.every((field) => { const fieldValue = allValues[field]; return fieldValue !== null && fieldValue !== undefined && fieldValue !== ""; }); if (hasAllFields) { return required(value); } return true; } function required_without_all(value, params, allValues) { const allFieldsMissing = params.every((field) => { const fieldValue = allValues[field]; return fieldValue === null || fieldValue === undefined || fieldValue === ""; }); if (allFieldsMissing) { return required(value); } return true; } function accepted_if(value, [otherField, compareValue], allValues) { const otherValue = allValues[otherField]; if (String(otherValue) === String(compareValue)) { return accepted(value); } return true; } function declined_if(value, [otherField, compareValue], allValues) { const otherValue = allValues[otherField]; if (String(otherValue) === String(compareValue)) { return declined(value); } return true; } var rules = /*#__PURE__*/Object.freeze({ __proto__: null, accepted: accepted, accepted_if: accepted_if, active_url: active_url, after: after, after_or_equal: after_or_equal, alpha: alpha, alpha_dash: alpha_dash, alpha_num: alpha_num, array: array, ascii: ascii, before: before, before_or_equal: before_or_equal, between: between, boolean: boolean, confirmed: confirmed, date: date, date_equals: date_equals, date_format: date_format, decimal: decimal, declined: declined, declined_if: declined_if, different: different, digits: digits, digits_between: digits_between, distinct: distinct, doesnt_end_with: doesnt_end_with, doesnt_start_with: doesnt_start_with, email: email, ends_with: ends_with, filled: filled, gt: gt, gte: gte, hex_color: hex_color, in: in_rule, in_rule: in_rule, integer: integer, ip: ip, ipv4: ipv4, ipv6: ipv6, json: json, lowercase: lowercase, lt: lt, lte: lte, mac_address: mac_address, max: max, max_digits: max_digits, min: min, min_digits: min_digits, multiple_of: multiple_of, not_in: not_in, not_regex: not_regex, nullable: nullable, numeric: numeric, password: password, present: present, prohibited: prohibited, regex: regex, required: required, required_if: required_if, required_unless: required_unless, required_with: required_with, required_with_all: required_with_all, required_without: required_without, required_without_all: required_without_all, same: same, size: size, starts_with: starts_with, string: string, timezone: timezone, ulid: ulid, uppercase: uppercase, url: url, uuid: uuid }); /** * Rule Engine * Manages and executes validation rules */ class RuleEngine { constructor() { this.rules = new Map(); this.registerDefaultRules(); } registerDefaultRules() { for (const [name, ruleFn] of Object.entries(rules)) { this.register(name, ruleFn); } } register(name, validator) { if (typeof validator !== "function") { throw new Error(`Validator for rule "${name}" must be a function`); } this.rules.set(name, validator); } validate(ruleName, value, params = [], allValues = {}, fieldName = "") { const validator = this.rules.get(ruleName); if (!validator) { console.warn(`Validation rule "${ruleName}" not found`); return true; } try { return validator(value, params, allValues, fieldName); } catch (error) { console.error(`Error executing validation rule "${ruleName}":`, error); return false; } } has(ruleName) { return this.rules.has(ruleName); } getAllRules() { return Array.from(this.rules.keys()); } unregister(ruleName) { return this.rules.delete(ruleName); } } /** * Parser Utility * Parse Laravel-style validation rule strings */ function parseRule(rule) { if (typeof rule !== "string") { return { name: rule, params: [] }; } const parts = rule.split(":"); const name = parts[0]; const params = parts[1] ? parts[1].split(",") : []; return { name, params }; } function normalizeSchema(schema) { const normalized = {}; for (const [field, rules] of Object.entries(schema)) { if (Array.isArray(rules)) { normalized[field] = rules; } else if (typeof rules === "string") { normalized[field] = rules.split("|"); } else { normalized[field] = [rules]; } } return normalized; } /** * Default Error Messages * Laravel-style error messages for validation rules */ const defaultMessages = { required: "The :attribute field is required.", email: "The :attribute must be a valid email address.", min: "The :attribute must be at least :min characters.", max: "The :attribute may not be greater than :max characters.", between: "The :attribute must be between :min and :max.", numeric: "The :attribute must be a number.", integer: "The :attribute must be an integer.", alpha: "The :attribute may only contain letters.", alpha_dash: "The :attribute may only contain letters, numbers, dashes and underscores.", alpha_num: "The :attribute may only contain letters and numbers.", url: "The :attribute must be a valid URL.", same: "The :attribute and :other must match.", different: "The :attribute and :other must be different.", confirmed: "The :attribute confirmation does not match.", in: "The selected :attribute is invalid.", not_in: "The selected :attribute is invalid.", boolean: "The :attribute field must be true or false.", accepted: "The :attribute must be accepted.", declined: "The :attribute must be declined.", size: "The :attribute must be :size.", digits: "The :attribute must be :digits digits.", digits_between: "The :attribute must be between :min and :max digits.", date: "The :attribute is not a valid date.", before: "The :attribute must be a date before :date.", after: "The :attribute must be a date after :date.", before_or_equal: "The :attribute must be a date before or equal to :date.", after_or_equal: "The :attribute must be a date after or equal to :date.", regex: "The :attribute format is invalid.", string: "The :attribute must be a string.", nullable: "The :attribute field is optional.", array: "The :attribute must be an array.", starts_with: "The :attribute must start with one of the following: :values.", ends_with: "The :attribute must end with one of the following: :values.", lowercase: "The :attribute must be lowercase.", uppercase: "The :attribute must be uppercase.", ip: "The :attribute must be a valid IP address.", ipv4: "The :attribute must be a valid IPv4 address.", ipv6: "The :attribute must be a valid IPv6 address.", json: "The :attribute must be a valid JSON string.", uuid: "The :attribute must be a valid UUID.", gt: "The :attribute must be greater than :other.", gte: "The :attribute must be greater than or equal to :other.", lt: "The :attribute must be less than :other.", lte: "The :attribute must be less than or equal to :other.", required_if: "The :attribute field is required when :other is :value.", required_with: "The :attribute field is required when :values is present.", required_without: "The :attribute field is required when :values is not present.", ulid: "The :attribute must be a valid ULID.", mac_address: "The :attribute must be a valid MAC address.", ascii: "The :attribute must only contain single-byte alphanumeric characters and symbols.", hex_color: "The :attribute must be a valid hexadecimal color.", password: "The :attribute must be at least :min characters and contain uppercase, lowercase, numbers, and symbols.", max_digits: "The :attribute must not have more than :max digits.", min_digits: "The :attribute must have at least :min digits.", decimal: "The :attribute must have :decimal decimal places.", multiple_of: "The :attribute must be a multiple of :value.", active_url: "The :attribute is not a valid URL.", timezone: "The :attribute must be a valid timezone.", date_equals: "The :attribute must be a date equal to :date.", date_format: "The :attribute does not match the format :format.", not_regex: "The :attribute format must not match the given pattern.", doesnt_start_with: "The :attribute may not start with one of the following: :values.", doesnt_end_with: "The :attribute may not end with one of the following: :values.", present: "The :attribute field must be present.", filled: "The :attribute field must have a value.", prohibited: "The :attribute field is prohibited.", distinct: "The :attribute field has a duplicate value.", required_unless: "The :attribute field is required unless :other is in :values.", required_with_all: "The :attribute field is required when :values are present.", required_without_all: "The :attribute field is required when none of :values are present.", accepted_if: "The :attribute must be accepted when :other is :value.", declined_if: "The :attribute must be declined when :other is :value.", }; /** * ValidaJS Core Validator * Framework-agnostic validation engine */ class Validator { constructor(schema, options = {}) { this.schema = schema; this.options = { realtime: true, validateOnBlur: true, validateOnInput: true, validateOnSubmit: true, messages: {}, ...options, }; this.ruleEngine = new RuleEngine(); this.errors = {}; this.touched = {}; this.values = {}; this.customMessages = { ...defaultMessages, ...this.options.messages }; } validateField(fieldName, value, allValues = {}) { const rules = this.schema[fieldName]; if (!rules || !Array.isArray(rules)) { return null; } delete this.errors[fieldName]; for (const rule of rules) { const { name, params } = parseRule(rule); const isValid = this.ruleEngine.validate( name, value, params, allValues, fieldName ); if (!isValid) { this.errors[fieldName] = this.getErrorMessage(fieldName, name, params); break; } } return this.errors[fieldName] || null; } validateAll(values) { this.errors = {}; this.values = values; for (const fieldName in this.schema) { const value = values[fieldName]; this.validateField(fieldName, value, values); } return { isValid: Object.keys(this.errors).length === 0, errors: this.errors, }; } getErrorMessage(fieldName, ruleName, params) { const customKey = `${fieldName}.${ruleName}`; if (this.customMessages[customKey]) { return this.formatMessage( this.customMessages[customKey], fieldName, params ); } if (this.customMessages[ruleName]) { return this.formatMessage( this.customMessages[ruleName], fieldName, params ); } const defaultMessage = defaultMessages[ruleName] || "The :attribute field is invalid."; return this.formatMessage(defaultMessage, fieldName, params); } formatMessage(message, fieldName, params = []) { let formatted = message.replace( ":attribute", this.formatFieldName(fieldName) ); // Replace specific parameter placeholders first if (params.length > 0) { formatted = formatted.replace(":min", params[0]); formatted = formatted.replace(":size", params[0]); formatted = formatted.replace(":other", params[0]); formatted = formatted.replace(":date", params[0]); formatted = formatted.replace(":digits", params[0]); formatted = formatted.replace(":value", params[0]); } if (params.length > 1) { formatted = formatted.replace(":max", params[1]); } // Replace generic parameter placeholders params.forEach((param, index) => { formatted = formatted.replace(`:param${index}`, param); }); // Replace :values with comma-separated list if (params.length > 0) { formatted = formatted.replace(":values", params.join(", ")); } return formatted; } formatFieldName(fieldName) { return fieldName .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()); } touch(fieldName) { this.touched[fieldName] = true; } isTouched(fieldName) { return !!this.touched[fieldName]; } getAllValues() { return this.values; } clearErrors() { this.errors = {}; } clearFieldError(fieldName) { delete this.errors[fieldName]; } getErrors() { return this.errors; } hasErrors() { return Object.keys(this.errors).length > 0; } getFieldError(fieldName) { return this.errors[fieldName] || null; } reset() { this.errors = {}; this.touched = {}; this.values = {}; } } /** * ValidaJS - React Adapter * React hook for form validation */ function useValidaJS(schema, options = {}) { const [values, setValues] = useState({}); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const validator = useState( () => new Validator(normalizeSchema(schema), options) )[0]; const validateField = useCallback( (fieldName, value) => { const error = validator.validateField(fieldName, value, values); setErrors((prev) => ({ ...prev, [fieldName]: error, })); return error; }, [validator, values] ); const validateAll = useCallback(() => { const result = validator.validateAll(values); setErrors(result.errors); return result; }, [validator, values]); const register = useCallback( (fieldName) => { return { name: fieldName, value: values[fieldName] || "", onChange: (e) => { const value = e.target.type === "checkbox" ? e.target.checked : e.target.value; setValues((prev) => ({ ...prev, [fieldName]: value })); if (touched[fieldName]) { validateField(fieldName, value); } }, onBlur: () => { setTouched((prev) => ({ ...prev, [fieldName]: true })); validateField(fieldName, values[fieldName]); }, }; }, [values, touched, validateField] ); const handleSubmit = useCallback( (onSuccess, onError) => { return (e) => { e.preventDefault(); const result = validateAll(); if (result.isValid) { onSuccess?.(values); } else { onError?.(result.errors); } }; }, [values, validateAll] ); const reset = useCallback(() => { setValues({}); setErrors({}); setTouched({}); validator.reset(); }, [validator]); const setValue = useCallback((fieldName, value) => { setValues((prev) => ({ ...prev, [fieldName]: value })); }, []); const getError = useCallback( (fieldName) => { return errors[fieldName] || null; }, [errors] ); return { values, errors, touched, isValid: Object.keys(errors).length === 0, register, handleSubmit, validateField, validateAll, reset, setValue, getError, }; } export { useValidaJS };