Skip to main content
astrocraft-ui/ components · 101

Form Error Summary

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/form-error-summary

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/form-error-summary --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/form-error-summary --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/form-error-summary --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/form-error-summary --theme tailwind --bridge lumos

Live demo

Submitting: error summary, autosize, character count

Submit it empty: the summary appears, takes focus, and links to the first field that failed. Fill it in and the button goes aria-busy exactly once. The bio grows as you type and the counter only speaks up near the limit.

As it should appear on the invoice.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`FormErrorSummary.astro``form-error-summary` `form-error-summary-list` `form-error-summary-title`——`[hidden]`

Source

What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/form-error-summary/FormErrorSummary.astro
---
// src/components/ui/forms/form-error-summary/FormErrorSummary.astro — headless primitive (see ../../README.md).
// The accessible failed-submit pattern, and a WCAG 3.3.1 requirement: when a submit fails, say what
// failed, in one place, and put the keyboard somewhere useful. On submit the script collects every
// control the browser's own constraint validation rejects, lists each as a link to its field, shows
// the summary, and focuses it.
//
// It sets `form.noValidate` once it is running, on purpose: the UA's error bubbles appear one at a
// time, disappear on their own, are announced by almost nothing, and cannot be styled. Replacing
// them is only safe from JavaScript — which is exactly where we are — so with the script absent the
// form keeps native validation and this element stays hidden and harmless.
//
// The list is built at runtime and carries no `data-slot` of its own; style it through its container
// (`[data-slot="form-error-summary-list"] a`). `role="alert"` announces it the moment it appears,
// and `tabindex="-1"` makes it focusable without adding a tab stop.
//
// Put it at the TOP of the form. `for` names the form by id when it sits outside one; `heading` is
// the summary title, kept off the native `title` attribute so that stays a plain passthrough.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & { for?: string; heading?: string };

const { for: target, heading = "There is a problem", class: className, ...rest } = Astro.props;
---

<div
  class={className}
  role="alert"
  tabindex="-1"
  data-slot="form-error-summary"
  data-for={target}
  hidden
  {...rest}
>
  <p data-slot="form-error-summary-title">{heading}</p>
  <ul data-slot="form-error-summary-list"></ul>
</div>

<script>
  import { onReadyOnce } from "../../_once";

  type Validatable = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;

  let uid = 0;

  function isValidatable(el: Element): el is Validatable {
    return (
      (el instanceof HTMLInputElement ||
        el instanceof HTMLSelectElement ||
        el instanceof HTMLTextAreaElement) &&
      el.willValidate
    );
  }

  /**
   * The control's own label text — `labels` is the native association, so it needs no id bookkeeping.
   * `aria-hidden` descendants are dropped first, because that is what they mean: FormLabel's required
   * asterisk is hidden from the accessibility tree, and without this the summary reads "Full name*".
   */
  function nameOf(el: Validatable): string {
    const label = el.labels?.[0]?.cloneNode(true);
    if (label instanceof HTMLElement) {
      for (const decoration of label.querySelectorAll('[aria-hidden="true"]')) decoration.remove();
      const text = label.textContent?.trim();
      if (text) return text;
    }
    return el.getAttribute("aria-label") || el.name || "This field";
  }

  function wire(root: HTMLElement) {
    const form = root.dataset.for
      ? document.getElementById(root.dataset.for)
      : root.closest("form");
    const list = root.querySelector('[data-slot="form-error-summary-list"]');
    if (!(form instanceof HTMLFormElement) || !list) return;

    form.noValidate = true;

    form.addEventListener("submit", (event) => {
      const controls = [...form.elements].filter(isValidatable);
      const invalid: Validatable[] = [];
      const seen = new Set<string>();
      for (const control of controls) {
        const ok = control.checkValidity();
        control.setAttribute("aria-invalid", String(!ok));
        // One entry per radio group: every button in it fails together and they share a message.
        if (ok || (control.name && seen.has(control.name))) continue;
        if (control.name) seen.add(control.name);
        invalid.push(control);
      }

      if (invalid.length === 0) {
        root.hidden = true;
        return;
      }

      event.preventDefault();
      list.replaceChildren(
        ...invalid.map((control) => {
          control.id ||= `form-error-field-${(uid += 1)}`;
          const link = document.createElement("a");
          link.href = `#${control.id}`;
          link.textContent = `${nameOf(control)}: ${control.validationMessage}`;
          const item = document.createElement("li");
          item.append(link);
          return item;
        }),
      );
      root.hidden = false;
      root.focus();
    });
  }

  onReadyOnce('[data-slot="form-error-summary"]', wire);
</script>
src/components/ui/forms/form-error-summary/index.ts
import FormErrorSummary from "./FormErrorSummary.astro";

export { FormErrorSummary };
export default FormErrorSummary;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/form-error-summary — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.