Skip to content

Commit 85e1bb2

Browse files
authored
[Design System] Toast supports actions and single-line design (#63)
- Add an optional prop to the existing `toast` object. - `actions` is typed as a one-tuple or two-tuple (accepting either one CTA or two). Each item must be a React node, allowing composition. - If `title` is not specified, the single-line design is used. The `message` is truncated, while actions and icon remain full-width.
1 parent b768112 commit 85e1bb2

2 files changed

Lines changed: 218 additions & 19 deletions

File tree

packages/ui/src/components/Toast.tsx

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client';
22

3-
import { ReactNode } from 'react';
3+
import React from 'react';
44
import { LuCircleAlert, LuCircleCheck, LuX } from 'react-icons/lu';
55
import { Toaster as Sonner, toast as sonnerToast } from 'sonner';
66

7+
import { cn } from '../lib/utils';
78
import { Button } from './Button';
89

910
export const Toast = () => {
@@ -15,9 +16,13 @@ export const Toast = () => {
1516
visibleToasts={3}
1617
duration={3000}
1718
icons={{
18-
success: <LuCircleCheck className="size-6 text-functional-green" />,
19-
close: <LuX className="size-6 text-neutral-black" />,
20-
error: <LuCircleAlert className="size-6 text-functional-red" />,
19+
success: (
20+
<LuCircleCheck className="size-6 shrink-0 text-functional-green" />
21+
),
22+
close: <LuX className="size-6 shrink-0 text-neutral-black" />,
23+
error: (
24+
<LuCircleAlert className="size-6 shrink-0 text-functional-red" />
25+
),
2126
}}
2227
toastOptions={{
2328
classNames: {
@@ -39,13 +44,20 @@ const ToastWrapper = ({
3944
id,
4045
dismissable = false,
4146
children,
47+
isSingleLine = false,
4248
}: {
4349
id: string | number;
4450
dismissable?: boolean;
4551
children: React.ReactNode;
52+
isSingleLine?: boolean;
4653
}) => {
4754
return (
48-
<div className="flex w-full items-start gap-2">
55+
<div
56+
className={cn(
57+
'flex w-full items-start gap-2',
58+
isSingleLine && 'items-center',
59+
)}
60+
>
4961
{children}
5062
{dismissable && (
5163
<Button
@@ -60,14 +72,55 @@ const ToastWrapper = ({
6072
);
6173
};
6274

63-
const ToastBody = ({ children }: { children: React.ReactNode }) => {
75+
const ToastBody = ({
76+
children,
77+
isSingleLine = false,
78+
}: {
79+
children: React.ReactNode;
80+
isSingleLine?: boolean;
81+
}) => {
82+
if (isSingleLine) {
83+
return (
84+
<div className="flex w-full min-w-0 items-center gap-2 text-base text-neutral-charcoal">
85+
{children}
86+
</div>
87+
);
88+
}
89+
6490
return (
6591
<div className="flex w-full flex-col gap-2 px-1 pt-1 text-base text-neutral-charcoal">
6692
{children}
6793
</div>
6894
);
6995
};
7096

97+
const ToastActions = ({
98+
children,
99+
isSingleLine = false,
100+
}: {
101+
children: React.ReactNode;
102+
isSingleLine?: boolean;
103+
}) => {
104+
const renderActions = () => {
105+
if (Array.isArray(children)) {
106+
return children.map((action, index) => (
107+
<React.Fragment key={index}>{action}</React.Fragment>
108+
));
109+
}
110+
return children;
111+
};
112+
113+
if (isSingleLine) {
114+
return (
115+
<div className="ml-auto flex shrink-0 items-center gap-2">
116+
{renderActions()}
117+
</div>
118+
);
119+
}
120+
121+
return <div className="mt-2 flex gap-4">{renderActions()}</div>;
122+
};
123+
71124
const ToastTitle = ({
72125
children,
73126
}: {
@@ -81,25 +134,53 @@ const ToastTitle = ({
81134
);
82135
};
83136

137+
const SingleLineMessage = ({
138+
className,
139+
children,
140+
}: {
141+
className?: string;
142+
children: React.ReactNode;
143+
}) => {
144+
return (
145+
<span className={cn('min-w-0 flex-1 truncate', className)}>{children}</span>
146+
);
147+
};
148+
84149
export const toast = {
85150
success: ({
86151
title,
87152
message,
88153
dismissable,
89154
children,
155+
actions,
90156
}: {
91157
title?: string;
92-
message?: ReactNode;
158+
message?: React.ReactNode;
93159
dismissable?: boolean;
94160
children?: React.ReactNode;
161+
actions?: [React.ReactNode, React.ReactNode?];
95162
}) => {
163+
const isSingleLine = !title;
164+
96165
return sonnerToast.custom((id) => (
97-
<ToastWrapper id={id} dismissable={dismissable}>
98-
<LuCircleCheck className="size-6 stroke-1 text-functional-green" />
99-
<ToastBody>
100-
{title ? <ToastTitle>{title}</ToastTitle> : null}
101-
{message ? <div>{message}</div> : null}
166+
<ToastWrapper
167+
id={id}
168+
dismissable={dismissable}
169+
isSingleLine={isSingleLine}
170+
>
171+
<LuCircleCheck className="size-6 shrink-0 stroke-1 text-functional-green" />
172+
<ToastBody isSingleLine={isSingleLine}>
173+
{title && <ToastTitle>{title}</ToastTitle>}
174+
{message &&
175+
(isSingleLine ? (
176+
<SingleLineMessage>{message}</SingleLineMessage>
177+
) : (
178+
<span>{message}</span>
179+
))}
102180
{children}
181+
{actions && (
182+
<ToastActions isSingleLine={isSingleLine}>{actions}</ToastActions>
183+
)}
103184
</ToastBody>
104185
</ToastWrapper>
105186
));
@@ -110,25 +191,43 @@ export const toast = {
110191
message,
111192
children,
112193
dismissable = true,
194+
actions,
113195
}: {
114196
title?: string;
115-
message?: string;
197+
message?: React.ReactNode;
116198
children?: React.ReactNode;
117199
dismissable?: boolean;
200+
actions?: [React.ReactNode, React.ReactNode?];
118201
}) => {
202+
const isSingleLine = !title;
203+
119204
// TODO: some odd behavior with Tailwind text-white an text-title-base conflicting here (the size gets stripped by the compiler).
120205
return sonnerToast.custom(
121206
(id) => (
122-
<ToastWrapper id={id} dismissable={dismissable}>
123-
<LuCircleAlert className="size-6 stroke-1 text-white" />
124-
<ToastBody>
125-
{title ? (
207+
<ToastWrapper
208+
id={id}
209+
dismissable={dismissable}
210+
isSingleLine={isSingleLine}
211+
>
212+
<LuCircleAlert className="size-6 shrink-0 stroke-1 text-white" />
213+
<ToastBody isSingleLine={isSingleLine}>
214+
{title && (
126215
<ToastTitle>
127216
<span className="text-white">{title}</span>
128217
</ToastTitle>
129-
) : null}
130-
{message ? <div className="text-white">{message}</div> : null}
218+
)}
219+
{message &&
220+
(isSingleLine ? (
221+
<SingleLineMessage className="text-white">
222+
{message}
223+
</SingleLineMessage>
224+
) : (
225+
<div className="text-white">{message}</div>
226+
))}
131227
{children}
228+
{actions && (
229+
<ToastActions isSingleLine={isSingleLine}>{actions}</ToastActions>
230+
)}
132231
</ToastBody>
133232
</ToastWrapper>
134233
),

packages/ui/stories/Toast.stories.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,103 @@ export const MultipleToasts = () => (
294294
</div>
295295
</div>
296296
);
297+
298+
export const ToastWithActions = () => (
299+
<div className="space-y-4">
300+
<Toast />
301+
<div className="flex flex-wrap gap-4">
302+
<Button
303+
onPress={() =>
304+
toast.success({
305+
title: 'File Upload Complete',
306+
message: 'Your document has been successfully uploaded.',
307+
actions: [<Button color="primary">View File</Button>],
308+
})
309+
}
310+
>
311+
Toast with One Action
312+
</Button>
313+
314+
<Button
315+
onPress={() =>
316+
toast.success({
317+
title: 'Connection Restored',
318+
message: 'Your internet connection has been restored.',
319+
dismissable: true,
320+
actions: [
321+
<Button color="primary">Retry</Button>,
322+
<Button color="secondary">Dismiss</Button>,
323+
],
324+
})
325+
}
326+
>
327+
Toast with Two Actions
328+
</Button>
329+
</div>
330+
</div>
331+
);
332+
333+
export const SingleLineToasts = () => (
334+
<div className="space-y-4">
335+
<Toast />
336+
<div className="flex flex-wrap gap-4">
337+
<Button
338+
onPress={() =>
339+
toast.success({
340+
message:
341+
'Cooperativum mutualitas communis, equitatis prosperum. Societas nostra fundata est super principia cooperationis et mutuae auxilii.',
342+
actions: [
343+
<Button color="primary" size="small">
344+
View profile
345+
</Button>,
346+
],
347+
})
348+
}
349+
>
350+
Single Line Success with Action
351+
</Button>
352+
353+
<Button
354+
onPress={() =>
355+
toast.success({
356+
message:
357+
'Cooperativum mutualitas communis, equitatis prosperum. Societas nostra fundata est super principia cooperationis et mutuae auxilii.',
358+
actions: [
359+
<Button color="primary" size="small">
360+
View profile
361+
</Button>,
362+
<Button color="secondary" size="small">
363+
Undo
364+
</Button>,
365+
],
366+
})
367+
}
368+
>
369+
Single Line Success with Two Actions
370+
</Button>
371+
372+
<Button
373+
color="destructive"
374+
onPress={() =>
375+
toast.error({
376+
message:
377+
'Cooperativum mutualitas communis, equitatis prosperum. Societas nostra fundata est super principia cooperationis et mutuae auxilii.',
378+
dismissable: true,
379+
})
380+
}
381+
>
382+
Single Line Error with Dismiss
383+
</Button>
384+
385+
<Button
386+
onPress={() =>
387+
toast.success({
388+
message: 'Message without actions. '.repeat(4),
389+
})
390+
}
391+
>
392+
Single Line No Actions
393+
</Button>
394+
</div>
395+
</div>
396+
);

0 commit comments

Comments
 (0)