shadimage
nexus-ui
uiattachmentssource: ok · hit

Attachments

Composable file attachments for chat inputs and messages with preview, variants, and upload wiring

npx shadcn add "https://nexus-ui.dev/r/attachments.json"
free

Where it goes

testimonialchat

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@radix-ui/react-slot

Files2 · source cached 10/8/2026, 3:40:30 PM

components/nexus-ui/attachments.tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import {
  Cancel01Icon,
  Image02Icon,
  File02Icon,
  Video02Icon,
  MusicNote02Icon,
  Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useOnChange } from "@/lib/use-on-change";

import { cn } from "@/lib/utils";

// ——— Metadata schema (single source) ———

/** Upload or message attachment metadata. */
export interface AttachmentMeta {
  type: "image" | "file" | "video" | "audio";
  name?: string;
  url?: string;
  /** Raster preview URL (e.g. PDF first page). When unset, preview uses the icon for `type`. */
  thumbnailUrl?: string;
  mimeType?: string;
  size?: number;
  width?: number;
  height?: number;
  /** Binary payload when not using `url` (browser-friendly; prefer `Blob`). */
  data?: Blob | ArrayBuffer;
  /**
   * **`"paste"`** when created from clipboard (e.g. long text → **`File`**). Use with **`Attachment`** **`variant="pasted"`**.
   */
  source?: "paste";
}

/** Files not appended by the picker: oversized, over `maxFiles`, extra when `multiple` is false, or outside `accept`. */
export type AttachmentsRejectedFiles = {
  tooLarge: File[];
  /** Within size but did not fit under `maxFiles`. */
  overMaxFiles: File[];
  /** Ignored because `multiple` is false. */
  truncatedByMultiple: File[];
  /** Did not match the root `accept` string (drop path or permissive pickers). */
  notAccepted: File[];
};

export function toAttachmentMeta(
  file: File,
  options?: { objectUrl?: string; source?: AttachmentMeta["source"] },
): AttachmentMeta {
  const mime = file.type?.toLowerCase() ?? "";
  let kind: AttachmentMeta["type"] = "file";
  if (mime.startsWith("image/")) kind = "image";
  else if (mime.startsWith("video/")) kind = "video";
  else if (mime.startsWith("audio/")) kind = "audio";

  return {
    type: kind,
    name: file.name,
    url: options?.objectUrl,
    mimeType: file.type || undefined,
    size: file.size,
    ...(options?.source != null ? { source: options.source } : {}),
  };
}

/** Optional second argument to **`appendFiles`** (from **`useAttachments`**). */
export type AppendFilesOptions = {
  /** Sets **`AttachmentMeta.source`** to **`"paste"`** for every appended item. */
  paste?: boolean;
};

/**
 * `File`s from a `DataTransfer` (paste **`clipboardData`** or drop **`dataTransfer`**).
 * Prefer **`items`** so pasted screenshots and copied images resolve reliably; falls back to **`files`**.
 */
export function filesFromDataTransfer(data: DataTransfer | null): File[] {
  if (!data) return [];
  const out: File[] = [];
  if (data.items?.length) {
    for (const item of data.items) {
      if (item.kind !== "file") continue;
      const f = item.getAsFile();
      if (f) out.push(f);
    }
  }
  if (out.length > 0) return out;
  if (data.files?.length) return Array.from(data.files);
  return [];
}

/** Best-effort match for an HTML `accept` attribute (comma tokens: `.pdf`, `image/*`, exact MIME). */
function fileMatchesAccept(file: File, accept: string): boolean {
  const trimmed = accept.trim();
  if (!trimmed || trimmed === "*/*") return true;
  const tokens = trimmed
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);
  const type = (file.type ?? "").toLowerCase();
  const name = file.name ?? "";
  const extWithDot =
    name.lastIndexOf(".") > 0
      ? name.slice(name.lastIndexOf(".")).toLowerCase()
      : "";

  for (const token of tokens) {
    const t = token.toLowerCase();
    if (t === "*/*") return true;
    if (t.startsWith(".")) {
      if (extWithDot === t) return true;
      continue;
    }
    if (t.endsWith("/*")) {
      const prefix = t.slice(0, -1);
      if (type.startsWith(prefix)) return true;
      continue;
    }
    if (type && type === t) return true;
  }
  return false;
}

