Skip to content

Vanilla JavaScript Integration

This example demonstrates a complete form implementation using EncolaJS Form Controller with vanilla JavaScript and EncolaJS Validator. The example uses a scalable, path-based approach for handling form fields and error display.

WARNING

The code below is for demo purposes only to showcase how you can implement responding to reactivity of the formController
The code contains comments that explain the logic behind various decisions.

js
// Import dependencies (replace with actual CDN URLs or local files)
import createForm, { FormController, PlainObjectDataSource, effect /* exported from alien-signals */ } from '@encolajs/form-controller'
import { createEncolaValidatorFromRules } from '@encolajs/form-controller/encola'
import { ValidatorFactory } from '@encolajs/validator'

// Create validator factory
const validatorFactory = new ValidatorFactory()

const userRules = {
  'name': 'required|min_length:2|max_length:50',
  'email': 'required|email',
  'age': 'required|integer|min:18|max:120',
  'password': 'required|min_length:8|matches:^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])',
  'confirmPassword': 'required|same_as:@password',
  'profile.bio': 'max_length:500',
  'profile.website': 'url',
  'contacts.*.name': 'required',
  'contacts.*.email': 'required|email'
}

// Define custom error messages
const customMessages = {
  'password:matches': 'Password must contain at least one digit, one small letter and on capital letter',
  'contacts.*.name:required': 'Contact name is required',
  'contacts.*.email:required': 'Contact email is required',
}

const validator = createEncolaValidatorFromRules(validatorFactory, userRules, customMessages)

// We assume the form is pre-populated by the server
// so we don't need to populate the datasource
// However this means that the datasource would be empty
// so we would need to retrieve the values from the DOM
// which is beyond the scope of this example
const dataSource = new PlainObjectDataSource({
  name: '',
  email: '',
  age: 18,
  password: '',
  confirmPassword: '',
  profile: {
    bio: '',
    website: ''
  },
  preferences: {
    newsletter: false,
    notifications: true
  },
  contacts: []
})

// initialize the controller
const form = createForm(dataSource, validator)

// Make form globally available for debugging
window.form = form

/**
 * Binds a form element to a FormController with automatic reactivity and event handling
 * This is a reusable function that can be used across different forms
 *
 * @param {HTMLFormElement} formElement - The form element to bind
 * @param {IFormController} formController - The form controller instance
 * @param {Object} options - Configuration options
 * @param {Object} options.arrayDefaults - Default items for array fields
 * @param {Function} options.onSubmit - Custom submit handler
 * @param {Function} options.onReset - Custom reset handler
 */
