@cplieger/ui 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/NOTICE +3 -0
- package/README.md +157 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +44 -0
- package/package.json +105 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -0
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- 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
|
+
}
|