mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ minHeight: 140 }}>
|
||||
<Toaster position="top-center" />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(<Toaster />);
|
||||
|
||||
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(<Toaster />);
|
||||
|
||||
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(<Toaster />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <ToastPrimitive.Provider {...props} />;
|
||||
}
|
||||
|
||||
function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
|
||||
return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />;
|
||||
}
|
||||
|
||||
function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Viewport
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
data-slot="toast"
|
||||
className={cn(
|
||||
"group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom select-none rounded-2xl border bg-popover text-popover-foreground shadow-lg outline-none will-change-transform focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
|
||||
"h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
|
||||
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
||||
"data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
|
||||
"data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
|
||||
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
|
||||
"data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
"data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
|
||||
"data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Content
|
||||
data-slot="toast-content"
|
||||
className={cn(
|
||||
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
|
||||
return <ToastPrimitive.Title data-slot="toast-title" className={cn("font-medium text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Description
|
||||
data-slot="toast-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastAction({
|
||||
className,
|
||||
render = <Button variant="outline" size="sm" />,
|
||||
...props
|
||||
}: ToastPrimitive.Action.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Action data-slot="toast-action" render={render} className={cn("shrink-0", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ToastClose({
|
||||
className,
|
||||
children,
|
||||
render = <Button variant="ghost" size="icon-sm" />,
|
||||
...props
|
||||
}: ToastPrimitive.Close.Props) {
|
||||
return (
|
||||
<ToastPrimitive.Close
|
||||
data-slot="toast-close"
|
||||
aria-label="Close toast"
|
||||
render={render}
|
||||
className={cn(
|
||||
"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon aria-hidden="true" />}
|
||||
</ToastPrimitive.Close>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastIcon({ type }: { type: string | undefined }) {
|
||||
let icon: React.ReactNode = null;
|
||||
|
||||
if (type === "success") icon = <CheckCircleIcon aria-hidden="true" />;
|
||||
if (type === "info") icon = <InfoIcon aria-hidden="true" />;
|
||||
if (type === "warning") icon = <WarningIcon aria-hidden="true" />;
|
||||
if (type === "error") icon = <XCircleIcon className="text-destructive" aria-hidden="true" />;
|
||||
if (type === "loading") icon = <SpinnerIcon className="animate-spin" aria-hidden="true" />;
|
||||
|
||||
if (!icon) return null;
|
||||
|
||||
return (
|
||||
<span data-slot="toast-icon" className="shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none">
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastList() {
|
||||
const { toasts } = ToastPrimitive.useToastManager();
|
||||
|
||||
return toasts.map((toastItem) => (
|
||||
<Toast key={toastItem.id} toast={toastItem}>
|
||||
<ToastContent>
|
||||
<ToastIcon type={toastItem.type} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<ToastTitle />
|
||||
<ToastDescription />
|
||||
</div>
|
||||
<ToastAction />
|
||||
<ToastClose />
|
||||
</ToastContent>
|
||||
</Toast>
|
||||
));
|
||||
}
|
||||
|
||||
function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
|
||||
return (
|
||||
<ToastProvider toastManager={toastManager} {...props}>
|
||||
{children}
|
||||
<ToastPortal>
|
||||
<ToastViewport>
|
||||
<ToastList />
|
||||
</ToastViewport>
|
||||
</ToastPortal>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const createToastManager = ToastPrimitive.createToastManager;
|
||||
const useToastManager = ToastPrimitive.useToastManager;
|
||||
|
||||
export {
|
||||
createToastManager,
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastContent,
|
||||
ToastDescription,
|
||||
Toaster,
|
||||
ToastPortal,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
toast,
|
||||
useToastManager,
|
||||
};
|
||||
Reference in New Issue
Block a user