Basic Form Handling

WildflowerJS provides two-way data binding between form inputs and component state through data-model attributes, with automatic type conversion for numbers, booleans, and dates.

💡 Key Concept: WildflowerJS provides two-way data binding between form inputs and component state, making form handling intuitive and reactive.

Basic Form Binding

Use data-model for two-way binding between form inputs and component state:

<div data-component="basic-form">
    <p class="text-muted">Demonstrates two-way data binding with various form input types.</p>
    
    <div class="row">
        <div class="col-md-6">
            <h5>Form Inputs</h5>
            <form data-action="handleSubmit">
                <div class="mb-3">
                    <label class="form-label">Name: *</label>
                    <input type="text" data-model="form.name" class="form-control" 
                           placeholder="Enter your full name" required>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Email: *</label>
                    <input type="email" data-model="form.email" class="form-control" 
                           placeholder="your.email@example.com" required>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Age:</label>
                    <input type="number" data-model="form.age" class="form-control" 
                           min="1" max="120" placeholder="Your age">
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Profession:</label>
                    <select data-model="form.profession" class="form-select">
                        <option value="">Select your profession</option>
                        <option value="developer">Software Developer</option>
                        <option value="designer">Designer</option>
                        <option value="manager">Manager</option>
                        <option value="student">Student</option>
                        <option value="other">Other</option>
                    </select>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Bio:</label>
                    <textarea data-model="form.bio" class="form-control" rows="3" 
                              placeholder="Tell us a little about yourself..."></textarea>
                </div>
                
                <div class="mb-3">
                    <div class="form-check">
                        <input type="checkbox" data-model="form.subscribe" id="subscribe" class="form-check-input">
                        <label for="subscribe" class="form-check-label">
                            Subscribe to our newsletter
                        </label>
                    </div>
                    <div class="form-check">
                        <input type="checkbox" data-model="form.notifications" id="notifications" class="form-check-input">
                        <label for="notifications" class="form-check-label">
                            Allow push notifications
                        </label>
                    </div>
                </div>
                
                <div class="d-flex gap-2">
                    <button type="submit" class="btn btn-primary">
                        Submit Form
                    </button>
                    <button type="button" data-action="resetForm" class="btn btn-secondary">
                        Reset
                    </button>
                </div>
            </form>
        </div>
        
        <div class="col-md-6">
            <h5>Live Data Preview</h5>
            <div class="card h-100">
                <div class="card-body">
                    <div class="mb-2">
                        <strong>Name:</strong> 
                        <span data-bind="form.name || 'Not provided'" 
                              data-bind-class="form.name ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Email:</strong> 
                        <span data-bind="form.email || 'Not provided'"
                              data-bind-class="form.email ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Age:</strong> 
                        <span data-bind="form.age || 'Not provided'"
                              data-bind-class="form.age ? 'text-success' : 'text-muted'"></span>
                        <span data-show="form.age" class="text-muted small"> years old</span>
                    </div>
                    <div class="mb-2">
                        <strong>Profession:</strong> 
                        <span data-bind="form.profession || 'Not selected'"
                              data-bind-class="form.profession ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Bio:</strong> 
                        <div data-bind="form.bio || 'No bio provided'"
                             data-bind-class="form.bio ? 'text-success' : 'text-muted'"
                             style="white-space: pre-wrap; font-size: 0.9em;"></div>
                    </div>
                    <div class="mb-2">
                        <strong>Newsletter:</strong> 
                        <span data-bind="form.subscribe ? '✅ Subscribed' : '❌ Not subscribed'"
                              data-bind-class="form.subscribe ? 'text-success' : 'text-muted'"></span>
                    </div>
                    <div class="mb-2">
                        <strong>Notifications:</strong> 
                        <span data-bind="form.notifications ? '✅ Enabled' : '❌ Disabled'"
                              data-bind-class="form.notifications ? 'text-success' : 'text-muted'"></span>
                    </div>
                    
                    <hr>
                    <div class="small text-muted">
                        <strong>Form Completeness:</strong> 
                        <span data-bind="completionPercentage"></span>%
                        <div class="progress mt-1" style="height: 12px;">
                            <div class="progress-bar" data-bind-class="progressColorClass"
                                 data-bind-style="{ width: completionPercentage + '%' }"></div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <div class="mt-3" data-show="submissionResult">
        <div class="alert alert-info">
            <h6>✅ Form Submitted Successfully!</h6>
            <p>Here's the data that was captured:</p>
            <pre class="small bg-light p-2 rounded"><code data-bind="submissionResult"></code></pre>
        </div>
    </div>