function formatBytes(bytes?: number): string | undefined {
  if (bytes == null || !Number.isFinite(bytes)) return undefined;
  const units = ["B", "KB", "MB", "GB"] as const;
  let v = bytes;
  let i = 0;
  while (v >= 1024 && i < units.length - 1) {
    v /= 1024;
    i += 1;
  }
  const rounded = i === 0 ? Math.round(v) : Math.round(v * 10) / 10;
  return `${rounded} ${units[i]}`;
}

/** Uppercased file extension for the detailed subtitle (no leading dot); `undefined` if there is no usable extension. */
function kindLabel(item: AttachmentMeta): string | undefined {
  const name = item.name?.trim();
  if (!name) return undefined;
  const dot = name.lastIndexOf(".");
  if (dot <= 0 || dot >= name.length - 1) return undefined;
  const ext = name.slice(dot + 1).toLowerCase();
  if (!ext || ext.length > 16) return undefined;
  return ext.toUpperCase();
}

function iconForAttachmentType(type: AttachmentMeta["type"]) {
  switch (type) {
    case "image":
      return Image02Icon;
    case "video":
      return Video02Icon;
    case "audio":
      return MusicNote02Icon;
    default:
      return File02Icon;
  }
}

function inferDetailedSubtitleMode(
  attachment: AttachmentMeta,
): "size" | "kind" {
  if (
    attachment.size != null &&
    Number.isFinite(attachment.size) &&
    attachment.size > 0
  ) {
    return "size";
  }
  return "kind";
}

const attachmentVariants = cva(
  "group relative cursor-default overflow-hidden rounded-sm border border-muted bg-secondary text-muted-foreground",
  {
    variants: {
      variant: {
        compact: "relative flex size-15 shrink-0 items-center justify-center",
        inline:
          "relative flex h-8 w-auto min-w-0 max-w-[200px] shrink-0 items-center justify-start p-1 pr-2",
        detailed:
          "relative flex h-15 w-auto min-w-[200px] max-w-[250px] shrink-0 items-center justify-start p-2 pr-3",
        pasted:
          "relative flex w-[156px] h-[144px] shrink-0 flex-col items-center justify-center overflow-hidden rounded-sm p-2 gap-2",
      },
    },
    defaultVariants: {
      variant: "compact",
    },
  },
);

type AttachmentVariant = NonNullable<
  VariantProps<typeof attachmentVariants>["variant"]
>;

// ——— Context ———

/** Public context value from **`useAttachments()`** (also used internally by **`Attachments`**). */
export type AttachmentsContextValue = {
  inputRef: React.RefObject<HTMLInputElement | null>;
  /** Stable id on the hidden file input (labels, tests, or custom `aria-*`). */
  inputId: string;
  openPicker: () => void;
  /**
   * Append files with the same limits and `onFilesRejected` behavior as the native picker.
   * For drag-and-drop or paste, call this from your handler.
   */
  appendFiles: (files: File[], options?: AppendFilesOptions) => void;
  /** True while a file drag is active over the document (when `windowDrop` is enabled). */
  isDraggingFile: boolean;
  attachments: AttachmentMeta[];
  onAttachmentsChange: (next: AttachmentMeta[]) => void;
  accept?: string;
  multiple: boolean;
  maxFiles?: number;
  maxSize?: number;
  disabled: boolean;
};

const AttachmentsContext = React.createContext<AttachmentsContextValue | null>(
  null,
);

function useAttachmentsContext(component: string) {
  const ctx = React.useContext(AttachmentsContext);
  if (!ctx) {
    throw new Error(`${component} must be used within <Attachments>`);
  }
  return ctx;
}

type AttachmentItemContextValue = {
  variant: AttachmentVariant;
  attachment: AttachmentMeta;
  onRemove?: () => void;
  /** When true, remove controls are hidden (see `Attachment` `readOnly`). */
  readOnly?: boolean;
};

const AttachmentItemContext =
  React.createContext<AttachmentItemContextValue | null>(null);

function useAttachmentItemContext(component: string) {
  const ctx = React.useContext(AttachmentItemContext);
  if (!ctx) {
    throw new Error(`${component} must be used within <Attachment>`);
  }
  return ctx;
}

