"use client"; import { AnimatePresence, motion } from "motion/react"; import { type FocusEvent, type ReactNode, useState } from "react"; import { SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface NotificationStackItem { id: string; title: string; description?: string; /** A small trailing element, such as a retry count or a status icon. */ trailing?: ReactNode; } export interface NotificationStackProps { items: NotificationStackItem[]; /** Controlled expanded state. */ expanded?: boolean; /** Starting state when uncontrolled. Default false. */ defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; /** How many cards peek out behind the top one when collapsed. Default 3. */ maxVisible?: number; emptyLabel?: string; className?: string; } function Card({ item }: { item: NotificationStackItem }) { return ( {item.title} {item.description ? ( {item.description} ) : null} {item.trailing ? {item.trailing} : null} ); } /** * A deck of notification cards: collapsed to the top card with peeking * edges behind it, fanning into a readable list on hover or focus. */ export function NotificationStack({ items, expanded: expandedProp, defaultExpanded = false, onExpandedChange, maxVisible = 3, emptyLabel = "All caught up", className, }: NotificationStackProps) { const [uncontrolled, setUncontrolled] = useState(defaultExpanded); const isControlled = expandedProp !== undefined; const expanded = isControlled ? expandedProp : uncontrolled; const setExpanded = (next: boolean) => { if (!isControlled) setUncontrolled(next); onExpandedChange?.(next); }; const onBlur = (event: FocusEvent) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) setExpanded(false); }; if (items.length === 0) { return (

{emptyLabel}

); } const peek = items.slice(0, maxVisible); return ( // biome-ignore lint/a11y/noStaticElementInteractions: only tracks hover/focus to expand the stack; any interactive trailing content underneath is its own focusable control.
setExpanded(true)} onMouseLeave={() => setExpanded(false)} onFocus={() => setExpanded(true)} onBlur={onBlur} className={cn("relative isolate flex flex-col gap-2", className)} > {!expanded && peek.length > 1 ? (
); }