gbs-add-block 1.2.11 → 1.2.12
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 +3 -2
- package/index.cjs +1 -1
- package/package.json +1 -1
- 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
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# GBS Building Blocks 2.0 (v1.2.
|
|
1
|
+
# GBS Building Blocks 2.0 (v1.2.12)
|
|
2
2
|
|
|
3
3
|
Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
|
|
4
4
|
|
|
@@ -14,9 +14,10 @@ DataGrid and Combobox use the redesigned core API and design system. Install the
|
|
|
14
14
|
npx gbs-add-block -a DataGrid -beta
|
|
15
15
|
npx gbs-add-block -a Combobox -beta
|
|
16
16
|
npx gbs-add-block -a DatePicker -beta
|
|
17
|
+
npx gbs-add-block -a Toaster -beta
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
## What's New 🎉 (Ver 1.2.
|
|
20
|
+
## What's New 🎉 (Ver 1.2.12)
|
|
20
21
|
|
|
21
22
|
- Update Candidate for next major change 2.0.0
|
|
22
23
|
|
package/index.cjs
CHANGED
|
@@ -38,7 +38,7 @@ const CONFIG = {
|
|
|
38
38
|
betaComponents: ["DataGrid", "Combobox", "DatePicker"],
|
|
39
39
|
// Define component dependencies
|
|
40
40
|
dependencies: {
|
|
41
|
-
FormRenderer: ["Select", "MultiSelect", "Input", "DatePicker"],
|
|
41
|
+
FormRenderer: ["Select", "MultiSelect", "Input", "DatePicker", "toaster"],
|
|
42
42
|
},
|
|
43
43
|
docs: "https://gramprokit.vercel.app/",
|
|
44
44
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Toaster
|
|
2
|
+
|
|
3
|
+
Notifications for any React 19 app — Vite, Next.js, Remix or plain React — styled
|
|
4
|
+
to match the DataGrid, Combobox and DatePicker. No runtime dependencies besides
|
|
5
|
+
React.
|
|
6
|
+
|
|
7
|
+
- **`<Toaster />`** — mount once; renders the toasts.
|
|
8
|
+
- **`toast()`** — call from anywhere: components, event handlers, data layers,
|
|
9
|
+
even code outside React. No hook, context or provider.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { toast, Toaster } from "@/components/toaster";
|
|
15
|
+
import "@/components/toaster/styles.css";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
// Once, near the root of the app (e.g. app/layout.tsx or App.tsx)
|
|
20
|
+
<Toaster />
|
|
21
|
+
|
|
22
|
+
// Anywhere
|
|
23
|
+
toast.success("Settings saved");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Import from one path everywhere. `toast()` and `<Toaster />` meet through a
|
|
27
|
+
shared store, and a bundler that sees two import paths may create two stores.
|
|
28
|
+
|
|
29
|
+
## Showing toasts
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
toast("Event created", { description: "Monday, 10:00" });
|
|
33
|
+
toast.success("Saved");
|
|
34
|
+
toast.error("Upload failed", { action: { label: "Retry", onClick: retry } });
|
|
35
|
+
toast.warning("Storage almost full");
|
|
36
|
+
toast.info("New version available");
|
|
37
|
+
const id = toast.loading("Uploading…"); // stays until updated
|
|
38
|
+
|
|
39
|
+
toast.update(id, { type: "success", title: "Uploaded" });
|
|
40
|
+
toast.dismiss(id); // or toast.dismiss() for all
|
|
41
|
+
|
|
42
|
+
toast.promise(saveUser(data), {
|
|
43
|
+
loading: "Saving…",
|
|
44
|
+
success: (user) => `${user.name} saved`,
|
|
45
|
+
error: (error) => `Could not save: ${(error as Error).message}`,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
toast.custom(({ dismiss }) => <MyCard onClose={dismiss} />);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every call returns the toast's id. Passing an `id` that is already on screen
|
|
52
|
+
updates that toast instead of adding another.
|
|
53
|
+
|
|
54
|
+
## Toast options
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Description |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `id` | `string` | generated | Reuse to update a toast in place. |
|
|
59
|
+
| `type` | `"default"` \| `"success"` \| `"error"` \| `"warning"` \| `"info"` \| `"loading"` | `"default"` | Icon, color bar and announcement. |
|
|
60
|
+
| `description` | `ReactNode` | — | Second line. |
|
|
61
|
+
| `duration` | `number` | `5000` (loading: stays) | Milliseconds. `0` or `Infinity` stays until dismissed. |
|
|
62
|
+
| `dismissible` | `boolean` | `true` | Close button, Escape and swipe. |
|
|
63
|
+
| `action` / `cancel` | `{ label, onClick(event) }` | — | Buttons. The toast closes after a click unless `event.preventDefault()` is called. |
|
|
64
|
+
| `icon` | `ReactNode` | the type's icon | `null` hides it. |
|
|
65
|
+
| `className` | `string` | — | Class for this toast. |
|
|
66
|
+
| `render` | `({ id, dismiss }) => ReactNode` | — | Custom body (`toast.custom` sets it). |
|
|
67
|
+
| `onDismiss` / `onAutoClose` | `(toast) => void` | — | Closed by someone, or because time ran out. |
|
|
68
|
+
|
|
69
|
+
## Toaster props
|
|
70
|
+
|
|
71
|
+
| Prop | Type | Default | Description |
|
|
72
|
+
| --- | --- | --- | --- |
|
|
73
|
+
| `position` | `"top-left"` \| `"top-center"` \| `"top-right"` \| `"bottom-left"` \| `"bottom-center"` \| `"bottom-right"` | `"top-right"` | Where the stack sits. Full width on narrow screens. |
|
|
74
|
+
| `limit` | `number` | `3` | Toasts on screen at once. The rest wait, with their timers held. |
|
|
75
|
+
| `duration` | `number` | `5000` | Default auto-close delay. |
|
|
76
|
+
| `closeButton` | `boolean` | `true` | Close button on dismissible toasts. |
|
|
77
|
+
| `hotkey` | `string[]` | `["altKey", "KeyT"]` | Moves focus to the notifications. `[]` turns it off. |
|
|
78
|
+
| `icons` | `Partial<Record<ToastType, ReactNode>>` | — | Replace icons per type; `null` hides one. |
|
|
79
|
+
| `store` | `ToastStore` | the `toast()` store | For a separate notification area. |
|
|
80
|
+
| `dir` | `"ltr"` \| `"rtl"` \| `"auto"` | inherited | Text direction. |
|
|
81
|
+
| `className`, `classNames`, `style` | — | — | Slots: `region`, `list`, `toast`, `icon`, `content`, `title`, `description`, `actions`, `action`, `cancel`, `close`. |
|
|
82
|
+
| `localeText` | `Partial<ToasterLocaleText>` | English | `regionLabel(hotkey)`, `close`. |
|
|
83
|
+
|
|
84
|
+
## Behavior
|
|
85
|
+
|
|
86
|
+
- Timers pause while the pointer is over the toasts, while focus is inside them,
|
|
87
|
+
and while the browser tab is hidden.
|
|
88
|
+
- Toasts beyond `limit` wait with their full time and appear as others close.
|
|
89
|
+
- Toasts render in a portal on `<body>`, so no parent's overflow, transform or
|
|
90
|
+
z-index can hide them.
|
|
91
|
+
|
|
92
|
+
## Keyboard and accessibility
|
|
93
|
+
|
|
94
|
+
| Keys | Action |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| **Alt + T** | Move focus to the notifications (configurable with `hotkey`). |
|
|
97
|
+
| **Tab** | Move between toasts and their buttons. |
|
|
98
|
+
| **Escape** | Dismiss the focused toast. |
|
|
99
|
+
| swipe sideways | Dismiss (touch, pen or mouse). |
|
|
100
|
+
|
|
101
|
+
The list is a polite live region, always in the page, so new toasts are read out
|
|
102
|
+
without interrupting. Error toasts use `role="alert"` and interrupt. The region is
|
|
103
|
+
a labelled landmark ("Notifications (Alt+T)").
|
|
104
|
+
|
|
105
|
+
## Theming
|
|
106
|
+
|
|
107
|
+
Override `--ts-*` variables (they default to the grid's `--dg-*`). Toasts render
|
|
108
|
+
on `<body>`, so set shared `--dg-*` values on `:root`:
|
|
109
|
+
|
|
110
|
+
```css
|
|
111
|
+
.ts-region { --ts-width: 420px; --ts-success: #059669; --ts-radius: 12px; }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
State attributes: `data-position` on the region; `data-type`, `data-state`
|
|
115
|
+
(`open` / `closing`), `data-custom`, `data-swiping` on toasts.
|
|
116
|
+
|
|
117
|
+
## Headless use
|
|
118
|
+
|
|
119
|
+
`useToasts(store?)` returns the live snapshot (`toasts`, `paused`, `limit`) for a
|
|
120
|
+
custom UI, and `visibleToasts(snapshot)` picks the ones to show.
|
|
121
|
+
`createToastStore()` and `createToastApi(store)` build separate instances. All of
|
|
122
|
+
`core` runs without React.
|
|
123
|
+
|
|
124
|
+
## Known limits
|
|
125
|
+
|
|
126
|
+
- One stack per `<Toaster />`; toasts don't collapse into an expandable pile.
|
|
127
|
+
- `toast()` does nothing during a server render or in server code. Call it in
|
|
128
|
+
the browser, e.g. after a Server Action resolves.
|
|
@@ -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";
|