Skip to content

Form validation

Form-validator controllers typically listen to input, update an error target, and dispatch a custom event when state changes. All three are straightforward to assert.

Happy + sad paths

ts
import { vi } from 'vitest'

test('invalid input shows error and dispatches "invalid"', async () => {
  const invalid = vi.fn()

  const { user, element, getByLabelText } = await render(FormValidatorController, {
    identifier: 'form-validator',
    html: `
      <div ${attr.controller('form-validator', {
        pattern: '^\\d{3}$',
        message: 'Must be 3 digits',
      })}>
        <label for="code">Code</label>
        <input id="code" ${attr.combine(
          attr.target('form-validator', 'input'),
          attr.action('form-validator', 'validate', 'input'),
        )} />
        <p ${attr.target('form-validator', 'error')}></p>
      </div>
    `,
  })

  element.addEventListener('form-validator:invalid', invalid as EventListener)

  await user.type(getByLabelText('Code'), 'ab')
  expect(element.querySelector('p')!.textContent).toBe('Must be 3 digits')
  expect(invalid).toHaveBeenCalled()
})

test('valid input clears error and dispatches "valid"', async () => {
  const valid = vi.fn()

  const { user, element, getByLabelText } = await render(FormValidatorController, {
    identifier: 'form-validator',
    html: `
      <div ${attr.controller('form-validator', { pattern: '^\\d{3}$' })}>
        <label for="code">Code</label>
        <input id="code" ${attr.combine(
          attr.target('form-validator', 'input'),
          attr.action('form-validator', 'validate', 'input'),
        )} />
        <p ${attr.target('form-validator', 'error')}>preset error</p>
      </div>
    `,
  })

  element.addEventListener('form-validator:valid', valid as EventListener)

  await user.type(getByLabelText('Code'), '123')
  expect(element.querySelector('p')!.textContent).toBe('')
  expect(valid).toHaveBeenCalled()
})

Submitting a form

user.submit(formEl) fires submit the way a button press does, including the implicit form validation. Use it for end-to-end assertions:

ts
await user.submit(element.querySelector('form')!)
expect(submitSpy).toHaveBeenCalled()

To prevent the default navigation in jsdom-style environments, the controller should call event.preventDefault() — exactly as it does in production.

noValidate

Native <form> elements validate on submit unless you set noValidate. For custom validators, always add novalidate to avoid the browser's own bubble:

html
<form data-controller="form-validator" novalidate>…</form>

Otherwise your click or submit interactions may be cancelled before the controller ever runs.

Released under the MIT License.