1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { ChevronDownIcon, XIcon } from "lucide-react";
4import type { ComponentProps } from "react";
5
6import { cn } from "~/lib/utils";
7import { Button } from "../ui/button";
8import { ScrollArea } from "../ui/scroll-area";
9
10export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning";
11
12const 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
18const 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
26const 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/**
37 * An attached surface tucks its bottom under the card below it. A detached one is a whole card
38 * of its own, for a notice that sits apart from the composer.
39 */
40function Surface({
41 variant = "default",
42 detached = false,
43 className,
44 ...props
45}: ComponentProps<"div"> & {
46 variant?: ComposerBannerVariant;
47 detached?: boolean;
48}) {
49 return (
50 <div
51 data-composer-banner-surface={detached ? "detached" : "attached"}
52 data-variant={variant}
53 className={cn(
54 surfaceColors,
55 "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]",
56 variantColors[variant],
57 detached
58 ? "[--chat-composer-attachment-overlap:0px] before:rounded-2xl"
59 : // Bleed the mask 1px past the seam to cover Chromium's fractional-pixel backdrop loss.
60 "[--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",
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 !detached &&
65 "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-composer-seam-above",
66 "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:before:bg-(--chat-composer-attached-surface)",
67 className,
68 )}
69 {...props}
70 />
71 );
72}
73
74function Attachment({ className, ...props }: ComponentProps<"div">) {
75 return (
76 <div
77 data-slot="composer-banner-attachment"
78 className={cn(
79 "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]",
80 "[&+[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 [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:[--composer-banner-corner-top:var(--control-radius)]",
81 "[&+*_[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 [&+*_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:[--composer-banner-corner-top:var(--control-radius)]",
82 className,
83 )}
84 {...props}
85 />
86 );
87}
88
89function Dock({ className, ...props }: ComponentProps<"div">) {
90 return (
91 <Attachment
92 className={cn(
93 "flex items-end gap-1 not-has-data-[composer-banner-surface=attached]:hidden",
94 className,
95 )}
96 {...props}
97 />
98 );
99}
100
101function Column({ className, ...props }: ComponentProps<"div">) {
102 return (
103 <div
104 className={cn(
105 "flex min-w-0 flex-1 flex-col empty:hidden",
106 "[&>[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0",
107 className,
108 )}
109 {...props}
110 />
111 );
112}
113
114function Root({
115 className,
116 density = "default",
117 variant = "default",
118 width = "fill",
119 ...props
120}: ComponentProps<typeof Surface> & {
121 density?: "default" | "comfortable" | "spacious";
122 width?: "fill" | "content";
123}) {
124 return (
125 <Surface
126 className={cn(
127 "min-w-0 px-1 py-(--composer-banner-padding-block) after:block after:h-(--chat-composer-attachment-overlap) text-xs/4 [--composer-banner-corner-top:var(--radius-xl)] [--composer-banner-icon-column:--spacing(7)] [--composer-banner-padding-block:--spacing(1)] sm:[--composer-banner-icon-column:--spacing(6)]",
128 density === "comfortable" && "[--composer-banner-padding-block:--spacing(1.25)]",
129 density === "spacious" && "px-3 [--composer-banner-padding-block:--spacing(3)]",
130 width === "content" ? "w-fit max-w-full flex-none" : "@container",
131 // A corner button nests inside the banner's radius only when the padding
132 // is tight; spacious padding already leaves the default radius concentric.
133 // Joined cards have a square top, and only detached cards round the bottom.
134 density !== "spacious" && [
135 "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-(--composer-banner-corner-top)",
136 "[&>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-se-(--composer-banner-corner-top)",
137 "[&>:first-child>[data-composer-banner-row]:first-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-se-(--composer-banner-corner-top)",
138 "data-[composer-banner-surface=detached]:[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>[data-slot=button]:last-child]:rounded-ee-xl",
139 "data-[composer-banner-surface=detached]:[&>[data-composer-banner-row]:only-child>[data-slot=composer-banner-actions]>:last-child>[data-slot=button]:last-of-type]:rounded-ee-xl",
140 ],
141 className,
142 )}
143 data-slot="composer-banner"
144 data-composer-banner-width={width}
145 variant={variant}
146 {...props}
147 />
148 );
149}
150
151function Row({
152 className,
153 render,
154 layout = "inline",
155 ...props
156}: useRender.ComponentProps<"div"> & {
157 layout?: "inline" | "wrap-actions" | "approval";
158}) {
159 const rowProps = {
160 className: cn(
161 "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",
162 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
163 "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-xl [&:is(button)]:hover:bg-foreground/5 [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",
164 layout === "wrap-actions" &&
165 "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",
166 layout === "approval" && "items-start gap-x-2 gap-y-3",
167 className,
168 ),
169 "data-composer-banner-row": "true",
170 "data-composer-banner-layout": layout,
171 };
172 return useRender({
173 defaultTagName: "div",
174 render,
175 props: mergeProps<"div">(rowProps, props),
176 });
177}
178
179function Icon({ className, ...props }: ComponentProps<"span">) {
180 return (
181 <span
182 aria-hidden
183 data-slot="composer-banner-icon"
184 className={cn(
185 "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",
186 "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",
187 className,
188 )}
189 {...props}
190 />
191 );
192}
193
194function Content({ className, ...props }: ComponentProps<"span">) {
195 return (
196 <span
197 data-slot="composer-banner-content"
198 className={cn(
199 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
200 "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4",
201 "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",
202 "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",
203 className,
204 )}
205 {...props}
206 />
207 );
208}
209
210function Separator() {
211 return (
212 <span
213 aria-hidden
214 data-slot="composer-banner-separator"
215 className="mx-1 inline-block flex-none text-muted-foreground/40"
216 >
217 ·
218 </span>
219 );
220}
221
222function Actions({ className, ...props }: ComponentProps<"span">) {
223 return (
224 <span
225 data-slot="composer-banner-actions"
226 className={cn(
227 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
228 "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",
229 "group-data-[composer-banner-layout=wrap-actions]/banner-row:ms-auto",
230 className,
231 )}
232 {...props}
233 />
234 );
235}
236
237function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
238 return useRender({
239 defaultTagName: "div",
240 render,
241 props: mergeProps<"div">(
242 { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
243 props,
244 ),
245 });
246}
247
248function Scroll({ className, children, ...props }: ComponentProps<typeof ScrollArea>) {
249 return (
250 <ScrollArea
251 radius="none"
252 scrollFade
253 className={cn("h-auto max-h-[min(24rem,40dvh)]", className)}
254 {...props}
255 >
256 {}
257 <div className="[[data-has-overflow-y]>&]:pe-2">{children}</div>
258 </ScrollArea>
259 );
260}
261
262function Count({ className, ...props }: ComponentProps<"span">) {
263 return (
264 <span
265 className={cn(
266 "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",
267 className,
268 )}
269 {...props}
270 />
271 );
272}
273
274function Body({ className, ...props }: ComponentProps<"div">) {
275 return <div className={cn("min-w-0 ps-8 sm:ps-7", className)} {...props} />;
276}
277
278function Dot({ className, ...props }: ComponentProps<"span">) {
279 return (
280 <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />
281 );
282}
283
284function ToggleIcon({ expanded }: { expanded: boolean }) {
285 return (
286 <Button
287 render={<span aria-hidden />}
288 size="icon-xs"
289 variant="ghost"
290 tabIndex={-1}
291 className="pointer-events-none"
292 >
293 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
294 </Button>
295 );
296}
297
298function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {
299 return (
300 <Button size="icon-xs" variant="ghost" className={className} {...props}>
301 {children ?? <XIcon className="size-3.5" />}
302 </Button>
303 );
304}
305
306export const ComposerBanner = {
307 Surface,
308 Attachment,
309 Dock,
310 Column,
311 Root,
312 Row,
313 Icon,
314 Content,
315 Separator,
316 Actions,
317 Children,
318 Scroll,
319 Count,
320 Body,
321 Dot,
322 ToggleIcon,
323 Dismiss,
324};