| 1 | import { InfoIcon } from "lucide-react"; |
| 2 | import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; |
| 3 | |
| 4 | import { cn } from "~/lib/utils"; |
| 5 | import { Button } from "../ui/button"; |
| 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 7 | import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; |
| 8 | import { observeResize } from "~/lib/observeResize"; |
| 9 | |
| 10 | // Match the duration-220 exit transition before removing a dismissed notice. |
| 11 | const DISMISS_TRANSITION_MS = 220; |
| 12 | |
| 13 | export 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 | |
| 26 | export type ComposerBannerStackContent = Pick< |
| 27 | ComposerBannerStackItem, |
| 28 | "id" | "variant" | "priority" |
| 29 | > & { readonly content: ReactNode }; |
| 30 | |
| 31 | type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent; |
| 32 | |
| 33 | function 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 | |
| 43 | interface 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 | |
| 51 | export 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. */ |
| 253 | function 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 | |
| 324 | function 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 | } |