Behive Tech Component Registry

Toast

v0.5.0Stable

A toast viewport to mount once, anywhere in the app; `showToast()` from anywhere then renders a dismissible, auto-expiring Radix toast in it.

pnpm add @behivetech/atoms.toast

registry/behivetech/atoms/toast

toast-item.tsx
1import * as RadixToast from "@radix-ui/react-toast";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./toast-item.module.scss";
4import type { ToastAction, ToastVariant } from "./toast.js";
5
6export interface ToastItemProps {
7 /** Bold headline text */
8 title: string;
9 /** Optional supporting text shown below the title */
10 description?: string;
11 /** Accent stripe: adds `--success`, `--warning` or `--error`; "info" adds no modifier */
12 variant?: ToastVariant;
13 /** Optional button rendered alongside the dismiss control */
14 action?: ToastAction;
15 /** Called when Radix closes the toast (timeout, swipe, dismiss) */
16 onOpenChange: (open: boolean) => void;
17}
18
19/**
20 * One toast. A block of its own (`BHT__ToastItem`) so `variant` is styled
21 * through a modifier instead of a `data-variant` attribute.
22 */
23export const ToastItem = ({
24 title,
25 description,
26 variant = "info",
27 action,
28 onOpenChange,
29}: ToastItemProps) => {
30 const [rootClass, getChildClass] = getClassName({
31 modifiers: {
32 success: variant === "success",
33 warning: variant === "warning",
34 error: variant === "error",
35 },
36 rootClass: "BHT__ToastItem",
37 styles,
38 });
39
40 return (
41 <RadixToast.Root className={rootClass} onOpenChange={onOpenChange}>
42 <RadixToast.Title className={getChildClass("title")}>
43 {title}
44 </RadixToast.Title>
45 {description && (
46 <RadixToast.Description className={getChildClass("description")}>
47 {description}
48 </RadixToast.Description>
49 )}
50 {action && (
51 <RadixToast.Action
52 className={getChildClass("action")}
53 altText={action.altText ?? action.label}
54 onClick={action.onClick}
55 >
56 {action.label}
57 </RadixToast.Action>
58 )}
59 <RadixToast.Close className={getChildClass("close")} aria-label="Dismiss">
60 ×
61 </RadixToast.Close>
62 </RadixToast.Root>
63 );
64};
toast.tsx
1import { useCallback, useEffect, useState } from "react";
2import * as RadixToast from "@radix-ui/react-toast";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./toast.module.scss";
5import { ToastItem } from "./toast-item.js";
6
7/** Accent stripe of a toast; "info" is the default and adds no modifier. */
8export type ToastVariant = "info" | "success" | "warning" | "error";
9
10export interface ToastAction {
11 /** Button label, e.g. "Undo" */
12 label: string;
13 /** Fired on click; the toast dismisses itself afterwards */
14 onClick: () => void;
15 /**
16 * How to achieve the same thing without the toast, for assistive tech.
17 * Required by Radix; defaults to `label`.
18 */
19 altText?: string;
20}
21
22export interface ToastMessage {
23 /** Unique identifier, auto-generated by `showToast` */
24 id: string;
25 /** Bold headline text */
26 title: string;
27 /** Optional supporting text shown below the title */
28 description?: string;
29 /** Controls the accent stripe: "info", "success", "warning" or "error"; defaults to "info" */
30 variant?: ToastVariant;
31 /** Optional button rendered alongside the dismiss control, e.g. an undo */
32 action?: ToastAction;
33}
34
35const TOAST_EVENT = "bht:toast";
36const DISMISS_EVENT = "bht:toast-dismiss";
37
38/**
39 * Queues a toast on any mounted `Toast` component. Dispatches a plain `window`
40 * event under the hood, so it's callable from anywhere with no React context
41 * required — including directly from the browser console, e.g.
42 * `showToast({ title: "Saved", variant: "success" })`.
43 *
44 * @returns the toast's id, which `dismissToast` takes to remove it early
45 */
46export function showToast(message: Omit<ToastMessage, "id">): string {
47 const detail: ToastMessage = { id: crypto.randomUUID(), ...message };
48 // `action.onClick` survives this trip intact: `CustomEvent.detail` is handed
49 // to same-realm listeners by reference, not structured-cloned, so functions
50 // are fine here. This is not a bug in need of a serializable payload.
51 window.dispatchEvent(new CustomEvent<ToastMessage>(TOAST_EVENT, { detail }));
52
53 return detail.id;
54}
55
56/**
57 * Removes a toast before it expires, for a message that stopped being true
58 * (an error toast after the retry succeeded). Does nothing for an id that has
59 * already expired.
60 *
61 * @param id The id `showToast` returned
62 */
63export function dismissToast(id: string): void {
64 window.dispatchEvent(new CustomEvent<string>(DISMISS_EVENT, { detail: id }));
65}
66
67export interface ToastProps {
68 /** How long a toast stays visible before auto-dismissing, in ms. Defaults to 5000. */
69 duration?: number;
70}
71
72/**
73 * Mount once, anywhere in the app (e.g. a root layout). Listens for `showToast()`
74 * calls and renders each as a dismissible, auto-expiring Radix Toast in a fixed
75 * viewport.
76 */
77export const Toast = ({ duration = 5000 }: ToastProps) => {
78 const [messages, setMessages] = useState<ToastMessage[]>([]);
79 const [, getChildClass] = getClassName({ rootClass: "BHT__Toast", styles });
80
81 const dismiss = useCallback((id: string) => {
82 setMessages((current) => current.filter((message) => message.id !== id));
83 }, []);
84
85 useEffect(() => {
86 const handleToastEvent = (event: Event) => {
87 const { detail } = event as CustomEvent<ToastMessage>;
88 setMessages((current) => [...current, detail]);
89 };
90
91 const handleDismissEvent = (event: Event) => {
92 dismiss((event as CustomEvent<string>).detail);
93 };
94
95 window.addEventListener(TOAST_EVENT, handleToastEvent);
96 window.addEventListener(DISMISS_EVENT, handleDismissEvent);
97 return () => {
98 window.removeEventListener(TOAST_EVENT, handleToastEvent);
99 window.removeEventListener(DISMISS_EVENT, handleDismissEvent);
100 };
101 }, [dismiss]);
102
103 return (
104 <RadixToast.Provider duration={duration}>
105 {messages.map((message) => (
106 <ToastItem
107 key={message.id}
108 title={message.title}
109 description={message.description}
110 variant={message.variant}
111 action={message.action}
112 onOpenChange={(open) => !open && dismiss(message.id)}
113 />
114 ))}
115 <RadixToast.Viewport className={getChildClass("viewport")} />
116 </RadixToast.Provider>
117 );
118};
toast-item.module.scss
1// Material Design 3 snackbar. `inverse-surface` and `inverse-primary` have no
2// pre-MD3 names, so their fallbacks are the old surface/on-surface pair
3// swapped, which is exactly what "inverse" means.
4//
5// The `--success` / `--warning` / `--error` accent stripe is kept (it is part of the public
6// API) even though MD3 snackbars have no variants.
7
8.BHT__ToastItem {
9 display: grid;
10 grid-template-columns: 1fr auto;
11 align-items: start;
12 gap: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);
13 padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);
14 background: var(--md-sys-color-inverse-surface, var(--color-on-surface));
15 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));
16 // Transparent rather than removed so the content box doesn't shift.
17 border: 1px solid transparent;
18 border-inline-start: 6px solid
19 var(--md-sys-color-inverse-primary, var(--color-primary));
20 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
21 box-shadow: var(--md-sys-elevation-3, var(--shadow-lg));
22
23 // `*-inverse`, not the bare status alias: the stripe sits on the inverse
24 // surface, where the alias (a text colour for a status fill) can be plain
25 // black or white. The fallback keeps apps that define no inverse token as
26 // they were.
27 &--success {
28 &.BHT__ToastItem {
29 border-inline-start-color: var(
30 --color-success-inverse,
31 var(--color-success)
32 );
33 }
34 }
35
36 &--warning {
37 &.BHT__ToastItem {
38 border-inline-start-color: var(
39 --color-warning-inverse,
40 var(--color-warning)
41 );
42 }
43 }
44
45 // `error-inverse`, falling back to `error-container`, not `error`: the stripe
46 // sits on the inverse surface, where `error` (tuned for the normal surface)
47 // has almost no contrast.
48 &--error {
49 &.BHT__ToastItem {
50 border-inline-start-color: var(
51 --color-error-inverse,
52 var(--md-sys-color-error-container, var(--color-error))
53 );
54 }
55 }
56
57 &[data-state="open"] {
58 animation: toast-in var(--md-sys-motion-duration-short-3, 150ms)
59 var(--md-sys-motion-easing-standard-decelerate, ease-out);
60 }
61
62 &[data-state="closed"] {
63 animation: toast-out var(--md-sys-motion-duration-short-3, 150ms)
64 var(--md-sys-motion-easing-standard-accelerate, ease-in);
65 }
66
67 &[data-swipe="move"] {
68 transform: translateX(var(--radix-toast-swipe-move-x));
69 }
70
71 &[data-swipe="end"] {
72 animation: toast-swipe-out var(--md-sys-motion-duration-short-3, 150ms)
73 var(--md-sys-motion-easing-standard-accelerate, ease-out);
74 }
75
76 &__title {
77 grid-column: 1;
78 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
79 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
80 font-weight: var(--md-sys-typescale-label-large-weight, 500);
81 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
82 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
83 }
84
85 &__description {
86 grid-column: 1;
87 font-family: var(--md-sys-typescale-body-medium-font, var(--font-body));
88 font-size: var(--md-sys-typescale-body-medium-size, 0.875rem);
89 font-weight: var(--md-sys-typescale-body-medium-weight, 400);
90 line-height: var(--md-sys-typescale-body-medium-line-height, 1.25rem);
91 letter-spacing: var(--md-sys-typescale-body-medium-tracking, 0.0179em);
92 }
93
94 // Action and close share the interaction treatment: a currentColor state
95 // layer on ::after and an invisible 48px touch target on ::before (px, as on
96 // Button — a touch target is a physical size, not a typographic one). The
97 // target is centered on both axes because the close glyph is narrow as well
98 // as short.
99 &__action,
100 &__close {
101 position: relative;
102 isolation: isolate;
103 background: transparent;
104 cursor: pointer;
105
106 &::before {
107 content: "";
108 position: absolute;
109 top: 50%;
110 left: 50%;
111 translate: -50% -50%;
112 width: 100%;
113 height: 100%;
114 min-width: 48px;
115 min-height: 48px;
116 }
117
118 &::after {
119 content: "";
120 position: absolute;
121 inset: 0;
122 border-radius: inherit;
123 background: currentColor;
124 opacity: 0;
125 pointer-events: none;
126 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
127 var(--md-sys-motion-easing-standard, ease);
128 }
129
130 &:hover::after {
131 opacity: var(--md-sys-state-hover-opacity, 0.08);
132 }
133
134 &:focus-visible::after {
135 opacity: var(--md-sys-state-focus-opacity, 0.1);
136 }
137
138 &:active::after {
139 opacity: var(--md-sys-state-pressed-opacity, 0.1);
140 }
141
142 &:focus-visible {
143 outline: 3px solid
144 var(--md-sys-color-inverse-primary, var(--color-primary));
145 outline-offset: 2px;
146 }
147 }
148
149 // The action sits 12px left of the content edge to align its label, so the
150 // default 2px ring offset would land on the 6px stripe. At -1px the ring
151 // spans x 8 to 11 (16px root), 2px clear of the stripe, and still outside
152 // the label, which starts at x 22.
153 &__action:focus-visible {
154 outline-offset: -1px;
155 }
156
157 &__action {
158 grid-column: 1;
159 justify-self: start;
160 margin-top: var(--spacing-1, 0.25rem);
161 // Pulls the padded state layer left so the label lines up with the title.
162 margin-inline-start: calc(-1 * var(--spacing-3, 0.75rem));
163 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);
164 // MD3's snackbar action is a text button; the border stays, transparent,
165 // only so the button keeps its size.
166 border: 1px solid transparent;
167 border-radius: var(--md-sys-shape-corner-full, 9999px);
168 color: var(--md-sys-color-inverse-primary, var(--color-primary));
169 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
170 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
171 font-weight: var(--md-sys-typescale-label-large-weight, 500);
172 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
173 line-height: 1;
174 }
175
176 &__close {
177 grid-column: 2;
178 grid-row: 1 / 3;
179 border: none;
180 border-radius: var(--md-sys-shape-corner-full, 9999px);
181 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));
182 // A glyph size, not a type role — the × is sized as an icon.
183 font-size: 1.125rem;
184 line-height: 1;
185 padding: 0;
186 }
187}
188
189@keyframes toast-in {
190 from {
191 opacity: 0;
192 transform: translateY(0.5rem);
193 }
194
195 to {
196 opacity: 1;
197 transform: translateY(0);
198 }
199}
200
201@keyframes toast-out {
202 from {
203 opacity: 1;
204 }
205
206 to {
207 opacity: 0;
208 }
209}
210
211@keyframes toast-swipe-out {
212 from {
213 transform: translateX(var(--radix-toast-swipe-end-x));
214 }
215
216 to {
217 transform: translateX(100%);
218 }
219}
toast.module.scss
1// Material Design 3 snackbar viewport; the toast element itself is
2// `ToastItem` (toast-item.module.scss).
3
4.BHT__Toast {
5 &__viewport {
6 position: fixed;
7 bottom: var(--spacing-4, 1rem);
8 right: var(--spacing-4, 1rem);
9 display: flex;
10 flex-direction: column;
11 gap: var(--spacing-2, 0.5rem);
12 width: min(22.5rem, calc(100vw - 2rem));
13 z-index: var(--z-toast, 500);
14 list-style: none;
15 margin: 0;
16 padding: 0;
17 outline: none;
18 }
19}
index.ts
1export { Toast, showToast, dismissToast } from "./toast.js";
2export type {
3 ToastProps,
4 ToastMessage,
5 ToastVariant,
6 ToastAction,
7} from "./toast.js";
toast.example.tsx
1import { Toast, showToast } from "./toast.js";
2
3export const BasicToast = () => (
4 <>
5 <button
6 type="button"
7 onClick={() =>
8 showToast({ title: "Saved", description: "Your changes were saved." })
9 }
10 >
11 Show info toast
12 </button>
13 <button
14 type="button"
15 onClick={() => showToast({ title: "Published", variant: "success" })}
16 >
17 Show success toast
18 </button>
19 <button
20 type="button"
21 onClick={() =>
22 showToast({ title: "Reservation expires soon", variant: "warning" })
23 }
24 >
25 Show warning toast
26 </button>
27 <button
28 type="button"
29 onClick={() => showToast({ title: "Failed to save", variant: "error" })}
30 >
31 Show error toast
32 </button>
33 <button
34 type="button"
35 onClick={() =>
36 showToast({
37 title: "Structure moved",
38 action: {
39 label: "Undo",
40 onClick: () => showToast({ title: "Move undone" }),
41 },
42 })
43 }
44 >
45 Show toast with an action
46 </button>
47 <Toast />
48 </>
49);
toast.spec.tsx
1import { act, render, screen, waitFor } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Toast, dismissToast, showToast } from "./toast.js";
4
5describe("Toast", () => {
6 it("renders an empty viewport before showToast is called", () => {
7 render(<Toast />);
8 expect(screen.getByRole("region").querySelectorAll("li").length).toBe(0);
9 });
10
11 it("renders a toast's title and description after showToast is called", async () => {
12 render(<Toast />);
13 act(() => {
14 showToast({ title: "Saved", description: "Your changes were saved." });
15 });
16
17 await waitFor(() => {
18 expect(screen.getByText("Saved")).toBeTruthy();
19 });
20 expect(screen.getByText("Your changes were saved.")).toBeTruthy();
21 });
22
23 it("marks the variant through a modifier, not a data attribute", async () => {
24 render(<Toast />);
25 act(() => {
26 showToast({ title: "Saved", variant: "success" });
27 });
28
29 const toast = await screen.findByText("Saved");
30 const root = toast.closest("li") as HTMLElement;
31 expect(root.className).toContain("--success");
32 expect(root).not.toHaveAttribute("data-variant");
33 });
34
35 it("uses the --error modifier for the error variant", async () => {
36 render(<Toast />);
37 act(() => {
38 showToast({ title: "Failed", variant: "error" });
39 });
40
41 const toast = await screen.findByText("Failed");
42 expect((toast.closest("li") as HTMLElement).className).toContain("--error");
43 });
44
45 it("uses the --warning modifier for the warning variant", async () => {
46 render(<Toast />);
47 act(() => {
48 showToast({ title: "Expires soon", variant: "warning" });
49 });
50
51 const toast = await screen.findByText("Expires soon");
52 expect((toast.closest("li") as HTMLElement).className).toContain(
53 "--warning",
54 );
55 });
56
57 it("renders no action button when no action is supplied", async () => {
58 render(<Toast />);
59 act(() => {
60 showToast({ title: "Saved" });
61 });
62
63 await waitFor(() => {
64 expect(screen.getByText("Saved")).toBeTruthy();
65 });
66 expect(screen.getAllByRole("button").length).toBe(1);
67 expect(screen.getByRole("button", { name: "Dismiss" })).toBeTruthy();
68 });
69
70 it("renders the action button and fires onClick when clicked", async () => {
71 const user = userEvent.setup();
72 const onClick = vi.fn();
73 render(<Toast />);
74 act(() => {
75 showToast({
76 title: "Structure moved",
77 action: { label: "Undo", onClick },
78 });
79 });
80
81 const action = await screen.findByRole("button", { name: "Undo" });
82 await user.click(action);
83
84 expect(onClick).toHaveBeenCalledTimes(1);
85 await waitFor(() => {
86 expect(screen.queryByText("Structure moved")).toBeNull();
87 });
88 });
89
90 it("falls back to the label for the Radix altText", async () => {
91 render(<Toast />);
92 act(() => {
93 showToast({
94 title: "Moved",
95 action: { label: "Undo", onClick: vi.fn() },
96 });
97 });
98
99 const action = await screen.findByRole("button", { name: "Undo" });
100 expect(action).toHaveAttribute("data-radix-toast-announce-alt", "Undo");
101 });
102
103 it("supports multiple simultaneous toasts", async () => {
104 render(<Toast />);
105 act(() => {
106 showToast({ title: "First" });
107 showToast({ title: "Second" });
108 });
109
110 await waitFor(() => {
111 expect(screen.getByText("First")).toBeTruthy();
112 expect(screen.getByText("Second")).toBeTruthy();
113 });
114 });
115
116 it("removes a toast early when dismissToast is given the id showToast returned", async () => {
117 render(<Toast />);
118
119 let id = "";
120
121 act(() => {
122 id = showToast({ title: "Order not saved" });
123 });
124 expect(await screen.findByText("Order not saved")).toBeTruthy();
125
126 act(() => dismissToast(id));
127
128 await waitFor(() =>
129 expect(screen.queryByText("Order not saved")).toBeNull(),
130 );
131 });
132});