| 1 | import { mergeProps } from "@base-ui/react/merge-props"; |
| 2 | import { useRender } from "@base-ui/react/use-render"; |
| 3 | import { ChevronDownIcon, XIcon } from "lucide-react"; |
| 4 | import type { ComponentProps } from "react"; |
| 5 | |
| 6 | import { cn } from "~/lib/utils"; |
| 7 | import { Button } from "../ui/button"; |
| 8 | import { ScrollArea } from "../ui/scroll-area"; |
| 9 | |
| 10 | export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning"; |
| 11 | |
| 12 | const surfaceColors = cn( |
| 13 | "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]", |
| 14 | "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--surface-raised))]", |
| 15 | "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]", |
| 16 | ); |
| 17 | |
| 18 | const neutralOutline = cn( |
| 19 | "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]", |
| 20 | "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]", |
| 21 | "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]", |
| 22 | "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--app-theme-input)_30%,var(--background)))]", |
| 23 | "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]", |
| 24 | ); |
| 25 | |
| 26 | const variantColors: Record<ComposerBannerVariant, string> = { |
| 27 | default: neutralOutline, |
| 28 | error: |
| 29 | "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]", |
| 30 | info: neutralOutline, |
| 31 | success: neutralOutline, |
| 32 | warning: |
| 33 | "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", |
| 34 | }; |
| 35 | |
| 36 | /** Shared glass and attachment seam, also used by the command menu without banner row padding. */ |
| 37 | function Surface({ |
| 38 | placement = "attached", |
| 39 | variant = "default", |
| 40 | className, |
| 41 | ...props |
| 42 | }: ComponentProps<"div"> & { |
| 43 | placement?: "attached" | "floating"; |
| 44 | variant?: ComposerBannerVariant; |
| 45 | }) { |
| 46 | return ( |
| 47 | <div |
| 48 | data-composer-banner-surface={placement} |
| 49 | data-variant={variant} |
| 50 | className={cn( |
| 51 | surfaceColors, |
| 52 | "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]", |
| 53 | variantColors[variant], |
| 54 | // The mask cut-off (1rem) bleeds one pixel past the seam (1rem + 1px): Chromium |
| 55 | // drops the last device-pixel row of a filtered backdrop when the cut-off lands |
| 56 | // off the device-pixel grid, and the composer's surface starts exactly there. |
| 57 | // The composer's own glass covers the extra row, so the overlap never shows. |
| 58 | placement === "attached" |
| 59 | ? "[--chat-composer-attachment-overlap:calc(1rem+1px)] before:rounded-t-2xl before:mask-t-from-transparent before:mask-t-from-4 before:mask-t-to-black before:mask-t-to-4" |
| 60 | : "[--chat-composer-attachment-overlap:0px] before:rounded-2xl", |
| 61 | "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)", |
| 62 | "before:bg-(--chat-composer-attached-surface)/(--glass-opacity) before:bg-linear-to-b before:from-(--chat-composer-attached-tint) before:to-(--chat-composer-attached-tint) before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)", |
| 63 | "before:shadow-composer dark:before:shadow-composer-dark", |
| 64 | "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-composer-seam-above", |
| 65 | "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)", |
| 66 | className, |
| 67 | )} |
| 68 | {...props} |
| 69 | /> |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | // A peeking notice uses the first hidden notice's severity, never the attached row's. |
| 74 | const peekBorder: Record<ComposerBannerVariant, string> = { |
| 75 | default: "border-(--chat-composer-attached-outline)", |
| 76 | error: "border-destructive/24", |
| 77 | info: "border-(--chat-composer-attached-outline)", |
| 78 | success: "border-(--chat-composer-attached-outline)", |
| 79 | warning: "border-warning/24", |
| 80 | }; |
| 81 | |
| 82 | function Peek({ |
| 83 | className, |
| 84 | variant = "default", |
| 85 | ...props |
| 86 | }: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) { |
| 87 | return ( |
| 88 | <button |
| 89 | type="button" |
| 90 | data-slot="composer-banner-peek" |
| 91 | className={cn( |
| 92 | surfaceColors, |
| 93 | neutralOutline, |
| 94 | "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-md", |
| 95 | "bg-(--chat-composer-attached-surface)/(--glass-opacity) backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)", |
| 96 | "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:bg-(--chat-composer-attached-surface)", |
| 97 | "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring", |
| 98 | peekBorder[variant], |
| 99 | className, |
| 100 | )} |
| 101 | {...props} |
| 102 | /> |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | function Attachment({ className, ...props }: ComponentProps<"div">) { |
| 107 | return ( |
| 108 | <div |
| 109 | data-slot="composer-banner-attachment" |
| 110 | className={cn( |
| 111 | "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", |
| 112 | // Adjacent attachments share their outline, including notices outside the form. |
| 113 | "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0", |
| 114 | "[&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0", |
| 115 | className, |
| 116 | )} |
| 117 | {...props} |
| 118 | /> |
| 119 | ); |
| 120 | } |
| 121 | |
| 122 | function Dock({ className, ...props }: ComponentProps<"div">) { |
| 123 | return ( |
| 124 | <Attachment |
| 125 | className={cn( |
| 126 | "flex items-end gap-1 not-has-data-[composer-banner-surface=attached]:hidden", |
| 127 | className, |
| 128 | )} |
| 129 | {...props} |
| 130 | /> |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | /** Attachments share a column while neighboring tabs keep their own surface. */ |
| 135 | function Column({ className, ...props }: ComponentProps<"div">) { |
| 136 | return ( |
| 137 | <div |
| 138 | className={cn( |
| 139 | "flex min-w-0 flex-1 flex-col empty:hidden", |
| 140 | "[&>[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0", |
| 141 | className, |
| 142 | )} |
| 143 | {...props} |
| 144 | /> |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | function Root({ |
| 149 | className, |
| 150 | density = "default", |
| 151 | placement = "attached", |
| 152 | variant = "default", |
| 153 | width = "fill", |
| 154 | ...props |
| 155 | }: ComponentProps<"div"> & { |
| 156 | density?: "default" | "comfortable" | "spacious"; |
| 157 | placement?: "attached" | "floating"; |
| 158 | variant?: ComposerBannerVariant; |
| 159 | width?: "fill" | "content"; |
| 160 | }) { |
| 161 | return ( |
| 162 | <Surface |
| 163 | className={cn( |
| 164 | "min-w-0 px-1 py-(--composer-banner-padding-block) after:block after:h-(--chat-composer-attachment-overlap) text-xs/4 [--composer-banner-icon-column:--spacing(7)] [--composer-banner-padding-block:--spacing(1)] sm:[--composer-banner-icon-column:--spacing(6)]", |
| 165 | density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]", |
| 166 | density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]", |
| 167 | width === "content" ? "w-fit max-w-full flex-none" : "@container", |
| 168 | // The first row's last action sits in the banner's corner: its outer corner follows the |
| 169 | // banner's curve (2xl minus the 1 spacing inset), and floating banners curve below too. |
| 170 | // Actions may also arrive grouped in one wrapper element (usage-limit recovery). |
| 171 | "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-xl", |
| 172 | "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-se-xl", |
| 173 | "[&>:first-child>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-xl", |
| 174 | placement === "floating" && [ |
| 175 | "[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-ee-xl", |
| 176 | "[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-ee-xl", |
| 177 | ], |
| 178 | className, |
| 179 | )} |
| 180 | data-slot="composer-banner" |
| 181 | placement={placement} |
| 182 | data-composer-banner-width={width} |
| 183 | variant={variant} |
| 184 | {...props} |
| 185 | /> |
| 186 | ); |
| 187 | } |
| 188 | |
| 189 | /** The same row can be a status, a list item, or an entire disclosure button. */ |
| 190 | function Row({ |
| 191 | className, |
| 192 | render, |
| 193 | layout = "inline", |
| 194 | ...props |
| 195 | }: useRender.ComponentProps<"div"> & { |
| 196 | layout?: "inline" | "wrap-actions" | "approval"; |
| 197 | }) { |
| 198 | const rowProps = { |
| 199 | className: cn( |
| 200 | "group/banner-row grid min-h-(--composer-banner-icon-column) w-full min-w-0 grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)_auto] items-center gap-x-1 text-start", |
| 201 | "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]", |
| 202 | "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-md [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring", |
| 203 | // Actions share the title row while the title fits and move below it otherwise. |
| 204 | // The content's max width keeps it beside the icon, so a long title truncates instead of wrapping. |
| 205 | layout === "wrap-actions" && |
| 206 | "flex flex-wrap *:data-[slot=composer-banner-content]:max-w-[calc(100%-var(--composer-banner-icon-column)-var(--spacing))] *:data-[slot=composer-banner-content]:grow", |
| 207 | layout === "approval" && "items-start gap-x-2 gap-y-3", |
| 208 | className, |
| 209 | ), |
| 210 | "data-composer-banner-row": "true", |
| 211 | "data-composer-banner-layout": layout, |
| 212 | }; |
| 213 | return useRender({ |
| 214 | defaultTagName: "div", |
| 215 | render, |
| 216 | props: mergeProps<"div">(rowProps, props), |
| 217 | }); |
| 218 | } |
| 219 | |
| 220 | function Icon({ className, ...props }: ComponentProps<"span">) { |
| 221 | return ( |
| 222 | <span |
| 223 | aria-hidden |
| 224 | data-slot="composer-banner-icon" |
| 225 | className={cn( |
| 226 | "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3", |
| 227 | "group-data-[composer-banner-layout=approval]/banner-row:pt-0.5 group-data-[composer-banner-layout=approval]/banner-row:text-warning group-data-[composer-banner-layout=approval]/banner-row:[&>svg]:size-4", |
| 228 | className, |
| 229 | )} |
| 230 | {...props} |
| 231 | /> |
| 232 | ); |
| 233 | } |
| 234 | |
| 235 | function Content({ className, ...props }: ComponentProps<"span">) { |
| 236 | return ( |
| 237 | <span |
| 238 | data-slot="composer-banner-content" |
| 239 | className={cn( |
| 240 | "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0", |
| 241 | "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4", |
| 242 | "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5", |
| 243 | "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5", |
| 244 | className, |
| 245 | )} |
| 246 | {...props} |
| 247 | /> |
| 248 | ); |
| 249 | } |
| 250 | |
| 251 | function Separator() { |
| 252 | return ( |
| 253 | <span |
| 254 | aria-hidden |
| 255 | data-slot="composer-banner-separator" |
| 256 | className="mx-1 inline-block flex-none text-muted-foreground/40" |
| 257 | > |
| 258 | · |
| 259 | </span> |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | function Actions({ className, ...props }: ComponentProps<"span">) { |
| 264 | return ( |
| 265 | <span |
| 266 | data-slot="composer-banner-actions" |
| 267 | className={cn( |
| 268 | "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1", |
| 269 | "group-data-[composer-banner-layout=approval]/banner-row:self-center group-data-[composer-banner-layout=approval]/banner-row:gap-1.5 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-start-2 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4 @max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:row-start-2", |
| 270 | "group-data-[composer-banner-layout=wrap-actions]/banner-row:ms-auto", |
| 271 | className, |
| 272 | )} |
| 273 | {...props} |
| 274 | /> |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | /** Child rows keep their parent's columns and begin immediately after its header. */ |
| 279 | function Children({ className, render, ...props }: useRender.ComponentProps<"div">) { |
| 280 | return useRender({ |
| 281 | defaultTagName: "div", |
| 282 | render, |
| 283 | props: mergeProps<"div">( |
| 284 | { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) }, |
| 285 | props, |
| 286 | ), |
| 287 | }); |
| 288 | } |
| 289 | |
| 290 | /** Bounded banner content uses the app's scroll area and fades only overflowing edges. */ |
| 291 | function Scroll({ className, children, ...props }: ComponentProps<typeof ScrollArea>) { |
| 292 | return ( |
| 293 | <ScrollArea |
| 294 | radius="none" |
| 295 | scrollFade |
| 296 | className={cn("h-auto max-h-[min(24rem,40dvh)]", className)} |
| 297 | {...props} |
| 298 | > |
| 299 | {/* Clears the overlay scrollbar only once there is something to scroll. */} |
| 300 | <div className="[[data-has-overflow-y]>&]:pe-2">{children}</div> |
| 301 | </ScrollArea> |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | function Count({ className, ...props }: ComponentProps<"span">) { |
| 306 | return ( |
| 307 | <span |
| 308 | className={cn( |
| 309 | "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums", |
| 310 | className, |
| 311 | )} |
| 312 | {...props} |
| 313 | /> |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | function Body({ className, ...props }: ComponentProps<"div">) { |
| 318 | return <div className={cn("min-w-0 ps-8 sm:ps-7", className)} {...props} />; |
| 319 | } |
| 320 | |
| 321 | function Dot({ className, ...props }: ComponentProps<"span">) { |
| 322 | return ( |
| 323 | <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} /> |
| 324 | ); |
| 325 | } |
| 326 | |
| 327 | // Decorative: the row itself is the control, so this only matches Dismiss's box. |
| 328 | function ToggleIcon({ expanded }: { expanded: boolean }) { |
| 329 | return ( |
| 330 | <Button |
| 331 | render={<span aria-hidden />} |
| 332 | size="icon-xs" |
| 333 | variant="ghost" |
| 334 | tabIndex={-1} |
| 335 | className="pointer-events-none" |
| 336 | > |
| 337 | <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} /> |
| 338 | </Button> |
| 339 | ); |
| 340 | } |
| 341 | |
| 342 | function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) { |
| 343 | return ( |
| 344 | <Button size="icon-xs" variant="ghost" className={className} {...props}> |
| 345 | {children ?? <XIcon className="size-3.5" />} |
| 346 | </Button> |
| 347 | ); |
| 348 | } |
| 349 | |
| 350 | export const ComposerBanner = { |
| 351 | Surface, |
| 352 | Peek, |
| 353 | Attachment, |
| 354 | Dock, |
| 355 | Column, |
| 356 | Root, |
| 357 | Row, |
| 358 | Icon, |
| 359 | Content, |
| 360 | Separator, |
| 361 | Actions, |
| 362 | Children, |
| 363 | Scroll, |
| 364 | Count, |
| 365 | Body, |
| 366 | Dot, |
| 367 | ToggleIcon, |
| 368 | Dismiss, |
| 369 | }; |