</div>
wildflower.component('basic-form', {
    state: {
        form: {
            name: '',
            email: '',
            age: '',
            profession: '',
            bio: '',
            subscribe: false,
            notifications: false
        },
        submissionResult: ''
    },
    
    computed: {
        // Calculate form completion percentage
        completionPercentage() {
            const fields = ['name', 'email', 'age', 'profession', 'bio']
            const completed = fields.filter(field => this.form[field]).length
            return Math.round((completed / fields.length) * 100)
        },
        
        // Dynamic progress bar color based on completion
        progressColorClass() {
            const percentage = this.completionPercentage
            if (percentage >= 80) return 'bg-success'
            if (percentage >= 50) return 'bg-warning'
            return 'bg-danger'
        },
        
    },
    
    // Handle form submission
    handleSubmit(event, element) {
        event.preventDefault()
        
        // Create a clean copy of form data for submission
        const submissionData = {
            ...this.form,
            submittedAt: new Date().toISOString(),
            completeness: `${this.completionPercentage}%`
        }
        
        // Simulate form submission
        this.submissionResult = JSON.stringify(submissionData, null, 2)
        
        // Clear result after 8 seconds
        setTimeout(() => {
            this.submissionResult = ''
        }, 8000)
    },
    
    // Reset form to initial state
    resetForm() {
        this.form = {
            name: '',
            email: '',
            age: '',
            profession: '',
            bio: '',
            subscribe: false,
            notifications: false
        }
        this.submissionResult = ''
    },
    
})
Live Preview

Store-Backed Inputs

A data-model path whose first segment names a registered store binds that store's state instead of the component's, in both directions: typing writes the store, and a programmatic store write repaints the input. This is how a form spread across several components edits one shared record:

<input data-model="checkout.email">
wildflower.store('checkout', {
    state: { email: '' }
});

Which one a root binds to follows the same rule bare names follow everywhere else: the component comes first. If the component's own state declares the root (a state key named checkout in the example), the binding is component state and the same-named store is never touched, reading or writing. The store route is the fallback for roots the component does not own, which is exactly the shape the pattern is used in, since a component editing a shared store record has no reason to declare a local key by the same name. Development builds note the shadowing (WF-512) when a root names both, so a collision is never a silent surprise. The $store.path spelling is not accepted in data-model (WF-501); the bare root is the store spelling here.

Form Input Types

WildflowerJS supports all HTML form input types with automatic type conversion and proper data binding:

<div data-component="input-types-demo">
    <p class="text-muted">Experience how WildflowerJS handles various HTML input types with automatic data binding and type conversion.</p>
    
    <div class="row">
        <div class="col-md-6">
            <h5>Text-Based Inputs</h5>
            
            <div class="mb-3">
                <label class="form-label">Text:</label>
                <input type="text" data-model="inputs.text" class="form-control" placeholder="Enter any text">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Password:</label>
                <input type="password" data-model="inputs.password" class="form-control" placeholder="Enter password">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Email:</label>
                <input type="email" data-model="inputs.email" class="form-control" placeholder="user@example.com">
            </div>
            
            <div class="mb-3">
                <label class="form-label">URL:</label>
                <input type="url" data-model="inputs.url" class="form-control" placeholder="https://example.com">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Search:</label>
                <input type="search" data-model="inputs.search" class="form-control" placeholder="Search terms...">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Tel:</label>
                <input type="tel" data-model="inputs.tel" class="form-control" placeholder="(555) 123-4567">
            </div>
        </div>
        
        <div class="col-md-6">
            <h5>Numeric & Date Inputs</h5>
            
            <div class="mb-3">
                <label class="form-label">Number:</label>
                <input type="number" data-model="inputs.number" class="form-control" min="0" max="100" step="1">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Range (<span data-bind="inputs.range"></span>):</label>
                <input type="range" data-model="inputs.range" class="form-range" min="0" max="100" step="5">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Date:</label>
                <input type="date" data-model="inputs.date" class="form-control">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Time:</label>
                <input type="time" data-model="inputs.time" class="form-control">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Date/Time:</label>
                <input type="datetime-local" data-model="inputs.datetime" class="form-control">
            </div>
            
            <div class="mb-3">
                <label class="form-label">Color:</label>
                <input type="color" data-model="inputs.color" class="form-control form-control-color">
            </div>
        </div>
    </div>
    
    <div class="row">
        <div class="col-md-6">
            <h5>Selection Inputs</h5>
            
            <div class="mb-3">
                <label class="form-label">Select:</label>
                <select data-model="inputs.select" class="form-select">
                    <option value="">Choose category...</option>
                    <option value="frontend">Frontend Development</option>
                    <option value="backend">Backend Development</option>
                    <option value="fullstack">Full Stack Development</option>
                    <option value="mobile">Mobile Development</option>
                    <option value="devops">DevOps</option>
                </select>
            </div>
            
            <div class="mb-3">
                <label class="form-label">Experience Level:</label>
                <div class="form-check">
                    <input type="radio" data-model="inputs.experience" value="beginner" id="beginner" name="experience" class="form-check-input">
                    <label for="beginner" class="form-check-label">Beginner (0-2 years)</label>
                </div>
                <div class="form-check">
                    <input type="radio" data-model="inputs.experience" value="intermediate" id="intermediate" name="experience" class="form-check-input">
                    <label for="intermediate" class="form-check-label">Intermediate (3-5 years)</label>
                </div>
                <div class="form-check">
                    <input type="radio" data-model="inputs.experience" value="advanced" id="advanced" name="experience" class="form-check-input">
                    <label for="advanced" class="form-check-label">Advanced (5+ years)</label>
                </div>
            </div>
            
            <div class="mb-3">
                <label class="form-label">Technologies:</label>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.javascript" id="js" class="form-check-input">
                    <label for="js" class="form-check-label">JavaScript</label>
                </div>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.python" id="py" class="form-check-input">
                    <label for="py" class="form-check-label">Python</label>
                </div>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.java" id="java" class="form-check-input">
                    <label for="java" class="form-check-label">Java</label>
                </div>
                <div class="form-check">
                    <input type="checkbox" data-model="inputs.technologies.react" id="react" class="form-check-input">
                    <label for="react" class="form-check-label">React</label>
                </div>
            </div>
        </div>
        
        <div class="col-md-6">
            <h5>Live Data Preview</h5>
            <div class="card h-100">
                <div class="card-body">
                    <div class="small" style="max-height: 400px; overflow-y: auto;">
                        <pre><code data-bind="inputsJson"></code></pre>
                    </div>
                    <hr>
                    <div class="small text-muted">
                        <strong>Data Types:</strong><br>
                        Number: <span data-bind="numberType"></span><br>
                        Range: <span data-bind="rangeType"></span><br>
                        Date: <span data-bind="dateType"></span><br>
                        Color: <span data-bind="colorType"></span>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <div class="mt-3">
        <button type="button" data-action="clearAll" class="btn btn-secondary">
            Clear All
        </button>
    </div>
