@enigmax/primitives 0.21.0 → 0.23.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.
Files changed (83) hide show
  1. package/dist/chunk-3BQVOOAM.js +388 -0
  2. package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
  3. package/dist/chunk-4VUHQFAT.js +130 -0
  4. package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
  5. package/dist/chunk-DSBYVA7V.js +713 -0
  6. package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
  7. package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
  8. package/dist/chunk-N6PDHMAX.js +213 -0
  9. package/dist/chunk-NONGREXC.js +248 -0
  10. package/dist/chunk-VKL3DEIQ.js +804 -0
  11. package/dist/chunk-WPTBURIC.js +1627 -0
  12. package/dist/chunk-WSQC3PCC.js +466 -0
  13. package/dist/context-menu-D3FtTn7v.d.ts +174 -0
  14. package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.js +8 -4
  17. package/dist/keys-D2zJs1uB.d.ts +100 -0
  18. package/dist/next/index.d.ts +12 -5
  19. package/dist/next/index.js +21 -14
  20. package/dist/react/button.d.ts +2 -2
  21. package/dist/react/context-menu.d.ts +202 -0
  22. package/dist/react/context-menu.js +6 -0
  23. package/dist/react/index.d.ts +11 -4
  24. package/dist/react/index.js +20 -13
  25. package/dist/react/input.d.ts +2 -2
  26. package/dist/react/input.js +1 -1
  27. package/dist/react/palette.d.ts +1 -1
  28. package/dist/react/palette.js +3 -3
  29. package/dist/react/search.d.ts +1 -1
  30. package/dist/react/search.js +2 -2
  31. package/dist/react/select.d.ts +217 -0
  32. package/dist/react/select.js +7 -0
  33. package/dist/react/selection.d.ts +104 -0
  34. package/dist/react/selection.js +4 -0
  35. package/dist/react/slot.d.ts +2 -2
  36. package/dist/react/toast.d.ts +165 -32
  37. package/dist/react/toast.js +1 -1
  38. package/dist/react-router/index.d.ts +12 -5
  39. package/dist/react-router/index.js +21 -14
  40. package/dist/search/index.d.ts +2 -2
  41. package/dist/search/index.js +1 -1
  42. package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
  43. package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
  44. package/dist/select-ClSy-J1f.d.ts +100 -0
  45. package/dist/selection-B_pmzHpy.d.ts +150 -0
  46. package/package.json +21 -2
  47. package/recipes/context-menu/styles.css +177 -0
  48. package/recipes/input/styles.css +9 -0
  49. package/recipes/palette/styles.css +3 -0
  50. package/recipes/search/tailwind.tsx +3 -2
  51. package/recipes/select/styles.css +230 -0
  52. package/recipes/toast/styles.css +688 -192
  53. package/registry.json +434 -29
  54. package/src/core/context-menu.ts +694 -0
  55. package/src/core/keys.ts +264 -0
  56. package/src/core/search.ts +25 -1
  57. package/src/core/select.ts +404 -0
  58. package/src/core/selection.ts +648 -0
  59. package/src/index.ts +60 -1
  60. package/src/react/context-menu/context.ts +57 -0
  61. package/src/react/context-menu/index.tsx +94 -0
  62. package/src/react/context-menu/root.tsx +846 -0
  63. package/src/react/context-menu/styles.ts +186 -0
  64. package/src/react/index.ts +70 -2
  65. package/src/react/palette/root.tsx +2 -2
  66. package/src/react/select/context.ts +56 -0
  67. package/src/react/select/index.tsx +96 -0
  68. package/src/react/select/root.tsx +839 -0
  69. package/src/react/select/styles.ts +238 -0
  70. package/src/react/selection/index.tsx +115 -0
  71. package/src/react/selection/use-selection.ts +260 -0
  72. package/src/react/toast/NOTICE +20 -0
  73. package/src/react/toast/assets.tsx +85 -0
  74. package/src/react/toast/cn.ts +10 -0
  75. package/src/react/toast/hooks.ts +13 -0
  76. package/src/react/toast/index.tsx +736 -0
  77. package/src/react/toast/state.ts +207 -0
  78. package/src/react/toast/styles.ts +738 -0
  79. package/src/react/toast/types.ts +193 -0
  80. package/src/react/toaster.tsx +76 -341
  81. package/dist/chunk-JCCL7XKC.js +0 -465
  82. package/src/react/toast-styles.ts +0 -247
  83. /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
