explain-me · web components · constraint validation
A form-group is a custom element (a Web Component: your own HTML tag
with JS behavior) that wraps every control of one form field and renders that field's error
below it instead of the browser's native validation bubble. Light-DOM controls go through
a <slot>; the message lives in shadow DOM as
span.error-message[aria-live="polite"][part="error-message"], styled by a shared
constructed stylesheet (adoptedStyleSheets).
Nothing here uses novalidate — native validation stays on so invalid fires.
Click Submit without touching anything: every invalid control fires
invalid, each group writes its own message, and focus jumps to the first bad field.
Type to clear a message live; tab through the radio group to see the focusout guard;
press Reset to see messages and aria-invalid markers vanish.
Native validation already knows what's wrong (input.validity flags +
validationMessage), but its UI is one modal-ish bubble on one control. The component
keeps the knowledge and replaces the UI: it listens for invalid (submit-time, even on
untouched fields), input (clear while typing), focusout (validate on leave),
and form reset (scrub script-written state). update() finds the first invalid
control in the group, #messageFor() picks the message by VALIDITY_ORDER, and
syncAriaInvalidity() mirrors validity into aria-invalid so assistive tech
sees what sighted users see.
<form-group value-missing-message="Pick a username." too-short-message="8+ characters, please.">
<label for="username">Username</label>
<input id="username" name="username" type="text" required minlength="8" />
</form-group>
<script type="module" src="./form-group.js"></script>
Custom messages come from *-message attributes; anything not overridden falls back to the
browser's validationMessage. Page CSS themes the shadow message through
form-group::part(error-message) and --error-message-color (try the
purple toggle above — it sets the variable on the form, the shadow sheet reads it).
| Concern | Original (Kevin Powell, 109 lines) | Reworked (kitchen-sink, 180 lines) |
|---|---|---|
| Controls | #input, first querySelector("input, textarea") | #inputs[], all of querySelectorAll("input, textarea, select") — radio groups + selects work |
| Message pick | for...in validity — enumeration order, can blame the wrong flag | Fixed VALIDITY_ORDER — empty+short reports valueMissing |
| Submit | preventDefault() only; no message until blur | update() immediately + queueMicrotask focus to form.querySelector(":invalid") |
| Leaving a field | per-input blur (doesn't bubble; radio hop misfires) | group-level focusout + relatedTarget guard (bubbles; intra-group moves ignored) |
| Typing | clears text (plus stray console.log) | clears text and re-syncs aria-invalid per control |
| Reset | unhandled — stale message stays | form reset + queueMicrotask (event fires pre-restore) clears all |
| Listeners | manual add/remove, doubles on re-connect | AbortController + { signal }, abort-and-recreate on reconnect |
| Empty message | :empty { display:none } removes node from tree | always in tree, display:block (empty block takes no room; live region stays announced) |
| Define | unconditional define (throws on double registration) | guarded if (!customElements.get(...)) |
// 1. Precedence, not enumeration order (src/form-group.js)
const VALIDITY_ORDER = ['valueMissing', 'typeMismatch', 'patternMismatch', 'tooShort', /* … */];
#messageFor(input) {
for (const key of VALIDITY_ORDER) {
if (input.validity[key] === true)
return this.#customErrorMessage[key] || input.validationMessage;
}
return input.validationMessage || 'This field is invalid';
}
// 2. Group-level leave, not per-input blur (src/form-group.js)
handleFocusOut(event) {
if (event.relatedTarget instanceof Node && this.contains(event.relatedTarget)) return; // still inside
this.update();
}
// 3. One abort tears down every listener (src/form-group.js)
this.#abortController?.abort();
this.#abortController = new AbortController();
input.addEventListener('invalid', this.handleInvalid, { signal });
invalid fires per control and doesn't bubble — that's why listeners sit on each input, not the group. preventDefault() kills the bubble popup and the auto-focus, so the component re-focuses :invalid in a microtask (after the cancelled submit settles).VALIDITY_ORDER puts valueMissing first. The original's for...in validity has no such contract.focusout bubbles to the group and the relatedTarget check skips moves that stay inside. Per-input blur can't do this cleanly.aria-invalid tracks each control's live validity instead of the last message.aria-invalid is scrubbed too.:empty{display:none} would drop the live region so its return isn't announced.customElements.get guard stops double-define throws. Handlers are bound in the constructor because they run as input listeners where this would otherwise be the input.src/form-group.js (true source; this listing is a copy)const formGroupStyles = new CSSStyleSheet();
formGroupStyles.replaceSync(`
.error-message {
display: block;
color: var(--error-message-color, red);
}
`);
// The validity states in the order their messages take precedence: a field
// that is empty and too short is missing, not short.
const VALIDITY_ORDER = [
'valueMissing',
'typeMismatch',
'patternMismatch',
'tooShort',
'tooLong',
'rangeUnderflow',
'rangeOverflow',
'stepMismatch',
'badInput',
'customError',
];
class FormGroup extends HTMLElement {
#inputs = [];
#form = null;
#abortController = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [formGroupStyles];
// The message stays in the tree while it is empty: an empty block takes
// no room, and a region that leaves the tree is not announced when it
// comes back.
this.shadowRoot.innerHTML = `
<slot></slot>
<span class="error-message" aria-live="polite" part="error-message"></span>
`;
// bind event handlers since we're applying them
// to external elements and need the `this`
// context to refer to this class
this.handleInvalid = this.handleInvalid.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleFocusOut = this.handleFocusOut.bind(this);
this.handleReset = this.handleReset.bind(this);
}
handleInvalid(event) {
// turn off the browser validation popup and show the message below the
// field instead. `invalid` fires on every invalid control when a submit
// is blocked, whatever triggered the submit, so this is also what
// validates a form nobody has touched yet.
event.preventDefault();
this.update();
// Without the popup the browser no longer puts the caret on the first
// field to fix, so it is done here instead. The microtask waits out the
// submit, so the focus lands after the browser has finished blocking it.
queueMicrotask(() => {
this.#form?.querySelector(':invalid')?.focus();
});
}
handleInput() {
// hide the error message while the user is typing; the invalid marker
// follows each control's own validity instead.
this.setMessage('');
this.syncAriaInvalidity();
}
handleFocusOut(event) {
// Moving between the controls of one group blurs one before the next is
// checked, and the group is momentarily invalid. The message waits until
// the focus truly leaves the group.
if (event.relatedTarget instanceof Node && this.contains(event.relatedTarget)) {
return;
}
this.update();
}
handleReset() {
// A reset restores the values but leaves anything the script wrote, so
// the message and the markers go back to how the page loaded: none on
// screen. The event fires before the values change, so this waits a tick.
queueMicrotask(() => {
this.setMessage('');
for (const input of this.#inputs) {
input.removeAttribute('aria-invalid');
}
});
}
connectedCallback() {
// A group holds every control of one field, so all of them are wired: a
// radio group, for example, is only valid once one of its buttons is
// checked.
this.#inputs = [...this.querySelectorAll("input, textarea, select")];
this.#form = this.closest('form');
// connecting again replaces the listeners instead of doubling them
this.#abortController?.abort();
this.#abortController = new AbortController();
const { signal } = this.#abortController;
for (const input of this.#inputs) {
input.addEventListener('invalid', this.handleInvalid, { signal });
input.addEventListener('input', this.handleInput, { signal });
input.addEventListener('focusout', this.handleFocusOut, { signal });
}
this.#form?.addEventListener('reset', this.handleReset, { signal });
}
disconnectedCallback() {
// clean up all event listeners
this.#abortController?.abort();
this.#abortController = null;
this.#form = null;
}
update() {
const invalid = this.#inputs.find((input) => !input.validity.valid);
this.setMessage(invalid ? this.#messageFor(invalid) : '');
this.syncAriaInvalidity();
}
setMessage(text) {
const message = this.#errorMessage;
if (message.textContent !== text) {
message.textContent = text;
}
}
syncAriaInvalidity() {
for (const input of this.#inputs) {
if (input.validity.valid) {
input.removeAttribute('aria-invalid');
} else {
input.setAttribute('aria-invalid', 'true');
}
}
}
get #errorMessage() {
return this.shadowRoot.querySelector('.error-message');
}
#messageFor(input) {
for (const key of VALIDITY_ORDER) {
if (input.validity[key] === true) {
return this.#customErrorMessage[key] || input.validationMessage;
}
}
return input.validationMessage || 'This field is invalid';
}
get #customErrorMessage() {
return {
valueMissing: this.getAttribute('value-missing-message') || 'This field is required',
tooLong: this.getAttribute('too-long-message') || 'This field is too long',
tooShort: this.getAttribute('too-short-message') || 'This field is too short',
rangeOverflow: this.getAttribute('range-overflow-message') || 'This field has a number that is too big',
rangeUnderflow: this.getAttribute('range-underflow-message') || 'This field has a number that is too small',
typeMismatch: this.getAttribute('type-mismatch-message') || 'This field is the wrong type',
patternMismatch: this.getAttribute('pattern-mismatch-message') || "This field's value does not match the pattern",
}
}
}
if (!customElements.get('form-group')) {
customElements.define('form-group', FormGroup);
}