1 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 5a7d44781b feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
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]>
2026-10-14 18:24:07 -05:00
23 changed files with 2538 additions and 59 deletions
+8
View File
@@ -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.
+4 -1
View File
@@ -19,7 +19,10 @@
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
"preset": "recommended",
"suspicious": {
"noAlert": "error"
}
}
},
"javascript": {
@@ -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
`<details>` 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 `<dialog>` 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.
+1
View File
@@ -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
+5 -1
View File
@@ -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
+28
View File
@@ -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
+191
View File
@@ -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 <phrase> 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<HTMLInputElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(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<HTMLFormElement>): void => {
event.preventDefault();
if (canConfirm) {
props.onConfirm();
}
};
return (
<Dialog
{...(tone === "destructive" ? { className: "ek-confirmation--destructive" } : {})}
{...(props["data-testid"] === undefined ? {} : { "data-testid": props["data-testid"] })}
{...(props.description === undefined ? {} : { description: props.description })}
dismissible={!busy}
initialFocusRef={initialFocusRef}
onDismiss={cancel}
open={props.open}
title={
tone === "destructive" ? (
<>
<span aria-hidden="true" className="ek-confirmation__symbol" />
{props.title}
</>
) : (
props.title
)
}
>
<form aria-busy={busy || undefined} className="ek-confirmation" onSubmit={submit}>
{props.children}
{props.requiredPhrase === undefined ? undefined : (
<div className="ek-field">
<label className="ek-field__label" htmlFor={phraseId}>
{props.requiredPhraseLabel ?? (
<>
Type <strong className="ek-confirmation__phrase">{props.requiredPhrase}</strong>{" "}
to confirm
</>
)}
</label>
<input
aria-describedby={phraseHintId}
aria-invalid={typed.length > 0 && !phraseMatches ? true : undefined}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="ek-input ek-focusable"
disabled={busy}
id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)}
ref={phraseRef}
spellCheck={false}
type="text"
value={typed}
/>
<span aria-live="polite" className="ek-field__hint" id={phraseHintId}>
{phraseMatches
? "Matches. You can confirm."
: typed.length === 0
? "Confirm stays unavailable until this matches exactly."
: "Does not match yet. It must match exactly, including capitals."}
</span>
</div>
)}
<div className="ek-dialog__actions">
<Button disabled={busy} onClick={cancel} ref={cancelRef}>
{props.cancelLabel ?? "Cancel"}
</Button>
<Button
aria-describedby={requiresPhrase && !phraseMatches ? phraseHintId : undefined}
disabled={!canConfirm}
ref={confirmRef}
type="submit"
variant={tone === "destructive" ? "danger" : "primary"}
>
{tone === "destructive" ? (
<span aria-hidden="true" className="ek-confirmation__symbol" />
) : undefined}
{props.confirmLabel}
</Button>
</div>
</form>
</Dialog>
);
}
+173 -20
View File
@@ -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 (
<div
aria-live={tone === "danger" ? "assertive" : "polite"}
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
role="status"
data-toast-id={props["data-toast-id"]}
role={announce ? "status" : undefined}
>
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
<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.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</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>
);
@@ -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 (
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
<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}
{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>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { type KeyboardEvent, type RefObject, useCallback, useEffect, useRef } from "react";
/** Everything a keyboard can land on. Disabled controls and `tabindex="-1"` are skipped. */
const FOCUSABLE = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
/** The focused element as seen from the container's own root: its shadow root inside an isolated
* module artifact (ADR 0040), where the document would only report the shadow host, or the document. */
function activeWithin(container: HTMLElement): Element | null {
const root = container.getRootNode() as Document | ShadowRoot;
return root.activeElement ?? null;
}
function focusableWithin(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(element) => !element.closest("[inert]"),
);
}
export interface FocusTrapOptions {
/** Where focus starts when the trap activates. Without one, the container itself. */
readonly initialFocus?: RefObject<HTMLElement | null>;
}
/**
* Keeps keyboard focus inside a modal surface while it is open, and puts it back afterwards.
*
* - On activation, remembers what had focus and moves focus to `initialFocus` (or the container).
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
* first wraps to the last, so focus cannot walk out into the page behind.
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
* the surface — if that control is still in the document.
*
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
* as well as in a document: it only ever looks inside its own container and at the container's own
* root node, never at a global. This is the one focus trap ADR 0039 deferred until the platform needed
* it (ADR 0059); every modal surface in `@ekklesia/ui` uses it.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
options: FocusTrapOptions = {},
): (event: KeyboardEvent<HTMLElement>) => void {
const { initialFocus } = options;
const restoreTo = useRef<HTMLElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!active || !container) {
return;
}
const previous = activeWithin(container);
restoreTo.current =
previous && previous !== container.ownerDocument.body && "focus" in previous
? (previous as HTMLElement)
: null;
(initialFocus?.current ?? container).focus();
return () => {
const target = restoreTo.current;
restoreTo.current = null;
if (target?.isConnected) {
target.focus();
}
};
}, [active, containerRef, initialFocus]);
return useCallback(
(event: KeyboardEvent<HTMLElement>) => {
const container = containerRef.current;
if (!active || event.key !== "Tab" || !container) {
return;
}
const focusable = focusableWithin(container);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
const current = activeWithin(container);
const outside = !(current instanceof Node) || !container.contains(current);
if (event.shiftKey && (current === first || current === container || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
},
[active, containerRef],
);
}
+18
View File
@@ -43,6 +43,11 @@ export {
type TableRowProps,
type TableSectionProps,
} from "./data.js";
export {
ConfirmationDialog,
type ConfirmationDialogProps,
type ConfirmationTone,
} from "./confirmation.js";
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
export {
Badge,
@@ -56,6 +61,10 @@ export {
type InlineNoticeProps,
LoadingState,
type LoadingStateProps,
Skeleton,
SkeletonGroup,
type SkeletonGroupProps,
type SkeletonProps,
Toast,
type ToastProps,
ToastStack,
@@ -75,8 +84,17 @@ export {
Panel,
type PanelProps,
PageHeader,
type PageHeaderBack,
type PageHeaderProps,
type TabDefinition,
Tabs,
type TabsProps,
} from "./surfaces.js";
export {
type ToastApi,
type ToastOptions,
ToastProvider,
type ToastProviderProps,
type ToastTone,
useToasts,
} from "./toasts.js";
+172 -1
View File
@@ -595,14 +595,185 @@ export const EKKLESIA_UI_STYLESHEET = `
width: ${designToken("spacing.xl")};
}
.ek-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-dialog__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-confirmation {
display: grid;
gap: ${designToken("spacing.lg")};
margin: 0;
}
/* A destructive confirmation says so with a symbol and a heavier frame, never by colour alone. */
.ek-confirmation--destructive {
border-color: ${designToken("status.danger-border")};
border-width: calc(${designToken("border.width")} * 2);
}
.ek-confirmation__symbol::before {
content: ${designToken("status.warning-symbol")};
font-weight: ${designToken("typography.weight-bold")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-confirmation__phrase {
font-family: ${designToken("typography.family-mono")};
white-space: pre-wrap;
}
.ek-toast-region {
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
left: 50%;
max-width: calc(100vw - (${designToken("spacing.md")} * 2));
pointer-events: none;
position: fixed;
top: ${designToken("spacing.lg")};
transform: translateX(-50%);
width: 28rem;
z-index: 50;
}
.ek-toast-region__list {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-toast-region .ek-toast {
animation: ek-toast-enter 160ms ease-out;
pointer-events: auto;
}
.ek-toast {
align-items: start;
display: flex;
gap: ${designToken("spacing.sm")};
}
.ek-toast__content {
display: grid;
flex: 1 1 auto;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-toast__dismiss {
flex: 0 0 auto;
min-width: ${designToken("control.height-sm")};
}
.ek-message__actions {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
justify-content: center;
}
.ek-message__details {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-xs")};
max-width: 100%;
text-align: start;
width: 100%;
}
.ek-message__details summary {
cursor: pointer;
min-height: ${designToken("control.height-sm")};
padding-block: ${designToken("spacing.2xs")};
}
.ek-message__details-text {
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
font-family: ${designToken("typography.family-mono")};
margin: ${designToken("spacing.xs")} 0 0;
max-height: 16rem;
overflow: auto;
padding: ${designToken("spacing.sm")};
white-space: pre-wrap;
word-break: break-word;
}
.ek-message__reference {
margin: ${designToken("spacing.xs")} 0 0;
}
.ek-page-header__back {
justify-self: start;
margin-inline-start: calc(-1 * ${designToken("control.padding-inline")});
text-decoration: none;
}
.ek-skeleton-group {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-skeleton {
animation: ek-skeleton-pulse 1.6s ease-in-out infinite;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
display: block;
}
.ek-skeleton--text {
height: 1em;
width: 100%;
}
.ek-skeleton--block {
height: 6rem;
width: 100%;
}
.ek-skeleton--circle {
border-radius: ${designToken("radius.pill")};
height: 2.5rem;
width: 2.5rem;
}
@keyframes ek-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ek-skeleton-pulse {
50% {
opacity: 0.55;
}
}
@keyframes ek-toast-enter {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
}
/* Every animation in these elements stops for a person who asked for reduced motion; nothing in
them depends on movement to be understood. */
@media (prefers-reduced-motion: reduce) {
.ek-spinner {
.ek-spinner,
.ek-skeleton,
.ek-toast-region .ek-toast {
animation: none;
}
}
+126 -23
View File
@@ -1,29 +1,101 @@
import {
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
useEffect,
type RefObject,
useId,
useRef,
} from "react";
import { classNames } from "./class-names.js";
import { useFocusTrap } from "./focus-trap.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
/**
* A page's Back action. The caller decides where Back goes: a host that knows its own routes
* validates any return target before handing it over (ADR 0059); this element only renders it.
*/
export interface PageHeaderBack {
/** Visible and accessible. Name the destination where it helps: "Back to Signage". */
readonly label: string;
/** When Back is a real place, its address: rendered as a link, so it can be opened in a new tab
* and is announced as a link. Without one, Back is a button. */
readonly href?: string;
/** Called on an ordinary activation. A click with a modifier key or the middle button on a link
* is left to the browser. */
readonly onBack: () => void;
}
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
/** The Back action of a nested page (DESIGN.md §4.8): Back plus the page title is the baseline;
* breadcrumbs are not part of it. */
readonly back?: PageHeaderBack;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
function BackAction(props: { readonly back: PageHeaderBack }): ReactElement {
const { back } = props;
const content = (
<>
<span aria-hidden="true" className="ek-page-header__back-symbol">
←
</span>
{back.label}
</>
);
const className = "ek-button ek-button--quiet ek-button--sm ek-focusable ek-page-header__back";
if (back.href !== undefined) {
return (
<a
className={className}
href={back.href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
back.onBack();
}}
>
{content}
</a>
);
}
return (
<button className={className} onClick={back.onBack} type="button">
{content}
</button>
);
}
/**
* The consistent top-level identity and primary-action region for an Ekklesia screen.
*
* The title is the page's one `h1`; a section within the page is a `Panel` with its own `h2`. A
* nested page puts its Back action above the title, and page-level actions sit opposite it.
*/
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.back === undefined ? undefined : <BackAction back={props.back} />}
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
@@ -98,12 +170,23 @@ export function Panel(props: PanelProps): ReactElement {
export interface DialogProps {
readonly open: boolean;
/** The dialog's accessible name, rendered as its heading. */
readonly title: ReactNode;
/** A short statement of what the dialog is about, announced with its name (`aria-describedby`). */
readonly description?: ReactNode;
readonly onDismiss: () => void;
/**
* Whether Escape dismisses the dialog. Default `true`. A caller turns it off while something is
* in progress that dismissing would leave half-done.
*/
readonly dismissible?: boolean;
/** Where focus starts. Without one, the dialog itself, so its name and description are read. */
readonly initialFocusRef?: RefObject<HTMLElement | null>;
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
readonly actions?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
readonly "data-testid"?: string;
}
/**
@@ -112,43 +195,58 @@ export interface DialogProps {
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered, and it
* takes every pointer event, so nothing behind the dialog can be clicked.
*
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
* deliberately not implemented here rather than implemented badly; when the platform needs one it
* belongs in one place, tested, not sprinkled through call sites.
* While open, keyboard focus is trapped inside it ({@link useFocusTrap}): Tab and Shift+Tab wrap,
* and focus returns to the control that opened it when it closes. Escape dismisses it unless the
* caller says that is not safe right now. Escape is handled on the dialog itself and goes no
* further, so a drawer or menu the dialog was opened from does not close with it. Assistive
* technology is told it is modal (`aria-modal`), named by its title, and described by its
* description.
*/
export function Dialog(props: DialogProps): ReactElement | null {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const { onDismiss, open } = props;
useEffect(() => {
if (!open) {
return;
}
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss();
}
};
const owner = dialogRef.current?.ownerDocument;
owner?.addEventListener("keydown", onKeyDown);
return () => owner?.removeEventListener("keydown", onKeyDown);
}, [onDismiss, open]);
const dismissible = props.dismissible ?? true;
const trapKeyDown = useFocusTrap(dialogRef, open, {
...(props.initialFocusRef ? { initialFocus: props.initialFocusRef } : {}),
});
if (!open) {
return null;
}
return (
<div className="ek-dialog__scrim">
<div
className="ek-dialog__scrim"
// The scrim is the barrier between the dialog and the page: a press on it reaches nothing
// behind it and does not dismiss the dialog, which only ever closes deliberately.
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault();
}
}}
>
<div
aria-describedby={props.description === undefined ? undefined : descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className={classNames("ek-dialog", "ek-focusable", props.className)}
data-testid={props["data-testid"]}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (dismissible) {
onDismiss();
}
return;
}
trapKeyDown(event);
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
@@ -156,6 +254,11 @@ export function Dialog(props: DialogProps): ReactElement | null {
<h2 className="ek-dialog__title" id={titleId}>
{props.title}
</h2>
{props.description === undefined ? undefined : (
<div className="ek-dialog__description" id={descriptionId}>
{props.description}
</div>
)}
{props.children}
{props.actions === undefined ? undefined : (
<div className="ek-dialog__actions">{props.actions}</div>
+343
View File
@@ -0,0 +1,343 @@
import {
createContext,
type FocusEvent,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Toast } from "./feedback.js";
/** The four severities a toast can carry. `danger` is an error. */
export type ToastTone = "success" | "info" | "warning" | "danger";
export interface ToastOptions {
readonly tone?: ToastTone;
/** What happened, in a few words. Also names the toast's dismiss control. */
readonly title: string;
readonly description?: string;
/**
* How long it stays, in milliseconds, or `"persistent"` to stay until dismissed. Defaults by
* severity: an error stays until dismissed; anything else lasts long enough to read, and never
* less than three seconds (DESIGN.md §7.4).
*/
readonly duration?: number | "persistent";
}
export interface ToastApi {
/** Shows a toast and returns its id. An identical toast already showing is refreshed instead. */
show(options: ToastOptions): string;
dismiss(id: string): void;
}
/** DESIGN.md §7.4's floor: no toast disappears in under about three seconds. */
export const TOAST_MINIMUM_DURATION_MS = 3000;
const DEFAULT_DURATION_MS: Readonly<Record<Exclude<ToastTone, "danger">, number>> = Object.freeze({
info: 4000,
success: 4000,
warning: 6000,
});
const READING_MS_PER_CHARACTER = 50;
const LONGEST_TIMED_DURATION_MS = 12_000;
const DEFAULT_MAX_VISIBLE = 3;
/**
* How long a toast stays: its requested or default duration, but never less than it takes to read.
* Longer messages stay longer, up to a ceiling; an error stays until dismissed unless its caller
* deliberately gives it a duration (a routine validation error, say).
*/
export function toastDuration(options: ToastOptions): number | "persistent" {
const tone = options.tone ?? "info";
const requested =
options.duration ?? (tone === "danger" ? "persistent" : DEFAULT_DURATION_MS[tone]);
if (requested === "persistent") {
return "persistent";
}
const characters = options.title.length + (options.description?.length ?? 0);
const readingTime = Math.min(
LONGEST_TIMED_DURATION_MS,
TOAST_MINIMUM_DURATION_MS + characters * READING_MS_PER_CHARACTER,
);
return Math.max(TOAST_MINIMUM_DURATION_MS, requested, readingTime);
}
interface ToastRecord {
readonly id: string;
readonly tone: ToastTone;
readonly title: string;
readonly description?: string;
readonly duration: number | "persistent";
readonly key: string;
}
/** A running or paused countdown. `remaining` is what is left when it was last started. */
interface ToastTimer {
handle: ReturnType<typeof setTimeout> | undefined;
remaining: number;
started: number;
}
const ToastContext = createContext<ToastApi | null>(null);
/**
* The toast API of the nearest {@link ToastProvider}. Throws outside one, because a toast shown
* nowhere is a silent failure.
*/
export function useToasts(): ToastApi {
const api = useContext(ToastContext);
if (!api) {
throw new Error("useToasts must be used inside a ToastProvider.");
}
return api;
}
export interface ToastProviderProps {
readonly children?: ReactNode;
/** Names the notification region for assistive technology. */
readonly label?: string;
/** How many non-error toasts show at once. Errors are never displaced to make room. */
readonly maxVisible?: number;
}
/**
* The toast system of one React tree (ADR 0059): a top-center region and the queue behind it.
*
* **Per tree, never a package singleton.** A module mounts in its own isolated root (ADR 0040) with
* its own React tree; a module-level store shared through this package would be mutable state
* reaching across every artifact on the page. Each provider owns its toasts, its timers, and its
* region, and is torn down with its tree — every timer with it.
*
* **Calm under load.** At most `maxVisible` routine toasts show; the oldest routine one makes room.
* An error is never displaced by a routine toast, and stays until dismissed by default. Showing a
* toast identical to one already visible refreshes it rather than stacking a copy. Newest is on top.
*
* **Readable and accessible.** Timers pause while the pointer is over the region or focus is inside
* it (WCAG 2.2 §2.2.1). The region's two live regions are mounted before any toast arrives, so
* assistive technology announces what is added: routine toasts politely, errors assertively. Every
* toast has a dismiss control named for it, and Escape on a focused toast dismisses it; when the
* last one goes, focus returns to where it was before it entered the region.
*/
export function ToastProvider(props: ToastProviderProps): ReactElement {
const maxVisible = Math.max(1, props.maxVisible ?? DEFAULT_MAX_VISIBLE);
const [toasts, setToasts] = useState<readonly ToastRecord[]>([]);
// The synchronous source of truth: `show` must know at once whether a toast is a duplicate and
// what its id is, which a state updater (run later, during render) cannot tell it.
const current = useRef<readonly ToastRecord[]>([]);
const commit = useCallback((next: readonly ToastRecord[]) => {
current.current = next;
setToasts(next);
}, []);
const timers = useRef(new Map<string, ToastTimer>());
const paused = useRef(false);
const nextId = useRef(0);
const focusBeforeRegion = useRef<HTMLElement | null>(null);
const regionRef = useRef<HTMLDivElement | null>(null);
const clearTimer = useCallback((id: string) => {
const timer = timers.current.get(id);
if (timer?.handle !== undefined) {
clearTimeout(timer.handle);
}
timers.current.delete(id);
}, []);
const dismiss = useCallback(
(id: string) => {
clearTimer(id);
if (current.current.some((toast) => toast.id === id)) {
commit(current.current.filter((toast) => toast.id !== id));
}
},
[clearTimer, commit],
);
const startTimer = useCallback(
(id: string, remaining: number) => {
const existing = timers.current.get(id);
if (existing?.handle !== undefined) {
clearTimeout(existing.handle);
}
const timer: ToastTimer = { handle: undefined, remaining, started: Date.now() };
if (!paused.current) {
timer.handle = setTimeout(() => dismiss(id), remaining);
}
timers.current.set(id, timer);
},
[dismiss],
);
const show = useCallback(
(options: ToastOptions): string => {
const tone = options.tone ?? "info";
const duration = toastDuration(options);
const key = JSON.stringify([tone, options.title, options.description ?? ""]);
const duplicate = current.current.find((toast) => toast.key === key);
if (duplicate) {
// Showing it again restarts its time rather than stacking a copy.
if (duplicate.duration !== "persistent") {
startTimer(duplicate.id, duplicate.duration);
}
return duplicate.id;
}
nextId.current += 1;
const id = `ek-toast-${nextId.current}`;
const record: ToastRecord = {
duration,
id,
key,
title: options.title,
tone,
...(options.description === undefined ? {} : { description: options.description }),
};
const next = [record, ...current.current];
const routine = next.filter((toast) => toast.tone !== "danger");
const overflow = new Set(routine.slice(maxVisible).map((toast) => toast.id));
for (const removed of overflow) {
clearTimer(removed);
}
commit(next.filter((toast) => !overflow.has(toast.id)));
if (duration !== "persistent") {
startTimer(id, duration);
}
return id;
},
[clearTimer, commit, maxVisible, startTimer],
);
const pause = useCallback(() => {
if (paused.current) {
return;
}
paused.current = true;
const now = Date.now();
for (const timer of timers.current.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
timer.handle = undefined;
timer.remaining = Math.max(0, timer.remaining - (now - timer.started));
}
}
}, []);
const resume = useCallback(() => {
if (!paused.current) {
return;
}
paused.current = false;
for (const [id, timer] of [...timers.current.entries()]) {
startTimer(id, timer.remaining);
}
}, [startTimer]);
// Every timer dies with the provider.
useEffect(() => {
const active = timers.current;
return () => {
for (const timer of active.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
}
}
active.clear();
};
}, []);
const dismissFromRegion = (id: string): void => {
const remaining = current.current.filter((toast) => toast.id !== id);
dismiss(id);
const region = regionRef.current;
const focusWasInside =
region !== null &&
(region.getRootNode() as Document | ShadowRoot).activeElement !== null &&
region.contains((region.getRootNode() as Document | ShadowRoot).activeElement);
if (!focusWasInside) {
return;
}
// Keep a keyboard user in the region while toasts remain; otherwise return them to where they
// were before they entered it, rather than to nowhere.
if (remaining.length > 0) {
queueMicrotask(() => {
region.querySelector<HTMLElement>(".ek-toast__dismiss")?.focus();
});
} else {
const target = focusBeforeRegion.current;
focusBeforeRegion.current = null;
resume();
if (target?.isConnected) {
target.focus();
}
}
};
const api = useMemo<ToastApi>(() => ({ dismiss, show }), [dismiss, show]);
const routine = toasts.filter((toast) => toast.tone !== "danger");
const errors = toasts.filter((toast) => toast.tone === "danger");
const renderToast = (toast: ToastRecord): ReactElement => (
<Toast
announce={false}
data-toast-id={toast.id}
dismissLabel={`Dismiss notification: ${toast.title}`}
key={toast.id}
onDismiss={() => dismissFromRegion(toast.id)}
title={toast.title}
tone={toast.tone}
>
{toast.description}
</Toast>
);
return (
<ToastContext.Provider value={api}>
{props.children}
<section
aria-label={props.label ?? "Notifications"}
className="ek-toast-region"
onBlur={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
resume();
}
}}
onFocus={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
focusBeforeRegion.current =
event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
}
pause();
}}
onKeyDown={(event) => {
if (event.key !== "Escape") {
return;
}
const owner = (event.target as HTMLElement).closest<HTMLElement>("[data-toast-id]");
const id = owner?.dataset.toastId;
if (id) {
event.stopPropagation();
dismissFromRegion(id);
}
}}
onMouseEnter={pause}
onMouseLeave={() => {
const region = regionRef.current;
const root = region?.getRootNode() as Document | ShadowRoot | undefined;
if (!region || !root?.activeElement || !region.contains(root.activeElement)) {
resume();
}
}}
ref={regionRef}
>
{/* Errors are announced assertively and listed first, so nothing routine can hide one. */}
<div aria-live="assertive" className="ek-toast-region__list" role="alert">
{errors.map(renderToast)}
</div>
<div aria-live="polite" className="ek-toast-region__list" role="status">
{routine.map(renderToast)}
</div>
</section>
</ToastContext.Provider>
);
}
+2 -1
View File
@@ -131,8 +131,9 @@ describe("Ekklesia elements", () => {
// dialog must not escape its isolated root.
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
+797
View File
@@ -0,0 +1,797 @@
import { act, type ReactElement, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matchesRequiredPhrase } from "../src/confirmation.js";
import { ERROR_DETAILS_MAX_LENGTH, errorDetailsText } from "../src/feedback.js";
import {
ConfirmationDialog,
type ConfirmationDialogProps,
Dialog,
ErrorState,
PageHeader,
Skeleton,
SkeletonGroup,
type ToastApi,
ToastProvider,
useToasts,
} from "../src/index.js";
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
import { TOAST_MINIMUM_DURATION_MS, toastDuration } from "../src/toasts.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function render(node: ReactElement): void {
act(() => root.render(node));
}
function byTestId(id: string): HTMLElement {
const element = container.querySelector<HTMLElement>(`[data-testid="${id}"]`);
if (!element) {
throw new Error(`missing ${id}`);
}
return element;
}
function dialog(): HTMLElement {
const element = container.querySelector<HTMLElement>('[role="dialog"]');
if (!element) {
throw new Error("no dialog");
}
return element;
}
function buttonNamed(name: string, scope: ParentNode = container): HTMLButtonElement {
const button = [...scope.querySelectorAll("button")].find(
(candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent) === name,
);
if (!button) {
throw new Error(`no button ${name}`);
}
return button;
}
function press(element: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
act(() => {
element.dispatchEvent(event);
});
return event;
}
function type(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
/** A page with a control that opens a confirmation, so opening, focus return, and the background
* can all be observed. */
function ConfirmHarness(props: {
readonly onConfirm?: () => void;
readonly onCancel?: () => void;
readonly dialog?: Partial<ConfirmationDialogProps>;
}): ReactElement {
const [open, setOpen] = useState(false);
return (
<div>
<button data-testid="background" type="button">
Something behind
</button>
<button data-testid="opener" onClick={() => setOpen(true)} type="button">
Delete display
</button>
<ConfirmationDialog
confirmLabel="Delete display"
description="Foyer-1 stops showing content and must be paired again to come back."
onCancel={() => {
props.onCancel?.();
setOpen(false);
}}
onConfirm={() => {
props.onConfirm?.();
setOpen(false);
}}
open={open}
title="Delete Foyer-1?"
{...props.dialog}
/>
</div>
);
}
function openHarness(options: Parameters<typeof ConfirmHarness>[0] = {}): void {
render(<ConfirmHarness {...options} />);
const opener = byTestId("opener");
act(() => opener.focus());
act(() => opener.click());
}
describe("ConfirmationDialog", () => {
it("is closed until asked, and opens as a named, described modal dialog", () => {
render(<ConfirmHarness />);
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
const element = dialog();
expect(element.getAttribute("aria-modal")).toBe("true");
expect(
container.querySelector(`#${element.getAttribute("aria-labelledby")}`)?.textContent,
).toBe("Delete Foyer-1?");
expect(
container.querySelector(`#${element.getAttribute("aria-describedby")}`)?.textContent,
).toBe("Foyer-1 stops showing content and must be paired again to come back.");
});
it("confirms with its named action, and cancels with Cancel, without the other firing", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
openHarness({ onCancel, onConfirm });
act(() => buttonNamed("Cancel", dialog()).click());
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
act(() => buttonNamed("Delete display", dialog()).click());
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("starts a simple confirmation on its action, ready for Enter", () => {
openHarness();
expect(document.activeElement?.textContent).toBe("Delete display");
expect(document.activeElement?.className).toContain("ek-button--primary");
});
it("marks a destructive action by symbol and frame as well as colour, and starts on Cancel", () => {
openHarness({ dialog: { tone: "destructive" } });
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
);
expect(confirm?.className).toContain("ek-button--danger");
expect(confirm?.querySelector(".ek-confirmation__symbol")?.getAttribute("aria-hidden")).toBe(
"true",
);
expect(dialog().className).toContain("ek-confirmation--destructive");
expect(dialog().querySelector("h2 .ek-confirmation__symbol")).not.toBeNull();
// The safe choice has focus, so a stray Enter cancels rather than destroys.
expect(document.activeElement?.textContent).toBe("Cancel");
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-confirmation__symbol::before");
});
it("requires the exact phrase before a typed confirmation can be confirmed", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "Foyer-1", tone: "destructive" }, onConfirm });
const input = dialog().querySelector("input") as HTMLInputElement;
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
) as HTMLButtonElement;
// Focus starts at the field, which is labelled with the phrase it wants.
expect(document.activeElement).toBe(input);
expect(input.labels?.[0]?.textContent).toBe("Type Foyer-1 to confirm");
expect(input.getAttribute("autocapitalize")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
expect(confirm.disabled).toBe(true);
type(input, "foyer-1");
expect(confirm.disabled).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).not.toHaveBeenCalled();
type(input, " Foyer-1 ");
expect(confirm.disabled).toBe(false);
expect(input.getAttribute("aria-invalid")).toBeNull();
// Enter in the field submits once it matches.
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("forgets a typed phrase when it closes", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
type(dialog().querySelector("input") as HTMLInputElement, "DELETE");
act(() => buttonNamed("Cancel", dialog()).click());
act(() => byTestId("opener").click());
expect((dialog().querySelector("input") as HTMLInputElement).value).toBe("");
});
it("matches a phrase exactly, forgiving only surrounding whitespace and Unicode composition", () => {
expect(matchesRequiredPhrase("DELETE", "DELETE")).toBe(true);
expect(matchesRequiredPhrase(" DELETE\n", "DELETE")).toBe(true);
expect(matchesRequiredPhrase("delete", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELET", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELETE DELETE", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("Café", "Café")).toBe(true);
});
it("closes on Escape and returns focus to the control that opened it", () => {
const onCancel = vi.fn();
openHarness({ onCancel });
const event = press(dialog(), "Escape");
expect(event.defaultPrevented).toBe(true);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(byTestId("opener"));
});
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
const onCancel = vi.fn();
openHarness({ dialog: { busy: true }, onCancel });
press(dialog(), "Escape");
expect(onCancel).not.toHaveBeenCalled();
expect(buttonNamed("Cancel", dialog()).disabled).toBe(true);
expect(dialog().querySelector("form")?.getAttribute("aria-busy")).toBe("true");
});
it("keeps Tab and Shift+Tab inside the dialog", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
const input = dialog().querySelector("input") as HTMLInputElement;
type(input, "DELETE");
const cancel = buttonNamed("Cancel", dialog());
const confirm = buttonNamed("Delete display", dialog());
act(() => confirm.focus());
expect(press(confirm, "Tab").defaultPrevented).toBe(true);
expect(document.activeElement).toBe(input);
expect(press(input, "Tab", { shiftKey: true }).defaultPrevented).toBe(true);
expect(document.activeElement).toBe(confirm);
// Between inner controls, the browser moves focus itself.
act(() => input.focus());
expect(press(input, "Tab").defaultPrevented).toBe(false);
expect(cancel).toBeTruthy();
});
it("blocks the page behind: the scrim takes the pointer and nothing behind is in the tab loop", () => {
openHarness();
const scrim = dialog().parentElement as HTMLElement;
expect(scrim.className).toBe("ek-dialog__scrim");
const pointer = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
act(() => {
scrim.dispatchEvent(pointer);
});
expect(pointer.defaultPrevented).toBe(true);
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(dialog().contains(byTestId("background"))).toBe(false);
// Tab from the last control wraps inside rather than reaching "Something behind".
const last = buttonNamed("Delete display", dialog());
act(() => last.focus());
press(last, "Tab");
expect(document.activeElement).not.toBe(byTestId("background"));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("stops Escape at the dialog, so a drawer or menu around it stays open", () => {
const outer = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: a test stand-in for a drawer that closes on Escape.
<div
onKeyDown={(event) => {
if (event.key === "Escape") {
outer();
}
}}
>
<ConfirmHarness />
</div>,
);
act(() => byTestId("opener").click());
press(dialog(), "Escape");
expect(outer).not.toHaveBeenCalled();
});
it("is operable with the keyboard alone", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "DELETE" }, onConfirm });
const input = document.activeElement as HTMLInputElement;
type(input, "DELETE");
act(() => {
(input.form as HTMLFormElement).requestSubmit();
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
describe("Dialog", () => {
it("traps focus, describes itself, and returns focus when it closes", () => {
function Harness(): ReactElement {
const [open, setOpen] = useState(false);
return (
<>
<button data-testid="open" onClick={() => setOpen(true)} type="button">
Open
</button>
<Dialog
actions={
<button onClick={() => setOpen(false)} type="button">
Done
</button>
}
description="Details about the dialog."
onDismiss={() => setOpen(false)}
open={open}
title="Information"
/>
</>
);
}
render(<Harness />);
act(() => byTestId("open").focus());
act(() => byTestId("open").click());
expect(document.activeElement).toBe(dialog());
expect(
container.querySelector(`#${dialog().getAttribute("aria-describedby")}`)?.textContent,
).toBe("Details about the dialog.");
const done = buttonNamed("Done");
act(() => done.focus());
press(done, "Tab");
expect(document.activeElement).toBe(done);
act(() => done.click());
expect(document.activeElement).toBe(byTestId("open"));
});
it("ignores Escape when the caller says dismissing is not safe", () => {
const onDismiss = vi.fn();
render(<Dialog dismissible={false} onDismiss={onDismiss} open title="Saving" />);
press(dialog(), "Escape");
expect(onDismiss).not.toHaveBeenCalled();
});
});
function ToastHarness(props: { readonly onApi: (api: ToastApi) => void }): null {
props.onApi(useToasts());
return null;
}
function renderToasts(options: { readonly maxVisible?: number } = {}): ToastApi {
let api: ToastApi | undefined;
render(
<ToastProvider {...options}>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
if (!api) {
throw new Error("no toast api");
}
return api;
}
function toastTitles(): string[] {
return [...container.querySelectorAll(".ek-toast .ek-toast__title")].map(
(title) => title.textContent?.replace(/^\S+\s/u, "") ?? "",
);
}
describe("toasts", () => {
it("renders a top-center region whose live regions exist before any toast arrives", () => {
renderToasts();
const region = container.querySelector(".ek-toast-region");
expect(region?.tagName).toBe("SECTION");
expect(region?.getAttribute("aria-label")).toBe("Notifications");
expect(region?.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull();
expect(region?.querySelector('[role="alert"][aria-live="assertive"]')).not.toBeNull();
expect(EKKLESIA_UI_STYLESHEET).toMatch(/\.ek-toast-region \{[^}]*position: fixed;[^}]*top:/su);
});
it("shows each severity with its symbol, errors assertively and the rest politely", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
api.show({ title: "Heads up", tone: "info" });
api.show({ title: "Storage is nearly full", tone: "warning" });
api.show({ title: "The display could not be revoked", tone: "danger" });
});
const polite = container.querySelector('[aria-live="polite"]') as HTMLElement;
const assertive = container.querySelector('[aria-live="assertive"]') as HTMLElement;
expect(assertive.textContent).toContain("The display could not be revoked");
expect(polite.textContent).not.toContain("could not be revoked");
for (const tone of ["success", "info", "warning", "danger"]) {
const toast = container.querySelector(`.ek-toast--${tone}`);
expect(toast, tone).not.toBeNull();
// Its own role would nest one live region in another.
expect(toast?.getAttribute("role"), tone).toBeNull();
expect(toast?.querySelector('[aria-hidden="true"]')?.textContent?.trim(), tone).not.toBe("");
}
});
it("dismisses a routine toast on its own once there has been time to read it", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
act(() => {
vi.advanceTimersByTime(TOAST_MINIMUM_DURATION_MS - 1);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(toastTitles()).toEqual([]);
});
it("never times a toast out in under three seconds, and gives longer messages longer", () => {
const tooShort = toastDuration({ duration: 500, title: "Saved" }) as number;
expect(tooShort).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(tooShort).toBeLessThan(TOAST_MINIMUM_DURATION_MS + 1000);
const short = toastDuration({ title: "Saved", tone: "success" }) as number;
const long = toastDuration({
description:
"The timeline was published to every screen in the Sanctuary and Foyer groups, and the next slide starts in a minute.",
title: "Published",
tone: "success",
}) as number;
expect(short).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(long).toBeGreaterThan(short);
expect(toastDuration({ title: "Careful", tone: "warning" })).toBeGreaterThan(short);
});
it("keeps an error until it is dismissed, unless its caller gives it a duration", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
api.show({ duration: 4000, title: "Enter a name", tone: "danger" });
});
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(toastTitles()).toEqual(["The display could not be revoked"]);
expect(toastDuration({ title: "x", tone: "danger" })).toBe("persistent");
});
it("dismisses on its named dismiss control", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
});
act(() => buttonNamed("Dismiss notification: The display could not be revoked").click());
expect(toastTitles()).toEqual([]);
});
it("dismisses programmatically by the id it returned", () => {
vi.useFakeTimers();
const api = renderToasts();
let id = "";
act(() => {
id = api.show({ title: "Uploading", tone: "info" });
});
expect(id).toMatch(/^ek-toast-/u);
act(() => api.dismiss(id));
expect(toastTitles()).toEqual([]);
});
it("dismisses the focused toast on Escape and returns focus where it was", () => {
vi.useFakeTimers();
let api: ToastApi | undefined;
render(
<ToastProvider>
<button data-testid="work" type="button">
Work
</button>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
act(() => {
api?.show({ title: "Saved", tone: "success" });
});
const work = byTestId("work");
act(() => work.focus());
const dismiss = buttonNamed("Dismiss notification: Saved");
act(() => {
dismiss.dispatchEvent(new FocusEvent("focusin", { bubbles: true, relatedTarget: work }));
dismiss.focus();
});
press(dismiss, "Escape");
expect(toastTitles()).toEqual([]);
expect(document.activeElement).toBe(work);
});
it("stacks newest first, refreshes a duplicate, and never lets routine toasts displace an error", () => {
vi.useFakeTimers();
const api = renderToasts({ maxVisible: 2 });
act(() => {
api.show({ title: "Storage is unavailable", tone: "danger" });
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "info" });
api.show({ title: "Three", tone: "info" });
api.show({ title: "Three", tone: "info" });
});
// Errors are listed first; then routine toasts, newest first, at most two of them.
expect(toastTitles()).toEqual(["Storage is unavailable", "Three", "Two"]);
expect(container.querySelectorAll(".ek-toast")).toHaveLength(3);
});
it("pauses while the pointer is over it, so a toast being read does not vanish", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
const region = container.querySelector(".ek-toast-region") as HTMLElement;
act(() => {
region.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
region.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual([]);
});
it("clears every timer when the provider goes away", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "warning" });
});
expect(vi.getTimerCount()).toBe(2);
act(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
root = createRoot(container);
});
it("is per React tree: outside a provider there is no toast API to reach for", () => {
function Orphan(): null {
useToasts();
return null;
}
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(() => render(<Orphan />)).toThrow("useToasts must be used inside a ToastProvider.");
consoleError.mockRestore();
});
});
describe("ErrorState", () => {
it("leads with a plain message, and shows no disclosure when there is no detail", () => {
render(<ErrorState description="Check the connection, then retry." title="Could not load" />);
const element = container.querySelector(".ek-message--error") as HTMLElement;
expect(element.getAttribute("role")).toBe("alert");
expect(element.querySelector(".ek-message__title")?.textContent).toBe("Could not load");
expect(element.querySelector("details")).toBeNull();
});
it("keeps technical detail and a reference collapsed behind a disclosure", () => {
render(
<ErrorState
details={"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s."}
reference="req-7f3a"
title="The image could not be uploaded"
/>,
);
const details = container.querySelector("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.querySelector("summary")?.textContent).toBe("Technical details");
expect(details.querySelector("pre")?.textContent).toBe(
"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s.",
);
expect(details.querySelector("code")?.textContent).toBe("req-7f3a");
});
it("offers recovery and remedy actions beside the message", () => {
const retry = vi.fn();
render(
<ErrorState
actions={
<>
<button onClick={retry} type="button">
Retry
</button>
<a href="/settings">Open storage settings</a>
</>
}
title="Could not load"
/>,
);
act(() => buttonNamed("Retry").click());
expect(retry).toHaveBeenCalledTimes(1);
expect(container.querySelector(".ek-message__actions a")?.getAttribute("href")).toBe(
"/settings",
);
});
it("renders an arbitrary server string as inert, bounded text", () => {
const hostile = `<img src=x onerror="alert(1)"><script>steal()</script>\u0007\u001b[31m${"x".repeat(5000)}`;
render(<ErrorState details={hostile} title="Something went wrong" />);
const pre = container.querySelector("pre") as HTMLElement;
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("script")).toBeNull();
expect(pre.textContent?.startsWith('<img src=x onerror="alert(1)">')).toBe(true);
expect(pre.textContent).not.toContain("\u0007");
expect(pre.textContent?.length).toBe(ERROR_DETAILS_MAX_LENGTH + 1);
expect(errorDetailsText(" line one\n\tline two ")).toBe("line one\n\tline two");
});
it("can be part of a page's initial content without being announced", () => {
render(<ErrorState announce={false} title="Not available" />);
expect(container.querySelector(".ek-message--error")?.getAttribute("role")).toBeNull();
});
});
describe("Skeleton", () => {
it("is a busy, labelled region announced once, with every placeholder hidden from it", () => {
render(
<SkeletonGroup label="Loading displays">
<Skeleton shape="circle" width="2rem" />
<Skeleton width="60%" />
<Skeleton shape="block" />
</SkeletonGroup>,
);
const group = container.querySelector(".ek-skeleton-group") as HTMLElement;
expect(group.getAttribute("role")).toBe("status");
expect(group.getAttribute("aria-busy")).toBe("true");
expect(container.querySelector(`#${group.getAttribute("aria-labelledby")}`)?.textContent).toBe(
"Loading displays",
);
const placeholders = [...group.querySelectorAll(".ek-skeleton")] as HTMLElement[];
expect(placeholders).toHaveLength(3);
for (const placeholder of placeholders) {
expect(placeholder.getAttribute("aria-hidden")).toBe("true");
expect(placeholder.textContent).toBe("");
}
expect(placeholders[0]?.style.height).toBe("2rem");
// No percentage, no progress: there is none to report.
expect(group.textContent).toBe("Loading displays");
});
it("stops moving for a person who asked for reduced motion", () => {
const reduced = EKKLESIA_UI_STYLESHEET.slice(
EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"),
);
expect(reduced).toMatch(/\.ek-skeleton[,\s]/u);
expect(reduced).toContain("animation: none");
});
});
describe("PageHeader", () => {
it("names the page with its one h1 and keeps page actions beside it", () => {
render(<PageHeader actions={<button type="button">Add display</button>} title="Displays" />);
expect(container.querySelector("h1")?.textContent).toBe("Displays");
expect(container.querySelector(".ek-page-header__actions button")?.textContent).toBe(
"Add display",
);
expect(container.querySelector(".ek-page-header__back")).toBeNull();
});
it("puts a Back link above the title when Back is a real place", () => {
const onBack = vi.fn();
render(
<PageHeader
back={{ href: "/signage", label: "Back to Signage", onBack }}
title="Add display"
/>,
);
const back = container.querySelector(".ek-page-header__back") as HTMLAnchorElement;
expect(back.tagName).toBe("A");
expect(back.getAttribute("href")).toBe("/signage");
expect(back.textContent).toBe("←Back to Signage");
expect(back.querySelector('[aria-hidden="true"]')?.textContent).toBe("←");
const copy = back.parentElement as HTMLElement;
expect([...copy.children].indexOf(back)).toBeLessThan(
[...copy.children].indexOf(copy.querySelector("h1") as Element),
);
const click = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true });
act(() => {
back.dispatchEvent(click);
});
expect(click.defaultPrevented).toBe(true);
expect(onBack).toHaveBeenCalledTimes(1);
const newTab = new MouseEvent("click", {
bubbles: true,
button: 0,
cancelable: true,
ctrlKey: true,
});
act(() => {
back.dispatchEvent(newTab);
});
expect(newTab.defaultPrevented).toBe(false);
expect(onBack).toHaveBeenCalledTimes(1);
});
it("renders Back as a button when it has no address", () => {
const onBack = vi.fn();
render(<PageHeader back={{ label: "Back", onBack }} title="Details" />);
const back = container.querySelector(".ek-page-header__back") as HTMLButtonElement;
expect(back.tagName).toBe("BUTTON");
expect(back.type).toBe("button");
act(() => back.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,32 @@
import type { PageHeaderBack } from "@ekklesia/ui";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import { parseReturnTarget } from "../return-target.js";
import type { HostHistory } from "./host-history.js";
export interface DashboardBackOptions {
/** Where the person came from, if a link said so. Validated; anything unsafe is ignored. */
readonly returnTarget?: unknown;
/** The page's own parent: where Back goes when there is no valid return target. */
readonly fallback: DashboardDestination;
/** Names the destination: "Back to Signage". Default "Back". */
readonly label?: (destination: DashboardDestination) => string;
}
/**
* A nested Dashboard page's Back action, for `PageHeader` (DESIGN.md §2.7, §4.8; ADR 0059).
*
* Back returns to the workflow that sent the person here when a validated return target says where
* that was; otherwise it goes to the page's declared parent. It never calls the browser's own
* `history.back()`: after a bookmark or a link from outside, the previous entry is not the
* Dashboard's, and Back must not take the person off the Dashboard. The action is a real link to the
* canonical address, so it can also be opened in a new tab.
*/
export function dashboardBack(history: HostHistory, options: DashboardBackOptions): PageHeaderBack {
const destination = parseReturnTarget(options.returnTarget) ?? options.fallback;
const href = dashboardPath(destination);
return {
href,
label: options.label?.(destination) ?? "Back",
onBack: () => history.push(href),
};
}
+54
View File
@@ -0,0 +1,54 @@
import { type DashboardDestination, parseDashboardPath } from "./dashboard-routes.js";
/** Long enough for any Dashboard address; short enough that nothing else hides in one. */
const MAXIMUM_LENGTH = 2048;
const PLACEHOLDER_ORIGIN = "https://dashboard.invalid";
/**
* Reads a "return to where I came from" value into a Dashboard destination, or refuses it
* (ADR 0059).
*
* A return target is input: it arrives from a link another page or module built, so it is treated
* as hostile until proven otherwise. ADR 0034 requires any return location to be a separately
* validated, same-origin route decision. This is that validation, and it is deliberately narrow:
*
* - a string of at most 2 KiB, beginning with exactly one `/`;
* - no whitespace or control character, no backslash, no percent-encoded slash, backslash, or dot,
* and no dot-segment — nothing a browser would normalize into a different address;
* - no query and no fragment, for now;
* - and it must be a page the Dashboard actually has (`parseDashboardPath` is not `not-found`).
*
* The answer is a destination, never the raw string, so the caller can only ever navigate to the
* canonical spelling of a real page. How a link carries a return target is the module navigation
* contract's decision (ADR 0057); whatever carries it hands the value here first.
*/
export function parseReturnTarget(value: unknown): DashboardDestination | undefined {
if (typeof value !== "string" || value.length === 0 || value.length > MAXIMUM_LENGTH) {
return undefined;
}
if (!value.startsWith("/") || value.startsWith("//")) {
return undefined;
}
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
if (/[\s\u0000-\u001f\u007f\\]/u.test(value) || /%(?:2f|5c|2e)/iu.test(value)) {
return undefined;
}
let url: URL;
try {
url = new URL(value, PLACEHOLDER_ORIGIN);
} catch {
return undefined;
}
if (
url.origin !== PLACEHOLDER_ORIGIN ||
url.search !== "" ||
url.hash !== "" ||
value.includes("?") ||
value.includes("#") ||
url.pathname !== value
) {
return undefined;
}
const route = parseDashboardPath(url.pathname);
return route.kind === "not-found" ? undefined : route;
}
-12
View File
@@ -95,18 +95,6 @@ describe("the shell is coupled to no module", () => {
}
});
it("never calls a browser-native dialog: alert, confirm, and prompt are prohibited in product UI", () => {
for (const [file, source] of sources) {
const code = source
.split("\n")
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
.join("\n");
expect(code, path.relative(packageDirectory, file)).not.toMatch(
/\b(?:window\.|globalThis\.|self\.)?(?:alert|confirm|prompt)\s*\(/u,
);
}
});
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
for (const [file, source] of sources) {
// Comment lines are dropped so a doc comment may still *describe* the loader's own
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { dashboardBack } from "../src/browser/dashboard-back.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { parseReturnTarget } from "../src/return-target.js";
describe("parseReturnTarget", () => {
it("accepts a Dashboard page on this origin, as its canonical destination", () => {
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
});
it("refuses anything that is not a string, or is empty or enormous", () => {
for (const value of [undefined, null, 42, {}, ["/signage"], "", `/${"a".repeat(3000)}`]) {
expect(parseReturnTarget(value), JSON.stringify(value)).toBeUndefined();
}
});
it("refuses every way of naming another origin", () => {
for (const value of [
"https://evil.example/signage",
"http://localhost/signage",
"//evil.example/signage",
"/\\evil.example",
"\\\\evil.example",
"javascript:alert(1)",
"data:text/html,hi",
" /signage",
"/signage\n",
"/sign\tage",
"signage",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
it("refuses traversal and encoded separators that would normalize into something else", () => {
for (const value of [
"/signage/../settings",
"/./signage",
"/signage/%2e%2e/api",
"/%2F%2Fevil.example",
"/%5Cevil.example",
"/signage%2Fx",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
it("refuses a query or fragment, and any path the Dashboard has no page for", () => {
for (const value of [
"/signage?x=1",
"/signage#top",
"/api/v1/principal",
"/display/signage",
"/settings",
"/signage/timelines",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
});
describe("dashboardBack", () => {
it("goes back to a validated origin when one was supplied", () => {
const history = createMemoryHostHistory("/calendar");
const back = dashboardBack(history, {
fallback: { kind: "overview" },
label: (destination) => (destination.kind === "module" ? "Back to Signage" : "Back"),
returnTarget: "/signage",
});
expect(back.href).toBe("/signage");
expect(back.label).toBe("Back to Signage");
back.onBack();
expect(history.entries).toEqual(["/calendar", "/signage"]);
});
it("falls back to the page's own parent when the target is missing or unsafe", () => {
for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) {
const history = createMemoryHostHistory("/calendar");
const back = dashboardBack(history, {
fallback: { kind: "overview" },
returnTarget,
});
expect(back.href, String(returnTarget)).toBe("/");
expect(back.label, String(returnTarget)).toBe("Back");
back.onBack();
expect(history.entries, String(returnTarget)).toEqual(["/calendar", "/"]);
}
});
it("never leaves the Dashboard through the browser's own history", () => {
const history = createMemoryHostHistory("/calendar");
const go = vi.spyOn(history, "go");
dashboardBack(history, { fallback: { kind: "overview" } }).onBack();
expect(go).not.toHaveBeenCalled();
});
});
+19
View File
@@ -0,0 +1,19 @@
// Nothing here is a browser dialog, and nothing here may be reported.
const confirm = (value: string): string => value;
confirm("a local function that happens to share the name");
function prompt(text: string): string {
return text;
}
prompt("a local declaration");
const dialog = { alert: () => 1, confirm: () => true, prompt: () => "" };
dialog.confirm();
dialog.alert();
dialog.prompt();
const props = { onConfirm: () => undefined };
props.onConfirm();
const confirmLabel = "Delete display";
const message = "Call alert( only in a string";
const pattern = /window\.confirm/u;
// window.confirm("a comment is not code");
/* alert("nor is a block comment"); */
export { confirmLabel, message, pattern };
@@ -0,0 +1,4 @@
// Forms Biome's call-based rule does not see; the repository scan must refuse each one.
self.prompt("a");
const borrowed = window.confirm;
export { borrowed };
+9
View File
@@ -0,0 +1,9 @@
// Every line below is a browser-native dialog. The repository's lint must refuse each one.
alert("a");
window.alert("b");
confirm("c");
window.confirm("d");
prompt("e");
window.prompt("f");
globalThis.confirm("g");
window["alert"]("h");
+152
View File
@@ -0,0 +1,152 @@
import { execFile } from "node:child_process";
import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Browser-native dialogs — `alert`, `confirm`, `prompt` — are prohibited in product UI (DESIGN.md
* §8.1, ADR 0059). The guard is repository-wide and two-part:
*
* 1. Biome's scope-aware `suspicious/noAlert`, an error in the root `biome.json`, so `pnpm lint`
* refuses a dialog call anywhere in this repository — the shell, `@ekklesia/ui`, the module
* template — while leaving a local function or a method that shares the name alone.
* 2. A narrow scan of browser source for the two forms that rule cannot see: a call through `self`,
* `top`, `parent`, or `frames`, and borrowing the function without calling it.
*
* This file proves both against fixtures, with the repository's own lint configuration, so the
* guard is shown to catch real usage and to stay quiet about lookalikes.
*/
const execFileAsync = promisify(execFile);
const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const fixtures = path.join(repositoryRoot, "tests", "fixtures", "native-dialogs");
const biome = path.join(repositoryRoot, "node_modules", ".bin", "biome");
/** Where browser code lives in this repository. */
const BROWSER_SOURCE_ROOTS = [
"surfaces",
path.join("packages", "ui", "src"),
path.join("packages", "module-ui-sdk", "src"),
path.join("packages", "module-ui-host", "src"),
path.join("templates", "module", "src"),
];
/** A dialog reached through a window alias, called or not: `self.prompt(`, `= window.confirm;`. */
const DIALOG_THROUGH_A_WINDOW =
/\b(?:window|globalThis|self|top|parent|frames)\s*(?:\.\s*|\[\s*["'`])(?:alert|confirm|prompt)\b/u;
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:\\])\/\/.*$/gmu, "$1");
}
/** Lines that reach a dialog in a way Biome's rule does not report. */
function scan(source: string): number[] {
return withoutComments(source)
.split("\n")
.flatMap((line, index) => {
const code = line.replace(/\/(?:\\.|[^/\n])+\/[a-z]*/gu, "").replace(/"[^"]*"|'[^']*'/gu, "");
return DIALOG_THROUGH_A_WINDOW.test(code) ? [index + 1] : [];
});
}
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!["node_modules", "dist", ".turbo", "tests", "test"].includes(entry.name)) {
files.push(...(await sourceFiles(full)));
}
} else if (/\.(?:ts|tsx|js|jsx|mjs)$/u.test(entry.name)) {
files.push(full);
}
}
return files;
}
let workspace: string;
/** Lints a fixture with exactly the root configuration's rules, from a directory the root
* configuration's own `files.includes` does not exclude. */
async function lint(
fixture: string,
): Promise<Array<{ readonly line: number; readonly rule: string }>> {
try {
await execFileAsync(biome, ["lint", "--max-diagnostics=100", fixture], {
cwd: workspace,
});
return [];
} catch (error) {
const output = `${(error as { stdout?: string }).stdout ?? ""}${(error as { stderr?: string }).stderr ?? ""}`;
return [...output.matchAll(/^\S*?(\d+):\d+ (lint\/\S+)/gmu)].map((match) => ({
line: Number(match[1]),
rule: match[2] ?? "",
}));
}
}
beforeAll(async () => {
workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-native-dialogs-"));
const config = JSON.parse(await readFile(path.join(repositoryRoot, "biome.json"), "utf8")) as {
files?: unknown;
};
delete config.files;
await writeFile(path.join(workspace, "biome.json"), JSON.stringify(config));
await cp(fixtures, workspace, { recursive: true });
});
afterAll(async () => {
await rm(workspace, { force: true, recursive: true });
});
describe("the repository's lint refuses browser-native dialogs", () => {
it("makes noAlert an error in the root configuration", async () => {
const config = JSON.parse(await readFile(path.join(repositoryRoot, "biome.json"), "utf8")) as {
linter?: { rules?: { suspicious?: { noAlert?: unknown } } };
};
expect(config.linter?.rules?.suspicious?.noAlert).toBe("error");
});
it("reports every dialog call", async () => {
const findings = await lint("prohibited.ts");
expect(findings.filter((finding) => finding.rule === "lint/suspicious/noAlert")).toEqual(
[2, 3, 4, 5, 6, 7, 8, 9].map((line) => ({ line, rule: "lint/suspicious/noAlert" })),
);
}, 30_000);
it("reports nothing about a local function, a method, a property, a string, or a comment", async () => {
expect(await lint("permitted.ts")).toEqual([]);
}, 30_000);
});
describe("the repository scan covers what the lint rule cannot see", () => {
it("catches a dialog called through self, or borrowed without being called", async () => {
const source = await readFile(path.join(fixtures, "prohibited-references.ts"), "utf8");
expect(scan(source)).toEqual([2, 3]);
});
it("stays quiet about the permitted fixture", async () => {
const source = await readFile(path.join(fixtures, "permitted.ts"), "utf8");
expect(scan(source)).toEqual([]);
});
it("finds no dialog anywhere in this repository's browser source", async () => {
const offenders: string[] = [];
let scanned = 0;
for (const sourceRoot of BROWSER_SOURCE_ROOTS) {
for (const file of await sourceFiles(path.join(repositoryRoot, sourceRoot))) {
scanned += 1;
for (const line of scan(await readFile(file, "utf8"))) {
offenders.push(`${path.relative(repositoryRoot, file)}:${line}`);
}
}
}
expect(scanned).toBeGreaterThan(20);
expect(offenders).toEqual([]);
});
});