"use client"; import { ArrowUpRight, BellOff } from "lucide-react"; import { motion, useReducedMotion, type Transition } from "motion/react"; import { useCallback, useRef, useState, type FocusEvent, type KeyboardEvent, type ReactNode, } from "react"; import { ActionSwapText } from "@/components/motion/action-swap"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export type NotificationStackItem = { id: string; title: ReactNode; description?: ReactNode; trailing?: ReactNode; }; export type NotificationStackClassNames = { stack?: string; card?: string; content?: string; title?: string; description?: string; trailing?: string; footer?: string; count?: string; }; export interface NotificationStackProps { items: NotificationStackItem[]; expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; onViewAll?: () => void; maxVisible?: number; collapsedLabel?: string; expandedLabel?: string; emptyLabel?: string; className?: string; classNames?: NotificationStackClassNames; } const STACK_PEEK = 8; const STACK_INSET = 12; function useControllableExpanded({ expanded, defaultExpanded, onExpandedChange, }: { expanded?: boolean; defaultExpanded: boolean; onExpandedChange?: (expanded: boolean) => void; }) { const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const isControlled = expanded !== undefined; const value = expanded ?? internalExpanded; const setValue = useCallback( (next: boolean) => { if (!isControlled) setInternalExpanded(next); onExpandedChange?.(next); }, [isControlled, onExpandedChange], ); return [value, setValue] as const; } function NotificationCardContent({ item, classNames, }: { item: NotificationStackItem; classNames?: NotificationStackClassNames; }) { return ( {item.title} {item.trailing ? ( {item.trailing} ) : null} {item.description ? ( {item.description} ) : null} ); } export function NotificationStack({ items, expanded, defaultExpanded = false, onExpandedChange, onViewAll, maxVisible = 3, collapsedLabel = "Notifications", expandedLabel = "View all", emptyLabel = "All caught up", className, classNames, }: NotificationStackProps) { const reduce = useReducedMotion(); const canHover = useHoverCapable(); const hasFocus = useRef(false); const [isExpanded, setIsExpanded] = useControllableExpanded({ expanded, defaultExpanded, onExpandedChange, }); const visibleItems = items.slice(0, Math.max(1, maxVisible)); const primaryItem = visibleItems[0]; const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT; const cardTransition: Transition = reduce ? { duration: 0 } : { duration: 0.32, ease: EASE_OUT }; const backgroundTransition: Transition = reduce ? { duration: 0 } : { duration: 0.26, ease: EASE_OUT }; if (!primaryItem) { return (
); } const handleBlur = (event: FocusEvent) => { if (event.currentTarget.contains(event.relatedTarget)) return; hasFocus.current = false; setIsExpanded(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); setIsExpanded(false); event.currentTarget.blur(); }; const handleClick = () => { if (!isExpanded) { setIsExpanded(true); return; } if (onViewAll) { onViewAll(); return; } setIsExpanded(false); }; return ( { if (canHover) setIsExpanded(true); }} onPointerLeave={() => { if (canHover && !hasFocus.current) setIsExpanded(false); }} onFocus={() => { hasFocus.current = true; setIsExpanded(true); }} onBlur={handleBlur} onKeyDown={handleKeyDown} onClick={handleClick} className={cn( "relative z-10 block w-full max-w-[22rem] cursor-pointer rounded-3xl text-left text-foreground outline-none", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", className, )} > {/* This invisible first card gives the button its compact intrinsic footprint. */} ); }