Validation

Form validation for custom elements using ElementInternals.

Overview

When building form-associated custom elements, you want them to behave like native <input> elements - participating in form submission, validation, and lifecycle events.

With static formAssociated = true and a value property, your Component subclass automatically gets:

Quick Start

Here's a minimal form-associated component:

import { Component, elements } from 'tosijs'

class SimpleInput extends Component {
  static preferredTagName = 'simple-input'
  static formAssociated = true
  value = ''

  content = ({input}) => input({part: 'input', style: 'padding: 8px'})

  connectedCallback() {
    super.connectedCallback()
    this.parts.input.addEventListener('input', (e) => {
      this.value = e.target.value
    })
  }

  render() {
    super.render()
    if (this.parts.input.value !== this.value) {
      this.parts.input.value = this.value
    }
  }
}

const simpleInput = SimpleInput.elementCreator()
const { form, button, div } = elements

const output = div()
const myForm = form(
  simpleInput({name: 'username', required: true}),
  button({type: 'submit'}, 'Submit')
)
myForm.addEventListener('submit', (e) => {
  e.preventDefault()
  const fd = new FormData(e.target)
  output.textContent = 'FormData: ' + [...fd.entries()].map(([k,v]) => `${k}=${v}`).join(', ')
})

preview.append(myForm, output)
.preview form { display: flex; gap: 8px; align-items: center; }
.preview simple-input { display: inline-block; }

Validation API

Form-associated components expose the standard validation API:

Properties

Methods

ValidityStateFlags

When calling setValidity(), you can set these flags:

Automatic Validation

Component automatically validates against standard HTML constraint attributes when value changes:

import { Component, elements } from 'tosijs'

class ValidatedInput extends Component {
  static preferredTagName = 'validated-input'
  static formAssociated = true
  value = ''

  content = ({input}) => input({part: 'input', style: 'padding: 8px'})

  connectedCallback() {
    super.connectedCallback()
    this.parts.input.addEventListener('input', (e) => {
      this.value = e.target.value
    })
  }

  render() {
    super.render()
    if (this.parts.input.value !== this.value) {
      this.parts.input.value = this.value
    }
  }
}

const validatedInput = ValidatedInput.elementCreator()
const { form, button, div } = elements

const output = div()
const myForm = form(
  validatedInput({
    name: 'code',
    required: true,
    minlength: '3',
    maxlength: '10',
    pattern: '[a-z]+'
  }),
  button({type: 'submit'}, 'Submit')
)
myForm.addEventListener('submit', (e) => {
  e.preventDefault()
  output.textContent = 'Valid! Value: ' + e.target.elements.code.value
})

preview.append(div('Enter 3-10 lowercase letters:'), myForm, output)
.preview form { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.preview validated-input { display: inline-block; }

Custom Validation

Override validateValue() for custom logic. Call super.validateValue() first to include standard constraint validation:

validateValue() {
  super.validateValue() // check required, minlength, etc.

  // Add custom validation
  if (this.value && !/^[a-z][a-z0-9_]*$/.test(this.value)) {
    this.setValidity(
      { patternMismatch: true },
      'Must start with letter, only lowercase letters/numbers/underscores',
      this
    )
  }
}

Form Lifecycle Callbacks

Component provides default implementations for form lifecycle callbacks:

formResetCallback()

Called when the containing <form> is reset. Default resets value to defaultValue or empty string.

formDisabledCallback(disabled: boolean)

Called when the form or a parent <fieldset> is disabled/enabled. Default syncs the disabled attribute.

formStateRestoreCallback(state)

Called when browser restores form state (back/forward navigation). Default restores string values.

Custom States

Use this.internals.states to expose component state to CSS via :state():

import { Component, elements } from 'tosijs'

class StatefulInput extends Component {
  static preferredTagName = 'stateful-input'
  static formAssociated = true
  value = ''

  content = ({input}) => input({part: 'input', style: 'padding: 8px; border: 2px solid #ccc; border-radius: 4px;'})

  connectedCallback() {
    super.connectedCallback()
    const input = this.parts.input

    input.addEventListener('input', () => {
      this.value = input.value
    })

    input.addEventListener('focus', () => {
      this.internals.states.add('focused')
    })

    input.addEventListener('blur', () => {
      this.internals.states.delete('focused')
      // Show validation state on blur
      if (this.validity && !this.validity.valid) {
        this.internals.states.add('invalid')
      } else {
        this.internals.states.delete('invalid')
      }
    })
  }

  render() {
    super.render()
    if (this.parts.input.value !== this.value) {
      this.parts.input.value = this.value
    }
  }
}

const statefulInput = StatefulInput.elementCreator()
const { div } = elements

preview.append(
  div('Required, min 3 chars. Focus shows blue border, invalid on blur shows red:'),
  statefulInput({required: true, minlength: '3'})
)
.preview stateful-input { display: block; margin-top: 8px; }
.preview stateful-input:state(focused) input { border-color: #007bff !important; outline: none; }
.preview stateful-input:state(invalid) input { border-color: #dc3545 !important; }

Browser Support

ElementInternals is supported in all modern browsers:

For older browsers, form-associated features gracefully degrade - the component still works but won't participate in form submission or validation.

See Also