Skip to content

Commit 7ad5480

Browse files
authored
feat: toast component (#1588)
1 parent fa48562 commit 7ad5480

File tree

8 files changed

+335
-121
lines changed

8 files changed

+335
-121
lines changed

playground/nextjs-app-router/onchainkit/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@coinbase/onchainkit",
3-
"version": "0.35.3",
3+
"version": "0.35.4",
44
"type": "module",
55
"repository": "https://github.com/coinbase/onchainkit.git",
66
"license": "MIT",
+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { fireEvent, render } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { Toast } from './Toast';
5+
6+
describe('Toast component', () => {
7+
it('should render bottom-right correctly', () => {
8+
const handleClose = vi.fn();
9+
const { getByTestId } = render(
10+
<Toast isVisible={true} position="bottom-right" onClose={handleClose}>
11+
<div>Test</div>
12+
</Toast>,
13+
);
14+
15+
const toastContainer = getByTestId('ockToast');
16+
expect(toastContainer).toBeInTheDocument();
17+
expect(toastContainer).toHaveClass('bottom-5 left-3/4');
18+
19+
const closeButton = getByTestId('ockCloseButton');
20+
expect(closeButton).toBeInTheDocument();
21+
});
22+
23+
it('should render top-right correctly', () => {
24+
const handleClose = vi.fn();
25+
const { getByTestId } = render(
26+
<Toast isVisible={true} position="top-right" onClose={handleClose}>
27+
<div>Test</div>
28+
</Toast>,
29+
);
30+
31+
const toastContainer = getByTestId('ockToast');
32+
expect(toastContainer).toBeInTheDocument();
33+
expect(toastContainer).toHaveClass('top-[100px] left-3/4');
34+
35+
const closeButton = getByTestId('ockCloseButton');
36+
expect(closeButton).toBeInTheDocument();
37+
});
38+
39+
it('should render top-center correctly', () => {
40+
const handleClose = vi.fn();
41+
const { getByTestId } = render(
42+
<Toast isVisible={true} position="top-center" onClose={handleClose}>
43+
<div>Test</div>
44+
</Toast>,
45+
);
46+
47+
const toastContainer = getByTestId('ockToast');
48+
expect(toastContainer).toBeInTheDocument();
49+
expect(toastContainer).toHaveClass('top-[100px] left-2/4');
50+
51+
const closeButton = getByTestId('ockCloseButton');
52+
expect(closeButton).toBeInTheDocument();
53+
});
54+
55+
it('should render bottom-center correctly', () => {
56+
const handleClose = vi.fn();
57+
const { getByTestId } = render(
58+
<Toast isVisible={true} position="bottom-center" onClose={handleClose}>
59+
<div>Test</div>
60+
</Toast>,
61+
);
62+
63+
const toastContainer = getByTestId('ockToast');
64+
expect(toastContainer).toBeInTheDocument();
65+
expect(toastContainer).toHaveClass('bottom-5 left-2/4');
66+
67+
const closeButton = getByTestId('ockCloseButton');
68+
expect(closeButton).toBeInTheDocument();
69+
});
70+
71+
it('should apply custom className correctly', () => {
72+
const handleClose = vi.fn();
73+
const { getByTestId } = render(
74+
<Toast
75+
isVisible={true}
76+
position="bottom-right"
77+
onClose={handleClose}
78+
className="custom-class"
79+
>
80+
<div>Test</div>
81+
</Toast>,
82+
);
83+
84+
const toastContainer = getByTestId('ockToast');
85+
expect(toastContainer).toHaveClass('custom-class');
86+
});
87+
88+
it('should not be visible when isVisible is false', () => {
89+
const handleClose = vi.fn();
90+
const { queryByTestId } = render(
91+
<Toast isVisible={false} position="bottom-right" onClose={handleClose}>
92+
<div>Test</div>
93+
</Toast>,
94+
);
95+
const toastContainer = queryByTestId('ockToast');
96+
expect(toastContainer).not.toBeInTheDocument();
97+
});
98+
99+
it('should close when close button is clicked', () => {
100+
const handleClose = vi.fn();
101+
const { getByTestId } = render(
102+
<Toast isVisible={true} position="bottom-right" onClose={handleClose}>
103+
<div>Test</div>
104+
</Toast>,
105+
);
106+
107+
const closeButton = getByTestId('ockCloseButton');
108+
fireEvent.click(closeButton);
109+
expect(handleClose).toHaveBeenCalled();
110+
});
111+
112+
it('should render children correctly', () => {
113+
const handleClose = vi.fn();
114+
const { getByText } = render(
115+
<Toast isVisible={true} position="bottom-right" onClose={handleClose}>
116+
<div>Test</div>
117+
</Toast>,
118+
);
119+
120+
const text = getByText('Test');
121+
expect(text).toBeInTheDocument();
122+
});
123+
124+
it('should disappear after durationMs', async () => {
125+
vi.useFakeTimers();
126+
const handleClose = vi.fn();
127+
const durationMs = 2000;
128+
129+
render(
130+
<Toast
131+
isVisible={true}
132+
position="bottom-right"
133+
onClose={handleClose}
134+
durationMs={durationMs}
135+
>
136+
<div>Test</div>
137+
</Toast>,
138+
);
139+
140+
expect(handleClose).not.toHaveBeenCalled();
141+
142+
vi.advanceTimersByTime(durationMs);
143+
144+
expect(handleClose).toHaveBeenCalled();
145+
146+
vi.useRealTimers();
147+
});
148+
149+
it('should not fire timer after manual close', () => {
150+
vi.useFakeTimers();
151+
const handleClose = vi.fn();
152+
const durationMs = 2000;
153+
154+
const { getByTestId, rerender } = render(
155+
<Toast
156+
isVisible={true}
157+
position="bottom-right"
158+
onClose={handleClose}
159+
durationMs={durationMs}
160+
>
161+
<div>Test</div>
162+
</Toast>,
163+
);
164+
165+
vi.advanceTimersByTime(1000);
166+
167+
const closeButton = getByTestId('ockCloseButton');
168+
fireEvent.click(closeButton);
169+
170+
expect(handleClose).toHaveBeenCalledTimes(1);
171+
172+
rerender(
173+
<Toast
174+
isVisible={false}
175+
position="bottom-right"
176+
onClose={handleClose}
177+
durationMs={durationMs}
178+
>
179+
<div>Test</div>
180+
</Toast>,
181+
);
182+
183+
vi.advanceTimersByTime(1500);
184+
expect(handleClose).toHaveBeenCalledTimes(1);
185+
186+
vi.useRealTimers();
187+
});
188+
189+
it('should cleanup correctly on unmount', () => {
190+
vi.useFakeTimers();
191+
const handleClose = vi.fn();
192+
193+
const { unmount } = render(
194+
<Toast
195+
isVisible={true}
196+
position="bottom-right"
197+
onClose={handleClose}
198+
durationMs={2000}
199+
>
200+
<div>Test</div>
201+
</Toast>,
202+
);
203+
204+
unmount();
205+
vi.advanceTimersByTime(2000);
206+
207+
expect(handleClose).not.toHaveBeenCalled();
208+
vi.useRealTimers();
209+
});
210+
});

src/internal/components/Toast.tsx

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect } from 'react';
2+
import { background, cn } from '../../styles/theme';
3+
import { closeSvg } from '../svg/closeSvg';
4+
import { getToastPosition } from '../utils/getToastPosition';
5+
6+
type ToastProps = {
7+
className?: string;
8+
durationMs?: number;
9+
position: 'top-center' | 'top-right' | 'bottom-center' | 'bottom-right';
10+
isVisible: boolean;
11+
onClose: () => void;
12+
children: React.ReactNode;
13+
};
14+
15+
export function Toast({
16+
className,
17+
durationMs = 3000,
18+
position = 'bottom-center',
19+
isVisible,
20+
onClose,
21+
children,
22+
}: ToastProps) {
23+
const positionClass = getToastPosition(position);
24+
25+
useEffect(() => {
26+
const timer = setTimeout(() => {
27+
if (isVisible) {
28+
onClose();
29+
}
30+
}, durationMs);
31+
32+
return () => {
33+
if (timer) {
34+
clearTimeout(timer);
35+
}
36+
};
37+
}, [durationMs, isVisible, onClose]);
38+
39+
if (!isVisible) {
40+
return null;
41+
}
42+
43+
return (
44+
<div
45+
className={cn(
46+
background.default,
47+
'flex animate-enter items-center justify-between rounded-lg',
48+
'p-2 shadow-[0px_8px_24px_0px_rgba(0,0,0,0.12)]',
49+
'-translate-x-2/4 fixed z-20',
50+
positionClass,
51+
className,
52+
)}
53+
data-testid="ockToast"
54+
>
55+
<div className="flex items-center gap-4 p-2">{children}</div>
56+
<button
57+
className="p-2"
58+
onClick={onClose}
59+
type="button"
60+
data-testid="ockCloseButton"
61+
>
62+
{closeSvg}
63+
</button>
64+
</div>
65+
);
66+
}

