Behive Tech Registry

Toast

v0.2.0

TODO: Add component description.

pnpm add @behivetech/atoms.toast

registry/behivetech/atoms/toast · depends on @behivetech/get-class-name

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