@@ -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,43 +1,34 @@
1
1
  "use client";
2
2
 
3
- import { TOAST_STYLES } from "@/react/toast-styles";
3
+ import { TOAST_STYLES } from "@/react/toast/styles";
4
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";
5
7
  import type { Notification, Notifications } from "@/core/notifications";
6
- import { useCallback, useEffect, useLayoutEffect, 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";
7
10
 
8
11
  /**
9
- * `<Toaster />` - the stack, the movement, the gestures and, unlike the rest of this
10
- * package, a look.
12
+ * `<Toaster />` - the toast stack, and the one component in this package that ships a look.
11
13
  *
12
- * The queue underneath already owns ordering, dedupe by key, sticky errors and timers that
13
- * HOLD while the tab is hidden. What this adds is everything you cannot express in a queue:
14
- * a toast on its way out is still on screen, a pointer resting on the stack stops the clock,
15
- * a swipe follows the finger before it decides anything, and the stack itself is depth - the
16
- * newest toast in front, the older ones behind it, clipped to its height and fanning out
17
- * when the pointer arrives.
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.
18
20
  *
19
- * WHY THIS ONE SHIPS STYLES. Everywhere else in this package a missing stylesheet gives you
20
- * an unstyled component you can see and fix. A toast renders at the edge of the screen,
21
- * stacked and animated, so the same omission gives you a pile of text in a corner - and
22
- * "remember to import the CSS" is a footgun to hand somebody for a component that appears
23
- * once every few minutes. The theme is injected once, PREPENDED to `<head>` so anything the
24
- * document already has outranks it by source order, and `styles={false}` turns it off.
25
- *
26
- * Every part still carries a data attribute, so overriding any of it is a selector rather
27
- * than a fork, and `enigma add toast --copy` writes the same stylesheet out to edit.
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.
28
28
  */
29
29
 
30
- /** How many toasts are on screen at once. The rest wait behind, counted but not drawn. */
31
- const VISIBLE = 3;
32
-
33
30
  let injected = false;
34
31
 
35
- /**
36
- * The theme, once per document.
37
- *
38
- * Prepended rather than appended: at equal specificity the LAST rule wins, so putting this
39
- * first means a consumer stylesheet always beats it without a single `!important`.
40
- */
41
32
  function injectStyles(): void {
42
33
  if (injected || typeof document === "undefined") return;
43
34
  injected = true;
@@ -48,344 +39,88 @@ function injectStyles(): void {
48
39
  document.head.prepend(element);
49
40
  }
50
41
 
51
- const ICONS: Record<string, ReactNode> = {
52
- success: <path d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.86-9.81a.75.75 0 00-1.22-.88l-3.48 4.79-1.88-1.88a.75.75 0 10-1.06 1.06l2.5 2.5a.75.75 0 001.14-.09l4-5.5z" />,
53
- error: <path d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" />,
54
- warning: <path d="M9.4 3a1.5 1.5 0 012.6 0l7.36 12.75A1.5 1.5 0 0118.06 18H3.94a1.5 1.5 0 01-1.3-2.25L10 3zm.85 4.5a.75.75 0 00-1.5 0v4a.75.75 0 001.5 0v-4zM10 15a.9.9 0 100-1.8.9.9 0 000 1.8z" />,
55
- info: <path d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.25a.25.25 0 01.25.3l-.46 2.07A1.75 1.75 0 0010.75 15H11a.75.75 0 000-1.5h-.25a.25.25 0 01-.25-.3l.46-2.07A1.75 1.75 0 009.25 9H9z" />
56
- };
57
-
58
- /** The tone's glyph, or the spinner a loading toast turns into. */
59
- function ToneIcon({ tone }: { tone?: string; }): ReactNode {
60
- if (!tone || tone === "default") return null;
61
- if (tone === "loading") {
62
- return (
63
- <span data-enigma-toast-icon="" data-tone="loading">
64
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
65
- <path d="M21 12a9 9 0 1 1-6.22-8.56" opacity="0.85" />
66
- </svg>
67
- </span>
68
- );
69
- }
70
- const glyph = ICONS[tone];
71
- if (!glyph) return null;
72
- return (
73
- <span data-enigma-toast-icon="" data-tone={tone}>
74
- <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">{glyph}</svg>
75
- </span>
76
- );
77
- }
78
-
79
- export type ToastPosition =
80
- | "top-left" | "top-center" | "top-right"
81
- | "bottom-left" | "bottom-center" | "bottom-right";
82
-
83
- export interface ToasterProps {
84
- /** Which queue to render. The shared one by default. */
85
- queue?: Notifications;
86
- position?: ToastPosition;
42
+ export interface ToasterProps extends VendorToasterProps {
87
43
  /**
88
- * How long the exit animation lasts, in ms. The node stays mounted for exactly this
89
- * long after it leaves the queue - a toast that vanishes the instant it is dismissed
90
- * cannot animate out, and that is the only reason this component keeps its own list.
44
+ * Inject the stylesheet. On by default - see the note above. `false` leaves the markup
45
+ * bare for a theme of your own.
91
46
  */
92
- exitDuration?: number;
93
- /** Px a toast must travel before the release dismisses it. */
94
- swipeThreshold?: number;
95
- /** Render a toast yourself. The default renders title, body and the action. */
96
- children?: (notification: Notification, controls: ToastControls) => ReactNode;
97
- /** How many toasts are drawn at once. The rest wait behind them. Default 3. */
98
- visibleCount?: number;
99
- /** Fan the stack out permanently, instead of only while the pointer is on it. */
100
- expand?: boolean;
47
+ styles?: boolean;
101
48
  /**
102
- * Inject the theme. On by default - see the note above; `false` leaves the markup
103
- * unstyled for your own stylesheet, and `@enigmax/primitives/toast.css` is the same
104
- * sheet if you would rather import it.
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.
105
54
  */
106
- styles?: boolean;
107
- /** Accessible name for the region. */
108
- label?: string;
109
- className?: string;
110
- style?: CSSProperties;
111
- }
112
-
113
- export interface ToastControls {
114
- dismiss: () => void;
115
- }
116
-
117
- interface Rendered {
118
- notification: Notification;
119
- /** Set once it has left the queue and is animating out. */
120
- leaving: boolean;
121
- }
122
-
123
- /** Swipe follows the edge the stack is pinned to, so a toast never slides across the page. */
124
- function swipeAxis(position: ToastPosition): { axis: "x" | "y"; sign: number; } {
125
- if (position.endsWith("-center")) return { axis: "y", sign: position.startsWith("top") ? -1 : 1 };
126
- return { axis: "x", sign: position.endsWith("-left") ? -1 : 1 };
55
+ queue?: Notifications | null;
127
56
  }
128
57
 
129
- export function Toaster({
130
- queue = defaultQueue,
131
- position = "bottom-right",
132
- exitDuration = 350,
133
- swipeThreshold = 60,
134
- visibleCount = VISIBLE,
135
- expand = false,
136
- styles: withStyles = true,
137
- children,
138
- label = "Notifications",
139
- className,
140
- style
141
- }: ToasterProps): ReactNode {
142
- // Seeded from the queue rather than empty: a notify() that happened before this mounted
143
- // - during a redirect, or from a module that runs early - would otherwise be invisible
144
- // for a frame. On a server the queue is empty, so this still renders nothing there.
145
- const [rendered, setRendered] = useState<Rendered[]>(
146
- () => queue.items.map((notification) => ({ notification, leaving: false }))
147
- );
148
- const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
149
- /** Fanned out: the pointer is on the stack, or focus is inside it. */
150
- const [hovered, setHovered] = useState(false);
151
- /** Measured per toast, because the fan-out offset is the SUM of the ones in front. */
152
- const [heights, setHeights] = useState<Record<string, number>>({});
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]);
153
61
 
154
- // Before paint, so the first frame of a new toast is already at its right offset. An
155
- // effect that ran after would show the stack settle into place.
156
- useLayoutEffect(() => { if (withStyles) injectStyles(); }, [withStyles]);
62
+ /** Queue id -> the toast raised for it, so a dismissal reaches the right one. */
63
+ const forwarded = useRef(new Map<string, string | number>());
157
64
 
158
65
  useEffect(() => {
66
+ if (!queue) return;
159
67
  const sync = (items: readonly Notification[]): void => {
160
- setRendered((current) => {
161
- const live = new Map(items.map((item) => [item.id, item]));
162
- const kept = current.map((entry) => {
163
- const still = live.get(entry.notification.id);
164
- if (still) return { notification: still, leaving: false };
165
- if (entry.leaving) return entry;
166
-
167
- // It has just left the queue: hold the node for the exit animation, and
168
- // only then drop it. Without this the stack jumps.
169
- const timer = setTimeout(() => {
170
- timers.current.delete(entry.notification.id);
171
- setRendered((later) => later.filter((row) => row.notification.id !== entry.notification.id));
172
- }, exitDuration);
173
- timers.current.set(entry.notification.id, timer);
174
- return { ...entry, leaving: true };
175
- });
176
-
177
- const seen = new Set(kept.map((entry) => entry.notification.id));
178
- const added = items.filter((item) => !seen.has(item.id)).map((item) => ({ notification: item, leaving: false }));
179
- return [...kept, ...added];
180
- });
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
+ }
181
82
  };
182
83
 
183
84
  sync(queue.items);
184
85
  return queue.subscribe(sync);
185
- }, [queue, exitDuration]);
186
-
187
- // Clearing on unmount only: a timer per exiting toast is cheap, and clearing them on
188
- // every render would cancel the exit that is currently running.
189
- useEffect(() => {
190
- const pending = timers.current;
191
- return () => {
192
- for (const timer of pending.values()) clearTimeout(timer);
193
- pending.clear();
194
- };
195
- }, []);
86
+ }, [queue]);
196
87
 
