@simplybusiness/mobius 7.0.0 → 7.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.
@@ -0,0 +1,285 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-webpack5";
2
+ import { useEffect } from "react";
3
+ import { Button } from "../Button";
4
+ import { Flex } from "../Flex";
5
+ import { Stack } from "../Stack";
6
+ import { Text } from "../Text";
7
+ import { toast } from "./Toast";
8
+ import { Toaster } from "./Toaster";
9
+ import type { ToasterProps } from "./Toaster";
10
+ import { excludeControls } from "../../utils";
11
+
12
+ type StoryType = StoryObj<typeof Toaster>;
13
+
14
+ const meta: Meta<typeof Toaster> = {
15
+ title: "Components/Toast",
16
+ component: Toaster,
17
+ argTypes: {
18
+ position: {
19
+ control: { type: "select" },
20
+ options: [
21
+ "top-left",
22
+ "top-center",
23
+ "top-right",
24
+ "bottom-left",
25
+ "bottom-center",
26
+ "bottom-right",
27
+ ],
28
+ },
29
+ ...excludeControls("toastOptions", "theme", "icons", "dir", "hotkey"),
30
+ },
31
+ parameters: {
32
+ docs: {
33
+ description: {
34
+ component:
35
+ "Toast notifications for displaying brief, non-blocking messages. Built on Sonner.",
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export default meta;
42
+
43
+ export const Default: StoryType = {
44
+ render: (args: ToasterProps) => (
45
+ <>
46
+ <Toaster {...args} />
47
+ <Button onClick={() => toast.info("This is an info toast")}>
48
+ Show Toast
49
+ </Button>
50
+ </>
51
+ ),
52
+ args: {
53
+ position: "top-right",
54
+ closeButton: true,
55
+ expand: false,
56
+ duration: 4000,
57
+ visibleToasts: 3,
58
+ },
59
+ play: () => {
60
+ toast.info("This is an info toast");
61
+ },
62
+ };
63
+
64
+ export const Info: StoryType = {
65
+ render: (args: ToasterProps) => (
66
+ <>
67
+ <Toaster {...args} />
68
+ <Button
69
+ onClick={() => toast.info("Your session will expire in 5 minutes")}
70
+ >
71
+ Show Info Toast
72
+ </Button>
73
+ </>
74
+ ),
75
+ args: {
76
+ position: "top-right",
77
+ },
78
+ play: () => {
79
+ toast.info("Your session will expire in 5 minutes");
80
+ },
81
+ };
82
+
83
+ export const Success: StoryType = {
84
+ render: (args: ToasterProps) => (
85
+ <>
86
+ <Toaster {...args} />
87
+ <Button onClick={() => toast.success("Your changes have been saved")}>
88
+ Show Success Toast
89
+ </Button>
90
+ </>
91
+ ),
92
+ args: {
93
+ position: "top-right",
94
+ },
95
+ play: () => {
96
+ toast.success("Your changes have been saved");
97
+ },
98
+ };
99
+
100
+ export const Warning: StoryType = {
101
+ render: (args: ToasterProps) => (
102
+ <>
103
+ <Toaster {...args} />
104
+ <Button
105
+ onClick={() =>
106
+ toast.warning("Please review your input before continuing")
107
+ }
108
+ >
109
+ Show Warning Toast
110
+ </Button>
111
+ </>
112
+ ),
113
+ args: {
114
+ position: "top-right",
115
+ },
116
+ play: () => {
117
+ toast.warning("Please review your input before continuing");
118
+ },
119
+ };
120
+
121
+ export const ErrorVariant: StoryType = {
122
+ name: "Error",
123
+ render: (args: ToasterProps) => (
124
+ <>
125
+ <Toaster {...args} />
126
+ <Button
127
+ onClick={() => toast.error("Something went wrong. Please try again.")}
128
+ >
129
+ Show Error Toast
130
+ </Button>
131
+ </>
132
+ ),
133
+ args: {
134
+ position: "top-right",
135
+ },
136
+ play: () => {
137
+ toast.error("Something went wrong. Please try again.");
138
+ },
139
+ };
140
+
141
+ export const WithTitle: StoryType = {
142
+ render: (args: ToasterProps) => (
143
+ <>
144
+ <Toaster {...args} />
145
+ <Button
146
+ onClick={() =>
147
+ toast.success("Your quote has been saved and sent to your email.", {
148
+ title: "Quote saved",
149
+ })
150
+ }
151
+ >
152
+ Show Toast with Title
153
+ </Button>
154
+ </>
155
+ ),
156
+ args: {
157
+ position: "top-right",
158
+ },
159
+ play: () => {
160
+ toast.success("Your quote has been saved and sent to your email.", {
161
+ title: "Quote saved",
162
+ });
163
+ },
164
+ };
165
+
166
+ export const WithActions: StoryType = {
167
+ render: (args: ToasterProps) => (
168
+ <>
169
+ <Toaster {...args} />
170
+ <Button
171
+ onClick={() =>
172
+ toast.info("Would you like to save your progress?", {
173
+ title: "Save progress",
174
+ action: {
175
+ label: "Save",
176
+ onClick: () => console.log("Saved!"),
177
+ },
178
+ cancel: {
179
+ label: "Discard",
180
+ onClick: () => console.log("Discarded!"),
181
+ },
182
+ })
183
+ }
184
+ >
185
+ Show Toast with Actions
186
+ </Button>
187
+ </>
188
+ ),
189
+ args: {
190
+ position: "top-right",
191
+ },
192
+ play: () => {
193
+ toast.info("Would you like to save your progress?", {
194
+ title: "Save progress",
195
+ action: {
196
+ label: "Save",
197
+ onClick: () => console.log("Saved!"),
198
+ },
199
+ cancel: {
200
+ label: "Discard",
201
+ onClick: () => console.log("Discarded!"),
202
+ },
203
+ });
204
+ },
205
+ };
206
+
207
+ export const AllVariants: StoryType = {
208
+ render: (args: ToasterProps) => (
209
+ <>
210
+ <Toaster {...args} />
211
+ <Stack gap="sm">
212
+ <Button onClick={() => toast.info("This is an info message")}>
213
+ Info
214
+ </Button>
215
+ <Button onClick={() => toast.success("This is a success message")}>
216
+ Success
217
+ </Button>
218
+ <Button onClick={() => toast.warning("This is a warning message")}>
219
+ Warning
220
+ </Button>
221
+ <Button onClick={() => toast.error("This is an error message")}>
222
+ Error
223
+ </Button>
224
+ </Stack>
225
+ </>
226
+ ),
227
+ args: {
228
+ position: "top-right",
229
+ },
230
+ play: () => {
231
+ toast.info("This is an info message");
232
+ toast.success("This is a success message");
233
+ toast.warning("This is a warning message");
234
+ toast.error("This is an error message");
235
+ },
236
+ };
237
+
238
+ const InteractiveDemoComponent = () => {
239
+ useEffect(() => {
240
+ const handler = (e: KeyboardEvent) => {
241
+ if (e.key === "r" || e.key === "R") {
242
+ const random = Math.floor(Math.random() * 4);
243
+ switch (random) {
244
+ case 0:
245
+ toast.info("This is an info toast");
246
+ break;
247
+ case 1:
248
+ toast.success("Operation completed successfully");
249
+ break;
250
+ case 2:
251
+ toast.warning("Please check your input");
252
+ break;
253
+ default:
254
+ toast.error("Something went wrong");
255
+ }
256
+ }
257
+ };
258
+ window.addEventListener("keydown", handler);
259
+ return () => window.removeEventListener("keydown", handler);
260
+ }, []);
261
+
262
+ return (
263
+ <Stack gap="md">
264
+ <Text>Press &apos;R&apos; to show a random toast</Text>
265
+ <Flex gap="sm">
266
+ <Button onClick={() => toast.info("Info toast")}>Info</Button>
267
+ <Button onClick={() => toast.success("Success toast")}>Success</Button>
268
+ <Button onClick={() => toast.warning("Warning toast")}>Warning</Button>
269
+ <Button onClick={() => toast.error("Error toast")}>Error</Button>
270
+ </Flex>
271
+ </Stack>
272
+ );
273
+ };
274
+
275
+ export const InteractiveDemo: StoryType = {
276
+ render: (args: ToasterProps) => (
277
+ <>
278
+ <Toaster {...args} />
279
+ <InteractiveDemoComponent />
280
+ </>
281
+ ),
282
+ args: {
283
+ position: "top-right",
284
+ },
285
+ };
@@ -0,0 +1,188 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { Toaster, toast } from ".";
4
+
5
+ // Mock setPointerCapture which JSDOM doesn't support (used by Sonner)
6
+ beforeAll(() => {
7
+ Element.prototype.setPointerCapture = jest.fn();
8
+ Element.prototype.releasePointerCapture = jest.fn();
9
+ });
10
+
11
+ describe("Toast", () => {
12
+ describe("Toaster", () => {
13
+ it("renders without errors", () => {
14
+ render(<Toaster />);
15
+ // Sonner renders a section element with aria-label
16
+ expect(screen.getByRole("region")).toBeInTheDocument();
17
+ });
18
+ });
19
+
20
+ describe("toast functions", () => {
21
+ beforeEach(() => {
22
+ toast.dismiss();
23
+ });
24
+
25
+ it("shows info toast with correct content", async () => {
26
+ render(<Toaster />);
27
+
28
+ toast.info("Info message");
29
+
30
+ await waitFor(() => {
31
+ expect(screen.getByText("Info message")).toBeInTheDocument();
32
+ });
33
+ });
34
+
35
+ it("shows success toast with correct content", async () => {
36
+ render(<Toaster />);
37
+
38
+ toast.success("Success message");
39
+
40
+ await waitFor(() => {
41
+ expect(screen.getByText("Success message")).toBeInTheDocument();
42
+ });
43
+ });
44
+
45
+ it("shows warning toast with correct content", async () => {
46
+ render(<Toaster />);
47
+
48
+ toast.warning("Warning message");
49
+
50
+ await waitFor(() => {
51
+ expect(screen.getByText("Warning message")).toBeInTheDocument();
52
+ });
53
+ });
54
+
55
+ it("shows error toast with correct content", async () => {
56
+ render(<Toaster />);
57
+
58
+ toast.error("Error message");
59
+
60
+ await waitFor(() => {
61
+ expect(screen.getByText("Error message")).toBeInTheDocument();
62
+ });
63
+ });
64
+
65
+ it("shows toast with title", async () => {
66
+ render(<Toaster />);
67
+
68
+ toast.info("Description text", { title: "Title text" });
69
+
70
+ await waitFor(() => {
71
+ expect(screen.getByText("Title text")).toBeInTheDocument();
72
+ });
73
+ });
74
+
75
+ it("shows toast with description when title is provided", async () => {
76
+ render(<Toaster />);
77
+
78
+ toast.info("Description text", { title: "Title text" });
79
+
80
+ await waitFor(() => {
81
+ expect(screen.getByText("Description text")).toBeInTheDocument();
82
+ });
83
+ });
84
+
85
+ it("renders toast with action button", async () => {
86
+ render(<Toaster />);
87
+
88
+ toast.info("Action toast", {
89
+ action: { label: "Undo", onClick: jest.fn() },
90
+ });
91
+
92
+ await waitFor(() => {
93
+ expect(screen.getByText("Undo")).toBeInTheDocument();
94
+ });
95
+ });
96
+
97
+ it("shows toast with cancel button", async () => {
98
+ render(<Toaster />);
99
+
100
+ toast.info("Cancel toast", {
101
+ cancel: { label: "Cancel" },
102
+ });
103
+
104
+ await waitFor(() => {
105
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
106
+ });
107
+ });
108
+
109
+ it("calls action onClick and dismisses toast when action button is clicked", async () => {
110
+ const user = userEvent.setup();
111
+ const onClickMock = jest.fn();
112
+ render(<Toaster />);
113
+
114
+ toast.info("Action toast", {
115
+ action: { label: "Confirm", onClick: onClickMock },
116
+ });
117
+
118
+ await waitFor(() => {
119
+ expect(screen.getByText("Confirm")).toBeInTheDocument();
120
+ });
121
+
122
+ await user.click(screen.getByText("Confirm"));
123
+
124
+ expect(onClickMock).toHaveBeenCalledTimes(1);
125
+
126
+ await waitFor(() => {
127
+ expect(screen.queryByText("Action toast")).not.toBeInTheDocument();
128
+ });
129
+ });
130
+
131
+ it("calls cancel onClick and dismisses toast when cancel button is clicked", async () => {
132
+ const user = userEvent.setup();
133
+ const onClickMock = jest.fn();
134
+ render(<Toaster />);
135
+
136
+ toast.info("Cancel toast", {
137
+ cancel: { label: "Dismiss", onClick: onClickMock },
138
+ });
139
+
140
+ await waitFor(() => {
141
+ expect(screen.getByText("Dismiss")).toBeInTheDocument();
142
+ });
143
+
144
+ await user.click(screen.getByText("Dismiss"));
145
+
146
+ expect(onClickMock).toHaveBeenCalledTimes(1);
147
+
148
+ await waitFor(() => {
149
+ expect(screen.queryByText("Cancel toast")).not.toBeInTheDocument();
150
+ });
151
+ });
152
+
153
+ it("dismisses toast when dismiss is called", async () => {
154
+ render(<Toaster />);
155
+
156
+ toast.info("Dismissable toast");
157
+
158
+ await waitFor(() => {
159
+ expect(screen.getByText("Dismissable toast")).toBeInTheDocument();
160
+ });
161
+
162
+ toast.dismiss();
163
+
164
+ await waitFor(() => {
165
+ expect(screen.queryByText("Dismissable toast")).not.toBeInTheDocument();
166
+ });
167
+ });
168
+
169
+ it("calls onDismiss callback when toast is dismissed via close button", async () => {
170
+ const user = userEvent.setup();
171
+ const onDismissMock = jest.fn();
172
+ render(<Toaster closeButton />);
173
+
174
+ toast.info("Dismissable toast", { onDismiss: onDismissMock });
175
+
176
+ await waitFor(() => {
177
+ expect(screen.getByText("Dismissable toast")).toBeInTheDocument();
178
+ });
179
+
180
+ const closeButton = screen.getByRole("button", { name: "Close" });
181
+ await user.click(closeButton);
182
+
183
+ await waitFor(() => {
184
+ expect(onDismissMock).toHaveBeenCalledTimes(1);
185
+ });
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,154 @@
1
+ import {
2
+ circleCheck,
3
+ circleExclamation,
4
+ circleInfo,
5
+ cross,
6
+ triangleExclamation,
7
+ } from "@simplybusiness/icons";
8
+ import classNames from "classnames/dedupe";
9
+ import type { ReactNode } from "react";
10
+ import { toast as sonnerToast } from "sonner";
11
+ import { Icon } from "../Icon";
12
+ import { toastState } from "./state";
13
+ import type { ToastOptions, ToastVariant } from "./types";
14
+
15
+ const variantIcons: Record<ToastVariant, typeof circleInfo> = {
16
+ info: circleInfo,
17
+ success: circleCheck,
18
+ warning: triangleExclamation,
19
+ error: circleExclamation,
20
+ };
21
+
22
+ const variantColors: Record<ToastVariant, string> = {
23
+ info: "var(--color-info)",
24
+ success: "var(--color-valid)",
25
+ warning: "var(--color-warning)",
26
+ error: "var(--color-error)",
27
+ };
28
+
29
+ const ToastIcon = ({ variant }: { variant: ToastVariant }) => (
30
+ <span className="mobius-toast__icon">
31
+ <Icon icon={variantIcons[variant]} color={variantColors[variant]} />
32
+ </span>
33
+ );
34
+
35
+ const CloseIcon = () => (
36
+ <span className="mobius-toast__close-icon">
37
+ <Icon icon={cross} />
38
+ </span>
39
+ );
40
+
41
+ type ToastContentProps = {
42
+ toastId: string | number;
43
+ variant: ToastVariant;
44
+ title?: string;
45
+ description?: ReactNode;
46
+ action?: ToastOptions["action"];
47
+ cancel?: ToastOptions["cancel"];
48
+ showCloseButton?: boolean;
49
+ };
50
+
51
+ const ToastContent = ({
52
+ toastId,
53
+ variant,
54
+ title,
55
+ description,
56
+ action,
57
+ cancel,
58
+ showCloseButton = toastState.showCloseButton,
59
+ }: ToastContentProps) => (
60
+ <div className={classNames("mobius", "mobius-toast", `--${variant}`)}>
61
+ <ToastIcon variant={variant} />
62
+ <div className="mobius-toast__body">
63
+ <div className="mobius-toast__content">
64
+ {title && <div className="mobius-toast__title">{title}</div>}
65
+ {description && (
66
+ <div className="mobius-toast__description">{description}</div>
67
+ )}
68
+ </div>
69
+ {(action || cancel) && (
70
+ <div className="mobius-toast__actions">
71
+ {cancel && (
72
+ <button
73
+ type="button"
74
+ className="mobius-toast__cancel"
75
+ onClick={() => {
76
+ cancel.onClick?.();
77
+ sonnerToast.dismiss(toastId);
78
+ }}
79
+ >
80
+ {cancel.label}
81
+ </button>
82
+ )}
83
+ {action && (
84
+ <button
85
+ type="button"
86
+ className="mobius-toast__action"
87
+ onClick={() => {
88
+ action.onClick();
89
+ sonnerToast.dismiss(toastId);
90
+ }}
91
+ >
92
+ {action.label}
93
+ </button>
94
+ )}
95
+ </div>
96
+ )}
97
+ </div>
98
+ {showCloseButton && (
99
+ <button
100
+ type="button"
101
+ className="mobius-toast__close"
102
+ onClick={() => sonnerToast.dismiss(toastId)}
103
+ aria-label="Close"
104
+ >
105
+ <CloseIcon />
106
+ </button>
107
+ )}
108
+ </div>
109
+ );
110
+
111
+ const createCustomToast = (
112
+ message: string,
113
+ variant: ToastVariant,
114
+ options?: ToastOptions,
115
+ ) =>
116
+ sonnerToast.custom(
117
+ id => (
118
+ <ToastContent
119
+ toastId={id}
120
+ variant={variant}
121
+ title={options?.title}
122
+ description={options?.description ?? message}
123
+ action={options?.action}
124
+ cancel={options?.cancel}
125
+ showCloseButton={options?.showCloseButton}
126
+ />
127
+ ),
128
+ {
129
+ duration: options?.duration,
130
+ onDismiss: options?.onDismiss,
131
+ onAutoClose: options?.onAutoClose,
132
+ },
133
+ );
134
+
135
+ export const toast = {
136
+ /** Show an info toast */
137
+ info: (message: string, options?: ToastOptions) =>
138
+ createCustomToast(message, "info", options),
139
+
140
+ /** Show a success toast */
141
+ success: (message: string, options?: ToastOptions) =>
142
+ createCustomToast(message, "success", options),
143
+
144
+ /** Show a warning toast */
145
+ warning: (message: string, options?: ToastOptions) =>
146
+ createCustomToast(message, "warning", options),
147
+
148
+ /** Show an error toast */
149
+ error: (message: string, options?: ToastOptions) =>
150
+ createCustomToast(message, "error", options),
151
+
152
+ /** Dismiss a specific toast by ID or all toasts */
153
+ dismiss: (toastId?: string | number) => sonnerToast.dismiss(toastId),
154
+ };
@@ -0,0 +1,8 @@
1
+ import type { ToastOptions } from "./types";
2
+
3
+ /**
4
+ * Documentation-only component for ToastOptions.
5
+ * Exists solely to provide ArgTypes for the toast() function options.
6
+ */
7
+ export const ToastOptionsDoc = (_props: ToastOptions) => null;
8
+ ToastOptionsDoc.displayName = "ToastOptionsDoc";
@@ -0,0 +1,46 @@
1
+ import { useEffect } from "react";
2
+ import { Toaster as SonnerToaster } from "sonner";
3
+ import { toastState } from "./state";
4
+ import type { ToastPosition } from "./types";
5
+
6
+ export interface ToasterProps {
7
+ /** Position of the toast container */
8
+ position?: ToastPosition;
9
+ /** Whether to show the close (X) button on toasts */
10
+ closeButton?: boolean;
11
+ /** Whether toasts expand on hover */
12
+ expand?: boolean;
13
+ /** Duration in milliseconds before auto-dismiss (default: 4000) */
14
+ duration?: number;
15
+ /** Maximum number of visible toasts */
16
+ visibleToasts?: number;
17
+ /** Gap between toasts in pixels */
18
+ gap?: number;
19
+ }
20
+
21
+ export const Toaster = ({
22
+ position = "top-right",
23
+ closeButton = true,
24
+ expand = false,
25
+ duration = 4000,
26
+ visibleToasts = 3,
27
+ gap = 8,
28
+ }: ToasterProps) => {
29
+ // Sync shared state with Toaster's closeButton prop
30
+ useEffect(() => {
31
+ toastState.showCloseButton = closeButton;
32
+ }, [closeButton]);
33
+
34
+ return (
35
+ <SonnerToaster
36
+ position={position}
37
+ closeButton={false}
38
+ expand={expand}
39
+ duration={duration}
40
+ visibleToasts={visibleToasts}
41
+ gap={gap}
42
+ />
43
+ );
44
+ };
45
+
46
+ Toaster.displayName = "Toaster";
@@ -0,0 +1,4 @@
1
+ export { toast } from "./Toast";
2
+ export { Toaster } from "./Toaster";
3
+ export type { ToasterProps } from "./Toaster";
4
+ export type { ToastOptions, ToastVariant, ToastPosition } from "./types";
@@ -0,0 +1,6 @@
1
+ // Shared state between Toaster and toast functions
2
+ // Toaster sets this value, toast functions read it
3
+
4
+ export const toastState = {
5
+ showCloseButton: true,
6
+ };