"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.