197
- if (!rendered.length) return null;
198
-
199
- const expanded = expand || hovered;
200
- // Newest first: index 0 is the front toast, and everything below is measured against it.
201
- const stack = [...rendered].reverse();
202
- const frontHeight = heights[stack[0]?.notification.id ?? ""] ?? 0;
203
-
204
- return (
205
- <section
206
- aria-label={label}
207
- data-enigma-toaster=""
208
- data-position={position}
209
- data-expanded={expanded ? "" : undefined}
210
- style={{ ...style, "--enigma-toast-front-height": `${frontHeight}px` } as CSSProperties}
211
- className={className}
212
- // A pointer resting on the stack stops every clock, so a message cannot expire
213
- // while it is being read, and fans it out so every one of them is readable.
214
- // Focus counts too, for anyone using a keyboard.
215
- onPointerEnter={() => { queue.pause(); setHovered(true); }}
216
- onPointerLeave={() => { queue.resume(); setHovered(false); }}
217
- onFocusCapture={() => { queue.pause(); setHovered(true); }}
218
- onBlurCapture={() => { queue.resume(); setHovered(false); }}
219
- >
220
- {stack.map((entry, index) => (
221
- <Toast
222
- key={entry.notification.id}
223
- entry={entry}
224
- index={index}
225
- hidden={index >= visibleCount}
226
- // The summed height of everything in front of it, plus one gap each -
227
- // which is what makes an expanded stack a list rather than a pile.
228
- offset={stack.slice(0, index).reduce((total, row) => total + (heights[row.notification.id] ?? 0) + 14, 0)}
229
- onMeasure={(height) => setHeights((current) => (
230
- current[entry.notification.id] === height
231
- ? current
232
- : { ...current, [entry.notification.id]: height }
233
- ))}
234
- position={position}
235
- swipeThreshold={swipeThreshold}
236
- onDismiss={() => queue.dismiss(entry.notification.id)}
237
- render={children}
238
- />
239
- ))}
240
- </section>
241
- );
88
+ return <VendorToaster {...props} />;
242
89
  }
