@varialkit/toast 0.1.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/docs.md ADDED
@@ -0,0 +1,68 @@
1
+ # Toast
2
+
3
+ Toast provides lightweight notification messaging with optional actions and animated stacking.
4
+
5
+ ## Setup
6
+
7
+ Wrap your application with `ToastProvider` so child components can enqueue notifications.
8
+
9
+ ```tsx
10
+ import { ToastProvider } from "@solara/toast";
11
+
12
+ export function App() {
13
+ return (
14
+ <ToastProvider dismissDuration={5000}>
15
+ {/* app content */}
16
+ </ToastProvider>
17
+ );
18
+ }
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Use the convenience hook for common variants:
24
+
25
+ ```tsx
26
+ import { useToast } from "@solara/toast/use-toast";
27
+
28
+ export function SaveButton() {
29
+ const { success, error } = useToast();
30
+
31
+ return (
32
+ <button
33
+ type="button"
34
+ onClick={() => success("Saved", "Your changes are live.")}
35
+ >
36
+ Save
37
+ </button>
38
+ );
39
+ }
40
+ ```
41
+
42
+ ## API Notes
43
+
44
+ - `ToastProvider` controls default dismissal timing with `dismissDuration`.
45
+ - `useToast` (low-level) exposes `addToast`, `dismissToast`, and the current `toasts` list.
46
+ - Toasts support `variant`, `duration`, `direction`, `action`, and optional `icon`.
47
+
48
+ ## Icons
49
+
50
+ You can provide an icon name or full icon props. Use `showIcon={false}` to hide it.
51
+ Icons inherit the toast text color.
52
+
53
+ ```tsx
54
+ toast({
55
+ title: "Saved",
56
+ description: "Your changes are live.",
57
+ iconName: "data_spreadsheet_search_24",
58
+ });
59
+ ```
60
+
61
+ ## Tokens Used
62
+
63
+ The toast styles rely on `@solara/styles` tokens for spacing, typography, colors, elevation, and radius:
64
+
65
+ - `--space-*`, `--spacing-multiplier`
66
+ - `--font-body`, `--font-size-caption-scaled`, `--line-height-caption-scaled`
67
+ - `--color-surface-*`, `--color-text-*`, `--color-divider-*`
68
+ - `--elevation-*`, `--radius-*`
package/examples.tsx ADDED
@@ -0,0 +1,534 @@
1
+ import React from "react";
2
+ import { iconNames } from "@solara/icons";
3
+ import type { SolaraIconName } from "@solara/icons";
4
+ import { ToastProvider } from "./src/Toast";
5
+ import { useToast as useToastConvenience } from "./src/use-toast";
6
+ import { useToast as useToastContext } from "./src/useToast";
7
+ import type { ToastVariant } from "./src/Toast.types";
8
+
9
+ const panelStyle: React.CSSProperties = {
10
+ display: "flex",
11
+ flexDirection: "column",
12
+ gap: "1rem",
13
+ padding: "1.5rem",
14
+ borderRadius: "12px",
15
+ border: "1px solid var(--color-divider-secondary)",
16
+ background: "var(--color-surface-0)",
17
+ fontFamily: "var(--font-body)",
18
+ };
19
+
20
+ const buttonStyle: React.CSSProperties = {
21
+ padding: "0.5rem 0.85rem",
22
+ borderRadius: "999px",
23
+ border: "1px solid var(--color-divider-primary)",
24
+ background: "var(--color-surface-0)",
25
+ color: "var(--color-text-primary)",
26
+ fontFamily: "var(--font-body)",
27
+ fontSize: "0.875rem",
28
+ cursor: "pointer",
29
+ };
30
+
31
+ const PrimaryButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
32
+ <button type="button" onClick={onClick} style={buttonStyle}>
33
+ {label}
34
+ </button>
35
+ );
36
+
37
+ const ToastPlayground = ({
38
+ title,
39
+ description,
40
+ variant,
41
+ duration,
42
+ direction,
43
+ withAction,
44
+ iconName,
45
+ showIcon,
46
+ }: {
47
+ title: string;
48
+ description?: string;
49
+ variant?: ToastVariant;
50
+ duration?: number;
51
+ direction?: "left" | "right";
52
+ withAction?: boolean;
53
+ iconName?: SolaraIconName | "";
54
+ showIcon?: boolean;
55
+ }) => {
56
+ const { toast } = useToastConvenience();
57
+
58
+ return (
59
+ <div style={panelStyle}>
60
+ <div>
61
+ <strong style={{ color: "var(--color-text-primary)" }}>Toast Playground</strong>
62
+ <p style={{ color: "var(--color-text-secondary)", margin: "0.5rem 0 0" }}>
63
+ Trigger a toast using the controls on the right panel.
64
+ </p>
65
+ </div>
66
+ <PrimaryButton
67
+ label="Show Toast"
68
+ onClick={() =>
69
+ toast({
70
+ title,
71
+ description,
72
+ variant,
73
+ duration,
74
+ direction,
75
+ iconName: iconName || undefined,
76
+ showIcon,
77
+ action: withAction
78
+ ? {
79
+ label: "Undo",
80
+ onClick: () => {
81
+ console.log("Toast action clicked");
82
+ },
83
+ }
84
+ : undefined,
85
+ })
86
+ }
87
+ />
88
+ </div>
89
+ );
90
+ };
91
+
92
+ const ToastVariants = () => {
93
+ const { toast } = useToastConvenience();
94
+
95
+ return (
96
+ <div style={{ ...panelStyle, flexDirection: "row", flexWrap: "wrap" }}>
97
+ <PrimaryButton
98
+ label="Success"
99
+ onClick={() =>
100
+ toast({
101
+ title: "Success",
102
+ description: "Changes saved.",
103
+ variant: "success",
104
+ iconName: "arrow_line_up_16",
105
+ })
106
+ }
107
+ />
108
+ <PrimaryButton
109
+ label="Error"
110
+ onClick={() =>
111
+ toast({
112
+ title: "Error",
113
+ description: "Something went wrong.",
114
+ variant: "destructive",
115
+ iconName: "arrow_swap_16",
116
+ })
117
+ }
118
+ />
119
+ <PrimaryButton
120
+ label="Warning"
121
+ onClick={() =>
122
+ toast({
123
+ title: "Warning",
124
+ description: "Check your inputs.",
125
+ variant: "warning",
126
+ iconName: "arrow_chevron_down_16",
127
+ })
128
+ }
129
+ />
130
+ <PrimaryButton
131
+ label="Info"
132
+ onClick={() =>
133
+ toast({
134
+ title: "Info",
135
+ description: "Update available.",
136
+ variant: "info",
137
+ iconName: "data_spreadsheet_search_24",
138
+ })
139
+ }
140
+ />
141
+ </div>
142
+ );
143
+ };
144
+
145
+ const ToastWithAction = () => {
146
+ const { toast } = useToastConvenience();
147
+
148
+ return (
149
+ <div style={panelStyle}>
150
+ <PrimaryButton
151
+ label="Show Action Toast"
152
+ onClick={() =>
153
+ toast({
154
+ title: "Message sent",
155
+ description: "Your update is live.",
156
+ variant: "success",
157
+ action: {
158
+ label: "Undo",
159
+ onClick: () => {
160
+ console.log("Undo");
161
+ },
162
+ },
163
+ })
164
+ }
165
+ />
166
+ </div>
167
+ );
168
+ };
169
+
170
+ const ToastWithDismiss = () => {
171
+ const { toast } = useToastConvenience();
172
+
173
+ return (
174
+ <div style={panelStyle}>
175
+ <PrimaryButton
176
+ label="Show Dismissible Toast"
177
+ onClick={() =>
178
+ toast({
179
+ title: "Dismissible Toast",
180
+ description: "This toast can be dismissed.",
181
+ variant: "info",
182
+ duration: 10000, // Set a longer duration for dismissible toast
183
+ })
184
+ }
185
+ />
186
+ </div>
187
+ );
188
+ };
189
+
190
+ const ToastStacking = () => {
191
+ const { addToast } = useToastContext();
192
+
193
+ return (
194
+ <div style={panelStyle}>
195
+ <PrimaryButton
196
+ label="Queue 6 Toasts"
197
+ onClick={() => {
198
+ Array.from({ length: 6 }).forEach((_, index) => {
199
+ addToast({
200
+ title: `Toast ${index + 1}`,
201
+ description: "Stacking demo",
202
+ variant: index % 2 === 0 ? "info" : "default",
203
+ });
204
+ });
205
+ }}
206
+ />
207
+ </div>
208
+ );
209
+ };
210
+
211
+ export const stories = {
212
+ playground: {
213
+ title: "Playground",
214
+ description: "Customize the toast and trigger it on demand.",
215
+ render: (props: {
216
+ title: string;
217
+ description?: string;
218
+ variant?: ToastVariant;
219
+ duration?: number;
220
+ direction?: "left" | "right";
221
+ withAction?: boolean;
222
+ iconName?: SolaraIconName | "";
223
+ showIcon?: boolean;
224
+ }) => (
225
+ <ToastProvider>
226
+ <ToastPlayground {...props} />
227
+ </ToastProvider>
228
+ ),
229
+ controls: [
230
+ { name: "title", type: "text" },
231
+ { name: "description", type: "text" },
232
+ {
233
+ name: "variant",
234
+ type: "select",
235
+ options: ["default", "success", "destructive", "warning", "info"],
236
+ },
237
+ { name: "duration", type: "number", min: 1000, max: 10000, step: 500 },
238
+ { name: "direction", type: "select", options: ["right", "left"] },
239
+ { name: "withAction", type: "boolean" },
240
+ { name: "showIcon", type: "boolean" },
241
+ { name: "iconName", type: "select", options: ["", ...iconNames] },
242
+ ],
243
+ initialProps: {
244
+ title: "Update saved",
245
+ description: "Your changes have been published.",
246
+ variant: "success",
247
+ duration: 5000,
248
+ direction: "right",
249
+ withAction: false,
250
+ showIcon: false,
251
+ iconName: "",
252
+ },
253
+ },
254
+ variants: {
255
+ title: "Variants",
256
+ description: "Common toast variants for state feedback.",
257
+ showProps: false,
258
+ render: () => (
259
+ <ToastProvider>
260
+ <ToastVariants />
261
+ </ToastProvider>
262
+ ),
263
+ code: `import React from "react";
264
+ import { ToastProvider } from "@solara/toast";
265
+ import { useToast } from "@solara/toast";
266
+
267
+ const buttonStyle: React.CSSProperties = {
268
+ padding: "0.5rem 0.85rem",
269
+ borderRadius: "999px",
270
+ border: "1px solid var(--color-divider-primary)",
271
+ background: "var(--color-surface-0)",
272
+ color: "var(--color-text-primary)",
273
+ fontFamily: "var(--font-body)",
274
+ fontSize: "0.875rem",
275
+ cursor: "pointer",
276
+ };
277
+
278
+ const PrimaryButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
279
+ <button type="button" onClick={onClick} style={buttonStyle}>
280
+ {label}
281
+ </button>
282
+ );
283
+
284
+ const ToastVariants = () => {
285
+ const { toast } = useToast();
286
+
287
+ return (
288
+ <div style={{ display: "flex", flexDirection: "row", flexWrap: "wrap", gap: "1rem" }}>
289
+ <PrimaryButton
290
+ label="Success"
291
+ onClick={() =>
292
+ toast({
293
+ title: "Success",
294
+ description: "Changes saved.",
295
+ variant: "success",
296
+ iconName: "arrow_line_up_16",
297
+ })
298
+ }
299
+ />
300
+ <PrimaryButton
301
+ label="Error"
302
+ onClick={() =>
303
+ toast({
304
+ title: "Error",
305
+ description: "Something went wrong.",
306
+ variant: "destructive",
307
+ iconName: "arrow_swap_16",
308
+ })
309
+ }
310
+ />
311
+ <PrimaryButton
312
+ label="Warning"
313
+ onClick={() =>
314
+ toast({
315
+ title: "Warning",
316
+ description: "Check your inputs.",
317
+ variant: "warning",
318
+ iconName: "arrow_chevron_down_16",
319
+ })
320
+ }
321
+ />
322
+ <PrimaryButton
323
+ label="Info"
324
+ onClick={() =>
325
+ toast({
326
+ title: "Info",
327
+ description: "Update available.",
328
+ variant: "info",
329
+ iconName: "data_spreadsheet_search_24",
330
+ })
331
+ }
332
+ />
333
+ </div>
334
+ );
335
+ };
336
+
337
+ export function Example() {
338
+ return (
339
+ <ToastProvider>
340
+ <ToastVariants />
341
+ </ToastProvider>
342
+ );
343
+ }
344
+ `,
345
+ },
346
+ withAction: {
347
+ title: "With Action",
348
+ description: "Include an optional inline action button.",
349
+ showProps: false,
350
+ render: () => (
351
+ <ToastProvider>
352
+ <ToastWithAction />
353
+ </ToastProvider>
354
+ ),
355
+ code: `import React from "react";
356
+ import { ToastProvider } from "@solara/toast";
357
+ import { useToast } from "@solara/toast";
358
+
359
+ const buttonStyle: React.CSSProperties = {
360
+ padding: "0.5rem 0.85rem",
361
+ borderRadius: "999px",
362
+ border: "1px solid var(--color-divider-primary)",
363
+ background: "var(--color-surface-0)",
364
+ color: "var(--color-text-primary)",
365
+ fontFamily: "var(--font-body)",
366
+ fontSize: "0.875rem",
367
+ cursor: "pointer",
368
+ };
369
+
370
+ const PrimaryButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
371
+ <button type="button" onClick={onClick} style={buttonStyle}>
372
+ {label}
373
+ </button>
374
+ );
375
+
376
+ const ToastWithAction = () => {
377
+ const { toast } = useToast();
378
+
379
+ return (
380
+ <div>
381
+ <PrimaryButton
382
+ label="Show Action Toast"
383
+ onClick={() =>
384
+ toast({
385
+ title: "Message sent",
386
+ description: "Your update is live.",
387
+ variant: "success",
388
+ action: {
389
+ label: "Undo",
390
+ onClick: () => {
391
+ console.log("Undo");
392
+ },
393
+ },
394
+ })
395
+ }
396
+ />
397
+ </div>
398
+ );
399
+ };
400
+
401
+ export function Example() {
402
+ return (
403
+ <ToastProvider>
404
+ <ToastWithAction />
405
+ </ToastProvider>
406
+ );
407
+ }
408
+ `,
409
+ },
410
+ stacking: {
411
+ title: "ToastContainer behavior",
412
+ description: "Queue more than five toasts to preview stacking limits.",
413
+ showProps: false,
414
+ render: () => (
415
+ <ToastProvider>
416
+ <ToastStacking />
417
+ </ToastProvider>
418
+ ),
419
+ code: `import React from "react";
420
+ import { ToastProvider } from "@solara/toast";
421
+ import { useToast } from "@solara/toast";
422
+
423
+ const buttonStyle: React.CSSProperties = {
424
+ padding: "0.5rem 0.85rem",
425
+ borderRadius: "999px",
426
+ border: "1px solid var(--color-divider-primary)",
427
+ background: "var(--color-surface-0)",
428
+ color: "var(--color-text-primary)",
429
+ fontFamily: "var(--font-body)",
430
+ fontSize: "0.875rem",
431
+ cursor: "pointer",
432
+ };
433
+
434
+ const PrimaryButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
435
+ <button type="button" onClick={onClick} style={buttonStyle}>
436
+ {label}
437
+ </button>
438
+ );
439
+
440
+ const ToastStacking = () => {
441
+ const { addToast } = useToast();
442
+
443
+ return (
444
+ <div>
445
+ <PrimaryButton
446
+ label="Queue 6 Toasts"
447
+ onClick={() => {
448
+ Array.from({ length: 6 }).forEach((_, index) => {
449
+ addToast({
450
+ title: \`Toast \${index + 1}\`,
451
+ description: "Stacking demo",
452
+ variant: index % 2 === 0 ? "info" : "default",
453
+ });
454
+ });
455
+ }}
456
+ />
457
+ </div>
458
+ );
459
+ };
460
+
461
+ export function Example() {
462
+ return (
463
+ <ToastProvider>
464
+ <ToastStacking />
465
+ </ToastProvider>
466
+ );
467
+ }
468
+ `,
469
+ },
470
+ withButton: {
471
+ title: "With Button",
472
+ description: "A toast with a tertiary button.",
473
+ showProps: false,
474
+ render: () => (
475
+ <ToastProvider>
476
+ <ToastWithAction />
477
+ </ToastProvider>
478
+ ),
479
+ code: `import React from "react";
480
+ import { ToastProvider } from "@solara/toast";
481
+ import { useToast } from "@solara/toast";
482
+
483
+ const buttonStyle: React.CSSProperties = {
484
+ padding: "0.5rem 0.85rem",
485
+ borderRadius: "999px",
486
+ border: "1px solid var(--color-divider-primary)",
487
+ background: "var(--color-surface-0)",
488
+ color: "var(--color-text-primary)",
489
+ fontFamily: "var(--font-body)",
490
+ fontSize: "0.875rem",
491
+ cursor: "pointer",
492
+ };
493
+
494
+ const PrimaryButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
495
+ <button type="button" onClick={onClick} style={buttonStyle}>
496
+ {label}
497
+ </button>
498
+ );
499
+
500
+ const ToastWithAction = () => {
501
+ const { toast } = useToast();
502
+
503
+ return (
504
+ <div>
505
+ <PrimaryButton
506
+ label="Show Action Toast"
507
+ onClick={() =>
508
+ toast({
509
+ title: "Message sent",
510
+ description: "Your update is live.",
511
+ variant: "success",
512
+ action: {
513
+ label: "Undo",
514
+ onClick: () => {
515
+ console.log("Undo");
516
+ },
517
+ },
518
+ })
519
+ }
520
+ />
521
+ </div>
522
+ );
523
+ };
524
+
525
+ export function Example() {
526
+ return (
527
+ <ToastProvider>
528
+ <ToastWithAction />
529
+ </ToastProvider>
530
+ );
531
+ }
532
+ `,
533
+ },
534
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@varialkit/toast",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples.tsx",
10
+ "./use-toast": "./src/use-toast.ts"
11
+ },
12
+ "dependencies": {
13
+ "@varialkit/icons": "0.1.0"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "docs.md",
18
+ "examples.tsx"
19
+ ],
20
+ "peerDependencies": {
21
+ "react": "^19.0.0",
22
+ "framer-motion": "^11.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "19.0.10",
26
+ "react": "19.0.0",
27
+ "framer-motion": "11.0.0"
28
+ }
29
+ }
package/src/Toast.scss ADDED
@@ -0,0 +1,185 @@
1
+ .solara-toast-container {
2
+ position: fixed;
3
+ z-index: 9999;
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: calc(var(--space-3) * var(--spacing-multiplier));
7
+ width: min(100%, 24rem);
8
+ padding: 0;
9
+ margin: 0;
10
+ pointer-events: none;
11
+ }
12
+
13
+ .solara-toast-container--top-right {
14
+ top: var(--space-4);
15
+ right: var(--space-4);
16
+ align-items: flex-end;
17
+ }
18
+
19
+ .solara-toast-container--top-left {
20
+ top: var(--space-4);
21
+ left: var(--space-4);
22
+ align-items: flex-start;
23
+ }
24
+
25
+ .solara-toast-container--bottom-right {
26
+ bottom: var(--space-4);
27
+ right: var(--space-4);
28
+ align-items: flex-end;
29
+ }
30
+
31
+ .solara-toast-container--bottom-left {
32
+ bottom: var(--space-4);
33
+ left: var(--space-4);
34
+ align-items: flex-start;
35
+ }
36
+
37
+ .solara-toast-wrapper {
38
+ position: relative;
39
+ width: 100%;
40
+ max-width: 24rem;
41
+ pointer-events: auto;
42
+ overflow: hidden;
43
+ transition: opacity 0.2s ease;
44
+ will-change: transform, opacity;
45
+ }
46
+
47
+ .solara-toast-wrapper--dismissed {
48
+ opacity: 0;
49
+ }
50
+
51
+ .solara-toast {
52
+ --toast-bg: var(--color-surface-0);
53
+ --toast-text: var(--color-text-primary);
54
+ --toast-description: var(--color-text-secondary);
55
+ --toast-border: var(--color-divider-secondary);
56
+
57
+ position: relative;
58
+ width: 100%;
59
+ border-radius: var(--radius-pill);
60
+ border: 1px solid hsl(from var(--toast-border) h s l / 0.8);
61
+ background-color: var(--toast-bg);
62
+ color: var(--toast-text);
63
+ box-shadow: var(--elevation-2);
64
+ backdrop-filter: blur(12px);
65
+ -webkit-backdrop-filter: blur(12px);
66
+ overflow: hidden;
67
+ transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
68
+ font-family: var(--font-body);
69
+ }
70
+
71
+ .solara-toast--default {
72
+ --toast-bg: var(--color-content-gray-light);
73
+ --toast-text: var(--color-content-gray-dark);
74
+ --toast-border: var(--color-content-gray-med);
75
+ }
76
+
77
+ .solara-toast--success {
78
+ --toast-bg: var(--color-content-green-light);
79
+ --toast-text: var(--color-content-green-dark);
80
+ --toast-border: var(--color-content-green-med);
81
+ }
82
+
83
+ .solara-toast--destructive {
84
+ --toast-bg: var(--color-content-red-light);
85
+ --toast-text: var(--color-content-red-dark);
86
+ --toast-border: var(--color-content-red-med);
87
+ }
88
+
89
+ .solara-toast--warning {
90
+ --toast-bg: var(--color-content-yellow-light);
91
+ --toast-text: var(--color-content-yellow-dark);
92
+ --toast-border: var(--color-content-yellow-med);
93
+ }
94
+
95
+ .solara-toast--info {
96
+ --toast-bg: var(--color-content-blue-light);
97
+ --toast-text: var(--color-content-blue-dark);
98
+ --toast-border: var(--color-content-blue-med);
99
+ }
100
+
101
+ .solara-toast__content {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
106
+ padding: calc(var(--space-2) * var(--spacing-multiplier)) calc(var(--space-6) * var(--spacing-multiplier)) calc(var(--space-2) * var(--spacing-multiplier)) calc(var(--space-4) * var(--spacing-multiplier));
107
+ }
108
+
109
+ .solara-toast__icon {
110
+ flex-shrink: 0;
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ width: calc(var(--space-5) * var(--spacing-multiplier));
115
+ height: calc(var(--space-5) * var(--spacing-multiplier));
116
+ color: currentColor;
117
+ }
118
+
119
+ .solara-toast__icon .solara-icon [stroke]:not([stroke="none"]) {
120
+ stroke: currentColor;
121
+ }
122
+
123
+ .solara-toast__icon .solara-icon [fill]:not([fill="none"]) {
124
+ fill: currentColor;
125
+ }
126
+
127
+ .solara-toast__message {
128
+ flex: 1;
129
+ min-width: 0;
130
+ }
131
+
132
+ .solara-toast__actions {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
136
+ }
137
+
138
+ .solara-toast__title {
139
+ margin: 0 0 calc(var(--space-1) * var(--spacing-multiplier)) 0;
140
+ font-weight: 600;
141
+ font-size: var(--font-size-caption-scaled);
142
+ line-height: var(--line-height-caption-scaled);
143
+ color: currentColor;
144
+ }
145
+
146
+ .solara-toast__description {
147
+ margin: 0;
148
+ font-size: var(--font-size-caption-scaled);
149
+ line-height: var(--line-height-caption-scaled);
150
+ color: var(--toast-description);
151
+ }
152
+
153
+ .solara-toast__action-wrapper {
154
+ display: flex;
155
+ align-items: center;
156
+ margin-left: auto;
157
+ padding-left: calc(var(--space-2) * var(--spacing-multiplier));
158
+ }
159
+
160
+ .solara-toast__close {
161
+ position: relative;
162
+ top: auto;
163
+ right: auto;
164
+ transform: none;
165
+ display: inline-flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ width: calc(var(--space-4) * var(--spacing-multiplier));
169
+ height: calc(var(--space-4) * var(--spacing-multiplier));
170
+ padding: 0;
171
+ background: transparent;
172
+ border: none;
173
+ border-radius: var(--radius-1);
174
+ color: var(--color-text-secondary);
175
+ cursor: pointer;
176
+ opacity: 0.7;
177
+ transition: opacity 0.2s ease, background-color 0.2s ease;
178
+ }
179
+
180
+ .solara-toast__close:hover,
181
+ .solara-toast__close:focus-visible {
182
+ opacity: 1;
183
+ background-color: var(--color-surface-200);
184
+ outline: none;
185
+ }
package/src/Toast.tsx ADDED
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Button } from "@solara/button";
5
+ import { Icon } from "@solara/icons";
6
+ import type { IconProps } from "@solara/icons";
7
+ import type { ToastContextValue, ToastOptions, ToastProps } from "./Toast.types";
8
+ import { ToastContainer } from "./ToastContainer";
9
+ import "./Toast.scss";
10
+
11
+ const classNames = (...classes: Array<string | undefined | false | null>) =>
12
+ classes.filter(Boolean).join(" ");
13
+
14
+ type ToastIcon = IconProps | IconProps["name"];
15
+
16
+ const normalizeIconProps = (icon: ToastIcon): IconProps =>
17
+ typeof icon === "string" ? { name: icon } : icon;
18
+
19
+ const resolveIconProps = (icon: ToastIcon): IconProps => {
20
+ const iconProps = normalizeIconProps(icon);
21
+
22
+ return {
23
+ ...iconProps,
24
+ style: {
25
+ ...iconProps.style,
26
+ color: "currentColor",
27
+ },
28
+ };
29
+ };
30
+
31
+ export const Toast = React.forwardRef<
32
+ HTMLDivElement,
33
+ ToastProps & { createdAt?: number; isDismissed?: boolean }
34
+ >(
35
+ (
36
+ {
37
+ title,
38
+ description,
39
+ variant = "default",
40
+ icon,
41
+ iconName,
42
+ showIcon = true,
43
+ action,
44
+ onDismiss,
45
+ className,
46
+ duration,
47
+ direction,
48
+ createdAt,
49
+ isDismissed,
50
+ ...rest
51
+ },
52
+ ref
53
+ ) => {
54
+ void duration;
55
+ void direction;
56
+ const resolvedIcon =
57
+ showIcon === false
58
+ ? null
59
+ : icon
60
+ ? icon
61
+ : iconName
62
+ ? <Icon {...resolveIconProps(iconName)} />
63
+ : null;
64
+
65
+ return (
66
+ <div
67
+ ref={ref}
68
+ className={classNames("solara-toast", `solara-toast--${variant}`, className)}
69
+ role="alert"
70
+ {...rest}
71
+ >
72
+ <div className="solara-toast__content">
73
+ {resolvedIcon ? <div className="solara-toast__icon">{resolvedIcon}</div> : null}
74
+ <div className="solara-toast__message">
75
+ <h3 className="solara-toast__title">{title}</h3>
76
+ {description ? (
77
+ <p className="solara-toast__description">{description}</p>
78
+ ) : null}
79
+ </div>
80
+ <div className="solara-toast__actions">
81
+ {action ? (
82
+ <div className="solara-toast__action-wrapper">
83
+ <Button
84
+ variant="tertiary"
85
+ size="small"
86
+ label={action.label}
87
+ onClick={action.onClick}
88
+ />
89
+ </div>
90
+ ) : null}
91
+ {onDismiss ? (
92
+ <button
93
+ type="button"
94
+ className="solara-toast__close"
95
+ onClick={onDismiss}
96
+ aria-label="Close"
97
+ >
98
+ <Icon name="x_16" size={14} aria-hidden />
99
+ </button>
100
+ ) : null}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
106
+ );
107
+
108
+ Toast.displayName = "Toast";
109
+
110
+ export const ToastContext = React.createContext<ToastContextValue | undefined>(
111
+ undefined
112
+ );
113
+
114
+ export function ToastProvider({
115
+ children,
116
+ dismissDuration = 5000,
117
+ }: {
118
+ children: React.ReactNode;
119
+ dismissDuration?: number;
120
+ }) {
121
+ const [toasts, setToasts] = React.useState<
122
+ Array<ToastOptions & { id: string; createdAt: number; isDismissed?: boolean }>
123
+ >([]);
124
+
125
+ const addToast = React.useCallback((toast: ToastOptions) => {
126
+ const id = Math.random().toString(36).substring(2, 9);
127
+ const newToast = {
128
+ ...toast,
129
+ id,
130
+ createdAt: Date.now(),
131
+ isDismissed: false,
132
+ };
133
+ setToasts((prev) => [...prev, newToast]);
134
+ return id;
135
+ }, []);
136
+
137
+ const dismissToast = React.useCallback((id: string) => {
138
+ setToasts((prev) =>
139
+ prev.map((toast) =>
140
+ toast.id === id
141
+ ? {
142
+ ...toast,
143
+ isDismissed: true,
144
+ }
145
+ : toast
146
+ )
147
+ );
148
+
149
+ setTimeout(() => {
150
+ setToasts((prev) => prev.filter((toast) => toast.id !== id));
151
+ }, 300);
152
+ }, []);
153
+
154
+ const value = React.useMemo(
155
+ () => ({
156
+ toasts,
157
+ addToast,
158
+ dismissToast,
159
+ }),
160
+ [toasts, addToast, dismissToast]
161
+ );
162
+
163
+ return (
164
+ <ToastContext.Provider value={value}>
165
+ {children}
166
+ <ToastContainer
167
+ toasts={toasts}
168
+ onDismiss={dismissToast}
169
+ dismissDuration={dismissDuration}
170
+ />
171
+ </ToastContext.Provider>
172
+ );
173
+ }
174
+
175
+ export default Toast;
@@ -0,0 +1,48 @@
1
+ import type React from "react";
2
+ import type { IconProps } from "@solara/icons";
3
+
4
+ export type ToastVariant = "default" | "success" | "destructive" | "warning" | "info";
5
+
6
+ export type ToastAction = {
7
+ label: string;
8
+ onClick: () => void | Promise<void>;
9
+ };
10
+
11
+ export interface ToastMessage {
12
+ id: string;
13
+ title: string;
14
+ description?: string;
15
+ variant?: ToastVariant;
16
+ icon?: React.ReactNode;
17
+ iconName?: IconProps | IconProps["name"];
18
+ showIcon?: boolean;
19
+ action?: ToastAction;
20
+ duration?: number;
21
+ createdAt: number;
22
+ isDismissed?: boolean;
23
+ direction?: "left" | "right";
24
+ }
25
+
26
+ export interface ToastContainerProps {
27
+ toasts: ToastMessage[];
28
+ onDismiss: (id: string) => void;
29
+ maxToasts?: number;
30
+ position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
31
+ className?: string;
32
+ dismissDuration?: number;
33
+ }
34
+
35
+ export interface ToastProps
36
+ extends Omit<ToastMessage, "id" | "createdAt" | "isDismissed">,
37
+ Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
38
+ onDismiss?: () => void;
39
+ className?: string;
40
+ }
41
+
42
+ export type ToastOptions = Omit<ToastProps, "onDismiss" | "className">;
43
+
44
+ export type ToastContextValue = {
45
+ toasts: ToastMessage[];
46
+ addToast: (toast: ToastOptions) => string;
47
+ dismissToast: (id: string) => void;
48
+ };
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { AnimatePresence, LayoutGroup, motion, type Variants } from "framer-motion";
5
+ import { Toast } from "./Toast";
6
+ import type { ToastContainerProps } from "./Toast.types";
7
+
8
+ const DEFAULT_MAX_TOASTS = 5;
9
+
10
+ const toastVariants: Variants = {
11
+ hidden: (custom: { position: string; direction?: "left" | "right" }) => {
12
+ const isBottom = custom.position.includes("bottom");
13
+ return {
14
+ opacity: 0,
15
+ x: custom.direction === "left" ? -50 : 50,
16
+ y: isBottom ? 40 : -40,
17
+ scale: 0.85,
18
+ };
19
+ },
20
+ visible: {
21
+ opacity: 1,
22
+ scale: 1,
23
+ x: 0,
24
+ y: 0,
25
+ },
26
+ exit: (custom: { position: string; direction?: "left" | "right" }) => {
27
+ const isBottom = custom.position.includes("bottom");
28
+ return {
29
+ opacity: 0,
30
+ scale: 0.8,
31
+ x: custom.direction === "left" ? -50 : 50,
32
+ y: isBottom ? 20 : -20,
33
+ };
34
+ },
35
+ };
36
+
37
+
38
+
39
+ const classNames = (...classes: Array<string | undefined | false | null>) =>
40
+ classes.filter(Boolean).join(" ");
41
+
42
+ export function ToastContainer({
43
+ toasts,
44
+ onDismiss,
45
+ maxToasts = DEFAULT_MAX_TOASTS,
46
+ position = "bottom-right",
47
+ className,
48
+ dismissDuration = 5000,
49
+ }: ToastContainerProps) {
50
+ const containerRef = React.useRef<HTMLDivElement>(null);
51
+ const toastRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
52
+
53
+ React.useEffect(() => {
54
+ const now = Date.now();
55
+ const timers = toasts
56
+ .filter((toast) => !toast.isDismissed)
57
+ .map((toast) => ({
58
+ id: toast.id,
59
+ duration: toast.duration ?? dismissDuration,
60
+ remaining: Math.max(0, toast.createdAt + (toast.duration ?? dismissDuration) - now),
61
+ }));
62
+
63
+ const timeouts = timers
64
+ .map(({ id, remaining }) => {
65
+ if (remaining <= 0) {
66
+ onDismiss(id);
67
+ return null;
68
+ }
69
+ return setTimeout(() => onDismiss(id), remaining);
70
+ })
71
+ .filter(Boolean) as Array<ReturnType<typeof setTimeout>>;
72
+
73
+ return () => timeouts.forEach(clearTimeout);
74
+ }, [toasts, onDismiss, dismissDuration]);
75
+
76
+ const visibleToasts = React.useMemo(() => {
77
+ return toasts
78
+ .filter((toast) => !toast.isDismissed)
79
+ .sort((a, b) => a.createdAt - b.createdAt)
80
+ .slice(-maxToasts);
81
+ }, [toasts, maxToasts]);
82
+
83
+ const positionClasses = {
84
+ "top-right": "solara-toast-container--top-right",
85
+ "top-left": "solara-toast-container--top-left",
86
+ "bottom-right": "solara-toast-container--bottom-right",
87
+ "bottom-left": "solara-toast-container--bottom-left",
88
+ } as const;
89
+
90
+ if (visibleToasts.length === 0) return null;
91
+
92
+ return (
93
+ <div
94
+ ref={containerRef}
95
+ className={classNames(
96
+ "solara-toast-container",
97
+ positionClasses[position as keyof typeof positionClasses],
98
+ className
99
+ )}
100
+ role="region"
101
+ aria-live="polite"
102
+ aria-label="Notifications"
103
+ >
104
+ <LayoutGroup>
105
+ <AnimatePresence>
106
+ {visibleToasts.map((toast, index) => (
107
+ <motion.div
108
+ key={toast.id}
109
+ {...({
110
+ layout: true,
111
+ layoutId: `toast-${toast.id}`,
112
+ ref: (el: HTMLDivElement | null) => {
113
+ if (el) {
114
+ toastRefs.current[toast.id] = el;
115
+ } else {
116
+ delete toastRefs.current[toast.id];
117
+ }
118
+ },
119
+ className: classNames(
120
+ "solara-toast-wrapper",
121
+ toast.isDismissed ? "solara-toast-wrapper--dismissed" : undefined
122
+ ),
123
+ variants: toastVariants,
124
+ custom: { position, direction: toast.direction },
125
+ initial: "hidden",
126
+ animate: "visible",
127
+ exit: "exit",
128
+ transition: {
129
+ type: "spring",
130
+ stiffness: 400,
131
+ damping: 30,
132
+ mass: 0.9,
133
+ },
134
+ style: {
135
+ zIndex: 1000 + index,
136
+ marginBottom: 4,
137
+ willChange: "transform, opacity, height",
138
+ },
139
+ whileHover: {
140
+ scale: 1.02,
141
+ transition: {
142
+ type: "spring",
143
+ stiffness: 400,
144
+ damping: 15,
145
+ },
146
+ },
147
+ whileTap: {
148
+ scale: 0.98,
149
+ transition: {
150
+ type: "spring",
151
+ stiffness: 400,
152
+ damping: 20,
153
+ },
154
+ },
155
+ } as any)}
156
+ >
157
+ <Toast {...toast} onDismiss={() => onDismiss(toast.id)} />
158
+ </motion.div>
159
+ ))}
160
+ </AnimatePresence>
161
+ </LayoutGroup>
162
+ </div>
163
+ );
164
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { Toast, ToastProvider, ToastContext } from "./Toast";
2
+ export { ToastContainer } from "./ToastContainer";
3
+ export { useToast } from "./useToast";
4
+ export type {
5
+ ToastAction,
6
+ ToastContainerProps,
7
+ ToastContextValue,
8
+ ToastMessage,
9
+ ToastOptions,
10
+ ToastProps,
11
+ ToastVariant,
12
+ } from "./Toast.types";
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useToast as useToastContext } from "./useToast";
5
+ import type { ToastAction, ToastVariant } from "./Toast.types";
6
+ import type { IconProps } from "@solara/icons";
7
+
8
+ export type ToastOptions = {
9
+ title: string;
10
+ description?: string;
11
+ variant?: ToastVariant;
12
+ duration?: number;
13
+ direction?: "left" | "right";
14
+ action?: ToastAction;
15
+ icon?: React.ReactNode;
16
+ iconName?: IconProps | IconProps["name"];
17
+ showIcon?: boolean;
18
+ };
19
+
20
+ export function useToast() {
21
+ const { addToast } = useToastContext();
22
+
23
+ const toast = React.useCallback(
24
+ ({ title, description, variant = "default", duration = 5000, action, icon, iconName, showIcon, direction }: ToastOptions) => {
25
+ addToast({
26
+ title,
27
+ description,
28
+ variant,
29
+ duration,
30
+ action,
31
+ icon,
32
+ iconName,
33
+ showIcon,
34
+ direction,
35
+ });
36
+ },
37
+ [addToast]
38
+ );
39
+
40
+ return React.useMemo(
41
+ () => ({
42
+ toast,
43
+ success: (title: string, description?: string) => toast({ title, description, variant: "success" }),
44
+ error: (title: string, description?: string) => toast({ title, description, variant: "destructive" }),
45
+ warning: (title: string, description?: string) => toast({ title, description, variant: "warning" }),
46
+ info: (title: string, description?: string) => toast({ title, description, variant: "info" }),
47
+ }),
48
+ [toast]
49
+ );
50
+ }
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { ToastContext } from "./Toast";
5
+ import type { ToastContextValue, ToastMessage } from "./Toast.types";
6
+
7
+ export function useToast(): ToastContextValue {
8
+ const context = useContext(ToastContext);
9
+
10
+ if (context === undefined) {
11
+ throw new Error("useToast must be used within a ToastProvider");
12
+ }
13
+
14
+ return context;
15
+ }
16
+
17
+ export type { ToastMessage };