</div>
wildflower.component('input-types-demo', {
    state: {
        inputs: {
            text: '',
            password: '',
            email: '',
            url: '',
            search: '',
            tel: '',
            number: 0,
            range: 50,
            date: '',
            time: '',
            datetime: '',
            color: '#3498db',
            select: '',
            experience: '',
            technologies: {
                javascript: false,
                python: false,
                java: false,
                react: false
            }
        },
        typeInfo: false
    },
    
    computed: {
        inputsJson() {
            return JSON.stringify(this.inputs, null, 2)
        },
        
        numberType() {
            return typeof this.inputs.number + ' (' + this.inputs.number + ')'
        },
        
        rangeType() {
            return typeof this.inputs.range + ' (' + this.inputs.range + ')'
        },
        
        dateType() {
            return typeof this.inputs.date + ' (' + (this.inputs.date || 'empty') + ')'
        },
        
        colorType() {
            return typeof this.inputs.color + ' (' + this.inputs.color + ')'
        }
    },
    
    clearAll() {
        this.inputs = {
            text: '',
            password: '',
            email: '',
            url: '',
            search: '',
            tel: '',
            number: 0,
            range: 50,
            date: '',
            time: '',
            datetime: '',
            color: '#3498db',
            select: '',
            experience: '',
            technologies: {
                javascript: false,
                python: false,
                java: false,
                react: false
            }
        }
    }
})
Live Preview
Input Type Handling

WildflowerJS automatically converts input values to appropriate JavaScript types:

  • number, range: Converted to JavaScript numbers
  • checkbox: Converted to boolean values
  • date, time, datetime-local: Remain as strings in ISO format
  • text, email, url, etc.: Remain as strings

Form Validation

WildflowerJS uses the browser's native HTML5 Constraint Validation API for standard rules (required, minlength, pattern, type="email", etc.) and adds a small set of framework attributes for triggers, custom rules, and inline error rendering.

Built-in validation with data-validate-on

Add data-validate-on to the <form> element along with novalidate. The novalidate attribute is required. Without it, the browser's native tooltip popups fire first and prevent the framework's inline error messages from rendering.

Trigger values are a comma-separated list:

  • data-validate-on="submit": validate the entire form on submit. The configured data-action on the form runs only when validation passes.
  • data-validate-on="blur": validate each input as it loses focus. Updates the matching data-error-for element and toggles the .invalid class on the input. No manual focusout listener needed.
  • data-validate-on="submit,blur": both. Per-field feedback as the user tabs through, plus a whole-form pass on submit.

Template pattern

<form data-validate-on="submit,blur" data-action="handleSubmit" novalidate>
    <label>Email
        <input type="email" required data-model="form.email">
    </label>
    <span class="error-message" data-error-for="form.email"></span>

    <label>Name
        <input type="text" required minlength="2" data-model="form.name">
    </label>
    <span class="error-message" data-error-for="form.name"></span>

    <button type="submit">Submit</button>
</form>

Notes on the form-level data-action: when data-action is on a <form>, the framework handles the submit event automatically; do not use the submit: prefix. The action method receives (event, formElement) and is called only after validation passes.

Native validation rules

Standard HTML5 input attributes work out of the box; messages come from input.validationMessage (browser-default text, localized):

  • required
  • minlength="N", maxlength="N"
  • type="email", type="url", type="number"
  • pattern="regex"
  • min="N", max="N", step="N"