src/internal/utils/getToastPosition.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { describe, expect, it } from 'vitest';
12
import { getToastPosition } from './getToastPosition';
23

34
describe('getToastPosition', () => {

src/swap/components/SwapToast.test.tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,18 @@ describe('SwapToast', () => {
3131

3232
it('closes when the close button is clicked', () => {
3333
const setIsToastVisible = vi.fn();
34+
const setTransactionHash = vi.fn();
3435
(useSwapContext as Mock).mockReturnValue({
3536
isToastVisible: true,
3637
setIsToastVisible,
38+
setTransactionHash,
3739
});
3840

3941
render(<SwapToast />);
4042
fireEvent.click(screen.getByTestId('ockCloseButton'));
4143

4244
expect(setIsToastVisible).toHaveBeenCalledWith(false);
45+
expect(setTransactionHash).toHaveBeenCalledWith('');
4346
});
4447

4548
it('displays transaction hash when available', () => {
@@ -125,48 +128,57 @@ describe('SwapToast', () => {
125128
it('hides toast after specified duration', () => {
126129
vi.useFakeTimers();
127130
const setIsToastVisible = vi.fn();
131+
const setTransactionHash = vi.fn();
128132
(useSwapContext as Mock).mockReturnValue({
129133
isToastVisible: true,
130134
transactionHash: '',
131135
setIsToastVisible,
136+
setTransactionHash,
132137
});
133138

134139
render(<SwapToast durationMs={2000} />);
135140

136141
vi.advanceTimersByTime(2000);
137142
expect(setIsToastVisible).toHaveBeenCalledWith(false);
143+
expect(setTransactionHash).toHaveBeenCalledWith('');
138144
vi.useRealTimers();
139145
});
140146

141147
it('resets transactionhash after specified duration', () => {
142148
vi.useFakeTimers();
149+
const setIsToastVisible = vi.fn();
143150
const setTransactionHash = vi.fn();
144151
(useSwapContext as Mock).mockReturnValue({
145152
isToastVisible: true,
146153
transactionHash: '',
154+
setIsToastVisible,
147155
setTransactionHash,
148156
});
149157

150158
render(<SwapToast durationMs={2000} />);
151159

152160
vi.advanceTimersByTime(2000);
153-
expect(setTransactionHash).toHaveBeenCalled();
161+
expect(setIsToastVisible).toHaveBeenCalledWith(false);
162+
expect(setTransactionHash).toHaveBeenCalledWith('');
154163
vi.useRealTimers();
155164
});
156165

157166
it('hides toast after specified duration when error message is present', () => {
158167
vi.useFakeTimers();
159168
const setIsToastVisible = vi.fn();
169+
const setTransactionHash = vi.fn();
160170
(useSwapContext as Mock).mockReturnValue({
161171
isToastVisible: true,
162172
transactionHash: '',
163173
setIsToastVisible,
174+
setTransactionHash,
164175
});
165176

166177
render(<SwapToast durationMs={2000} />);
167178

168179
vi.advanceTimersByTime(2000);
169180
expect(setIsToastVisible).toHaveBeenCalledWith(false);
181+
expect(setTransactionHash).toHaveBeenCalledWith('');
170182
vi.useRealTimers();
171183
});
172184
});

0 commit comments

Comments
 (0)