← Componentes

Attachment

Un archivo adjunto — en un compositor, un formulario, una lista de assets de la plantilla. variant="image" es un card, no una fila con miniatura: la foto ocupa el ancho completo para que de verdad se aprecie. El botón de quitar solo aparece en hover o foco; en reposo queda limpio. Sin color decorativo: dorado solo cuando algo sube, rojo solo cuando algo falla.

Playground

Variant
State

brief.pdf

2.4 MB

<Attachment>
  <AttachmentPreview type="document" />
  <AttachmentInfo>
    <AttachmentName>brief.pdf</AttachmentName>
    <AttachmentSize>2.4 MB</AttachmentSize>
  </AttachmentInfo>
  <AttachmentRemove aria-label="Quitar archivo" />
</Attachment>

Variantes

file es una fila compacta con ícono por tipo — para documentos, donde el nombre es el dato principal. image cambia de layout, no solo de tamaño: es un card vertical donde la foto ocupa el ancho completo — el protagonista real, no una miniatura de ícono agrandada. Sin src, cae al mismo ícono que file.

file

brief.pdf

2.4 MB

image

Paisaje de muestra

captura.png

1.2 MB

<Attachment variant="image" className="max-w-[240px]">
  <AttachmentPreview src={url} alt="Descripción real de la foto" />
  <AttachmentInfo>
    <AttachmentName>captura.png</AttachmentName>
    <AttachmentSize>1.2 MB</AttachmentSize>
  </AttachmentInfo>
  <AttachmentRemove aria-label="Quitar captura.png" />
</Attachment>

Tipos de ícono

type en AttachmentPreview — cuatro íconos, no un set completo por extensión. document cubre pdf/doc/txt/todo lo demás; alcanza para reconocer la categoría de un vistazo sin construir una librería de íconos aparte.

brief.pdf

2.4 MB

logo.png

340 KB

demo.mp4

18.1 MB

nota-de-voz.m4a

620 KB

Estados

uploading dibuja un riel de progreso de 2px en dorado — no un spinner encima del ícono, para no mover dos cosas a la vez. error tiñe el borde y el ícono en danger, mismo patrón de badge tintado que ya usa Alert Dialog para su ícono.

brief.pdf

2.4 MB

propuesta.pdf

64%

foto.heic

Formato no soportado

"use client";

import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";

export type AttachmentVariant = "file" | "image";
export type AttachmentState = "idle" | "uploading" | "error";
export type AttachmentFileType = "image" | "video" | "audio" | "document";

type AttachmentContextValue = { variant: AttachmentVariant; state: AttachmentState };

const AttachmentContext = React.createContext<AttachmentContextValue | null>(null);

function useAttachmentContext(component: string) {
  const ctx = React.useContext(AttachmentContext);
  if (!ctx) throw new Error(`${component} debe usarse dentro de Attachment`);
  return ctx;
}

function DocumentTypeIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M7 3h7l4 4v14H7z" />
      <path d="M14 3v4h4" />
      <path d="M9.5 13h5M9.5 16.5h5" />
    </svg>
  );
}

function ImageTypeIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="4" width="18" height="16" rx="1.5" />
      <circle cx="9" cy="10" r="1.5" />
      <path d="M4 17l5-5 4 4 3-3 4 4" />
    </svg>
  );
}

function VideoTypeIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="5" width="14" height="14" rx="1.5" />
      <path d="M17 9.5l4-2.5v10l-4-2.5" />
    </svg>
  );
}

function AudioTypeIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M9 18V6l10-2v12" />
      <circle cx="6.5" cy="18" r="2.5" />
      <circle cx="16.5" cy="16" r="2.5" />
    </svg>
  );
}

function RemoveIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className={className} aria-hidden="true">
      <path d="M6 6l12 12M18 6L6 18" />
    </svg>
  );
}

const typeIcons: Record<AttachmentFileType, (props: { className?: string }) => React.ReactElement> = {
  image: ImageTypeIcon,
  video: VideoTypeIcon,
  audio: AudioTypeIcon,
  document: DocumentTypeIcon,
};

export interface AttachmentProps extends React.HTMLAttributes<HTMLDivElement> {
  /** file: fila compacta con ícono. image: card vertical con la foto de verdad como protagonista, no una miniatura de ícono */
  variant?: AttachmentVariant;
  state?: AttachmentState;
  /** 0–100, solo se lee cuando state="uploading" */
  progress?: number;
}