export type AttachmentsProps = {
  attachments: AttachmentMeta[];
  onAttachmentsChange: (attachments: AttachmentMeta[]) => void;
  accept?: string;
  /** @default true */
  multiple?: boolean;
  maxFiles?: number;
  /** Maximum file size per file in bytes */
  maxSize?: number;
  disabled?: boolean;
  /** Fires after the internal file handler (same event; `target.files` still available). */
  onFileInputChange?: React.ChangeEventHandler<HTMLInputElement>;
  /**
   * Called when some selected files are not appended (oversized, over `maxFiles`, or trimmed because `multiple` is false).
   */
  onFilesRejected?: (detail: AttachmentsRejectedFiles) => void;
  /**
   * Register `dragover` / `drop` on `document` so files can be dropped anywhere.
   * Pair with **`AttachmentsDropOverlay`** or **`useAttachments().appendFiles`** if you build a custom drop target.
   * @default false
   */
  windowDrop?: boolean;
  children?: React.ReactNode;
};

function Attachments({
  attachments,
  onAttachmentsChange,
  accept,
  multiple = true,
  maxFiles,
  maxSize,
  disabled = false,
  onFileInputChange,
  onFilesRejected,
  windowDrop = false,
  children,
}: AttachmentsProps) {
  const inputRef = React.useRef<HTMLInputElement | null>(null);
  const inputId = React.useId();
  const managedBlobUrlsRef = React.useRef<Set<string>>(new Set());
  const [isDraggingFile, setIsDraggingFile] = React.useState(false);

  React.useLayoutEffect(() => {
    const inUse = new Set<string>();
    for (const a of attachments) {
      if (a.url) inUse.add(a.url);
      if (a.thumbnailUrl) inUse.add(a.thumbnailUrl);
    }
    for (const url of [...managedBlobUrlsRef.current]) {
      if (!inUse.has(url)) {
        URL.revokeObjectURL(url);
        managedBlobUrlsRef.current.delete(url);
      }
    }
  }, [attachments]);

  React.useEffect(() => {
    const managedBlobUrls = managedBlobUrlsRef.current;
    return () => {
      for (const url of managedBlobUrls) {
        URL.revokeObjectURL(url);
      }
      managedBlobUrls.clear();
    };
  }, []);

  const openPicker = React.useCallback(() => {
    if (disabled) return;
    inputRef.current?.click();
  }, [disabled]);

  const appendFilesFromList = React.useCallback(
    (rawFiles: File[], appendOptions?: AppendFilesOptions) => {
      if (disabled || rawFiles.length === 0) return;

      let incoming = [...rawFiles];
      const notAccepted =
        accept != null && accept !== "" && accept !== "*/*"
          ? incoming.filter((f) => !fileMatchesAccept(f, accept))
          : [];
      if (accept != null && accept !== "" && accept !== "*/*") {
        incoming = incoming.filter((f) => fileMatchesAccept(f, accept));
      }

      let truncatedByMultiple: File[] = [];
      if (!multiple && incoming.length > 1) {
        truncatedByMultiple = incoming.slice(1);
        incoming = incoming.slice(0, 1);
      }

      const tooLarge =
        maxSize != null ? incoming.filter((f) => f.size > maxSize) : [];

      const withinSize =
        maxSize != null ? incoming.filter((f) => f.size <= maxSize) : incoming;

      const room =
        maxFiles != null
          ? Math.max(0, maxFiles - attachments.length)
          : Number.POSITIVE_INFINITY;

      const take =
        room === Number.POSITIVE_INFINITY
          ? withinSize
          : withinSize.slice(0, room);

      const overMaxFiles =
        room === Number.POSITIVE_INFINITY ? [] : withinSize.slice(room);

      if (
        notAccepted.length > 0 ||
        tooLarge.length > 0 ||
        overMaxFiles.length > 0 ||
        truncatedByMultiple.length > 0
      ) {
        onFilesRejected?.({
          notAccepted,
          tooLarge,
          overMaxFiles,
          truncatedByMultiple,
        });
      }

      const newMetas = take.map((file) => {
        const objectUrl = URL.createObjectURL(file);
        managedBlobUrlsRef.current.add(objectUrl);
        return toAttachmentMeta(file, {
          objectUrl,
          source: appendOptions?.paste ? "paste" : undefined,
        });
      });

      if (newMetas.length > 0) {
        onAttachmentsChange([...attachments, ...newMetas]);
      }
    },
    [
      disabled,
      accept,
      multiple,
      maxSize,
      maxFiles,
      attachments,
      onAttachmentsChange,
      onFilesRejected,
    ],
  );

  const handleInputChange = React.useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const list = e.target.files;
      if (!list?.length || disabled) {
        onFileInputChange?.(e);
        e.target.value = "";
        return;
      }

      appendFilesFromList(Array.from(list));
      onFileInputChange?.(e);
      e.target.value = "";
    },
    [disabled, appendFilesFromList, onFileInputChange],
  );

  const dragAndDropEnabled = !disabled && windowDrop;

  useOnChange(dragAndDropEnabled, (enabled) => {
    if (!enabled) setIsDraggingFile(false);
  });

  React.useEffect(() => {
    if (!dragAndDropEnabled) return;

    const hasFiles = (e: DragEvent) =>
      Boolean(
        e.dataTransfer?.types?.length &&
        [...e.dataTransfer.types].includes("Files"),
      );

    const onDragEnter = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      setIsDraggingFile(true);
    };

    const onDragLeave = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      const next = e.relatedTarget as Node | null;
      if (next && document.contains(next)) return;
      setIsDraggingFile(false);
    };

    const onDragOver = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      e.preventDefault();
      e.dataTransfer!.dropEffect = "copy";
    };

    const onDrop = (e: DragEvent) => {
      e.preventDefault();
      setIsDraggingFile(false);
      const list = e.dataTransfer?.files;
      if (!list?.length) return;
      appendFilesFromList(Array.from(list));
    };

    document.addEventListener("dragenter", onDragEnter);
    document.addEventListener("dragleave", onDragLeave);
    document.addEventListener("dragover", onDragOver);
    document.addEventListener("drop", onDrop);
    return () => {
      document.removeEventListener("dragenter", onDragEnter);
      document.removeEventListener("dragleave", onDragLeave);
      document.removeEventListener("dragover", onDragOver);
      document.removeEventListener("drop", onDrop);
    };
  }, [dragAndDropEnabled, appendFilesFromList]);

  const value = React.useMemo<AttachmentsContextValue>(
    () => ({
      inputRef,
      inputId,
      openPicker,
      appendFiles: appendFilesFromList,
      isDraggingFile,
      attachments,
      onAttachmentsChange,
      accept,
      multiple,
      maxFiles,
      maxSize,
      disabled,
    }),
    [
      inputId,
      appendFilesFromList,
      isDraggingFile,
      attachments,
      onAttachmentsChange,
      accept,
      multiple,
      maxFiles,
      maxSize,
      disabled,
      openPicker,
    ],
  );

  return (
    <AttachmentsContext.Provider value={value}>
      <input
        ref={inputRef}
        id={inputId}
        type="file"
        data-slot="attachments-input"
        className="sr-only"
        aria-hidden
        tabIndex={-1}
        accept={accept}
        multiple={multiple}
        disabled={disabled}
        onChange={handleInputChange}
      />
      {children}
    </AttachmentsContext.Provider>
  );
}