243
90
 
244
- interface ToastProps {
245
- entry: Rendered;
246
- /** 0 is the front of the stack. Drives the offset and scale in the theme. */
247
- index: number;
248
- /** Past the visible count: still counted and still measured, just not drawn. */
249
- hidden: boolean;
250
- /** Px to lift it by when the stack is fanned out. */
251
- offset: number;
252
- /** Reports its own height, which is what every offset behind it is computed from. */
253
- onMeasure: (height: number) => void;
254
- position: ToastPosition;
255
- swipeThreshold: number;
256
- onDismiss: () => void;
257
- 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
+ }
258
111
  }
259
112
 
260
- function Toast({ entry, index, hidden, offset: stackOffset, onMeasure, position, swipeThreshold, onDismiss, render }: ToastProps): ReactNode {
261
- const { notification, leaving } = entry;
262
- const [offset, setOffset] = useState(0);
263
- const [swiping, setSwiping] = useState(false);
264
- /** Off for the first frame, so the enter animation has somewhere to come from. */
265
- const [mounted, setMounted] = useState(false);
266
- const node = useRef<HTMLElement | null>(null);
267
- const start = useRef(0);
268
- const { axis, sign } = swipeAxis(position);
269
-
270
- // Measured, not assumed: a toast is as tall as its text, and the offsets behind it are
271
- // the sum of those heights. A ResizeObserver rather than one reading, because a body
272
- // that wraps differently after a font loads changes every offset behind it.
273
- useLayoutEffect(() => {
274
- const element = node.current;
275
- if (!element) return;
276
- const report = (): void => onMeasure(element.getBoundingClientRect().height);
277
- report();
278
- if (typeof ResizeObserver === "undefined") return;
279
- const observer = new ResizeObserver(report);
280
- observer.observe(element);
281
- return () => observer.disconnect();
282
- // eslint-disable-next-line react-hooks/exhaustive-deps
283
- }, [notification.title, notification.body]);
284
-
285
- // One frame later: setting it in the same commit as the mount would give the browser no
286
- // "before" to animate from, and the toast would appear rather than arrive.
287
- useEffect(() => {
288
- const frame = requestAnimationFrame(() => setMounted(true));
289
- return () => cancelAnimationFrame(frame);
290
- }, []);
291
-
292
- const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
293
- // Not on the buttons: a press on "Undo" is a press, not the start of a drag.
294
- if ((event.target as HTMLElement).closest("button")) return;
295
- if (event.pointerType === "mouse" && event.button !== 0) return;
296
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
297
- start.current = axis === "x" ? event.clientX : event.clientY;
298
- setSwiping(true);
299
- }, [axis]);
300
-
301
- const onPointerMove = useCallback((event: React.PointerEvent<HTMLElement>) => {
302
- if (!swiping) return;
303
- const delta = (axis === "x" ? event.clientX : event.clientY) - start.current;
304
- // Only towards the edge it is pinned to. Dragging the other way does nothing, so a
305
- // scroll or a misread gesture cannot pull the toast into the middle of the page.
306
- setOffset(sign > 0 ? Math.max(0, delta) : Math.min(0, delta));
307
- }, [swiping, axis, sign]);
308
-
309
- const onPointerUp = useCallback(() => {
310
- if (!swiping) return;
311
- setSwiping(false);
312
- if (Math.abs(offset) >= swipeThreshold) {
313
- onDismiss();
314
- return;
315
- }
316
- setOffset(0); // Under the threshold it springs back, so nothing is lost by accident.
317
- }, [swiping, offset, swipeThreshold, onDismiss]);
318
-
319
- const controls: ToastControls = { dismiss: onDismiss };
320
-
321
- return (
322
- <article
323
- ref={(element) => { node.current = element; }}
324
- data-enigma-toast=""
325
- data-tone={notification.tone}
326
- data-state={leaving ? "leaving" : "open"}
327
- data-index={index}
328
- data-front={index === 0 ? "" : undefined}
329
- data-mounted={mounted && !leaving ? "" : undefined}
330
- data-hidden={hidden ? "" : undefined}
331
- data-swiping={swiping ? "" : undefined}
332
- // An error interrupts; anything else waits its turn rather than talking over
333
- // whatever a screen reader is in the middle of.
334
- role={notification.tone === "error" ? "alert" : "status"}
335
- aria-live={notification.tone === "error" ? "assertive" : "polite"}
336
- tabIndex={0}
337
- onKeyDown={(event) => { if (event.key === "Escape") onDismiss(); }}
338
- onPointerDown={onPointerDown}
339
- onPointerMove={onPointerMove}
340
- onPointerUp={onPointerUp}
341
- onPointerCancel={onPointerUp}
342
- style={{
343
- // Read by the theme, so every transform stays the stylesheet's decision.
344
- "--enigma-toast-swipe": `${offset}px`,
345
- "--enigma-toast-index": index,
346
- "--enigma-toast-before": index,
347
- "--enigma-toast-offset": `${stackOffset}px`,
348
- // The front toast is on top, and each one behind it a layer lower.
349
- "--enigma-toast-z": 100 - index
350
- } as CSSProperties}
351
- >
352
- {render
353
- ? render(notification, controls)
354
- : (
355
- <>
356
- <ToneIcon tone={notification.tone} />
357
- <span data-enigma-toast-content="">
358
- <p data-enigma-toast-title="">{notification.title}</p>
359
- {notification.body && <p data-enigma-toast-body="">{notification.body}</p>}
360
- </span>
361
- {notification.action && (
362
- <button
363
- type="button"
364
- data-enigma-toast-action=""
365
- onClick={() => {
366
- notification.action?.onSelect();
367
- if (notification.action?.dismiss !== false) onDismiss();
368
- }}
369
- >{notification.action.label}</button>
370
- )}
371
- <button type="button" data-enigma-toast-close="" aria-label="Dismiss" onClick={onDismiss}>
372
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
373
- <path d="M18 6 6 18M6 6l12 12" />
374
- </svg>
375
- </button>
376
- </>
377
- )}
378
- </article>
379
- );
380
- }
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";
381
117
 
382
118
  /**
383
119
  * The queue, from the entry that renders it.
384
120
  *
385
121
  * `@enigmax/primitives/react/toast` is where somebody looks for both halves - mounting the
386
122
  * stack and telling it something - and sending them to a second subpath for the second half
387
- * is the kind of split that makes an import line something to look up. It costs nothing:
388
- * the toaster already holds the queue.
123
+ * is the kind of split that makes an import line something to look up.
389
124
  */
390
125
  export { useNotifications, createNotificationQueue, defaultQueue } from "@/react/use-notifications";
391
126
  export type { Notification, NotificationInput, NotificationTone, NotificationAction, Notifications } from "@/core/notifications";