Replace sonner toasts with one top-centre notification stack
The app said things in two places: sonner's bottom-right toasts and the custom top-centre chrome. This is the second of those, generalised — a module-level store anyone can call, drawn as a stack of frosted cards that go on their own unless raised as persistent, with room for buttons and for a message longer than a line. useCustomToast keeps its signatures, so the forty-odd call sites behind it and handleError are untouched. The component and its store are byte-identical with the portal's copy and checked by `make design-check`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The one place the app says something back.
|
||||
*
|
||||
* A single stack at the top centre, over whatever is below it. Newest on top,
|
||||
* each card going on its own after a few seconds unless it was raised as
|
||||
* persistent — an offline notice stays until the installation answers again.
|
||||
*
|
||||
* Raised from anywhere through `notify()` in `lib/notificationStore`, which is
|
||||
* where the semantics live; this only draws them.
|
||||
*
|
||||
* Byte-identical with the portal's copy — see DESIGN-GUIDELINES.md § Sync rule.
|
||||
*/
|
||||
|
||||
import { CircleAlert, CircleCheck, Info, TriangleAlert, X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { duration, easeStandard, transitions } from "@/lib/motion"
|
||||
import {
|
||||
type AppNotification,
|
||||
dismiss,
|
||||
type NotificationSeverity,
|
||||
notificationStore,
|
||||
} from "@/lib/notificationStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** Long enough to read a sentence, short enough not to sit in the way. */
|
||||
const AUTO_HIDE_MS = 5_000
|
||||
|
||||
const SEVERITY: Record<
|
||||
NotificationSeverity,
|
||||
{ icon: typeof Info; accent: string }
|
||||
> = {
|
||||
error: { icon: CircleAlert, accent: "text-destructive" },
|
||||
warning: { icon: TriangleAlert, accent: "text-primary" },
|
||||
success: { icon: CircleCheck, accent: "text-status-success" },
|
||||
info: { icon: Info, accent: "text-muted-foreground" },
|
||||
}
|
||||
|
||||
const exitTransition = { duration: duration.fast, ease: easeStandard }
|
||||
|
||||
export function Notifications() {
|
||||
const items = useSyncExternalStore(
|
||||
notificationStore.subscribe,
|
||||
notificationStore.snapshot,
|
||||
// The portal prerenders its shell, where there is no store to read yet.
|
||||
notificationStore.snapshot,
|
||||
)
|
||||
|
||||
return (
|
||||
// Over dialogs, which sit at z-50: a notification is often what explains
|
||||
// why the dialog did nothing. Not interactive except on the cards, so the
|
||||
// page underneath stays clickable through the empty column.
|
||||
<div className="pointer-events-none fixed inset-x-0 top-4 z-[100] flex flex-col items-center gap-2 px-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{[...items].reverse().map((item) => (
|
||||
<Card key={item.id} item={item} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ item }: { item: AppNotification }) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Hovering holds it: somebody is reading it, or about to press one of its
|
||||
// buttons. Leaving starts the full countdown again rather than resuming a
|
||||
// second of it.
|
||||
if (item.persistent || hovered) return
|
||||
const timer = setTimeout(() => dismiss(item.id), AUTO_HIDE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [item.id, item.persistent, hovered])
|
||||
|
||||
const { icon: Icon, accent } = SEVERITY[item.severity]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
role="alert"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0, transition: transitions.emphasized }}
|
||||
exit={{ opacity: 0, y: -16, transition: exitTransition }}
|
||||
className="pointer-events-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2 rounded-lg border border-border bg-card/80 p-3 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon className={cn("mt-0.5 size-5 shrink-0", accent)} />
|
||||
{/* Wraps rather than truncates: a message worth interrupting for is
|
||||
worth reading whole. */}
|
||||
<div className="min-w-0 flex-1 text-sm">{item.message}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Dismiss"
|
||||
className="-mr-1 -mt-1 shrink-0"
|
||||
onClick={() => dismiss(item.id)}
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Under the message rather than beside it, so the text keeps the whole
|
||||
card width however long the labels are. */}
|
||||
{item.actions?.length ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{item.actions.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
variant={action.variant ?? "outline"}
|
||||
size="sm"
|
||||
className="h-auto max-w-full whitespace-normal py-1.5 text-left"
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
Reference in New Issue
Block a user