Adopt the shared v2 Toaster from the auth branch

Replace the minimal placeholder Toaster with the richer implementation
already built on compliance-portal-auth (per-type icons plus warning and
info variants, and its stories). Both branches now share the exact same
component and app-root wiring, so they converge instead of colliding
when they merge. Align App.tsx provider ordering to match.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-15 18:34:20 +02:00
parent 158861ccdd
commit e546f24148
4 changed files with 166 additions and 30 deletions

View File

@@ -27,11 +27,11 @@ import { router } from "#/routes";
export function App() {
return (
<RelayProvider>
<Toast.Provider>
<Toast.Provider>
<RelayProvider>
<RouterProvider router={router} />
<Toaster />
</Toast.Provider>
</RelayProvider>
</RelayProvider>
<Toaster />
</Toast.Provider>
);
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import { Button } from "../Button/Button";
import { Toaster } from "./Toaster";
export default {
title: "v2/Toaster",
component: Toaster,
};
const types = ["neutral", "success", "error", "warning", "info"] as const;
const titles: Record<(typeof types)[number], string> = {
neutral: "Saved",
success: "Access requested",
error: "Something went wrong",
warning: "Session expiring",
info: "New update available",
};
const descriptions: Record<(typeof types)[number], string> = {
neutral: "Your changes have been saved.",
success: "We'll email you once it's approved.",
error: "Please try again in a moment.",
warning: "Your session expires in 5 minutes.",
info: "A subprocessor list was published.",
};
function ToasterDemo({ withDescription = false }: { withDescription?: boolean }) {
const toast = Toast.useToastManager();
return (
<div className="flex flex-wrap gap-2">
{types.map(type => (
<Button
key={type}
variant="soft"
color="neutral"
highContrast
onClick={() =>
toast.add({
title: titles[type],
description: withDescription ? descriptions[type] : undefined,
type,
})}
>
{type}
</Button>
))}
<Toaster />
</div>
);
}
// Title-only toasts (the common case — most app mutations toast a single line).
// The Toaster reads Base UI's toast manager, so it must be wrapped in a
// `Toast.Provider` (mounted once at the app root in real usage).
export function Default() {
return (
<Toast.Provider>
<ToasterDemo />
</Toast.Provider>
);
}
// Toasts carrying a supporting description line under the title.
export function WithDescription() {
return (
<Toast.Provider>
<ToasterDemo withDescription />
</Toast.Provider>
);
}

View File

@@ -19,18 +19,37 @@
// SOFTWARE.
import { Toast } from "@base-ui/react/toast";
import { XIcon } from "@phosphor-icons/react";
import {
CheckCircleIcon,
InfoIcon,
WarningCircleIcon,
WarningIcon,
XIcon,
} from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { toaster } from "./variants";
type ToastType = "success" | "error" | "neutral";
type ToastType = "neutral" | "success" | "error" | "warning" | "info";
function resolveType(type: string | undefined): ToastType {
return type === "success" || type === "error" ? type : "neutral";
if (type === "success" || type === "error" || type === "warning" || type === "info") {
return type;
}
return "neutral";
}
// Renders the active toasts from the Base UI toast manager. Mount once at the
// app root, inside a `<Toast.Provider>`.
const typeIcons: Record<ToastType, ReactNode> = {
neutral: <InfoIcon weight="fill" />,
success: <CheckCircleIcon weight="fill" />,
error: <WarningCircleIcon weight="fill" />,
warning: <WarningIcon weight="fill" />,
info: <InfoIcon weight="fill" />,
};
// Styled Base UI toast viewport. Mount once at the app root inside a
// `Toast.Provider`; queue toasts through `Toast.useToastManager()`. See
// contrib/claude/ui.md.
export function Toaster() {
const { toasts } = Toast.useToastManager();
const slots = toaster();
@@ -39,15 +58,21 @@ export function Toaster() {
<Toast.Portal>
<Toast.Viewport className={slots.viewport()}>
{toasts.map((toast) => {
const variant = { type: resolveType(toast.type) };
const type = resolveType(toast.type);
const typed = toaster({ type });
return (
<Toast.Root key={toast.id} toast={toast} className={slots.toast(variant)}>
<div className={slots.content()}>
<Toast.Title className={slots.title(variant)} />
<Toast.Description className={slots.description()} />
<Toast.Root key={toast.id} toast={toast} className={typed.root()}>
<span aria-hidden className={typed.icon()}>
{typeIcons[type]}
</span>
<div className={typed.content()}>
<Toast.Title className={typed.title()} />
{toast.description != null && (
<Toast.Description className={typed.description()} />
)}
</div>
<Toast.Close className={slots.close()} aria-label="Close">
<Toast.Close className={typed.close()} aria-label="Close">
<XIcon />
</Toast.Close>
</Toast.Root>

View File

@@ -20,30 +20,47 @@
import { tv } from "tailwind-variants/lite";
// Toaster (Radix "Toast" over Base UI's Toast). A bottom-right stack of
// dismissible notifications, colored by type.
// Styled Base UI toast. Each toast is an elevated card that reuses Callout's
// `surface` hue language (tinted background + hue border, icon and body in the
// hue text step, title one step higher for contrast) so toasts and callouts
// read as one family. Meaning is carried by the icon too, not color alone.
export const toaster = tv({
slots: {
viewport: "fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2 outline-none",
toast: [
"pointer-events-auto flex items-start gap-3 rounded-3 border border-sand-a6 bg-sand-1 p-3 shadow-3",
"transition-all duration-200 ease-out",
"data-starting-style:translate-x-4 data-starting-style:opacity-0",
"data-ending-style:translate-x-4 data-ending-style:opacity-0",
viewport: [
"fixed bottom-0 right-0 z-60 flex w-[380px] max-w-[calc(100vw-2rem)] flex-col gap-2 p-4",
"outline-none",
],
root: [
"flex items-start gap-3 rounded-4 border p-4 shadow-4",
"transition-all duration-150",
"data-starting-style:translate-y-2 data-starting-style:opacity-0",
"data-ending-style:translate-y-2 data-ending-style:opacity-0",
],
// Icon and description inherit the root's hue text step (currentColor).
icon: "mt-px shrink-0 [&_svg]:size-5",
content: "flex min-w-0 flex-1 flex-col gap-1",
title: "text-2 font-medium text-sand-12",
description: "text-2 break-words text-sand-11",
close: "flex size-5 shrink-0 items-center justify-center rounded-1 text-sand-a10 outline-none transition-colors hover:bg-sand-3 hover:text-sand-12 [&_svg]:size-4",
title: "text-2 font-medium",
description: "text-1",
close: "-mr-1 -mt-1 shrink-0 rounded-2 p-1 opacity-70 transition-opacity hover:opacity-100 [&_svg]:size-4",
},
variants: {
// Mirrors Callout's surface tokens: bg step 2, border step 6, text step 11,
// with the title bumped to step 12.
type: {
success: { toast: "border-green-6 bg-green-2", title: "text-green-12" },
error: { toast: "border-red-6 bg-red-2", title: "text-red-12" },
neutral: {},
success: {},
error: {},
warning: {},
info: {},
},
},
compoundVariants: [
{ type: "neutral", class: { root: "bg-sand-2 border-sand-6 text-sand-11", title: "text-sand-12" } },
{ type: "success", class: { root: "bg-green-2 border-green-6 text-green-11", title: "text-green-12" } },
{ type: "error", class: { root: "bg-red-2 border-red-6 text-red-11", title: "text-red-12" } },
{ type: "warning", class: { root: "bg-amber-2 border-amber-6 text-amber-11", title: "text-amber-12" } },
{ type: "info", class: { root: "bg-sky-2 border-sky-6 text-sky-11", title: "text-sky-12" } },
],
defaultVariants: {
type: "neutral",
},