function bindFormToController(formElement, formController, options = {}) {
    const {
        arrayDefaults = {},
        onSubmit = null,
        onReset = null
    } = options

    // DOM utility functions (scoped to the form)
    function getErrorContainer(fieldPath) {
        return formElement.querySelector(`[data-error="${fieldPath}"]`)
    }

    function updateFieldValue(fieldPath, value, options = {}) {
        formController.setValue(fieldPath, value, options).catch(console.error)
    }

    function updateErrorDisplay(fieldPath, errors) {
        const container = getErrorContainer(fieldPath)
        if (!container) return

        container.innerHTML = errors.length > 0
            ? `<div class="text-red-500 text-sm">${errors[0]}</div>`
            : ''
    }

    function updateFormState() {
        // Update form-level state indicators (scoped to the form)
        const isDirtyElement = formElement.querySelector('[data-form-dirty]')
        const isTouchedElement = formElement.querySelector('[data-form-touched]')
        const isValidElement = formElement.querySelector('[data-form-valid]')

        if (isDirtyElement) {
            isDirtyElement.textContent = formController.isDirty() ? 'Has Changes' : 'No Changes'
            isDirtyElement.className = formController.isDirty()
                ? 'text-orange-600 font-medium'
                : 'text-gray-500'
        }

        if (isTouchedElement) {
            isTouchedElement.textContent = formController.isTouched() ? 'Touched' : 'Untouched'
        }

        if (isValidElement) {
            isValidElement.textContent = formController.isValid() ? 'Valid' : 'Invalid'
            isValidElement.className = formController.isValid()
                ? 'text-green-600 font-medium'
                : 'text-red-600 font-medium'
        }
    }

    function handleFieldChange(event) {
        const element = event.target
        const fieldPath = element.getAttribute('name')

        if (!fieldPath) return

        let value = element.value

        // Handle different input types
        if (element.type === 'checkbox') {
            value = element.checked
        } else if (element.type === 'number') {
            value = element.value === '' ? undefined : Number(element.value)
        }

        // handle `input` and `change` events differently to improve the UX
        updateFieldValue(fieldPath, value, {
            touched: event.type === 'input',
            dirty: event.type === 'change',
        })
    }

    function handleArrayAction(event) {
        const action = event.target.dataset.arrayAction
        const arrayPath = event.target.dataset.arrayPath
        const index = event.target.dataset.arrayIndex

        if (!action || !arrayPath) return

        switch (action) {
            case 'add':
                const newItem = arrayDefaults[arrayPath] || {}
                formController.arrayAppend(arrayPath, newItem).catch(console.error)
                break

            case 'remove':
                if (index !== undefined) {
                    formController.arrayRemove(arrayPath, parseInt(index)).catch(console.error)
                }
                break

            case 'move-up':
                if (index !== undefined && index > 0) {
                    formController.arrayMove(arrayPath, parseInt(index), parseInt(index) - 1).catch(console.error)
                }
                break

            case 'move-down':
                if (index !== undefined) {
                    const array = formController.getValue(arrayPath)
                    if (Array.isArray(array) && index < array.length - 1) {
                        formController.arrayMove(arrayPath, parseInt(index), parseInt(index) + 1).catch(console.error)
                    }
                }
                break
        }

        renderDynamicArrays() 
    }

    function handleFormSubmit(event) {
        event.preventDefault()

        if (onSubmit) {
            onSubmit(formController, formElement)
        } else {
            // Default submit behavior
            formController.submit().then(success => {
                if (success) {
                    alert('Form submitted successfully!')
                    console.log('Form data:', formController.getValues())
                } else {
                    alert('Please fix the errors before submitting')
                }
            }).catch(console.error)
        }
    }

    function handleFormReset(event) {
        event.preventDefault()

        if (onReset) {
            onReset(formController, formElement)
        } else {
            // Default reset behavior
            formController.reset()
        }
    }

    function handleClick(event) {
        // Handle array actions
        if (event.target.dataset.arrayAction) {
            handleArrayAction(event)
            return
        }

        // Handle reset button
        if (event.target.matches('[data-reset]')) {
            handleFormReset(event)
            return
        }
    }

    // Dynamic content rendering for arrays
    function renderDynamicArrays() {
        const arrayContainers = formElement.querySelectorAll('[data-array-list]')

        arrayContainers.forEach(container => {
            const arrayPath = container.getAttribute('data-array-list')
            if (!arrayPath) return

            const items = formController.getValue(arrayPath) || []

            // For contacts array specifically (this could be made more generic)
            if (arrayPath === 'contacts') {
                container.innerHTML = items.map((contact, index) => `
                    <div class="border border-gray-200 rounded-lg p-4 space-y-4">
                        <div class="flex justify-between items-center">
                            <h4 class="font-medium text-gray-900">Contact ${index + 1}</h4>
                            <div class="flex gap-2">
                                <button type="button"
                                        data-array-action="move-up"
                                        data-array-path="contacts"
                                        data-array-index="${index}"
                                        class="text-blue-600 hover:text-blue-800 text-sm"
                                        ${index === 0 ? 'disabled' : ''}>↑</button>
                                <button type="button"
                                        data-array-action="move-down"
                                        data-array-path="contacts"
                                        data-array-index="${index}"
                                        class="text-blue-600 hover:text-blue-800 text-sm"
                                        ${index === items.length - 1 ? 'disabled' : ''}>↓</button>
                                <button type="button"
                                        data-array-action="remove"
                                        data-array-path="contacts"
                                        data-array-index="${index}"
                                        class="text-red-600 hover:text-red-800 text-sm">Remove</button>
                            </div>
                        </div>

                        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                            <div>
                                <label class="block text-sm font-medium text-gray-700">Name</label>
                                <input type="text"
                                       name="contacts.${index}.name"
                                       value="${contact.name || ''}"
                                       class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
                                <div data-error="contacts.${index}.name"></div>
                            </div>

                            <div>
                                <label class="block text-sm font-medium text-gray-700">Email</label>
                                <input type="email"
                                       name="contacts.${index}.email"
                                       value="${contact.email || ''}"
                                       class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
                                <div data-error="contacts.${index}.email"></div>
                            </div>
                        </div>
                    </div>
                `).join('')
            }
        })
    }

    // Setup event delegation from the form element
    formElement.addEventListener('input', handleFieldChange)
    formElement.addEventListener('change', handleFieldChange)
    formElement.addEventListener('click', handleClick)
    formElement.addEventListener('submit', handleFormSubmit)

    // Setup automatic reactivity
    function setupAutoReactivity() {
        // Form-level state reactivity
        effect(() => {
            updateFormState()
        })

        // Automatic error display for ALL error containers in the form
        effect(() => {
            // Subscribe to any error changes
            formController.errorsChanged()

            // Find all error containers within this form and update them
            const errorContainers = formElement.querySelectorAll('[data-error]')
            errorContainers.forEach(container => {
                const fieldPath = container.getAttribute('data-error')
                if (fieldPath) {
                    const field = formController.field(fieldPath)
                    const errors = field.errors()
                    updateErrorDisplay(fieldPath, errors)
                }
            })
        })

        // Data change reactivity for dynamic content (arrays, etc.)
        effect(() => {
            formController.dataChanged() // Subscribe to data changes
        })
    }

    setupAutoReactivity()

    // Initial render
    renderDynamicArrays()
    updateFormState()

    // Return cleanup function for potential removal
    return () => {
        formElement.removeEventListener('input', handleFieldChange)
        formElement.removeEventListener('change', handleFieldChange)
        formElement.removeEventListener('click', handleClick)
        formElement.removeEventListener('submit', handleFormSubmit)
    }
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    const formElement = document.querySelector('form')

    // Bind the form to the controller with configuration
    bindFormToController(formElement, form, {
        arrayDefaults: {
            contacts: { name: '', email: '' }
        },
        onSubmit: (formController, formElement) => {
            formController.submit().then(success => {
                if (success) {
                    alert('Form submitted successfully!')
                    console.log('Form data:', formController.getValues())
                } else {
                    alert('Please fix the errors before submitting')
                }
            }).catch(console.error)
        },
        onReset: (formController, formElement) => {
            formController.reset()
        }
    })
})
html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>EncolaJS Form Controller - Vanilla JS Example</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script type="module">
        // check the script tab
    </script>
