import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Form } from './form'; import { FORM_CHANGE_EVENT, FORM_INVALID_EVENT, FORM_SUBMIT_EVENT, type FormInvalidEvent, type FormSubmitEvent, } from './form.types'; const MARKUP = `
`; function setup(markup = MARKUP): HTMLFormElement { document.body.innerHTML = markup; return document.body.firstElementChild as HTMLFormElement; } function input(root: HTMLElement, name: string): HTMLInputElement { return root.querySelector(`[name="${name}"]`)!; } function errorEl(root: HTMLElement, name: string): HTMLElement { return input(root, name).closest('[data-c42-field]')!.querySelector('[data-c42-field-error]')!; } describe('Form', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('throws when no fields are present', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new Form(root)).toThrow(/data-c42-field/); }); it('disables native validation on a form root', () => { const root = setup(); new Form(root); expect(root.noValidate).toBe(true); }); it('wires aria-describedby to the error element', () => { const root = setup(); new Form(root); const email = input(root, 'email'); const errId = errorEl(root, 'email').id; expect(errId).not.toBe(''); expect(email.getAttribute('aria-describedby')).toContain(errId); }); it('collects values across inputs', () => { const root = setup(); const form = new Form(root); input(root, 'email').value = 'a@b.com'; input(root, 'pwd').value = 'secret123'; expect(form.getValues()).toEqual({ email: 'a@b.com', pwd: 'secret123' }); }); it('flags required + email + minlength errors on submit', () => { const root = setup(); const form = new Form(root); expect(form.submit()).toBe(false); expect(form.getState().errors.email).toMatch(/required/i); input(root, 'email').value = 'not-an-email'; input(root, 'pwd').value = 'short'; expect(form.submit()).toBe(false); const errs = form.getState().errors; expect(errs.email).toMatch(/valid email/i); expect(errs.pwd).toMatch(/short/i); }); it('marks invalid fields with data-state and aria-invalid', () => { const root = setup(); const form = new Form(root); form.submit(); const field = input(root, 'email').closest('[data-c42-field]') as HTMLElement; expect(field.dataset.state).toBe('invalid'); expect(input(root, 'email').getAttribute('aria-invalid')).toBe('true'); expect(errorEl(root, 'email').hidden).toBe(false); }); it('emits form:submit with values when valid', () => { const root = setup(); const form = new Form(root); const spy = vi.fn(); form.on(FORM_SUBMIT_EVENT, spy); input(root, 'email').value = 'a@b.com'; input(root, 'pwd').value = 'longenough'; expect(form.submit()).toBe(true); expect(spy).toHaveBeenCalledOnce(); expect(spy.mock.calls[0][0].detail.values).toEqual({ email: 'a@b.com', pwd: 'longenough', }); }); it('emits form:invalid with the error map when invalid', () => { const root = setup(); const form = new Form(root); const spy = vi.fn(); form.on(FORM_INVALID_EVENT, spy); form.submit(); expect(spy).toHaveBeenCalledOnce(); expect(Object.keys(spy.mock.calls[0][0].detail.errors)).toEqual(['email', 'pwd']); }); it('prevents native submit and validates via the submit event', () => { const root = setup(); const form = new Form(root); const spy = vi.fn(); form.on(FORM_INVALID_EVENT, spy); const event = new Event('submit', { cancelable: true, bubbles: true }); root.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); expect(spy).toHaveBeenCalledOnce(); }); it('re-validates on input after a submit attempt to clear errors', () => { const root = setup(); const form = new Form(root); form.submit(); expect(form.getState().errors.email).toBeDefined(); const email = input(root, 'email'); email.value = 'a@b.com'; email.dispatchEvent(new Event('input', { bubbles: true })); expect(form.getState().errors.email).toBeUndefined(); expect(errorEl(root, 'email').hidden).toBe(true); }); it('emits form:change on input with the field name and value', () => { const root = setup(); const form = new Form(root); const spy = vi.fn(); form.on(FORM_CHANGE_EVENT, spy); const email = input(root, 'email'); email.value = 'x@y.com'; email.dispatchEvent(new Event('input', { bubbles: true })); expect(spy).toHaveBeenCalled(); expect(spy.mock.calls[0][0].detail.name).toBe('email'); expect(spy.mock.calls[0][0].detail.value).toBe('x@y.com'); }); it('validates on blur in mode "blur"', () => { const root = setup(); const form = new Form(root, { mode: 'blur' }); const email = input(root, 'email'); email.dispatchEvent(new Event('focusout', { bubbles: true })); expect(form.getState().errors.email).toMatch(/required/i); }); it('supports custom validators per field', () => { const root = setup(); const form = new Form(root, { validators: { pwd: (value) => (value.includes('1') ? null : 'Needs a digit.'), }, }); input(root, 'email').value = 'a@b.com'; input(root, 'pwd').value = 'nodigits!'; expect(form.submit()).toBe(false); expect(form.getState().errors.pwd).toBe('Needs a digit.'); }); it('honors per-field custom messages via data attributes', () => { const root = setup(`
`); const form = new Form(root); form.submit(); expect(form.getState().errors.email).toBe('We need your email.'); }); it('validates radio groups as a single required field', () => { const root = setup(`
`); const form = new Form(root); expect(form.submit()).toBe(false); const pro = root.querySelector('[value="pro"]')!; pro.checked = true; expect(form.validateField('plan')).toBeNull(); expect(form.getValues().plan).toBe('pro'); }); it('treats an unchecked required checkbox as empty', () => { const root = setup(`
`); const form = new Form(root); expect(form.submit()).toBe(false); const box = input(root, 'tos'); box.checked = true; expect(form.validateField('tos')).toBeNull(); expect(form.getValues().tos).toBe('yes'); }); it('setValues updates inputs, checkboxes and radios', () => { const root = setup(`
`); const form = new Form(root); form.setValues({ email: 'z@z.com', tos: 'yes', plan: 'pro' }); expect(form.getValues()).toEqual({ email: 'z@z.com', tos: 'yes', plan: 'pro' }); }); it('setError + clearErrors manage server-side errors', () => { const root = setup(); const form = new Form(root); form.setError('email', 'Already taken.'); expect(form.getState().errors.email).toBe('Already taken.'); expect(errorEl(root, 'email').textContent).toBe('Already taken.'); form.clearErrors(); expect(form.getState().errors).toEqual({}); expect(errorEl(root, 'email').hidden).toBe(true); }); it('reset clears values, errors and emits form:reset', () => { const root = setup(); const form = new Form(root); const spy = vi.fn(); form.on('form:reset', spy); input(root, 'email').value = 'a@b.com'; form.submit(); form.reset(); expect(spy).toHaveBeenCalledOnce(); expect(form.getValues().email).toBe(''); expect(form.getState().errors).toEqual({}); }); it('enforces min/max numeric constraints', () => { const root = setup(`
`); const form = new Form(root); input(root, 'age').value = '12'; expect(form.submit()).toBe(false); expect(form.getState().errors.age).toMatch(/small/i); input(root, 'age').value = '40'; expect(form.validateField('age')).toBeNull(); }); it('removes listeners on destroy', () => { const root = setup(); const form = new Form(root); const spy = vi.fn(); form.on(FORM_INVALID_EVENT, spy); form.destroy(); root.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); expect(spy).not.toHaveBeenCalled(); }); });