gbs-add-block 1.2.11 → 1.2.13
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/README.md +4 -2
- package/index.cjs +7 -1
- package/package.json +1 -1
- package/source/beta-components/fileuploader/README.md +99 -0
- package/source/beta-components/fileuploader/__tests__/core.test.ts +395 -0
- package/source/beta-components/fileuploader/core/format.ts +79 -0
- package/source/beta-components/fileuploader/core/index.ts +26 -0
- package/source/beta-components/fileuploader/core/store.ts +432 -0
- package/source/beta-components/fileuploader/core/transport.ts +145 -0
- package/source/beta-components/fileuploader/core/types.ts +126 -0
- package/source/beta-components/fileuploader/core/validate.ts +83 -0
- package/source/beta-components/fileuploader/index.ts +7 -0
- package/source/beta-components/fileuploader/react/FileRow.tsx +268 -0
- package/source/beta-components/fileuploader/react/FileUploader.tsx +515 -0
- package/source/beta-components/fileuploader/react/icons.tsx +80 -0
- package/source/beta-components/fileuploader/react/locale.ts +33 -0
- package/source/beta-components/fileuploader/react/props.ts +31 -0
- package/source/beta-components/fileuploader/react/useFileUploader.ts +32 -0
- package/source/beta-components/fileuploader/styles.css +395 -0
- package/source/beta-components/toaster/README.md +128 -0
- package/source/beta-components/toaster/__tests__/core.test.ts +256 -0
- package/source/beta-components/toaster/core/api.ts +87 -0
- package/source/beta-components/toaster/core/index.ts +14 -0
- package/source/beta-components/toaster/core/store.ts +211 -0
- package/source/beta-components/toaster/core/toast.ts +12 -0
- package/source/beta-components/toaster/core/types.ts +78 -0
- package/source/beta-components/toaster/index.ts +6 -0
- package/source/beta-components/toaster/react/ToastItem.tsx +164 -0
- package/source/beta-components/toaster/react/Toaster.tsx +187 -0
- package/source/beta-components/toaster/react/icons.tsx +56 -0
- package/source/beta-components/toaster/react/locale.ts +6 -0
- package/source/beta-components/toaster/react/props.ts +15 -0
- package/source/beta-components/toaster/react/useToasts.ts +11 -0
- package/source/beta-components/toaster/styles.css +283 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createToastApi } from "../core/api";
|
|
3
|
+
import { createToastStore, visibleToasts, type ToastStore } from "../core/store";
|
|
4
|
+
import { toast, toastStore } from "../core/toast";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const states = (store: ToastStore) =>
|
|
15
|
+
store.getSnapshot().toasts.map((item) => `${item.id}:${item.state}`);
|
|
16
|
+
|
|
17
|
+
const find = (store: ToastStore, id: string) =>
|
|
18
|
+
store.getSnapshot().toasts.find((item) => item.id === id);
|
|
19
|
+
|
|
20
|
+
describe("showing", () => {
|
|
21
|
+
it("adds toasts newest first, with defaults", () => {
|
|
22
|
+
const store = createToastStore();
|
|
23
|
+
const first = store.show("Saved");
|
|
24
|
+
const second = store.show("Deleted", { type: "error", description: "3 rows" });
|
|
25
|
+
|
|
26
|
+
expect([first, second]).toEqual(["toast-1", "toast-2"]);
|
|
27
|
+
const [newest, oldest] = store.getSnapshot().toasts;
|
|
28
|
+
expect(newest).toMatchObject({ id: "toast-2", type: "error", description: "3 rows" });
|
|
29
|
+
expect(oldest).toMatchObject({
|
|
30
|
+
title: "Saved",
|
|
31
|
+
type: "default",
|
|
32
|
+
duration: 5000,
|
|
33
|
+
dismissible: true,
|
|
34
|
+
state: "open",
|
|
35
|
+
version: 1,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("updates a toast shown again with the same id, keeping its place", () => {
|
|
40
|
+
const store = createToastStore();
|
|
41
|
+
store.show("Syncing", { id: "sync" });
|
|
42
|
+
store.show("Other");
|
|
43
|
+
store.show("Synced", { id: "sync", type: "success" });
|
|
44
|
+
|
|
45
|
+
expect(states(store)).toEqual(["toast-1:open", "sync:open"]);
|
|
46
|
+
expect(find(store, "sync")).toMatchObject({ title: "Synced", type: "success", version: 2 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("restarts the countdown when the same id is shown again", () => {
|
|
50
|
+
const store = createToastStore();
|
|
51
|
+
store.show("Draft saved", { id: "draft" });
|
|
52
|
+
vi.advanceTimersByTime(4000);
|
|
53
|
+
store.show("Draft saved again", { id: "draft" });
|
|
54
|
+
vi.advanceTimersByTime(4000);
|
|
55
|
+
expect(find(store, "draft")?.state).toBe("open");
|
|
56
|
+
vi.advanceTimersByTime(1000);
|
|
57
|
+
expect(find(store, "draft")?.state).toBe("closing");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("brings back a toast that is fading out instead of stacking a copy", () => {
|
|
61
|
+
const store = createToastStore();
|
|
62
|
+
store.show("Offline", { id: "net" });
|
|
63
|
+
store.dismiss("net");
|
|
64
|
+
store.show("Still offline", { id: "net" });
|
|
65
|
+
vi.advanceTimersByTime(300);
|
|
66
|
+
expect(states(store)).toEqual(["net:open"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("timing", () => {
|
|
71
|
+
it("closes after its duration, then leaves after the exit animation", () => {
|
|
72
|
+
const store = createToastStore();
|
|
73
|
+
const onAutoClose = vi.fn();
|
|
74
|
+
const onDismiss = vi.fn();
|
|
75
|
+
store.show("Saved", { duration: 2000, onAutoClose, onDismiss });
|
|
76
|
+
|
|
77
|
+
vi.advanceTimersByTime(1999);
|
|
78
|
+
expect(states(store)).toEqual(["toast-1:open"]);
|
|
79
|
+
vi.advanceTimersByTime(1);
|
|
80
|
+
expect(states(store)).toEqual(["toast-1:closing"]);
|
|
81
|
+
expect(onAutoClose).toHaveBeenCalledOnce();
|
|
82
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
83
|
+
vi.advanceTimersByTime(200);
|
|
84
|
+
expect(states(store)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps sticky and loading toasts until dismissed", () => {
|
|
88
|
+
const store = createToastStore();
|
|
89
|
+
store.show("Zero", { duration: 0 });
|
|
90
|
+
store.show("Infinite", { duration: Infinity });
|
|
91
|
+
store.show("Working", { type: "loading" });
|
|
92
|
+
|
|
93
|
+
vi.advanceTimersByTime(60_000);
|
|
94
|
+
expect(states(store)).toEqual(["toast-3:open", "toast-2:open", "toast-1:open"]);
|
|
95
|
+
expect(find(store, "toast-1")?.duration).toBe(Infinity);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("dismisses one toast or all of them", () => {
|
|
99
|
+
const store = createToastStore();
|
|
100
|
+
const onDismiss = vi.fn();
|
|
101
|
+
store.show("A", { onDismiss });
|
|
102
|
+
store.show("B");
|
|
103
|
+
store.show("C");
|
|
104
|
+
|
|
105
|
+
store.dismiss("toast-1");
|
|
106
|
+
expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ id: "toast-1" }));
|
|
107
|
+
expect(states(store)).toEqual(["toast-3:open", "toast-2:open", "toast-1:closing"]);
|
|
108
|
+
|
|
109
|
+
store.dismiss();
|
|
110
|
+
vi.advanceTimersByTime(200);
|
|
111
|
+
expect(states(store)).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("holds the remaining time while paused", () => {
|
|
115
|
+
const store = createToastStore();
|
|
116
|
+
store.show("Saved");
|
|
117
|
+
|
|
118
|
+
vi.advanceTimersByTime(3000);
|
|
119
|
+
store.pause();
|
|
120
|
+
vi.advanceTimersByTime(10_000);
|
|
121
|
+
expect(states(store)).toEqual(["toast-1:open"]);
|
|
122
|
+
|
|
123
|
+
store.resume();
|
|
124
|
+
vi.advanceTimersByTime(1999);
|
|
125
|
+
expect(states(store)).toEqual(["toast-1:open"]);
|
|
126
|
+
vi.advanceTimersByTime(1);
|
|
127
|
+
expect(states(store)).toEqual(["toast-1:closing"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("queues toasts beyond the limit with their full time", () => {
|
|
131
|
+
const store = createToastStore({ limit: 3 });
|
|
132
|
+
for (const title of ["A", "B", "C", "D"]) store.show(title);
|
|
133
|
+
|
|
134
|
+
expect(visibleToasts(store.getSnapshot()).map((item) => item.title)).toEqual(["D", "C", "B"]);
|
|
135
|
+
|
|
136
|
+
// B, C and D run out; A only starts counting once it gets a slot.
|
|
137
|
+
vi.advanceTimersByTime(5000);
|
|
138
|
+
expect(find(store, "toast-1")?.state).toBe("open");
|
|
139
|
+
expect(visibleToasts(store.getSnapshot()).map((item) => item.title)).toEqual([
|
|
140
|
+
"D",
|
|
141
|
+
"C",
|
|
142
|
+
"B",
|
|
143
|
+
"A",
|
|
144
|
+
]);
|
|
145
|
+
vi.advanceTimersByTime(4999);
|
|
146
|
+
expect(find(store, "toast-1")?.state).toBe("open");
|
|
147
|
+
vi.advanceTimersByTime(1);
|
|
148
|
+
expect(find(store, "toast-1")?.state).toBe("closing");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("updates", () => {
|
|
153
|
+
it("gives a loading toast a normal duration once its type changes", () => {
|
|
154
|
+
const store = createToastStore();
|
|
155
|
+
const id = store.show("Uploading", { type: "loading" });
|
|
156
|
+
|
|
157
|
+
store.update(id, { description: "40%" });
|
|
158
|
+
expect(find(store, id)).toMatchObject({ duration: Infinity, description: "40%", version: 2 });
|
|
159
|
+
|
|
160
|
+
store.update(id, { type: "success", title: "Uploaded" });
|
|
161
|
+
expect(find(store, id)).toMatchObject({ type: "success", title: "Uploaded", duration: 5000 });
|
|
162
|
+
vi.advanceTimersByTime(5000);
|
|
163
|
+
expect(find(store, id)?.state).toBe("closing");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("ignores ids that are not open", () => {
|
|
167
|
+
const store = createToastStore();
|
|
168
|
+
store.update("missing", { title: "Nope" });
|
|
169
|
+
expect(states(store)).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("applies a new limit and notifies subscribers", () => {
|
|
173
|
+
const store = createToastStore();
|
|
174
|
+
const listener = vi.fn();
|
|
175
|
+
store.subscribe(listener);
|
|
176
|
+
|
|
177
|
+
store.configure({ limit: 1, duration: undefined });
|
|
178
|
+
expect(store.getSnapshot().limit).toBe(1);
|
|
179
|
+
expect(listener).toHaveBeenCalledOnce();
|
|
180
|
+
|
|
181
|
+
store.pause();
|
|
182
|
+
store.pause();
|
|
183
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keeps the same snapshot object until something changes", () => {
|
|
187
|
+
const store = createToastStore();
|
|
188
|
+
const before = store.getSnapshot();
|
|
189
|
+
expect(store.getSnapshot()).toBe(before);
|
|
190
|
+
store.show("Hi");
|
|
191
|
+
expect(store.getSnapshot()).not.toBe(before);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("toast api", () => {
|
|
196
|
+
it("sets the type from the shortcut used", () => {
|
|
197
|
+
const store = createToastStore();
|
|
198
|
+
const api = createToastApi(store);
|
|
199
|
+
api.success("Saved");
|
|
200
|
+
api.warning("Low disk");
|
|
201
|
+
api.custom(({ id }) => id);
|
|
202
|
+
|
|
203
|
+
const [custom, warning, success] = store.getSnapshot().toasts;
|
|
204
|
+
expect(success.type).toBe("success");
|
|
205
|
+
expect(warning.type).toBe("warning");
|
|
206
|
+
expect(typeof custom.render).toBe("function");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("turns a promise into a success toast", async () => {
|
|
210
|
+
const store = createToastStore();
|
|
211
|
+
const api = createToastApi(store);
|
|
212
|
+
const result = api.promise(Promise.resolve(42), {
|
|
213
|
+
loading: "Saving",
|
|
214
|
+
success: (value) => `Saved ${value}`,
|
|
215
|
+
error: "Failed",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(store.getSnapshot().toasts[0]).toMatchObject({ type: "loading", title: "Saving" });
|
|
219
|
+
await expect(result).resolves.toBe(42);
|
|
220
|
+
expect(store.getSnapshot().toasts[0]).toMatchObject({ type: "success", title: "Saved 42" });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("turns a rejected promise into an error toast and still rejects", async () => {
|
|
224
|
+
const store = createToastStore();
|
|
225
|
+
const api = createToastApi(store);
|
|
226
|
+
const result = api.promise(() => Promise.reject(new Error("Network down")), {
|
|
227
|
+
loading: "Saving",
|
|
228
|
+
error: (error) => (error as Error).message,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await expect(result).rejects.toThrow("Network down");
|
|
232
|
+
expect(store.getSnapshot().toasts[0]).toMatchObject({ type: "error", title: "Network down" });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("closes quietly when a promise message is left out", async () => {
|
|
236
|
+
const store = createToastStore();
|
|
237
|
+
const api = createToastApi(store);
|
|
238
|
+
await api.promise(Promise.resolve("ok"), { loading: "Checking" });
|
|
239
|
+
expect(states(store)).toEqual(["toast-1:closing"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does nothing where a toast cannot be shown", async () => {
|
|
243
|
+
const store = createToastStore();
|
|
244
|
+
const api = createToastApi(store, () => false);
|
|
245
|
+
expect(api("Hidden")).toBe("");
|
|
246
|
+
expect(api.success("Hidden", { id: "kept" })).toBe("kept");
|
|
247
|
+
await expect(api.promise(Promise.resolve(1), { loading: "…" })).resolves.toBe(1);
|
|
248
|
+
expect(states(store)).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("ignores the default toast() on a server, where there is no document", () => {
|
|
252
|
+
expect(typeof document).toBe("undefined");
|
|
253
|
+
expect(toast("Rendered on the server")).toBe("");
|
|
254
|
+
expect(toastStore.getSnapshot().toasts).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ToastPatch, ToastStore } from "./store";
|
|
3
|
+
import type { ToastOptions, ToastRenderProps, ToastType } from "./types";
|
|
4
|
+
|
|
5
|
+
type TypedOptions = Omit<ToastOptions, "type">;
|
|
6
|
+
type Message<T> = ReactNode | ((value: T) => ReactNode);
|
|
7
|
+
|
|
8
|
+
export interface PromiseMessages<T> {
|
|
9
|
+
loading: ReactNode;
|
|
10
|
+
/** Omit to close the toast quietly on success. */
|
|
11
|
+
success?: Message<T>;
|
|
12
|
+
/** Omit to close the toast quietly on failure. */
|
|
13
|
+
error?: Message<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToastApi {
|
|
17
|
+
(title: ReactNode, options?: ToastOptions): string;
|
|
18
|
+
success(title: ReactNode, options?: TypedOptions): string;
|
|
19
|
+
error(title: ReactNode, options?: TypedOptions): string;
|
|
20
|
+
warning(title: ReactNode, options?: TypedOptions): string;
|
|
21
|
+
info(title: ReactNode, options?: TypedOptions): string;
|
|
22
|
+
/** Stays until updated or dismissed. */
|
|
23
|
+
loading(title: ReactNode, options?: TypedOptions): string;
|
|
24
|
+
custom(
|
|
25
|
+
render: (props: ToastRenderProps) => ReactNode,
|
|
26
|
+
options?: Omit<TypedOptions, "render">,
|
|
27
|
+
): string;
|
|
28
|
+
/** Shows a loading toast, then turns it into a success or error. Returns the same promise. */
|
|
29
|
+
promise<T>(
|
|
30
|
+
input: Promise<T> | (() => Promise<T>),
|
|
31
|
+
messages: PromiseMessages<T>,
|
|
32
|
+
options?: TypedOptions,
|
|
33
|
+
): Promise<T>;
|
|
34
|
+
update(id: string, patch: ToastPatch): void;
|
|
35
|
+
dismiss(id?: string): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The `toast()` function for a store. `canShow` lets the default instance ignore
|
|
40
|
+
* calls made where nobody can see them, such as during a server render.
|
|
41
|
+
*/
|
|
42
|
+
export function createToastApi(store: ToastStore, canShow: () => boolean = () => true): ToastApi {
|
|
43
|
+
const show = (title: ReactNode, options?: ToastOptions) =>
|
|
44
|
+
canShow() ? store.show(title, options) : (options?.id ?? "");
|
|
45
|
+
|
|
46
|
+
const typed = (type: ToastType) => (title: ReactNode, options?: TypedOptions) =>
|
|
47
|
+
show(title, { ...options, type });
|
|
48
|
+
|
|
49
|
+
function promise<T>(
|
|
50
|
+
input: Promise<T> | (() => Promise<T>),
|
|
51
|
+
messages: PromiseMessages<T>,
|
|
52
|
+
options?: TypedOptions,
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
const pending = typeof input === "function" ? input() : input;
|
|
55
|
+
if (!canShow()) return pending;
|
|
56
|
+
|
|
57
|
+
const id = store.show(messages.loading, { ...options, type: "loading" });
|
|
58
|
+
|
|
59
|
+
function settle<V>(type: "success" | "error", message: Message<V> | undefined, value: V) {
|
|
60
|
+
if (message === undefined) store.dismiss(id);
|
|
61
|
+
else store.update(id, { type, title: typeof message === "function" ? message(value) : message });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Handling the rejection here also stops it being reported as unhandled when
|
|
65
|
+
// the caller doesn't await the returned promise.
|
|
66
|
+
pending.then(
|
|
67
|
+
(value) => settle("success", messages.success, value),
|
|
68
|
+
(error: unknown) => settle("error", messages.error, error),
|
|
69
|
+
);
|
|
70
|
+
return pending;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return Object.assign(show, {
|
|
74
|
+
success: typed("success"),
|
|
75
|
+
error: typed("error"),
|
|
76
|
+
warning: typed("warning"),
|
|
77
|
+
info: typed("info"),
|
|
78
|
+
loading: typed("loading"),
|
|
79
|
+
custom: (
|
|
80
|
+
render: (props: ToastRenderProps) => ReactNode,
|
|
81
|
+
options?: Omit<TypedOptions, "render">,
|
|
82
|
+
) => show(null, { ...options, render }),
|
|
83
|
+
promise,
|
|
84
|
+
update: (id: string, patch: ToastPatch) => store.update(id, patch),
|
|
85
|
+
dismiss: (id?: string) => store.dismiss(id),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Framework-free: the store, the timers and the `toast()` API run without
|
|
2
|
+
// React, so they can be called from a data layer and tested on their own.
|
|
3
|
+
export { createToastApi } from "./api";
|
|
4
|
+
export type { PromiseMessages, ToastApi } from "./api";
|
|
5
|
+
export {
|
|
6
|
+
createToastStore,
|
|
7
|
+
DEFAULT_DURATION,
|
|
8
|
+
DEFAULT_LIMIT,
|
|
9
|
+
EXIT_DURATION,
|
|
10
|
+
visibleToasts,
|
|
11
|
+
} from "./store";
|
|
12
|
+
export type { ToastPatch, ToastStore, ToastStoreConfig } from "./store";
|
|
13
|
+
export { toast, toastStore } from "./toast";
|
|
14
|
+
export type * from "./types";
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ToastOptions, ToastRecord, ToastSnapshot } from "./types";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_DURATION = 5000;
|
|
5
|
+
export const DEFAULT_LIMIT = 3;
|
|
6
|
+
/** Matches the CSS exit transition, so a toast fades out before it leaves the DOM. */
|
|
7
|
+
export const EXIT_DURATION = 200;
|
|
8
|
+
|
|
9
|
+
export interface ToastStoreConfig {
|
|
10
|
+
/** Default auto-close delay in milliseconds. */
|
|
11
|
+
duration?: number;
|
|
12
|
+
/** Open toasts on screen at once; the rest wait with their timers held. */
|
|
13
|
+
limit?: number;
|
|
14
|
+
/** How long a closed toast stays for its exit animation. */
|
|
15
|
+
exitDuration?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ToastPatch = Omit<ToastOptions, "id"> & { title?: ReactNode };
|
|
19
|
+
|
|
20
|
+
export interface ToastStore {
|
|
21
|
+
subscribe(listener: () => void): () => void;
|
|
22
|
+
getSnapshot(): ToastSnapshot;
|
|
23
|
+
getServerSnapshot(): ToastSnapshot;
|
|
24
|
+
/** Adds a toast, or updates the one with the same `id`. Returns the id. */
|
|
25
|
+
show(title: ReactNode, options?: ToastOptions): string;
|
|
26
|
+
/** Changes an open toast. A new `type` without a `duration` gets that type's default. */
|
|
27
|
+
update(id: string, patch: ToastPatch): void;
|
|
28
|
+
/** Closes one toast, or every toast when no id is given. */
|
|
29
|
+
dismiss(id?: string): void;
|
|
30
|
+
pause(): void;
|
|
31
|
+
resume(): void;
|
|
32
|
+
configure(config: ToastStoreConfig): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Countdown {
|
|
36
|
+
remaining: number;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
handle: ReturnType<typeof setTimeout> | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const EMPTY: ToastSnapshot = { toasts: [], paused: false, limit: DEFAULT_LIMIT };
|
|
42
|
+
|
|
43
|
+
/** What a Toaster renders: every closing toast, plus the newest `limit` open ones. */
|
|
44
|
+
export function visibleToasts(snapshot: ToastSnapshot): ToastRecord[] {
|
|
45
|
+
let open = 0;
|
|
46
|
+
return snapshot.toasts.filter((toast) => toast.state !== "open" || open++ < snapshot.limit);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function definedOnly<T extends object>(value: T): Partial<T> {
|
|
50
|
+
return Object.fromEntries(
|
|
51
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined),
|
|
52
|
+
) as Partial<T>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Toast state and timers, with no React. Snapshots are immutable, so the store
|
|
57
|
+
* plugs straight into `useSyncExternalStore`. Only toasts on screen count down:
|
|
58
|
+
* one waiting beyond `limit` keeps its full time until it gets a slot.
|
|
59
|
+
*/
|
|
60
|
+
export function createToastStore(config: ToastStoreConfig = {}): ToastStore {
|
|
61
|
+
let settings: Required<ToastStoreConfig> = {
|
|
62
|
+
duration: DEFAULT_DURATION,
|
|
63
|
+
limit: DEFAULT_LIMIT,
|
|
64
|
+
exitDuration: EXIT_DURATION,
|
|
65
|
+
...definedOnly(config),
|
|
66
|
+
};
|
|
67
|
+
let snapshot: ToastSnapshot = { ...EMPTY, limit: settings.limit };
|
|
68
|
+
let counter = 0;
|
|
69
|
+
const listeners = new Set<() => void>();
|
|
70
|
+
const countdowns = new Map<string, Countdown>();
|
|
71
|
+
const removals = new Map<string, ReturnType<typeof setTimeout>>();
|
|
72
|
+
|
|
73
|
+
function stop(id: string) {
|
|
74
|
+
const countdown = countdowns.get(id);
|
|
75
|
+
if (!countdown?.handle) return;
|
|
76
|
+
clearTimeout(countdown.handle);
|
|
77
|
+
countdown.handle = null;
|
|
78
|
+
countdown.remaining = Math.max(0, countdown.remaining - (Date.now() - countdown.startedAt));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function syncTimers() {
|
|
82
|
+
const visible = new Set(visibleToasts(snapshot));
|
|
83
|
+
for (const toast of snapshot.toasts) {
|
|
84
|
+
const countdown = countdowns.get(toast.id);
|
|
85
|
+
if (!countdown || toast.state !== "open") continue;
|
|
86
|
+
const run =
|
|
87
|
+
visible.has(toast) && !snapshot.paused && Number.isFinite(countdown.remaining);
|
|
88
|
+
if (run && !countdown.handle) {
|
|
89
|
+
countdown.startedAt = Date.now();
|
|
90
|
+
countdown.handle = setTimeout(() => close(toast.id, true), countdown.remaining);
|
|
91
|
+
} else if (!run && countdown.handle) {
|
|
92
|
+
stop(toast.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function commit(
|
|
98
|
+
toasts: readonly ToastRecord[],
|
|
99
|
+
changes: Partial<Omit<ToastSnapshot, "toasts">> = {},
|
|
100
|
+
) {
|
|
101
|
+
snapshot = { ...snapshot, ...changes, toasts };
|
|
102
|
+
syncTimers();
|
|
103
|
+
for (const listener of listeners) listener();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function close(id: string, auto: boolean) {
|
|
107
|
+
const toast = snapshot.toasts.find((item) => item.id === id && item.state === "open");
|
|
108
|
+
if (!toast) return;
|
|
109
|
+
stop(id);
|
|
110
|
+
countdowns.delete(id);
|
|
111
|
+
|
|
112
|
+
const closing: ToastRecord = { ...toast, state: "closing" };
|
|
113
|
+
commit(snapshot.toasts.map((item) => (item === toast ? closing : item)));
|
|
114
|
+
if (auto) closing.onAutoClose?.(closing);
|
|
115
|
+
else closing.onDismiss?.(closing);
|
|
116
|
+
|
|
117
|
+
removals.set(
|
|
118
|
+
id,
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
removals.delete(id);
|
|
121
|
+
commit(snapshot.toasts.filter((item) => item !== closing));
|
|
122
|
+
}, settings.exitDuration),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function show(title: ReactNode, options: ToastOptions = {}): string {
|
|
127
|
+
const id = options.id ?? `toast-${++counter}`;
|
|
128
|
+
const type = options.type ?? "default";
|
|
129
|
+
const existing = snapshot.toasts.find((item) => item.id === id);
|
|
130
|
+
const reused = existing?.state === "open" ? existing : undefined;
|
|
131
|
+
const requested = options.duration ?? (type === "loading" ? Infinity : settings.duration);
|
|
132
|
+
|
|
133
|
+
const record: ToastRecord = {
|
|
134
|
+
...options,
|
|
135
|
+
id,
|
|
136
|
+
type,
|
|
137
|
+
title,
|
|
138
|
+
// 0, negative and NaN all mean "stay until dismissed".
|
|
139
|
+
duration: requested > 0 ? requested : Infinity,
|
|
140
|
+
dismissible: options.dismissible ?? true,
|
|
141
|
+
state: "open",
|
|
142
|
+
createdAt: reused?.createdAt ?? Date.now(),
|
|
143
|
+
version: (existing?.version ?? 0) + 1,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Showing an id that is fading out brings it back instead of stacking a copy.
|
|
147
|
+
const removal = removals.get(id);
|
|
148
|
+
if (removal !== undefined) {
|
|
149
|
+
clearTimeout(removal);
|
|
150
|
+
removals.delete(id);
|
|
151
|
+
}
|
|
152
|
+
stop(id);
|
|
153
|
+
countdowns.set(id, { remaining: record.duration, startedAt: 0, handle: null });
|
|
154
|
+
|
|
155
|
+
commit(
|
|
156
|
+
reused
|
|
157
|
+
? snapshot.toasts.map((item) => (item === reused ? record : item))
|
|
158
|
+
: [record, ...snapshot.toasts.filter((item) => item.id !== id)],
|
|
159
|
+
);
|
|
160
|
+
return id;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function update(id: string, patch: ToastPatch) {
|
|
164
|
+
const existing = snapshot.toasts.find((item) => item.id === id && item.state === "open");
|
|
165
|
+
if (!existing) return;
|
|
166
|
+
const { title = existing.title, ...changes } = patch;
|
|
167
|
+
const typeChanged = changes.type !== undefined && changes.type !== existing.type;
|
|
168
|
+
show(title, {
|
|
169
|
+
...existing,
|
|
170
|
+
...changes,
|
|
171
|
+
id,
|
|
172
|
+
duration: changes.duration ?? (typeChanged ? undefined : existing.duration),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function dismiss(id?: string) {
|
|
177
|
+
if (id !== undefined) {
|
|
178
|
+
close(id, false);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const toast of snapshot.toasts) {
|
|
182
|
+
if (toast.state === "open") close(toast.id, false);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setPaused(paused: boolean) {
|
|
187
|
+
if (snapshot.paused !== paused) commit(snapshot.toasts, { paused });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function configure(next: ToastStoreConfig) {
|
|
191
|
+
settings = { ...settings, ...definedOnly(next) };
|
|
192
|
+
if (settings.limit !== snapshot.limit) commit(snapshot.toasts, { limit: settings.limit });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
subscribe(listener) {
|
|
197
|
+
listeners.add(listener);
|
|
198
|
+
return () => {
|
|
199
|
+
listeners.delete(listener);
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
getSnapshot: () => snapshot,
|
|
203
|
+
getServerSnapshot: () => EMPTY,
|
|
204
|
+
show,
|
|
205
|
+
update,
|
|
206
|
+
dismiss,
|
|
207
|
+
pause: () => setPaused(true),
|
|
208
|
+
resume: () => setPaused(false),
|
|
209
|
+
configure,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createToastApi } from "./api";
|
|
2
|
+
import { createToastStore } from "./store";
|
|
3
|
+
|
|
4
|
+
/** The store behind `toast()`, and behind any `<Toaster />` without a `store` prop. */
|
|
5
|
+
export const toastStore = createToastStore();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shows a notification from anywhere: components, event handlers, data layers,
|
|
9
|
+
* code outside React. On a server nobody can see a toast, so calls do nothing
|
|
10
|
+
* there — which also keeps one request's toasts from leaking into another's.
|
|
11
|
+
*/
|
|
12
|
+
export const toast = createToastApi(toastStore, () => typeof document !== "undefined");
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type ToastType = "default" | "success" | "error" | "warning" | "info" | "loading";
|
|
4
|
+
|
|
5
|
+
export type ToastPosition =
|
|
6
|
+
| "top-left"
|
|
7
|
+
| "top-center"
|
|
8
|
+
| "top-right"
|
|
9
|
+
| "bottom-left"
|
|
10
|
+
| "bottom-center"
|
|
11
|
+
| "bottom-right";
|
|
12
|
+
|
|
13
|
+
/** The click event handed to an action. Call `preventDefault()` to keep the toast open. */
|
|
14
|
+
export interface ToastActionEvent {
|
|
15
|
+
preventDefault(): void;
|
|
16
|
+
readonly defaultPrevented: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToastAction {
|
|
20
|
+
label: ReactNode;
|
|
21
|
+
onClick(event: ToastActionEvent): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ToastRenderProps {
|
|
25
|
+
id: string;
|
|
26
|
+
dismiss(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ToastOptions {
|
|
30
|
+
/** Showing a toast with an id that is already on screen updates it in place. */
|
|
31
|
+
id?: string;
|
|
32
|
+
type?: ToastType;
|
|
33
|
+
description?: ReactNode;
|
|
34
|
+
/** Milliseconds before it closes. `0` or `Infinity` keeps it until dismissed. */
|
|
35
|
+
duration?: number;
|
|
36
|
+
/** Close button, Escape and swipe. Default true. */
|
|
37
|
+
dismissible?: boolean;
|
|
38
|
+
action?: ToastAction;
|
|
39
|
+
cancel?: ToastAction;
|
|
40
|
+
/** Replaces the type's icon; `null` hides it. */
|
|
41
|
+
icon?: ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Renders the whole toast body yourself. */
|
|
44
|
+
render?(props: ToastRenderProps): ReactNode;
|
|
45
|
+
/** Closed by the user or by code. */
|
|
46
|
+
onDismiss?(toast: ToastRecord): void;
|
|
47
|
+
/** Closed because its time ran out. */
|
|
48
|
+
onAutoClose?(toast: ToastRecord): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ToastRecord
|
|
52
|
+
extends Omit<ToastOptions, "id" | "type" | "duration" | "dismissible"> {
|
|
53
|
+
id: string;
|
|
54
|
+
type: ToastType;
|
|
55
|
+
title: ReactNode;
|
|
56
|
+
/** Always a positive number; `Infinity` when it stays until dismissed. */
|
|
57
|
+
duration: number;
|
|
58
|
+
dismissible: boolean;
|
|
59
|
+
/** `closing` while the exit animation plays, just before removal. */
|
|
60
|
+
state: "open" | "closing";
|
|
61
|
+
createdAt: number;
|
|
62
|
+
/** Goes up each time the same id is shown or updated. */
|
|
63
|
+
version: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ToastSnapshot {
|
|
67
|
+
/** Newest first, including toasts waiting beyond `limit`. */
|
|
68
|
+
toasts: readonly ToastRecord[];
|
|
69
|
+
/** Timers are held, e.g. while the pointer is over the toasts. */
|
|
70
|
+
paused: boolean;
|
|
71
|
+
/** How many open toasts are on screen at once. */
|
|
72
|
+
limit: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ToasterLocaleText {
|
|
76
|
+
regionLabel(hotkey: string): string;
|
|
77
|
+
close: string;
|
|
78
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Toaster } from "./react/Toaster";
|
|
2
|
+
export type { ToasterProps } from "./react/Toaster";
|
|
3
|
+
export { useToasts } from "./react/useToasts";
|
|
4
|
+
export { defaultToasterText } from "./react/locale";
|
|
5
|
+
export type { ToasterSlot } from "./react/props";
|
|
6
|
+
export * from "./core";
|