@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.
- package/dist/chunk-JCCL7XKC.js +465 -0
- package/dist/next/index.js +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/toast.d.ts +14 -13
- package/dist/react/toast.js +2 -2
- package/dist/react-router/index.js +1 -1
- package/package.json +1 -1
- package/recipes/toast/styles.css +173 -100
- package/registry.json +9 -4
- package/src/react/toast-styles.ts +247 -0
- package/src/react/toaster.tsx +174 -21
- package/dist/chunk-ZCUFYBPB.js +0 -154
package/src/react/toaster.tsx
CHANGED
|
@@ -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
|
|
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
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
* toast
|
|
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 =
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
{
|
|
220
|
+
{stack.map((entry, index) => (
|
|
133
221
|
<Toast
|
|
134
222
|
key={entry.notification.id}
|
|
135
223
|
entry={entry}
|
|
136
|
-
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
|
|
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
|
-
<
|
|
222
|
-
|
|
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}
|
|
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";
|
package/dist/chunk-ZCUFYBPB.js
DELETED
|
@@ -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 };
|