@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.
- package/dist/chunk-3BQVOOAM.js +388 -0
- package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
- package/dist/chunk-4VUHQFAT.js +130 -0
- package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
- package/dist/chunk-DSBYVA7V.js +713 -0
- package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
- package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
- package/dist/chunk-N6PDHMAX.js +213 -0
- package/dist/chunk-NONGREXC.js +248 -0
- package/dist/chunk-VKL3DEIQ.js +804 -0
- package/dist/chunk-WPTBURIC.js +1627 -0
- package/dist/chunk-WSQC3PCC.js +466 -0
- package/dist/context-menu-D3FtTn7v.d.ts +174 -0
- package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +8 -4
- package/dist/keys-D2zJs1uB.d.ts +100 -0
- package/dist/next/index.d.ts +12 -5
- package/dist/next/index.js +21 -14
- package/dist/react/button.d.ts +2 -2
- package/dist/react/context-menu.d.ts +202 -0
- package/dist/react/context-menu.js +6 -0
- package/dist/react/index.d.ts +11 -4
- package/dist/react/index.js +20 -13
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +1 -1
- package/dist/react/palette.d.ts +1 -1
- package/dist/react/palette.js +3 -3
- package/dist/react/search.d.ts +1 -1
- package/dist/react/search.js +2 -2
- package/dist/react/select.d.ts +217 -0
- package/dist/react/select.js +7 -0
- package/dist/react/selection.d.ts +104 -0
- package/dist/react/selection.js +4 -0
- package/dist/react/slot.d.ts +2 -2
- package/dist/react/toast.d.ts +165 -32
- package/dist/react/toast.js +1 -1
- package/dist/react-router/index.d.ts +12 -5
- package/dist/react-router/index.js +21 -14
- package/dist/search/index.d.ts +2 -2
- package/dist/search/index.js +1 -1
- package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
- package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
- package/dist/select-ClSy-J1f.d.ts +100 -0
- package/dist/selection-B_pmzHpy.d.ts +150 -0
- package/package.json +21 -2
- package/recipes/context-menu/styles.css +177 -0
- package/recipes/input/styles.css +9 -0
- package/recipes/palette/styles.css +3 -0
- package/recipes/search/tailwind.tsx +3 -2
- package/recipes/select/styles.css +230 -0
- package/recipes/toast/styles.css +688 -192
- package/registry.json +434 -29
- package/src/core/context-menu.ts +694 -0
- package/src/core/keys.ts +264 -0
- package/src/core/search.ts +25 -1
- package/src/core/select.ts +404 -0
- package/src/core/selection.ts +648 -0
- package/src/index.ts +60 -1
- package/src/react/context-menu/context.ts +57 -0
- package/src/react/context-menu/index.tsx +94 -0
- package/src/react/context-menu/root.tsx +846 -0
- package/src/react/context-menu/styles.ts +186 -0
- package/src/react/index.ts +70 -2
- package/src/react/palette/root.tsx +2 -2
- package/src/react/select/context.ts +56 -0
- package/src/react/select/index.tsx +96 -0
- package/src/react/select/root.tsx +839 -0
- package/src/react/select/styles.ts +238 -0
- package/src/react/selection/index.tsx +115 -0
- package/src/react/selection/use-selection.ts +260 -0
- package/src/react/toast/NOTICE +20 -0
- package/src/react/toast/assets.tsx +85 -0
- package/src/react/toast/cn.ts +10 -0
- package/src/react/toast/hooks.ts +13 -0
- package/src/react/toast/index.tsx +736 -0
- package/src/react/toast/state.ts +207 -0
- package/src/react/toast/styles.ts +738 -0
- package/src/react/toast/types.ts +193 -0
- package/src/react/toaster.tsx +76 -341
- package/dist/chunk-JCCL7XKC.js +0 -465
- package/src/react/toast-styles.ts +0 -247
- /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
|
+
};
|
package/src/react/toaster.tsx
CHANGED
|
@@ -1,43 +1,34 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { TOAST_STYLES } from "@/react/toast
|
|
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 {
|
|
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
|
|
10
|
-
* package, a look.
|
|
12
|
+
* `<Toaster />` - the toast stack, and the one component in this package that ships a look.
|
|
11
13
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
89
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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.
|
|
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";
|