/** Access **`Attachments`** context: **`appendFiles`**, **`isDraggingFile`**, **`openPicker`**, controlled state, and limits. Must be used under **`Attachments`**. */
export function useAttachments(): AttachmentsContextValue {
  return useAttachmentsContext("useAttachments");
}

export type AttachmentsDropOverlayProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> & {
  /**
   * `fullscreen` portals to `document.body` and covers the viewport.
   * `contained` fills the nearest positioned ancestor — wrap a **`relative`** container (e.g. prompt shell).
   * @default "fullscreen"
   */
  variant?: "fullscreen" | "contained";
  children?: React.ReactNode;
};

export function AttachmentsDropOverlay({
  variant = "fullscreen",
  className,
  children,
  ...props
}: AttachmentsDropOverlayProps) {
  const { isDraggingFile, disabled, maxSize } = useAttachmentsContext(
    "AttachmentsDropOverlay",
  );
  const canUseDOM = typeof document !== "undefined";
  const open = canUseDOM && !disabled && isDraggingFile;

  React.useEffect(() => {
    if (!open || variant !== "fullscreen") return;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = previousOverflow;
    };
  }, [open, variant]);

  if (!open) return null;

  const maxSizeLabel = maxSize != null ? formatBytes(maxSize) : undefined;

  const inner = (
    <div
      data-slot="attachments-drop-overlay"
      role="presentation"
      aria-hidden
      className={cn(
        "pointer-events-none bg-background/50 backdrop-blur-sm",
        variant === "fullscreen"
          ? "fixed inset-0 z-50 flex items-center justify-center"
          : "absolute inset-0 z-10 flex items-center justify-center rounded-[inherit]",
        className,
      )}
      {...props}
    >
      {children ?? (
        <div className="flex flex-col items-center gap-3">
          <HugeiconsIcon icon={Upload01Icon} className="size-5 text-primary" />

          <div className="flex flex-col items-center gap-1">
            <p className="text-sm font-[350] text-primary">
              Drop files here to add as attachment
            </p>
            {maxSizeLabel ? (
              <p className="text-xs font-[350] text-muted-foreground">
                Maximum {maxSizeLabel} per file
              </p>
            ) : null}
          </div>
        </div>
      )}
    </div>
  );

  if (variant === "fullscreen") {
    return createPortal(inner, document.body);
  }
  return inner;
}

