@marv3l/canopy-ui 1.0.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/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/index.js +99 -0
- package/package.json +53 -0
- package/templates/toast/toast.tsx +185 -0
- package/templates/toast/use-toast.ts +214 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShawnR04
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Canopy UI
|
|
2
|
+
|
|
3
|
+
> A modern, copy-and-paste CLI for adding customizable UI components directly to React and Next.js codebases.
|
|
4
|
+
|
|
5
|
+
Canopy UI installs component source code into your project, giving you full ownership over the markup, styles, behavior, and design tokens. Customize components as much as you need—without being locked into a hosted UI library.
|
|
6
|
+
|
|
7
|
+
## Quick Start
|
|
8
|
+
|
|
9
|
+
Add a component with one command:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx canopy-ui add toast
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run the command without a component name to open an interactive multi-select prompt:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx canopy-ui add
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Components
|
|
24
|
+
|
|
25
|
+
### Toast
|
|
26
|
+
|
|
27
|
+
A customizable, animated notification system with:
|
|
28
|
+
|
|
29
|
+
- Built-in `success` and `error` variants
|
|
30
|
+
- Configurable global default duration
|
|
31
|
+
- Configurable screen position
|
|
32
|
+
- Per-toast duration overrides
|
|
33
|
+
- Custom colors using hex values, CSS variables, or design tokens
|
|
34
|
+
- Progress-bar color controls
|
|
35
|
+
- Direct Tailwind `className` overrides
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Install the Toast component into your project:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx canopy-ui add toast
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The component is added to your local UI directory, typically under:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
components/ui/toast.tsx
|
|
49
|
+
components/ui/use-toast.ts
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> The exact generated paths may depend on your project's configured import aliases.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Setup
|
|
57
|
+
|
|
58
|
+
Mount `Toaster` once in your root `app/layout.tsx`. This provides the toast viewport for all routes in your application.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import type { Metadata } from "next";
|
|
62
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
63
|
+
|
|
64
|
+
import "./globals.css";
|
|
65
|
+
import { Toaster } from "@/components/ui/toast";
|
|
66
|
+
|
|
67
|
+
const geistSans = Geist({
|
|
68
|
+
variable: "--font-geist-sans",
|
|
69
|
+
subsets: ["latin"],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const geistMono = Geist_Mono({
|
|
73
|
+
variable: "--font-geist-mono",
|
|
74
|
+
subsets: ["latin"],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const metadata: Metadata = {
|
|
78
|
+
title: "My App",
|
|
79
|
+
description: "My application",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default function RootLayout({
|
|
83
|
+
children,
|
|
84
|
+
}: Readonly<{
|
|
85
|
+
children: React.ReactNode;
|
|
86
|
+
}>) {
|
|
87
|
+
return (
|
|
88
|
+
<html
|
|
89
|
+
lang="en"
|
|
90
|
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
91
|
+
>
|
|
92
|
+
<body className="flex min-h-full flex-col">
|
|
93
|
+
{children}
|
|
94
|
+
<Toaster defaultDuration={3500} position="top-center" />
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `Toaster` Props
|
|
102
|
+
|
|
103
|
+
| Prop | Type | Description |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `defaultDuration` | `number` | Default time, in milliseconds, before a toast dismisses. Individual toasts can override it. |
|
|
106
|
+
| `position` | `string` | Position of the toast viewport, for example `"top-center"`. |
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Usage
|
|
111
|
+
|
|
112
|
+
Import `toast` inside a client component, then call it from an event handler or client-side action.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
"use client";
|
|
116
|
+
|
|
117
|
+
import { toast } from "@/components/ui/use-toast";
|
|
118
|
+
|
|
119
|
+
export default function Page() {
|
|
120
|
+
return (
|
|
121
|
+
<button
|
|
122
|
+
onClick={() =>
|
|
123
|
+
toast({
|
|
124
|
+
variant: "success",
|
|
125
|
+
title: "Changes saved",
|
|
126
|
+
description: "Your preferences were updated successfully.",
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
Show toast
|
|
131
|
+
</button>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Examples
|
|
139
|
+
|
|
140
|
+
### Success Toast
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
toast({
|
|
144
|
+
variant: "success",
|
|
145
|
+
title: "Changes saved",
|
|
146
|
+
description: "Your preferences were updated successfully.",
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Error Toast
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
toast({
|
|
154
|
+
variant: "error",
|
|
155
|
+
title: "Action failed",
|
|
156
|
+
description: "Could not connect to the remote server.",
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Custom Colors and Duration
|
|
161
|
+
|
|
162
|
+
Use `customColor` to override the toast palette. Values can be literal CSS colors, such as hex values, or CSS custom properties such as `var(--primary)`.
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
toast({
|
|
166
|
+
title: "Pro subscription unlocked",
|
|
167
|
+
description: "Welcome to VIP perks and custom styling.",
|
|
168
|
+
duration: 5000,
|
|
169
|
+
customColor: {
|
|
170
|
+
bg: "var(--card)",
|
|
171
|
+
border: "var(--primary)",
|
|
172
|
+
text: "var(--card-foreground)",
|
|
173
|
+
icon: "var(--primary)",
|
|
174
|
+
progress: "var(--primary)",
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Tailwind Class Override
|
|
180
|
+
|
|
181
|
+
Use `className` when you want to apply direct Tailwind utility classes to an individual toast.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
toast({
|
|
185
|
+
title: "Tailwind classes applied",
|
|
186
|
+
description: "Styled with direct className overrides.",
|
|
187
|
+
duration: 3500,
|
|
188
|
+
customColor: {
|
|
189
|
+
progress: "var(--destructive)",
|
|
190
|
+
},
|
|
191
|
+
className: "border-destructive/40 bg-card text-primary",
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Toast API
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
toast({
|
|
201
|
+
variant?: "success" | "error";
|
|
202
|
+
title?: string;
|
|
203
|
+
description?: string;
|
|
204
|
+
duration?: number;
|
|
205
|
+
customColor?: {
|
|
206
|
+
bg?: string;
|
|
207
|
+
border?: string;
|
|
208
|
+
text?: string;
|
|
209
|
+
icon?: string;
|
|
210
|
+
progress?: string;
|
|
211
|
+
};
|
|
212
|
+
className?: string;
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
| Option | Description |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `variant` | Applies a built-in visual style, such as `"success"` or `"error"`. |
|
|
219
|
+
| `title` | Primary toast message. |
|
|
220
|
+
| `description` | Supporting text displayed below the title. |
|
|
221
|
+
| `duration` | Dismiss timeout in milliseconds. Overrides `Toaster`’s `defaultDuration`. |
|
|
222
|
+
| `customColor.bg` | Toast background color. |
|
|
223
|
+
| `customColor.border` | Toast border color. |
|
|
224
|
+
| `customColor.text` | Toast text color. |
|
|
225
|
+
| `customColor.icon` | Toast icon color. |
|
|
226
|
+
| `customColor.progress` | Toast progress-bar color. |
|
|
227
|
+
| `className` | Tailwind or custom CSS classes applied directly to the toast. |
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Development
|
|
232
|
+
|
|
233
|
+
Build the CLI:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
npm run build
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Link the package locally:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
npm link
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Then, inside a sample React or Next.js project, install a component through the linked CLI:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
canopy-ui add toast
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Publishing
|
|
252
|
+
|
|
253
|
+
Publish the package to npm:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
npm publish --access public
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT © Shawn Rimai
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/add.ts
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import fs from "fs-extra";
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
import * as p from "@clack/prompts";
|
|
12
|
+
import pc from "picocolors";
|
|
13
|
+
|
|
14
|
+
// src/registry.ts
|
|
15
|
+
var REGISTRY = {
|
|
16
|
+
// Toast component registration
|
|
17
|
+
toast: {
|
|
18
|
+
name: "Custom Toast Notification System",
|
|
19
|
+
// Packages to automatically install in consumer project
|
|
20
|
+
dependencies: ["lucide-react", "clsx", "tailwind-merge"],
|
|
21
|
+
files: [
|
|
22
|
+
{
|
|
23
|
+
templatePath: "toast/toast.tsx",
|
|
24
|
+
targetName: "toast.tsx"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
templatePath: "toast/use-toast.ts",
|
|
28
|
+
targetName: "use-toast.ts"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
// Future components (dialog, sheet, dropdown) are added here
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/commands/add.ts
|
|
36
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
37
|
+
var __dirname = path.dirname(__filename);
|
|
38
|
+
async function add(componentKeys) {
|
|
39
|
+
p.intro(pc.bgCyan(pc.black(" Canopy UI ")));
|
|
40
|
+
let selected = componentKeys;
|
|
41
|
+
if (!selected || selected.length === 0) {
|
|
42
|
+
const choices = Object.keys(REGISTRY).map((key) => ({
|
|
43
|
+
value: key,
|
|
44
|
+
label: `${REGISTRY[key]?.name ?? key} (${key})`
|
|
45
|
+
}));
|
|
46
|
+
const response = await p.multiselect({
|
|
47
|
+
message: "Select UI components to install:",
|
|
48
|
+
options: choices,
|
|
49
|
+
required: true
|
|
50
|
+
});
|
|
51
|
+
if (p.isCancel(response)) {
|
|
52
|
+
p.cancel("Operation aborted.");
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
selected = response;
|
|
56
|
+
}
|
|
57
|
+
const projectRoot = process.cwd();
|
|
58
|
+
const hasSrc = await fs.pathExists(path.join(projectRoot, "src"));
|
|
59
|
+
const targetDir = hasSrc ? path.join(projectRoot, "src", "components", "ui") : path.join(projectRoot, "components", "ui");
|
|
60
|
+
await fs.ensureDir(targetDir);
|
|
61
|
+
const spinner2 = p.spinner();
|
|
62
|
+
for (const key of selected) {
|
|
63
|
+
const meta = REGISTRY[key];
|
|
64
|
+
if (!meta) {
|
|
65
|
+
p.log.error(`Component "${key}" was not found in registry.`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
spinner2.start(`Copying ${meta.name} source files...`);
|
|
69
|
+
for (const file of meta.files) {
|
|
70
|
+
const srcPath = path.resolve(__dirname, "../templates", file.templatePath);
|
|
71
|
+
const destPath = path.join(targetDir, file.targetName);
|
|
72
|
+
if (await fs.pathExists(srcPath)) {
|
|
73
|
+
await fs.copy(srcPath, destPath, { overwrite: true });
|
|
74
|
+
} else {
|
|
75
|
+
spinner2.stop(pc.red(`Template file missing: ${file.templatePath}`));
|
|
76
|
+
p.log.warn(pc.dim(`Looked at path: ${srcPath}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (meta.dependencies && meta.dependencies.length > 0) {
|
|
81
|
+
spinner2.message(
|
|
82
|
+
`Installing required npm packages: ${meta.dependencies.join(", ")}...`
|
|
83
|
+
);
|
|
84
|
+
await execa("npm", ["install", ...meta.dependencies], {
|
|
85
|
+
cwd: projectRoot
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
spinner2.stop(pc.green(`\u2714 Added ${meta.name} into ${hasSrc ? "src/" : ""}components/ui/`));
|
|
89
|
+
}
|
|
90
|
+
p.outro(pc.green("All components successfully installed!"));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/index.ts
|
|
94
|
+
var program = new Command();
|
|
95
|
+
program.name("Canopy UI").description("Install custom modular UI components directly to your project").version("1.0.0");
|
|
96
|
+
program.command("add").description("Add a component to your project").argument("[components...]", "Component identifiers (e.g., toast").action(async (components) => {
|
|
97
|
+
await add(components);
|
|
98
|
+
});
|
|
99
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marv3l/canopy-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An accessible, themable React UI component library built for fast-moving web applications.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"canopy-ui": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"templates"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"dev": "tsup --watch",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ShawnR04/canopy-ui.git"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"react",
|
|
25
|
+
"ui",
|
|
26
|
+
"toast",
|
|
27
|
+
"shadcn",
|
|
28
|
+
"components",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"author": "Shawn Rimai",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"type": "module",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/ShawnR04/canopy-ui/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/ShawnR04/canopy-ui#readme",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@clack/prompts": "^1.7.0",
|
|
40
|
+
"commander": "^15.0.0",
|
|
41
|
+
"execa": "^10.0.1",
|
|
42
|
+
"fs-extra": "^11.4.0",
|
|
43
|
+
"lucide-react": "^1.31.0",
|
|
44
|
+
"ora": "^9.4.1",
|
|
45
|
+
"picocolors": "^1.1.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/fs-extra": "^11.0.4",
|
|
49
|
+
"@types/node": "^26.2.0",
|
|
50
|
+
"tsup": "^8.5.1",
|
|
51
|
+
"typescript": "^7.0.2"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Import React runtime and component types
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
// Import semantic SVG icons from lucide-react
|
|
6
|
+
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from "lucide-react";
|
|
7
|
+
// Import toast hook and type interfaces
|
|
8
|
+
import { useToast, ToastItem } from "./use-toast";
|
|
9
|
+
|
|
10
|
+
// Props accepted by the root Toaster container mounted in the root layout
|
|
11
|
+
export interface ToasterProps {
|
|
12
|
+
// Global lifespan (in milliseconds) for all toasts; defaults to 4000ms
|
|
13
|
+
defaultDuration?: number;
|
|
14
|
+
// Screen viewport placement position
|
|
15
|
+
position?: "top-right" | "bottom-right" | "top-center" | "bottom-center" | "top-left" | "bottom-left";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Visual preset configurations mapped to your semantic theme tokens
|
|
19
|
+
const variantStyles: Record<
|
|
20
|
+
string,
|
|
21
|
+
{ bg: string; border: string; text: string; progress: string; icon: any }
|
|
22
|
+
> = {
|
|
23
|
+
default: {
|
|
24
|
+
bg: "bg-card",
|
|
25
|
+
border: "border-border",
|
|
26
|
+
text: "text-card-foreground",
|
|
27
|
+
progress: "bg-foreground/20",
|
|
28
|
+
icon: null,
|
|
29
|
+
},
|
|
30
|
+
success: {
|
|
31
|
+
bg: "bg-success-bg",
|
|
32
|
+
border: "border-success/40",
|
|
33
|
+
text: "text-success",
|
|
34
|
+
progress: "bg-success",
|
|
35
|
+
icon: CheckCircle2,
|
|
36
|
+
},
|
|
37
|
+
error: {
|
|
38
|
+
bg: "bg-destructive/10",
|
|
39
|
+
border: "border-destructive/30",
|
|
40
|
+
text: "text-destructive",
|
|
41
|
+
progress: "bg-destructive",
|
|
42
|
+
icon: AlertCircle,
|
|
43
|
+
},
|
|
44
|
+
warning: {
|
|
45
|
+
bg: "bg-warning-bg",
|
|
46
|
+
border: "border-warning/40",
|
|
47
|
+
text: "text-warning",
|
|
48
|
+
progress: "bg-warning",
|
|
49
|
+
icon: AlertTriangle,
|
|
50
|
+
},
|
|
51
|
+
info: {
|
|
52
|
+
bg: "bg-accent",
|
|
53
|
+
border: "border-primary/30",
|
|
54
|
+
text: "text-accent-foreground",
|
|
55
|
+
progress: "bg-primary",
|
|
56
|
+
icon: Info,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Root Toaster Component placed into layout.tsx
|
|
61
|
+
export function Toaster({ defaultDuration = 4000, position = "top-center" }: ToasterProps) {
|
|
62
|
+
const { toasts, dismiss } = useToast();
|
|
63
|
+
|
|
64
|
+
const positionClasses = {
|
|
65
|
+
"top-right": "top-4 right-4 items-end",
|
|
66
|
+
"top-left": "top-4 left-4 items-start",
|
|
67
|
+
"bottom-right": "bottom-4 right-4 items-end",
|
|
68
|
+
"bottom-left": "bottom-4 left-4 items-start",
|
|
69
|
+
"top-center": "top-4 left-1/2 -translate-x-1/2 items-center",
|
|
70
|
+
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2 items-center",
|
|
71
|
+
}[position];
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<style>{`
|
|
76
|
+
@keyframes toast-progress {
|
|
77
|
+
from {
|
|
78
|
+
transform: scaleX(1);
|
|
79
|
+
}
|
|
80
|
+
to {
|
|
81
|
+
transform: scaleX(0);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`}</style>
|
|
85
|
+
|
|
86
|
+
<div className={`fixed z-50 pointer-events-none flex flex-col gap-2 p-4 w-full max-w-sm ${positionClasses}`}>
|
|
87
|
+
{toasts.map((item) => (
|
|
88
|
+
<ToastElement
|
|
89
|
+
key={item.id}
|
|
90
|
+
toast={item}
|
|
91
|
+
defaultDuration={defaultDuration}
|
|
92
|
+
onDismiss={() => dismiss(item.id)}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Atomic Toast Card Component
|
|
101
|
+
function ToastElement({
|
|
102
|
+
toast,
|
|
103
|
+
defaultDuration,
|
|
104
|
+
onDismiss,
|
|
105
|
+
}: {
|
|
106
|
+
toast: ToastItem;
|
|
107
|
+
defaultDuration: number;
|
|
108
|
+
onDismiss: () => void;
|
|
109
|
+
}) {
|
|
110
|
+
const duration = toast.duration ?? defaultDuration;
|
|
111
|
+
|
|
112
|
+
React.useEffect(() => {
|
|
113
|
+
if (duration <= 0) return;
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
onDismiss();
|
|
116
|
+
}, duration);
|
|
117
|
+
return () => clearTimeout(timer);
|
|
118
|
+
}, [duration, onDismiss]);
|
|
119
|
+
|
|
120
|
+
const variant = toast.variant || "default";
|
|
121
|
+
const defaultStyle = variantStyles[variant] || variantStyles.default;
|
|
122
|
+
const IconComponent = defaultStyle.icon;
|
|
123
|
+
|
|
124
|
+
// 1. Only build inline styles for values that are explicitly provided
|
|
125
|
+
const customInlineStyle: React.CSSProperties = {};
|
|
126
|
+
if (toast.customColor?.bg) customInlineStyle.backgroundColor = toast.customColor.bg;
|
|
127
|
+
if (toast.customColor?.border) customInlineStyle.borderColor = toast.customColor.border;
|
|
128
|
+
if (toast.customColor?.text) customInlineStyle.color = toast.customColor.text;
|
|
129
|
+
|
|
130
|
+
// 2. Prevent default Tailwind classes from overriding user custom classes or inline styles
|
|
131
|
+
const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
|
|
132
|
+
const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
|
|
133
|
+
const userHasText = Boolean(toast.customColor?.text || toast.className?.match(/(?:^|\s)text-/));
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
style={customInlineStyle}
|
|
138
|
+
className={`pointer-events-auto relative overflow-hidden flex items-start gap-3 w-full p-4 rounded-[var(--radius-lg,0.625rem)] border shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
|
139
|
+
!userHasBg ? defaultStyle.bg : ""
|
|
140
|
+
} ${!userHasBorder ? defaultStyle.border : ""} ${
|
|
141
|
+
!userHasText ? defaultStyle.text : ""
|
|
142
|
+
} ${toast.className || ""}`}
|
|
143
|
+
>
|
|
144
|
+
{/* Render icon if preset defines one */}
|
|
145
|
+
{IconComponent && (
|
|
146
|
+
<IconComponent
|
|
147
|
+
className="w-5 h-5 mt-0.5 shrink-0"
|
|
148
|
+
style={{ color: toast.customColor?.icon }}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Toast Content Area */}
|
|
153
|
+
<div className="flex-1 text-sm space-y-1">
|
|
154
|
+
{toast.title && <div className="font-semibold leading-tight">{toast.title}</div>}
|
|
155
|
+
{toast.description && (
|
|
156
|
+
<div className="opacity-90 leading-relaxed text-xs">
|
|
157
|
+
{toast.description}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
{toast.action && <div className="pt-1">{toast.action}</div>}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Close button */}
|
|
164
|
+
<button
|
|
165
|
+
onClick={onDismiss}
|
|
166
|
+
className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-foreground/10 transition-colors"
|
|
167
|
+
>
|
|
168
|
+
<X className="w-4 h-4" />
|
|
169
|
+
</button>
|
|
170
|
+
|
|
171
|
+
{/* Animated Lifespan Progress Bar */}
|
|
172
|
+
{duration > 0 && (
|
|
173
|
+
<div
|
|
174
|
+
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${
|
|
175
|
+
!toast.customColor?.progress ? defaultStyle.progress : ""
|
|
176
|
+
}`}
|
|
177
|
+
style={{
|
|
178
|
+
backgroundColor: toast.customColor?.progress,
|
|
179
|
+
animation: `toast-progress ${duration}ms linear forwards`,
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Import React to access state hooks and ReactNode type definitions
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
// Define the supported visual preset types for toast notifications
|
|
7
|
+
export type ToastVariant = "default" | "success" | "error" | "warning" | "info" | "custom";
|
|
8
|
+
|
|
9
|
+
// Interface defining all configurable parameters when triggering a toast notification
|
|
10
|
+
export interface ToastOptions {
|
|
11
|
+
// Unique identifier for toast; auto-generated if omitted
|
|
12
|
+
id?: string;
|
|
13
|
+
// Primary header text or React component
|
|
14
|
+
title?: React.ReactNode;
|
|
15
|
+
// Secondary descriptive message or details
|
|
16
|
+
description?: React.ReactNode;
|
|
17
|
+
// Optional action button or interactive element
|
|
18
|
+
action?: React.ReactNode;
|
|
19
|
+
// Visual style preset (success, error, warning, info, default, custom)
|
|
20
|
+
variant?: ToastVariant;
|
|
21
|
+
// Individual lifespan in milliseconds; overrides the global layout default
|
|
22
|
+
duration?: number;
|
|
23
|
+
// User-defined custom styling parameters for dynamic themes
|
|
24
|
+
customColor?: {
|
|
25
|
+
// Custom CSS background color (HEX, RGB, or HSL)
|
|
26
|
+
bg?: string;
|
|
27
|
+
// Custom text color
|
|
28
|
+
text?: string;
|
|
29
|
+
// Custom border stroke color
|
|
30
|
+
border?: string;
|
|
31
|
+
//Custom progress bar stroke color
|
|
32
|
+
progress?: string;
|
|
33
|
+
// Custom icon fill/stroke tint
|
|
34
|
+
icon?: string;
|
|
35
|
+
};
|
|
36
|
+
// Additional Tailwind or custom CSS classes applied to toast container
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Internal representation of an active toast item containing open state
|
|
41
|
+
export interface ToastItem extends ToastOptions {
|
|
42
|
+
// Guaranteed string ID for DOM key mapping
|
|
43
|
+
id: string;
|
|
44
|
+
// Boolean flag controlling entrance and exit animations
|
|
45
|
+
open: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Maximum number of visible toast cards on screen simultaneously
|
|
49
|
+
const TOAST_LIMIT = 5;
|
|
50
|
+
// Delay before removed toasts are completely purged from memory (allows exit transition)
|
|
51
|
+
const TOAST_REMOVE_DELAY = 1000;
|
|
52
|
+
|
|
53
|
+
// Discriminated union type representing all possible reducer actions
|
|
54
|
+
type Action =
|
|
55
|
+
// Adds a newly triggered toast to state
|
|
56
|
+
| { type: "ADD_TOAST"; toast: ToastItem }
|
|
57
|
+
// Modifies properties of an existing active toast
|
|
58
|
+
| { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
|
|
59
|
+
// Initiates dismiss sequence (triggers exit animation)
|
|
60
|
+
| { type: "DISMISS_TOAST"; toastId?: string }
|
|
61
|
+
// Purges toast object from memory after exit animation finishes
|
|
62
|
+
| { type: "REMOVE_TOAST"; toastId?: string };
|
|
63
|
+
|
|
64
|
+
// Structure of global toast memory state
|
|
65
|
+
interface State {
|
|
66
|
+
toasts: ToastItem[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Monotonically increasing counter for collision-free ID generation
|
|
70
|
+
let count = 0;
|
|
71
|
+
|
|
72
|
+
// Generates unique string identifiers for toast items
|
|
73
|
+
function genId(): string {
|
|
74
|
+
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
|
75
|
+
return count.toString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Map tracking active removal timers to prevent duplicate schedule queues
|
|
79
|
+
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
80
|
+
|
|
81
|
+
// Schedule the hard removal of a dismissed toast after its exit animation completes
|
|
82
|
+
const addToRemoveQueue = (toastId: string) => {
|
|
83
|
+
// If a timeout is already scheduled for this ID, skip to avoid duplicates
|
|
84
|
+
if (toastTimeouts.has(toastId)) return;
|
|
85
|
+
|
|
86
|
+
// Schedule state dispatch after delay
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
// Clean up timeout reference from tracking map
|
|
89
|
+
toastTimeouts.delete(toastId);
|
|
90
|
+
// Dispatch removal action to purge from state
|
|
91
|
+
dispatch({ type: "REMOVE_TOAST", toastId });
|
|
92
|
+
}, TOAST_REMOVE_DELAY);
|
|
93
|
+
|
|
94
|
+
// Store reference in tracking map
|
|
95
|
+
toastTimeouts.set(toastId, timeout);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Pure reducer function handling toast state transitions
|
|
99
|
+
export const reducer = (state: State, action: Action): State => {
|
|
100
|
+
switch (action.type) {
|
|
101
|
+
case "ADD_TOAST":
|
|
102
|
+
return {
|
|
103
|
+
...state,
|
|
104
|
+
// Prepend new toast and enforce maximum visible limit
|
|
105
|
+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
case "UPDATE_TOAST":
|
|
109
|
+
return {
|
|
110
|
+
...state,
|
|
111
|
+
// Map through toasts and merge updated properties onto target ID
|
|
112
|
+
toasts: state.toasts.map((t) =>
|
|
113
|
+
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
case "DISMISS_TOAST": {
|
|
118
|
+
const { toastId } = action;
|
|
119
|
+
|
|
120
|
+
// If a specific ID is provided, schedule removal for only that toast
|
|
121
|
+
if (toastId) {
|
|
122
|
+
addToRemoveQueue(toastId);
|
|
123
|
+
} else {
|
|
124
|
+
// Otherwise schedule removal for all currently open toasts
|
|
125
|
+
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
...state,
|
|
130
|
+
// Mark target toasts as closed to trigger CSS fade-out
|
|
131
|
+
toasts: state.toasts.map((t) =>
|
|
132
|
+
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
|
|
133
|
+
),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "REMOVE_TOAST":
|
|
138
|
+
// Clear entire array if no specific ID passed
|
|
139
|
+
if (action.toastId === undefined) return { ...state, toasts: [] };
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...state,
|
|
143
|
+
// Filter out target toast from state memory
|
|
144
|
+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
default:
|
|
148
|
+
return state;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Array of subscriber callbacks implementing the Observer pattern
|
|
153
|
+
const listeners: Array<(state: State) => void> = [];
|
|
154
|
+
|
|
155
|
+
// Singleton state variable preserving toast state across entire application
|
|
156
|
+
let memoryState: State = { toasts: [] };
|
|
157
|
+
|
|
158
|
+
// Dispatches actions to state and notifies all registered React hook subscribers
|
|
159
|
+
function dispatch(action: Action) {
|
|
160
|
+
// Update in-memory singleton state
|
|
161
|
+
memoryState = reducer(memoryState, action);
|
|
162
|
+
// Notify every mounted React component listener
|
|
163
|
+
listeners.forEach((listener) => listener(memoryState));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Imperative toast function callable from anywhere (inside or outside React lifecycle)
|
|
167
|
+
export function toast(props: ToastOptions) {
|
|
168
|
+
// Use provided ID or generate a new unique identifier
|
|
169
|
+
const id = props.id || genId();
|
|
170
|
+
|
|
171
|
+
// Helper to dynamically update this specific toast
|
|
172
|
+
const update = (updatedProps: ToastOptions) =>
|
|
173
|
+
dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
|
|
174
|
+
|
|
175
|
+
// Helper to dismiss this specific toast
|
|
176
|
+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
|
177
|
+
|
|
178
|
+
// Dispatch action to push toast into visible queue
|
|
179
|
+
dispatch({
|
|
180
|
+
type: "ADD_TOAST",
|
|
181
|
+
toast: {
|
|
182
|
+
...props,
|
|
183
|
+
id,
|
|
184
|
+
open: true,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Return control object allowing caller to dismiss or update toast programmatically
|
|
189
|
+
return { id, dismiss, update };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Custom React hook subscribing components to real-time toast updates
|
|
193
|
+
export function useToast() {
|
|
194
|
+
// Local state synced with singleton memory state
|
|
195
|
+
const [state, setState] = React.useState<State>(memoryState);
|
|
196
|
+
|
|
197
|
+
// Register listener on mount; unregister on unmount
|
|
198
|
+
React.useEffect(() => {
|
|
199
|
+
listeners.push(setState);
|
|
200
|
+
return () => {
|
|
201
|
+
const index = listeners.indexOf(setState);
|
|
202
|
+
if (index > -1) {
|
|
203
|
+
listeners.splice(index, 1);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}, []); // Empty array ensures registration only happens on mount/unmount
|
|
207
|
+
|
|
208
|
+
// Expose current state, trigger function, and dismiss helper
|
|
209
|
+
return {
|
|
210
|
+
...state,
|
|
211
|
+
toast,
|
|
212
|
+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
213
|
+
};
|
|
214
|
+
}
|