@stonedogcode/style 0.17.0 → 0.20.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,325 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { toastRecipe } from "styled-system/recipes";
6
+ import { cx } from "styled-system/css";
7
+
8
+ import StyledButton from "./StyledButton";
9
+ import StyledSpinner from "./StyledSpinner";
10
+ import StyledText from "./StyledText";
11
+ import type { Toast, ToasterStore, ToastType } from "./toaster-store";
12
+
13
+ /**
14
+ * Draws whatever is in a toaster store.
15
+ *
16
+ * Mount **one** of these, once, near the root of the application, and hand it
17
+ * the same store your `create()` calls go to. It renders nothing until there is
18
+ * something to show.
19
+ *
20
+ * ```tsx
21
+ * export const toaster = createToaster();
22
+ * // …somewhere near the root:
23
+ * <StyledToaster toaster={toaster} />
24
+ * // …anywhere at all:
25
+ * toaster.create({ title: "Saved.", type: "success" });
26
+ * ```
27
+ *
28
+ * ## The three things that make this SSR-safe
29
+ *
30
+ * All three were failure modes before they were requirements, and none of them
31
+ * shows up in a client-only test:
32
+ *
33
+ * 1. **`getServerSnapshot`** — `useSyncExternalStore` throws during hydration
34
+ * without one. The store supplies a frozen empty array, the same reference
35
+ * every time, so React sees no change between the server render and the
36
+ * first client one.
37
+ * 2. **The portal waits for mount.** `createPortal(…, document.body)` is a
38
+ * `document is not defined` crash on the server. `mounted` below is false
39
+ * for the server render *and* for the first client render, which is what
40
+ * keeps the two identical — checking `typeof document` instead would make
41
+ * them differ and produce a hydration mismatch rather than a crash.
42
+ * 3. **The store refuses to queue on the server**, so a toast created during a
43
+ * render cannot leak into the next request. That one lives in the store; see
44
+ * its header.
45
+ *
46
+ * ## Timers start here, not in the store
47
+ *
48
+ * A toast created before this component mounts — during a redirect, a slow
49
+ * hydration, an early event handler — must still be seen. Because each toast's
50
+ * countdown is an effect *in the toast's own element*, it cannot start before
51
+ * that element exists, so an early toast waits rather than expiring unseen.
52
+ */
53
+ export interface StyledToasterProps {
54
+ /** The store to draw. Create it with `createToaster()`. */
55
+ toaster: ToasterStore;
56
+ /**
57
+ * The glyph for each kind of toast.
58
+ *
59
+ * **This package ships no icon artwork**, deliberately — see CLAUDE.md. The
60
+ * defaults are text characters, which work everywhere and are nobody's
61
+ * favourite. Pass your own icon set here to replace them; pass `null` for a
62
+ * type to render no glyph at all.
63
+ *
64
+ * Whatever you pass is `aria-hidden`: the toast's role already tells a screen
65
+ * reader what kind of message it is, and reading "check mark" before the text
66
+ * is the same information twice.
67
+ */
68
+ icons?: Partial<Record<ToastType, React.ReactNode>> | undefined;
69
+ /** The glyph inside the close control. Text by default, for the same reason. */
70
+ closeIcon?: React.ReactNode;
71
+ /**
72
+ * The close control's accessible name. It is a button whose only content is a
73
+ * glyph, so without a name it announces as "button" and nothing else.
74
+ */
75
+ closeLabel?: string;
76
+ /**
77
+ * Names the region for a screen reader listing landmarks.
78
+ *
79
+ * Not the toasts themselves — those announce individually as they arrive.
80
+ */
81
+ regionLabel?: string;
82
+ }
83
+
84
+ /**
85
+ * Text stand-ins for the artwork this package will not ship.
86
+ *
87
+ * `default` has none on purpose: it is the type for a message with no status,
88
+ * and inventing a glyph for "no particular kind" would say something the
89
+ * message does not.
90
+ */
91
+ const DEFAULT_ICONS: Partial<Record<ToastType, React.ReactNode>> = {
92
+ success: "✓",
93
+ error: "✕",
94
+ warning: "!",
95
+ info: "i",
96
+ };
97
+
98
+ /**
99
+ * One toast, and the only place a dismissal timer exists.
100
+ *
101
+ * The timer is an effect keyed on `paused`, which gives pause-and-resume for
102
+ * free: pausing tears the effect down, and the cleanup subtracts the elapsed
103
+ * time from what is left, so resuming schedules the remainder rather than
104
+ * restarting the whole duration. Unmounting runs the same cleanup, so a toast
105
+ * removed mid-countdown cannot fire a state update into a component that is no
106
+ * longer there.
107
+ */
108
+ function ToastItem({
109
+ toast,
110
+ paused,
111
+ onDismiss,
112
+ icons,
113
+ closeIcon,
114
+ closeLabel,
115
+ }: {
116
+ toast: Toast;
117
+ paused: boolean;
118
+ onDismiss: (id: string) => void;
119
+ icons: Partial<Record<ToastType, React.ReactNode>>;
120
+ closeIcon: React.ReactNode;
121
+ closeLabel: string;
122
+ }) {
123
+ /**
124
+ * Resolved PER TOAST, with this toast's type.
125
+ *
126
+ * The first version called `toastRecipe()` once for the whole region and
127
+ * shared the result, so every card came out as `toast__root--type_default`
128
+ * and no status accent was ever painted — the recipe was correct, its
129
+ * stylesheet was correct, and nothing rendered it. Neither the unit tier
130
+ * (which asserts roles and text) nor the token-contract test (which reads the
131
+ * stylesheet) could see it; the component test comparing three computed
132
+ * accent colours found all three identical.
133
+ */
134
+ const classes = toastRecipe({ type: toast.type });
135
+
136
+ const remaining = useRef(toast.duration);
137
+
138
+ useEffect(() => {
139
+ // `loading` and anything given `Infinity` stay until something dismisses
140
+ // them. Scheduling a timeout for Infinity is not merely pointless — the
141
+ // value overflows a 32-bit delay and fires immediately, which would make
142
+ // "stays until dismissed" mean "vanishes at once".
143
+ if (paused || toast.dismissed || !Number.isFinite(remaining.current)) return;
144
+
145
+ const startedAt = Date.now();
146
+ const timer = setTimeout(() => onDismiss(toast.id), remaining.current);
147
+
148
+ return () => {
149
+ clearTimeout(timer);
150
+ remaining.current -= Date.now() - startedAt;
151
+ };
152
+ }, [paused, toast.dismissed, toast.id, onDismiss]);
153
+
154
+ const glyph = icons[toast.type];
155
+
156
+ return (
157
+ <div
158
+ // `status` rather than `alert`: polite, so it waits for a gap in whatever
159
+ // the reader is already saying instead of cutting across it. An
160
+ // interruption is right for a fire alarm and wrong for "Saved."
161
+ //
162
+ // `aria-atomic` makes the whole toast read as one message. Without it a
163
+ // reader announces only the part of the subtree that changed, which for a
164
+ // toast updated in place is a fragment with no context.
165
+ role="status"
166
+ aria-atomic="true"
167
+ data-state={toast.dismissed ? "closed" : "open"}
168
+ data-type={toast.type}
169
+ className={classes.root}
170
+ >
171
+ {/*
172
+ `aria-hidden` on the whole indicator, spinner included.
173
+
174
+ The glyph is hidden because the toast's role has already told the reader
175
+ what kind of message this is, and "check mark, Saved." is the same thing
176
+ twice. The SPINNER is hidden for a sharper reason: `StyledSpinner`
177
+ carries its own `role="status"`, so rendering it bare nests one live
178
+ region inside another — the message is announced twice, and the outer
179
+ `aria-atomic` no longer describes one coherent thing. What tells a
180
+ reader the work is still going is the toast's own text ("Uploading…"),
181
+ which is the part worth reading anyway.
182
+ */}
183
+ {(toast.type === "loading" || glyph != null) && (
184
+ <div className={classes.indicator} aria-hidden="true">
185
+ {toast.type === "loading" ? <StyledSpinner loadText="" /> : glyph}
186
+ </div>
187
+ )}
188
+
189
+ <div className={classes.content}>
190
+ {toast.title != null && (
191
+ <StyledText className={classes.title}>{toast.title}</StyledText>
192
+ )}
193
+ {toast.description != null && (
194
+ <StyledText className={classes.description}>
195
+ {toast.description}
196
+ </StyledText>
197
+ )}
198
+ </div>
199
+
200
+ {toast.action && (
201
+ <div className={classes.action}>
202
+ <StyledButton
203
+ onClick={() => {
204
+ toast.action?.onClick();
205
+ // A toast whose button has been pressed has done its job. Leaving
206
+ // it up invites a second press on an action that has already run.
207
+ onDismiss(toast.id);
208
+ }}
209
+ >
210
+ {toast.action.label}
211
+ </StyledButton>
212
+ </div>
213
+ )}
214
+
215
+ {toast.closable && (
216
+ <button
217
+ type="button"
218
+ aria-label={closeLabel}
219
+ className={classes.close}
220
+ onClick={() => onDismiss(toast.id)}
221
+ >
222
+ <span aria-hidden="true">{closeIcon}</span>
223
+ </button>
224
+ )}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ export const StyledToaster: React.FC<StyledToasterProps> = ({
230
+ toaster,
231
+ icons,
232
+ closeIcon = "✕",
233
+ closeLabel = "Dismiss notification",
234
+ regionLabel = "Notifications",
235
+ }) => {
236
+ const toasts = useSyncExternalStore(
237
+ toaster.subscribe,
238
+ toaster.getSnapshot,
239
+ toaster.getServerSnapshot,
240
+ );
241
+
242
+ const [mounted, setMounted] = useState(false);
243
+ useEffect(() => setMounted(true), []);
244
+
245
+ /**
246
+ * Pointer or keyboard inside the region, and the tab being hidden, all stop
247
+ * the clock. They are one boolean rather than three because the resume
248
+ * condition is "none of them", and three independent flags is how a toast
249
+ * ends up pinned forever by a hover the pointer left through a portal.
250
+ */
251
+ const [hovered, setHovered] = useState(false);
252
+ const [focused, setFocused] = useState(false);
253
+ const [pageHidden, setPageHidden] = useState(false);
254
+
255
+ useEffect(() => {
256
+ if (typeof document === "undefined") return;
257
+ // A countdown that runs in a background tab is a message the user never had
258
+ // the chance to read. Chakra's store called this `pauseOnPageIdle` and had
259
+ // it on; this keeps that, without the option, because no consumer wanted
260
+ // the other behaviour.
261
+ const sync = () => setPageHidden(document.hidden);
262
+ sync();
263
+ document.addEventListener("visibilitychange", sync);
264
+ return () => document.removeEventListener("visibilitychange", sync);
265
+ }, []);
266
+
267
+ const paused = hovered || focused || pageHidden;
268
+
269
+ // Stable identity so it is not a fresh dependency on every render of every
270
+ // toast — each toast's timer effect lists it.
271
+ const onDismiss = React.useCallback(
272
+ (id: string) => toaster.remove(id),
273
+ [toaster],
274
+ );
275
+
276
+ // Only the region slot is read here; every card resolves its own, above.
277
+ const classes = toastRecipe();
278
+ const resolvedIcons = icons ?? DEFAULT_ICONS;
279
+
280
+ if (!mounted) return null;
281
+
282
+ return createPortal(
283
+ <div
284
+ className={cx(classes.region)}
285
+ // The region is present from mount and stays, whether or not it holds
286
+ // anything. A live region created at the same moment as its content is
287
+ // announced inconsistently across screen readers; one that was already
288
+ // there is not.
289
+ aria-label={regionLabel}
290
+ onMouseEnter={() => setHovered(true)}
291
+ onMouseLeave={() => setHovered(false)}
292
+ onFocus={() => setFocused(true)}
293
+ onBlur={(event) => {
294
+ // Only when focus has actually left the region — moving between the
295
+ // action and the close button inside one toast fires blur too, and
296
+ // treating that as "focus left" would restart the countdown under the
297
+ // keyboard user's hands.
298
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
299
+ setFocused(false);
300
+ }
301
+ }}
302
+ >
303
+ {/*
304
+ Rendered oldest-last so the newest toast sits nearest the corner, which
305
+ is where the eye already is. The store keeps them newest-first because
306
+ that is the order its priority rules work in; the reversal is a
307
+ presentation decision and belongs here.
308
+ */}
309
+ {[...toasts].reverse().map((toast) => (
310
+ <ToastItem
311
+ key={toast.id}
312
+ toast={toast}
313
+ paused={paused}
314
+ onDismiss={onDismiss}
315
+ icons={resolvedIcons}
316
+ closeIcon={closeIcon}
317
+ closeLabel={closeLabel}
318
+ />
319
+ ))}
320
+ </div>,
321
+ document.body,
322
+ );
323
+ };
324
+
325
+ export default StyledToaster;
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The toast queue: a subscribable store, with no React and no DOM in it.
3
+ *
4
+ * ## Why this is a store and not a component
5
+ *
6
+ * A toast is created from places that are not rendering — an event handler, a
7
+ * `.catch()`, a module-level helper called before anything has mounted. So the
8
+ * thing callers reach for cannot be a hook. It has to be a plain object with a
9
+ * `create()` on it, and the component that draws toasts has to *subscribe* to
10
+ * that object rather than own it.
11
+ *
12
+ * That shape is what `useSyncExternalStore` exists for, and the three methods
13
+ * below (`subscribe`, `getSnapshot`, `getServerSnapshot`) are exactly its
14
+ * contract. Two of its rules are easy to break and fail loudly but obscurely:
15
+ *
16
+ * - **`getSnapshot` must return the identical reference when nothing changed.**
17
+ * Returning a fresh array each call makes React re-render forever. `toasts`
18
+ * below is therefore replaced only on mutation, never rebuilt on read.
19
+ * - **`getServerSnapshot` is mandatory** for anything server-rendered, and must
20
+ * also be reference-stable. `EMPTY` is a single frozen array shared by every
21
+ * call for that reason.
22
+ *
23
+ * ## Why `create()` does nothing on the server
24
+ *
25
+ * A module-level array lives for the lifetime of the Node process, not the
26
+ * request. A toast created during SSR would therefore still be sitting in the
27
+ * queue when the *next* user is served by that same instance — one person's
28
+ * "Saved." announced to a stranger. There is no request boundary available here
29
+ * to scope it to, and a toast has no meaning without a browser to show it in,
30
+ * so the honest answer is to refuse to queue one at all.
31
+ *
32
+ * `create()` still returns the id it would have used, so a caller that stores
33
+ * or logs the result behaves identically in both environments.
34
+ *
35
+ * ## Timers are deliberately NOT here
36
+ *
37
+ * Auto-dismiss lives in the renderer, per toast, starting when that toast first
38
+ * mounts. Putting it here would start the clock at `create()` time — so a toast
39
+ * fired before the toaster mounted (a redirect, a slow hydration, a `create()`
40
+ * in a module body) could expire before it was ever drawn. It would look like
41
+ * the toast was silently dropped, which is the failure this whole component is
42
+ * most likely to be blamed for and least likely to be caught doing.
43
+ *
44
+ * What *is* here is the exit delay, because it is a property of leaving the
45
+ * queue rather than of being on screen: `remove()` marks a toast `dismissed`
46
+ * and purges it `removeDelay` ms later, so the renderer has a state to animate
47
+ * out of.
48
+ */
49
+
50
+ import type { ReactNode } from "react";
51
+
52
+ /**
53
+ * The kinds of toast, matching what the extracted application already used.
54
+ *
55
+ * `default` is a toast with no status at all — no accent, no glyph. It is not a
56
+ * synonym for `info`; a message that means something should say which thing it
57
+ * means.
58
+ */
59
+ export type ToastType =
60
+ | "success"
61
+ | "error"
62
+ | "warning"
63
+ | "info"
64
+ | "loading"
65
+ | "default";
66
+
67
+ /** A single button on a toast. Rendered after the message. */
68
+ export interface ToastAction {
69
+ label: string;
70
+ onClick: () => void;
71
+ }
72
+
73
+ export interface ToastOptions {
74
+ /**
75
+ * Supply one to make `create()` idempotent — creating with an id that is
76
+ * already on screen updates that toast in place rather than stacking a
77
+ * duplicate. Useful for progress ("Uploading…" → "Uploaded").
78
+ */
79
+ id?: string | undefined;
80
+ title?: ReactNode;
81
+ description?: ReactNode;
82
+ type?: ToastType | undefined;
83
+ /**
84
+ * Milliseconds on screen. Omit to use the per-type default below.
85
+ * `Infinity` pins the toast until it is dismissed.
86
+ */
87
+ duration?: number | undefined;
88
+ action?: ToastAction | undefined;
89
+ /** Render a close control. */
90
+ closable?: boolean | undefined;
91
+ }
92
+
93
+ export interface Toast extends ToastOptions {
94
+ id: string;
95
+ type: ToastType;
96
+ duration: number;
97
+ /**
98
+ * Set the moment `remove()` is called and the toast starts animating out.
99
+ * It stays in the snapshot while true so the renderer has something to
100
+ * animate; it is purged `removeDelay` ms later.
101
+ */
102
+ dismissed: boolean;
103
+ }
104
+
105
+ export interface ToasterStoreOptions {
106
+ /**
107
+ * How many toasts may be on screen at once. Further ones wait in a queue and
108
+ * are admitted as room appears.
109
+ *
110
+ * 24 is not a considered number — it is the value the store this replaces
111
+ * used, kept so that the behaviour at overflow does not change silently along
112
+ * with everything else.
113
+ */
114
+ max?: number;
115
+ /** How long a dismissed toast stays in the snapshot so it can animate out. */
116
+ removeDelay?: number;
117
+ }
118
+
119
+ export interface ToasterStore {
120
+ /**
121
+ * Show a toast. Returns its id.
122
+ *
123
+ * Named `create` rather than `show` or `toast` because that is the name the
124
+ * 345 call sites in the application this was extracted from already use.
125
+ */
126
+ create: (options: ToastOptions) => string;
127
+ /**
128
+ * Dismiss one toast, or every toast when called with no argument.
129
+ *
130
+ * The toast animates out first — it is marked `dismissed` immediately and
131
+ * leaves the snapshot `removeDelay` ms later.
132
+ */
133
+ remove: (id?: string) => void;
134
+ subscribe: (listener: () => void) => () => void;
135
+ getSnapshot: () => readonly Toast[];
136
+ getServerSnapshot: () => readonly Toast[];
137
+ }
138
+
139
+ /**
140
+ * How long each kind stays up, in milliseconds.
141
+ *
142
+ * These are not invented. They are the values `@zag-js/toast` uses, read out of
143
+ * the installed package rather than guessed, so that swapping the
144
+ * implementation underneath an application does not quietly retime every
145
+ * message in it. Note `success` is much shorter than the rest — a confirmation
146
+ * has been read the moment it is seen, whereas a warning is asking for a
147
+ * decision.
148
+ */
149
+ export const DEFAULT_DURATIONS: Record<ToastType, number> = {
150
+ success: 2000,
151
+ error: 5000,
152
+ warning: 5000,
153
+ info: 5000,
154
+ loading: Infinity,
155
+ default: 5000,
156
+ };
157
+
158
+ /**
159
+ * Which toast wins a place on screen when more than `max` are pending.
160
+ *
161
+ * Lower sorts first. Errors outrank confirmations because a failure the user
162
+ * never sees is the expensive one, and within a type an *actionable* toast
163
+ * outranks a passive one — there is nothing to miss on a toast with no button.
164
+ *
165
+ * Only consulted past `max` simultaneous toasts, which in practice means a loop
166
+ * that has gone wrong. It exists so that what survives that is the half worth
167
+ * reading.
168
+ */
169
+ const PRIORITY: Record<ToastType, [actionable: number, passive: number]> = {
170
+ error: [1, 2],
171
+ warning: [3, 6],
172
+ loading: [4, 5],
173
+ success: [5, 7],
174
+ info: [6, 8],
175
+ default: [6, 8],
176
+ };
177
+
178
+ const priorityOf = (toast: Toast): number =>
179
+ PRIORITY[toast.type][toast.action ? 0 : 1];
180
+
181
+ /**
182
+ * One frozen array, returned by every server render and by any snapshot taken
183
+ * of an empty store. `useSyncExternalStore` compares snapshots by reference, so
184
+ * a fresh `[]` here would be a new value on every read.
185
+ */
186
+ const EMPTY: readonly Toast[] = Object.freeze([]);
187
+
188
+ /** `window` is the only reliable "is there a user in front of this" signal. */
189
+ const inBrowser = (): boolean => typeof window !== "undefined";
190
+
191
+ export function createToaster(options: ToasterStoreOptions = {}): ToasterStore {
192
+ const { max = 24, removeDelay = 200 } = options;
193
+
194
+ let toasts: readonly Toast[] = EMPTY;
195
+ let queued: Toast[] = [];
196
+ let listeners: Array<() => void> = [];
197
+ let counter = 0;
198
+
199
+ /**
200
+ * Ids are a counter, not a random or time-based value, so that a test can
201
+ * assert on one and so two toasts created in the same millisecond cannot
202
+ * collide. They are scoped to this store and never leave it.
203
+ */
204
+ const nextId = (): string => `toast-${++counter}`;
205
+
206
+ const emit = (): void => {
207
+ // Copied before iterating: a listener that unsubscribes itself while being
208
+ // notified would otherwise shorten the array mid-loop and skip its
209
+ // neighbour.
210
+ for (const listener of [...listeners]) listener();
211
+ };
212
+
213
+ /** Admit queued toasts until the screen is full again. */
214
+ const drain = (): void => {
215
+ if (queued.length === 0 || toasts.length >= max) return;
216
+ queued.sort((a, b) => priorityOf(a) - priorityOf(b));
217
+ const admitted = queued.splice(0, max - toasts.length);
218
+ // Queued toasts were created after everything on screen, so they go in
219
+ // front; among themselves the highest priority leads.
220
+ toasts = [...admitted, ...toasts];
221
+ };
222
+
223
+ const purge = (id: string): void => {
224
+ const next = toasts.filter((toast) => toast.id !== id);
225
+ if (next.length === toasts.length) return;
226
+ toasts = next.length === 0 ? EMPTY : next;
227
+ drain();
228
+ emit();
229
+ };
230
+
231
+ const create = (options: ToastOptions): string => {
232
+ const id = options.id ?? nextId();
233
+
234
+ // See the header: never queue on the server.
235
+ if (!inBrowser()) return id;
236
+
237
+ const existing = toasts.find((toast) => toast.id === id);
238
+ if (existing) {
239
+ const type = options.type ?? existing.type;
240
+ const updated: Toast = {
241
+ ...existing,
242
+ ...options,
243
+ id,
244
+ type,
245
+ // A change of TYPE re-derives the duration, and that is the whole point
246
+ // of this branch. The progress case — `loading` ("Uploading…") updated
247
+ // in place to `success` ("Uploaded.") — carries no explicit duration,
248
+ // and `loading` means `Infinity`. Simply keeping the old value leaves
249
+ // the finished toast pinned to the screen forever, which reads as the
250
+ // upload never having completed. Only an explicit `duration` overrides.
251
+ duration:
252
+ options.duration ??
253
+ (type === existing.type ? existing.duration : DEFAULT_DURATIONS[type]),
254
+ // Re-creating an id that is on its way out brings it back.
255
+ dismissed: false,
256
+ };
257
+ toasts = toasts.map((toast) => (toast.id === id ? updated : toast));
258
+ emit();
259
+ return id;
260
+ }
261
+
262
+ const type = options.type ?? "info";
263
+ const toast: Toast = {
264
+ ...options,
265
+ id,
266
+ type,
267
+ duration: options.duration ?? DEFAULT_DURATIONS[type],
268
+ dismissed: false,
269
+ };
270
+
271
+ if (toasts.length >= max) {
272
+ queued.push(toast);
273
+ return id;
274
+ }
275
+
276
+ toasts = [toast, ...toasts];
277
+ emit();
278
+ return id;
279
+ };
280
+
281
+ const remove = (id?: string): void => {
282
+ if (id === undefined) {
283
+ queued = [];
284
+ if (toasts.length === 0) return;
285
+ toasts = toasts.map((toast) => ({ ...toast, dismissed: true }));
286
+ emit();
287
+ const ids = toasts.map((toast) => toast.id);
288
+ schedulePurge(() => ids.forEach(purge));
289
+ return;
290
+ }
291
+
292
+ const target = toasts.find((toast) => toast.id === id);
293
+ if (!target) {
294
+ // It may still be waiting for a slot; drop it before it ever appears.
295
+ queued = queued.filter((toast) => toast.id !== id);
296
+ return;
297
+ }
298
+ if (target.dismissed) return;
299
+
300
+ toasts = toasts.map((toast) =>
301
+ toast.id === id ? { ...toast, dismissed: true } : toast,
302
+ );
303
+ emit();
304
+ schedulePurge(() => purge(id));
305
+ };
306
+
307
+ /**
308
+ * The exit delay, skipped entirely off-browser.
309
+ *
310
+ * A `setTimeout` on the server would keep the event loop alive and fire into
311
+ * a store nothing is subscribed to. There is nothing to animate there, so the
312
+ * removal is immediate.
313
+ */
314
+ const schedulePurge = (run: () => void): void => {
315
+ if (!inBrowser() || removeDelay <= 0) {
316
+ run();
317
+ return;
318
+ }
319
+ setTimeout(run, removeDelay);
320
+ };
321
+
322
+ return {
323
+ create,
324
+ remove,
325
+ subscribe: (listener) => {
326
+ listeners = [...listeners, listener];
327
+ return () => {
328
+ listeners = listeners.filter((candidate) => candidate !== listener);
329
+ };
330
+ },
331
+ getSnapshot: () => toasts,
332
+ getServerSnapshot: () => EMPTY,
333
+ };
334
+ }