// ~/src/siteplane/components/toast/toast.tsx "use client"; import { Toast } from "@base-ui/react/toast"; import { CircleAlertIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; import * as React from "react"; import { useSiteplaneMotionPreset } from "../../system/motion/siteplane-motion-provider"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { Button } from "../button"; import { KitButton } from "../button/button-internal"; import { Spinner } from "../spinner"; import { useToastStack } from "./toast-stack"; import { toastDismissClassName, toastSurfaceClassName } from "./toast-surface"; import "./toast.css"; // A loading toast shows the kit Spinner instead of an icon of its own. const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; /** * The content of every toast, the default one and the content `notify()` * passes: it fades out as the toast steps behind the front one and back in * when the stack expands (toast.css owns the fade), and a toast behind takes * no pointer until then. */ const toastContentClassName = "sp-toast-content-motion pointer-events-auto data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100"; type SwipeDirection = "up" | "down" | "left" | "right"; export type ToastData = { content?: React.ReactNode; rootProps?: Omit< React.ComponentProps, "children" | "className" | "swipeDirection" | "toast" >; }; function getSwipeDirection(position: ToastPosition): SwipeDirection[] { const verticalDirection: SwipeDirection = position.startsWith("top") ? "up" : "down"; if (position.includes("center")) return [verticalDirection]; if (position.includes("left")) return ["left", verticalDirection]; return ["right", verticalDirection]; } function upsertReplayClassName(toast: { type?: string | undefined; updateKey?: number | undefined; }): string | undefined { const key = toast.updateKey ?? 0; if (key <= 0) return undefined; const parity = key % 2 === 0 ? "even" : "odd"; return toast.type === "error" ? `animate-toast-error-${parity}` : `animate-toast-success-${parity}`; } function Toasts({ dismissLabel, manager, position, portalProps, }: { dismissLabel: string; manager: object | undefined; position: ToastPosition; portalProps?: WithStringClassName> | undefined; }): React.ReactElement { const { toasts } = Toast.useToastManager(); const swipeDirection = getSwipeDirection(position); // Stacks of other providers at this position take the edge first when they // received a toast later; this one stands back by their room. const [viewport, setViewport] = React.useState(null); const { mirrored, offset } = useToastStack( position, manager, toasts, viewport, ); // The viewport portals out of a SiteplaneMotionProvider, so it carries the // preset itself for the reduced-motion rules in toast.css. const motionPreset = useSiteplaneMotionPreset(); return ( 0 ? "" : undefined} style={ offset > 0 ? ({ "--toast-stack-offset": `${offset}px` } as React.CSSProperties) : undefined } > {toasts.map((toast) => { const Icon = toast.type && toast.type !== "loading" ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const toastData = toast.data as ToastData | undefined; return ( {toastData?.content ? ( {toastData.content} ) : ( {/* The text column may shrink, so a long title or description wraps instead of pushing the action out (rulebook K5). */}
{(Icon || toast.type === "loading") && (
{Icon ? ( ) : (
)}
{toast.actionProps && ( } > {toast.actionProps.children} )}
)}
); })}
); } function ToastCloseButton({ label }: { label: string }): React.ReactElement { return ( } > ); } export const toastManager: ReturnType = Toast.createToastManager(); export type ToastPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; export interface ToastProviderProps extends Toast.Provider.Props { /** The accessible name of each toast's dismiss button. Default: "Dismiss toast" */ dismissLabel?: string; position?: ToastPosition; portalProps?: WithStringClassName>; } export function ToastProvider({ children, dismissLabel = "Dismiss toast", position = "bottom-right", portalProps, ...props }: ToastProviderProps): React.ReactElement { const manager = "toastManager" in props ? props.toastManager : toastManager; return ( {children} ); } export { Toast as ToastPrimitive }; // ~/src/siteplane/components/toast/toast.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* A toast and a Notification read the kit's theme and motion tokens, so a * re-theme reaches them. Their surface publishes its colour like every * floating surface: the fill darkens from it per stack position, and the * status ring of an avatar takes it. */ .sp-toast-replay-motion { --sp-rw-surface-color: var(--sp-rw-bg-secondary); } /* The content of every toast, the default one and that of notify(), fades * in and out as the toast steps behind the front one and back. */ :where(.sp-toast-content-motion) { transition-property: opacity; transition-duration: var(--motion-duration-moderate); transition-timing-function: var(--motion-ease-standard); } /* A toast that is updated in place replays its type: a success pulses over * the slow step, and an error shakes a little quicker, in seven eighths of * it. */ .animate-toast-success-odd, .animate-toast-success-even { animation: toast-success var(--motion-duration-slow) var(--motion-ease-toast-replay); } .animate-toast-error-odd, .animate-toast-error-even { animation: toast-error calc(var(--motion-duration-slow) * 0.875) var(--motion-ease-toast-replay); } @keyframes toast-success { 0% { scale: 1; } 30% { scale: 1.025; } 60% { scale: 0.99; } 100% { scale: 1; } } @keyframes toast-error { 0%, 100% { translate: 0 0; } 25%, 75% { translate: -3px 0; } 50% { translate: 3px 0; } } /* Reduced motion is instant (M5): the replay and the spinner stop, the * content no longer fades, and the stack no longer glides when a toast comes, * goes or another stack makes way. */ @media (prefers-reduced-motion: reduce) { .sp-toast-replay-motion { animation: none; transition: none; } .sp-toast-viewport { transition: none; } .sp-toast-replay-motion .sp-spinner-motion { animation: none; } :root .sp-toast-content-motion { transition: none; } } html[data-motion-reduce="true"] .sp-toast-replay-motion, [data-sp-motion="reduced"] .sp-toast-replay-motion { animation: none; transition: none; } /* The viewport portals out of a SiteplaneMotionProvider and carries its preset. */ html[data-motion-reduce="true"] .sp-toast-viewport, [data-sp-motion="reduced"] .sp-toast-viewport, .sp-toast-viewport[data-sp-motion="reduced"] { transition: none; } html[data-motion-reduce="true"] .sp-toast-replay-motion .sp-spinner-motion, [data-sp-motion="reduced"] .sp-toast-replay-motion .sp-spinner-motion { animation: none; } html[data-motion-reduce="true"] .sp-toast-content-motion, [data-sp-motion="reduced"] .sp-toast-content-motion { transition: none; } } // ~/src/siteplane/components/toast/toast-stack.ts "use client"; import * as React from "react"; /* * Toast stacks that share a screen corner. * * A ToastProvider keeps its own toasts and stacks them itself. Two providers at * one position would draw their newest toast in the same place, one covering * the other completely. Every stack reports how much room it takes. The stack * that received a toast last sits at the edge and the others make way for it, * one above the other, so every front toast stays readable. A position with a * single stack is not moved at all. * * Providers on the same manager receive every toast twice. Only the first of * them is drawn; the others stay in step but are hidden, as if there were one. */ /** `--toast-gap` and `--toast-peek` of a toast (`--spacing(3)`), in rem. */ export const TOAST_GAP_REM = 0.75; export const TOAST_PEEK_REM = 0.75; /** What a stack needs to know about one of its toasts. */ export interface StackToast { id: string; height?: number | undefined; limited?: boolean | undefined; transitionStatus?: "starting" | "ending" | undefined; } /** * How far a stack reaches from its screen edge, in px, counting the toasts that * are on show as they sit now. A collapsed stack is its front toast and a sliver * of every toast behind it. An expanded stack lists each toast with a gap * between. A toast that is closing or over the limit takes no room. */ export function stackExtent( toasts: readonly StackToast[], expanded: boolean, rem = 16, ): number { const shown = toasts.filter( (toast) => toast.transitionStatus !== "ending" && !toast.limited, ); const [front] = shown; if (!front) return 0; if (expanded) { const heights = shown.reduce((sum, toast) => sum + (toast.height ?? 0), 0); return heights + (shown.length - 1) * TOAST_GAP_REM * rem; } return (front.height ?? 0) + (shown.length - 1) * TOAST_PEEK_REM * rem; } /** One stack at a position. */ interface Stack { /** Without any toast, a stack takes the edge when it gets its first one. */ empty: boolean; /** Which toast manager feeds it. */ manager: number; /** Higher is later. The stack that received a toast last has the highest rank. */ rank: number; /** When it appeared; the earliest stack of a manager is the one that is drawn. */ seq: number; /** Its extent plus the gap to the stack behind it; 0 when nothing is on show. */ size: number; } const corners = new Map>(); const managers = new WeakMap(); const listeners = new Set<() => void>(); let lastRank = 0; let lastSeq = 0; let lastManager = 0; let lastKey = 0; /** A number for a toast manager; 0 for none. The same manager always gets the same number. */ export function managerId(manager: object | undefined): number { if (!manager) return 0; let id = managers.get(manager); if (id === undefined) managers.set(manager, (id = ++lastManager)); return id; } function subscribe(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } function emit(): void { for (const listener of listeners) listener(); } /** What a stack tells the others each time its toasts change. */ export interface StackReport { empty: boolean; extent: number; gap: number; manager: number; /** A toast was added since the last report. */ received: boolean; } export function publishStack( position: string, key: string, { empty, extent, gap, manager, received }: StackReport, ): void { let corner = corners.get(position); if (!corner) corners.set(position, (corner = new Map())); const previous = corner.get(key); const rank = received ? ++lastRank : (previous?.rank ?? 0); const size = extent > 0 ? extent + gap : 0; if ( previous && previous.empty === empty && previous.manager === manager && previous.rank === rank && previous.size === size ) return; corner.set(key, { empty, manager, rank, seq: previous?.seq ?? ++lastSeq, size, }); emit(); } export function retireStack(position: string, key: string): void { const corner = corners.get(position); if (!corner?.delete(key)) return; if (corner.size === 0) corners.delete(position); emit(); } /** Whether an earlier stack at the position is fed by the same manager. */ function isMirror( corner: ReadonlyMap, key: string, stack: Stack, ): boolean { if (stack.manager === 0) return false; for (const [other, candidate] of corner) if ( other !== key && candidate.manager === stack.manager && candidate.seq < stack.seq ) return true; return false; } /** Whether the stack only repeats what an earlier stack on its manager shows. */ export function isMirrored(position: string, key: string): boolean { const corner = corners.get(position); const own = corner?.get(key); return Boolean(corner && own && isMirror(corner, key, own)); } /** * How far the stack stands back from the edge: the room of every stack that is * later. A stack without toasts stays at the edge, so that its first toast * enters there instead of gliding in from behind the others. */ export function offsetOf(position: string, key: string): number { const corner = corners.get(position); const own = corner?.get(key); if (!corner || !own || own.empty) return 0; let offset = 0; for (const [other, stack] of corner) if ( other !== key && stack.rank > own.rank && !isMirror(corner, other, stack) ) offset += stack.size; return offset; } function useHasAttribute(element: Element | null, name: string): boolean { const [has, setHas] = React.useState(false); React.useEffect(() => { if (!element) return; const read = () => setHas(element.hasAttribute(name)); read(); const observer = new MutationObserver(read); observer.observe(element, { attributeFilter: [name], attributes: true }); return () => observer.disconnect(); }, [element, name]); return has; } /** * Where a stack stands among the stacks at its position: how far it stands back * from the screen edge, in px, so that it clears the stacks that received a toast * later, and whether it is only a repeat of the stack of its own manager. It * follows the stack's own toasts and whether the viewport is expanded (Base UI * sets `data-expanded`). */ export function useToastStack( position: string, manager: object | undefined, toasts: readonly StackToast[], viewport: Element | null, ): { mirrored: boolean; offset: number } { // Not `useId`: separate React roots on one page can hand out the same id. const [key] = React.useState(() => `stack-${++lastKey}`); const expanded = useHasAttribute(viewport, "data-expanded"); const known = React.useRef(new Set()); React.useLayoutEffect(() => { const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; // New toasts enter at the front of the list; one that leaves does not // change which toast was added last. const front = toasts[0]?.id; const received = front !== undefined && !known.current.has(front); known.current = new Set(toasts.map((toast) => toast.id)); publishStack(position, key, { empty: toasts.length === 0, extent: stackExtent(toasts, expanded, rem), gap: TOAST_GAP_REM * rem, manager: managerId(manager), received, }); }, [position, key, manager, toasts, expanded]); React.useLayoutEffect( () => () => { known.current = new Set(); retireStack(position, key); }, [position, key], ); const offset = React.useSyncExternalStore( subscribe, () => offsetOf(position, key), () => 0, ); const mirrored = React.useSyncExternalStore( subscribe, () => isMirrored(position, key), () => false, ); return { mirrored, offset }; } // ~/src/siteplane/components/toast/toast-surface.ts // The chrome that Toast and the static Notification share. Kit internals, // imported by file path; neither is part of the public Toast API. /** * The toast surface: the kit's floating surface, radius and fill. The static * Notification renders the same chrome, so a preview and the toast it previews * cannot drift apart. The `sp-toast-surface` rule in design-rework.css * draws the borderless floating surface of Popover and Dialog: one edge from * the floating shadow, no border (rulebook B7). The fill is the surface colour * that toast.css publishes and mixes in black per stack position; outside a * toast there is no stack position and the fill is the plain surface colour. * The radius is the control radius token, not `rounded-lg`: a toast portals out * of the provider while the preview sits inside it, and the provider rescales * `--radius`, which would make the two differ. */ export const toastSurfaceClassName = "sp-toast-surface sp-toast-replay-motion relative rounded-(--sp-rw-radius-control) bg-[color-mix(in_srgb,var(--sp-rw-surface-color),var(--color-black)_calc(1%*max(0,var(--toast-index,0))))] dark:bg-[color-mix(in_srgb,var(--sp-rw-surface-color),var(--color-black)_calc(6%*max(0,var(--toast-index,0))))]"; /** * Where the dismiss control sits on that surface: 8 px inside the top-end * corner, as the close of every overlay (rulebook B5). Button's * overlay-dismiss profile owns its 24 px box, its 16 px glyph, its colour and * its hover; the surface owns only the corner. Text beside it ends 8 px before * it, 40 px from the edge (`pe-10`). */ export const toastDismissClassName = "absolute end-2 top-2 z-10"; // ~/src/siteplane/components/toast/index.ts export * from "./toast";