125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
/**
|
|
* 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 instance answers again.
|
|
*
|
|
* Raised from anywhere through `notify()` in `lib/notificationStore`, which is
|
|
* where the semantics live; this only draws them.
|
|
*
|
|
* See DESIGN-GUIDELINES.md at the repository root for notification styling.
|
|
*/
|
|
|
|
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>
|
|
)
|
|
}
|