import { type CSSProperties, type ReactElement, type ReactNode, useId } from "react"; import { classNames } from "./class-names.js"; /** * How the platform says something is fine, something needs attention, something broke, something is * loading, and there is nothing here yet. * * ## Status never depends on colour alone * * Every status tone renders a symbol beside its label — `✓`, `!`, `✕`, `i` — sourced from the * design-token registry so a theme can change the glyph without a component changing. A screen in a * bright lobby, a projector with a washed-out gamut, and a person who cannot distinguish red from * green all get the same information (WCAG 2.2 §1.4.1). This is a rule of the shared elements, not * an option a caller can turn off. */ export type EkklesiaStatusTone = "neutral" | "info" | "success" | "warning" | "danger"; const statusSymbols: Readonly> = Object.freeze({ danger: "✕", info: "i", neutral: "•", success: "✓", warning: "!", }); export interface BadgeProps { readonly tone?: EkklesiaStatusTone; readonly className?: string; readonly children?: ReactNode; } /** A compact status indicator: a tone, its symbol, and a label. */ export function Badge(props: BadgeProps): ReactElement { const tone = props.tone ?? "neutral"; return ( {props.children} ); } export interface ToastProps { readonly tone?: EkklesiaStatusTone; readonly title: ReactNode; readonly className?: string; readonly children?: ReactNode; /** * Whether this toast is its own live region. Default `true`, for a toast a caller renders on its * own. A {@link ToastProvider} renders toasts inside live regions it already owns and turns this * off, so nothing is announced twice. */ readonly announce?: boolean; /** Renders a dismiss control. */ readonly onDismiss?: () => void; /** The dismiss control's accessible name. Default "Dismiss notification". */ readonly dismissLabel?: string; readonly "data-toast-id"?: string; } export interface InlineNoticeProps { readonly tone?: EkklesiaStatusTone; readonly title?: ReactNode; readonly className?: string; readonly children?: ReactNode; } /** A calm in-flow status or explanation that should not interrupt the current task. */ export function InlineNotice(props: InlineNoticeProps): ReactElement { const tone = props.tone ?? "info"; return (
{props.title === undefined ? undefined : ( {props.title} )} {props.children}
); } /** * One notification's presentation. * * The queue, timing, and placement belong to {@link ToastProvider} (ADR 0059), which is per React * tree rather than a package singleton: a module-level store would be shared mutable state reaching * across every isolated artifact on a page. A caller may still render one of these on its own. */ export function Toast(props: ToastProps): ReactElement { const tone = props.tone ?? "info"; const announce = props.announce ?? true; return (
{props.title}
{props.children === undefined ? undefined : (
{props.children}
)}
{props.onDismiss === undefined ? undefined : ( )}
); } export interface ToastStackProps { readonly label?: string; readonly className?: string; readonly children?: ReactNode; } export function ToastStack(props: ToastStackProps): ReactElement { return (
{props.children}
); } export interface EmptyStateProps { readonly title: ReactNode; readonly description?: ReactNode; /** The one action that would resolve the emptiness, if there is one. */ readonly action?: ReactNode; readonly className?: string; } export function EmptyState(props: EmptyStateProps): ReactElement { return (

{props.title}

{props.description === undefined ? undefined : (

{props.description}

)} {props.action}
); } export interface ErrorStateProps { /** What failed, in plain words: "The displays could not be loaded." */ readonly title: ReactNode; /** What the person can do about it, and whether trying again makes sense. */ readonly description?: ReactNode; /** One recovery control. Kept for existing callers; prefer `actions`. */ readonly action?: ReactNode; /** * Recovery and remedy controls — Retry, a link to the setting that fixes it. A caller that knows * the viewer can administer the problem passes the administrator's remedies here as well; the * element has no notion of roles. */ readonly actions?: ReactNode; /** * Technical detail for whoever needs it: an error code, the failing step, a likely cause. Plain * text, collapsed behind a disclosure, so it never dominates the message. Rendered as text — markup * in it is shown, never interpreted — with control characters removed and its length bounded. */ readonly details?: string; /** The disclosure's label. Default "Technical details". */ readonly detailsLabel?: string; /** A diagnostic id that can be quoted to an administrator or found in a log, when it is safe to * show. Shown inside the details. */ readonly reference?: string; /** * Whether the error is announced when it appears (`role="alert"`). Default `true`. Turn it off for * an error that is part of a page's initial content rather than a change the person caused. */ readonly announce?: boolean; readonly className?: string; } /** Technical detail is bounded so an enormous server message cannot bury the page. */ export const ERROR_DETAILS_MAX_LENGTH = 2000; /** * Makes an arbitrary string safe to show as technical detail: strips control characters (keeping * line breaks and tabs), trims, and bounds its length. React already renders it as text, so markup * cannot execute; this keeps it legible. */ export function errorDetailsText(value: string): string { // biome-ignore lint/suspicious/noControlCharactersInRegex: removing control characters is the point. const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").trim(); return cleaned.length > ERROR_DETAILS_MAX_LENGTH ? `${cleaned.slice(0, ERROR_DETAILS_MAX_LENGTH)}…` : cleaned; } /** * The failure a person sees (DESIGN.md §7.3): what failed, what they can do, and — only if they want * it — the technical detail. * * The message is the caller's fixed, human wording. **Never** pass a caught exception or its stack: * `details` takes a string, chosen deliberately — a sanitized error code, the step that failed — * and stays collapsed until opened. */ export function ErrorState(props: ErrorStateProps): ReactElement { const details = props.details === undefined ? "" : errorDetailsText(props.details); const reference = props.reference === undefined ? "" : errorDetailsText(props.reference); const announce = props.announce ?? true; return (

{props.title}

{props.description === undefined ? undefined : (

{props.description}

)} {props.action === undefined && props.actions === undefined ? undefined : (
{props.action} {props.actions}
)} {details === "" && reference === "" ? undefined : (
{props.detailsLabel ?? "Technical details"} {details === "" ? undefined :
{details}
} {reference === "" ? undefined : (

Reference: {reference}

)}
)}
); } export interface SkeletonProps { /** `text` is one line of text; `block` an image, chart, or card; `circle` an avatar. */ readonly shape?: "text" | "block" | "circle"; /** Any CSS length. Text defaults to the full width; a circle is as wide as it is tall. */ readonly width?: string; readonly height?: string; readonly className?: string; } /** * A placeholder in the shape of content that is on its way (DESIGN.md §7.1), for content whose * shape is known. Decorative: hidden from assistive technology, which hears its * {@link SkeletonGroup}'s label instead. It pulses gently, and not at all for a person who asked * their system for reduced motion. There is no progress in it, because there is none to report. */ export function Skeleton(props: SkeletonProps): ReactElement { const shape = props.shape ?? "text"; const style: CSSProperties = {}; if (props.width !== undefined) { style.width = props.width; } if (props.height !== undefined) { style.height = props.height; } if (shape === "circle" && props.width !== undefined && props.height === undefined) { style.height = props.width; } return (