1import { InfoIcon } from "lucide-react";
2import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
3
4import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
8import { observeResize } from "~/lib/observeResize";
9
10// Match the duration-220 exit transition before removing a dismissed notice.
11const DISMISS_TRANSITION_MS = 220;
12
13export interface ComposerBannerStackItem {
14 readonly id: string;
15 readonly variant: ComposerBannerVariant;
16 readonly priority?: "urgent" | "activity" | "notice";
17 readonly icon: ReactNode;
18 readonly title: ReactNode;
19 readonly description?: ReactNode;
20 readonly children?: ReactNode;
21 readonly actions?: ReactNode;
22 readonly dismissLabel?: string;
23 readonly onDismiss?: () => void;
24}
25
26export type ComposerBannerStackContent = Pick<
27 ComposerBannerStackItem,
28 "id" | "variant" | "priority"
29> & { readonly content: ReactNode };
30
31type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
32
33function bannerPriority(item: ComposerBannerStackEntry) {
34 if (item.priority === "activity") {
35 return 0;
36 }
37 if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") {
38 return 1;
39 }
40 return 2;
41}
42
43interface ComposerBannerStackProps {
44 readonly className?: string;
45 readonly items: ReadonlyArray<ComposerBannerStackEntry>;
46 /** Attachments that sit between the stacked notices and the front item, such as the queue.
47 The stacked notices peek above the whole column, never from between two attachments. */
48 readonly attachedAbove?: ReactNode;
49}
50
51export function ComposerBannerStack({ className, items, attachedAbove }: ComposerBannerStackProps) {
52 const [stackExpanded, setStackExpanded] = useState(false);
53 const noticesRef = useRef<HTMLDivElement>(null);
54 const peekRef = useRef<HTMLButtonElement>(null);
55 const expandedItemsRef = useRef<HTMLDivElement>(null);
56 const pendingFocusRef = useRef<"peek" | "notice" | null>(null);
57 const expandedItemsId = useId();
58 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
59 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60 const exitingItemId =
61 requestedExitingItemId !== null && items.some((item) => item.id === requestedExitingItemId)
62 ? requestedExitingItemId
63 : null;
64
65 useEffect(() => {
66 return () => {
67 if (dismissTimeoutRef.current) {
68 clearTimeout(dismissTimeoutRef.current);
69 }
70 };
71 }, []);
72
73 useEffect(() => {
74 if (items.length < 2) setStackExpanded(false);
75 }, [items.length]);
76
77 useLayoutEffect(() => {
78 if (stackExpanded && pendingFocusRef.current === "notice") {
79 pendingFocusRef.current = null;
80 const firstControl = expandedItemsRef.current?.querySelector<HTMLElement>(
81 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]',
82 );
83 (firstControl ?? expandedItemsRef.current)?.focus({ preventScroll: true });
84 } else if (!stackExpanded && pendingFocusRef.current === "peek") {
85 pendingFocusRef.current = null;
86 peekRef.current?.focus({ preventScroll: true });
87 }
88 }, [stackExpanded]);
89
90 // Activity stays attached. Urgency and severity only order the notices behind it.
91 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
92 const frontItem = orderedItems[0];
93 if (!frontItem) {
94 return (
95 <>
96 {null}
97 {attachedAbove}
98 {null}
99 </>
100 );
101 }
102 const stackedItems = orderedItems.slice(1);
103 const hasStack = stackedItems.length > 0;
104 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
105 const firstStackedItem = stackedItems[0];
106
107 const requestDismiss = (item: ComposerBannerStackEntry) => {
108 if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) {
109 return;
110 }
111 setExitingItemId(item.id);
112 if (dismissTimeoutRef.current) {
113 clearTimeout(dismissTimeoutRef.current);
114 }
115 dismissTimeoutRef.current = setTimeout(() => {
116 dismissTimeoutRef.current = null;
117 item.onDismiss?.();
118 }, DISMISS_TRANSITION_MS);
119 };
120
121 return (
122 <>
123 {hasStack ? (
124 <div
125 ref={noticesRef}
126 data-chat-composer-collapsed-controls="true"
127 className={cn("relative z-20 min-h-3", stackExpanded && "z-50")}
128 onPointerEnter={(event) => {
129 if (event.pointerType === "touch") return;
130 if (document.activeElement === peekRef.current) {
131 pendingFocusRef.current = "notice";
132 }
133 setStackExpanded(true);
134 }}
135 onPointerLeave={(event) => {
136 if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false);
137 }}
138 onBlurCapture={(event) => {
139 if (
140 !event.currentTarget.contains(event.relatedTarget) &&
141 !event.currentTarget.matches(":hover")
142 ) {
143 setStackExpanded(false);
144 }
145 }}
146 onKeyDown={(event) => {
147 if (event.key !== "Escape" || !stackExpanded) return;
148 event.preventDefault();
149 event.stopPropagation();
150 pendingFocusRef.current = "peek";
151 setStackExpanded(false);
152 }}
153 >
154 {showCollapsedStackCap && firstStackedItem ? (
155 <ComposerBanner.Peek
156 ref={peekRef}
157 variant={firstStackedItem.variant}
158 aria-label="Show other notices"
159 aria-expanded={stackExpanded}
160 aria-controls={expandedItemsId}
161 aria-hidden={stackExpanded || undefined}
162 tabIndex={stackExpanded ? -1 : 0}
163 onClick={(event) => {
164 event.currentTarget.focus({ preventScroll: true });
165 pendingFocusRef.current = "notice";
166 setStackExpanded(true);
167 }}
168 className={cn(stackExpanded && "pointer-events-none invisible opacity-0")}
169 />
170 ) : null}
171 <div
172 id={expandedItemsId}
173 ref={expandedItemsRef}
174 role="group"
175 aria-label="Other notices"
176 tabIndex={-1}
177 data-composer-banner-stack-expanded-items="true"
178 className={cn(
179 "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
180 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
181 )}
182 >
183 <div className="min-h-0 overflow-hidden">
184 <div
185 className={cn(
186 "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
187 stackExpanded
188 ? "pointer-events-auto visible translate-y-0 opacity-100"
189 : "pointer-events-none invisible translate-y-1 opacity-0",
190 )}
191 >
192 {stackedItems.map((item) => (
193 <div
194 key={item.id}
195 className={cn(
196 "transition-[opacity,translate] duration-220 ease-in",
197 exitingItemId === item.id
198 ? "pointer-events-none translate-y-28 opacity-0"
199 : "opacity-100",
200 )}
201 >
202 <ComposerBannerStackAlert
203 item={item}
204 attached={false}
205 exiting={exitingItemId === item.id}
206 onDismissRequest={() => requestDismiss(item)}
207 />
208 </div>
209 ))}
210 </div>
211 </div>
212 </div>
213 </div>
214 ) : null}
215 {attachedAbove}
216 <ComposerBanner.Attachment
217 className={className}
218 data-composer-banner-drawer="true"
219 data-chat-composer-collapsed-controls="true"
220 >
221 <div
222 key={frontItem.id}
223 className={cn(
224 "relative z-10 transition-[opacity,translate] duration-220 ease-in",
225 exitingItemId === frontItem.id
226 ? "pointer-events-none translate-y-16 opacity-0"
227 : "opacity-100",
228 )}
229 onPointerDownCapture={() => {
230 setStackExpanded(false);
231 const activeElement = document.activeElement;
232 if (
233 activeElement instanceof HTMLElement &&
234 noticesRef.current?.contains(activeElement)
235 ) {
236 activeElement.blur();
237 }
238 }}
239 >
240 <ComposerBannerStackAlert
241 item={frontItem}
242 attached
243 exiting={exitingItemId === frontItem.id}
244 onDismissRequest={() => requestDismiss(frontItem)}
245 />
246 </div>
247 </ComposerBanner.Attachment>
248 </>
249 );
250}
251
252/** Keep full descriptions reachable only when their inline copy is clipped. */
253function NoticeDescription({ children }: { children: ReactNode }) {
254 const descriptionRef = useRef<HTMLSpanElement>(null);
255 const detailsRef = useRef<HTMLButtonElement>(null);
256 const [showDetails, setShowDetails] = useState(false);
257
258 useLayoutEffect(() => {
259 const description = descriptionRef.current;
260 if (!description) return;
261 const measure = () => {
262 // Ignore the space taken by the details button itself so it cannot
263 // sustain its own overflow after the description would otherwise fit.
264 const recoveredWidth = detailsRef.current ? detailsRef.current.offsetWidth + 4 : 0;
265 const hidden = getComputedStyle(description).position === "absolute";
266 setShowDetails(
267 hidden ||
268 [description, ...description.querySelectorAll("*")].some(
269 (element) => element.scrollWidth > element.clientWidth + recoveredWidth,
270 ),
271 );
272 };
273 measure();
274 const stopObserving = observeResize(description, measure);
275 // A child can reveal new text without resizing its clipped box.
276 const mutations = new MutationObserver(measure);
277 mutations.observe(description, { childList: true, subtree: true, characterData: true });
278 return () => {
279 stopObserving();
280 mutations.disconnect();
281 };
282 }, []);
283
284 return (
285 <span className="contents">
286 <span
287 ref={descriptionRef}
288 className="w-0 min-w-0 grow truncate text-muted-foreground @max-[400px]:sr-only"
289 >
290 {children}
291 </span>
292 {showDetails ? (
293 <Popover>
294 <PopoverTrigger
295 openOnHover
296 render={
297 <Button
298 ref={detailsRef}
299 size="icon-xs"
300 variant="ghost-muted"
301 aria-label="Show notice details"
302 className="flex-none"
303 />
304 }
305 >
306 <InfoIcon />
307 </PopoverTrigger>
308 <PopoverPopup
309 aria-label="Notice details"
310 tooltipStyle
311 side="top"
312 className="max-w-[min(30rem,calc(100vw-2rem))] whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full"
313 >
314 <ComposerBanner.Scroll className="max-h-[min(var(--available-height),24rem,40dvh)]">
315 {children}
316 </ComposerBanner.Scroll>
317 </PopoverPopup>
318 </Popover>
319 ) : null}
320 </span>
321 );
322}
323
324function ComposerBannerStackAlert({
325 item,
326 attached,
327 exiting,
328 onDismissRequest,
329}: {
330 readonly item: ComposerBannerStackEntry;
331 readonly attached: boolean;
332 readonly exiting: boolean;
333 readonly onDismissRequest: () => void;
334}) {
335 if ("content" in item) {
336 return (
337 <ComposerBanner.Root
338 density="comfortable"
339 placement={attached ? "attached" : "floating"}
340 variant={item.variant}
341 >
342 {item.content}
343 </ComposerBanner.Root>
344 );
345 }
346 return (
347 <ComposerBanner.Root
348 role="alert"
349 placement={attached ? "attached" : "floating"}
350 variant={item.variant}
351 density="comfortable"
352 >
353 <ComposerBanner.Row layout="wrap-actions">
354 <ComposerBanner.Icon className="h-(--composer-banner-icon-column) self-start">
355 {item.icon}
356 </ComposerBanner.Icon>
357 <ComposerBanner.Content className="whitespace-nowrap">
358 <span className="min-w-0 truncate font-medium leading-7 sm:leading-6">{item.title}</span>
359 {item.description ? <NoticeDescription>{item.description}</NoticeDescription> : null}
360 </ComposerBanner.Content>
361 {item.actions || item.onDismiss ? (
362 <ComposerBanner.Actions>
363 {item.actions}
364 {item.onDismiss ? (
365 <ComposerBanner.Dismiss
366 aria-label={item.dismissLabel ?? "Dismiss warning"}
367 disabled={exiting}
368 onClick={onDismissRequest}
369 />
370 ) : null}
371 </ComposerBanner.Actions>
372 ) : null}
373 </ComposerBanner.Row>
374 {item.children ? <ComposerBanner.Children>{item.children}</ComposerBanner.Children> : null}
375 </ComposerBanner.Root>
376 );
377}