@enigmax/primitives 0.20.0 → 0.21.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.
@@ -1,20 +1,80 @@
1
1
  "use client";
2
2
 
3
+ import { TOAST_STYLES } from "@/react/toast-styles";
3
4
  import { defaultQueue } from "@/react/use-notifications";
4
5
  import type { Notification, Notifications } from "@/core/notifications";
5
- import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
6
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
6
7
 
7
8
  /**
8
- * `<Toaster />` - the stack, the movement and the gestures. No colours.
9
+ * `<Toaster />` - the stack, the movement, the gestures and, unlike the rest of this
10
+ * package, a look.
9
11
  *
10
12
  * The queue underneath already owns ordering, dedupe by key, sticky errors and timers that
11
13
  * 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
+ * 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
18
  *
15
- * Every part carries a data attribute, so the theme is a stylesheet you own. `enigma add
16
- * toast --copy` writes one.
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.
28
+ */
29
+
30
+ /** How many toasts are on screen at once. The rest wait behind, counted but not drawn. */
31
+ const VISIBLE = 3;
32
+
33
+ let injected = false;
34
+
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`.
17
40
  */
41
+ function injectStyles(): void {
42
+ if (injected || typeof document === "undefined") return;
43
+ injected = true;
44
+ if (document.querySelector("[data-enigma-toast-styles]")) return;
45
+ const element = document.createElement("style");
46
+ element.setAttribute("data-enigma-toast-styles", "");
47
+ element.textContent = TOAST_STYLES;
48
+ document.head.prepend(element);
49
+ }
50
+
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
+ }
18
78
 
19
79
  export type ToastPosition =
20
80
  | "top-left" | "top-center" | "top-right"
@@ -34,6 +94,16 @@ export interface ToasterProps {
34
94
  swipeThreshold?: number;
35
95
  /** Render a toast yourself. The default renders title, body and the action. */
36
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;
101
+ /**
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.
105
+ */
106
+ styles?: boolean;
37
107
  /** Accessible name for the region. */
38
108
  label?: string;
39
109
  className?: string;
@@ -59,8 +129,11 @@ function swipeAxis(position: ToastPosition): { axis: "x" | "y"; sign: number; }
59
129
  export function Toaster({
60
130
  queue = defaultQueue,
61
131
  position = "bottom-right",
62
- exitDuration = 200,
132
+ exitDuration = 350,
63
133
  swipeThreshold = 60,
134
+ visibleCount = VISIBLE,
135
+ expand = false,
136
+ styles: withStyles = true,
64
137
  children,
65
138
  label = "Notifications",
66
139
  className,
@@ -73,6 +146,14 @@ export function Toaster({
73
146
  () => queue.items.map((notification) => ({ notification, leaving: false }))
74
147
  );
75
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>>({});
153
+
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]);
76
157
 
77
158
  useEffect(() => {
78
159
  const sync = (items: readonly Notification[]): void => {
@@ -115,25 +196,41 @@ export function Toaster({
115
196
 
116
197
  if (!rendered.length) return null;
117
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
+
118
204
  return (
119
205
  <section
120
206
  aria-label={label}
121
207
  data-enigma-toaster=""
122
208
  data-position={position}
209
+ data-expanded={expanded ? "" : undefined}
210
+ style={{ ...style, "--enigma-toast-front-height": `${frontHeight}px` } as CSSProperties}
123
211
  className={className}
124
- style={style}
125
212
  // 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()}
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); }}
131
219
  >
132
- {rendered.map((entry, index) => (
220
+ {stack.map((entry, index) => (
133
221
  <Toast
134
222
  key={entry.notification.id}
135
223
  entry={entry}
136
- index={rendered.length - 1 - index}
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
+ ))}
137
234
  position={position}
138
235
  swipeThreshold={swipeThreshold}
139
236
  onDismiss={() => queue.dismiss(entry.notification.id)}
@@ -148,19 +245,50 @@ interface ToastProps {
148
245
  entry: Rendered;
149
246
  /** 0 is the front of the stack. Drives the offset and scale in the theme. */
150
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;
151
254
  position: ToastPosition;
152
255
  swipeThreshold: number;
153
256
  onDismiss: () => void;
154
257
  render?: (notification: Notification, controls: ToastControls) => ReactNode;
155
258
  }
156
259
 
157
- function Toast({ entry, index, position, swipeThreshold, onDismiss, render }: ToastProps): ReactNode {
260
+ function Toast({ entry, index, hidden, offset: stackOffset, onMeasure, position, swipeThreshold, onDismiss, render }: ToastProps): ReactNode {
158
261
  const { notification, leaving } = entry;
159
262
  const [offset, setOffset] = useState(0);
160
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);
161
267
  const start = useRef(0);
162
268
  const { axis, sign } = swipeAxis(position);
163
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
+
164
292
  const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
165
293
  // Not on the buttons: a press on "Undo" is a press, not the start of a drag.
166
294
  if ((event.target as HTMLElement).closest("button")) return;
@@ -192,11 +320,14 @@ function Toast({ entry, index, position, swipeThreshold, onDismiss, render }: To
192
320
 
193
321
  return (
194
322
  <article
323
+ ref={(element) => { node.current = element; }}
195
324
  data-enigma-toast=""
196
325
  data-tone={notification.tone}
197
326
  data-state={leaving ? "leaving" : "open"}
198
327
  data-index={index}
199
328
  data-front={index === 0 ? "" : undefined}
329
+ data-mounted={mounted && !leaving ? "" : undefined}
330
+ data-hidden={hidden ? "" : undefined}
200
331
  data-swiping={swiping ? "" : undefined}
201
332
  // An error interrupts; anything else waits its turn rather than talking over
202
333
  // whatever a screen reader is in the middle of.
@@ -209,17 +340,24 @@ function Toast({ entry, index, position, swipeThreshold, onDismiss, render }: To
209
340
  onPointerUp={onPointerUp}
210
341
  onPointerCancel={onPointerUp}
211
342
  style={{
212
- // Read by the theme, so the transform stays the stylesheet's decision.
343
+ // Read by the theme, so every transform stays the stylesheet's decision.
213
344
  "--enigma-toast-swipe": `${offset}px`,
214
- "--enigma-toast-index": index
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
215
350
  } as CSSProperties}
216
351
  >
217
352
  {render
218
353
  ? render(notification, controls)
219
354
  : (
220
355
  <>
221
- <p data-enigma-toast-title="">{notification.title}</p>
222
- {notification.body && <p data-enigma-toast-body="">{notification.body}</p>}
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>
223
361
  {notification.action && (
224
362
  <button
225
363
  type="button"
@@ -230,9 +368,24 @@ function Toast({ entry, index, position, swipeThreshold, onDismiss, render }: To
230
368
  }}
231
369
  >{notification.action.label}</button>
232
370
  )}
233
- <button type="button" data-enigma-toast-close="" aria-label="Dismiss" onClick={onDismiss}>&times;</button>
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>
234
376
  </>
235
377
  )}
236
378
  </article>
237
379
  );
238
380
  }
381
+
382
+ /**
383
+ * The queue, from the entry that renders it.
384
+ *
385
+ * `@enigmax/primitives/react/toast` is where somebody looks for both halves - mounting the
386
+ * 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.
389
+ */
390
+ export { useNotifications, createNotificationQueue, defaultQueue } from "@/react/use-notifications";
391
+ export type { Notification, NotificationInput, NotificationTone, NotificationAction, Notifications } from "@/core/notifications";
@@ -1,154 +0,0 @@
1
- import { defaultQueue } from './chunk-QYMUIW5I.js';
2
- import { useState, useRef, useEffect, useCallback } from 'react';
3
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
-
5
- function swipeAxis(position) {
6
- if (position.endsWith("-center")) return { axis: "y", sign: position.startsWith("top") ? -1 : 1 };
7
- return { axis: "x", sign: position.endsWith("-left") ? -1 : 1 };
8
- }
9
- function Toaster({
10
- queue = defaultQueue,
11
- position = "bottom-right",
12
- exitDuration = 200,
13
- swipeThreshold = 60,
14
- children,
15
- label = "Notifications",
16
- className,
17
- style
18
- }) {
19
- const [rendered, setRendered] = useState(
20
- () => queue.items.map((notification) => ({ notification, leaving: false }))
21
- );
22
- const timers = useRef(/* @__PURE__ */ new Map());
23
- useEffect(() => {
24
- const sync = (items) => {
25
- setRendered((current) => {
26
- const live = new Map(items.map((item) => [item.id, item]));
27
- const kept = current.map((entry) => {
28
- const still = live.get(entry.notification.id);
29
- if (still) return { notification: still, leaving: false };
30
- if (entry.leaving) return entry;
31
- const timer = setTimeout(() => {
32
- timers.current.delete(entry.notification.id);
33
- setRendered((later) => later.filter((row) => row.notification.id !== entry.notification.id));
34
- }, exitDuration);
35
- timers.current.set(entry.notification.id, timer);
36
- return { ...entry, leaving: true };
37
- });
38
- const seen = new Set(kept.map((entry) => entry.notification.id));
39
- const added = items.filter((item) => !seen.has(item.id)).map((item) => ({ notification: item, leaving: false }));
40
- return [...kept, ...added];
41
- });
42
- };
43
- sync(queue.items);
44
- return queue.subscribe(sync);
45
- }, [queue, exitDuration]);
46
- useEffect(() => {
47
- const pending = timers.current;
48
- return () => {
49
- for (const timer of pending.values()) clearTimeout(timer);
50
- pending.clear();
51
- };
52
- }, []);
53
- if (!rendered.length) return null;
54
- return /* @__PURE__ */ jsx(
55
- "section",
56
- {
57
- "aria-label": label,
58
- "data-enigma-toaster": "",
59
- "data-position": position,
60
- className,
61
- style,
62
- onPointerEnter: () => queue.pause(),
63
- onPointerLeave: () => queue.resume(),
64
- onFocusCapture: () => queue.pause(),
65
- onBlurCapture: () => queue.resume(),
66
- children: rendered.map((entry, index) => /* @__PURE__ */ jsx(
67
- Toast,
68
- {
69
- entry,
70
- index: rendered.length - 1 - index,
71
- position,
72
- swipeThreshold,
73
- onDismiss: () => queue.dismiss(entry.notification.id),
74
- render: children
75
- },
76
- entry.notification.id
77
- ))
78
- }
79
- );
80
- }
81
- function Toast({ entry, index, position, swipeThreshold, onDismiss, render }) {
82
- const { notification, leaving } = entry;
83
- const [offset, setOffset] = useState(0);
84
- const [swiping, setSwiping] = useState(false);
85
- const start = useRef(0);
86
- const { axis, sign } = swipeAxis(position);
87
- const onPointerDown = useCallback((event) => {
88
- if (event.target.closest("button")) return;
89
- if (event.pointerType === "mouse" && event.button !== 0) return;
90
- event.currentTarget.setPointerCapture(event.pointerId);
91
- start.current = axis === "x" ? event.clientX : event.clientY;
92
- setSwiping(true);
93
- }, [axis]);
94
- const onPointerMove = useCallback((event) => {
95
- if (!swiping) return;
96
- const delta = (axis === "x" ? event.clientX : event.clientY) - start.current;
97
- setOffset(sign > 0 ? Math.max(0, delta) : Math.min(0, delta));
98
- }, [swiping, axis, sign]);
99
- const onPointerUp = useCallback(() => {
100
- if (!swiping) return;
101
- setSwiping(false);
102
- if (Math.abs(offset) >= swipeThreshold) {
103
- onDismiss();
104
- return;
105
- }
106
- setOffset(0);
107
- }, [swiping, offset, swipeThreshold, onDismiss]);
108
- const controls = { dismiss: onDismiss };
109
- return /* @__PURE__ */ jsx(
110
- "article",
111
- {
112
- "data-enigma-toast": "",
113
- "data-tone": notification.tone,
114
- "data-state": leaving ? "leaving" : "open",
115
- "data-index": index,
116
- "data-front": index === 0 ? "" : void 0,
117
- "data-swiping": swiping ? "" : void 0,
118
- role: notification.tone === "error" ? "alert" : "status",
119
- "aria-live": notification.tone === "error" ? "assertive" : "polite",
120
- tabIndex: 0,
121
- onKeyDown: (event) => {
122
- if (event.key === "Escape") onDismiss();
123
- },
124
- onPointerDown,
125
- onPointerMove,
126
- onPointerUp,
127
- onPointerCancel: onPointerUp,
128
- style: {
129
- // Read by the theme, so the transform stays the stylesheet's decision.
130
- "--enigma-toast-swipe": `${offset}px`,
131
- "--enigma-toast-index": index
132
- },
133
- children: render ? render(notification, controls) : /* @__PURE__ */ jsxs(Fragment, { children: [
134
- /* @__PURE__ */ jsx("p", { "data-enigma-toast-title": "", children: notification.title }),
135
- notification.body && /* @__PURE__ */ jsx("p", { "data-enigma-toast-body": "", children: notification.body }),
136
- notification.action && /* @__PURE__ */ jsx(
137
- "button",
138
- {
139
- type: "button",
140
- "data-enigma-toast-action": "",
141
- onClick: () => {
142
- notification.action?.onSelect();
143
- if (notification.action?.dismiss !== false) onDismiss();
144
- },
145
- children: notification.action.label
146
- }
147
- ),
148
- /* @__PURE__ */ jsx("button", { type: "button", "data-enigma-toast-close": "", "aria-label": "Dismiss", onClick: onDismiss, children: "\xD7" })
149
- ] })
150
- }
151
- );
152
- }
153
-
154
- export { Toaster };