@rozie-ui/toast-solid 0.1.0

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.
@@ -0,0 +1,683 @@
1
+ import type { JSX } from 'solid-js';
2
+ import { Show, createSignal, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
3
+ import { Key } from '@solid-primitives/keyed';
4
+ import { __rozieInjectStyle, mergeListeners, parseInlineStyle, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
5
+
6
+ __rozieInjectStyle('Toaster-12d4265c', `@media (prefers-reduced-motion: reduce) {
7
+ .rozie-toast[data-rozie-s-12d4265c] {
8
+ animation-name: rozie-toast-fade-in;
9
+ animation-duration: 1ms;
10
+ }
11
+ .rozie-toast--exiting[data-rozie-s-12d4265c] {
12
+ animation-name: rozie-toast-fade-out;
13
+ animation-duration: 1ms;
14
+ }
15
+ }
16
+ .rozie-toaster[data-rozie-s-12d4265c] {
17
+ position: fixed;
18
+ z-index: var(--rozie-toast-z, 9999);
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--rozie-toast-gap, 0.5rem);
22
+ padding: var(--rozie-toast-region-padding, 1rem);
23
+ max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
24
+ pointer-events: none;
25
+ font: var(--rozie-toast-font, inherit);
26
+ }
27
+ .rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
28
+ pointer-events: auto;
29
+ }
30
+ .rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
31
+ .rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
32
+ .rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
33
+ .rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
34
+ .rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
35
+ .rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
36
+ .rozie-toaster--stacked[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
37
+ grid-area: 1 / 1;
38
+ z-index: calc(100 - var(--rozie-toast-depth, 0));
39
+ }
40
+ .rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) {
41
+ display: grid;
42
+ }
43
+ .rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
44
+ transform:
45
+ translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
46
+ scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
47
+ opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
48
+ }
49
+ .rozie-toaster--stacked.rozie-toaster--bottom-left[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
50
+ .rozie-toaster--stacked.rozie-toaster--bottom-right[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
51
+ .rozie-toaster--stacked.rozie-toaster--bottom-center[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
52
+ transform:
53
+ translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
54
+ scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
55
+ }
56
+ .rozie-toast[data-rozie-s-12d4265c] {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: var(--rozie-toast-content-gap, 0.75rem);
60
+ min-width: var(--rozie-toast-min-width, 16rem);
61
+ max-width: var(--rozie-toast-toast-max-width, 24rem);
62
+ padding: var(--rozie-toast-padding, 0.75rem 1rem);
63
+ color: var(--rozie-toast-color, #fff);
64
+ background: var(--rozie-toast-bg, #333);
65
+ border-radius: var(--rozie-toast-radius, 0.5rem);
66
+ box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
67
+ /* Swipe: page scroll stays alive on touch along the axis the toast does
68
+ NOT move on. The transition here drives the spring-back (the active-drag
69
+ :style sets an inline \`transition: none\` to track the finger 1:1;
70
+ releasing it without a further gesture falls back to this transition). */
71
+ touch-action: pan-y;
72
+ transition: transform 200ms ease, opacity 200ms ease;
73
+ }
74
+ .rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
75
+ .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
76
+ touch-action: pan-x;
77
+ }
78
+ .rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
79
+ .rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
80
+ .rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
81
+ .rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
82
+ from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
83
+ to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
84
+ from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
85
+ to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
86
+ from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
87
+ to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
88
+ from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
89
+ to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
90
+ .rozie-toast[data-rozie-s-12d4265c] {
91
+ animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
92
+ }
93
+ .rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
94
+ .rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
95
+ .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
96
+ animation-name: rozie-toast-enter-from-bottom;
97
+ }
98
+ .rozie-toast--exiting[data-rozie-s-12d4265c] {
99
+ animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
100
+ }
101
+ .rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
102
+ .rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
103
+ .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c] {
104
+ animation-name: rozie-toast-exit-to-bottom;
105
+ }
106
+ from[data-rozie-s-12d4265c] { opacity: 0; }
107
+ to[data-rozie-s-12d4265c] { opacity: 1; }
108
+ from[data-rozie-s-12d4265c] { opacity: 1; }
109
+ to[data-rozie-s-12d4265c] { opacity: 0; }
110
+ from[data-rozie-s-12d4265c] { opacity: 1; transform: translateX(0); }
111
+ to[data-rozie-s-12d4265c] { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
112
+ from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
113
+ to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
114
+ .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
115
+ animation-name: rozie-toast-swipe-exit-x;
116
+ }
117
+ .rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c],
118
+ .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
119
+ animation-name: rozie-toast-swipe-exit-y;
120
+ }
121
+ .rozie-toast-spinner[data-rozie-s-12d4265c] {
122
+ flex: 0 0 auto;
123
+ width: var(--rozie-toast-spinner-size, 1em);
124
+ height: var(--rozie-toast-spinner-size, 1em);
125
+ border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
126
+ border-top-color: var(--rozie-toast-spinner-color, currentColor);
127
+ border-radius: 50%;
128
+ animation: rozie-toast-spin 0.75s linear infinite;
129
+ }
130
+ to[data-rozie-s-12d4265c] { transform: rotate(360deg); }
131
+ .rozie-toast-message[data-rozie-s-12d4265c] {
132
+ flex: 1 1 auto;
133
+ font-size: var(--rozie-toast-font-size, 0.9rem);
134
+ }
135
+ .rozie-toast-close[data-rozie-s-12d4265c] {
136
+ flex: 0 0 auto;
137
+ display: inline-flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ width: var(--rozie-toast-close-size, 1.25rem);
141
+ height: var(--rozie-toast-close-size, 1.25rem);
142
+ padding: 0;
143
+ font-size: 1.1rem;
144
+ line-height: 1;
145
+ color: inherit;
146
+ background: transparent;
147
+ border: none;
148
+ border-radius: 0.25rem;
149
+ opacity: var(--rozie-toast-close-opacity, 0.75);
150
+ cursor: pointer;
151
+ }
152
+ .rozie-toast-close[data-rozie-s-12d4265c]:hover {
153
+ opacity: 1;
154
+ }`);
155
+
156
+ interface ToastSlotCtx { toast: any; dismiss: any; }
157
+
158
+ interface ToasterProps {
159
+ /**
160
+ * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
161
+ */
162
+ position?: string;
163
+ /**
164
+ * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
165
+ */
166
+ duration?: number;
167
+ /**
168
+ * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
169
+ */
170
+ max?: number;
171
+ /**
172
+ * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
173
+ */
174
+ disablePauseOnHover?: boolean;
175
+ /**
176
+ * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
177
+ */
178
+ ariaLabel?: (string) | null;
179
+ /**
180
+ * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
181
+ */
182
+ disableSwipe?: boolean;
183
+ /**
184
+ * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
185
+ */
186
+ stacked?: boolean;
187
+ onDismissed?: (...args: unknown[]) => void;
188
+ toastSlot?: (ctx: ToastSlotCtx) => JSX.Element;
189
+ slots?: Record<string, (ctx: any) => JSX.Element>;
190
+ ref?: (h: ToasterHandle) => void;
191
+ }
192
+
193
+ export interface ToasterHandle {
194
+ show: (...args: any[]) => any;
195
+ dismiss: (...args: any[]) => any;
196
+ clear: (...args: any[]) => any;
197
+ patch: (...args: any[]) => any;
198
+ promise: (...args: any[]) => any;
199
+ }
200
+
201
+ export default function Toaster(_props: ToasterProps): JSX.Element {
202
+ const _merged = mergeProps({ position: 'bottom-right', duration: 4000, max: 0, disablePauseOnHover: false, ariaLabel: null, disableSwipe: false, stacked: false }, _props);
203
+ const [local, attrs] = splitProps(_merged, ['position', 'duration', 'max', 'disablePauseOnHover', 'ariaLabel', 'disableSwipe', 'stacked', 'ref']);
204
+ onMount(() => { local.ref?.({ show, dismiss, clear, patch, promise }); });
205
+
206
+ const [toasts, setToasts] = createSignal<any[]>([]);
207
+ const [seq, setSeq] = createSignal(0);
208
+ const [swipe, setSwipe] = createSignal<any>(null);
209
+ const [swipeGesture, setSwipeGesture] = createSignal<any>(null);
210
+ onCleanup(() => {
211
+ unmounted = true;
212
+ teardownTimers();
213
+ });
214
+
215
+ // Mutable cross-render scratch (NOT reactive): per-id timer bookkeeping. A
216
+ // top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
217
+ // emitter hoists it). The id counter lives in $data.seq instead (see <data>).
218
+ //
219
+ // Shape: { [id]: { handle, startedAt, remaining } }. `pauseTimers` clears the
220
+ // live setTimeout handle but KEEPS the entry with a decremented `remaining` —
221
+ // the remainder IS the state (this is what makes the hover pause PRECISE
222
+ // instead of a full restart). `resumeTimers` re-arms with exactly that
223
+ // remainder. `clearTimer`/the full-teardown helper below are the only ways an
224
+ // entry is actually removed from the map.
225
+ let timers = {};
226
+
227
+ // Per-id handles for the ~350ms exit-removal failsafe (the fallback that
228
+ // removes a toast if its @animationend never fires). Tracked in a module map
229
+ // — NOT an anonymous window.setTimeout — so teardownTimers ($onUnmount /
230
+ // clear()) can cancel a pending failsafe (else it fires post-unmount and
231
+ // writes $data on a torn-down instance) and removeToast can cancel it
232
+ // first-wins when @animationend beats it. Escapes into $onUnmount's effect →
233
+ // React hoists it to useRef alongside `timers`.
234
+ let exitFailsafes = {};
235
+
236
+ // Set true in $onUnmount; read by promise()'s settle guard (never-resurrect
237
+ // a toast after the host itself is gone). A top-level `let` → React useRef
238
+ // (it escapes into $onUnmount's effect).
239
+ let unmounted = false;
240
+
241
+ // Same-tick id-uniqueness guard for React. The id counter lives in reactive
242
+ // $data.seq (persists across renders), but React batches setState so within a
243
+ // SINGLE synchronous tick two show() calls read the SAME stale $data.seq →
244
+ // duplicate ids. `seqLocal` is a plain counter incremented SYNCHRONOUSLY in
245
+ // show(); it survives the same tick (and, because show() is an $expose verb,
246
+ // the emitter hoists it to a persistent useRef on React too — but the design
247
+ // does NOT depend on that: `Math.max($data.seq, seqLocal)` is correct whether
248
+ // seqLocal persists OR resets per render, since the monotonic $data.seq
249
+ // carries the high-water mark across any reset). On the other five targets
250
+ // $data.seq is synchronous, so the two simply stay in lockstep. Result:
251
+ // strictly-increasing, collision-free ids on all six with NO randomness.
252
+ let seqLocal = 0;
253
+
254
+ // Hover-pause flag: true while the pointer is over the stack (set by
255
+ // pauseTimers, cleared by resumeTimers). Read by patch() so a duration change
256
+ // arriving mid-hover stores the new remainder WITHOUT arming a live timer
257
+ // (which would dismiss the toast while it is still hovered) — resume arms it
258
+ // on leave. A top-level `let` reachable from the $expose verbs (patch/show →
259
+ // startTimer) and the @mouseenter/@mouseleave handlers, so React hoists it to
260
+ // useRef (persistent) like `timers`.
261
+ let paused = false;
262
+
263
+ // ---- timers ------------------------------------------------------------
264
+ function startTimer(toast: any) {
265
+ if (!toast || !toast.duration || toast.duration <= 0) return;
266
+ if (typeof window === 'undefined') return;
267
+ // Belt-and-braces: clear any pre-existing live handle for this id before
268
+ // overwriting the entry, so a re-arm never orphans a running timeout.
269
+ const existing = timers[toast.id];
270
+ if (existing && existing.handle != null) window.clearTimeout(existing.handle);
271
+ const remaining = toast.duration;
272
+ const handle = window.setTimeout(() => dismissBegin(toast.id, 'timeout'), remaining);
273
+ timers[toast.id] = {
274
+ handle,
275
+ startedAt: Date.now(),
276
+ remaining
277
+ };
278
+ }
279
+ function clearTimer(id: any) {
280
+ const entry = timers[id];
281
+ if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
282
+ delete timers[id];
283
+ }
284
+
285
+ // Pauses every live timer WITHOUT losing the remainder: clears the handle,
286
+ // decrements `remaining` by the elapsed time, and KEEPS the entry (does NOT
287
+ // delete it — the old v1 shortcut deleted entries here, which is why leave
288
+ // had to do a full restart).
289
+ function pauseTimers() {
290
+ paused = true;
291
+ if (typeof window === 'undefined') return;
292
+ for (const id in timers) {
293
+ const entry = timers[id];
294
+ // Idempotent: an entry already paused (handle cleared) keeps its stored
295
+ // remainder. A second pause must NOT re-subtract elapsed against the
296
+ // original startedAt — that drove `remaining` negative and stranded the
297
+ // toast forever once resume saw the non-positive value.
298
+ if (entry.handle == null) continue;
299
+ window.clearTimeout(entry.handle);
300
+ const elapsed = Date.now() - entry.startedAt;
301
+ // Clamp so a late pause (e.g. a background-tab timer that overran) can
302
+ // never store a negative remainder.
303
+ const remaining = Math.max(0, entry.remaining - elapsed);
304
+ timers[id] = {
305
+ handle: null,
306
+ startedAt: entry.startedAt,
307
+ remaining
308
+ };
309
+ }
310
+ }
311
+
312
+ // Re-arms every paused timer with EXACTLY its stored remainder (called on
313
+ // mouse leave). An entry with a non-positive remainder is left un-armed
314
+ // (it will be cleaned up by the next dismiss/clear pass) rather than firing
315
+ // immediately from inside this loop.
316
+ function resumeTimers() {
317
+ paused = false;
318
+ if (typeof window === 'undefined') return;
319
+ for (const id in timers) {
320
+ const entry = timers[id];
321
+ // Only re-arm entries that are actually paused (handle cleared). A live
322
+ // handle is left alone — re-arming it would orphan the running timeout.
323
+ if (entry.handle != null) continue;
324
+ if (entry.remaining == null || entry.remaining <= 0) {
325
+ // Its deadline elapsed while paused (a background-tab overrun, or a
326
+ // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
327
+ // is up — rather than leaving it un-armed and stranded forever.
328
+ dismissBegin(id, 'timeout');
329
+ continue;
330
+ }
331
+ const remaining = entry.remaining;
332
+ const handle = window.setTimeout(() => dismissBegin(id, 'timeout'), remaining);
333
+ timers[id] = {
334
+ handle,
335
+ startedAt: Date.now(),
336
+ remaining
337
+ };
338
+ }
339
+ }
340
+
341
+ // FULL teardown: clears every live handle AND drops every entry (unlike
342
+ // pauseTimers, which deliberately keeps entries to hold their remainders).
343
+ // clear() and $onUnmount can no longer reuse pauseTimers for this reason.
344
+ function teardownTimers() {
345
+ if (typeof window !== 'undefined') {
346
+ for (const id in timers) {
347
+ const entry = timers[id];
348
+ if (entry.handle != null) window.clearTimeout(entry.handle);
349
+ }
350
+ // Also cancel every pending exit failsafe — otherwise a removal timeout
351
+ // scheduled just before unmount/clear() fires afterward and writes $data.
352
+ for (const id in exitFailsafes) {
353
+ if (exitFailsafes[id] != null) window.clearTimeout(exitFailsafes[id]);
354
+ }
355
+ }
356
+ timers = {};
357
+ exitFailsafes = {};
358
+ }
359
+
360
+ // ---- queue (imperative handle implementations) -------------------------
361
+ function show(input: any) {
362
+ const t = input || {};
363
+ let id;
364
+ if (t.id != null) {
365
+ // Coerce a consumer-supplied id to a String once, at the single entry
366
+ // point. Ids flow through the `timers` map (whose `for (const id in …)`
367
+ // keys are ALWAYS strings) and every downstream `t.id === id` strict
368
+ // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
369
+ // stop matching after a hover pause/resume re-arms with the string key.
370
+ id = String(t.id);
371
+ } else {
372
+ // Take the high-water mark of the persistent-but-tick-stale $data.seq and
373
+ // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
374
+ // so same-tick multi-show yields DISTINCT ids on React too. Read both
375
+ // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
376
+ const s = Math.max(seq(), seqLocal);
377
+ id = 't' + s;
378
+ seqLocal = s + 1;
379
+ setSeq(s + 1);
380
+ }
381
+ const toast = {
382
+ id,
383
+ message: t.message != null ? t.message : '',
384
+ type: t.type || 'info',
385
+ duration: t.duration != null ? t.duration : local.duration
386
+ };
387
+ // ONE self-referential assignment so the React emitter lowers it to the
388
+ // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
389
+ // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
390
+ // stale-closure `setToasts(<value>)`, losing the first of two same-tick
391
+ // toasts). slice() start: keep the newest `max` when over the cap
392
+ // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
393
+ setToasts(toasts().concat([toast]).slice(local.max > 0 ? Math.max(0, toasts().length + 1 - local.max) : 0));
394
+ startTimer(toast);
395
+ return id;
396
+ }
397
+
398
+ // ---- exit lifecycle ------------------------------------------------------
399
+ // Deliberately exceeds the 200ms default --rozie-toast-exit-duration token
400
+ // comfortably; a consumer overriding the exit duration beyond ~350ms gets cut
401
+ // short by this failsafe (documented in docs/components/toast.md).
402
+ const EXIT_FAILSAFE_MS = 350;
403
+
404
+ // Idempotent removal: filters the entry out of $data.toasts. Safe to call
405
+ // twice (from the inline @animationend binding AND the failsafe) — the
406
+ // second call is a harmless no-op filter over an already-absent id.
407
+ function removeToast(id: any) {
408
+ // Cancel any pending exit failsafe for this id (first-wins: @animationend
409
+ // beating the ~350ms timeout, or vice-versa — either way, only one removal).
410
+ if (typeof window !== 'undefined' && exitFailsafes[id] != null) {
411
+ window.clearTimeout(exitFailsafes[id]);
412
+ }
413
+ delete exitFailsafes[id];
414
+ setToasts(toasts().filter((t: any) => t.id !== id));
415
+ }
416
+
417
+ // The single dismissal funnel every path routes through: the `dismiss(id)`
418
+ // verb ('api'), the built-in close button ('close'), a timer expiry
419
+ // ('timeout'), and a swipe past threshold ('swipe'). Idempotent via the
420
+ // entry's `exiting` flag — a second call on an id already exiting (or
421
+ // already gone) is a no-op, so a stray timeout firing mid-exit never
422
+ // double-emits. `extra` (swipe only) carries `{ swipeExitSign }` so the
423
+ // template can apply the direction-matched swipe-exit animation.
424
+ function dismissBegin(id: any, reason: any, extra?: {
425
+ swipeExitSign?: number;
426
+ }) {
427
+ const entry = toasts().find((t: any) => t.id === id);
428
+ if (!entry || entry.exiting) return;
429
+ clearTimer(id);
430
+ _props.onDismissed?.({
431
+ toast: entry,
432
+ reason
433
+ });
434
+ setToasts(toasts().map((t: any) => t.id === id ? {
435
+ ...t,
436
+ exiting: true,
437
+ ...(extra || {})
438
+ } : t));
439
+ if (typeof window === 'undefined') {
440
+ removeToast(id);
441
+ } else {
442
+ exitFailsafes[id] = window.setTimeout(() => removeToast(id), EXIT_FAILSAFE_MS);
443
+ }
444
+ }
445
+ function dismiss(id: any) {
446
+ dismissBegin(id, 'api');
447
+ }
448
+
449
+ // clear() is bulk: immediate full teardown, NO per-toast exit animation and
450
+ // NO emit (documented — see docs/components/toast.md).
451
+ function clear() {
452
+ teardownTimers();
453
+ setToasts([]);
454
+ }
455
+
456
+ // ---- patch / promise ------------------------------------------------------
457
+ // Update-in-place primitive: merges ONLY the present `{message,type,duration}`
458
+ // keys into the matching entry via a fresh-array map (never in-place
459
+ // mutation). Returns whether the id existed. A `duration` key clears+restarts
460
+ // the timer (0 → sticky/no-arm; positive → arm); any other key leaves a
461
+ // running timer untouched.
462
+ function patch(id: any, changes: any) {
463
+ const c = changes || {};
464
+ let existed = false;
465
+ const next = toasts().map((t: any) => {
466
+ if (t.id !== id) return t;
467
+ // Treat an EXITING entry as absent — never resurrect a toast whose
468
+ // dismissal is already in flight (removal deferred to @animationend / the
469
+ // failsafe). `existed` stays false → patch returns false, writes nothing,
470
+ // arms no timer.
471
+ if (t.exiting) return t;
472
+ existed = true;
473
+ const merged = {
474
+ ...t
475
+ };
476
+ if (c.message !== undefined) merged.message = c.message;
477
+ if (c.type !== undefined) merged.type = c.type;
478
+ if (c.duration !== undefined) merged.duration = c.duration;
479
+ return merged;
480
+ });
481
+ if (!existed) return false;
482
+ setToasts(next);
483
+ if (c.duration !== undefined) {
484
+ clearTimer(id);
485
+ const patched = next.find((t: any) => t.id === id);
486
+ if (paused) {
487
+ // Hovered: store the new duration as the pending remainder WITHOUT
488
+ // arming a live timer (which would dismiss the toast while the pointer
489
+ // is still over the stack). resumeTimers() arms it on leave.
490
+ if (patched && patched.duration > 0 && typeof window !== 'undefined') {
491
+ timers[id] = {
492
+ handle: null,
493
+ startedAt: Date.now(),
494
+ remaining: patched.duration
495
+ };
496
+ }
497
+ } else {
498
+ startTimer(patched);
499
+ }
500
+ }
501
+ return true;
502
+ }
503
+
504
+ // The settle guard: a no-op if the host unmounted OR the toast was already
505
+ // dismissed while the promise was still pending (never-resurrect).
506
+ function settlePromise(id: any, type: any, messageOrFn: any, value: any) {
507
+ if (unmounted) return;
508
+ // Never-resurrect: no-op if the toast is gone OR already exiting (its
509
+ // dismissal is in flight — settling now would flip it back to a live
510
+ // success/error toast and re-arm a timer).
511
+ const entry = toasts().find((t: any) => t.id === id);
512
+ if (!entry || entry.exiting) return;
513
+ const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
514
+ patch(id, {
515
+ type,
516
+ message,
517
+ duration: local.duration
518
+ });
519
+ }
520
+
521
+ // Sugar over show()+patch(): shows a sticky loading toast synchronously
522
+ // (returns its id immediately — the consumer already holds `p`), then patches
523
+ // the SAME entry to success/error on settle (the auto-dismiss timer starts AT
524
+ // SETTLE, via patch's duration-key restart). Never returns/derives a new
525
+ // promise — `p`'s own .then/.catch still fire for the consumer untouched.
526
+ function promise(p: any, opts: any) {
527
+ const o = opts || {};
528
+ const id = show({
529
+ type: 'loading',
530
+ duration: 0,
531
+ message: o.loading
532
+ });
533
+ if (p && typeof p.then === 'function') {
534
+ p.then((value: any) => settlePromise(id, 'success', o.success, value)).catch((err: any) => settlePromise(id, 'error', o.error, err));
535
+ }
536
+ return id;
537
+ }
538
+
539
+ // ---- swipe-to-dismiss ------------------------------------------------------
540
+ // Axis + dismiss-direction sign, purely derived from the corner (no per-
541
+ // gesture state needed for these two — they only depend on $props.position).
542
+ function swipeAxisFor(position: any) {
543
+ return position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
544
+ }
545
+ function swipeSignFor(position: any) {
546
+ if (position === 'top-right' || position === 'bottom-right') return 1;
547
+ if (position === 'top-left' || position === 'bottom-left') return -1;
548
+ if (position === 'bottom-center') return 1;
549
+ return -1; // top-center
550
+ }
551
+ function onToastPointerDown(t: any, event: any) {
552
+ if (local.disableSwipe) return;
553
+ if (event.button != null && event.button !== 0) return;
554
+ // Ignore drags starting on the close button / any button-or-link chrome.
555
+ const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
556
+ if (chrome) return;
557
+ const axis = swipeAxisFor(local.position);
558
+ const sign = swipeSignFor(local.position);
559
+ const el = event.currentTarget;
560
+ const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
561
+ setSwipeGesture({
562
+ id: t.id,
563
+ axis,
564
+ sign,
565
+ size,
566
+ startX: event.clientX,
567
+ startY: event.clientY,
568
+ startTime: Date.now()
569
+ });
570
+ if (el && el.setPointerCapture) {
571
+ try {
572
+ el.setPointerCapture(event.pointerId);
573
+ } catch (e: any) {
574
+ // Some embedded contexts throw on setPointerCapture — swipe still
575
+ // works without capture (just loses "keeps tracking off-element").
576
+ }
577
+ }
578
+ }
579
+ function onToastPointerMove(t: any, event: any) {
580
+ if (local.disableSwipe) return;
581
+ const gesture = swipeGesture();
582
+ if (!gesture || gesture.id !== t.id) return;
583
+ const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
584
+ const towardDismiss = raw * gesture.sign > 0;
585
+ const d = towardDismiss ? raw : raw * 0.15;
586
+ setSwipe({
587
+ id: t.id,
588
+ d,
589
+ axis: gesture.axis,
590
+ sign: gesture.sign,
591
+ size: gesture.size
592
+ });
593
+ }
594
+ function onToastPointerUp(t: any, event: any) {
595
+ if (local.disableSwipe) return;
596
+ const gesture = swipeGesture();
597
+ setSwipeGesture(null);
598
+ // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
599
+ // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
600
+ // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
601
+ // "Cannot assign to constant"). Same collision class as the documented
602
+ // $refs/$props self-shadow, just for a $data key.
603
+ const dragState = swipe();
604
+ setSwipe(null);
605
+ if (!gesture || gesture.id !== t.id || !dragState) return;
606
+ const elapsed = Math.max(1, Date.now() - gesture.startTime);
607
+ const magnitude = dragState.d * gesture.sign;
608
+ const velocity = magnitude / elapsed;
609
+ if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
610
+ dismissBegin(t.id, 'swipe', {
611
+ swipeExitSign: gesture.sign
612
+ });
613
+ }
614
+ }
615
+ function onToastPointerCancel(t: any) {
616
+ if (local.disableSwipe) return;
617
+ if (swipeGesture() && swipeGesture().id === t.id) setSwipeGesture(null);
618
+ if (swipe() && swipe().id === t.id) setSwipe(null);
619
+ }
620
+
621
+ // ---- stacked mode ----------------------------------------------------------
622
+ // Depth from newest: the newest toast (last in the array — show() appends)
623
+ // is depth 0; each older toast is one deeper. Corner-independent — the
624
+ // collapsed grid overlay ignores flex-direction/column-reverse entirely, so
625
+ // this needs no position-aware math.
626
+ function depth(t: any) {
627
+ const idx = toasts().findIndex((x: any) => x.id === t.id);
628
+ return idx === -1 ? 0 : toasts().length - 1 - idx;
629
+ }
630
+
631
+ // String-form `:style` for the toast row. ALWAYS carries `--rozie-toast-depth`
632
+ // (a no-op unless `stacked` is on — CSS reads it only inside
633
+ // `.rozie-toaster--stacked`), plus EITHER the active drag transform (while
634
+ // $data.swipe tracks this id) OR the swipe-exit sign custom property (once
635
+ // `dismissBegin('swipe')` flipped `t.swipeExitSign`). Drag/exit never overlap.
636
+ function toastStyle(t: any) {
637
+ const depthDecl = '--rozie-toast-depth: ' + depth(t) + ';';
638
+ if (t.exiting) {
639
+ return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
640
+ }
641
+ // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
642
+ // above (Svelte 5 $data-key self-shadow).
643
+ const dragState = swipe();
644
+ if (!dragState || dragState.id !== t.id) return depthDecl;
645
+ const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
646
+ const magnitude = dragState.d * dragState.sign;
647
+ const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
648
+ return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
649
+ }
650
+
651
+ // ---- hover pause -------------------------------------------------------
652
+ function onMouseEnter() {
653
+ if (local.disablePauseOnHover) return;
654
+ pauseTimers();
655
+ }
656
+ function onMouseLeave() {
657
+ if (local.disablePauseOnHover) return;
658
+ resumeTimers();
659
+ }
660
+
661
+ // ---- helpers -----------------------------------------------------------
662
+ function regionLabel() {
663
+ return local.ariaLabel != null ? local.ariaLabel : 'Notifications';
664
+ }
665
+ // Type union: 'info' | 'success' | 'error' | 'warning' | 'loading'. Only
666
+ // error/warning interrupt (assertive); loading (like info/success) is polite.
667
+ function liveFor(type: any) {
668
+ return type === 'error' || type === 'warning' ? 'assertive' : 'polite';
669
+ }
670
+
671
+ // ---- lifecycle + handle ------------------------------------------------
672
+
673
+ return (
674
+ <>
675
+ <div role="region" aria-label={rozieAttr(regionLabel())} {...attrs} class={"rozie-toaster" + " " + rozieClass('rozie-toaster--' + local.position + (local.stacked ? ' rozie-toaster--stacked' : '')) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onMouseEnter: ($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { onMouseEnter(); }, onMouseLeave: ($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { onMouseLeave(); } }, attrs)} data-rozie-s-12d4265c="">
676
+
677
+ <Key each={toasts() as readonly any[]} by={(t) => t.id}>{(t) => <div role="status" aria-live={rozieAttr(liveFor(t().type))} class={"rozie-toast" + " " + rozieClass('rozie-toast--' + t().type + (t().exiting ? ' rozie-toast--exiting' : '') + (t().swipeExitSign != null ? ' rozie-toast--swipe-exit' : ''))} style={parseInlineStyle(toastStyle(t()))} onAnimationEnd={($event: AnimationEvent & { currentTarget: HTMLDivElement; target: Element }) => { t().exiting && removeToast(t().id); }} onPointerDown={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerDown(t(), $event); }} onPointerMove={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerMove(t(), $event); }} onPointerUp={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerUp(t(), $event); }} onPointerCancel={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerCancel(t()); }} data-rozie-s-12d4265c="">
678
+ {(_props.toastSlot ?? _props.slots?.['toast'])?.({ toast: t(), dismiss }) ?? <>{<Show when={t().type === 'loading'}><span class={"rozie-toast-spinner"} aria-hidden="true" data-rozie-s-12d4265c="" /></Show>}<span class={"rozie-toast-message"} data-rozie-s-12d4265c="">{rozieDisplay(t().message)}</span><button type="button" aria-label="Dismiss" class={"rozie-toast-close"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { dismissBegin(t().id, 'close'); }} data-rozie-s-12d4265c="">×</button></>}
679
+ </div>}</Key>
680
+ </div>
681
+ </>
682
+ );
683
+ }