explain-me · web components · constraint validation

<form-group>: one field, one message, no popup

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).

Try it: break the form, then submit it untouched

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.

Preferred contact (radio group — one form-group, three inputs)

Overall: what the component does

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.

Usage

<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).

Code walkthrough: original vs reworked

ConcernOriginal (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 pickfor...in validity — enumeration order, can blame the wrong flagFixed VALIDITY_ORDER — empty+short reports valueMissing
SubmitpreventDefault() only; no message until blurupdate() immediately + queueMicrotask focus to form.querySelector(":invalid")
Leaving a fieldper-input blur (doesn't bubble; radio hop misfires)group-level focusout + relatedTarget guard (bubbles; intra-group moves ignored)
Typingclears text (plus stray console.log)clears text and re-syncs aria-invalid per control
Resetunhandled — stale message staysform reset + queueMicrotask (event fires pre-restore) clears all
Listenersmanual add/remove, doubles on re-connectAbortController + { signal }, abort-and-recreate on reconnect
Empty message:empty { display:none } removes node from treealways in tree, display:block (empty block takes no room; live region stays announced)
Defineunconditional define (throws on double registration)guarded if (!customElements.get(...))

The three load-bearing snippets

// 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 });

Gotchas (try each one above)

Full reworked source — 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);
}