@enigmax/primitives 0.20.0 → 0.22.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,193 @@
1
+ import React from "react";
2
+
3
+ export type ToastTypes = "normal" | "action" | "success" | "info" | "warning" | "error" | "loading" | "default";
4
+
5
+ export type PromiseT<Data = any> = Promise<Data> | (() => Promise<Data>);
6
+
7
+ export type PromiseTResult<Data = any> = string | React.ReactNode | ((data: Data) => React.ReactNode | string | Promise<React.ReactNode | string>);
8
+
9
+ export type PromiseExternalToast = Omit<ExternalToast, "description">;
10
+
11
+ export type PromiseData<ToastData = any> = PromiseExternalToast & {
12
+ loading?: string | React.ReactNode;
13
+ success?: PromiseTResult<ToastData>;
14
+ error?: PromiseTResult;
15
+ description?: PromiseTResult;
16
+ finally?: () => void | Promise<void>;
17
+ };
18
+
19
+ export interface ToastClassnames {
20
+ toast?: string;
21
+ title?: string;
22
+ description?: string;
23
+ loader?: string;
24
+ closeButton?: string;
25
+ cancelButton?: string;
26
+ actionButton?: string;
27
+ success?: string;
28
+ error?: string;
29
+ info?: string;
30
+ warning?: string;
31
+ loading?: string;
32
+ default?: string;
33
+ content?: string;
34
+ icon?: string;
35
+ }
36
+
37
+ export interface ToastIcons {
38
+ success?: React.ReactNode;
39
+ info?: React.ReactNode;
40
+ warning?: React.ReactNode;
41
+ error?: React.ReactNode;
42
+ loading?: React.ReactNode;
43
+ close?: React.ReactNode;
44
+ }
45
+
46
+ export interface Action {
47
+ label: React.ReactNode;
48
+ onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
49
+ actionButtonStyle?: React.CSSProperties;
50
+ }
51
+
52
+ export interface ToastT {
53
+ id: number | string;
54
+ title?: (() => React.ReactNode) | React.ReactNode;
55
+ type?: ToastTypes;
56
+ icon?: React.ReactNode;
57
+ jsx?: React.ReactNode;
58
+ richColors?: boolean;
59
+ invert?: boolean;
60
+ closeButton?: boolean;
61
+ dismissible?: boolean;
62
+ description?: (() => React.ReactNode) | React.ReactNode;
63
+ duration?: number;
64
+ delete?: boolean;
65
+ action?: Action | React.ReactNode;
66
+ cancel?: Action | React.ReactNode;
67
+ onDismiss?: (toast: ToastT) => void;
68
+ onAutoClose?: (toast: ToastT) => void;
69
+ promise?: PromiseT;
70
+ cancelButtonStyle?: React.CSSProperties;
71
+ actionButtonStyle?: React.CSSProperties;
72
+ style?: React.CSSProperties;
73
+ unstyled?: boolean;
74
+ className?: string;
75
+ classNames?: ToastClassnames;
76
+ descriptionClassName?: string;
77
+ position?: Position;
78
+ }
79
+
80
+ export function isAction(action: Action | React.ReactNode): action is Action {
81
+ return (action as Action).label !== undefined;
82
+ }
83
+
84
+ export type Position = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center";
85
+ export interface HeightT {
86
+ height: number;
87
+ toastId: number | string;
88
+ position: Position;
89
+ }
90
+
91
+ interface ToastOptions {
92
+ className?: string;
93
+ closeButton?: boolean;
94
+ descriptionClassName?: string;
95
+ style?: React.CSSProperties;
96
+ cancelButtonStyle?: React.CSSProperties;
97
+ actionButtonStyle?: React.CSSProperties;
98
+ duration?: number;
99
+ unstyled?: boolean;
100
+ classNames?: ToastClassnames;
101
+ }
102
+
103
+ type Offset =
104
+ | {
105
+ top?: string | number;
106
+ right?: string | number;
107
+ bottom?: string | number;
108
+ left?: string | number;
109
+ }
110
+ | string
111
+ | number;
112
+
113
+ export interface ToasterProps {
114
+ invert?: boolean;
115
+ theme?: "light" | "dark" | "system";
116
+ position?: Position;
117
+ hotkey?: string[];
118
+ richColors?: boolean;
119
+ expand?: boolean;
120
+ duration?: number;
121
+ gap?: number;
122
+ visibleToasts?: number;
123
+ closeButton?: boolean;
124
+ toastOptions?: ToastOptions;
125
+ className?: string;
126
+ style?: React.CSSProperties;
127
+ offset?: Offset;
128
+ mobileOffset?: Offset;
129
+ dir?: "rtl" | "ltr" | "auto";
130
+ swipeDirections?: SwipeDirection[];
131
+ /**
132
+ * @deprecated Please use the `icons` prop instead:
133
+ * ```jsx
134
+ * <Toaster
135
+ * icons={{ loading: <LoadingIcon /> }}
136
+ * />
137
+ * ```
138
+ */
139
+ loadingIcon?: React.ReactNode;
140
+ icons?: ToastIcons;
141
+ containerAriaLabel?: string;
142
+ pauseWhenPageIsHidden?: boolean;
143
+ }
144
+
145
+ export type SwipeDirection = "top" | "right" | "bottom" | "left";
146
+
147
+ export interface ToastProps {
148
+ toast: ToastT;
149
+ toasts: ToastT[];
150
+ index: number;
151
+ swipeDirections?: SwipeDirection[];
152
+ expanded: boolean;
153
+ invert: boolean;
154
+ heights: HeightT[];
155
+ setHeights: React.Dispatch<React.SetStateAction<HeightT[]>>;
156
+ removeToast: (toast: ToastT) => void;
157
+ gap?: number;
158
+ position: Position;
159
+ visibleToasts: number;
160
+ expandByDefault: boolean;
161
+ closeButton: boolean;
162
+ interacting: boolean;
163
+ style?: React.CSSProperties;
164
+ cancelButtonStyle?: React.CSSProperties;
165
+ actionButtonStyle?: React.CSSProperties;
166
+ duration?: number;
167
+ className?: string;
168
+ unstyled?: boolean;
169
+ descriptionClassName?: string;
170
+ loadingIcon?: React.ReactNode;
171
+ classNames?: ToastClassnames;
172
+ icons?: ToastIcons;
173
+ closeButtonAriaLabel?: string;
174
+ pauseWhenPageIsHidden: boolean;
175
+ defaultRichColors?: boolean;
176
+ }
177
+
178
+ export enum SwipeStateTypes {
179
+ SwipedOut = "SwipedOut",
180
+ SwipedBack = "SwipedBack",
181
+ NotSwiped = "NotSwiped"
182
+ }
183
+
184
+ export type Theme = "light" | "dark";
185
+
186
+ export interface ToastToDismiss {
187
+ id: number | string;
188
+ dismiss: boolean;
189
+ }
190
+
191
+ export type ExternalToast = Omit<ToastT, "id" | "type" | "title" | "jsx" | "delete" | "promise"> & {
192
+ id?: number | string;
193
+ };
@@ -1,238 +1,126 @@
1
1
  "use client";
