Attachment
Muestra archivos como filas, tarjetas o chips con preview, tamaño, acciones y progreso de subida.
Nuevo1'use client';23import { Attachment } from '@/components/ui/attachment';45const files = [6 { name: 'q3-board-deck.pdf', type: 'application/pdf', size: 4_820_000 },7 { name: 'hero-final.png', type: 'image/png', size: 1_240_000 },8 { name: 'source-assets.zip', type: 'application/zip', size: 58_300_000 },9];1011export function Default() {12 return (13 <Attachment.List className="w-full max-w-sm">14 {files.map((file) => (15 <Attachment.Item key={file.name}>16 <Attachment>17 <Attachment.Preview type={file.type} name={file.name} />18 <Attachment.Content>19 <Attachment.Name>{file.name}</Attachment.Name>20 <Attachment.Meta>21 <Attachment.Size bytes={file.size} />22 </Attachment.Meta>23 </Attachment.Content>24 <Attachment.Actions>25 <Attachment.Download href="#" />26 <Attachment.Remove />27 </Attachment.Actions>28 </Attachment>29 </Attachment.Item>30 ))}31 </Attachment.List>32 );33}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import * as React from 'react';4import { cn } from '../lib/cn';56type IconProps = React.SVGProps<SVGSVGElement> & {7 size?: number | string;8};910function ArchiveIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {11 return (12 <svg13 xmlns="http://www.w3.org/2000/svg"14 width={size}15 height={size}16 viewBox="0 0 24 24"17 fill="none"18 stroke="currentColor"19 strokeWidth={strokeWidth}20 strokeLinecap="round"21 strokeLinejoin="round"22 aria-hidden="true"23 {...props}24 >25 <rect x="2.5" y="3.5" width="19" height="5.5" rx="2" />26 <path d="M4 9v8.5a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9" />27 <path d="M10 13.5h4" />28 </svg>29 );30}3132function DownloadIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {33 return (34 <svg35 xmlns="http://www.w3.org/2000/svg"36 width={size}37 height={size}38 viewBox="0 0 24 24"39 fill="none"40 stroke="currentColor"41 strokeWidth={strokeWidth}42 strokeLinecap="round"43 strokeLinejoin="round"44 aria-hidden="true"45 {...props}46 >47 <path d="M12 3v11" />48 <path d="m8 10.5 4 4 4-4" />49 <path d="M3.5 15v1.5A3.5 3.5 0 0 0 7 20h10a3.5 3.5 0 0 0 3.5-3.5V15" />50 </svg>51 );52}5354function FileIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {55 return (56 <svg57 xmlns="http://www.w3.org/2000/svg"58 width={size}59 height={size}60 viewBox="0 0 24 24"61 fill="none"62 stroke="currentColor"63 strokeWidth={strokeWidth}64 strokeLinecap="round"65 strokeLinejoin="round"66 aria-hidden="true"67 {...props}68 >69 <path d="M13.5 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8.5L13.5 3Z" />70 <path d="M13.5 3v3.5a2 2 0 0 0 2 2H19" />71 </svg>72 );73}7475function FileTextIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {76 return (77 <svg78 xmlns="http://www.w3.org/2000/svg"79 width={size}80 height={size}81 viewBox="0 0 24 24"82 fill="none"83 stroke="currentColor"84 strokeWidth={strokeWidth}85 strokeLinecap="round"86 strokeLinejoin="round"87 aria-hidden="true"88 {...props}89 >90 <path d="M13.5 3H7.5A2.5 2.5 0 0 0 5 5.5v13A2.5 2.5 0 0 0 7.5 21h9a2.5 2.5 0 0 0 2.5-2.5V8.5L13.5 3Z" />91 <path d="M13.5 3v3.5a2 2 0 0 0 2 2H19" />92 <path d="M8.5 13h7" />93 <path d="M8.5 17h4" />94 </svg>95 );96}9798function ImageIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {99 return (100 <svg101 xmlns="http://www.w3.org/2000/svg"102 width={size}103 height={size}104 viewBox="0 0 24 24"105 fill="none"106 stroke="currentColor"107 strokeWidth={strokeWidth}108 strokeLinecap="round"109 strokeLinejoin="round"110 aria-hidden="true"111 {...props}112 >113 <rect x="2.5" y="3.5" width="19" height="17" rx="3.5" />114 <circle cx="8.5" cy="9" r="2" />115 <path d="m21.5 14.5-3.4-3.4a2 2 0 0 0-2.8 0L7 19.4" />116 </svg>117 );118}119120function MusicIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {121 return (122 <svg123 xmlns="http://www.w3.org/2000/svg"124 width={size}125 height={size}126 viewBox="0 0 24 24"127 fill="none"128 stroke="currentColor"129 strokeWidth={strokeWidth}130 strokeLinecap="round"131 strokeLinejoin="round"132 aria-hidden="true"133 {...props}134 >135 <circle cx="6.5" cy="17.5" r="3" />136 <circle cx="17.5" cy="15.5" r="3" />137 <path d="M9.5 17.5V7.2a2 2 0 0 1 1.5-1.9l8-2a2 2 0 0 1 1.5 1.9v10.3" />138 </svg>139 );140}141142function VideoIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {143 return (144 <svg145 xmlns="http://www.w3.org/2000/svg"146 width={size}147 height={size}148 viewBox="0 0 24 24"149 fill="none"150 stroke="currentColor"151 strokeWidth={strokeWidth}152 strokeLinecap="round"153 strokeLinejoin="round"154 aria-hidden="true"155 {...props}156 >157 <rect x="2.5" y="5.5" width="13" height="13" rx="3" />158 <path d="m15.5 10 4.4-2.6a1 1 0 0 1 1.6.9v7.4a1 1 0 0 1-1.6.9L15.5 14" />159 </svg>160 );161}162163function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {164 return (165 <svg166 xmlns="http://www.w3.org/2000/svg"167 width={size}168 height={size}169 viewBox="0 0 24 24"170 fill="none"171 stroke="currentColor"172 strokeWidth={strokeWidth}173 strokeLinecap="round"174 strokeLinejoin="round"175 aria-hidden="true"176 {...props}177 >178 <path d="M18 6 6 18" />179 <path d="m6 6 12 12" />180 </svg>181 );182}183184type AttachmentVariant = 'row' | 'card' | 'chip';185type AttachmentStatus = 'idle' | 'uploading' | 'done' | 'error';186187interface AttachmentContextValue {188 variant: AttachmentVariant;189 status: AttachmentStatus;190 nameId: string | undefined;191 registerName: (id: string) => () => void;192}193194const AttachmentContext = React.createContext<AttachmentContextValue | null>(null);195196function useAttachmentContext(): AttachmentContextValue {197 const context = React.use(AttachmentContext);198 if (!context) throw new Error('Attachment parts must be used within Attachment');199 return context;200}201202const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;203204function formatFileSize(bytes: number, decimals = 1): string {205 if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';206 const exponent = Math.min(207 Math.max(0, Math.floor(Math.log(bytes) / Math.log(1024))),208 SIZE_UNITS.length - 1,209 );210 const value = bytes / 1024 ** exponent;211 const rounded = exponent === 0 ? value : Number(value.toFixed(decimals));212 return `${rounded} ${SIZE_UNITS[exponent]}`;213}214215const TYPE_ICONS = {216 image: ImageIcon,217 video: VideoIcon,218 audio: MusicIcon,219 document: FileTextIcon,220 archive: ArchiveIcon,221 file: FileIcon,222} satisfies Record<string, React.ComponentType<IconProps>>;223224function kindForType(type: string | undefined, name: string | undefined): keyof typeof TYPE_ICONS {225 const mime = (type ?? '').toLowerCase();226 const extension = (name ?? '').toLowerCase().split('.').pop() ?? '';227228 if (mime.startsWith('image/')) return 'image';229 if (mime.startsWith('video/')) return 'video';230 if (mime.startsWith('audio/')) return 'audio';231 if (mime === 'application/pdf' || extension === 'pdf') return 'document';232 if (233 mime.includes('zip') ||234 mime.includes('compressed') ||235 ['zip', 'rar', '7z', 'gz', 'tar'].includes(extension)236 ) {237 return 'archive';238 }239 return 'file';240}241242function AttachmentTypeIcon({ type, name }: { type?: string; name?: string }) {243 const Icon = TYPE_ICONS[kindForType(type, name)];244 return <Icon aria-hidden="true" />;245}246247interface AttachmentStatusLabels {248 uploading: string;249 error: string;250}251252const DEFAULT_STATUS_LABELS: AttachmentStatusLabels = {253 uploading: 'Uploading',254 error: 'Upload failed',255};256257interface AttachmentProps extends React.HTMLAttributes<HTMLDivElement> {258 variant?: AttachmentVariant;259 status?: AttachmentStatus;260 statusLabels?: Partial<AttachmentStatusLabels>;261}262263const AttachmentRoot = ({264 variant = 'row',265 status = 'idle',266 statusLabels,267 className,268 children,269 ref,270 ...props271}: AttachmentProps & { ref?: React.Ref<HTMLDivElement> }) => {272 const [nameId, setNameId] = React.useState<string | undefined>(undefined);273274 const registerName = React.useCallback((id: string) => {275 setNameId(id);276 return () => setNameId((current) => (current === id ? undefined : current));277 }, []);278279 const context = React.useMemo(280 () => ({ variant, status, nameId, registerName }),281 [variant, status, nameId, registerName],282 );283284 const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };285 const statusText =286 status === 'uploading' ? labels.uploading : status === 'error' ? labels.error : '';287288 return (289 <AttachmentContext value={context}>290 <div291 ref={ref}292 data-slot="attachment"293 data-variant={variant}294 data-status={status}295 aria-busy={status === 'uploading' || undefined}296 className={cn(297 'group/attachment border-border bg-card text-card-foreground relative overflow-hidden border transition-colors',298 'data-[status=error]:border-destructive/60 data-[status=uploading]:text-muted-foreground',299 variant === 'row' && 'flex items-center gap-3 rounded-lg p-2.5 pr-2',300 variant === 'card' && 'flex w-40 flex-col rounded-xl',301 variant === 'chip' &&302 'inline-flex h-8 max-w-60 items-center gap-1.5 rounded-full py-1 pr-1 pl-1.5',303 className,304 )}305 {...props}306 >307 {children}308 <span role="status" className="sr-only">309 {statusText}310 </span>311 </div>312 </AttachmentContext>313 );314};315AttachmentRoot.displayName = 'Attachment';316317interface AttachmentPreviewProps extends React.HTMLAttributes<HTMLDivElement> {318 src?: string;319 type?: string;320 name?: string;321 alt?: string;322}323324const AttachmentPreview = ({325 src,326 type,327 name,328 alt,329 className,330 children,331 ref,332 ...props333}: AttachmentPreviewProps & { ref?: React.Ref<HTMLDivElement> }) => {334 const { variant } = useAttachmentContext();335 const isImage = Boolean(src);336337 return (338 <div339 ref={ref}340 data-slot="attachment-preview"341 data-kind={isImage ? 'image' : 'icon'}342 className={cn(343 'bg-muted text-muted-foreground flex shrink-0 items-center justify-center overflow-hidden',344 variant === 'row' && 'size-10 rounded-md [&_svg]:size-5',345 variant === 'card' && 'aspect-square w-full [&_svg]:size-8',346 variant === 'chip' && 'size-6 rounded-full [&_svg]:size-3.5',347 className,348 )}349 {...props}350 >351 {children ??352 (isImage ? (353 <img src={src} alt={alt ?? name ?? ''} className="size-full object-cover" />354 ) : (355 <AttachmentTypeIcon type={type} name={name} />356 ))}357 </div>358 );359};360AttachmentPreview.displayName = 'AttachmentPreview';361362const AttachmentContent = ({363 className,364 ref,365 ...props366}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => {367 const { variant } = useAttachmentContext();368369 return (370 <div371 ref={ref}372 data-slot="attachment-content"373 className={cn(374 'flex min-w-0 flex-1 flex-col',375 variant === 'row' && 'gap-0.5',376 variant === 'card' && 'gap-0.5 p-2.5',377 variant === 'chip' && 'flex-row items-baseline gap-1',378 className,379 )}380 {...props}381 />382 );383};384AttachmentContent.displayName = 'AttachmentContent';385386const AttachmentName = ({387 className,388 id,389 ref,390 ...props391}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) => {392 const { variant, registerName } = useAttachmentContext();393 const generatedId = React.useId();394 const nameId = id ?? generatedId;395396 React.useEffect(() => registerName(nameId), [nameId, registerName]);397398 return (399 <p400 ref={ref}401 id={nameId}402 data-slot="attachment-name"403 className={cn('truncate font-medium', variant === 'chip' ? 'text-xs' : 'text-sm', className)}404 {...props}405 />406 );407};408AttachmentName.displayName = 'AttachmentName';409410const AttachmentMeta = ({411 className,412 ref,413 ...props414}: React.HTMLAttributes<HTMLParagraphElement> & { ref?: React.Ref<HTMLParagraphElement> }) => (415 <p416 ref={ref}417 data-slot="attachment-meta"418 className={cn('text-muted-foreground truncate text-xs', className)}419 {...props}420 />421);422AttachmentMeta.displayName = 'AttachmentMeta';423424interface AttachmentSizeProps extends React.HTMLAttributes<HTMLSpanElement> {425 bytes: number;426 decimals?: number;427}428429const AttachmentSize = ({430 bytes,431 decimals,432 className,433 ref,434 ...props435}: AttachmentSizeProps & { ref?: React.Ref<HTMLSpanElement> }) => (436 <span437 ref={ref}438 data-slot="attachment-size"439 className={cn('text-muted-foreground text-xs tabular-nums', className)}440 {...props}441 >442 {formatFileSize(bytes, decimals)}443 </span>444);445AttachmentSize.displayName = 'AttachmentSize';446447const AttachmentActions = ({448 className,449 ref,450 ...props451}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> }) => {452 const { variant } = useAttachmentContext();453454 return (455 <div456 ref={ref}457 data-slot="attachment-actions"458 className={cn(459 'flex shrink-0 items-center gap-0.5',460 variant === 'card' &&461 'absolute top-1.5 right-1.5 transition-opacity group-focus-within/attachment:opacity-100 group-hover/attachment:opacity-100 [@media(hover:hover)]:opacity-0',462 className,463 )}464 {...props}465 />466 );467};468AttachmentActions.displayName = 'AttachmentActions';469470function joinIds(...ids: (string | undefined)[]) {471 return ids.filter(Boolean).join(' ') || undefined;472}473474const actionClassName =475 'text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-40 pointer-coarse:before:absolute';476477interface AttachmentRemoveProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {478 label?: string;479}480481const AttachmentRemove = ({482 label = 'Remove attachment',483 className,484 children,485 'aria-describedby': describedBy,486 ref,487 ...props488}: AttachmentRemoveProps & { ref?: React.Ref<HTMLButtonElement> }) => {489 const { variant, nameId } = useAttachmentContext();490491 return (492 <button493 ref={ref}494 type="button"495 aria-label={label}496 aria-describedby={joinIds(nameId, describedBy)}497 data-slot="attachment-remove"498 className={cn(499 actionClassName,500 variant === 'chip'501 ? 'size-6 pointer-coarse:before:-inset-1.5 [&_svg]:size-3'502 : 'size-7 pointer-coarse:before:-inset-0.5 [&_svg]:size-3.5',503 variant === 'card' && 'bg-background/80 backdrop-blur-sm',504 className,505 )}506 {...props}507 >508 {children ?? <XIcon aria-hidden="true" />}509 </button>510 );511};512AttachmentRemove.displayName = 'AttachmentRemove';513514interface AttachmentDownloadProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {515 label?: string;516}517518const AttachmentDownload = ({519 label = 'Download attachment',520 className,521 children,522 'aria-describedby': describedBy,523 ref,524 ...props525}: AttachmentDownloadProps & { ref?: React.Ref<HTMLAnchorElement> }) => {526 const { variant, nameId } = useAttachmentContext();527528 return (529 <a530 ref={ref}531 download532 aria-label={label}533 aria-describedby={joinIds(nameId, describedBy)}534 data-slot="attachment-download"535 className={cn(536 actionClassName,537 variant === 'chip'538 ? 'size-6 pointer-coarse:before:-inset-1.5 [&_svg]:size-3'539 : 'size-7 pointer-coarse:before:-inset-0.5 [&_svg]:size-3.5',540 variant === 'card' && 'bg-background/80 backdrop-blur-sm',541 className,542 )}543 {...props}544 >545 {children ?? <DownloadIcon aria-hidden="true" />}546 </a>547 );548};549AttachmentDownload.displayName = 'AttachmentDownload';550551interface AttachmentProgressProps extends React.HTMLAttributes<HTMLDivElement> {552 value: number;553}554555const AttachmentProgress = ({556 value,557 className,558 'aria-label': ariaLabel,559 'aria-labelledby': ariaLabelledBy,560 ref,561 ...props562}: AttachmentProgressProps & { ref?: React.Ref<HTMLDivElement> }) => {563 const nameId = React.use(AttachmentContext)?.nameId;564 const clamped = Math.min(Math.max(value, 0), 100);565 const labelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : nameId);566567 return (568 <div569 ref={ref}570 role="progressbar"571 aria-label={labelledBy ? ariaLabel : (ariaLabel ?? 'Upload progress')}572 aria-labelledby={labelledBy}573 aria-valuemin={0}574 aria-valuemax={100}575 aria-valuenow={Math.round(clamped)}576 data-slot="attachment-progress"577 className={cn('bg-muted absolute inset-x-0 bottom-0 h-0.5', className)}578 {...props}579 >580 <div581 className="bg-primary h-full transition-[width] duration-300"582 style={{ width: `${clamped}%` }}583 />584 </div>585 );586};587AttachmentProgress.displayName = 'AttachmentProgress';588589interface AttachmentListProps extends React.HTMLAttributes<HTMLUListElement> {590 layout?: 'stack' | 'grid' | 'inline';591}592593const AttachmentList = ({594 layout = 'stack',595 className,596 ref,597 ...props598}: AttachmentListProps & { ref?: React.Ref<HTMLUListElement> }) => (599 <ul600 ref={ref}601 data-slot="attachment-list"602 data-layout={layout}603 className={cn(604 'm-0 list-none p-0',605 layout === 'stack' && 'flex flex-col gap-2',606 layout === 'grid' && 'flex flex-wrap gap-3',607 layout === 'inline' && 'flex flex-wrap items-center gap-1.5',608 className,609 )}610 {...props}611 />612);613AttachmentList.displayName = 'AttachmentList';614615const AttachmentItem = ({616 className,617 ref,618 ...props619}: React.LiHTMLAttributes<HTMLLIElement> & { ref?: React.Ref<HTMLLIElement> }) => (620 <li ref={ref} data-slot="attachment-item" className={cn('min-w-0', className)} {...props} />621);622AttachmentItem.displayName = 'AttachmentItem';623624const Attachment = Object.assign(AttachmentRoot, {625 Preview: AttachmentPreview,626 Content: AttachmentContent,627 Name: AttachmentName,628 Meta: AttachmentMeta,629 Size: AttachmentSize,630 Actions: AttachmentActions,631 Remove: AttachmentRemove,632 Download: AttachmentDownload,633 Progress: AttachmentProgress,634 List: AttachmentList,635 Item: AttachmentItem,636});637638export { Attachment, formatFileSize, useAttachmentContext };639export type {640 AttachmentProps,641 AttachmentStatusLabels,642 AttachmentVariant,643 AttachmentStatus,644 AttachmentPreviewProps,645 AttachmentSizeProps,646 AttachmentProgressProps,647 AttachmentListProps,648 AttachmentRemoveProps,649 AttachmentDownloadProps,650};
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { Attachment } from '@/components/ui/attachment';
1<Attachment>2 <Attachment.Preview type="application/pdf" name="deck.pdf" />3 <Attachment.Content>4 <Attachment.Name>deck.pdf</Attachment.Name>5 <Attachment.Meta>6 <Attachment.Size bytes={4820000} />7 </Attachment.Meta>8 </Attachment.Content>9 <Attachment.Actions>10 <Attachment.Download href="/files/deck.pdf" />11 <Attachment.Remove onClick={remove} />12 </Attachment.Actions>13</Attachment>
Attachment solo muestra. No sabe cómo llegó el archivo, así que sirve para una cola de subida, un compositor de mensajes, un hilo de comentarios o una lista de archivos que ya están en el servidor. Combínalo con File Upload cuando necesites el selector y la validación.Composición
Attachment.List└── Attachment.Item└── Attachment├── Attachment.Preview├── Attachment.Content│ ├── Attachment.Name│ └── Attachment.Meta│ └── Attachment.Size├── Attachment.Actions│ ├── Attachment.Download│ └── Attachment.Remove└── Attachment.Progress
Variantes
Filas
La variante
row por defecto, una lista apilada con ícono por tipo, nombre, tamaño y acciones.1'use client';23import { Attachment } from '@/components/ui/attachment';45const files = [6 { name: 'q3-board-deck.pdf', type: 'application/pdf', size: 4_820_000 },7 { name: 'hero-final.png', type: 'image/png', size: 1_240_000 },8 { name: 'source-assets.zip', type: 'application/zip', size: 58_300_000 },9];1011export function Default() {12 return (13 <Attachment.List className="w-full max-w-sm">14 {files.map((file) => (15 <Attachment.Item key={file.name}>16 <Attachment>17 <Attachment.Preview type={file.type} name={file.name} />18 <Attachment.Content>19 <Attachment.Name>{file.name}</Attachment.Name>20 <Attachment.Meta>21 <Attachment.Size bytes={file.size} />22 </Attachment.Meta>23 </Attachment.Content>24 <Attachment.Actions>25 <Attachment.Download href="#" />26 <Attachment.Remove />27 </Attachment.Actions>28 </Attachment>29 </Attachment.Item>30 ))}31 </Attachment.List>32 );33}
Tarjetas
variant="card" para imágenes. El preview ocupa la parte superior y las acciones aparecen al pasar el cursor o al enfocar.office-lobby.jpg
2 MB
team-offsite.jpg
2.9 MB
launch-day.jpg
1.8 MB
1'use client';23import { Attachment } from '@/components/ui/attachment';45const photos = [6 { name: 'office-lobby.jpg', size: 2_140_000, src: 'https://picsum.photos/seed/lobby/320/320' },7 { name: 'team-offsite.jpg', size: 3_020_000, src: 'https://picsum.photos/seed/offsite/320/320' },8 { name: 'launch-day.jpg', size: 1_870_000, src: 'https://picsum.photos/seed/launch/320/320' },9];1011export function Cards() {12 return (13 <Attachment.List layout="grid">14 {photos.map((photo) => (15 <Attachment.Item key={photo.name}>16 <Attachment variant="card">17 <Attachment.Preview src={photo.src} name={photo.name} />18 <Attachment.Content>19 <Attachment.Name>{photo.name}</Attachment.Name>20 <Attachment.Meta>21 <Attachment.Size bytes={photo.size} />22 </Attachment.Meta>23 </Attachment.Content>24 <Attachment.Actions>25 <Attachment.Remove />26 </Attachment.Actions>27 </Attachment>28 </Attachment.Item>29 ))}30 </Attachment.List>31 );32}
Chips
variant="chip" es una píldora compacta para compositores y cajas de comentarios.checkout-spec.md
18 KBpayment-flow.png
392.6 KBstripe-webhook.log
93.8 KB
1'use client';23import * as React from 'react';4import { Attachment } from '@/components/ui/attachment';5import { Button } from '@/components/ui/button';6import { Textarea } from '@/components/ui/textarea';7import { PaperclipIcon } from '@/components/ui/paperclip';8import { SendIcon } from '@/components/ui/send';910const initial = [11 { id: 'spec', name: 'checkout-spec.md', type: 'text/markdown', size: 18_400 },12 { id: 'flow', name: 'payment-flow.png', type: 'image/png', size: 402_000 },13 { id: 'log', name: 'stripe-webhook.log', type: 'text/plain', size: 96_000 },14];1516export function Chips() {17 const [files, setFiles] = React.useState(initial);1819 return (20 <div className="border-border bg-card flex w-full max-w-md flex-col gap-3 rounded-xl border p-3">21 <Textarea22 aria-label="Message"23 placeholder="Write a message to the payments team"24 autoResize25 maxRows={5}26 className="border-0 bg-transparent px-1 shadow-none focus-visible:ring-0 dark:bg-transparent"27 />28 {files.length > 0 && (29 <Attachment.List layout="inline">30 {files.map((file) => (31 <Attachment.Item key={file.id}>32 <Attachment variant="chip">33 <Attachment.Preview type={file.type} name={file.name} />34 <Attachment.Content>35 <Attachment.Name>{file.name}</Attachment.Name>36 <Attachment.Size bytes={file.size} />37 </Attachment.Content>38 <Attachment.Remove39 onClick={() => setFiles((current) => current.filter((f) => f.id !== file.id))}40 />41 </Attachment>42 </Attachment.Item>43 ))}44 </Attachment.List>45 )}46 <div className="flex items-center justify-between">47 <Button type="button" variant="ghost" size="sm">48 <PaperclipIcon size={16} />49 Attach50 </Button>51 <Button type="button" size="sm">52 Send53 <SendIcon size={16} />54 </Button>55 </div>56 </div>57 );58}
Subiendo
status define el aspecto: uploading atenúa la fila, error la delinea. Progress dibuja una barra en el borde inferior.customer-export.csv
0% of 12 MB
Uploadingonboarding.mp4
0% of 141.1 MB
Uploading- Upload failed
contract-v3.pdf
Upload failed, try again
1'use client';23import * as React from 'react';4import { Attachment } from '@/components/ui/attachment';56const uploads = [7 { name: 'customer-export.csv', type: 'text/csv', size: 12_600_000, speed: 9 },8 { name: 'onboarding.mp4', type: 'video/mp4', size: 148_000_000, speed: 3 },9 { name: 'contract-v3.pdf', type: 'application/pdf', size: 820_000, speed: 0, failed: true },10];1112export function Uploading() {13 const [progress, setProgress] = React.useState(() => uploads.map(() => 0));1415 React.useEffect(() => {16 const timer = setInterval(() => {17 setProgress((current) =>18 current.map((value, index) => {19 const upload = uploads[index];20 if (!upload || upload.failed) return value;21 return Math.min(value + upload.speed, 100);22 }),23 );24 }, 300);25 return () => clearInterval(timer);26 }, []);2728 return (29 <Attachment.List className="w-full max-w-sm">30 {uploads.map((upload, index) => {31 const value = progress[index] ?? 0;32 const status = upload.failed ? 'error' : value >= 100 ? 'done' : 'uploading';3334 return (35 <Attachment.Item key={upload.name}>36 <Attachment status={status}>37 <Attachment.Preview type={upload.type} name={upload.name} />38 <Attachment.Content>39 <Attachment.Name>{upload.name}</Attachment.Name>40 <Attachment.Meta>41 {status === 'error' && (42 <span className="text-destructive">Upload failed, try again</span>43 )}44 {status === 'uploading' && (45 <>46 {Math.round(value)}% of <Attachment.Size bytes={upload.size} />47 </>48 )}49 {status === 'done' && <Attachment.Size bytes={upload.size} />}50 </Attachment.Meta>51 </Attachment.Content>52 <Attachment.Actions>53 <Attachment.Remove54 aria-label={status === 'uploading' ? 'Cancel upload' : 'Remove'}55 />56 </Attachment.Actions>57 {status === 'uploading' && <Attachment.Progress value={value} />}58 </Attachment>59 </Attachment.Item>60 );61 })}62 </Attachment.List>63 );64}
Helpers
formatFileSize(bytes, decimals?) convierte una cantidad de bytes en 1.5 MB. Attachment.Size lo llama por ti.Referencia de API
Attachment
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
variant | "row" | "card" | "chip" | "row" | Layout de las partes |
status | "idle" | "uploading" | "done" | "error" | "idle" | Se expone como data-status y da estilo |
statusLabels | { uploading?: string; error?: string } | { uploading: "Uploading", error: "Upload failed" } | Texto que escuchan los lectores de pantalla en los estados uploading y error |
className | string | - | Clases CSS adicionales |
Attachment.Preview
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
src | string | - | URL de imagen. Renderiza una miniatura si está |
type | string | - | Tipo MIME, elige el ícono |
name | string | - | Nombre del archivo, usado para el ícono y el alt |
alt | string | - | Texto alternativo de la miniatura |
children | ReactNode | - | Reemplaza la miniatura o el ícono por completo |
Attachment.Size
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
bytes | number | - | Tamaño a formatear |
decimals | number | 1 | Decimales a conservar |
Attachment.Progress
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
value | number | - | Porcentaje completado, 0 a 100 |
La barra toma su nombre accesible de
Attachment.Name cuando existe, y si no usa "Upload progress". Pasá aria-label o aria-labelledby para nombrarla vos.Attachment.List
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
layout | "stack" | "grid" | "inline" | "stack" | Cómo fluyen los elementos |
Attachment.Item, Attachment.Content, Attachment.Name, Attachment.Meta, Attachment.Actions, Attachment.Remove, Attachment.Download
Partes de presentación. Cada una acepta
className y los atributos del elemento que renderiza. Remove es un botón y Download un ancla con el atributo download. Ambos aceptan una prop label para su nombre accesible, "Remove attachment" y "Download attachment" por defecto, y apuntan aria-describedby a Attachment.Name, así una lista llena de botones Remove sigue diciendo a qué archivo pertenece cada uno.¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.