export const Attachment = React.forwardRef<HTMLDivElement, AttachmentProps>(
  ({ variant = "file", state = "idle", progress = 0, className, children, ...props }, ref) => {
    const value = React.useMemo(() => ({ variant, state }), [variant, state]);
    return (
      <AttachmentContext.Provider value={value}>
        <div
          ref={ref}
          data-state={state}
          className={cn(
            "group relative overflow-hidden rounded-md border p-3 transition-colors duration-200",
            variant === "image" ? "flex flex-col gap-3" : "flex items-center gap-3",
            EASE,
            state === "error" ? "border-danger/40 bg-danger/5" : "border-border bg-background",
            className,
          )}
          {...props}
        >
          {children}
          {/* riel de progreso — reemplaza al spinner: comunica el estado
              sin sumar un segundo elemento que también se mueve */}
          {state === "uploading" && (
            <span
              aria-hidden="true"
              className={cn("absolute bottom-0 left-0 h-0.5 bg-primary transition-[width] duration-200", EASE)}
              style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
            />
          )}
        </div>
      </AttachmentContext.Provider>
    );
  },
);
Attachment.displayName = "Attachment";

export interface AttachmentPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
  /** solo se usa si variant="image" — sin src, cae al ícono como cualquier file */
  src?: string;
  alt?: string;
  type?: AttachmentFileType;
}

export const AttachmentPreview = React.forwardRef<HTMLDivElement, AttachmentPreviewProps>(
  ({ src, alt = "", type = "document", className, ...props }, ref) => {
    const { variant, state } = useAttachmentContext("AttachmentPreview");
    const Icon = typeIcons[type];
    const showImage = variant === "image" && Boolean(src);

    return (
      <div
        ref={ref}
        className={cn(
          "flex items-center justify-center overflow-hidden rounded-md",
          // en image, el preview es el protagonista: ancho completo del
          // card y alto real vía aspect-ratio, no una miniatura de 56px
          // apretada en una fila — ahí no se aprecia una foto real
          variant === "image" ? "aspect-square w-full shrink" : "h-10 w-10 shrink-0",
          !showImage && (state === "error" ? "bg-danger/10 text-danger" : "bg-muted text-muted-foreground"),
          className,
        )}
        {...props}
      >
        {showImage ? (
          // eslint-disable-next-line @next/next/no-img-element -- packages/ui es portable, no depende de next/image
          <img src={src} alt={alt} className="h-full w-full object-cover" />
        ) : (
          <Icon className={variant === "image" ? "h-8 w-8" : "h-5 w-5"} />
        )}
      </div>
    );
  },
);
AttachmentPreview.displayName = "AttachmentPreview";

export const AttachmentInfo = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} {...props} />
  ),
);
AttachmentInfo.displayName = "AttachmentInfo";

export const AttachmentName = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
  ({ className, ...props }, ref) => <p ref={ref} className={cn("truncate text-sm font-medium", className)} {...props} />,
);
AttachmentName.displayName = "AttachmentName";

export const AttachmentSize = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
  ({ className, ...props }, ref) => (
    <p ref={ref} className={cn("truncate text-xs text-muted-foreground", className)} {...props} />
  ),
);
AttachmentSize.displayName = "AttachmentSize";

export interface AttachmentRemoveProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

// visible solo en hover/foco — el estado en reposo queda limpio, sin un
// botón de borrar permanentemente encima de cada archivo. en image flota
// sobre la foto (necesita su propio fondo sólido para leerse encima de
// cualquier imagen); en file es un ítem más de la fila
export const AttachmentRemove = React.forwardRef<HTMLButtonElement, AttachmentRemoveProps>(
  ({ className, ...props }, ref) => {
    const { variant } = useAttachmentContext("AttachmentRemove");
    return (
      <button
        ref={ref}
        type="button"
        className={cn(
          "flex h-7 w-7 items-center justify-center rounded-md opacity-0 transition-opacity duration-150",
          "focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
          "group-hover:opacity-100",
          variant === "image"
            ? "absolute right-4 top-4 bg-background/90 text-foreground hover:bg-muted"
            : "shrink-0 text-muted-foreground hover:bg-muted hover:text-foreground",
          className,
        )}
        {...props}
      >
        <RemoveIcon className="h-4 w-4" />
      </button>
    );
  },
);
AttachmentRemove.displayName = "AttachmentRemove";

Código real de packages/ui/src/attachment.tsx.

Uso

import {
  Attachment,
  AttachmentPreview,
  AttachmentInfo,
  AttachmentName,
  AttachmentSize,
  AttachmentRemove,
} from "./components/ui/attachment";

<Attachment>
  <AttachmentPreview type="document" />
  <AttachmentInfo>
    <AttachmentName>brief.pdf</AttachmentName>
    <AttachmentSize>2.4 MB</AttachmentSize>
  </AttachmentInfo>
  <AttachmentRemove aria-label="Quitar brief.pdf" onClick={() => quitar("brief.pdf")} />
</Attachment>

API

ComponentePropTipoDefault
Attachmentvariantfile | imagefile
stateidle | uploading | erroridle
progressnumber (0–100)0
AttachmentPreviewsrc / altstring
typeimage | video | audio | documentdocument
Info / Name / Size / Removeatributos nativos de su elemento

AttachmentRemove no cierra nada por sí solo — a diferencia de AlertDialogAction, aquí quitar el archivo de la lista es tu estado, no el del componente.