@cplieger/ui 0.0.0 → 1.0.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +145 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/css/ui-primitives.css +344 -0
  5. package/jsr.json +36 -4
  6. package/package.json +86 -4
  7. package/src/actions/api.ts +268 -0
  8. package/src/actions/async-feedback.ts +217 -0
  9. package/src/actions/cleanup.ts +66 -0
  10. package/src/actions/debounce.ts +150 -0
  11. package/src/actions/define-helpers.ts +87 -0
  12. package/src/actions/define.ts +851 -0
  13. package/src/actions/error.ts +161 -0
  14. package/src/actions/index.ts +60 -0
  15. package/src/actions/loading.ts +131 -0
  16. package/src/actions/notifier.ts +61 -0
  17. package/src/actions/poll-until.ts +133 -0
  18. package/src/actions/poll.ts +156 -0
  19. package/src/actions/registry.ts +259 -0
  20. package/src/actions/retry.ts +79 -0
  21. package/src/actions/testing.ts +29 -0
  22. package/src/actions/transport.ts +104 -0
  23. package/src/actions/types.ts +249 -0
  24. package/src/dom/bind-list.ts +103 -0
  25. package/src/dom/el.ts +67 -0
  26. package/src/dom/index.ts +9 -0
  27. package/src/dom/reconcile-tree.ts +268 -0
  28. package/src/dom/reconcile.ts +76 -0
  29. package/src/fetch/index.ts +18 -0
  30. package/src/fetch/instance.ts +37 -0
  31. package/src/fetch/request.ts +437 -0
  32. package/src/fetch/timeout.ts +67 -0
  33. package/src/fetch/types.ts +206 -0
  34. package/src/fetch/verbs.ts +103 -0
  35. package/src/index.ts +4 -8
  36. package/src/primitives/announce.ts +134 -0
  37. package/src/primitives/ask.ts +406 -0
  38. package/src/primitives/dialog-engine.ts +500 -0
  39. package/src/primitives/dialog.ts +263 -0
  40. package/src/primitives/disclosure.ts +287 -0
  41. package/src/primitives/focus-trap.ts +157 -0
  42. package/src/primitives/fx.ts +204 -0
  43. package/src/primitives/live-regions.ts +83 -0
  44. package/src/primitives/modal-host.ts +214 -0
  45. package/src/primitives/modal.ts +247 -0
  46. package/src/primitives/owner.ts +322 -0
  47. package/src/primitives/popover.ts +500 -0
  48. package/src/primitives/popup-core.ts +603 -0
  49. package/src/primitives/popup.ts +23 -0
  50. package/src/primitives/roving-focus.ts +198 -0
  51. package/src/primitives/scroll-lock.ts +119 -0
  52. package/src/primitives/skeleton.ts +142 -0
  53. package/src/primitives/theme.ts +202 -0
  54. package/src/primitives/toast/engine.ts +327 -0
  55. package/src/primitives/toast/index.ts +139 -0
  56. package/src/primitives/toast/view.ts +244 -0
  57. package/src/primitives/tooltip.ts +377 -0
  58. package/src/primitives/transition.ts +119 -0
  59. package/src/primitives/view-transition.ts +57 -0
  60. package/src/signal/bus.ts +126 -0
  61. package/src/signal/collection.ts +182 -0
  62. package/src/signal/index.ts +34 -0
  63. package/src/signal/resource.ts +102 -0
  64. package/src/signal/signal-map.ts +46 -0
  65. package/src/signal/signal.ts +1002 -0
  66. package/src/signal/store.ts +84 -0