</head>
<body class="bg-gray-50 min-h-screen py-8">
    <div class="max-w-4xl mx-auto px-4">
        <div class="bg-white rounded-lg shadow-lg p-8">
            <div class="mb-8">
                <h1 class="text-3xl font-bold text-gray-900 mb-2">User Registration Form</h1>
                <p class="text-gray-600">Complete form with Zod validation and reactive state management</p>

                <!-- Form State Indicators -->
                <div class="mt-4 flex gap-4 text-sm">
                    <span>Status: <span data-form-dirty class="font-medium">No Changes</span></span>
                    <span>Touched: <span data-form-touched class="font-medium">Untouched</span></span>
                    <span>Valid: <span data-form-valid class="font-medium">Invalid</span></span>
                </div>
            </div>

            <form class="space-y-8">
                <!-- Basic Information -->
                <section>
                    <h2 class="text-xl font-semibold text-gray-900 mb-4">Basic Information</h2>
                    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
                        <div>
                            <label for="name" class="block text-sm font-medium text-gray-700">Full Name</label>
                            <input type="text"
                                   id="name"
                                   name="name"
                                   class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                                   placeholder="Enter your full name">
                            <div data-error="name"></div>
                        </div>

                        <div>
                            <label for="email" class="block text-sm font-medium text-gray-700">Email Address</label>
                            <input type="email"
                                   id="email"
                                   name="email"
                                   class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                                   placeholder="Enter your email">
                            <div data-error="email"></div>
                        </div>

                        <div>
                            <label for="age" class="block text-sm font-medium text-gray-700">Age</label>
                            <input type="number"
                                   id="age"
                                   name="age"
                                   min="18"
                                   max="120"
                                   class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
                            <div data-error="age"></div>
                        </div>
                    </div>
                </section>

                <!-- Security -->
                <section>
                    <h2 class="text-xl font-semibold text-gray-900 mb-4">Security</h2>
                    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
                        <div>
                            <label for="password" class="block text-sm font-medium text-gray-700">Password</label>
                            <input type="password"
                                   id="password"
                                   name="password"
                                   class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                                   placeholder="Create a secure password">
                            <div data-error="password"></div>
                        </div>

                        <div>
                            <label for="confirmPassword" class="block text-sm font-medium text-gray-700">Confirm Password</label>
                            <input type="password"
                                   id="confirmPassword"
                                   name="confirmPassword"
                                   class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                                   placeholder="Confirm your password">
                            <div data-error="confirmPassword"></div>
                        </div>
                    </div>
                </section>

                <!-- Profile Information -->
                <section>
                    <h2 class="text-xl font-semibold text-gray-900 mb-4">Profile Information</h2>
                    <div class="space-y-4">
                        <div>
                            <label for="bio" class="block text-sm font-medium text-gray-700">Bio</label>
                            <textarea id="bio"
                                      name="profile.bio"
                                      rows="3"
                                      class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                                      placeholder="Tell us about yourself (optional)"></textarea>
                            <div data-error="profile.bio"></div>
                        </div>

                        <div>
                            <label for="website" class="block text-sm font-medium text-gray-700">Website</label>
                            <input type="url"
                                   id="website"
                                   name="profile.website"
                                   class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
                                   placeholder="https://yourwebsite.com (optional)">
                            <div data-error="profile.website"></div>
                        </div>
                    </div>
                </section>

                <!-- Preferences -->
                <section>
                    <h2 class="text-xl font-semibold text-gray-900 mb-4">Preferences</h2>
                    <div class="space-y-4">
                        <div class="flex items-center">
                            <input type="checkbox"
                                   id="newsletter"
                                   name="preferences.newsletter"
                                   class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
                            <label for="newsletter" class="ml-2 block text-sm text-gray-900">
                                Subscribe to newsletter
                            </label>
                        </div>

                        <div class="flex items-center">
                            <input type="checkbox"
                                   id="notifications"
                                   name="preferences.notifications"
                                   checked
                                   class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
                            <label for="notifications" class="ml-2 block text-sm text-gray-900">
                                Enable notifications
                            </label>
                        </div>
                    </div>
                </section>

                <!-- Contacts Array -->
                <section>
                    <div class="flex justify-between items-center mb-4">
                        <h2 class="text-xl font-semibold text-gray-900">Emergency Contacts</h2>
                        <button type="button"
                                data-array-action="add"
                                data-array-path="contacts"
                                class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
                            Add Contact
                        </button>
                    </div>

                    <div data-array-list="contacts" class="space-y-4">
                        <!-- Dynamic contact list will be rendered here -->
                    </div>
                </section>

                <!-- Form Actions -->
                <div class="flex justify-between pt-6 border-t border-gray-200">
                    <button type="button"
                            data-reset
                            class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-md">
                        Reset Form
                    </button>

                    <button type="submit"
                            data-submit
                            class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md">
                        Create Account
                    </button>
                </div>
            </form>
        </div>
    </div>
</body>
</html>

MIT Licensed