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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<RelayProvider>
|
<Toast.Provider>
|
||||||
<Toast.Provider>
|
<RelayProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
<Toaster />
|
</RelayProvider>
|
||||||
</Toast.Provider>
|
<Toaster />
|
||||||
</RelayProvider>
|
</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.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { Toast } from "@base-ui/react/toast";
|
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";
|
import { toaster } from "./variants";
|
||||||
|
|
||||||
type ToastType = "success" | "error" | "neutral";
|
type ToastType = "neutral" | "success" | "error" | "warning" | "info";
|
||||||
|
|
||||||
function resolveType(type: string | undefined): ToastType {
|
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
|
const typeIcons: Record<ToastType, ReactNode> = {
|
||||||
// app root, inside a `<Toast.Provider>`.
|
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() {
|
export function Toaster() {
|
||||||
const { toasts } = Toast.useToastManager();
|
const { toasts } = Toast.useToastManager();
|
||||||
const slots = toaster();
|
const slots = toaster();
|
||||||
@@ -39,15 +58,21 @@ export function Toaster() {
|
|||||||
<Toast.Portal>
|
<Toast.Portal>
|
||||||
<Toast.Viewport className={slots.viewport()}>
|
<Toast.Viewport className={slots.viewport()}>
|
||||||
{toasts.map((toast) => {
|
{toasts.map((toast) => {
|
||||||
const variant = { type: resolveType(toast.type) };
|
const type = resolveType(toast.type);
|
||||||
|
const typed = toaster({ type });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toast.Root key={toast.id} toast={toast} className={slots.toast(variant)}>
|
<Toast.Root key={toast.id} toast={toast} className={typed.root()}>
|
||||||
<div className={slots.content()}>
|
<span aria-hidden className={typed.icon()}>
|
||||||
<Toast.Title className={slots.title(variant)} />
|
{typeIcons[type]}
|
||||||
<Toast.Description className={slots.description()} />
|
</span>
|
||||||
|
<div className={typed.content()}>
|
||||||
|
<Toast.Title className={typed.title()} />
|
||||||
|
{toast.description != null && (
|
||||||
|
<Toast.Description className={typed.description()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Toast.Close className={slots.close()} aria-label="Close">
|
<Toast.Close className={typed.close()} aria-label="Close">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</Toast.Close>
|
</Toast.Close>
|
||||||
</Toast.Root>
|
</Toast.Root>
|
||||||
|
|||||||
@@ -20,30 +20,47 @@
|
|||||||
|
|
||||||
import { tv } from "tailwind-variants/lite";
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
// Toaster (Radix "Toast" over Base UI's Toast). A bottom-right stack of
|
// Styled Base UI toast. Each toast is an elevated card that reuses Callout's
|
||||||
// dismissible notifications, colored by type.
|
// `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({
|
export const toaster = tv({
|
||||||
slots: {
|
slots: {
|
||||||
viewport: "fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2 outline-none",
|
viewport: [
|
||||||
toast: [
|
"fixed bottom-0 right-0 z-60 flex w-[380px] max-w-[calc(100vw-2rem)] flex-col gap-2 p-4",
|
||||||
"pointer-events-auto flex items-start gap-3 rounded-3 border border-sand-a6 bg-sand-1 p-3 shadow-3",
|
"outline-none",
|
||||||
"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",
|
|
||||||
],
|
],
|
||||||
|
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",
|
content: "flex min-w-0 flex-1 flex-col gap-1",
|
||||||
title: "text-2 font-medium text-sand-12",
|
title: "text-2 font-medium",
|
||||||
description: "text-2 break-words text-sand-11",
|
description: "text-1",
|
||||||
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",
|
close: "-mr-1 -mt-1 shrink-0 rounded-2 p-1 opacity-70 transition-opacity hover:opacity-100 [&_svg]:size-4",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
// Mirrors Callout's surface tokens: bg step 2, border step 6, text step 11,
|
||||||
|
// with the title bumped to step 12.
|
||||||
type: {
|
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: {},
|
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: {
|
defaultVariants: {
|
||||||
type: "neutral",
|
type: "neutral",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user