Skip to content
Source
Sign inSign up

ComposerBanner.tsx

324 lines13.1 KB

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 );
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 );
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 );
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 );
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 );
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 });
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 );
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 );
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 );
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 );
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 });
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 );
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 );
273​
274function Body({ className, ...props }: ComponentProps<"div">) {
275 return <div className={cn("min-w-0 ps-8 sm:ps-7", className)} {...props} />;
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 );
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 );
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 );
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};