Live form

Profile
      wizard.html
      <form id="wizard-form" novalidate>
        <p class="wizard-progress label" aria-live="polite"></p>
      
        <fieldset data-step="profile" data-validation-context="*">
          <legend>Profile</legend>
          <div>
            <label for="wiz-name">Full name</label>
            <input id="wiz-name" name="name" type="text" data-validation="required;minLength(2)" />
            <ul class="error-list"></ul>
          </div>
          <div>
            <label for="wiz-email">Email</label>
            <input id="wiz-email" name="email" type="email"
                   data-validation="required;pattern([^@\s]+@[^@\s]+\.[^@\s]+)" />
            <ul class="error-list"></ul>
          </div>
        </fieldset>
      
        <fieldset data-step="address" data-validation-context="*" hidden>
          <legend>Address</legend>
          <div>
            <label for="wiz-street">Street</label>
            <input id="wiz-street" name="street" type="text" data-validation="required" />
            <ul class="error-list"></ul>
          </div>
          <div>
            <label for="wiz-city">City</label>
            <input id="wiz-city" name="city" type="text" data-validation="required" />
            <ul class="error-list"></ul>
          </div>
        </fieldset>
      
        <fieldset data-step="review" data-validation-context="*" hidden>
          <legend>Review</legend>
          <dl class="wizard-review"></dl>
          <p class="muted">Click submit to finalize.</p>
        </fieldset>
      
        <div class="wizard-actions">
          <button type="button" data-action="back" class="secondary">Back</button>
          <button type="button" data-action="next">Next</button>
          <button type="submit">Submit</button>
        </div>
      </form>
      wizard.demo.ts
      import { FormValidator } from '@form-validator-js/core';
      import { required, minLength, pattern } from '@form-validator-js/validators';
      
      const form = document.querySelector<HTMLFormElement>('#wizard-form');
      if (!form) throw new Error('Wizard form missing');
      
      const steps = Array.from(form.querySelectorAll<HTMLFieldSetElement>('fieldset[data-step]'));
      const backBtn = form.querySelector<HTMLButtonElement>('[data-action="back"]')!;
      const nextBtn = form.querySelector<HTMLButtonElement>('[data-action="next"]')!;
      const submitBtn = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
      const reviewList = form.querySelector<HTMLDListElement>('.wizard-review')!;
      
      // What to show on the Review step. Order matters — drives the dl rows.
      const reviewFields: Array<{ id: string; label: string }> = [
        { id: 'wiz-name',   label: 'Name' },
        { id: 'wiz-email',  label: 'Email' },
        { id: 'wiz-street', label: 'Street' },
        { id: 'wiz-city',   label: 'City' },
      ];
      
      function renderReview() {
        // Build the dl via DOM API rather than innerHTML — visitor input is
        // never re-rendered as HTML, no XSS surface.
        const children: HTMLElement[] = [];
        for (const { id, label } of reviewFields) {
          const input = form.querySelector<HTMLInputElement>(`#${id}`);
          const value = input?.value.trim() ?? '';
          const dt = document.createElement('dt');
          dt.textContent = label;
          const dd = document.createElement('dd');
          if (value) {
            dd.textContent = value;
          } else {
            dd.textContent = '(empty)';
            dd.classList.add('empty');
          }
          children.push(dt, dd);
        }
        reviewList.replaceChildren(...children);
      }
      
      let currentStep = 0;
      
      function showStep(i: number) {
        steps.forEach((step, idx) => {
          step.hidden = idx !== i;
        });
        form.querySelector<HTMLElement>('.wizard-progress')!.textContent =
          `Step ${i + 1} of ${steps.length}`;
      
        // Nav buttons: Back hidden on the first step; Next hidden on the last;
        // Submit only on the last. Avoids the "Back on step 1 leads nowhere"
        // dead affordance and the symmetric "Next on the final step" confusion.
        const isFirst = i === 0;
        const isLast = i === steps.length - 1;
        backBtn.hidden = isFirst;
        nextBtn.hidden = isLast;
        submitBtn.hidden = !isLast;
      
        // Render the data summary each time we land on Review so going back
        // to fix a field and coming forward again shows the updated value.
        if (isLast) renderReview();
      }
      
      const validator = new FormValidator({
        form,
        validatorDeclarations: {
          required:  { ...required,  errorMessage: 'Required' },
          minLength: { ...minLength, errorMessage: 'Too short' },
          pattern:   { ...pattern,   errorMessage: 'Not a valid format' },
        },
        onErrorMessageListChanged(target, errors) {
          if (target === form) return;
          const list = (target as HTMLElement).parentElement?.querySelector('.error-list');
          if (!list) return;
          list.innerHTML = errors.map((m) => `<li>${m}</li>`).join('');
        },
      });
      
      nextBtn.addEventListener('click', () => {
        // Validate every field within the current step. If any are invalid, stay.
        const stepEl = steps[currentStep];
        const fields = stepEl.querySelectorAll<HTMLInputElement>('[data-validation]');
        let allValid = true;
        fields.forEach((f) => {
          f.dispatchEvent(FormValidator.createValidateEvent());
          if (f.getAttribute('aria-invalid') === 'true') allValid = false;
        });
        if (allValid && currentStep < steps.length - 1) {
          currentStep++;
          showStep(currentStep);
        }
      });
      
      backBtn.addEventListener('click', () => {
        if (currentStep > 0) {
          currentStep--;
          showStep(currentStep);
        }
      });
      
      // Submit handler attached AFTER new FormValidator(). The engine runs
      // validation first and stops propagation on any invalid field, so this
      // only fires when the whole form is valid. On the final step that means
      // the user is done — alert. On an earlier step (only reachable here if
      // the user backtracked from a fully-filled state) route the submit to
      // Next as a small UX nicety.
      form.addEventListener('submit', (e) => {
        e.preventDefault();
        if (currentStep === steps.length - 1) {
          alert('Submitted.');
        } else {
          nextBtn.click();
        }
      });
      
      showStep(0);