2
2
 
3
+ import { TOAST_STYLES } from "@/react/toast/styles";
3
4
  import { defaultQueue } from "@/react/use-notifications";
5
+ import { toast as vendorToast } from "@/react/toast/state";
6
+ import { Toaster as VendorToaster } from "@/react/toast/index";
4
7
  import type { Notification, Notifications } from "@/core/notifications";
5
- import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
8
+ import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
9
+ import type { ToasterProps as VendorToasterProps } from "@/react/toast/types";
6
10
 
7
11
  /**
8
- * `<Toaster />` - the stack, the movement and the gestures. No colours.
12
+ * `<Toaster />` - the toast stack, and the one component in this package that ships a look.
9
13
  *
10
- * The queue underneath already owns ordering, dedupe by key, sticky errors and timers that
11
- * HOLD while the tab is hidden. What this adds is everything you cannot express in a queue:
12
- * a toast that is on its way out is still on screen, a pointer resting on the stack must
13
- * stop the clock, and a swipe has to follow the finger before it decides anything.
14
+ * The implementation under `react/toast/` is vendored and deliberately unedited: it is the
15
+ * toast these projects already use, and a second one that merely resembled it would be a
16
+ * different thing on every screen. Two mechanical changes were unavoidable to make it a
17
+ * library - the `cn` helper became a local file rather than a path alias, and the
18
+ * `import "./styles.css"` became the injection below, because a package cannot assume its
19
+ * consumer has a CSS-capable bundler.
14
20
  *
15
- * Every part carries a data attribute, so the theme is a stylesheet you own. `enigma add
16
- * toast --copy` writes one.
21
+ * WHY IT SHIPS STYLES. Everywhere else here a missing stylesheet gives you an unstyled
22
+ * component you can see and fix. A toast renders at the edge of the screen, stacked and
23
+ * animated, so the same omission gives you a pile of text in a corner - and "remember to
24
+ * import the CSS" is a footgun for something that appears once every few minutes. The sheet
25
+ * is injected once and PREPENDED to `<head>`, so anything the document already has outranks
26
+ * it by source order without a single `!important`; `styles={false}` turns it off, and
27
+ * `@enigmax/primitives/toast.css` is the same sheet for anyone who prefers the import.
17
28
  */
