import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";import { XIcon } from "lucide-react"; import { cn } from "~/lib/utils";import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";import { Button } from "../ui/button"; const DISMISS_TRANSITION_MS = 220;const frontExitStyle = { opacity: 0, transform: "translate3d(0, 4rem, 0)",} satisfies CSSProperties;const stackedExitStyle = { opacity: 0, transform: "translate3d(0, 7rem, 0)",} satisfies CSSProperties;const restingStyle = { opacity: 1, transform: "none",} satisfies CSSProperties;const exitTransitionStyle = { transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,} satisfies CSSProperties; export interface ComposerBannerStackItem { readonly id: string; readonly variant: "error" | "info" | "success" | "warning"; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; readonly actions?: ReactNode; readonly className?: string; readonly actionClassName?: string; readonly dismissLabel?: string; readonly onDismiss?: () => void;} interface ComposerBannerStackProps { readonly className?: string; readonly items: ReadonlyArray<ComposerBannerStackItem>;} export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null); const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const exitingItemId = requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId) ? requestedExitingItemId : null; useEffect(() => { return () => { if (dismissTimeoutRef.current) { clearTimeout(dismissTimeoutRef.current); } }; }, []); if (items.length === 0) { return null; } const frontItem = items[0]; if (!frontItem) { return null; } const stackedItems = items.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; const requestDismiss = (item: ComposerBannerStackItem) => { if (!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 ( <div className={cn("group/banner-stack mx-auto mb-2 max-w-3xl", className)}> <div className={cn( "relative flex flex-col-reverse", hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null, )} > {showCollapsedStackCap ? ( <div className={cn( "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-xl", "border border-b-0 border-warning/24 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]", "transition-opacity duration-150 ease-out", "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0", )} style={{ width: "96%" }} aria-hidden="true" /> ) : null} <div className={cn( "relative z-10", exitingItemId === frontItem.id ? "pointer-events-none" : null, )} style={{ ...exitTransitionStyle, ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle), }} > <ComposerBannerStackAlert item={frontItem} exiting={exitingItemId === frontItem.id} onDismissRequest={() => requestDismiss(frontItem)} /> </div> {hasStack ? ( <div data-composer-banner-stack-expanded-items="true" className={cn( "relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out", "group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]", )} > <div className="min-h-0 overflow-hidden"> <div className={cn( "invisible pointer-events-none space-y-2 pb-2 opacity-0", "translate-y-1 transform-gpu transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]", "group-hover/banner-stack:visible group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100", "group-focus-within/banner-stack:visible group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100", )} > {stackedItems.map((item) => ( <div key={item.id} className={cn(exitingItemId === item.id ? "pointer-events-none" : null)} style={{ ...exitTransitionStyle, ...(exitingItemId === item.id ? stackedExitStyle : restingStyle), }} > <ComposerBannerStackAlert item={item} exiting={exitingItemId === item.id} onDismissRequest={() => requestDismiss(item)} /> </div> ))} </div> </div> </div> ) : null} </div> </div> );} function ComposerBannerStackAlert({ item, exiting, onDismissRequest,}: { readonly item: ComposerBannerStackItem; readonly exiting: boolean; readonly onDismissRequest: () => void;}) { const dismissOnly = item.onDismiss && !item.actions; return ( <Alert variant={item.variant} className={cn("alert-glass", item.className)} data-variant={item.variant} > {item.icon} <AlertTitle>{item.title}</AlertTitle> {item.description ? <AlertDescription>{item.description}</AlertDescription> : null} {item.actions || item.onDismiss ? ( <AlertAction className={cn( item.actionClassName, dismissOnly ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start" : undefined, )} > {item.actions} {item.onDismiss ? ( <Button size="icon-xs" variant="ghost" aria-label={item.dismissLabel ?? "Dismiss warning"} disabled={exiting} onClick={onDismissRequest} > <XIcon className="size-3.5" /> </Button> ) : null} </AlertAction> ) : null} </Alert> );}
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
import { Button } from "../ui/button";
const DISMISS_TRANSITION_MS = 220;
const frontExitStyle = {
opacity: 0,
transform: "translate3d(0, 4rem, 0)",
} satisfies CSSProperties;
const stackedExitStyle = {
opacity: 0,
transform: "translate3d(0, 7rem, 0)",
} satisfies CSSProperties;
const restingStyle = {
opacity: 1,
transform: "none",
} satisfies CSSProperties;
const exitTransitionStyle = {
transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
} satisfies CSSProperties;
export interface ComposerBannerStackItem {
readonly id: string;
readonly variant: "error" | "info" | "success" | "warning";
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly className?: string;
readonly actionClassName?: string;
readonly dismissLabel?: string;
readonly onDismiss?: () => void;
}
interface ComposerBannerStackProps {
readonly className?: string;
readonly items: ReadonlyArray<ComposerBannerStackItem>;
}
export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {
const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const exitingItemId =
requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId)
? requestedExitingItemId
: null;
useEffect(() => {
return () => {
if (dismissTimeoutRef.current) {
clearTimeout(dismissTimeoutRef.current);
}
};
}, []);
if (items.length === 0) {
return null;
}
const frontItem = items[0];
if (!frontItem) {
return null;
}
const stackedItems = items.slice(1);
const hasStack = stackedItems.length > 0;
const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
const requestDismiss = (item: ComposerBannerStackItem) => {
if (!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 (
<div className={cn("group/banner-stack mx-auto mb-2 max-w-3xl", className)}>
<div
className={cn(
"relative flex flex-col-reverse",
hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
)}
>
{showCollapsedStackCap ? (
<div
className={cn(
"pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-xl",
"border border-b-0 border-warning/24 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
"transition-opacity duration-150 ease-out",
"group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
)}
style={{ width: "96%" }}
aria-hidden="true"
/>
) : null}
<div
className={cn(
"relative z-10",
exitingItemId === frontItem.id ? "pointer-events-none" : null,
)}
style={{
...exitTransitionStyle,
...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),
}}
>
<ComposerBannerStackAlert
item={frontItem}
exiting={exitingItemId === frontItem.id}
onDismissRequest={() => requestDismiss(frontItem)}
/>
</div>
{hasStack ? (
<div
data-composer-banner-stack-expanded-items="true"
className={cn(
"relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out",
"group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"invisible pointer-events-none space-y-2 pb-2 opacity-0",
"translate-y-1 transform-gpu transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
"group-hover/banner-stack:visible group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100",
"group-focus-within/banner-stack:visible group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100",
)}
>
{stackedItems.map((item) => (
<div
key={item.id}
className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}
style={{
...exitTransitionStyle,
...(exitingItemId === item.id ? stackedExitStyle : restingStyle),
}}
>
<ComposerBannerStackAlert
item={item}
exiting={exitingItemId === item.id}
onDismissRequest={() => requestDismiss(item)}
/>
</div>
))}
</div>
</div>
</div>
) : null}
</div>
</div>
);
}
function ComposerBannerStackAlert({
item,
exiting,
onDismissRequest,
}: {
readonly item: ComposerBannerStackItem;
readonly exiting: boolean;
readonly onDismissRequest: () => void;
}) {
const dismissOnly = item.onDismiss && !item.actions;
return (
<Alert
variant={item.variant}
className={cn("alert-glass", item.className)}
data-variant={item.variant}
>
{item.icon}
<AlertTitle>{item.title}</AlertTitle>
{item.description ? <AlertDescription>{item.description}</AlertDescription> : null}
{item.actions || item.onDismiss ? (
<AlertAction
className={cn(
item.actionClassName,
dismissOnly
? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"
: undefined,
)}
>
{item.actions}
{item.onDismiss ? (
<Button
size="icon-xs"
variant="ghost"
aria-label={item.dismissLabel ?? "Dismiss warning"}
disabled={exiting}
onClick={onDismissRequest}
>
<XIcon className="size-3.5" />
</Button>
) : null}
</AlertAction>
) : null}
</Alert>
);
}