"use client"; import { ArrowUpRight, BellOff } from "lucide-react"; import { motion, type Transition, useReducedMotion } from "motion/react"; import { type FocusEvent, type KeyboardEvent, type PointerEvent, type ReactNode, useCallback, useRef, useState, } from "react"; import { ActionSwapText } from "@/components/motion/action-swap"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { useHoverGesture } from "@/lib/hooks/use-hover-gesture"; import { useTapGesture } from "@/lib/hooks/use-tap-gesture"; 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 hasFocus = useRef(false); const rootRef = useRef(null); const hover = useHoverGesture(); // What the last gesture on the stack was, and whether it was already // expanded when that gesture started. A click reports neither. const tap = useTapGesture(); const [isExpanded, setIsExpanded] = useControllableExpanded({ expanded, defaultExpanded, onExpandedChange, }); // Set by the tap that expands the stack, and the reason the outside-tap // dismisser exists at all — a hovering pointer has its own way out. const [tapExpanded, setTapExpanded] = useState(false); const collapse = useCallback(() => { setTapExpanded(false); setIsExpanded(false); }, [setIsExpanded]); // A pointer leaving the stack is what collapses it, and a finger never // leaves: without this the stack stays open for good once tapped, and when // `onViewAll` is set the next tap follows the link instead of closing. The // tap that lands somewhere else stands in for the pointer leaving, and it is // consumed rather than passed through — the expanded stack covers the page, // so the tap that dismisses it is aimed at nothing else. useDismiss(tapExpanded && isExpanded, collapse, rootRef, { behavior: "consume", }); 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; collapse(); }; const handleKeyDown = (event: KeyboardEvent) => { // A key press is the start of a keyboard activation, never part of a tap: // whatever a taken-away gesture left behind must not be read as one. tap.drop(); if (event.key !== "Escape") return; event.preventDefault(); collapse(); // Focus stays where the keyboard put it. Blurring here collapsed the stack // *and* threw the user back to the top of the document. }; const handleClick = () => { const gesture = tap.take(); // Read from where the gesture started, not from now: a browser that // focuses the stack on contact expands it mid-tap, and the first tap would // then follow `onViewAll` instead of opening the list it was meant to. const wasExpanded = gesture ? gesture.state : isExpanded; if (!wasExpanded) { setIsExpanded(true); if (gesture && gesture.pointerType !== "mouse") setTapExpanded(true); return; } if (onViewAll) { onViewAll(); return; } collapse(); }; return ( ) => { if (hover.enter(event)) setIsExpanded(true); }} onPointerLeave={(event: PointerEvent) => { if (hover.leave(event) && !hasFocus.current) collapse(); }} onPointerDown={(event: PointerEvent) => { tap.start(event, isExpanded); }} // The platform can take the gesture away mid-press — a scroll, a system // swipe — and no click follows it. onPointerCancel={tap.drop} 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. */} ); }