@@ -0,0 +1,327 @@
1
+ // engine.ts — Pure timer/queue state machine for toasts. No DOM: it drives an
2
+ // injected `ToastView` port, so it is fully testable headless. Owns the queue,
3
+ // the visible set, promotion, per-toast timers, and pause/resume math.
4
+
5
+ /** Severity of a toast. It selects the skin modifier class, the politeness of
6
+ * the screen-reader announcement, and the default duration — `"error"` is
7
+ * sticky, the other two auto-dismiss. */
8
+ export type ToastLevel = "info" | "success" | "error";
9
+
10
+ /** A retry affordance rendered inside the toast: a button the user can press to
11
+ * re-attempt whatever failed. */
12
+ export interface ToastRetry {
13
+ /** Button text. Default `"Retry"`. */
14
+ readonly label?: string;
15
+ /** Runs on press, after the toast has been dismissed. The result is not
16
+ * awaited: a throw or a rejection is logged and swallowed, so reporting the
17
+ * retry's own outcome is the caller's job. */
18
+ readonly onClick: () => void | Promise<void>;
19
+ }
20
+
21
+ /** Per-toast overrides, all optional: the level defaults to `"info"` and the
22
+ * duration to that level's default, so `show(message)` alone is a complete
23
+ * call. */
24
+ export interface ToastOptions {
25
+ /** Severity. Default `"info"`. */
26
+ level?: ToastLevel;
27
+ /** Auto-dismiss after this many ms. `0` = sticky (manual dismiss only). */
28
+ duration?: number;
29
+ retry?: ToastRetry;
30
+ }
31
+
32
+ const DEFAULT_MAX_VISIBLE = 3;
33
+ const DEFAULT_MAX_QUEUE = 20;
34
+ const DEFAULT_DURATION_MS = 4000;
35
+ const ERROR_DURATION_MS = 0;
36
+
37
+ /** The per-level default duration: sticky (0) for errors, `fallback` otherwise. */
38
+ function defaultDurationFor(level: ToastLevel, fallback: number): number {
39
+ return level === "error" ? ERROR_DURATION_MS : fallback;
40
+ }
41
+
42
+ /** Immutable data the view needs to render one toast. */
43
+ export interface ToastRenderData {
44
+ readonly message: string;
45
+ readonly level: ToastLevel;
46
+ readonly duration: number;
47
+ readonly retry?: ToastRetry;
48
+ }
49
+
50
+ /** Per-toast callbacks the view wires to DOM events. */
51
+ export interface ToastCallbacks {
52
+ dismiss(): void;
53
+ pause(): void;
54
+ resume(): void;
55
+ }
56
+
57
+ /** DOM port. The engine holds handles of type `H` opaquely. */
58
+ export interface ToastView<H> {
59
+ mount(data: ToastRenderData, ctx: ToastCallbacks): H;
60
+ /** Animate out, then call `done()` once the node is gone. */
61
+ scheduleLeave(handle: H, done: () => void): void;
62
+ /** Remove immediately, no animation. */
63
+ remove(handle: H): void;
64
+ pauseProgress(handle: H): void;
65
+ resumeProgress(handle: H): void;
66
+ /** Tear down any shared container. */
67
+ dispose(): void;
68
+ }
69
+
70
+ /** Construction options for {@link ToastEngine}: the view port it drives, plus
71
+ * the capacity, duration and mode knobs a `Toaster` forwards from its own
72
+ * options. `view` is the only required field. */
73
+ export interface ToastEngineOptions<H> {
74
+ view: ToastView<H>;
75
+ maxVisible?: number;
76
+ maxQueue?: number;
77
+ defaultDuration?: number;
78
+ /** `"stack"` (default): up to `maxVisible` toasts show at once, the rest
79
+ * queue. `"replace"`: single-slot latest-wins — a new toast instantly
80
+ * replaces the visible one (no leave animation, no queue), the embeddable-
81
+ * widget semantics where a stale "Copied" queue would be wrong. */
82
+ mode?: "stack" | "replace";
83
+ }
84
+
85
+ interface ActiveToast<H> {
86
+ readonly id: number;
87
+ readonly duration: number;
88
+ remaining: number;
89
+ startedAt: number;
90
+ timer: ReturnType<typeof setTimeout> | null;
91
+ handle: H;
92
+ dismissed: boolean;
93
+ }
94
+
95
+ interface QueuedToast {
96
+ readonly id: number;
97
+ readonly message: string;
98
+ readonly level: ToastLevel;
99
+ readonly duration: number;
100
+ readonly retry?: ToastRetry;
101
+ bindDismiss(dismiss: () => void): void;
102
+ }
103
+
104
+ /** The toast state machine, holding the queue, the visible set, per-toast
105
+ * timers and the pause/resume math, and driving an injected {@link ToastView}.
106
+ * Touches no DOM itself: handles of type `H` come from the view and are held
107
+ * opaquely. */
108
+ export class ToastEngine<H> {
109
+ private readonly view: ToastView<H>;
110
+ private readonly maxVisible: number;
111
+ private readonly maxQueue: number;
112
+ private readonly defaultDuration: number;
113
+ private readonly replace: boolean;
114
+ private readonly visible: ActiveToast<H>[] = [];
115
+ private readonly queue: QueuedToast[] = [];
116
+ private idSeq = 0;
117
+
118
+ constructor(opts: ToastEngineOptions<H>) {
119
+ this.view = opts.view;
120
+ this.replace = opts.mode === "replace";
121
+ this.maxVisible = this.replace ? 1 : Math.max(1, opts.maxVisible ?? DEFAULT_MAX_VISIBLE);
122
+ this.maxQueue = Math.max(0, opts.maxQueue ?? DEFAULT_MAX_QUEUE);
123
+ this.defaultDuration = opts.defaultDuration ?? DEFAULT_DURATION_MS;
124
+ }
125
+
126
+ /** Show a toast (or queue it if the visible set is full). Returns a dismiss fn. */
127
+ show(message: string, opts?: ToastOptions): () => void {
128
+ const level = opts?.level ?? "info";
129
+ const duration = opts?.duration ?? defaultDurationFor(level, this.defaultDuration);
130
+ const retry = opts?.retry;
131
+ const id = ++this.idSeq;
132
+
133
+ // No leave animation — the new message must not coexist with or wait for the old one.
134
+ if (this.replace && this.visible.length > 0) {
135
+ for (const t of [...this.visible]) {
136
+ if (t.timer !== null) {
137
+ clearTimeout(t.timer);
138
+ t.timer = null;
139
+ }
140
+ t.dismissed = true;
141
+ this.view.remove(t.handle);
142
+ }
143
+ this.visible.length = 0;
144
+ }
145
+
146
+ if (this.visible.length < this.maxVisible) {
147
+ this.mountToast(id, message, level, duration, retry);
148
+ return () => {
149
+ this.dismiss(id);
150
+ };
151
+ }
152
+
153
+ let mountedDismiss: (() => void) | null = null;
154
+ let cancelled = false;
155
+ const queued: QueuedToast = {
156
+ id,
157
+ message,
158
+ level,
159
+ duration,
160
+ ...(retry !== undefined ? { retry } : {}),
161
+ bindDismiss: (dismiss) => {
162
+ if (cancelled) {
163
+ dismiss();
164
+ } else {
165
+ mountedDismiss = dismiss;
166
+ }
167
+ },
168
+ };
169
+ this.enqueue(queued);
170
+ return () => {
171
+ if (mountedDismiss !== null) {
172
+ mountedDismiss();
173
+ return;
174
+ }
175
+ cancelled = true;
176
+ const i = this.queue.indexOf(queued);
177
+ if (i !== -1) {
178
+ this.queue.splice(i, 1);
179
+ }
180
+ };
181
+ }
182
+
183
+ /** Dismiss the newest visible toast (Escape handler target). */
184
+ dismissNewest(): void {
185
+ const newest = this.visible[this.visible.length - 1];
186
+ if (newest !== undefined) {
187
+ this.dismiss(newest.id);
188
+ }
189
+ }
190
+
191
+ /** Immediately remove all visible toasts and clear the queue (test-friendly). */
192
+ clear(): void {
193
+ for (const t of [...this.visible]) {
194
+ if (t.timer !== null) {
195
+ clearTimeout(t.timer);
196
+ t.timer = null;
197
+ }
198
+ t.dismissed = true;
199
+ this.view.remove(t.handle);
200
+ }
201
+ this.visible.length = 0;
202
+ this.queue.length = 0;
203
+ }
204
+
205
+ private mountToast(
206
+ id: number,
207
+ message: string,
208
+ level: ToastLevel,
209
+ duration: number,
210
+ retry: ToastRetry | undefined,
211
+ ): void {
212
+ const data: ToastRenderData = {
213
+ message,
214
+ level,
215
+ duration,
216
+ ...(retry !== undefined ? { retry } : {}),
217
+ };
218
+ const ctx: ToastCallbacks = {
219
+ dismiss: () => {
220
+ this.dismiss(id);
221
+ },
222
+ pause: () => {
223
+ this.pause(id);
224
+ },
225
+ resume: () => {
226
+ this.resume(id);
227
+ },
228
+ };
229
+ const handle = this.view.mount(data, ctx);
230
+ const record: ActiveToast<H> = {
231
+ id,
232
+ duration,
233
+ remaining: duration,
234
+ startedAt: Date.now(),
235
+ timer: null,
236
+ handle,
237
+ dismissed: false,
238
+ };
239
+ this.visible.push(record);
240
+ this.startTimer(record);
241
+ }
242
+
243
+ private enqueue(entry: QueuedToast): void {
244
+ if (this.maxQueue <= 0) {
245
+ return;
246
+ }
247
+ while (this.queue.length >= this.maxQueue) {
248
+ this.queue.shift();
249
+ }
250
+ this.queue.push(entry);
251
+ }
252
+
253
+ private promote(): void {
254
+ while (this.visible.length < this.maxVisible) {
255
+ const next = this.queue.shift();
256
+ if (next === undefined) {
257
+ return;
258
+ }
259
+ this.mountToast(next.id, next.message, next.level, next.duration, next.retry);
260
+ next.bindDismiss(() => {
261
+ this.dismiss(next.id);
262
+ });
263
+ }
264
+ }
265
+
266
+ private startTimer(t: ActiveToast<H>): void {
267
+ if (t.duration <= 0 || t.remaining <= 0) {
268
+ return;
269
+ }
270
+ t.startedAt = Date.now();
271
+ t.timer = setTimeout(() => {
272
+ this.dismiss(t.id);
273
+ }, t.remaining);
274
+ }
275
+
276
+ private pause(id: number): void {
277
+ const t = this.find(id);
278
+ if (t === undefined || t.duration <= 0 || t.timer === null) {
279
+ return;
280
+ }
281
+ clearTimeout(t.timer);
282
+ t.timer = null;
283
+ const elapsed = Date.now() - t.startedAt;
284
+ t.remaining = Math.max(0, t.remaining - elapsed);
285
+ this.view.pauseProgress(t.handle);
286
+ }
287
+
288
+ private resume(id: number): void {
289
+ const t = this.find(id);
290
+ if (t === undefined || t.duration <= 0 || t.timer !== null) {
291
+ return;
292
+ }
293
+ if (t.remaining <= 0) {
294
+ // A backgrounded tab can let the wall clock pass the deadline before the
295
+ // pending timeout runs, draining `remaining` to 0 with no timer left to
296
+ // fire. Releasing the hold is the last chance to run the promised
297
+ // auto-dismiss, or it stays on screen until clicked/Escaped/cleared.
298
+ this.dismiss(id);
299
+ return;
300
+ }
301
+ this.startTimer(t);
302
+ this.view.resumeProgress(t.handle);
303
+ }
304
+
305
+ private dismiss(id: number): void {
306
+ const record = this.visible.find((t) => t.id === id);
307
+ if (record === undefined || record.dismissed) {
308
+ return;
309
+ }
310
+ record.dismissed = true;
311
+ if (record.timer !== null) {
312
+ clearTimeout(record.timer);
313
+ record.timer = null;
314
+ }
315
+ this.view.scheduleLeave(record.handle, () => {
316
+ const i = this.visible.indexOf(record);
317
+ if (i !== -1) {
318
+ this.visible.splice(i, 1);
319
+ }
320
+ this.promote();
321
+ });
322
+ }
323
+
324
+ private find(id: number): ActiveToast<H> | undefined {
325
+ return this.visible.find((t) => t.id === id);
326
+ }
327
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * toast/index.ts — Public toast surface: the `Toaster` factory + a default
3
+ * module singleton for convenience, with `info` / `success` / `error` free
4
+ * functions bound to it.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ import { ToastEngine } from "./engine.js";
10
+ import type { ToastLevel, ToastOptions, ToastRetry } from "./engine.js";
11
+ import { createToastView } from "./view.js";
12
+ import type { ToastHandle } from "./view.js";
13
+
14
+ export type { ToastLevel, ToastOptions, ToastRetry };
15
+
16
+ /** One toast stack: its own container, queue and Escape listener. Every show
17
+ * variant returns a function that dismisses the toast it created, so a caller
18
+ * can retract a message whose cause has gone away. */
19
+ export interface Toaster {
20
+ /** Show a toast; returns a function that dismisses it. */
21
+ show(message: string, opts?: ToastOptions): () => void;
22
+ info(message: string): () => void;
23
+ success(message: string): () => void;
24
+ error(message: string, retry?: ToastRetry): () => void;
25
+ /** Dismiss all visible toasts and clear the queue. */
26
+ clear(): void;
27
+ /** Tear down this toaster: remove its document keydown (Escape) listener,
28
+ * clear all toasts, and remove its stack container. Call this for any
29
+ * per-component `createToaster()` so its listener doesn't outlive the
30
+ * component. (The module `toast` singleton lives for the app's lifetime, so
31
+ * it is never disposed.) */
32
+ dispose(): void;
33
+ }
34
+
35
+ /** How one stack behaves: how many toasts show and queue, how long a
36
+ * non-error toast lives, where the stack mounts, and whether an arriving
37
+ * toast queues behind the visible ones or replaces them. All optional. */
38
+ export interface ToasterOptions {
39
+ /** Maximum simultaneously-visible toasts (`"stack"` mode). Default `3`. */
40
+ maxVisible?: number;
41
+ /** Overflow queue capacity (`"stack"` mode; oldest dropped). Default `20`. */
42
+ maxQueue?: number;
43
+ /** Auto-dismiss window (ms) for non-error toasts. Default `4000`. */
44
+ defaultDuration?: number;
45
+ /** Mount the toast stack inside this element instead of `document.body` —
46
+ * for an embeddable widget that must confine its chrome to its own root. */
47
+ container?: HTMLElement;
48
+ /** `"stack"` (default): up to `maxVisible` show, the rest queue.
49
+ * `"replace"`: single-slot latest-wins — a new toast instantly replaces the
50
+ * visible one; nothing queues. */
51
+ mode?: "stack" | "replace";
52
+ }
53
+
54
+ function build(opts?: ToasterOptions): Toaster {
55
+ const view = createToastView(opts?.container);
56
+ const engine = new ToastEngine<ToastHandle>({
57
+ view,
58
+ ...(opts?.maxVisible !== undefined ? { maxVisible: opts.maxVisible } : {}),
59
+ ...(opts?.maxQueue !== undefined ? { maxQueue: opts.maxQueue } : {}),
60
+ ...(opts?.defaultDuration !== undefined ? { defaultDuration: opts.defaultDuration } : {}),
61
+ ...(opts?.mode !== undefined ? { mode: opts.mode } : {}),
62
+ });
63
+
64
+ const onKeyDown = (e: KeyboardEvent): void => {
65
+ if (e.key === "Escape") {
66
+ engine.dismissNewest();
67
+ }
68
+ };
69
+ document.addEventListener("keydown", onKeyDown);
70
+
71
+ return {
72
+ show: (message, showOpts) => engine.show(message, showOpts),
73
+ info: (message) => engine.show(message, { level: "info" }),
74
+ success: (message) => engine.show(message, { level: "success" }),
75
+ error: (message, retry) =>
76
+ engine.show(message, retry !== undefined ? { level: "error", retry } : { level: "error" }),
77
+ clear: () => {
78
+ engine.clear();
79
+ },
80
+ dispose: () => {
81
+ document.removeEventListener("keydown", onKeyDown);
82
+ engine.clear();
83
+ view.dispose();
84
+ },
85
+ };
86
+ }
87
+
88
+ /** Create an isolated toaster with its own stack container and queue. */
89
+ export function createToaster(opts?: ToasterOptions): Toaster {
90
+ return build(opts);
91
+ }
92
+
93
+ // Built lazily, so a page that only calls createToaster never builds the
94
+ // default stack or its document listener.
95
+ let singleton: Toaster | null = null;
96
+
97
+ function getSingleton(): Toaster {
98
+ singleton ??= build();
99
+ return singleton;
100
+ }
101
+
102
+ /** Default shared toaster (built lazily on first use; lives for the app's
103
+ * lifetime — never dispose it). */
104
+ export const toast: Toaster = {
105
+ show: (message, opts) => getSingleton().show(message, opts),
106
+ info: (message) => getSingleton().info(message),
107
+ success: (message) => getSingleton().success(message),
108
+ error: (message, retry) => getSingleton().error(message, retry),
109
+ clear: (): void => {
110
+ getSingleton().clear();
111
+ },
112
+ dispose: (): void => {
113
+ getSingleton().dispose();
114
+ },
115
+ };
116
+
117
+ /** Show an info toast on the default toaster. */
118
+ export function info(message: string): () => void {
119
+ return getSingleton().info(message);
120
+ }
121
+
122
+ /** Show a success toast on the default toaster. */
123
+ export function success(message: string): () => void {
124
+ return getSingleton().success(message);
125
+ }
126
+
127
+ /** Show an error toast on the default toaster. */
128
+ export function error(message: string, retry?: ToastRetry): () => void {
129
+ return getSingleton().error(message, retry);
130
+ }
131
+
132
+ /** Test-only: tear down the default toaster entirely (listener included); the
133
+ * next use lazily rebuilds it fresh. */
134
+ export function _resetForTest(): void {
135
+ if (singleton !== null) {
136
+ singleton.dispose();
137
+ singleton = null;
138
+ }
139
+ }
@@ -0,0 +1,244 @@
1
+ // view.ts — DOM implementation of the toast `ToastView` port. Enter/leave
2
+ // lifecycle via `is-entering` → `is-shown` → `is-leaving` state classes.
3
+ // Screen-reader announcement is delegated to `announce()`, so neither the
4
+ // stack nor a toast node is a live region and no live region nests inside
5
+ // another.
6
+
7
+ import { el } from "../../dom/el.js";
8
+
9
+ import { announce } from "../announce.js";
10
+ import { topmostOpenDialog } from "../modal-host.js";
11
+ import { cancelTransition, runTransition } from "../transition.js";
12
+ import type { ToastCallbacks, ToastRenderData, ToastView } from "./engine.js";
13
+
14
+ /** What this view hands the engine for one mounted toast. The engine stores it
15
+ * and passes it back; only this module reads the fields. */
16
+ export interface ToastHandle {
17
+ /** The node `mount` built and appended to the stack. Detaching it is this
18
+ * module's alone — from `scheduleLeave`'s settle, from `remove`, or from
19
+ * `dispose`, which removes the whole stack container and takes any node
20
+ * still mounted inside it — so the engine must not remove it itself, and a
21
+ * handle is spent once any of the three has run: the reference still reads,
22
+ * but the node is out of the document. */
23
+ readonly el: HTMLElement;
24
+ /** The progress bar, or `null` for a sticky toast (no duration to show). */
25
+ readonly progressEl: HTMLElement | null;
26
+ /** Handle of the pending enter `requestAnimationFrame`, or `null` once it has
27
+ * run (or been cancelled). Cancelled on leave/remove so a late enter frame
28
+ * can't re-apply `is-shown` mid-leave. */
29
+ enterRaf: number | null;
30
+ }
31
+
32
+ /** Create a DOM-backed toast view whose stack is created on first `mount`.
33
+ * It mounts on `host` when given, else on `document.body`, moving into the
34
+ * topmost open modal `<dialog>` while one is open (`showModal()` inerts
35
+ * everything outside it) and back to the body after. */
36
+ export function createToastView(host?: HTMLElement): ToastView<ToastHandle> {
37
+ let container: HTMLElement | null = null;
38
+ // The modal <dialog> currently auto-hosting the stack, if any. Its `close`
39
+ // event re-runs syncHost so a sticky toast is evacuated before it hides.
40
+ let adoptedDialog: HTMLDialogElement | null = null;
41
+
42
+ const onAdoptedClose = (): void => {
43
+ syncHost();
44
+ };
45
+
46
+ const releaseAdopted = (): void => {
47
+ if (adoptedDialog !== null) {
48
+ adoptedDialog.removeEventListener("close", onAdoptedClose);
49
+ adoptedDialog = null;
50
+ }
51
+ };
52
+
53
+ /** Move the stack to `host`, else the topmost open modal dialog, else
54
+ * `document.body`. `appendChild` MOVES the stack, so live toasts (timers,
55
+ * listeners, progress state) ride along untouched. */
56
+ const syncHost = (): void => {
57
+ if (container === null) {
58
+ return;
59
+ }
60
+ const desired: HTMLElement = host ?? topmostOpenDialog() ?? document.body;
61
+ if (container.parentElement !== desired) {
62
+ desired.appendChild(container);
63
+ }
64
+ const dialog = host === undefined && desired instanceof HTMLDialogElement ? desired : null;
65
+ if (dialog !== adoptedDialog) {
66
+ releaseAdopted();
67
+ if (dialog !== null) {
68
+ adoptedDialog = dialog;
69
+ dialog.addEventListener("close", onAdoptedClose);
70
+ }
71
+ }
72
+ };
73
+
74
+ const ensureContainer = (): HTMLElement => {
75
+ container ??= el("div", { className: "uip-toast-stack" });
76
+ // Re-resolve on every mount: a modal may have opened/closed, or its host
77
+ // dialog may have been removed, since the last toast.
78
+ syncHost();
79
+ return container;
80
+ };
81
+
82
+ return {
83
+ mount(data: ToastRenderData, ctx: ToastCallbacks): ToastHandle {
84
+ const stack = ensureContainer();
85
+
86
+ // The node itself stays non-live, so the message is announced once.
87
+ announce(data.message, data.level === "error" ? "assertive" : "polite");
88
+
89
+ const node = el("div", {
90
+ className: `uip-toast uip-toast--${data.level} is-entering`,
91
+ tabindex: "0",
92
+ });
93
+
94
+ // Visually-hidden (not aria-hidden) dismiss hint: the node is
95
+ // tabindex=0 but not a live region, so a tabbed-to toast needs its own
96
+ // description of how to dismiss it.
97
+ node.appendChild(el("span", { className: "uip-toast-msg" }, data.message));
98
+ node.appendChild(el("span", { className: "uip-visually-hidden" }, "Click to dismiss."));
99
+
100
+ const retry = data.retry;
101
+ if (retry !== undefined) {
102
+ const btn = el(
103
+ "button",
104
+ { type: "button", className: "uip-toast-retry" },
105
+ retry.label ?? "Retry",
106
+ );
107
+ btn.addEventListener("click", (e) => {
108
+ e.stopPropagation();
109
+ ctx.dismiss();
110
+ runRetry(retry.onClick);
111
+ });
112
+ node.appendChild(btn);
113
+ }
114
+
115
+ let progressEl: HTMLElement | null = null;
116
+ if (data.duration > 0) {
117
+ // The CSS animates the progress bar off this property (no inline transition-duration).
118
+ node.style.setProperty("--uip-toast-duration", `${data.duration}ms`);
119
+ progressEl = el("span", { className: "uip-toast-progress", "aria-hidden": "true" });
120
+ node.appendChild(progressEl);
121
+ }
122
+
123
+ node.addEventListener("click", () => {
124
+ ctx.dismiss();
125
+ });
126
+ // Escape only targets the newest toast, so make the focused one
127
+ // dismissable by keyboard too; guard to the node so it doesn't swallow
128
+ // the retry button's Enter.
129
+ node.addEventListener("keydown", (e) => {
130
+ if (e.target !== node) {
131
+ return;
132
+ }
133
+ if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
134
+ e.preventDefault();
135
+ ctx.dismiss();
136
+ }
137
+ });
138
+
139
+ // Ref-count hover + focus (two pause sources, one engine timer): resume
140
+ // only when both release, or un-hovering a still-focused toast would
141
+ // resume the countdown while focused.
142
+ let pauseCount = 0;
143
+ const addPause = (): void => {
144
+ pauseCount++;
145
+ if (pauseCount === 1) {
146
+ ctx.pause();
147
+ }
148
+ };
149
+ const removePause = (): void => {
150
+ if (pauseCount === 0) {
151
+ return;
152
+ }
153
+ pauseCount--;
154
+ if (pauseCount === 0) {
155
+ ctx.resume();
156
+ }
157
+ };
158
+ node.addEventListener("mouseenter", addPause);
159
+ node.addEventListener("mouseleave", removePause);
160
+ node.addEventListener("focusin", addPause);
161
+ node.addEventListener("focusout", removePause);
162
+
163
+ const handle: ToastHandle = { el: node, progressEl, enterRaf: null };
164
+ stack.appendChild(node);
165
+ // Deferred a frame so the resting is-entering state resolves before
166
+ // is-shown is applied — the node's first style resolution has nothing
167
+ // to transition from otherwise.
168
+ handle.enterRaf = requestAnimationFrame(() => {
169
+ handle.enterRaf = null;
170
+ node.classList.remove("is-entering");
171
+ node.classList.add("is-shown");
172
+ });
173
+
174
+ return handle;
175
+ },
176
+
177
+ scheduleLeave(handle: ToastHandle, done: () => void): void {
178
+ const node = handle.el;
179
+ // A dismiss can land before the enter frame runs. Force is-shown now so
180
+ // the leave has a defined start state — is-entering has no stylesheet
181
+ // rule (opacity 0, same as is-leaving), so that pairing is no change and
182
+ // starts no transition.
183
+ if (handle.enterRaf !== null) {
184
+ cancelAnimationFrame(handle.enterRaf);
185
+ handle.enterRaf = null;
186
+ node.classList.remove("is-entering");
187
+ node.classList.add("is-shown");
188
+ }
189
+ runTransition(node, {
190
+ change: () => {
191
+ node.classList.remove("is-shown");
192
+ node.classList.add("is-leaving");
193
+ },
194
+ settled: () => {
195
+ node.remove();
196
+ done();
197
+ },
198
+ });
199
+ },
200
+
201
+ remove(handle: ToastHandle): void {
202
+ if (handle.enterRaf !== null) {
203
+ cancelAnimationFrame(handle.enterRaf);
204
+ handle.enterRaf = null;
205
+ }
206
+ cancelTransition(handle.el);
207
+ handle.el.remove();
208
+ },
209
+
210
+ pauseProgress(handle: ToastHandle): void {
211
+ if (handle.progressEl !== null) {
212
+ handle.progressEl.style.animationPlayState = "paused";
213
+ }
214
+ },
215
+
216
+ resumeProgress(handle: ToastHandle): void {
217
+ if (handle.progressEl !== null) {
218
+ handle.progressEl.style.animationPlayState = "running";
219
+ }
220
+ },
221
+
222
+ dispose(): void {
223
+ releaseAdopted();
224
+ if (container !== null) {
225
+ container.remove();
226
+ container = null;
227
+ }
228
+ },
229
+ };
230
+ }
231
+
232
+ /** Invoke a retry handler, guarding both sync throws and async rejections. */
233
+ function runRetry(onClick: () => void | Promise<void>): void {
234
+ try {
235
+ const result = onClick();
236
+ if (result instanceof Promise) {
237
+ result.catch((err: unknown) => {
238
+ console.error("[uip-toast] retry handler rejected", err);
239
+ });
240
+ }
241
+ } catch (err) {
242
+ console.error("[uip-toast] retry handler threw", err);
243
+ }
244
+ }