Claude
Skills
Sign in
Back

form-vanilla

Included with Lifetime
$97 forever

Framework-free form validation using HTML5 Constraint Validation API enhanced with Zod for complex rules. Use when building forms without React/Vue or for progressive enhancement.

Web Devscripts

What this skill does


# Form Vanilla

Framework-free form patterns using native browser APIs enhanced with Zod.

## Quick Start

```html
<form id="login-form" novalidate>
  <div class="form-field">
    <label for="email">Email</label>
    <input 
      id="email" 
      name="email" 
      type="email" 
      autocomplete="email"
      required
    />
    <span class="error" aria-live="polite"></span>
  </div>
  
  <div class="form-field">
    <label for="password">Password</label>
    <input 
      id="password" 
      name="password" 
      type="password" 
      autocomplete="current-password"
      required
      minlength="8"
    />
    <span class="error" aria-live="polite"></span>
  </div>
  
  <button type="submit">Sign in</button>
</form>

<script type="module">
import { createFormValidator } from './vanilla-validator.js';
import { loginSchema } from './schemas.js';

const form = document.getElementById('login-form');
const validator = createFormValidator(form, loginSchema);

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  
  const result = await validator.validate();
  if (result.valid) {
    console.log('Submit:', result.data);
  }
});
</script>
```

## HTML5 Constraint Validation API

### Built-in Attributes

```html
<!-- Required field -->
<input required />

<!-- Length constraints -->
<input minlength="3" maxlength="50" />

<!-- Number constraints -->
<input type="number" min="0" max="100" step="1" />

<!-- Pattern (regex) -->
<input pattern="[A-Za-z]{3}" title="Three letter code" />

<!-- Email validation -->
<input type="email" />

<!-- URL validation -->
<input type="url" />
```

### Validity State Properties

```javascript
const input = document.querySelector('input');

// Check individual constraints
input.validity.valueMissing;    // required but empty
input.validity.typeMismatch;    // email/url format wrong
input.validity.patternMismatch; // regex failed
input.validity.tooShort;        // < minlength
input.validity.tooLong;         // > maxlength
input.validity.rangeUnderflow;  // < min
input.validity.rangeOverflow;   // > max
input.validity.stepMismatch;    // not divisible by step
input.validity.badInput;        // browser can't parse
input.validity.customError;     // setCustomValidity called

// Check overall validity
input.validity.valid;           // all constraints pass
input.checkValidity();          // returns boolean
input.reportValidity();         // shows browser UI
```

### Custom Error Messages

```javascript
const input = document.querySelector('#email');

// Set custom validation message
input.addEventListener('invalid', (e) => {
  if (input.validity.valueMissing) {
    input.setCustomValidity('Please enter your email address');
  } else if (input.validity.typeMismatch) {
    input.setCustomValidity('Please enter a valid email (e.g., [email protected])');
  }
});

// Clear custom message on input
input.addEventListener('input', () => {
  input.setCustomValidity('');
});
```

## Zod Integration

### Vanilla Validator Class