type AttachmentTriggerProps = React.ComponentProps<"button"> & {
  asChild?: boolean;
};

function AttachmentTrigger({
  asChild = false,
  className,
  children,
  onClick,
  disabled: disabledProp,
  ...props
}: AttachmentTriggerProps) {
  const { openPicker, disabled: rootDisabled } =
    useAttachmentsContext("AttachmentTrigger");
  const disabled = Boolean(rootDisabled || disabledProp);

  const handleClick = React.useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (disabled) return;
      onClick?.(e);
      openPicker();
    },
    [disabled, onClick, openPicker],
  );

  const triggerClassName = cn(
    disabled && "cursor-not-allowed opacity-50",
    className,
  );

  if (asChild) {
    return (
      <Slot
        {...props}
        data-slot="attachment-trigger"
        className={triggerClassName}
        aria-disabled={disabled}
        onClick={handleClick as React.MouseEventHandler<HTMLElement>}
      >
        {children}
      </Slot>
    );
  }

  return (
    <button
      {...props}
      type="button"
      data-slot="attachment-trigger"
      className={triggerClassName}
      disabled={disabled}
      onClick={handleClick}
    >
      {children}
    </button>
  );
}

type AttachmentListProps = React.HTMLAttributes<HTMLDivElement>;

function AttachmentList({ className, role, ...props }: AttachmentListProps) {
  return (
    <div
      data-slot="attachment-list"
      role={role ?? "list"}
      className={cn(
        "flex w-full max-w-full min-w-0 scrollbar-thin [scrollbar-color:var(--scrollbar-thumb)_transparent] flex-wrap items-end justify-center gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-track]:bg-transparent",
        className,
      )}
      {...props}
    />
  );
}

type AttachmentOverflowFadeLayerProps = React.HTMLAttributes<HTMLDivElement> & {
  variant: "inline" | "detailed";
};

function AttachmentOverflowFadeLayer({
  className,
  variant,
  ...props
}: AttachmentOverflowFadeLayerProps) {
  return (
    <div
      aria-hidden
      data-slot="attachment-overflow-fade"
      className={cn(
        "pointer-events-none absolute top-1/2 right-0 w-10 -translate-y-1/2 bg-linear-to-l from-secondary from-65% to-transparent transition-opacity group-hover:opacity-100 sm:opacity-0",
        variant === "detailed" ? "h-15" : "h-8",
        className,
      )}
      {...props}
    />
  );
}

type AttachmentProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> & {
  variant?: AttachmentVariant;
  /** Attachment metadata; drives preview and properties. */
  attachment: AttachmentMeta;
  /** Upload progress 0–100; shows a bottom bar when set */
  progress?: number;
  onRemove?: () => void;
  /**
   * When true, hides remove controls and the progress bar (e.g. message history).
   * @default false
   */
  readOnly?: boolean;
  /** Detailed layout: second line; inferred from `attachment.size` when omitted. */
  detailedSubtitle?: "size" | "kind";
  /**
   * When set, replaces the default layout.
   */
  children?: React.ReactNode;
  /**
   * `pasted` only: maximum characters in the preview line (remainder as ellipsis).
   * @default 220
   */
  pastedExcerptMaxChars?: number;
};

