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 CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.2.11)
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.11)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.11",
3
+ "version": "1.2.12",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -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";