Toast
v0.2.0TODO: Add component description.
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";56export type ToastVariant = "info" | "success" | "error";78export 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}1920export 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}3233const TOAST_EVENT = "bht:toast";3435/**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 context38 * 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 handed44 // to same-realm listeners by reference, not structured-cloned, so functions45 // are fine here. This is not a bug in need of a serializable payload.46 window.dispatchEvent(new CustomEvent<ToastMessage>(TOAST_EVENT, { detail }));47}4849export interface ToastProps {50 /** How long a toast stays visible before auto-dismissing, in ms. Defaults to 5000. */51 duration?: number;52}5354/**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 fixed57 * viewport.58 */59export const Toast = ({ duration = 5000 }: ToastProps) => {60 const [messages, setMessages] = useState<ToastMessage[]>([]);61 const [, getChildClass] = getClassName({ rootClass: "BHT__Toast", styles });6263 const dismiss = useCallback((id: string) => {64 setMessages((current) => current.filter((message) => message.id !== id));65 }, []);6667 useEffect(() => {68 const handleToastEvent = (event: Event) => {69 const { detail } = event as CustomEvent<ToastMessage>;70 setMessages((current) => [...current, detail]);71 };7273 window.addEventListener(TOAST_EVENT, handleToastEvent);74 return () => window.removeEventListener(TOAST_EVENT, handleToastEvent);75 }, []);7677 return (78 <RadixToast.Provider duration={duration}>79 {messages.map((message) => (80 <RadixToast.Root81 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.Action96 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.Close104 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 no2// pre-MD3 names, so their fallbacks are the old surface/on-surface pair3// swapped, which is exactly what "inverse" means.4//5// The `data-variant` accent stripe is kept (it is part of the public API) even6// though MD3 snackbars have no variants.78.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 }2324 &__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 solid35 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));3839 &[data-variant="success"] {40 border-left-color: var(--color-success);41 }4243 // `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 }5152 &[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 }5657 &[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 }6162 &[data-swipe="move"] {63 transform: translateX(var(--radix-toast-swipe-move-x));64 }6566 &[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 }7172 &__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 }8081 &__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 }8990 // Action and close share the interaction treatment: a currentColor state91 // layer on ::after and an invisible 48px touch target on ::before (px, as on92 // Button — a touch target is a physical size, not a typographic one). The93 // target is centered on both axes because the close glyph is narrow as well94 // as short.95 &__action,96 &__close {97 position: relative;98 isolation: isolate;99 background: transparent;100 cursor: pointer;101102 &::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 }113114 &::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 }125126 &:hover::after {127 opacity: var(--md-sys-state-hover-opacity, 0.08);128 }129130 &:focus-visible::after {131 opacity: var(--md-sys-state-focus-opacity, 0.1);132 }133134 &:active::after {135 opacity: var(--md-sys-state-pressed-opacity, 0.1);136 }137138 &:focus-visible {139 outline: 3px solid140 var(--md-sys-color-secondary, var(--color-border-focus));141 outline-offset: 2px;142 }143 }144145 &__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 }161162 &__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}174175@keyframes toast-in {176 from {177 opacity: 0;178 transform: translateY(0.5rem);179 }180181 to {182 opacity: 1;183 transform: translateY(0);184 }185}186187@keyframes toast-out {188 from {189 opacity: 1;190 }191192 to {193 opacity: 0;194 }195}196197@keyframes toast-swipe-out {198 from {199 transform: translateX(var(--radix-toast-swipe-end-x));200 }201202 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";23export const BasicToast = () => (4 <>5 <button6 type="button"7 onClick={() =>8 showToast({ title: "Saved", description: "Your changes were saved." })9 }10 >11 Show info toast12 </button>13 <button14 type="button"15 onClick={() => showToast({ title: "Published", variant: "success" })}16 >17 Show success toast18 </button>19 <button20 type="button"21 onClick={() => showToast({ title: "Failed to save", variant: "error" })}22 >23 Show error toast24 </button>25 <button26 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 action38 </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";45describe("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 });1011 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 });1617 await waitFor(() => {18 expect(screen.getByText("Saved")).toBeTruthy();19 });20 expect(screen.getByText("Your changes were saved.")).toBeTruthy();21 });2223 it("marks the variant via a data attribute", async () => {24 render(<Toast />);25 act(() => {26 showToast({ title: "Failed", variant: "error" });27 });2829 await waitFor(() => {30 expect(31 screen.getByText("Failed").closest("[data-variant]"),32 ).toHaveAttribute("data-variant", "error");33 });34 });3536 it("renders no action button when no action is supplied", async () => {37 render(<Toast />);38 act(() => {39 showToast({ title: "Saved" });40 });4142 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 });4849 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 });5960 const action = await screen.findByRole("button", { name: "Undo" });61 await user.click(action);6263 expect(onClick).toHaveBeenCalledTimes(1);64 await waitFor(() => {65 expect(screen.queryByText("Structure moved")).toBeNull();66 });67 });6869 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 });7778 const action = await screen.findByRole("button", { name: "Undo" });79 expect(action).toHaveAttribute("data-radix-toast-announce-alt", "Undo");80 });8182 it("supports multiple simultaneous toasts", async () => {83 render(<Toast />);84 act(() => {85 showToast({ title: "First" });86 showToast({ title: "Second" });87 });8889 await waitFor(() => {90 expect(screen.getByText("First")).toBeTruthy();91 expect(screen.getByText("Second")).toBeTruthy();92 });93 });94});