From 170550ed597383e7cad7909f190b7ded0869e5a3 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 17 Aug 2026 22:32:31 +0200 Subject: [PATCH] feat(ui): add a Base UI toast component Adds the toast primitive that replaces sonner, along with its design-sync card mapping. Nothing consumes it yet; the call sites move over next. --- .design-sync/NOTES.md | 2 +- .design-sync/previews/Toaster.tsx | 11 +- packages/ui/src/components/toast.test.tsx | 34 ++++ packages/ui/src/components/toast.tsx | 182 ++++++++++++++++++++++ 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/components/toast.test.tsx create mode 100644 packages/ui/src/components/toast.tsx diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md index 62c2ce03b..d83ab88d7 100644 --- a/.design-sync/NOTES.md +++ b/.design-sync/NOTES.md @@ -14,7 +14,7 @@ Syncs to Claude Design project **Reactive Resume** (`3c0f6556-050a-41e5-9886-c3f ## Card scope - The package exports **202 symbols** (39 primary components + 163 compound sub-parts). User chose **~40 primary cards**: `cfg.componentSrcMap` nulls the 163 sub-parts. All 202 stay importable from `window.RRUI` (the bundle exports everything regardless of the card list), so previews compose sub-parts (`RRUI.DialogContent`, etc.) freely. -- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `sonner.tsx`→Toaster. +- Multi-primary files represented by one card: `combobox.tsx`→ComboboxRoot, `form.tsx`→FormItem, `resizable.tsx`→ResizableGroup, `toast.tsx`→Toaster. ## Preview authoring conventions (calibrated on Button / Alert / Dialog) diff --git a/.design-sync/previews/Toaster.tsx b/.design-sync/previews/Toaster.tsx index 8d26aaee4..528013762 100644 --- a/.design-sync/previews/Toaster.tsx +++ b/.design-sync/previews/Toaster.tsx @@ -1,19 +1,20 @@ import { useEffect } from "react"; -import { toast } from "sonner"; -import { Toaster } from "@reactive-resume/ui/components/sonner"; +import { Toaster, toast } from "@reactive-resume/ui/components/toast"; // Toaster is the toast host. Fire a persistent toast on mount so the card // shows a real notification instead of an empty portal. export const Notification = () => { useEffect(() => { - toast.success("Resume published", { + toast.add({ + type: "success", + title: "Resume published", description: "“Software Engineer” is now live at rxresume.me/jane-doe.", - duration: Number.POSITIVE_INFINITY, + timeout: 0, }); }, []); return (
- +
); }; diff --git a/packages/ui/src/components/toast.test.tsx b/packages/ui/src/components/toast.test.tsx new file mode 100644 index 000000000..7747eb146 --- /dev/null +++ b/packages/ui/src/components/toast.test.tsx @@ -0,0 +1,34 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Toaster, toast } from "./toast"; + +describe("Toaster", () => { + it("renders a toast added through the manager", async () => { + render(); + + toast.add({ type: "success", title: "Resume published", description: "It is now live." }); + + await waitFor(() => expect(screen.getByText("Resume published")).toBeInTheDocument()); + expect(screen.getByText("It is now live.")).toBeInTheDocument(); + }); + + it("removes a toast when closed by id", async () => { + render(); + + const id = toast.add({ description: "Temporary", timeout: 0 }); + await waitFor(() => expect(screen.getByText("Temporary")).toBeInTheDocument()); + + toast.close(id); + await waitFor(() => expect(screen.queryByText("Temporary")).not.toBeInTheDocument()); + }); + + it("upserts a toast reusing the same id", async () => { + render(); + + const id = toast.add({ id: "upsert", description: "Saving…", timeout: 0 }); + toast.add({ id, type: "success", description: "Saved." }); + + await waitFor(() => expect(screen.getByText("Saved.")).toBeInTheDocument()); + expect(screen.queryByText("Saving…")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx new file mode 100644 index 000000000..12e92ec56 --- /dev/null +++ b/packages/ui/src/components/toast.tsx @@ -0,0 +1,182 @@ +import type * as React from "react"; +import { Toast as ToastPrimitive } from "@base-ui/react/toast"; +import { CheckCircleIcon, InfoIcon, SpinnerIcon, WarningIcon, XCircleIcon, XIcon } from "@phosphor-icons/react"; +import { cn } from "@reactive-resume/utils/style"; +import { Button } from "./button"; + +const toast = ToastPrimitive.createToastManager(); + +function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) { + return ; +} + +function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) { + return ; +} + +function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { + return ( + + ); +} + +function Toast({ className, ...props }: ToastPrimitive.Root.Props) { + return ( + + ); +} + +function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) { + return ( + + ); +} + +function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) { + return ; +} + +function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) { + return ( + + ); +} + +function ToastAction({ + className, + render =