feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives (Stage 1, ADR 0059) #106
@@ -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
|
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
|
||||||
sidebar's identity slot is still to come.
|
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
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
|
|||||||
+4
-1
@@ -19,7 +19,10 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"preset": "recommended"
|
"preset": "recommended",
|
||||||
|
"suspicious": {
|
||||||
|
"noAlert": "error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"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.
|
||||||
@@ -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
|
- [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
|
- [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
|
- [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
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
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
|
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
|
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||||
|
|||||||
@@ -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
|
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||||
import, and `pnpm boundaries` says so.
|
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
|
### Styling: a stylesheet compiled into your artifact
|
||||||
|
|
||||||
Author plain `.css` beside your UI source, import it, and declare it
|
Author plain `.css` beside your UI source, import it, and declare it
|
||||||
|
|||||||
@@ -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
@@ -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";
|
import { classNames } from "./class-names.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +48,17 @@ export interface ToastProps {
|
|||||||
readonly title: ReactNode;
|
readonly title: ReactNode;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly children?: ReactNode;
|
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 {
|
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
|
* The queue, timing, and placement belong to {@link ToastProvider} (ADR 0059), which is per React
|
||||||
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
|
* tree rather than a package singleton: a module-level store would be shared mutable state reaching
|
||||||
* package-level singleton would be shared mutable state reaching across every isolated artifact on
|
* across every isolated artifact on a page. A caller may still render one of these on its own.
|
||||||
* a page. A caller renders these where it wants them, for as long as it wants them.
|
|
||||||
*/
|
*/
|
||||||
export function Toast(props: ToastProps): ReactElement {
|
export function Toast(props: ToastProps): ReactElement {
|
||||||
const tone = props.tone ?? "info";
|
const tone = props.tone ?? "info";
|
||||||
|
const announce = props.announce ?? true;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live={tone === "danger" ? "assertive" : "polite"}
|
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
|
||||||
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
|
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">
|
<div className="ek-toast__content">
|
||||||
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
<div className="ek-toast__title">
|
||||||
{props.title}
|
<span aria-hidden="true">{statusSymbols[tone]} </span>
|
||||||
|
{props.title}
|
||||||
|
</div>
|
||||||
|
{props.children === undefined ? undefined : (
|
||||||
|
<div className="ek-toast__body">{props.children}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{props.children === undefined ? undefined : (
|
{props.onDismiss === undefined ? undefined : (
|
||||||
<div className="ek-toast__body">{props.children}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -143,28 +167,157 @@ export function EmptyState(props: EmptyStateProps): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorStateProps {
|
export interface ErrorStateProps {
|
||||||
|
/** What failed, in plain words: "The displays could not be loaded." */
|
||||||
readonly title: ReactNode;
|
readonly title: ReactNode;
|
||||||
|
/** What the person can do about it, and whether trying again makes sense. */
|
||||||
readonly description?: ReactNode;
|
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;
|
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;
|
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
|
* The message is the caller's fixed, human wording. **Never** pass a caught exception or its stack:
|
||||||
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
|
* `details` takes a string, chosen deliberately — a sanitized error code, the step that failed —
|
||||||
* component is where that discipline becomes visible to a user.
|
* and stays collapsed until opened.
|
||||||
*/
|
*/
|
||||||
export function ErrorState(props: ErrorStateProps): ReactElement {
|
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 (
|
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>
|
<p className="ek-message__title">{props.title}</p>
|
||||||
{props.description === undefined ? undefined : (
|
{props.description === undefined ? undefined : (
|
||||||
<p className="ek-message__body">{props.description}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,11 @@ export {
|
|||||||
type TableRowProps,
|
type TableRowProps,
|
||||||
type TableSectionProps,
|
type TableSectionProps,
|
||||||
} from "./data.js";
|
} from "./data.js";
|
||||||
|
export {
|
||||||
|
ConfirmationDialog,
|
||||||
|
type ConfirmationDialogProps,
|
||||||
|
type ConfirmationTone,
|
||||||
|
} from "./confirmation.js";
|
||||||
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
|
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
|
||||||
export {
|
export {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -56,6 +61,10 @@ export {
|
|||||||
type InlineNoticeProps,
|
type InlineNoticeProps,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
type LoadingStateProps,
|
type LoadingStateProps,
|
||||||
|
Skeleton,
|
||||||
|
SkeletonGroup,
|
||||||
|
type SkeletonGroupProps,
|
||||||
|
type SkeletonProps,
|
||||||
Toast,
|
Toast,
|
||||||
type ToastProps,
|
type ToastProps,
|
||||||
ToastStack,
|
ToastStack,
|
||||||
@@ -75,8 +84,17 @@ export {
|
|||||||
Panel,
|
Panel,
|
||||||
type PanelProps,
|
type PanelProps,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
type PageHeaderBack,
|
||||||
type PageHeaderProps,
|
type PageHeaderProps,
|
||||||
type TabDefinition,
|
type TabDefinition,
|
||||||
Tabs,
|
Tabs,
|
||||||
type TabsProps,
|
type TabsProps,
|
||||||
} from "./surfaces.js";
|
} from "./surfaces.js";
|
||||||
|
export {
|
||||||
|
type ToastApi,
|
||||||
|
type ToastOptions,
|
||||||
|
ToastProvider,
|
||||||
|
type ToastProviderProps,
|
||||||
|
type ToastTone,
|
||||||
|
useToasts,
|
||||||
|
} from "./toasts.js";
|
||||||
|
|||||||
+172
-1
@@ -595,14 +595,185 @@ export const EKKLESIA_UI_STYLESHEET = `
|
|||||||
width: ${designToken("spacing.xl")};
|
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 {
|
@keyframes ek-spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ek-spinner {
|
.ek-spinner,
|
||||||
|
.ek-skeleton,
|
||||||
|
.ek-toast-region .ek-toast {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-23
@@ -1,29 +1,101 @@
|
|||||||
import {
|
import {
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
|
type MouseEvent,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
type RefObject,
|
||||||
useId,
|
useId,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { classNames } from "./class-names.js";
|
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
|
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
|
||||||
* that divides it. */
|
* 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 {
|
export interface PageHeaderProps {
|
||||||
readonly title: ReactNode;
|
readonly title: ReactNode;
|
||||||
readonly description?: ReactNode;
|
readonly description?: ReactNode;
|
||||||
readonly actions?: ReactNode;
|
readonly actions?: ReactNode;
|
||||||
readonly eyebrow?: 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;
|
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 {
|
export function PageHeader(props: PageHeaderProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<header className={classNames("ek-page-header", props.className)}>
|
<header className={classNames("ek-page-header", props.className)}>
|
||||||
<div className="ek-page-header__copy">
|
<div className="ek-page-header__copy">
|
||||||
|
{props.back === undefined ? undefined : <BackAction back={props.back} />}
|
||||||
{props.eyebrow === undefined ? undefined : (
|
{props.eyebrow === undefined ? undefined : (
|
||||||
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
|
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
|
||||||
)}
|
)}
|
||||||
@@ -98,12 +170,23 @@ export function Panel(props: PanelProps): ReactElement {
|
|||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
readonly open: boolean;
|
readonly open: boolean;
|
||||||
|
/** The dialog's accessible name, rendered as its heading. */
|
||||||
readonly title: ReactNode;
|
readonly title: ReactNode;
|
||||||
|
/** A short statement of what the dialog is about, announced with its name (`aria-describedby`). */
|
||||||
|
readonly description?: ReactNode;
|
||||||
readonly onDismiss: () => void;
|
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. */
|
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
|
||||||
readonly actions?: ReactNode;
|
readonly actions?: ReactNode;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly children?: ReactNode;
|
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
|
* 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
|
* 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
|
* 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
|
* While open, keyboard focus is trapped inside it ({@link useFocusTrap}): Tab and Shift+Tab wrap,
|
||||||
* deliberately not implemented here rather than implemented badly; when the platform needs one it
|
* and focus returns to the control that opened it when it closes. Escape dismisses it unless the
|
||||||
* belongs in one place, tested, not sprinkled through call sites.
|
* 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 {
|
export function Dialog(props: DialogProps): ReactElement | null {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { onDismiss, open } = props;
|
const { onDismiss, open } = props;
|
||||||
|
const dismissible = props.dismissible ?? true;
|
||||||
useEffect(() => {
|
const trapKeyDown = useFocusTrap(dialogRef, open, {
|
||||||
if (!open) {
|
...(props.initialFocusRef ? { initialFocus: props.initialFocusRef } : {}),
|
||||||
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]);
|
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
|
aria-describedby={props.description === undefined ? undefined : descriptionId}
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className={classNames("ek-dialog", "ek-focusable", props.className)}
|
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}
|
ref={dialogRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -156,6 +254,11 @@ export function Dialog(props: DialogProps): ReactElement | null {
|
|||||||
<h2 className="ek-dialog__title" id={titleId}>
|
<h2 className="ek-dialog__title" id={titleId}>
|
||||||
{props.title}
|
{props.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
{props.description === undefined ? undefined : (
|
||||||
|
<div className="ek-dialog__description" id={descriptionId}>
|
||||||
|
{props.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{props.children}
|
{props.children}
|
||||||
{props.actions === undefined ? undefined : (
|
{props.actions === undefined ? undefined : (
|
||||||
<div className="ek-dialog__actions">{props.actions}</div>
|
<div className="ek-dialog__actions">{props.actions}</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,8 +131,9 @@ describe("Ekklesia elements", () => {
|
|||||||
// dialog must not escape its isolated root.
|
// dialog must not escape its isolated root.
|
||||||
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
|
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
|
||||||
|
|
||||||
|
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
|
||||||
act(() => {
|
act(() => {
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||||
});
|
});
|
||||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
|
||||||
for (const [file, source] of sources) {
|
for (const [file, source] of sources) {
|
||||||
// Comment lines are dropped so a doc comment may still *describe* the loader's own
|
// Comment lines are dropped so a doc comment may still *describe* the loader's own
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user