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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/form-error-summary/FormErrorSummary.astro
---
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
    );
  }

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