UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

857 lines (856 loc) 26.8 kB
/** * Enhanced validation utilities for ClarityKit components * Provides comprehensive form validation with TypeScript support */ import { writable, derived } from 'svelte/store'; import { announceFormValidation } from './accessibility.js'; /** * Validates an email address * @param email - The email address to validate * @returns Boolean indicating if the email is valid */ export function isValidEmail(email) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } /** * Validates a URL * @param url - The URL to validate * @returns Boolean indicating if the URL is valid */ export function isValidUrl(url) { try { new URL(url); return true; } catch { return false; } } /** * Validates a phone number (basic validation) * @param phone - The phone number to validate * @returns Boolean indicating if the phone number is valid */ export function isValidPhone(phone) { const phoneRegex = /^\+?[0-9]{10,15}$/; return phoneRegex.test(phone.replace(/[\s()-]/g, '')); } /** * Validates a password against common strength requirements * @param password - The password to validate * @returns An object with validation results and a strength score */ export function validatePassword(password) { const hasMinLength = password.length >= 8; const hasUppercase = /[A-Z]/.test(password); const hasLowercase = /[a-z]/.test(password); const hasNumber = /[0-9]/.test(password); const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password); // Calculate strength score (0-5) const score = [hasMinLength, hasUppercase, hasLowercase, hasNumber, hasSpecialChar].filter(Boolean).length; // Determine strength category let strength; if (score <= 2) strength = 'weak'; else if (score <= 4) strength = 'medium'; else strength = 'strong'; // Password is valid if it meets minimum requirements const valid = hasMinLength && (hasUppercase || hasLowercase) && (hasNumber || hasSpecialChar); return { valid, hasMinLength, hasUppercase, hasLowercase, hasNumber, hasSpecialChar, strength }; } /** * Validates a date string * @param dateString - The date string to validate * @returns Boolean indicating if the date is valid */ export function isValidDate(dateString) { // Try ISO format first (YYYY-MM-DD) if (dateString.match(/^\d{4}-\d{2}-\d{2}$/)) { const [year, month, day] = dateString.split('-').map(Number); if (isNaN(year) || isNaN(month) || isNaN(day)) return false; const date = new Date(year, month - 1, day); return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day; } // For other formats, use native Date parsing with validation const date = new Date(dateString); const isValid = !isNaN(date.getTime()); // Additional check: make sure the parsed date string is reasonable if (isValid && dateString.length > 0) { // Check for obvious invalid dates like '2023-13-01' or '2023-02-30' if (dateString.match(/\d{4}-\d{2}-\d{2}/)) { const [year, month, day] = dateString.split('-').map(Number); if (month > 12 || month < 1 || day > 31 || day < 1) return false; // Check for February 30th and other invalid combinations if (month === 2 && day > 29) return false; if (month === 2 && day === 29) { // Check leap year const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); if (!isLeapYear) return false; } } } return isValid; } /** * Validates a credit card number using Luhn algorithm * @param cardNumber - The credit card number to validate * @returns Boolean indicating if the card number is valid */ export function isValidCreditCard(cardNumber) { const sanitized = cardNumber.replace(/\D/g, ''); if (sanitized.length < 13 || sanitized.length > 19) return false; // Luhn algorithm let sum = 0; let double = false; for (let i = sanitized.length - 1; i >= 0; i--) { let digit = parseInt(sanitized.charAt(i)); if (double) { digit *= 2; if (digit > 9) digit -= 9; } sum += digit; double = !double; } return sum % 10 === 0; } /** * Built-in validation rules */ export const ValidationRules = { required: (message = 'This field is required') => ({ name: 'required', validator: (value) => ({ valid: value !== null && value !== undefined && value !== '', message }), message }), minLength: (min, message) => ({ name: 'minLength', validator: (value) => ({ valid: !value || value.length >= min, message: message || `Must be at least ${min} characters` }), message: message || `Must be at least ${min} characters` }), maxLength: (max, message) => ({ name: 'maxLength', validator: (value) => ({ valid: !value || value.length <= max, message: message || `Must be no more than ${max} characters` }), message: message || `Must be no more than ${max} characters` }), pattern: (regex, message = 'Invalid format') => ({ name: 'pattern', validator: (value) => ({ valid: !value || regex.test(value), message }), message }), email: (message = 'Please enter a valid email address') => ({ name: 'email', validator: (value) => ({ valid: !value || isValidEmail(value), message }), message }), url: (message = 'Please enter a valid URL') => ({ name: 'url', validator: (value) => ({ valid: !value || isValidUrl(value), message }), message }), phone: (message = 'Please enter a valid phone number') => ({ name: 'phone', validator: (value) => ({ valid: !value || isValidPhone(value), message }), message }), min: (min, message) => ({ name: 'min', validator: (value) => ({ valid: value === null || value === undefined || value >= min, message: message || `Must be at least ${min}` }), message: message || `Must be at least ${min}` }), max: (max, message) => ({ name: 'max', validator: (value) => ({ valid: value === null || value === undefined || value <= max, message: message || `Must be no more than ${max}` }), message: message || `Must be no more than ${max}` }), custom: (validator, message = 'Invalid value') => ({ name: 'custom', validator: async (value, context) => { const result = await validator(value, context); return { valid: result, message }; }, message }), conditional: (condition, rule) => ({ name: `conditional_${rule.name}`, validator: async (value, context) => { // If condition is not met, always return valid if (context && !condition(context)) { return { valid: true }; } // If condition is met, run the underlying rule return await rule.validator(value, context); }, message: rule.message, debounce: rule.debounce }), matchField: (fieldName, message = 'Fields do not match') => ({ name: 'matchField', validator: (value, context) => ({ valid: !value || !context || value === context.formData[fieldName], message }), message }) }; /** * Creates a debounced validation function */ function createDebouncedValidator(validator, delay) { let timeoutId; let lastResolve = null; return (value, context) => { return new Promise((resolve) => { // Clear any previous timeout clearTimeout(timeoutId); // If there's a previous resolve pending, resolve it with valid: true // This simulates canceling the previous validation if (lastResolve) { lastResolve({ valid: true }); } lastResolve = resolve; timeoutId = setTimeout(async () => { lastResolve = null; try { const result = await validator(value, context); resolve(result); } catch (error) { resolve({ valid: false, message: 'Validation error' }); } }, delay); }); }; } /** * Field validator class for managing validation of individual form fields */ export class FieldValidator { constructor(name, initialValue, rules = []) { Object.defineProperty(this, "name", { enumerable: true, configurable: true, writable: true, value: name }); Object.defineProperty(this, "rules", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "_value", { enumerable: true, configurable: true, writable: true, value: writable(null) }); Object.defineProperty(this, "_error", { enumerable: true, configurable: true, writable: true, value: writable(null) }); Object.defineProperty(this, "_touched", { enumerable: true, configurable: true, writable: true, value: writable(false) }); Object.defineProperty(this, "_dirty", { enumerable: true, configurable: true, writable: true, value: writable(false) }); Object.defineProperty(this, "_validating", { enumerable: true, configurable: true, writable: true, value: writable(false) }); Object.defineProperty(this, "debouncedValidators", { enumerable: true, configurable: true, writable: true, value: new Map() }); Object.defineProperty(this, "value", { enumerable: true, configurable: true, writable: true, value: this._value }); Object.defineProperty(this, "error", { enumerable: true, configurable: true, writable: true, value: this._error }); Object.defineProperty(this, "touched", { enumerable: true, configurable: true, writable: true, value: this._touched }); Object.defineProperty(this, "dirty", { enumerable: true, configurable: true, writable: true, value: this._dirty }); Object.defineProperty(this, "validating", { enumerable: true, configurable: true, writable: true, value: this._validating }); Object.defineProperty(this, "valid", { enumerable: true, configurable: true, writable: true, value: derived(this._error, ($error) => $error === null) }); this.rules = rules; if (initialValue !== undefined) { this._value.set(initialValue); } } /** * Add a validation rule */ addRule(rule) { this.rules.push(rule); return this; } /** * Add multiple validation rules */ addRules(rules) { this.rules.push(...rules); return this; } /** * Set the field value and trigger validation */ async setValue(value, context) { this._value.set(value); this._dirty.set(true); await this.validate(context); } /** * Mark the field as touched */ setTouched(touched = true) { this._touched.set(touched); } /** * Validate the current value */ async validate(context) { const currentValue = await new Promise(resolve => { let unsubscribe; unsubscribe = this._value.subscribe(value => { resolve(value); unsubscribe?.(); }); }); this._validating.set(true); this._error.set(null); const validationContext = context || { fieldName: this.name, formData: {}, touched: false, dirty: false, submitAttempted: false }; try { for (const rule of this.rules) { // Skip rule if condition is not met if (rule.when && !rule.when(validationContext)) { continue; } let validator; if (rule.debounce) { // Use cached debounced validator or create new one const cacheKey = `${rule.name}_${rule.debounce}`; if (!this.debouncedValidators.has(cacheKey)) { this.debouncedValidators.set(cacheKey, createDebouncedValidator(rule.validator, rule.debounce)); } validator = this.debouncedValidators.get(cacheKey); } else { validator = rule.validator; } const result = await validator(currentValue, validationContext); if (!result.valid) { this._error.set(result.message || rule.message || 'Invalid value'); this._validating.set(false); return false; } } this._validating.set(false); return true; } catch (error) { this._error.set('Validation error occurred'); this._validating.set(false); return false; } } /** * Clear validation error */ clearError() { this._error.set(null); } /** * Reset field to initial state */ reset(value) { this._value.set(value ?? null); this._error.set(null); this._touched.set(false); this._dirty.set(false); this._validating.set(false); } /** * Get current field state */ async getState() { const [value, error, touched, dirty, validating, valid] = await Promise.all([ new Promise(resolve => { let unsubscribe; unsubscribe = this._value.subscribe(v => { resolve(v); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this._error.subscribe(e => { resolve(e); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this._touched.subscribe(t => { resolve(t); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this._dirty.subscribe(d => { resolve(d); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this._validating.subscribe(v => { resolve(v); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this.valid.subscribe(v => { resolve(v); unsubscribe?.(); }); }) ]); return { value: value, error, touched, dirty, validating, valid }; } } /** * Form validator class for managing validation of entire forms */ export class FormValidator { constructor() { Object.defineProperty(this, "fields", { enumerable: true, configurable: true, writable: true, value: new Map() }); Object.defineProperty(this, "_submitting", { enumerable: true, configurable: true, writable: true, value: writable(false) }); Object.defineProperty(this, "_submitAttempted", { enumerable: true, configurable: true, writable: true, value: writable(false) }); Object.defineProperty(this, "_formValid", { enumerable: true, configurable: true, writable: true, value: writable(true) }); Object.defineProperty(this, "_formData", { enumerable: true, configurable: true, writable: true, value: writable({}) }); Object.defineProperty(this, "submitting", { enumerable: true, configurable: true, writable: true, value: this._submitting }); Object.defineProperty(this, "submitAttempted", { enumerable: true, configurable: true, writable: true, value: this._submitAttempted }); Object.defineProperty(this, "valid", { enumerable: true, configurable: true, writable: true, value: this._formValid }); Object.defineProperty(this, "formData", { enumerable: true, configurable: true, writable: true, value: this._formData }); } /** * Add a field validator to the form */ addField(field) { this.fields.set(field.name, field); // Update form state synchronously for initial setup this.updateFormStateSync(); return this; } /** * Create and add a new field validator */ createField(name, initialValue, rules = []) { const field = new FieldValidator(name, initialValue, rules); this.addField(field); return field; } /** * Get a field validator by name */ getField(name) { return this.fields.get(name); } /** * Remove a field validator */ removeField(name) { const removed = this.fields.delete(name); if (removed) { this.updateFormStateSync(); } return removed; } /** * Get all field names */ getFieldNames() { return Array.from(this.fields.keys()); } /** * Validate all fields in the form */ async validateForm() { const formData = await this.getFormData(); const validationPromises = []; for (const [fieldName, field] of this.fields) { const context = { fieldName, formData, touched: await new Promise(resolve => { let unsubscribe; unsubscribe = field.touched.subscribe(t => { resolve(t); unsubscribe?.(); }); }), dirty: await new Promise(resolve => { let unsubscribe; unsubscribe = field.dirty.subscribe(d => { resolve(d); unsubscribe?.(); }); }), submitAttempted: await new Promise(resolve => { let unsubscribe; unsubscribe = this._submitAttempted.subscribe(s => { resolve(s); unsubscribe?.(); }); }) }; validationPromises.push(field.validate(context)); } const results = await Promise.all(validationPromises); const isValid = results.every(result => result); this._formValid.set(isValid); await this.updateFormState(); return isValid; } /** * Validate a specific field */ async validateField(fieldName) { const field = this.fields.get(fieldName); if (!field) return true; const formData = await this.getFormData(); const context = { fieldName, formData, touched: await new Promise(resolve => { let unsubscribe; unsubscribe = field.touched.subscribe(t => { resolve(t); unsubscribe?.(); }); }), dirty: await new Promise(resolve => { let unsubscribe; unsubscribe = field.dirty.subscribe(d => { resolve(d); unsubscribe?.(); }); }), submitAttempted: await new Promise(resolve => { let unsubscribe; unsubscribe = this._submitAttempted.subscribe(s => { resolve(s); unsubscribe?.(); }); }) }; return field.validate(context); } /** * Submit the form with validation */ async submit(submitFn) { this._submitAttempted.set(true); this._submitting.set(true); try { const isValid = await this.validateForm(); if (!isValid) { this._submitting.set(false); // Announce validation errors const errorCount = await this.getErrorCount(); announceFormValidation(false, errorCount); return null; } const formData = await this.getFormData(); const result = await submitFn(formData); this._submitting.set(false); announceFormValidation(true, 0); return result; } catch (error) { this._submitting.set(false); throw error; } } /** * Reset the entire form */ reset() { for (const field of this.fields.values()) { field.reset(); } this._submitting.set(false); this._submitAttempted.set(false); this._formValid.set(true); this.updateFormStateSync(); } /** * Get form data as an object */ async getFormData() { const data = {}; for (const [fieldName, field] of this.fields) { const value = await new Promise(resolve => { let unsubscribe; unsubscribe = field.value.subscribe(v => { resolve(v); unsubscribe?.(); }); }); data[fieldName] = value; } this._formData.set(data); return data; } /** * Get form state */ async getFormState() { const errors = {}; const touched = {}; const dirty = {}; for (const [fieldName, field] of this.fields) { const state = await field.getState(); if (state.error) { errors[fieldName] = state.error; } touched[fieldName] = state.touched; dirty[fieldName] = state.dirty; } const [valid, submitting, submitAttempted] = await Promise.all([ new Promise(resolve => { let unsubscribe; unsubscribe = this._formValid.subscribe(v => { resolve(v); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this._submitting.subscribe(s => { resolve(s); unsubscribe?.(); }); }), new Promise(resolve => { let unsubscribe; unsubscribe = this._submitAttempted.subscribe(s => { resolve(s); unsubscribe?.(); }); }) ]); return { valid, submitting, submitAttempted, errors, touched, dirty }; } /** * Get error count */ async getErrorCount() { let count = 0; for (const field of this.fields.values()) { const hasError = await new Promise(resolve => { let unsubscribe; unsubscribe = field.error.subscribe(error => { resolve(error !== null); unsubscribe?.(); }); }); if (hasError) count++; } return count; } /** * Update form-level state based on field states (async version) */ async updateFormState() { let isFormValid = true; for (const field of this.fields.values()) { const isFieldValid = await new Promise(resolve => { let unsubscribe; unsubscribe = field.valid.subscribe(valid => { resolve(valid); unsubscribe?.(); }); }); if (!isFieldValid) { isFormValid = false; break; } } this._formValid.set(isFormValid); } /** * Update form-level state based on field states (sync version for initial setup) */ updateFormStateSync() { // For initial state, assume valid if no fields have errors // This is used during setup when fields are first added this._formValid.set(true); } } /** * Utility function to create a form validator */ export function createFormValidator() { return new FormValidator(); } /** * Utility function to create a field validator */ export function createFieldValidator(name, initialValue, rules = []) { return new FieldValidator(name, initialValue, rules); }