import { InfoIcon } from "lucide-react"; import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; // Match the duration-220 exit transition before removing a dismissed notice. const DISMISS_TRANSITION_MS = 220; export interface ComposerBannerStackItem { readonly id: string; readonly variant: ComposerBannerVariant; readonly priority?: "urgent" | "activity" | "notice"; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; readonly children?: ReactNode; readonly actions?: ReactNode; readonly dismissLabel?: string; readonly onDismiss?: () => void; } export type ComposerBannerStackContent = Pick< ComposerBannerStackItem, "id" | "variant" | "priority" > & { readonly content: ReactNode }; type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent; function bannerPriority(item: ComposerBannerStackEntry) { if (item.priority === "activity") { return 0; } if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") { return 1; } return 2; } interface ComposerBannerStackProps { readonly className?: string; readonly items: ReadonlyArray; } export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { const [stackExpanded, setStackExpanded] = useState(false); const noticesRef = useRef(null); const peekRef = useRef(null); const expandedItemsRef = useRef(null); const pendingFocusRef = useRef<"peek" | "notice" | null>(null); const expandedItemsId = useId(); const [requestedExitingItemId, setExitingItemId] = useState(null); const dismissTimeoutRef = useRef | null>(null); const exitingItemId = requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId) ? requestedExitingItemId : null; useEffect(() => { return () => { if (dismissTimeoutRef.current) { clearTimeout(dismissTimeoutRef.current); } }; }, []); useEffect(() => { if (items.length < 2) setStackExpanded(false); }, [items.length]); useLayoutEffect(() => { if (stackExpanded && pendingFocusRef.current === "notice") { pendingFocusRef.current = null; const firstControl = expandedItemsRef.current?.querySelector( 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]', ); (firstControl ?? expandedItemsRef.current)?.focus({ preventScroll: true }); } else if (!stackExpanded && pendingFocusRef.current === "peek") { pendingFocusRef.current = null; peekRef.current?.focus({ preventScroll: true }); } }, [stackExpanded]); if (items.length === 0) { return null; } // Activity stays attached. Urgency and severity only order the notices behind it. const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b)); const frontItem = orderedItems[0]; if (!frontItem) { return null; } const stackedItems = orderedItems.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; const firstStackedItem = stackedItems[0]; const requestDismiss = (item: ComposerBannerStackEntry) => { if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) { return; } setExitingItemId(item.id); if (dismissTimeoutRef.current) { clearTimeout(dismissTimeoutRef.current); } dismissTimeoutRef.current = setTimeout(() => { dismissTimeoutRef.current = null; item.onDismiss?.(); }, DISMISS_TRANSITION_MS); }; return (
{ setStackExpanded(false); const activeElement = document.activeElement; if ( activeElement instanceof HTMLElement && noticesRef.current?.contains(activeElement) ) { activeElement.blur(); } }} > requestDismiss(frontItem)} />
{hasStack ? (
{ if (event.pointerType === "touch") return; if (document.activeElement === peekRef.current) { pendingFocusRef.current = "notice"; } setStackExpanded(true); }} onPointerLeave={(event) => { if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false); }} onBlurCapture={(event) => { if ( !event.currentTarget.contains(event.relatedTarget) && !event.currentTarget.matches(":hover") ) { setStackExpanded(false); } }} onKeyDown={(event) => { if (event.key !== "Escape" || !stackExpanded) return; event.preventDefault(); event.stopPropagation(); pendingFocusRef.current = "peek"; setStackExpanded(false); }} > {showCollapsedStackCap && firstStackedItem ? ( { event.currentTarget.focus({ preventScroll: true }); pendingFocusRef.current = "notice"; setStackExpanded(true); }} className={cn(stackExpanded && "pointer-events-none invisible opacity-0")} /> ) : null}
{stackedItems.map((item) => (
requestDismiss(item)} />
))}
) : null}
); } function ComposerBannerStackAlert({ item, attached, exiting, onDismissRequest, }: { readonly item: ComposerBannerStackEntry; readonly attached: boolean; readonly exiting: boolean; readonly onDismissRequest: () => void; }) { if ("content" in item) { return ( {item.content} ); } return ( {item.icon} {item.title} {item.description ? ( <> {item.description} } > {item.description} ) : null} {item.actions || item.onDismiss ? ( {item.actions} {item.onDismiss ? ( ) : null} ) : null} {item.children ? {item.children} : null} ); }