18
29
 
19
- export type ToastPosition =
20
- | "top-left" | "top-center" | "top-right"
21
- | "bottom-left" | "bottom-center" | "bottom-right";
30
+ let injected = false;
22
31
 
23
- export interface ToasterProps {
24
- /** Which queue to render. The shared one by default. */
25
- queue?: Notifications;
26
- position?: ToastPosition;
27
- /**
28
- * How long the exit animation lasts, in ms. The node stays mounted for exactly this
29
- * long after it leaves the queue - a toast that vanishes the instant it is dismissed
30
- * cannot animate out, and that is the only reason this component keeps its own list.
31
- */
32
- exitDuration?: number;
33
- /** Px a toast must travel before the release dismisses it. */
34
- swipeThreshold?: number;
35
- /** Render a toast yourself. The default renders title, body and the action. */
36
- children?: (notification: Notification, controls: ToastControls) => ReactNode;
37
- /** Accessible name for the region. */
38
- label?: string;
39
- className?: string;
40
- style?: CSSProperties;
41
- }
42
-
43
- export interface ToastControls {
44
- dismiss: () => void;
32
+ function injectStyles(): void {
33
+ if (injected || typeof document === "undefined") return;
34
+ injected = true;
35
+ if (document.querySelector("[data-enigma-toast-styles]")) return;
36
+ const element = document.createElement("style");
37
+ element.setAttribute("data-enigma-toast-styles", "");
38
+ element.textContent = TOAST_STYLES;
39
+ document.head.prepend(element);
45
40
  }
46
41
 
47
- interface Rendered {
48
- notification: Notification;
49
- /** Set once it has left the queue and is animating out. */
50
- leaving: boolean;
42
+ export interface ToasterProps extends VendorToasterProps {
43
+ /**
44
+ * Inject the stylesheet. On by default - see the note above. `false` leaves the markup
45
+ * bare for a theme of your own.
46
+ */
47
+ styles?: boolean;
48
+ /**
49
+ * A notification QUEUE to render, alongside anything raised with `toast()`.
50
+ *
51
+ * This is the bridge for `useNotifications().notify()`, which predates the vendored
52
+ * component: everything already written against the queue keeps working, and both APIs
53
+ * end up in one stack rather than in two competing ones. `null` unsubscribes.
54
+ */
55
+ queue?: Notifications | null;
51
56
  }
52
57
 
53
- /** Swipe follows the edge the stack is pinned to, so a toast never slides across the page. */
54
- function swipeAxis(position: ToastPosition): { axis: "x" | "y"; sign: number; } {
55
- if (position.endsWith("-center")) return { axis: "y", sign: position.startsWith("top") ? -1 : 1 };
56
- return { axis: "x", sign: position.endsWith("-left") ? -1 : 1 };
57
- }
58
+ export function Toaster({ styles = true, queue = defaultQueue, ...props }: ToasterProps): ReactNode {
59
+ // Before paint: a sheet applied after the first frame shows the toast unstyled first.
60
+ useLayoutEffect(() => { if (styles) injectStyles(); }, [styles]);
58
61
 
59
- export function Toaster({
60
- queue = defaultQueue,
61
- position = "bottom-right",
62
- exitDuration = 200,
63
- swipeThreshold = 60,
64
- children,
65
- label = "Notifications",
66
- className,
67
- style
68
- }: ToasterProps): ReactNode {
69
- // Seeded from the queue rather than empty: a notify() that happened before this mounted
70
- // - during a redirect, or from a module that runs early - would otherwise be invisible
71
- // for a frame. On a server the queue is empty, so this still renders nothing there.
72
- const [rendered, setRendered] = useState<Rendered[]>(
73
- () => queue.items.map((notification) => ({ notification, leaving: false }))
74
- );
75
- const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
62
+ /** Queue id -> the toast raised for it, so a dismissal reaches the right one. */
63
+ const forwarded = useRef(new Map<string, string | number>());
76
64
 
77
65
  useEffect(() => {
66
+ if (!queue) return;
78
67
  const sync = (items: readonly Notification[]): void => {
79
- setRendered((current) => {
80
- const live = new Map(items.map((item) => [item.id, item]));
81
- const kept = current.map((entry) => {
82
- const still = live.get(entry.notification.id);
83
- if (still) return { notification: still, leaving: false };
84
- if (entry.leaving) return entry;
85
-
86
- // It has just left the queue: hold the node for the exit animation, and
87
- // only then drop it. Without this the stack jumps.
88
- const timer = setTimeout(() => {
89
- timers.current.delete(entry.notification.id);
90
- setRendered((later) => later.filter((row) => row.notification.id !== entry.notification.id));
91
- }, exitDuration);
92
- timers.current.set(entry.notification.id, timer);
93
- return { ...entry, leaving: true };
94
- });
95
-
96
- const seen = new Set(kept.map((entry) => entry.notification.id));
97
- const added = items.filter((item) => !seen.has(item.id)).map((item) => ({ notification: item, leaving: false }));
98
- return [...kept, ...added];
99
- });
68
+ const live = new Set(items.map((item) => item.id));
69
+
70
+ for (const item of items) {
71
+ if (forwarded.current.has(item.id)) continue;
72
+ forwarded.current.set(item.id, raise(item));
73
+ }
74
+ // Dismissed in the queue is dismissed on screen: the queue owns its own timers,
75
+ // and a toast that outlived the item behind it would be showing a state that no
76
+ // longer exists.
77
+ for (const [id, toastId] of forwarded.current) {
78
+ if (live.has(id)) continue;
79
+ vendorToast.dismiss(toastId);
80
+ forwarded.current.delete(id);
81
+ }
100
82
  };
101
83
 
102
84
  sync(queue.items);
103
85
  return queue.subscribe(sync);
104
- }, [queue, exitDuration]);
86
+ }, [queue]);
105
87
 
106
- // Clearing on unmount only: a timer per exiting toast is cheap, and clearing them on
107
- // every render would cancel the exit that is currently running.
108
- useEffect(() => {
109
- const pending = timers.current;
110
- return () => {
111
- for (const timer of pending.values()) clearTimeout(timer);
112
- pending.clear();
113
- };
114
- }, []);
115
-
116
- if (!rendered.length) return null;
117
-
118
- return (
119
- <section
120
- aria-label={label}
121
- data-enigma-toaster=""
122
- data-position={position}
123
- className={className}
124
- style={style}
125
- // A pointer resting on the stack stops every clock, so a message cannot expire
126
- // while it is being read. Focus counts too, for anyone using a keyboard.
127
- onPointerEnter={() => queue.pause()}
128
- onPointerLeave={() => queue.resume()}
129
- onFocusCapture={() => queue.pause()}
130
- onBlurCapture={() => queue.resume()}
131
- >
132
- {rendered.map((entry, index) => (
133
- <Toast
134
- key={entry.notification.id}
135
- entry={entry}
136
- index={rendered.length - 1 - index}
137
- position={position}
138
- swipeThreshold={swipeThreshold}
139
- onDismiss={() => queue.dismiss(entry.notification.id)}
140
- render={children}
141
- />
142
- ))}
143
- </section>
144
- );
88
+ return <VendorToaster {...props} />;
145
89
  }
146
90
 
147
- interface ToastProps {
148
- entry: Rendered;
149
- /** 0 is the front of the stack. Drives the offset and scale in the theme. */
150
- index: number;
151
- position: ToastPosition;
152
- swipeThreshold: number;
153
- onDismiss: () => void;
154
- render?: (notification: Notification, controls: ToastControls) => ReactNode;
91
+ /** One queue item, as the vendored toast API expects it. */
92
+ function raise(item: Notification): string | number {
93
+ const options = {
94
+ description: item.body,
95
+ // The queue already counts down and removes the item; leaving the toast's own timer
96
+ // running as well would race it, and the shorter of the two would win at random.
97
+ duration: Infinity,
98
+ action: item.action
99
+ ? { label: item.action.label, onClick: () => item.action?.onSelect() }
100
+ : undefined
101
+ };
102
+
103
+ switch (item.tone) {
104
+ case "success": return vendorToast.success(item.title, options);
105
+ case "error": return vendorToast.error(item.title, options);
106
+ case "warning": return vendorToast.warning(item.title, options);
107
+ case "info": return vendorToast.info(item.title, options);
108
+ case "loading": return vendorToast.loading(item.title, options);
109
+ default: return vendorToast(item.title, options);
110
+ }
155
111
  }
156
112
 
157
- function Toast({ entry, index, position, swipeThreshold, onDismiss, render }: ToastProps): ReactNode {
158
- const { notification, leaving } = entry;
159
- const [offset, setOffset] = useState(0);
160
- const [swiping, setSwiping] = useState(false);
161
- const start = useRef(0);
162
- const { axis, sign } = swipeAxis(position);
163
-
164
- const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
165
- // Not on the buttons: a press on "Undo" is a press, not the start of a drag.
166
- if ((event.target as HTMLElement).closest("button")) return;
167
- if (event.pointerType === "mouse" && event.button !== 0) return;
168
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
169
- start.current = axis === "x" ? event.clientX : event.clientY;
170
- setSwiping(true);
171
- }, [axis]);
172
-
173
- const onPointerMove = useCallback((event: React.PointerEvent<HTMLElement>) => {
174
- if (!swiping) return;
175
- const delta = (axis === "x" ? event.clientX : event.clientY) - start.current;
176
- // Only towards the edge it is pinned to. Dragging the other way does nothing, so a
177
- // scroll or a misread gesture cannot pull the toast into the middle of the page.
178
- setOffset(sign > 0 ? Math.max(0, delta) : Math.min(0, delta));
179
- }, [swiping, axis, sign]);
180
-
181
- const onPointerUp = useCallback(() => {
182
- if (!swiping) return;
183
- setSwiping(false);
184
- if (Math.abs(offset) >= swipeThreshold) {
185
- onDismiss();
186
- return;
187
- }
188
- setOffset(0); // Under the threshold it springs back, so nothing is lost by accident.
189
- }, [swiping, offset, swipeThreshold, onDismiss]);
113
+ /** The vendored API, which is the direct way to raise one. */
114
+ export { toast } from "@/react/toast/state";
115
+ export { useSonner } from "@/react/toast/index";
116
+ export type { ToastT, ExternalToast, ToastClassnames, Action } from "@/react/toast/types";
190
117
 
191
- const controls: ToastControls = { dismiss: onDismiss };
192
-
193
- return (
194
- <article
195
- data-enigma-toast=""
196
- data-tone={notification.tone}
197
- data-state={leaving ? "leaving" : "open"}
198
- data-index={index}
199
- data-front={index === 0 ? "" : undefined}
200
- data-swiping={swiping ? "" : undefined}
201
- // An error interrupts; anything else waits its turn rather than talking over
202
- // whatever a screen reader is in the middle of.
203
- role={notification.tone === "error" ? "alert" : "status"}
204
- aria-live={notification.tone === "error" ? "assertive" : "polite"}
205
- tabIndex={0}
206
- onKeyDown={(event) => { if (event.key === "Escape") onDismiss(); }}
207
- onPointerDown={onPointerDown}
208
- onPointerMove={onPointerMove}
209
- onPointerUp={onPointerUp}
210
- onPointerCancel={onPointerUp}
211
- style={{
212
- // Read by the theme, so the transform stays the stylesheet's decision.
213
- "--enigma-toast-swipe": `${offset}px`,
214
- "--enigma-toast-index": index
215
- } as CSSProperties}
216
- >
217
- {render
218
- ? render(notification, controls)
219
- : (
220
- <>
221
- <p data-enigma-toast-title="">{notification.title}</p>
222
- {notification.body && <p data-enigma-toast-body="">{notification.body}</p>}
223
- {notification.action && (
224
- <button
225
- type="button"
226
- data-enigma-toast-action=""
227
- onClick={() => {
228
- notification.action?.onSelect();
229
- if (notification.action?.dismiss !== false) onDismiss();
230
- }}
231
- >{notification.action.label}</button>
232
- )}
233
- <button type="button" data-enigma-toast-close="" aria-label="Dismiss" onClick={onDismiss}>&times;</button>
234
- </>
235
- )}
236
- </article>
237
- );
238
- }
118
+ /**
119
+ * The queue, from the entry that renders it.
120
+ *
121
+ * `@enigmax/primitives/react/toast` is where somebody looks for both halves - mounting the
122
+ * stack and telling it something - and sending them to a second subpath for the second half
123
+ * is the kind of split that makes an import line something to look up.
124
+ */
125
+ export { useNotifications, createNotificationQueue, defaultQueue } from "@/react/use-notifications";
126
+ export type { Notification, NotificationInput, NotificationTone, NotificationAction, Notifications } from "@/core/notifications";