Forms and Validation
The browser has a complete form model that most frontend stacks ignore.
Native validation, end to end
Section titled “Native validation, end to end”<form> <label> Email <input type="email" name="email" required /> </label> <label> Password <input type="password" name="password" minlength="8" required /> </label> <button>Sign in</button></form>What you get without writing one line of JS:
- The form will not submit if either field is empty.
- The email field validates against an email regex.
- The password field validates against
minlength. - Invalid fields get the
:invalidpseudo-class and can be styled. - The browser focuses the first invalid field on submit.
- Screen readers announce validation errors.
event.targeton submit is aFormData-shaped object you cannew FormData(form)from.
When you want to customize, you have hooks: setCustomValidity() lets you add your own messages, validationMessage lets you read them, the invalid event lets you intercept default UI.
Form-associated custom elements
Section titled “Form-associated custom elements”Custom elements can participate in <form> natively. Use ElementInternals and static formAssociated = true:
class MyEmailInput extends HTMLElement { static formAssociated = true #internals = this.attachInternals() #input = document.createElement('input')
constructor() { super() this.attachShadow({ mode: 'open' }).append(this.#input) this.#input.type = 'email' this.#input.addEventListener('input', () => { this.#internals.setFormValue(this.#input.value) if (!this.#input.validity.valid) { this.#internals.setValidity(this.#input.validity, this.#input.validationMessage, this.#input) } else { this.#internals.setValidity({}) } }) }
get value() { return this.#input.value }}customElements.define('my-email', MyEmailInput)<form> <my-email name="email" required></my-email> <button>Submit</button></form>new FormData(form).get('email') returns the value. Validation flows. The element participates in :invalid. Screen readers see it as a form control.
This is the right way to build custom inputs. It’s a little verbose. <kit-field> wraps the verbosity for the common case (label + native input + error), and the door is open to ship form-associated custom inputs as the library grows.
What <kit-field> does
Section titled “What <kit-field> does”<kit-field> keeps the input light DOM and adds:
- Visible label, association via
for/id. - Optional description with
aria-describedby. - Optional error with
role="alert"andaria-invalid="true". - Required handling with a visible asterisk and
requiredpropagated to the input.
<kit-field label="Email" description="We'll never share it." required> <input type="email" name="email" /></kit-field>The input is real. It validates. new FormData(form) reads it. The label, description, and error are wired together for screen readers. Three lines, full accessibility.
Submission
Section titled “Submission”You can keep using regular form submission, with full progressive enhancement, or you can intercept:
<form id="signup">...</form>
<script type="module"> document.getElementById('signup').addEventListener('submit', async (e) => { e.preventDefault() const data = Object.fromEntries(new FormData(e.target).entries()) await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) }) })</script>Or in a Kitsune app, dispatch a command from the form’s submit:
form.addEventListener('submit', (e) => { e.preventDefault() shell.runtime.command({ type: 'note.save', payload: Object.fromEntries(new FormData(e.target).entries()), })})The submit becomes a runtime command. A persistence module handles it. The form has zero knowledge of where the data goes.