CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
Stage 1's later screens and modules need the interactions DESIGN.md fixes once for the product (ADR 0059). @ekklesia/ui gains them, additively: - ConfirmationDialog: simple, destructive, or typed-phrase confirmation, with severity chosen by the caller. Destructive is marked by symbol and frame, not colour alone, and starts on Cancel. A required phrase must match exactly (forgiving only surrounding whitespace and NFC). While busy, Cancel and Escape are disabled. - ToastProvider / useToasts: a top-center toast system per React tree, never a package singleton (ADR 0040). Live regions are mounted before any toast arrives; errors are assertive and persist until dismissed; routine toasts last at least three seconds and longer for longer text. Timers pause on hover and focus; duplicates refresh; errors are never displaced; every timer is cleared on unmount. - ErrorState: recovery actions, plus collapsed technical details and a reference, rendered as bounded inert text. - Skeleton / SkeletonGroup: an aria-busy region labelled once, with every animation stopped under prefers-reduced-motion. - PageHeader `back` (link or button) above the page's one h1. - Dialog: the focus trap ADR 0039 deferred, plus focus restoration and an accessible description. Escape is now handled on the dialog itself, so a drawer it was opened from stays open. The shell validates Back targets (parseReturnTarget, dashboardBack): only a canonical Dashboard page on this origin is accepted, otherwise Back goes to the page's parent. It never uses history.back(). Biome's scope-aware suspicious/noAlert is now an error in the root biome.json. tests/native-dialogs.test.ts proves it catches real dialogs and not lookalikes, and scans for the forms the rule misses. It supersedes the shell-only regex test. Co-Authored-By: Claude Opus 5.5 <[email protected]>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
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<Record<EkklesiaStatusTone, string>> = 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 (
|
|
<span className={classNames("ek-badge", `ek-badge--${tone}`, props.className)}>
|
|
<span aria-hidden="true" className="ek-badge__symbol">
|
|
{statusSymbols[tone]}
|
|
</span>
|
|
{props.children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={classNames("ek-notice", `ek-notice--${tone}`, props.className)}
|
|
role={tone === "danger" ? "alert" : "status"}
|
|
>
|
|
<span aria-hidden="true" className="ek-notice__symbol">
|
|
{statusSymbols[tone]}
|
|
</span>
|
|
<div className="ek-notice__body">
|
|
{props.title === undefined ? undefined : (
|
|
<strong className="ek-notice__title">{props.title}</strong>
|
|
)}
|
|
{props.children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div
|
|
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
|
|
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
|
|
data-toast-id={props["data-toast-id"]}
|
|
role={announce ? "status" : undefined}
|
|
>
|
|
<div className="ek-toast__content">
|
|
<div className="ek-toast__title">
|
|
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
|
{props.title}
|
|
</div>
|
|
{props.children === undefined ? undefined : (
|
|
<div className="ek-toast__body">{props.children}</div>
|
|
)}
|
|
</div>
|
|
{props.onDismiss === undefined ? undefined : (
|
|
<button
|
|
aria-label={props.dismissLabel ?? "Dismiss notification"}
|
|
className="ek-button ek-button--quiet ek-button--sm ek-focusable ek-toast__dismiss"
|
|
onClick={props.onDismiss}
|
|
type="button"
|
|
>
|
|
<span aria-hidden="true">✕</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface ToastStackProps {
|
|
readonly label?: string;
|
|
readonly className?: string;
|
|
readonly children?: ReactNode;
|
|
}
|
|
|
|
export function ToastStack(props: ToastStackProps): ReactElement {
|
|
return (
|
|
<section
|
|
aria-label={props.label ?? "Notifications"}
|
|
className={classNames("ek-toast__stack", props.className)}
|
|
>
|
|
{props.children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={classNames("ek-message", props.className)}>
|
|
<p className="ek-message__title">{props.title}</p>
|
|
{props.description === undefined ? undefined : (
|
|
<p className="ek-message__body">{props.description}</p>
|
|
)}
|
|
{props.action}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={classNames("ek-message", "ek-message--error", props.className)}
|
|
role={announce ? "alert" : undefined}
|
|
>
|
|
<p className="ek-message__title">{props.title}</p>
|
|
{props.description === undefined ? undefined : (
|
|
<p className="ek-message__body">{props.description}</p>
|
|
)}
|
|
{props.action === undefined && props.actions === undefined ? undefined : (
|
|
<div className="ek-message__actions">
|
|
{props.action}
|
|
{props.actions}
|
|
</div>
|
|
)}
|
|
{details === "" && reference === "" ? undefined : (
|
|
<details className="ek-message__details">
|
|
<summary className="ek-focusable">{props.detailsLabel ?? "Technical details"}</summary>
|
|
{details === "" ? undefined : <pre className="ek-message__details-text">{details}</pre>}
|
|
{reference === "" ? undefined : (
|
|
<p className="ek-message__reference">
|
|
Reference: <code>{reference}</code>
|
|
</p>
|
|
)}
|
|
</details>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<span
|
|
aria-hidden="true"
|
|
className={classNames("ek-skeleton", `ek-skeleton--${shape}`, props.className)}
|
|
style={style}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export interface SkeletonGroupProps {
|
|
/** What is loading, announced once: "Loading displays". */
|
|
readonly label: string;
|
|
readonly children?: ReactNode;
|
|
readonly className?: string;
|
|
}
|
|
|
|
/**
|
|
* The loading region a set of {@link Skeleton}s stands in for. It is `aria-busy` and announces its
|
|
* label once, politely, rather than every placeholder; when the real content replaces the group,
|
|
* the busy region is gone with it.
|
|
*/
|
|
export function SkeletonGroup(props: SkeletonGroupProps): ReactElement {
|
|
const labelId = useId();
|
|
return (
|
|
<div
|
|
aria-busy="true"
|
|
aria-labelledby={labelId}
|
|
aria-live="polite"
|
|
className={classNames("ek-skeleton-group", props.className)}
|
|
role="status"
|
|
>
|
|
<span className="ek-visually-hidden" id={labelId}>
|
|
{props.label}
|
|
</span>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface LoadingStateProps {
|
|
readonly label?: string;
|
|
readonly className?: string;
|
|
}
|
|
|
|
export function LoadingState(props: LoadingStateProps): ReactElement {
|
|
const label = props.label ?? "Loading…";
|
|
return (
|
|
<div
|
|
aria-live="polite"
|
|
className={classNames("ek-message", "ek-message--loading", props.className)}
|
|
role="status"
|
|
>
|
|
<div aria-hidden="true" className="ek-spinner" />
|
|
<p className="ek-message__body">{label}</p>
|
|
</div>
|
|
);
|
|
}
|