function Attachment({
  className,
  variant = "compact",
  attachment,
  progress,
  onRemove,
  readOnly = false,
  detailedSubtitle: detailedSubtitleProp,
  pastedExcerptMaxChars = 220,
  children,
  ...props
}: AttachmentProps) {
  const detailedSubtitle =
    variant === "detailed"
      ? (detailedSubtitleProp ?? inferDetailedSubtitleMode(attachment))
      : undefined;

  const ctxValue = React.useMemo<AttachmentItemContextValue>(
    () => ({
      variant: variant ?? "compact",
      attachment,
      onRemove,
      readOnly,
    }),
    [variant, attachment, onRemove, readOnly],
  );

  const showProgress =
    !readOnly && progress != null && Number.isFinite(progress);

  const defaultLayout =
    variant === "pasted" ? (
      <>
        <AttachmentPreview pastedExcerptMaxChars={pastedExcerptMaxChars} />

        {!readOnly ? (
          <div className="flex h-6 w-full items-center justify-between gap-2 rounded-sm bg-card pr-1 pl-2">
            <span className="text-xs leading-4 font-[350] text-muted-foreground uppercase">
              Pasted
            </span>
            <AttachmentRemove
              position="inline"
              className="bg-transparent text-muted-foreground hover:bg-muted dark:bg-transparent dark:hover:bg-muted"
            />
          </div>
        ) : null}
      </>
    ) : variant === "compact" ? (
      <>
        <AttachmentRemove />
        <AttachmentPreview />
      </>
    ) : variant === "detailed" ? (
      <>
        <AttachmentRemove />
        <div className="flex min-w-0 items-center gap-2">
          <AttachmentPreview />
          <AttachmentInfo>
            <AttachmentProperty as="name" />
            <AttachmentProperty
              as={detailedSubtitle === "size" ? "size" : "kind"}
            />
          </AttachmentInfo>
        </div>
      </>
    ) : (
      <>
        <AttachmentRemove />
        <div className="flex min-w-0 items-center gap-1">
          <AttachmentPreview />
          <AttachmentProperty as="name" />
        </div>
      </>
    );

  return (
    <AttachmentItemContext.Provider value={ctxValue}>
      <div
        data-slot="attachment"
        data-variant={variant}
        role="listitem"
        className={cn(attachmentVariants({ variant }), className)}
        {...props}
      >
        {variant === "inline" || variant === "detailed" ? (
          <AttachmentOverflowFadeLayer variant={variant} />
        ) : null}
        {children ?? defaultLayout}
        {showProgress && variant !== "pasted" ? (
          <AttachmentProgress value={progress} />
        ) : null}
      </div>
    </AttachmentItemContext.Provider>
  );
}

const attachmentPreviewVariants = cva(
  "flex shrink-0 items-center justify-center overflow-hidden bg-card text-muted-foreground",
  {
    variants: {
      variant: {
        compact:
          "absolute inset-0 size-full rounded-[inherit] border-0 bg-transparent",
        inline: "size-6 rounded-xs border border-input",
        detailed: "size-11 rounded-sm border border-input",
        pasted:
          "min-h-0 w-full flex-1 shrink self-stretch items-start justify-start border-0 bg-transparent p-0",
      },
    },
    defaultVariants: {
      variant: "compact",
    },
  },
);

type AttachmentPreviewProps = Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "children"
> &
  Partial<VariantProps<typeof attachmentPreviewVariants>> & {
    /**
     * `pasted` only: max characters before ellipsis (from **`Attachment`** **`pastedExcerptMaxChars`**).
     * @default 220
     */
    pastedExcerptMaxChars?: number;
  };

