From 5a7d44781bb7d906adee7c7c79ebd56deda4fef4 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 14 Oct 2026 18:24:07 -0500 Subject: [PATCH] feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives 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 --- README.md | 8 + biome.json | 5 +- .../0059-shared-interaction-primitives.md | 196 +++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 6 +- docs/module-development/README.md | 28 + packages/ui/src/confirmation.tsx | 191 +++++ packages/ui/src/feedback.tsx | 193 ++++- packages/ui/src/focus-trap.ts | 102 +++ packages/ui/src/index.ts | 18 + packages/ui/src/styles.ts | 173 +++- packages/ui/src/surfaces.tsx | 149 +++- packages/ui/src/toasts.tsx | 343 ++++++++ packages/ui/tests/elements.test.tsx | 3 +- packages/ui/tests/interaction.test.tsx | 797 ++++++++++++++++++ surfaces/shell/src/browser/dashboard-back.ts | 32 + surfaces/shell/src/return-target.ts | 54 ++ surfaces/shell/tests/modularity.test.ts | 12 - surfaces/shell/tests/return-target.test.ts | 102 +++ tests/fixtures/native-dialogs/permitted.ts | 19 + .../native-dialogs/prohibited-references.ts | 4 + tests/fixtures/native-dialogs/prohibited.ts | 9 + tests/native-dialogs.test.ts | 152 ++++ 23 files changed, 2538 insertions(+), 59 deletions(-) create mode 100644 docs/architecture/decisions/0059-shared-interaction-primitives.md create mode 100644 packages/ui/src/confirmation.tsx create mode 100644 packages/ui/src/focus-trap.ts create mode 100644 packages/ui/src/toasts.tsx create mode 100644 packages/ui/tests/interaction.test.tsx create mode 100644 surfaces/shell/src/browser/dashboard-back.ts create mode 100644 surfaces/shell/src/return-target.ts create mode 100644 surfaces/shell/tests/return-target.test.ts create mode 100644 tests/fixtures/native-dialogs/permitted.ts create mode 100644 tests/fixtures/native-dialogs/prohibited-references.ts create mode 100644 tests/fixtures/native-dialogs/prohibited.ts create mode 100644 tests/native-dialogs.test.ts diff --git a/README.md b/README.md index d5a74d0..f487463 100644 --- a/README.md +++ b/README.md @@ -929,6 +929,14 @@ it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; declare their own icon (manifest v9), every module shows one generic icon. Church branding in the sidebar's identity slot is still to come. +`@ekklesia/ui` now carries the interactions every later screen shares +([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned +`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree +top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading, +and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog +traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt` +repository-wide. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/biome.json b/biome.json index 5796ace..650ebf8 100644 --- a/biome.json +++ b/biome.json @@ -19,7 +19,10 @@ "linter": { "enabled": true, "rules": { - "preset": "recommended" + "preset": "recommended", + "suspicious": { + "noAlert": "error" + } } }, "javascript": { diff --git a/docs/architecture/decisions/0059-shared-interaction-primitives.md b/docs/architecture/decisions/0059-shared-interaction-primitives.md new file mode 100644 index 0000000..29f85db --- /dev/null +++ b/docs/architecture/decisions/0059-shared-interaction-primitives.md @@ -0,0 +1,196 @@ +# 0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs + +- Status: Accepted +- Date: 2026-10-15 +- Extends: [0034](0034-host-owned-browser-authentication.md), + [0039](0039-shared-design-tokens-and-elements.md), + [0040](0040-module-stylesheets-and-artifact-isolation.md), + [0057](0057-dashboard-urls-are-host-owned-routes.md), + [0058](0058-dashboard-shell-navigation-and-lucide-icons.md) + +## Context + +Every Stage 1 surface still to come — Settings, Displays, module management, Signage as the reference +module — needs the same few interactions DESIGN.md fixes once for the whole product: a product-owned +confirmation for meaningful actions, with typed confirmation for the serious ones (§8); top-center +toasts (§7.4); errors that say what failed and keep technical detail out of the way (§7.3); skeleton +loading that keeps context visible (§7.1); Back plus a page title for nested pages, returning to the +workflow that sent the person there (§2.7, §4.8); and no `window.alert`, `confirm`, or `prompt` +anywhere (§8.1). + +`@ekklesia/ui` (ADR 0039) had the presentation of some of these and deliberately not the behaviour. +ADR 0039 deferred "a focus-trap utility"; the package's `Dialog` said a focus trap belonged "in one +place, tested, not sprinkled through call sites"; its `Toast` said there was "deliberately no toast +manager", because a package-level singleton would be shared mutable state across every isolated +artifact on a page (ADR 0040); and ADR 0057 deferred return-to-origin. The only guard against native +dialogs was a regular expression in one shell test. + +Each of these is now needed by more than one consumer, which is ADR 0039's bar for adding to the +package. + +## Decision + +### Placement + +The primitives with more than one consumer go in `@ekklesia/ui`; what depends on the Dashboard's own +routes stays in the shell. + +| Where | What | +| --- | --- | +| `@ekklesia/ui` | `ConfirmationDialog`; `ToastProvider` and `useToasts`; `ErrorState` (extended); `Skeleton` and `SkeletonGroup`; `PageHeader` (extended with `back`); `Dialog` (now with a focus trap) | +| `@ekklesia/shell` | `parseReturnTarget` and `dashboardBack`: which addresses are safe to go back to | +| Root `biome.json` and `tests/native-dialogs.test.ts` | the native-dialog guard | + +The public additions are components and their prop types only. The focus trap, the duration rule, +the phrase matcher, and the detail sanitizer are internal: tested, and used by the components, but +not promised to callers. `@ekklesia/ui` gains no dependency — its few glyphs (`←`, `✕`, the status +symbols) are text, as its status symbols already were, so a module bundle does not inherit an icon +library's licence material through it. + +### One focus trap, used by every modal surface + +`useFocusTrap` remembers what had focus, moves focus into the surface, wraps Tab and Shift+Tab at its +edges, and returns focus to the remembered control when the surface closes. It reads the focused +element from the surface's **own root node**, so it works inside a module's shadow root, where the +document would only report the shadow host (ADR 0040). `Dialog` uses it, so every dialog — including +the shell's existing device-revocation dialog — now traps and restores focus. + +`Dialog` stays rendered in place, never portalled out of an artifact's isolated root. Its scrim +covers the viewport and takes every pointer press, so nothing behind it can be activated, and does +not dismiss the dialog. Escape is now handled on the dialog element and stopped there, rather than on +the whole document, so a drawer or menu the dialog was opened from no longer closes with it. A caller +can make Escape inert while something is in progress (`dismissible={false}`), and give the dialog an +accessible description. + +### Confirmation: three severities, chosen by the caller + +`ConfirmationDialog` is one element for a simple confirmation, a destructive one, and a destructive +one that also requires a typed phrase. The consuming feature chooses; the element never upgrades or +downgrades severity. + +- It is a `Dialog`: named by its title, described by its description, focus trapped and restored. +- **Destructive** is marked by a symbol in the title and on the action, a heavier frame, and the + danger button — never by colour alone — and focus starts on **Cancel**, so a stray Enter is safe. +- **Typed confirmation** is `requiredPhrase`: `DELETE`, or a resource's own name. Confirm is disabled + until the typed value matches exactly and case-sensitively, forgiving only surrounding whitespace and + Unicode composition (NFC), which phone keyboards introduce unasked. The field is labelled with the + phrase, has autocapitalize, autocorrect, and spellcheck off, says politely whether it matches, and + submits on Enter once it does. The typed value is forgotten whenever the dialog closes. +- **Busy** disables both actions, makes Escape inert, and marks the form `aria-busy`, so an action in + flight cannot be abandoned half-done; the caller closes the dialog when it finishes. + +### Toasts: a system per React tree, never a package singleton + +`ToastProvider` owns one tree's toasts, timers, and top-center region; `useToasts()` returns +`show(options) → id` and `dismiss(id)`. ADR 0040's isolation is why there is no package-level store: +each module artifact and the shell render their own tree, and a store shared through the package +would be mutable state reaching across all of them. + +- **Severities:** success, info, warning, and error (`danger`), each with its symbol. +- **Duration:** never under about three seconds (DESIGN.md §7.4), longer for longer text and for + warnings, up to a ceiling. An error stays until dismissed unless its caller gives it a duration — a + routine validation error, say. +- **Calm under load:** at most three routine toasts at once, newest on top; an error is listed first + and never displaced by a routine toast; an identical toast already showing is refreshed rather than + stacked. +- **Accessible:** the region's polite and assertive live regions exist before any toast arrives, so + additions are announced — routine toasts politely, errors assertively — and no toast is a nested + live region of its own. Timers pause while the pointer is over the region or focus is in it (WCAG + 2.2 §2.2.1). Every toast has a dismiss control named for it; Escape dismisses the focused toast; when + the last one goes, focus returns to where it was. +- **Clean:** every timer is cleared when its toast goes and when the provider unmounts. + +The shell does not mount a provider yet, and nothing is wired to toasts merely to show them. A module +mounts its own provider today. One host-owned region that modules post into would need a module UI +SDK contract, and is deferred. + +### Errors: plain message first, technical detail on request + +`ErrorState` keeps its existing props and adds `actions` (recovery and remedies — a caller that knows +the viewer can administer the problem passes those remedies too; the element has no notion of roles), +`details` (plain text), `reference` (a diagnostic id), and `announce` (`role="alert"` by default, off +for an error that is part of a page's initial content). Details and the reference sit in a closed +`
` disclosure, so they never dominate. `details` is a string, chosen deliberately: never an +exception, so a stack cannot arrive by accident. It is rendered as text, so markup in it is shown and +never interpreted, with control characters removed and its length bounded (2,000 characters). + +### Skeletons: shape without pretence + +`Skeleton` (text line, block, circle) is decorative and hidden from assistive technology. +`SkeletonGroup` is the region it stands in for: `role="status"`, `aria-busy`, and one label announced +once ("Loading displays") rather than every placeholder. Placeholders pulse gently and stop for a +person whose system asks for reduced motion; there is no percentage, because there is no progress to +report. When the personal reduced-motion preference arrives, it overrides the same single rule. + +### Back: a validated origin, or the page's own parent + +`PageHeader` takes an optional `back: { label, href?, onBack }` and renders it above the page's one +`h1`: a real link when it has an address (so it can be opened in a new tab), a button otherwise. +Breadcrumbs are not part of it (DESIGN.md §4.8). The element knows nothing about routes; the host +decides where Back goes. + +In the shell, `dashboardBack(history, { returnTarget, fallback, label })` decides. A return target is +input from whoever built the link, so it is hostile until proven otherwise, and ADR 0034 requires any +return location to be a validated, same-origin route decision. `parseReturnTarget` accepts only a +string of at most 2 KiB that begins with exactly one `/`, contains no whitespace, control character, +backslash, percent-encoded slash, backslash, or dot, and no dot-segment, query, or fragment, and names +a page the Dashboard actually has. Its answer is a destination, never the raw string, so Back can only +go to the canonical address of a real page. Without a valid target, Back goes to the page's declared +parent. It never calls the browser's `history.back()`: after a bookmark or a link from outside, the +previous entry is not the Dashboard's. + +How a link *carries* a return target — a query parameter, or something the module navigation contract +defines — is deliberately not decided here (ADR 0057); whatever carries it hands it to +`parseReturnTarget` first. + +### No native dialogs, enforced repository-wide + +Biome's scope-aware `suspicious/noAlert` is an **error** in the root `biome.json`, which every package, +surface, and template in this repository lints against. It reports `alert(`, `confirm(`, and +`prompt(`, called bare, through `window` or `globalThis`, or by computed member, and leaves alone a +local function that shares the name, a method such as `dialog.confirm()`, a property such as +`onConfirm`, a string, and a comment. `tests/native-dialogs.test.ts` proves both directions against +fixtures with the repository's own configuration, and scans browser source for the two forms the rule +cannot see — a call through `self`, `top`, `parent`, or `frames`, and borrowing the function without +calling it. The shell's earlier regular-expression test is superseded and removed. + +A module repository has its own lint configuration and is outside this repository's reach. It should +enable the same rule; the module development guide says so. + +## Alternatives + +- **A third-party toast or dialog library.** Rejected: the behaviour is small, it must work inside + isolated roots without portals, and a framework would bring its own styling and a dependency into + every module bundle. +- **A package-level toast singleton (`toast("Saved")` from anywhere).** Rejected for ADR 0040's + reason: shared mutable state across every artifact on the page. +- **Native `` with `showModal()`.** Not chosen for now: it promotes the dialog to the top + layer, outside an artifact's isolated root, and its focus return and inertness differ across the + browsers churches use. +- **`history.back()` as Back's fallback.** Rejected: it can leave the Dashboard. +- **Accepting any same-origin path as a return target.** Rejected: an address the Dashboard has no + page for is not a workflow to return to, and a narrow grammar is easier to prove safe. +- **Typed confirmation that ignores case.** Rejected: typing `DELETE` is meant to be deliberate. +- **Enforcing native dialogs at module build time** (`@ekklesia/module-ui-build`). Not chosen: the build + sees a bundle without scope information, so it would either miss shadowed names or report code in + dependencies; a scope-aware lint rule in source is the accurate tool. +- **Exporting the focus trap and helpers.** Deferred: they are tested internals until a caller needs + them outside these components. + +## Consequences + +- Later Stage 1 pages compose these instead of inventing their own, and modules get the same ones. +- `Dialog`'s Escape is handled on the dialog element rather than on the document. A caller that relied + on Escape reaching the document while focus was elsewhere sees a change; with focus trapped inside + the dialog, that situation no longer arises. The existing test was updated accordingly. +- `@ekklesia/ui` grows by two components, a provider and its hook, and three extended props. It remains + dependency-free beyond `@ekklesia/design-tokens`. +- `pnpm lint` now fails on any native dialog in this repository. + +## Deferred + +A host-owned toast region that module UI posts into through the module UI SDK; how a link carries a +return target (with the module navigation contract); exporting the focus trap; migrating the shell's +navigation drawer onto the shared focus trap; replacing the device panel's revocation dialog with a +`ConfirmationDialog` (it moves to Settings → Displays); a personal reduced-motion override; and the +module repositories' own lint configuration. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 7dc628c..7d94c73 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -67,3 +67,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted - [0057: Dashboard URLs are real, host-owned routes](0057-dashboard-urls-are-host-owned-routes.md) — Accepted - [0058: The Dashboard shell's navigation, and Lucide as Ekklesia's icon vocabulary](0058-dashboard-shell-navigation-and-lucide-icons.md) — Accepted +- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index a8e8174..d4f523f 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -592,7 +592,11 @@ registry that constructs is one that can be emitted as CSS without escaping. Tha exactly what makes a future theme editor implementable rather than aspirational: the values it would edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of elements a real module dashboard cannot be built without, every value a token reference, with real -`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens +`:focus-visible` treatment and status that never depends on colour alone. It now also carries the +shared interactions — a focus-trapping `Dialog` and `ConfirmationDialog`, a per-tree toast system, +`ErrorState` with collapsed technical detail, skeletons, and `PageHeader`'s Back — and the lint +refuses browser-native dialogs repository-wide +([ADR 0059](decisions/0059-shared-interaction-primitives.md)). `pnpm boundaries` opens exactly two shared roles to modules **by name**, and pins both design packages beneath everything else. The Dashboard and display install the defaults at their document root; custom properties inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at diff --git a/docs/module-development/README.md b/docs/module-development/README.md index c59031f..96bd7c1 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -899,6 +899,34 @@ Every other shared package stays rejected inside a module: `@ekklesia/api-client `@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to import, and `pnpm boundaries` says so. +### Confirmations, toasts, errors, loading, and Back + +`@ekklesia/ui` also carries the interactions DESIGN.md fixes for every screen +([ADR 0059](../architecture/decisions/0059-shared-interaction-primitives.md)); use these rather than +building your own: + +- **`ConfirmationDialog`** for any action worth confirming. You choose the severity: `tone` stays + `default` for a simple confirmation, becomes `"destructive"` for an action that matters, and adds + `requiredPhrase` (`"DELETE"`, or the item's own name) only for the most serious ones. Name the + action for what it does (`confirmLabel="Delete display"`), name the resource and the consequence in + `description`, and set `busy` while the action runs. +- **`ToastProvider`** once near the top of your tree, and `useToasts().show({ tone, title })` where + something worth confirming happened. Errors stay until dismissed; everything else times out once it + has been read. +- **`ErrorState`** with a plain `title` and `description`, recovery `actions`, and — collapsed — + `details` and a `reference`. Pass a sanitized code or message as `details`, never a caught exception. +- **`SkeletonGroup`** labelled with what is loading, containing `Skeleton`s in the shape of the content. +- **`PageHeader`** with `back` on a nested page. Your module decides where its own Back goes; a link + into the Dashboard's Settings will carry a validated return target once the module navigation + contract exists. + +Never call `window.alert`, `window.confirm`, or `window.prompt`. Ekklesia's own lint refuses them; +enable the same rule in your module's `biome.json`: + +```json +{ "linter": { "rules": { "suspicious": { "noAlert": "error" } } } } +``` + ### Styling: a stylesheet compiled into your artifact Author plain `.css` beside your UI source, import it, and declare it diff --git a/packages/ui/src/confirmation.tsx b/packages/ui/src/confirmation.tsx new file mode 100644 index 0000000..7063019 --- /dev/null +++ b/packages/ui/src/confirmation.tsx @@ -0,0 +1,191 @@ +import { + type FormEvent, + type ReactElement, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { Button } from "./controls.js"; +import { Dialog } from "./surfaces.js"; + +export type ConfirmationTone = "default" | "destructive"; + +export interface ConfirmationDialogProps { + readonly open: boolean; + /** The question, as the dialog's accessible name: "Delete Foyer-1?" */ + readonly title: ReactNode; + /** What will happen, announced with the name: name the resource and the consequence. */ + readonly description?: ReactNode; + /** Anything more the person should weigh — whether it can be undone, what else it affects. */ + readonly children?: ReactNode; + /** The confirming action, named for what it does: "Delete display", never "OK". */ + readonly confirmLabel: string; + readonly cancelLabel?: string; + /** + * `destructive` for an action whose effect matters. It is marked by a symbol and wording as well + * as by colour, and focus starts on Cancel rather than on the action. The consuming feature + * decides the severity; this element never upgrades or downgrades it. + */ + readonly tone?: ConfirmationTone; + /** + * Typed confirmation, for the most serious actions (DESIGN.md §8.2): the exact phrase the person + * must type before Confirm is enabled — `DELETE`, or the resource's own name. Leave it unset for + * every action that does not need it. + */ + readonly requiredPhrase?: string; + /** The label of the typed-confirmation field. Default: "Type to confirm". */ + readonly requiredPhraseLabel?: ReactNode; + /** + * The confirmed action is in progress: both actions are disabled, Escape does nothing, and the + * dialog is marked busy, so the person cannot leave it half-done. The caller closes it when done. + */ + readonly busy?: boolean; + readonly onConfirm: () => void; + readonly onCancel: () => void; + readonly "data-testid"?: string; +} + +/** + * Whether a typed value matches a required phrase. + * + * Exact and case-sensitive, because typing it is meant to be deliberate — `delete` is not `DELETE`. + * Only what a phone keyboard adds without being asked is forgiven: whitespace around the value, and + * a different Unicode composition of the same characters (NFC). + */ +export function matchesRequiredPhrase(value: string, phrase: string): boolean { + return value.trim().normalize("NFC") === phrase.trim().normalize("NFC"); +} + +/** + * The product-owned confirmation every Ekklesia surface uses instead of `window.confirm` (DESIGN.md + * §8, ADR 0059). + * + * Three severities from one element, chosen by the caller: a simple confirmation; a destructive one; + * and a destructive one that also requires a typed phrase. It is a {@link Dialog}: modal, named by + * its title and described by its description, with focus trapped inside and returned to the control + * that opened it. Cancel is always present and always safe. Enter confirms only once a required + * phrase matches. + */ +export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement | null { + const tone = props.tone ?? "default"; + const busy = props.busy ?? false; + const phraseId = useId(); + const phraseHintId = useId(); + const [typed, setTyped] = useState(""); + const phraseRef = useRef(null); + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const requiresPhrase = props.requiredPhrase !== undefined; + const phraseMatches = + props.requiredPhrase === undefined || matchesRequiredPhrase(typed, props.requiredPhrase); + const canConfirm = phraseMatches && !busy; + + // A phrase typed for one resource must never carry over to the next time the dialog opens. + useEffect(() => { + if (!props.open) { + setTyped(""); + } + }, [props.open]); + + // A required phrase starts at its field; a destructive action starts on the safe choice; a simple + // confirmation starts on the action itself. + const initialFocusRef = requiresPhrase + ? phraseRef + : tone === "destructive" + ? cancelRef + : confirmRef; + + const cancel = (): void => { + if (busy) { + return; + } + setTyped(""); + props.onCancel(); + }; + + const submit = (event: FormEvent): void => { + event.preventDefault(); + if (canConfirm) { + props.onConfirm(); + } + }; + + return ( + + + ); +} diff --git a/packages/ui/src/feedback.tsx b/packages/ui/src/feedback.tsx index 4fde15e..e00b032 100644 --- a/packages/ui/src/feedback.tsx +++ b/packages/ui/src/feedback.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode } from "react"; +import { type CSSProperties, type ReactElement, type ReactNode, useId } from "react"; import { classNames } from "./class-names.js"; /** @@ -48,6 +48,17 @@ export interface ToastProps { 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 { @@ -79,27 +90,40 @@ export function InlineNotice(props: InlineNoticeProps): ReactElement { } /** - * One notification's presentation — and only its presentation. + * One notification's presentation. * - * There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative - * `toast()` function. Queuing policy belongs to whichever application is rendering, and a - * package-level singleton would be shared mutable state reaching across every isolated artifact on - * a page. A caller renders these where it wants them, for as long as it wants them. + * 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.title} +
+ {props.children === undefined ? undefined : ( +
{props.children}
+ )}
- {props.children === undefined ? undefined : ( -
{props.children}
+ {props.onDismiss === undefined ? undefined : ( + )}
); @@ -143,28 +167,157 @@ export function EmptyState(props: EmptyStateProps): ReactElement { } 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; - /** A retry control, or whatever else the caller can honestly offer. */ + /** 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; + /** - * The failure a person sees. + * 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. * - * Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a - * filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this - * component is where that discipline becomes visible to a user. + * 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} + {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 ( +