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
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
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
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Attachment | variant | file | image | file |
| state | idle | uploading | error | idle | |
| progress | number (0–100) | 0 | |
| AttachmentPreview | src / alt | string | — |
| type | image | video | audio | document | document | |
| Info / Name / Size / Remove | — | atributos 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.