Attachment
A file on its way into a conversation: thumbnail, a name that keeps its extension, and a thin ink line while it uploads.
- q3-board-memo-final-v2.pdfUploading · 0%
- renewals-october.xlsxUploading · 0%
dashboard.pngPNG · 820 KB
- pricing.tsTS · 12 KB
- workspace-reference.pngPNG · 1.1 MB
- office-north-wing.jpgUploading · 0%
The idea
The name truncates in the middle, because the end of a file name is what tells two drafts apart — “q3-board-me…l-v2.pdf” is useful where “q3-board-memo-fi…” is not — and it does it in CSS, as a stem that ellipses beside a tail that does not. Progress is the one solid fill on the chip. A failure is said in words with a mark in place of the glyph, never in red: what helps is knowing the connection dropped.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Attachment, AttachmentList } from "mihirmodi-ui";
<AttachmentList>
{files.map((f) => (
<Attachment
key={f.id}
name={f.name} // the extension survives truncation
size={f.bytes}
src={f.previewUrl} // an image becomes the thumbnail
progress={f.uploaded} // 0–1 while uploading
error={f.failed && "Upload failed: the connection dropped"}
onRetry={() => retry(f.id)}
onRemove={() => remove(f.id)}
/>
))}
</AttachmentList>
// Images can lead with the picture instead.
<Attachment layout="tile" name="workspace.png" size={820_000} src={url} />Props
| Prop | Type | Notes |
|---|---|---|
| namestring | string | With its extension, which always survives truncation. |
| size / kindnumber / string | number / string | Bytes, formatted for you; the kind defaults to the extension. |
| srcstring | string | An image URL. The media slot becomes a thumbnail. |
| progressnumber | number | 0–1. Below 1 it reads “Uploading · 64%” and the bar fills. |
| errorReactNode | ReactNode | What went wrong, in words. Pair with `onRetry`. |
| onRemove() => void | () => void | Draws the remove button, named for the file. |
| layout"row" | "tile" | "row" | "tile"= "row" | A chip for a composer, or a tile that leads with the picture. |
| AttachmentListcomponent | component | A wrapping list; each file fades in, and out where it stands when removed. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { Children, isValidElement, type ReactNode } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CloseIcon, FileIcon } from "../../lib/glyph.js";
import { CLOSE, FLAT, enter } from "../../lib/motion.js";
export type AttachmentProps = {
/** The file name, extension included. The extension always survives truncation. */
name: string;
/** Size in bytes. */
size?: number;
/** An image URL. Given, the media slot is a thumbnail instead of a glyph. */
src?: string;
/** The kind in the meta line — "PDF", "Spreadsheet". Defaults to the extension, upper-cased. */
kind?: string;
/** 0–1 while uploading. Below 1 the meta line says so and an ink bar fills; at 1 or unset the file is in. */
progress?: number;
/** Uploaded, but still being read on the far side. */
processing?: boolean;
/** What went wrong, in words — "Too large: 25 MB is the limit". */
error?: ReactNode;
/** Draws a remove button. */
onRemove?: () => void;
/** Draws a retry button while `error` is set. */
onRetry?: () => void;
/** `row` is a chip for a composer or a list; `tile` leads with the picture, for images. */
layout?: "row" | "tile";
className?: string;
};
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
const units = ["KB", "MB", "GB"];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
}
/** "q3-board-memo-final.pdf" → ["q3-board-memo-fi", "nal.pdf"]: the tail never truncates. */
function splitName(name: string) {
const dot = name.lastIndexOf(".");
const ext = dot > 0 ? name.slice(dot) : "";
const stem = dot > 0 ? name.slice(0, dot) : name;
const keep = Math.min(4, Math.max(0, stem.length - 4));
return [stem.slice(0, stem.length - keep), stem.slice(stem.length - keep) + ext] as const;
}
function AlertGlyph({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden className={className}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.8v5" />
<path d="M12 16.2h.01" strokeWidth="2.2" />
</svg>
);
}
/**
* A file on its way into a conversation, or already in it.
*
* The name truncates in the middle, not at the end: the end of a file name is
* the part that tells two drafts apart and says what kind of thing it is, so
* "q3-board-me…l-v2.pdf" is useful where "q3-board-memo-fi…" is not. It is two
* spans — a stem that ellipses and a tail that does not — so it is CSS, and it
* re-fits as the chip's width changes without measuring anything.
*
* Upload progress is a thin ink line under the meta, the one solid fill on the
* chip, filling by transform. An error says what went wrong in words and swaps
* the glyph for a mark, rather than turning anything red: the palette is
* monochrome, and "Too large: 25 MB is the limit" is the part that helps.
*/
export function Attachment({
name,
size,
src,
kind,
progress,
processing = false,
error,
onRemove,
onRetry,
layout = "row",
className = "",
}: AttachmentProps) {
const [head, tail] = splitName(name);
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toUpperCase() : "";
const uploading = !error && progress !== undefined && progress < 1;
const pct = Math.round(Math.max(0, Math.min(1, progress ?? 0)) * 100);
const meta = error
? error
: uploading
? `Uploading · ${pct}%`
: processing
? "Processing…"
: [kind ?? ext, size !== undefined ? formatBytes(size) : null].filter(Boolean).join(" · ");
const title = (
<span className="flex min-w-0 text-[13px] font-medium leading-[1.3] text-gray-1000" title={name}>
<span className="truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>
);
const metaLine = (
<span className={`block truncate text-[12px] leading-[1.35] tabular-nums ${error ? "text-muted" : "text-faint"}`} aria-live="polite">
{meta}
</span>
);
const bar = uploading && (
<span
role="progressbar"
aria-label={`Uploading ${name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
className="mt-1.5 block h-[2px] overflow-hidden rounded-full bg-gray-alpha-200"
>
<span
className="block h-full origin-left rounded-full bg-ink transition-transform duration-200 ease-out motion-reduce:transition-none"
style={{ transform: `scaleX(${pct / 100})` }}
/>
</span>
);
const glyph = error ? <AlertGlyph /> : <FileIcon className="h-4 w-4" />;
const remove = onRemove && (
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${name}`}
className={
layout === "tile"
? "glass-nav absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-gray-alpha-400 text-gray-1000 transition-transform duration-150 ease-geist active:scale-[0.92] motion-reduce:transition-none"
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-quiet transition-[background-color,color,transform] duration-150 ease-geist hover:bg-gray-alpha-200 hover:text-gray-1000 active:scale-[0.92] motion-reduce:transition-none"
}
>
<CloseIcon className="h-3 w-3" />
</button>
);
const retry = error && onRetry && (
<button
type="button"
onClick={onRetry}
className="shrink-0 rounded-[6px] px-1.5 py-0.5 text-[12px] font-medium text-gray-1000 transition-colors duration-150 hover:bg-gray-alpha-200"
>
Retry
</button>
);
if (layout === "tile") {
return (
<div className={`flex w-[132px] flex-col gap-2 ${className}`}>
<div className="relative aspect-[4/3] overflow-hidden rounded-[12px] border-[0.5px] border-gray-alpha-400 bg-background-100">
{src && !error ? (
<img src={src} alt="" className={`h-full w-full object-cover ${uploading ? "opacity-60" : ""} transition-opacity duration-200`} />
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1 text-quiet">
{glyph}
{!error && ext && <span className="text-[11px] font-medium tabular-nums">{ext}</span>}
</span>
)}
{remove}
</div>
<div className="min-w-0 px-0.5">
{title}
<span className="flex items-center gap-1">
<span className="min-w-0 flex-1">{metaLine}</span>
{retry}
</span>
{bar}
</div>
</div>
);
}
return (
<div
className={`flex min-w-0 max-w-[280px] items-center gap-2.5 rounded-[12px] border-[0.5px] border-gray-alpha-400 bg-background-100 p-1.5 pr-1 ${className}`}
>
{/* Outer 12 less 6 of padding: the thumbnail nests at 6. */}
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-gray-alpha-100 text-quiet">
{src && !error ? <img src={src} alt="" className="h-full w-full object-cover" /> : glyph}
</span>
<span className="min-w-0 flex-1 py-0.5">
{title}
{metaLine}
{bar}
</span>
{retry}
{remove}
</div>
);
}
export type AttachmentListProps = {
/** Attachments, each with a `key`. Removing one fades it out where it stands. */
children: ReactNode;
"aria-label"?: string;
className?: string;
};
/**
* Attachments in a wrapping row, as a list a screen reader can count. Each one
* arrives with the thread's fade and 4px, and leaves with the quick flat close
* — removing a file is not an event worth a bounce.
*/
export function AttachmentList({ children, "aria-label": ariaLabel = "Attachments", className = "" }: AttachmentListProps) {
const reduce = useReducedMotion();
const items = Children.toArray(children).filter(isValidElement);
return (
<LazyMotion features={domAnimation} strict>
<ul aria-label={ariaLabel} className={`flex flex-wrap items-start gap-2 ${className}`}>
<AnimatePresence initial={false}>
{items.map((child) => (
<m.li
key={child.key ?? undefined}
{...enter(reduce)}
exit={reduce ? { opacity: 0, transition: FLAT } : { opacity: 0, scale: 0.96, transition: CLOSE }}
className="min-w-0"
>
{child}
</m.li>
))}
</AnimatePresence>
</ul>
</LazyMotion>
);
}