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
JavaScript
/**
* 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);
}