Framework extensions

These input-level attributes add to native validation:

  • data-validate="/regex/": custom regex check (slash-delimited). Runs after native validation passes.
  • data-validate="number": accepts any value that parses as a float.
  • data-validate="integer": accepts only -?\d+.
  • data-validate-message="...": overrides the default error message for the data-validate rule on that input.
<input data-model="user.code"
       data-validate="/^[A-Z]{3}-\d{4}$/"
       data-validate-message="Code must look like ABC-1234">
<span class="error-message" data-error-for="user.code"></span>

State the framework sets

After every validation pass, the framework writes:

  • The CSS class invalid on each failing input (removed when it passes).
  • this.formValid (boolean): whole-form validity after the most recent pass.
  • this.validationErrors: { [modelPath]: errorMessage } for currently-failing fields.

You can bind to that state declaratively. For example, disable a submit button until the form is valid:

<button type="submit" data-bind-attr="{ disabled: !formValid }">Submit</button>

Or show a single summary line when any error is present:

<div class="form-summary-error" data-show="!formValid">Please fix the highlighted fields.</div>

Cross-field rules

Per-input validation cannot say "the return date must be after departure" or "provide a pickup point or an address." Those are facts about the form as a whole. Declare them in a rules: block on the component and they run on the same validation pass, block submit the same way, and surface through the same error plumbing:

wildflower.component('booking-form', {
    state: { depart: '', ret: '', pickup: '', address: '' },
    rules: {
        'Return must be on or after departure':
            'ret == "" || depart == "" || ret >= depart',
        'destination': {
            check: 'pickup != "" || address != ""',
            message: 'Provide a pickup point or an address',
            fields: ['pickup', 'address']
        }
    }
});

The rule's name is its user-facing message, so name rules in plain language; message: overrides that when you want a short key. A failing rule adds the invalid class to the inputs it involves and writes its message to any matching data-error-for element, either per field or under the rule's own name:

<input type="date" data-model="depart">
<input type="date" data-model="ret">
<span class="error-message" data-error-for="Return must be on or after departure"></span>

With data-validate-on="blur" or on any change, rules involving the touched field re-evaluate live, so the user sees the cross-field message as soon as the second field makes it true, without waiting for submit. String checks may reference state and computed names; a typo in a variable name rejects the rule at setup with a dev warning (WF-228) rather than silently gating your form. Rule expressions run through the CSP-safe interpreter that ships in every build, never through eval(), so they work unchanged under a strict Content-Security-Policy.

A rule checking that a text field has a value compares exactly what was typed, so a lone space would satisfy pickup != "". Put data-model-trim on the input and whitespace-only entries never reach state in the first place; the check stays a clean string and the rule can't be fooled.

<div data-component="booking-demo">
    <form data-validate-on="submit,blur,change" data-action="save" novalidate>
        <div class="row">
            <div class="col-md-6 mb-3">
                <label class="form-label" for="bk-depart">Departure *</label>
                <input id="bk-depart" type="date" data-model="depart" class="form-control" required>
                <div class="text-danger small" data-error-for="depart"></div>
            </div>
            <div class="col-md-6 mb-3">
                <label class="form-label" for="bk-ret">Return</label>
                <input id="bk-ret" type="date" data-model="ret" class="form-control">
                <div class="text-danger small" data-error-for="Return must be on or after departure"></div>
            </div>
        </div>
        <div class="mb-3">
            <label class="form-label" for="bk-pickup">Pickup point</label>
            <input id="bk-pickup" type="text" data-model="pickup" data-model-trim class="form-control"
                   placeholder="e.g. Main St depot">
        </div>
        <div class="mb-3">
            <label class="form-label" for="bk-address">Or an address</label>
            <input id="bk-address" type="text" data-model="address" data-model-trim class="form-control"
                   placeholder="e.g. 42 Meadow Lane">
            <div class="text-danger small" data-error-for="destination"></div>
        </div>
        <button type="submit" class="btn btn-primary">Book trip</button>
        <div class="alert alert-info mt-3" data-show="booked">
            Booked. Safe travels.
        </div>
    </form>
</div>
wildflower.component('booking-demo', {
    state: { depart: '', ret: '', pickup: '', address: '', booked: false },

    rules: {
        // The rule name is its message. Empty fields pass; the date
        // comparison only gates once both dates are present.
        'Return must be on or after departure':
            'ret == "" || depart == "" || ret >= depart',

        // Object form: message and the marked inputs stated explicitly.
        // The inputs use data-model-trim, so a lone space never reaches
        // state and can't satisfy this check.
        'destination': {
            check: 'pickup != "" || address != ""',
            message: 'Provide a pickup point or an address',
            fields: ['pickup', 'address']
        }
    },

    save() {
        // Rules gate submit; this only runs when every rule holds.
        this.booked = true
    },

    watch: {
        // Editing anything after booking clears the stale confirmation.
        depart() { this.booked = false },
        ret() { this.booked = false },
        pickup() { this.booked = false },
        address() { this.booked = false }
    }
})
Live Preview