```typescript
// vanilla-validator.ts
import { z } from 'zod';

export interface ValidationResult<T> {
  valid: boolean;
  data?: T;
  errors: Record<string, string>;
}

export interface ValidatorOptions {
  /** When to validate */
  validateOn: 'blur' | 'input' | 'submit';
  
  /** When to re-validate after error */
  revalidateOn: 'input' | 'blur';
  
  /** Debounce delay for input validation (ms) */
  debounceMs?: number;
}

const defaultOptions: ValidatorOptions = {
  validateOn: 'blur',
  revalidateOn: 'input',
  debounceMs: 300
};

export function createFormValidator<T extends z.ZodType>(
  form: HTMLFormElement,
  schema: T,
  options: Partial<ValidatorOptions> = {}
): FormValidator<z.infer<T>> {
  const opts = { ...defaultOptions, ...options };
  const fieldErrors = new Map<string, string>();
  const touchedFields = new Set<string>();
  let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();

  // Get all form fields
  const fields = Array.from(form.elements).filter(
    (el): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
      el instanceof HTMLInputElement ||
      el instanceof HTMLSelectElement ||
      el instanceof HTMLTextAreaElement
  );

  // Attach event listeners
  fields.forEach(field => {
    if (!field.name) return;

    // Blur handler (punish late)
    field.addEventListener('blur', () => {
      touchedFields.add(field.name);
      if (opts.validateOn === 'blur') {
        validateField(field.name);
      }
    });

    // Input handler (real-time correction)
    field.addEventListener('input', () => {
      // Clear existing timer
      const timer = debounceTimers.get(field.name);
      if (timer) clearTimeout(timer);

      // Only validate if already has error (correction mode)
      if (fieldErrors.has(field.name) && opts.revalidateOn === 'input') {
        debounceTimers.set(
          field.name,
          setTimeout(() => validateField(field.name), opts.debounceMs)
        );
      }
    });
  });

  function getFormData(): Record<string, unknown> {
    const data: Record<string, unknown> = {};
    const formData = new FormData(form);
    
    formData.forEach((value, key) => {
      // Handle checkboxes
      const field = form.elements.namedItem(key);
      if (field instanceof HTMLInputElement && field.type === 'checkbox') {
        data[key] = field.checked;
      } else if (field instanceof HTMLInputElement && field.type === 'number') {
        data[key] = value === '' ? undefined : Number(value);
      } else {
        data[key] = value;
      }
    });
    
    return data;
  }

  function validateField(name: string): string | undefined {
    const data = getFormData();
    const result = schema.safeParse(data);
    
    if (result.success) {
      clearFieldError(name);
      return undefined;
    }
    
    const fieldError = result.error.errors.find(e => e.path[0] === name);
    if (fieldError) {
      setFieldError(name, fieldError.message);
      return fieldError.message;
    } else {
      clearFieldError(name);
      return undefined;
    }
  }

  function setFieldError(name: string, message: string): void {
    fieldErrors.set(name, message);
    
    const field = form.elements.namedItem(name) as HTMLInputElement | null;
    if (!field) return;
    
    // Set ARIA attributes
    field.setAttribute('aria-invalid', 'true');
    
    // Find error element
    const fieldWrapper = field.closest('.form-field');
    const errorEl = fieldWrapper?.querySelector('.error');
    if (errorEl) {
      errorEl.textContent = message;
      field.setAttribute('aria-describedby', errorEl.id || '');
    }
    
    // Add error class
    fieldWrapper?.classList.add('has-error');
    fieldWrapper?.classList.remove('is-valid');
    
    // Set custom validity for native UI
    field.setCustomValidity(message);
  }

  function clearFieldError(name: string): void {
    fieldErrors.delete(name);
    
    const field = form.elements.namedItem(name) as HTMLInputElement | null;
    if (!field) return;
    
    // Clear ARIA
    field.setAttribute('aria-invalid', 'false');
    field.removeAttribute('aria-describedby');
    
    // Clear error element
    const fieldWrapper = field.closest('.form-field');
    const errorEl = fieldWrapper?.querySelector('.error');
    if (errorEl) {
      errorEl.textContent = '';
    }
    
    // Update classes
    fieldWrapper?.classList.remove('has-error');
    if (touchedFields.has(name)) {
      fieldWrapper?.classList.add('is-valid');
    }
    
    // Clear custom validity
    field.setCustomValidity('');
  }

  function clearAllErrors(): void {
    fieldErrors.forEach((_, name) => clearFieldError(name));
  }

  async function validate(): Promise<ValidationResult<z.infer<T>>> {
    const data = getFormData();
    const result = schema.safeParse(data);
    
    if (result.success) {
      clearAllErrors();
      return { valid: true, data: result.data, errors: {} };
    }
    
    // Set error
Files: 3
Size: 32.2 KB
Complexity: 51/100
Category: Web Dev

Related in Web Dev