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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
94
packages/ui/src/v2/Toaster/Toaster.stories.tsx
Normal file
94
packages/ui/src/v2/Toaster/Toaster.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user