pejay-ui 1.3.5 → 1.4.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/README.md +20 -0
- package/bin/cli.js +64 -34
- package/package.json +2 -2
- package/registry/buttons.json +9 -0
- package/registry/dropdowns.json +28 -0
- package/registry/forms.json +322 -0
- package/registry/layouts.json +18 -0
- package/registry/overlays.json +8 -0
- package/registry/scaffolds.json +83 -0
- package/registry/toast.json +10 -0
- package/templates/overlays/index.ts +1 -0
- package/templates/overlays/portal.tsx +26 -0
- package/templates/toast/README.md +183 -0
- package/templates/toast/container.tsx +320 -0
- package/templates/toast/index.ts +4 -0
- package/templates/toast/store.ts +35 -0
- package/templates/toast/toast.ts +54 -0
- package/templates/toast/types.ts +15 -0
- package/registry.json +0 -256
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./portal";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createPortal } from "react-dom";
|
|
2
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
interface PortalProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export function Portal({ children }: PortalProps) {
|
|
8
|
+
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const div = document.createElement("div");
|
|
12
|
+
div.id = "dynamic-portal";
|
|
13
|
+
document.body.appendChild(div);
|
|
14
|
+
setContainer(div);
|
|
15
|
+
|
|
16
|
+
return () => {
|
|
17
|
+
if (div.parentNode) {
|
|
18
|
+
document.body.removeChild(div);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
if (!container) return null;
|
|
24
|
+
|
|
25
|
+
return createPortal(children, container);
|
|
26
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Toast Notification Component
|
|
2
|
+
|
|
3
|
+
A toast notification system featuring:
|
|
4
|
+
- **Fully Customizable Styling**: Complete visual control over status designs.
|
|
5
|
+
- **Interactive Gestures**: Swipe-to-dismiss drag support for touch and pointer events.
|
|
6
|
+
- **Built-in Theme Presets**: Ready-to-use themes for Success, Error, Warning, and Info alerts.
|
|
7
|
+
- **Smart Timers**: Auto-dismiss timers that pause when the user hovers over a toast.
|
|
8
|
+
- **Custom Rendering**: Bypasses the default style to render custom React components and functions.
|
|
9
|
+
- **Transitions**: Smooth entry/exit animations (slide and fade transitions).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. Setup
|
|
14
|
+
|
|
15
|
+
To use the toast notification system, place the `<ToastContainer />` at the root of your application (typically in `App.tsx` or `main.tsx`).
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { ToastContainer } from "@/pejay-ui/components/toast";
|
|
19
|
+
|
|
20
|
+
export default function App() {
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{/* Your App Routing/Content */}
|
|
24
|
+
<ToastContainer placement="top-right" animationType="fade" />
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### `<ToastContainer />` Props
|
|
31
|
+
|
|
32
|
+
| Prop | Type | Default | Description |
|
|
33
|
+
| :--- | :--- | :--- | :--- |
|
|
34
|
+
| `placement` | `"top-right" \| "top-left" \| "bottom-right" \| "bottom-left"` | `"top-right"` | The screen corner where notifications will stack. |
|
|
35
|
+
| `animationType` | `"fade" \| "slide"` | `"fade"` | The entrance and exit transition animation style. Can also be passed as `animation-type`. |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 2. Usage & API
|
|
40
|
+
|
|
41
|
+
Import the `toast` function from the module:
|
|
42
|
+
```ts
|
|
43
|
+
import { toast } from "@/pejay-ui/components/toast";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Call Signatures
|
|
47
|
+
|
|
48
|
+
The status methods (`success`, `error`, `warning`, `info`) support two call signatures:
|
|
49
|
+
|
|
50
|
+
#### 1. Quick Message (String Only)
|
|
51
|
+
For displaying a single-line text message with default settings:
|
|
52
|
+
```ts
|
|
53
|
+
toast.success("All changes saved!");
|
|
54
|
+
```
|
|
55
|
+
You can also pass an optional configuration object as the second argument:
|
|
56
|
+
```ts
|
|
57
|
+
toast.error("An error occurred", { duration: 5000, showClose: false });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
#### 2. Detailed Object Configuration
|
|
61
|
+
For full title and description control:
|
|
62
|
+
```ts
|
|
63
|
+
toast.warning({
|
|
64
|
+
title: "Low Disk Space",
|
|
65
|
+
description: "You have less than 10% space remaining.",
|
|
66
|
+
duration: 6000
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### Shared Configuration Options (`ToastOptions`)
|
|
73
|
+
All toast methods accept an options object:
|
|
74
|
+
|
|
75
|
+
| Option | Type | Default | Description |
|
|
76
|
+
| :--- | :--- | :--- | :--- |
|
|
77
|
+
| `title` | `string` | — | Bold title text displayed in the toast. |
|
|
78
|
+
| `description` | `string` | — | Smaller detail text under the title. |
|
|
79
|
+
| `duration` | `number` | `4000` | Lifetime in milliseconds. Pass `Infinity` to disable auto-closing. |
|
|
80
|
+
| `showClose` | `boolean` | `true` | Whether to show the close cross button. |
|
|
81
|
+
| `dismiss` | `string` | — | An existing toast ID to dismiss before showing the new one. |
|
|
82
|
+
| `icon` | `React.ReactNode` | — | Custom React element to override the default status icon. |
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 3. Presets & Examples
|
|
87
|
+
|
|
88
|
+
### A. Success Toast
|
|
89
|
+
Use for successful operations (submitting forms, saves, payments).
|
|
90
|
+
```ts
|
|
91
|
+
toast.success({
|
|
92
|
+
title: "Payment Received",
|
|
93
|
+
description: "Your invoice #2093 has been paid successfully.",
|
|
94
|
+
duration: 4000,
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### B. Error Toast
|
|
99
|
+
Use for failed requests, validation errors, or application crashes.
|
|
100
|
+
```ts
|
|
101
|
+
toast.error({
|
|
102
|
+
title: "Upload Failed",
|
|
103
|
+
description: "The connection was lost. Please check your network and try again.",
|
|
104
|
+
duration: 6000,
|
|
105
|
+
showClose: true,
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### C. Warning Toast
|
|
110
|
+
Use for alerts, soft errors, or actions requiring user attention.
|
|
111
|
+
```ts
|
|
112
|
+
toast.warning({
|
|
113
|
+
title: "Unsaved Changes",
|
|
114
|
+
description: "Your work will be lost if you navigate away.",
|
|
115
|
+
duration: 5000,
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### D. Info Toast
|
|
120
|
+
Use for general system notices or status updates.
|
|
121
|
+
```ts
|
|
122
|
+
toast.info({
|
|
123
|
+
title: "System Maintenance",
|
|
124
|
+
description: "Scheduled maintenance will begin tonight at 12:00 AM EST.",
|
|
125
|
+
duration: Infinity, // Stays visible until manually closed
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 4. Custom Toasts
|
|
132
|
+
|
|
133
|
+
For completely custom designs, use `toast.custom()`. This method bypasses default styling and allows you to render any React component.
|
|
134
|
+
|
|
135
|
+
### Custom Example with Manual Dismissal
|
|
136
|
+
You can pass a function to the `content` property. It receives the unique `id` of the toast, allowing you to trigger a manual dismiss from inside your custom UI:
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
toast.custom({
|
|
140
|
+
id: "my-custom-toast", // Optional custom string ID
|
|
141
|
+
duration: 10000, // 10 seconds
|
|
142
|
+
content: (id) => (
|
|
143
|
+
<div className="flex flex-col gap-3 p-4 w-full bg-slate-900 border border-violet-500/30 rounded-xl shadow-lg">
|
|
144
|
+
<div className="flex flex-col gap-1">
|
|
145
|
+
<h4 className="text-sm font-semibold text-white">Importing Contacts</h4>
|
|
146
|
+
<p className="text-xs text-slate-400">Please wait while we process your file.</p>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="flex gap-2 justify-end">
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => toast.dismiss(id)}
|
|
151
|
+
className="px-3 py-1 bg-violet-600 hover:bg-violet-700 text-xs font-semibold text-white rounded-md transition-colors"
|
|
152
|
+
>
|
|
153
|
+
Cancel Import
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## 5. Dismissing Toasts
|
|
162
|
+
|
|
163
|
+
Toasts can be dismissed programmatically in two ways:
|
|
164
|
+
|
|
165
|
+
### 1. By ID via `toast.dismiss()`
|
|
166
|
+
You can manually trigger the removal of a toast at any time by calling `toast.dismiss(id)` with the ID returned when the toast was created:
|
|
167
|
+
```ts
|
|
168
|
+
// Trigger the toast and capture its generated ID
|
|
169
|
+
const toastId = toast.info("Uploading file...", { duration: Infinity });
|
|
170
|
+
|
|
171
|
+
// Dismiss it later (e.g. once the file upload succeeds)
|
|
172
|
+
toast.dismiss(toastId);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 2. Auto-Dismissing when Triggering a New Toast (via `dismiss` option)
|
|
176
|
+
You can automatically dismiss an active toast by passing its ID in the `dismiss` option of a new toast. This is useful for transitioning states (e.g., from a loading state to a success/error state):
|
|
177
|
+
```ts
|
|
178
|
+
// 1. Show loading toast with a fixed ID
|
|
179
|
+
toast.info("Saving changes...", { id: "saving-progress" });
|
|
180
|
+
|
|
181
|
+
// 2. Trigger success toast, which dismisses "saving-progress" before rendering
|
|
182
|
+
toast.success("All changes saved!", { dismiss: "saving-progress" });
|
|
183
|
+
```
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { useEffect, useState, useRef } from "react";
|
|
2
|
+
import type { ToastData } from "./types";
|
|
3
|
+
import { toastStore } from "./store";
|
|
4
|
+
import {
|
|
5
|
+
CheckCircle,
|
|
6
|
+
AlertCircle,
|
|
7
|
+
AlertTriangle,
|
|
8
|
+
Info,
|
|
9
|
+
X,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { cn } from "@/utils/cn";
|
|
12
|
+
import { Portal } from "../overlays";
|
|
13
|
+
|
|
14
|
+
// Dictionary mapping toast types to their respective Lucide icons and Tailwind styles
|
|
15
|
+
const TOAST_STYLES = {
|
|
16
|
+
success: {
|
|
17
|
+
Icon: CheckCircle,
|
|
18
|
+
iconColor: "text-emerald-500",
|
|
19
|
+
borderColor: "border-emerald-500/20",
|
|
20
|
+
bgGlow: "shadow-emerald-500/5",
|
|
21
|
+
accentColor: "bg-emerald-500",
|
|
22
|
+
},
|
|
23
|
+
error: {
|
|
24
|
+
Icon: AlertCircle,
|
|
25
|
+
iconColor: "text-red-500",
|
|
26
|
+
borderColor: "border-red-500/20",
|
|
27
|
+
bgGlow: "shadow-red-500/5",
|
|
28
|
+
accentColor: "bg-red-500",
|
|
29
|
+
},
|
|
30
|
+
warning: {
|
|
31
|
+
Icon: AlertTriangle,
|
|
32
|
+
iconColor: "text-amber-500",
|
|
33
|
+
borderColor: "border-amber-500/20",
|
|
34
|
+
bgGlow: "shadow-amber-500/5",
|
|
35
|
+
accentColor: "bg-amber-500",
|
|
36
|
+
},
|
|
37
|
+
info: {
|
|
38
|
+
Icon: Info,
|
|
39
|
+
iconColor: "text-sky-500",
|
|
40
|
+
borderColor: "border-sky-500/20",
|
|
41
|
+
bgGlow: "shadow-sky-500/5",
|
|
42
|
+
accentColor: "bg-sky-500",
|
|
43
|
+
},
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
function ToastItem({
|
|
47
|
+
toast,
|
|
48
|
+
placement = "top-right",
|
|
49
|
+
animationType = "fade",
|
|
50
|
+
}: {
|
|
51
|
+
toast: ToastData;
|
|
52
|
+
placement?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
|
53
|
+
animationType?: "slide" | "fade";
|
|
54
|
+
}) {
|
|
55
|
+
const [dragOffset, setDragOffset] = useState(0);
|
|
56
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
57
|
+
const [isDismissing, setIsDismissing] = useState(false);
|
|
58
|
+
const [animateState, setAnimateState] = useState(false);
|
|
59
|
+
|
|
60
|
+
const startXRef = useRef(0);
|
|
61
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
62
|
+
const isHoveringRef = useRef(false);
|
|
63
|
+
|
|
64
|
+
const isLeft = placement.endsWith("-left");
|
|
65
|
+
const slideOffset = isLeft ? -500 : 500;
|
|
66
|
+
|
|
67
|
+
// Helper to trigger the exit animation and remove toast from store after transition
|
|
68
|
+
const triggerDismiss = (customOffset?: number) => {
|
|
69
|
+
setIsDismissing(true);
|
|
70
|
+
if (customOffset !== undefined) {
|
|
71
|
+
setDragOffset(customOffset);
|
|
72
|
+
}
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
toastStore.remove(toast.id);
|
|
75
|
+
}, 400);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const startTimer = () => {
|
|
79
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
80
|
+
if (isHoveringRef.current) return;
|
|
81
|
+
const duration = toast.duration ?? 4000;
|
|
82
|
+
if (duration === Infinity) return;
|
|
83
|
+
timerRef.current = setTimeout(() => {
|
|
84
|
+
triggerDismiss();
|
|
85
|
+
}, duration);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const stopTimer = () => {
|
|
89
|
+
if (timerRef.current) {
|
|
90
|
+
clearTimeout(timerRef.current);
|
|
91
|
+
timerRef.current = null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
startTimer();
|
|
97
|
+
return () => stopTimer();
|
|
98
|
+
}, [toast.id, toast.duration]);
|
|
99
|
+
|
|
100
|
+
// Use requestAnimationFrame to guarantee the browser registers the initial off-screen paint before triggering the transition
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
let frame1: number;
|
|
103
|
+
let frame2: number;
|
|
104
|
+
frame1 = requestAnimationFrame(() => {
|
|
105
|
+
frame2 = requestAnimationFrame(() => {
|
|
106
|
+
setAnimateState(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return () => {
|
|
110
|
+
cancelAnimationFrame(frame1);
|
|
111
|
+
if (frame2) cancelAnimationFrame(frame2);
|
|
112
|
+
};
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const handleMouseEnter = () => {
|
|
116
|
+
isHoveringRef.current = true;
|
|
117
|
+
stopTimer();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleMouseLeave = () => {
|
|
121
|
+
isHoveringRef.current = false;
|
|
122
|
+
startTimer();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
126
|
+
if (isDismissing) return;
|
|
127
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
128
|
+
|
|
129
|
+
// Prevent dragging when clicking interactive elements (buttons, inputs, links, etc.)
|
|
130
|
+
const target = e.target as HTMLElement;
|
|
131
|
+
if (target.closest("button, input, select, textarea, a")) return;
|
|
132
|
+
|
|
133
|
+
setIsDragging(true);
|
|
134
|
+
startXRef.current = e.clientX;
|
|
135
|
+
stopTimer();
|
|
136
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
140
|
+
if (!isDragging) return;
|
|
141
|
+
const offset = e.clientX - startXRef.current;
|
|
142
|
+
setDragOffset(offset);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
146
|
+
if (!isDragging) return;
|
|
147
|
+
setIsDragging(false);
|
|
148
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
149
|
+
|
|
150
|
+
// Dismiss toast if dragged beyond threshold, otherwise snap back
|
|
151
|
+
if (Math.abs(dragOffset) > 100) {
|
|
152
|
+
triggerDismiss(dragOffset > 0 ? 500 : -500);
|
|
153
|
+
} else {
|
|
154
|
+
setDragOffset(0);
|
|
155
|
+
startTimer();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Determine transition styles based on active animation option
|
|
160
|
+
const opacity = Math.max(0, 1 - Math.abs(dragOffset) / 300);
|
|
161
|
+
|
|
162
|
+
const opacityStyle = animationType === "slide"
|
|
163
|
+
? (isDragging ? opacity : 1)
|
|
164
|
+
: (isDismissing || !animateState ? 0 : opacity);
|
|
165
|
+
|
|
166
|
+
const transformStyle = animationType === "slide"
|
|
167
|
+
? (isDismissing
|
|
168
|
+
? `translate3d(${dragOffset === 0 ? slideOffset : (dragOffset > 0 ? 500 : -500)}px, 0, 0)`
|
|
169
|
+
: (!animateState ? `translate3d(${slideOffset}px, 0, 0)` : `translate3d(${dragOffset}px, 0, 0)`))
|
|
170
|
+
: `translate3d(${dragOffset}px, 0, 0)`;
|
|
171
|
+
|
|
172
|
+
const itemStyle = {
|
|
173
|
+
transform: transformStyle,
|
|
174
|
+
opacity: opacityStyle,
|
|
175
|
+
touchAction: "none" as const,
|
|
176
|
+
willChange: "transform, opacity",
|
|
177
|
+
transition: isDragging
|
|
178
|
+
? "none"
|
|
179
|
+
: "transform 0.4s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.4s ease-in-out",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Return custom component directly, avoiding standard layout properties
|
|
183
|
+
if (toast.type === "custom") {
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
onPointerDown={handlePointerDown}
|
|
187
|
+
onPointerMove={handlePointerMove}
|
|
188
|
+
onPointerUp={handlePointerUp}
|
|
189
|
+
onPointerCancel={handlePointerUp}
|
|
190
|
+
onMouseEnter={handleMouseEnter}
|
|
191
|
+
onMouseLeave={handleMouseLeave}
|
|
192
|
+
style={itemStyle}
|
|
193
|
+
className={cn(
|
|
194
|
+
"relative overflow-hidden min-w-[320px] max-w-[400px] rounded-xl border backdrop-blur-md shadow-2xl flex items-start",
|
|
195
|
+
"select-none cursor-pointer active:cursor-grabbing",
|
|
196
|
+
"bg-gray-950/95 border-violet-500/20 shadow-violet-500/5",
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{typeof toast.content === "function" ? toast.content(toast.id!) : toast.content}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Standard toast style configuration mapping
|
|
205
|
+
const typeKey = (toast.type && toast.type in TOAST_STYLES) ? (toast.type as keyof typeof TOAST_STYLES) : "info";
|
|
206
|
+
const { Icon, iconColor, borderColor, bgGlow, accentColor } = TOAST_STYLES[typeKey];
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
onPointerDown={handlePointerDown}
|
|
211
|
+
onPointerMove={handlePointerMove}
|
|
212
|
+
onPointerUp={handlePointerUp}
|
|
213
|
+
onPointerCancel={handlePointerUp}
|
|
214
|
+
onMouseEnter={handleMouseEnter}
|
|
215
|
+
onMouseLeave={handleMouseLeave}
|
|
216
|
+
style={itemStyle}
|
|
217
|
+
className={cn(
|
|
218
|
+
"relative overflow-hidden min-w-[320px] max-w-[400px] rounded-xl border backdrop-blur-md p-4 shadow-2xl flex gap-3 items-start",
|
|
219
|
+
"select-none cursor-pointer active:cursor-grabbing",
|
|
220
|
+
"bg-gray-950/95",
|
|
221
|
+
borderColor,
|
|
222
|
+
bgGlow,
|
|
223
|
+
)}
|
|
224
|
+
>
|
|
225
|
+
{accentColor && (
|
|
226
|
+
<div
|
|
227
|
+
className={cn("absolute left-0 top-0 bottom-0 w-[3px]", accentColor)}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{toast.icon ? (
|
|
232
|
+
<div className="shrink-0 mt-0.5 ml-1 pointer-events-none flex items-center justify-center">
|
|
233
|
+
{toast.icon}
|
|
234
|
+
</div>
|
|
235
|
+
) : (
|
|
236
|
+
<Icon
|
|
237
|
+
className={cn("shrink-0 mt-0.5 ml-1 pointer-events-none", iconColor)}
|
|
238
|
+
size={18}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
<div className="flex-1 flex flex-col gap-0.5 pointer-events-none">
|
|
243
|
+
{toast.message && (
|
|
244
|
+
<p className="text-sm font-medium text-white leading-normal pr-4">
|
|
245
|
+
{toast.message}
|
|
246
|
+
</p>
|
|
247
|
+
)}
|
|
248
|
+
{toast.title && (
|
|
249
|
+
<h3
|
|
250
|
+
className="font-semibold text-sm tracking-tight leading-tight pr-4 text-white"
|
|
251
|
+
>
|
|
252
|
+
{toast.title}
|
|
253
|
+
</h3>
|
|
254
|
+
)}
|
|
255
|
+
{toast.description && (
|
|
256
|
+
<p
|
|
257
|
+
className="text-xs font-medium mt-1 leading-normal text-gray-400"
|
|
258
|
+
>
|
|
259
|
+
{toast.description}
|
|
260
|
+
</p>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{toast.showClose && (
|
|
265
|
+
<button
|
|
266
|
+
onClick={e => {
|
|
267
|
+
e.stopPropagation();
|
|
268
|
+
triggerDismiss();
|
|
269
|
+
}}
|
|
270
|
+
className="text-gray-500 hover:text-white transition-colors p-1 rounded-md hover:bg-gray-900 shrink-0 relative z-10"
|
|
271
|
+
>
|
|
272
|
+
<X size={14} />
|
|
273
|
+
</button>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface ToastContainerProps {
|
|
280
|
+
placement?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
|
281
|
+
"animation-type"?: "slide" | "fade";
|
|
282
|
+
animationType?: "slide" | "fade";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function ToastContainer({
|
|
286
|
+
placement = "top-right",
|
|
287
|
+
"animation-type": animationTypeHyphen,
|
|
288
|
+
animationType = animationTypeHyphen ?? "fade",
|
|
289
|
+
}: ToastContainerProps) {
|
|
290
|
+
const [toasts, setToasts] = useState<ToastData[]>(toastStore.getToasts());
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
const unsubscribe = toastStore.subscribe(setToasts);
|
|
294
|
+
return unsubscribe;
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
const placementClasses = {
|
|
298
|
+
"top-right": "fixed top-4 right-4 flex-col",
|
|
299
|
+
"top-left": "fixed top-4 left-4 flex-col",
|
|
300
|
+
"bottom-right": "fixed bottom-4 right-4 flex-col-reverse",
|
|
301
|
+
"bottom-left": "fixed bottom-4 left-4 flex-col-reverse",
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const containerClass = placementClasses[placement] || placementClasses["top-right"];
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<Portal>
|
|
308
|
+
<div className={cn("z-[9999] flex gap-3", containerClass)}>
|
|
309
|
+
{toasts.map(toast => (
|
|
310
|
+
<ToastItem
|
|
311
|
+
key={toast.id}
|
|
312
|
+
toast={toast}
|
|
313
|
+
placement={placement}
|
|
314
|
+
animationType={animationType}
|
|
315
|
+
/>
|
|
316
|
+
))}
|
|
317
|
+
</div>
|
|
318
|
+
</Portal>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Listener, ToastData } from "./types";
|
|
2
|
+
|
|
3
|
+
class ToastStore {
|
|
4
|
+
private toasts: ToastData[] = [];
|
|
5
|
+
private listeners: Listener[] = [];
|
|
6
|
+
|
|
7
|
+
subscribe(listener: Listener) {
|
|
8
|
+
this.listeners.push(listener);
|
|
9
|
+
return () => {
|
|
10
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
private publish() {
|
|
14
|
+
this.listeners.forEach((listener) => listener(this.toasts));
|
|
15
|
+
}
|
|
16
|
+
add(toast: ToastData) {
|
|
17
|
+
const newToast: ToastData = {
|
|
18
|
+
id: toast.id ?? crypto.randomUUID(),
|
|
19
|
+
...toast,
|
|
20
|
+
};
|
|
21
|
+
this.toasts = [newToast, ...this.toasts];
|
|
22
|
+
this.publish();
|
|
23
|
+
return newToast.id!;
|
|
24
|
+
}
|
|
25
|
+
remove(id: string | undefined) {
|
|
26
|
+
this.toasts = this.toasts.filter((toast) => toast.id !== id);
|
|
27
|
+
|
|
28
|
+
this.publish();
|
|
29
|
+
}
|
|
30
|
+
getToasts() {
|
|
31
|
+
return this.toasts;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const toastStore = new ToastStore();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { toastStore } from "./store";
|
|
2
|
+
import type { ToastData } from "./types";
|
|
3
|
+
|
|
4
|
+
type ToastOptions = ToastData;
|
|
5
|
+
type ShortcutOptions = Omit<ToastData, "type">;
|
|
6
|
+
|
|
7
|
+
function createToast(options: ToastOptions) {
|
|
8
|
+
if (options.dismiss) {
|
|
9
|
+
toastStore.remove(options.dismiss);
|
|
10
|
+
}
|
|
11
|
+
return toastStore.add(options);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const handleShortcut = (
|
|
15
|
+
type: ToastData["type"],
|
|
16
|
+
messageOrOptions: string | ShortcutOptions,
|
|
17
|
+
options?: ShortcutOptions
|
|
18
|
+
) => {
|
|
19
|
+
if (typeof messageOrOptions === "string") {
|
|
20
|
+
return createToast({ message: messageOrOptions, ...options, type });
|
|
21
|
+
}
|
|
22
|
+
return createToast({ ...messageOrOptions, type });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const toast = Object.assign(
|
|
26
|
+
(options: ToastOptions) => {
|
|
27
|
+
return createToast(options);
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
success: (messageOrOptions: string | ShortcutOptions, options?: ShortcutOptions) => {
|
|
31
|
+
return handleShortcut("success", messageOrOptions, options);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
error: (messageOrOptions: string | ShortcutOptions, options?: ShortcutOptions) => {
|
|
35
|
+
return handleShortcut("error", messageOrOptions, options);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
info: (messageOrOptions: string | ShortcutOptions, options?: ShortcutOptions) => {
|
|
39
|
+
return handleShortcut("info", messageOrOptions, options);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
warning: (messageOrOptions: string | ShortcutOptions, options?: ShortcutOptions) => {
|
|
43
|
+
return handleShortcut("warning", messageOrOptions, options);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
custom: (options: { content: React.ReactNode | ((id: string) => React.ReactNode); id?: string; duration?: number; dismiss?: string }) => {
|
|
47
|
+
return createToast({ ...options, type: "custom" });
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
dismiss: (id: string) => {
|
|
51
|
+
toastStore.remove(id);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ToastType = "info" | "error" | "warning" | "success" | "custom";
|
|
2
|
+
|
|
3
|
+
export type Listener = (toasts: ToastData[]) => void;
|
|
4
|
+
export interface ToastData {
|
|
5
|
+
id?: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
message?: string;
|
|
9
|
+
type?: ToastType;
|
|
10
|
+
duration?: number;
|
|
11
|
+
showClose?: boolean;
|
|
12
|
+
dismiss?: string;
|
|
13
|
+
icon?: React.ReactNode;
|
|
14
|
+
content?: React.ReactNode | ((id: string) => React.ReactNode);
|
|
15
|
+
}
|