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:
- Form submission (value appears in FormData)
- Validation API (
checkValidity(),reportValidity(),setCustomValidity()) - Automatic validation for
required,minlength,maxlength,patternattributes - Form lifecycle callbacks (reset, disabled state, browser restore)
- Focusability for validation UI
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
validity: ValidityState- Current validity state (readonly)validationMessage: string- Current validation message (readonly)willValidate: boolean- Whether element will be validated (readonly)
Methods
checkValidity()- Returnstrueif valid; firesinvalidevent if notreportValidity()- LikecheckValidity()but shows browser validation UIsetCustomValidity(message)- Set custom error (empty string clears)setValidity(flags, message?, anchor?)- Low-level validity controlsetFormValue(value, state?)- Explicitly set form valuevalidateValue()- Run constraint validation (called automatically)
ValidityStateFlags
When calling setValidity(), you can set these flags:
valueMissing- required but emptytypeMismatch- wrong type (email, url, etc.)patternMismatch- doesn't match pattern attributetooLong- exceeds maxlengthtooShort- below minlengthrangeUnderflow/rangeOverflow- outside min/max rangestepMismatch- doesn't match stepbadInput- browser can't parse inputcustomError- custom error via setCustomValidity
Automatic Validation
Component automatically validates against standard HTML constraint attributes
when value changes:
required- value cannot be emptyminlength- minimum string lengthmaxlength- maximum string lengthpattern- regex pattern (anchored to full string)
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:
- Chrome 77+, Firefox 93+, Safari 16.4+, Edge 79+
For older browsers, form-associated features gracefully degrade - the component still works but won't participate in form submission or validation.
See Also
- web-components - Component class documentation
- MDN: ElementInternals
- MDN: Form-associated custom elements