Custom validation logic

When you need validation beyond declared rules (debounced server checks, password-strength meters, anything asynchronous), write it yourself in action handlers. The example below uses blur: and input: action modifiers to run validators as the user interacts:

<div data-component="form-validation">
    <p class="text-muted">Experience real-time validation with custom rules, password strength analysis, and comprehensive error handling.</p>
    
    <form data-action="handleSubmit" class="needs-validation" novalidate>
        <div class="row">
            <div class="col-md-6">
                <div class="mb-3">
                    <label class="form-label">Username: *</label>
                    <input type="text" 
                           data-model="form.username" 
                           data-action="blur:validateUsername input:clearUsernameError"
                           class="form-control"
                           data-bind-class="getUsernameClass"
                           placeholder="Enter username"
                           required>
                    <div class="invalid-feedback" data-bind="errors.username"></div>
                    <div class="form-text">Username must be 3-20 characters, alphanumeric only</div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Email: *</label>
                    <input type="email" 
                           data-model="form.email" 
                           data-action="blur:validateEmail"
                           class="form-control"
                           data-bind-class="getEmailClass"
                           placeholder="your.email@example.com"
                           required>
                    <div class="invalid-feedback" data-bind="errors.email"></div>
                    <div class="valid-feedback" data-show="touched.email && form.email && !errors.email">
                        Valid email address!
                    </div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Phone: *</label>
                    <input type="tel" 
                           data-model="form.phone" 
                           data-action="blur:validatePhone input:formatPhone"
                           class="form-control"
                           data-bind-class="getPhoneClass"
                           placeholder="(555) 123-4567"
                           required>
                    <div class="invalid-feedback" data-bind="errors.phone"></div>
                    <div class="form-text">US phone number format</div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Age: *</label>
                    <input type="number" 
                           data-model="form.age" 
                           data-action="blur:validateAge"
                           class="form-control"
                           data-bind-class="getAgeClass"
                           min="13" max="120" 
                           placeholder="Enter your age"
                           required>
                    <div class="invalid-feedback" data-bind="errors.age"></div>
                    <div class="form-text">Must be between 13 and 120 years old</div>
                </div>
            </div>
            
            <div class="col-md-6">
                <div class="mb-3">
                    <label class="form-label">Password: *</label>
                    <div class="position-relative">
                        <input type="password"
                               id="password-input"
                               data-model="form.password"
                               data-action="input:validatePassword"
                               class="form-control"
                               data-bind-class="getPasswordClass"
                               placeholder="Create a strong password"
                               required>
                        <button type="button" 
                                data-action="togglePasswordVisibility" 
                                class="btn btn-secondary position-absolute end-0 top-0 h-100 px-2" 
                                style="border-left: none; border-radius: 0 0.375rem 0.375rem 0;">
                            <span data-bind="showPassword ? '🙈' : '👁️'"></span>
                        </button>
                    </div>
                    <div class="invalid-feedback" data-bind="errors.password"></div>
                    <div class="mt-2">
                        <div class="d-flex justify-content-between align-items-center">
                            <small class="text-muted">Password strength:</small>
                            <span class="badge" data-bind="passwordStrength" data-bind-class="passwordStrengthClass"></span>
                        </div>
                        <div class="progress mt-1" style="height: 6px;">
                            <div class="progress-bar"
                                 data-bind-class="passwordStrengthColor"
                                 data-bind-style="{ width: passwordStrengthPercent + '%' }"></div>
                        </div>
                    </div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Confirm Password: *</label>
                    <input type="password"
                           data-model="form.confirmPassword"
                           data-action="input:validateConfirmPassword"
                           class="form-control"
                           data-bind-class="getConfirmPasswordClass"
                           placeholder="Confirm your password"
                           required>
                    <div class="invalid-feedback" data-bind="errors.confirmPassword"></div>
                    <div class="valid-feedback" data-show="touched.confirmPassword && form.confirmPassword && !errors.confirmPassword">
                        Passwords match!
                    </div>
                </div>
                
                <div class="mb-3">
                    <label class="form-label">Account Type: *</label>
                    <select data-model="form.accountType" 
                            data-action="change:validateAccountType"
                            class="form-select"
                            data-bind-class="getAccountTypeClass"
                            required>
                        <option value="">Select account type...</option>
                        <option value="personal">Personal</option>
                        <option value="business">Business</option>
                        <option value="premium">Premium</option>
                    </select>
                    <div class="invalid-feedback" data-bind="errors.accountType"></div>
                </div>
            </div>
        </div>
        
        <div class="mb-3">
            <div class="form-check">
                <input type="checkbox" 
                       data-model="form.termsAccepted" 
                       data-action="change:validateTerms"
                       id="terms" 
                       class="form-check-input"
                       data-bind-class="getTermsClass"
                       required>
                <label for="terms" class="form-check-label">
                    I accept the <a href="#" data-action="showTerms">terms and conditions</a> *
                </label>
            </div>
            <div class="invalid-feedback" data-bind="errors.terms"></div>
        </div>
        
        <div class="d-flex flex-column flex-sm-row justify-content-between align-items-start align-items-sm-center gap-2 mb-3">
            <button type="submit"
                    class="btn"
                    data-bind-class="submitButtonClass"
                    data-bind-attr="{ disabled: !isFormValid || isSubmitting }">
                <span data-show="isSubmitting">
                    <span class="spinner-border spinner-border-sm me-2"></span>
                    Creating Account...
                </span>
                <span data-show="!isSubmitting">Create Account</span>
            </button>

            <div class="text-end">
                <small class="text-muted">
                    Form Progress: <span data-bind="validationProgress"></span>%
                </small>
                <div class="progress mt-1" style="width: 100px; height: 4px;">
                    <div class="progress-bar bg-success"
                         data-bind-style="{ width: validationProgress + '%' }"></div>
                </div>
            </div>
        </div>
    </form>
    
    <div class="mt-3" data-show="submissionSuccess">
        <div class="alert alert-info">
            <h5>✅ Account Created Successfully!</h5>
            <p>Welcome to our platform, <strong><span data-bind="form.username"></span></strong>! A confirmation email has been sent to <strong><span data-bind="form.email"></span></strong>.</p>
            <button type="button" data-action="resetForm" class="btn btn-success">
                Create Another Account
            </button>
        </div>
    </div>
    
    <div class="mt-3" data-show="showTermsModal">
        <div class="modal" style="display: block; background: rgba(0,0,0,0.5);">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title">Terms and Conditions</h5>
                        <button type="button" data-action="hideTerms" class="btn-close"></button>
                    </div>
                    <div class="modal-body">
                        <p>By creating an account, you agree to our terms of service and privacy policy.</p>
                        <p>This is a demo form for educational purposes.</p>
                    </div>
                    <div class="modal-footer">
                        <button type="button" data-action="acceptTerms" class="btn btn-primary">
                            Accept Terms
                        </button>
                        <button type="button" data-action="hideTerms" class="btn btn-secondary">
                            Cancel
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
wildflower.component('form-validation', {
    state: {
        form: {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            termsAccepted: false
        },
        errors: {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            terms: ''
        },
        touched: {
            username: false,
            email: false,
            phone: false,
            age: false,
            password: false,
            confirmPassword: false,
            accountType: false,
            terms: false
        },
        isSubmitting: false,
        submissionSuccess: false,
        showPassword: false,
        showTermsModal: false
    },
    
    computed: {
        isFormValid() {
            const form = this.form
            const errors = this.errors
            
            // Check all required fields are filled
            const requiredFields = ['username', 'email', 'phone', 'age', 'password', 'confirmPassword', 'accountType']
            const hasAllFields = requiredFields.every(field => form[field] && form[field].toString().trim())
            
            // Check no validation errors
            const hasNoErrors = Object.values(errors).every(error => !error)
            
            return hasAllFields && hasNoErrors && form.termsAccepted
        },
        
        validationProgress() {
            const form = this.form
            const errors = this.errors
            const totalFields = 8 // including termsAccepted
            
            let validFields = 0
            if (form.username && !errors.username) validFields++
            if (form.email && !errors.email) validFields++
            if (form.phone && !errors.phone) validFields++
            if (form.age && !errors.age) validFields++
            if (form.password && !errors.password) validFields++
            if (form.confirmPassword && !errors.confirmPassword) validFields++
            if (form.accountType && !errors.accountType) validFields++
            if (form.termsAccepted && !errors.terms) validFields++
            
            return Math.round((validFields / totalFields) * 100)
        },
        
        passwordStrengthScore() {
            const password = this.form.password
            if (!password) return 0
            
            let score = 0
            if (password.length >= 8) score++
            if (password.length >= 12) score++
            if (/[a-z]/.test(password)) score++
            if (/[A-Z]/.test(password)) score++
            if (/[0-9]/.test(password)) score++
            if (/[^A-Za-z0-9]/.test(password)) score++
            
            return score
        },
        
        passwordStrength() {
            const score = this.passwordStrengthScore
            if (score <= 1) return 'Very Weak'
            if (score <= 2) return 'Weak'
            if (score <= 3) return 'Fair'
            if (score <= 4) return 'Good'
            if (score <= 5) return 'Strong'
            return 'Very Strong'
        },
        
        passwordStrengthClass() {
            const strength = this.passwordStrength
            const baseClass = 'badge '
            switch (strength) {
                case 'Very Weak': return baseClass + 'bg-danger'
                case 'Weak': return baseClass + 'bg-warning'
                case 'Fair': return baseClass + 'bg-info'
                case 'Good': return baseClass + 'bg-primary'
                case 'Strong': return baseClass + 'bg-success'
                case 'Very Strong': return baseClass + 'bg-success'
                default: return baseClass + 'bg-secondary'
            }
        },
        
        passwordStrengthColor() {
            const strength = this.passwordStrength
            switch (strength) {
                case 'Very Weak': return 'bg-danger'
                case 'Weak': return 'bg-warning'
                case 'Fair': return 'bg-info'
                case 'Good': return 'bg-primary'
                case 'Strong': case 'Very Strong': return 'bg-success'
                default: return 'bg-secondary'
            }
        },

        passwordStrengthPercent() {
            return Math.min(Math.round((this.passwordStrengthScore / 6) * 100), 100)
        },
        
        submitButtonClass() {
            const isValid = this.isFormValid
            const isSubmitting = this.isSubmitting
            
            if (isSubmitting) return 'btn btn-primary'
            return isValid ? 'btn btn-primary' : 'btn btn-outline-primary'
        },
        
        // Field-specific validation classes
        getUsernameClass() {
            const { username } = this.form
            const error = this.errors.username
            if (this.touched.username && error) return 'form-control is-invalid'
            if (this.touched.username && username && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getEmailClass() {
            const { email } = this.form
            const error = this.errors.email
            if (this.touched.email && error) return 'form-control is-invalid'
            if (this.touched.email && email && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getPhoneClass() {
            const { phone } = this.form
            const error = this.errors.phone
            if (this.touched.phone && error) return 'form-control is-invalid'
            if (this.touched.phone && phone && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getAgeClass() {
            const { age } = this.form
            const error = this.errors.age
            if (this.touched.age && error) return 'form-control is-invalid'
            if (this.touched.age && age && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getPasswordClass() {
            const { password } = this.form
            const error = this.errors.password
            if (this.touched.password && error) return 'form-control is-invalid'
            if (this.touched.password && password && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getConfirmPasswordClass() {
            const { confirmPassword } = this.form
            const error = this.errors.confirmPassword
            if (this.touched.confirmPassword && error) return 'form-control is-invalid'
            if (this.touched.confirmPassword && confirmPassword && !error) return 'form-control is-valid'
            return 'form-control'
        },

        getAccountTypeClass() {
            const { accountType } = this.form
            const error = this.errors.accountType
            if (this.touched.accountType && error) return 'form-select is-invalid'
            if (this.touched.accountType && accountType && !error) return 'form-select is-valid'
            return 'form-select'
        },

        getTermsClass() {
            const error = this.errors.terms
            if (this.touched.terms && error) return 'form-check-input is-invalid'
            if (this.touched.terms && this.form.termsAccepted) return 'form-check-input is-valid'
            return 'form-check-input'
        }
    },
    
    // Validation methods
    validateUsername() {
        this.touched.username = true
        const username = this.form.username.trim()
        if (!username) {
            this.errors.username = 'Username is required'
        } else if (username.length < 3) {
            this.errors.username = 'Username must be at least 3 characters'
        } else if (username.length > 20) {
            this.errors.username = 'Username must be no more than 20 characters'
        } else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
            this.errors.username = 'Username can only contain letters, numbers, and underscores'
        } else {
            this.errors.username = ''
        }
    },
    
    clearUsernameError() {
        if (this.errors.username) {
            this.errors.username = ''
        }
    },
    
    validateEmail() {
        this.touched.email = true
        const email = this.form.email.trim()
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
        
        if (!email) {
            this.errors.email = 'Email is required'
        } else if (!emailRegex.test(email)) {
            this.errors.email = 'Please enter a valid email address'
        } else if (email.length > 254) {
            this.errors.email = 'Email address is too long'
        } else {
            this.errors.email = ''
        }
    },
    
    validatePhone() {
        this.touched.phone = true
        const phone = this.form.phone.replace(/\D/g, '')
        if (!phone) {
            this.errors.phone = 'Phone number is required'
        } else if (phone.length !== 10) {
            this.errors.phone = 'Please enter a valid 10-digit phone number'
        } else {
            this.errors.phone = ''
        }
    },
    
    formatPhone() {
        const phone = this.form.phone.replace(/\D/g, '')
        if (phone.length >= 6) {
            this.form.phone = `(${phone.slice(0, 3)}) ${phone.slice(3, 6)}-${phone.slice(6, 10)}`
        } else if (phone.length >= 3) {
            this.form.phone = `(${phone.slice(0, 3)}) ${phone.slice(3)}`
        }
    },
    
    validateAge() {
        this.touched.age = true
        const age = parseInt(this.form.age)
        if (!this.form.age) {
            this.errors.age = 'Age is required'
        } else if (isNaN(age) || age < 13) {
            this.errors.age = 'You must be at least 13 years old'
        } else if (age > 120) {
            this.errors.age = 'Please enter a valid age'
        } else {
            this.errors.age = ''
        }
    },
    
    validatePassword() {
        this.touched.password = true
        const password = this.form.password
        if (!password) {
            this.errors.password = 'Password is required'
        } else if (password.length < 8) {
            this.errors.password = 'Password must be at least 8 characters'
        } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/.test(password)) {
            this.errors.password = 'Password must contain lowercase, uppercase, and number'
        } else {
            this.errors.password = ''
        }
        
        // Re-validate confirm password if it exists
        if (this.form.confirmPassword) {
            this.validateConfirmPassword()
        }
    },
    
    validateConfirmPassword() {
        this.touched.confirmPassword = true
        const password = this.form.password
        const confirmPassword = this.form.confirmPassword
        
        if (!confirmPassword) {
            this.errors.confirmPassword = 'Please confirm your password'
        } else if (password !== confirmPassword) {
            this.errors.confirmPassword = 'Passwords do not match'
        } else {
            this.errors.confirmPassword = ''
        }
    },
    
    validateAccountType() {
        this.touched.accountType = true
        if (!this.form.accountType) {
            this.errors.accountType = 'Please select an account type'
        } else {
            this.errors.accountType = ''
        }
    },
    
    validateTerms() {
        this.touched.terms = true
        if (!this.form.termsAccepted) {
            this.errors.terms = 'You must accept the terms and conditions'
        } else {
            this.errors.terms = ''
        }
    },
    
    // UI interaction methods
    togglePasswordVisibility() {
        this.showPassword = !this.showPassword
        const passwordInput = this.element.querySelector('#password-input')
        if (passwordInput) {
            passwordInput.type = this.showPassword ? 'text' : 'password'
        }
    },
    
    showTerms() {
        this.showTermsModal = true
    },
    
    hideTerms() {
        this.showTermsModal = false
    },
    
    acceptTerms() {
        this.form.termsAccepted = true
        this.validateTerms()
        this.hideTerms()
    },
    
    // Form submission
    handleSubmit(event) {
        event.preventDefault()
        
        // Validate all fields
        this.validateUsername()
        this.validateEmail()
        this.validatePhone()
        this.validateAge()
        this.validatePassword()
        this.validateConfirmPassword()
        this.validateAccountType()
        this.validateTerms()
        
        if (this.isFormValid) {
            this.isSubmitting = true
            
            // Simulate API call
            setTimeout(() => {
                this.isSubmitting = false
                this.submissionSuccess = true
                
                // Hide success message after 8 seconds
                setTimeout(() => {
                    this.submissionSuccess = false
                }, 8000)
            }, 2000)
        }
    },
    
    resetForm() {
        this.form = {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            termsAccepted: false
        }
        this.errors = {
            username: '',
            email: '',
            phone: '',
            age: '',
            password: '',
            confirmPassword: '',
            accountType: '',
            terms: ''
        }
        this.touched = {
            username: false, email: false, phone: false, age: false,
            password: false, confirmPassword: false, accountType: false, terms: false
        }
        this.submissionSuccess = false
        this.showPassword = false
        this.showTermsModal = false
    }
})
Live Preview

Dynamic Forms

Create forms that dynamically change structure based on user input with conditional fields and sections:

<div data-component="dynamic-form">
    <form data-action="handleSubmit">
        <div class="mb-3">
            <label class="form-label">Account Type:</label>
            <select data-model="accountType" class="form-select">
                <option value="">Select type...</option>
                <option value="personal">Personal</option>
                <option value="business">Business</option>
            </select>
        </div>

        <div data-show="accountType === 'personal'" class="mb-3 p-3 border rounded">
            <h6>Personal Details</h6>
            <div class="mb-2">
                <label class="form-label">Full Name:</label>
                <input type="text" data-model="personal.name" class="form-control" placeholder="Your name">
            </div>
            <div class="mb-2">
                <label class="form-label">Email:</label>
                <input type="email" data-model="personal.email" class="form-control" placeholder="you@example.com">
            </div>
        </div>

        <div data-show="accountType === 'business'" class="mb-3 p-3 border rounded">
            <h6>Business Details</h6>
            <div class="mb-2">
                <label class="form-label">Company Name:</label>
                <input type="text" data-model="business.company" class="form-control" placeholder="Acme Inc.">
            </div>
            <div class="mb-2">
                <label class="form-label">Your Role:</label>
                <input type="text" data-model="business.role" class="form-control" placeholder="CTO">
            </div>
        </div>

        <div data-show="accountType" class="mt-3">
            <button type="submit" class="btn btn-primary me-2">Submit</button>
            <button type="button" data-action="resetForm" class="btn btn-secondary">Reset</button>
        </div>
    </form>

    <div class="mt-3" data-show="result">
        <div class="alert alert-info">
            Submitted: <code data-bind="result"></code>
        </div>
    </div>
</div>
wildflower.component('dynamic-form', {
    state: {
        accountType: 'personal',
        personal: { name: '', email: '' },
        business: { company: '', role: '' },
        result: ''
    },

    handleSubmit(event) {
        event.preventDefault()
        const data = this.accountType === 'personal'
            ? this.personal
            : this.business
        this.result = JSON.stringify(data)
    },

    resetForm() {
        this.accountType = ''
        this.personal = { name: '', email: '' }
        this.business = { company: '', role: '' }
        this.result = ''
    }
})
Live Preview