function AttachmentPreview({
  className,
  variant: variantProp,
  pastedExcerptMaxChars = 220,
  ...props
}: AttachmentPreviewProps) {
  const { variant, attachment } = useAttachmentItemContext("AttachmentPreview");
  const v = variantProp ?? variant;
  const [pastedRawText, setPastedRawText] = React.useState("");

  React.useEffect(() => {
    if (v !== "pasted") return;

    let cancelled = false;

    const run = async () => {
      if (attachment.data instanceof Blob) {
        const t = await attachment.data.text();
        if (!cancelled) setPastedRawText(t);
        return;
      }
      if (attachment.data instanceof ArrayBuffer) {
        const t = new TextDecoder().decode(attachment.data);
        if (!cancelled) setPastedRawText(t);
        return;
      }
      const url = attachment.url;
      if (url?.startsWith("blob:") || url?.startsWith("data:")) {
        try {
          const res = await fetch(url);
          const t = await res.text();
          if (!cancelled) setPastedRawText(t);
        } catch {
          if (!cancelled) setPastedRawText("");
        }
        return;
      }
      if (!cancelled) setPastedRawText("");
    };

    void run();
    return () => {
      cancelled = true;
    };
  }, [v, attachment.data, attachment.url]);

  const pastedExcerpt = React.useMemo(() => {
    if (v !== "pasted") return "";
    const normalized = pastedRawText.replace(/\s+/g, " ").trim();
    const max = pastedExcerptMaxChars;
    if (normalized.length <= max) return normalized;
    return `${normalized.slice(0, max).trimEnd()}…`;
  }, [v, pastedRawText, pastedExcerptMaxChars]);

  const rasterSrc =
    attachment.thumbnailUrl ??
    (attachment.type === "image" && attachment.url
      ? attachment.url
      : undefined);
  const videoSrc =
    !attachment.thumbnailUrl && attachment.type === "video" && attachment.url
      ? attachment.url
      : undefined;
  const showRaster = Boolean(rasterSrc);
  const showVideo = Boolean(videoSrc);
  const inlinePlainIcon =
    v === "inline" && !showRaster && !showVideo
      ? "border-0 bg-transparent dark:bg-transparent"
      : "";

  const iconClass = v === "inline" ? "size-5" : "size-7";

  const content = (() => {
    if (v === "pasted") {
      return (
        <p
          data-slot="attachment-preview-excerpt"
          className="my-0! line-clamp-6 text-xs leading-4 font-[350] text-ring"
        >
          {pastedExcerpt.length > 0 ? pastedExcerpt : "\u00a0"}
        </p>
      );
    }
    if (rasterSrc) {
      return (
        <>
          <div className="absolute inset-0 z-0 size-full animate-pulse bg-input" />
          <img
            src={rasterSrc}
            alt=""
            className="relative z-1 size-full object-cover"
          />
        </>
      );
    }
    if (videoSrc) {
      return (
        <>
          <div className="absolute inset-0 z-0 size-full animate-pulse bg-input" />
          <video
            src={videoSrc}
            muted
            playsInline
            preload="metadata"
            aria-hidden
            className="relative z-1 size-full object-cover"
          />
        </>
      );
    }
    return (
      <HugeiconsIcon
        icon={iconForAttachmentType(attachment.type)}
        strokeWidth={1.5}
        className={iconClass}
        aria-hidden
      />
    );
  })();

  return (
    <div
      data-slot="attachment-preview"
      className={cn(
        attachmentPreviewVariants({ variant: v }),
        inlinePlainIcon,
        v !== "pasted" && "relative",
        className,
      )}
      {...props}
    >
      {content}
    </div>
  );
}

const removeButtonVariants = cva(
  "z-10 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-secondary text-muted-foreground sm:opacity-0 transition-all group-hover:opacity-100 hover:bg-border hover:text-primary active:scale-[0.97]",
  {
    variants: {
      position: {
        corner: "absolute top-1 right-1",
        "center-end": "absolute top-1/2 right-1 -translate-y-1/2",
        /** Inline footer (e.g. **`variant="pasted"`**): always visible. */
        inline: "relative shrink-0 opacity-100 sm:opacity-100",
      },
    },
    defaultVariants: {
      position: "corner",
    },
  },
);

type AttachmentRemoveProps = React.ComponentProps<"button"> &
  VariantProps<typeof removeButtonVariants> & {
    asChild?: boolean;
  };

