352 lines · 13.2 KB · TypeScript
RawDownload
import { mergeProps } from "@base-ui/react/merge-props";import { useRender } from "@base-ui/react/use-render";import { ChevronDownIcon, XIcon } from "lucide-react";import type { ComponentProps } from "react"; import { cn } from "~/lib/utils";import { Button, buttonVariants } from "../ui/button";import { ScrollArea } from "../ui/scroll-area"; export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning"; const surfaceColors = cn(  "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]",  "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]",  "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]",); const neutralOutline = cn(  "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]",  "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]",  "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]",  "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)))]",  "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]",); const variantColors: Record<ComposerBannerVariant, string> = {  default: neutralOutline,  error:    "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]",  info: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--info)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--info)_4%,transparent)]",  success:    "[--chat-composer-attached-outline:color-mix(in_srgb,var(--success)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--success)_4%,transparent)]",  warning:    "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]",}; /** Shared glass and attachment seam, also used by the command menu without banner row padding. */function Surface({  placement = "attached",  variant = "default",  className,  ...props}: ComponentProps<"div"> & {  placement?: "attached" | "floating";  variant?: ComposerBannerVariant;}) {  return (    <div      data-composer-banner-surface={placement}      data-variant={variant}      className={cn(        surfaceColors,        "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]",        variantColors[variant],        placement === "attached"          ? "[--chat-composer-attachment-overlap:calc(1rem+1px)] before:rounded-t-[16px]"          : "[--chat-composer-attachment-overlap:0px] before:rounded-[1rem]",        "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)",        "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",        "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",        "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]",        "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)",        className,      )}      {...props}    />  );} // A peeking notice uses the first hidden notice's severity, never the attached row's.const peekBorder: Record<ComposerBannerVariant, string> = {  default: "border-(--chat-composer-attached-outline)",  error: "border-destructive/24",  info: "border-info/24",  success: "border-success/24",  warning: "border-warning/24",}; function Peek({  className,  variant = "default",  ...props}: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) {  return (    <button      type="button"      data-slot="composer-banner-peek"      className={cn(        surfaceColors,        neutralOutline,        "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]",        "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)",        "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)",        "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring",        peekBorder[variant],        className,      )}      {...props}    />  );} function Attachment({ className, ...props }: ComponentProps<"div">) {  return (    <div      data-slot="composer-banner-attachment"      className={cn(        "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]",        // Adjacent attachments share their outline, including notices outside the form.        "[&+[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",        "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0",        className,      )}      {...props}    />  );} function Dock({ className, ...props }: ComponentProps<"div">) {  return (    <Attachment      className={cn(        "flex items-end gap-1 not-has-data-[composer-banner-surface=attached]:hidden",        className,      )}      {...props}    />  );} /** Attachments share a column while neighboring tabs keep their own surface. */function Column({ className, ...props }: ComponentProps<"div">) {  return (    <div      className={cn(        "flex min-w-0 flex-1 flex-col empty:hidden",        "[&>[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0",        className,      )}      {...props}    />  );} function Root({  className,  placement = "attached",  variant = "default",  width = "fill",  ...props}: ComponentProps<"div"> & {  placement?: "attached" | "floating";  variant?: ComposerBannerVariant;  width?: "fill" | "content";}) {  return (    <Surface      className={cn(        "min-w-0 p-1 pb-[calc(var(--chat-composer-attachment-overlap)+(--spacing(1)))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] sm:[--composer-banner-icon-column:--spacing(6)]",        width === "content" ? "w-fit max-w-full flex-none" : "@container",        className,      )}      data-slot="composer-banner"      placement={placement}      data-composer-banner-width={width}      variant={variant}      {...props}    />  );} /** The same row can be a status, a list item, or an entire disclosure button. */function Row({  className,  render,  layout = "inline",  ...props}: useRender.ComponentProps<"div"> & {  layout?: "inline" | "wrap-actions";}) {  const rowProps = {    className: cn(      "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",      "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",      "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",      layout === "wrap-actions" &&        "@max-[400px]:flex @max-[400px]:flex-wrap @max-[400px]:gap-y-1 @max-[400px]:*:data-[slot=composer-banner-actions]:ms-auto @max-[400px]:*:data-[slot=composer-banner-actions]:max-w-full @max-[400px]:has-[>[data-slot=composer-banner-icon]]:*:data-[slot=composer-banner-actions]:max-w-[calc(100%-var(--composer-banner-icon-column)-(--spacing(1)))] @max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column) @max-[400px]:*:data-[slot=composer-banner-content]:flex-[1_1_10rem]",      className,    ),    "data-composer-banner-row": "true",    "data-composer-banner-layout": layout,  };  return useRender({    defaultTagName: "div",    render,    props: mergeProps<"div">(rowProps, props),  });} function Icon({ className, ...props }: ComponentProps<"span">) {  return (    <span      aria-hidden      data-slot="composer-banner-icon"      className={cn(        "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",        className,      )}      {...props}    />  );} function Content({ className, ...props }: ComponentProps<"span">) {  return (    <span      data-slot="composer-banner-content"      className={cn(        "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",        "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",        "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",        className,      )}      {...props}    />  );} function Separator() {  return (    <span      aria-hidden      data-slot="composer-banner-separator"      className="mx-1 inline-block flex-none text-muted-foreground/40"    >      ·    </span>  );} function Actions({ className, ...props }: ComponentProps<"span">) {  return (    <span      data-slot="composer-banner-actions"      className={cn(        "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",        className,      )}      {...props}    />  );} /** Child rows keep their parent's columns and begin immediately after its header. */function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {  return useRender({    defaultTagName: "div",    render,    props: mergeProps<"div">(      { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },      props,    ),  });} /** Bounded banner content uses the app's scroll area and fades only overflowing edges. */function Scroll({ className, ...props }: ComponentProps<typeof ScrollArea>) {  return (    <ScrollArea      scrollFade      className={cn(        "h-auto max-h-[min(24rem,40dvh)] rounded-none [&>[data-slot=scroll-area-viewport][data-has-overflow-y]]:pe-2",        className,      )}      {...props}    />  );} function Count({ className, ...props }: ComponentProps<"span">) {  return (    <span      className={cn(        "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",        className,      )}      {...props}    />  );} function Body({ className, ...props }: ComponentProps<"div">) {  return (    <div      className={cn(        "min-w-0 ps-[calc(var(--composer-banner-icon-column)+(--spacing(1)))]",        className,      )}      {...props}    />  );} function Dot({ className, ...props }: ComponentProps<"span">) {  return (    <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />  );} function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {  return (    <span      aria-hidden      className={cn(        buttonVariants({ size: "icon-xs", variant: "ghost" }),        "pointer-events-none",        className,      )}    >      <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />    </span>  );} function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {  return (    <Button size="icon-xs" variant="ghost" className={className} {...props}>      {children ?? <XIcon className="size-3.5" />}    </Button>  );} export const ComposerBanner = {  Surface,  Peek,  Attachment,  Dock,  Column,  Root,  Row,  Icon,  Content,  Separator,  Actions,  Children,  Scroll,  Count,  Body,  Dot,  ToggleIcon,  Dismiss,};