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();
});
});