function AttachmentRemove({
  className,
  asChild = false,
  position: positionProp,
  children,
  type: _type,
  onClick,
  "aria-label": ariaLabelProp,
  ...props
}: AttachmentRemoveProps) {
  const { variant, attachment, onRemove, readOnly } =
    useAttachmentItemContext("AttachmentRemove");
  const position =
    positionProp ?? (variant === "inline" ? "center-end" : "corner");

  const ariaLabel =
    ariaLabelProp ?? `Remove ${attachment.name ?? "attachment"}`;

  const handleClick = React.useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(e);
      onRemove?.();
    },
    [onClick, onRemove],
  );

  if (readOnly) {
    return null;
  }

  if (asChild) {
    return (
      <Slot
        className={cn(removeButtonVariants({ position }), className)}
        aria-label={ariaLabel}
        onClick={handleClick as React.MouseEventHandler<HTMLElement>}
        {...props}
      >
        {children}
      </Slot>
    );
  }

  return (
    <button
      type="button"
      data-slot="attachment-remove"
      className={cn(removeButtonVariants({ position }), className)}
      aria-label={ariaLabel}
      onClick={handleClick}
      {...props}
    >
      {children ?? (
        <HugeiconsIcon
          icon={Cancel01Icon}
          strokeWidth={2.5}
          className="size-3"
          aria-hidden
        />
      )}
    </button>
  );
}

type AttachmentInfoProps = React.HTMLAttributes<HTMLDivElement>;

function AttachmentInfo({ className, ...props }: AttachmentInfoProps) {
  return (
    <div
      data-slot="attachment-info"
      className={cn("flex min-w-0 flex-col gap-0", className)}
      {...props}
    />
  );
}

type AttachmentPropertyAs = "name" | "size" | "kind";

type AttachmentPropertyProps = Omit<
  React.HTMLAttributes<HTMLParagraphElement>,
  "children"
> & {
  as: AttachmentPropertyAs;
};

function AttachmentProperty({
  as: mode,
  className,
  ...props
}: AttachmentPropertyProps) {
  const { attachment } = useAttachmentItemContext("AttachmentProperty");
  let text: string;
  if (mode === "name") {
    text = attachment.name ?? "";
  } else if (mode === "size") {
    text = formatBytes(attachment.size) ?? "—";
  } else {
    text = kindLabel(attachment) ?? "—";
  }

  const isTitle = mode === "name";
  return (
    <p
      data-slot="attachment-property"
      data-as={mode}
      className={cn(
        isTitle
          ? "my-0! truncate text-sm leading-6 font-[450] text-primary"
          : "my-0! text-xs font-[350] text-muted-foreground",
        className,
      )}
      {...props}
    >
      {text}
    </p>
  );
}

type AttachmentProgressProps = React.HTMLAttributes<HTMLDivElement> & {
  /** 0–100 */
  value: number;
};

function AttachmentProgress({
  className,
  value,
  ...props
}: AttachmentProgressProps) {
  const clamped = Math.min(100, Math.max(0, value));
  return (
    <div
      data-slot="attachment-progress"
      className={cn(
        "pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-border/90",
        className,
      )}
      {...props}
    >
      <div
        className="h-full bg-foreground transition-[width] duration-200 dark:bg-primary"
        style={{ width: `${clamped}%` }}
      />
    </div>
  );
}

Attachments.displayName = "Attachments";
AttachmentsDropOverlay.displayName = "AttachmentsDropOverlay";
AttachmentTrigger.displayName = "AttachmentTrigger";
AttachmentList.displayName = "AttachmentList";
Attachment.displayName = "Attachment";
AttachmentPreview.displayName = "AttachmentPreview";
AttachmentRemove.displayName = "AttachmentRemove";
AttachmentInfo.displayName = "AttachmentInfo";
AttachmentProperty.displayName = "AttachmentProperty";
AttachmentProgress.displayName = "AttachmentProgress";

export {
  Attachments,
  AttachmentTrigger,
  AttachmentList,
  Attachment,
  AttachmentPreview,
  AttachmentRemove,
  AttachmentInfo,
  AttachmentProperty,
  AttachmentProgress,
};
lib/use-on-change.ts
"use client";

import * as React from "react";

/**
 * Runs `onChange` when `value` changes (compared against previous render).
 */
export function useOnChange<T>(
  value: T,
  onChange: (current: T, previous: T) => void,
  isUpdated: (previous: T, current: T) => boolean = Object.is,
) {
  const previousRef = React.useRef(value);

  React.useEffect(() => {
    const previous = previousRef.current;
    if (!isUpdated(previous, value)) {
      onChange(value, previous);
    }
    previousRef.current = value;
  }, [value, onChange, isUpdated]);
}

More from nexus-ui

Browse all components in this library →