Attachment
A file as a row: a glyph or thumbnail, the name and size, and its actions as cells. While it uploads, progress fills along its foot; if it fails, the row says why and where it stopped, and Retry starts it again. Long names keep their extension in view.
Loading the live example…
"use client"
import { useEffect, useRef, useState } from "react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGlyph,
AttachmentMedia,
AttachmentTitle,
} from "@/components/keyline/ui/attachment"
import { Button } from "@/components/keyline/ui/button"
type File = { id: number; name: string; size: string; kind: "file" | "image"; state: "uploading" | "error" | "done"; progress: number; fails: boolean }
const NEXT = [
{ name: "invoice-DM-4107.pdf", size: "212 KB", kind: "file" as const, fails: false },
{ name: "loading-dock.jpg", size: "1.8 MB", kind: "image" as const, fails: true },
{ name: "q4-price-list.pdf", size: "96 KB", kind: "file" as const, fails: false },
]
export function InvoiceUploads() {
const [files, setFiles] = useState<File[]>([{ id: 0, name: "signed-delivery-note.pdf", size: "88 KB", kind: "file", state: "done", progress: 100, fails: false }])
const n = useRef(1)
// a pretend upload: 20% a tick; the dock photo fails at 60% the first time
useEffect(() => {
if (!files.some((f) => f.state === "uploading")) return
const t = setInterval(() => {
setFiles((fs) =>
fs.map((f) => {
if (f.state !== "uploading") return f
if (f.fails && f.progress >= 60) return { ...f, state: "error", fails: false }
const p = Math.min(100, f.progress + 20)
return { ...f, progress: p, state: p === 100 ? "done" : "uploading" }
}),
)
}, 400)
return () => clearInterval(t)
}, [files])
const attach = () => {
const id = n.current++
const next = NEXT[(id - 1) % NEXT.length]
setFiles((fs) => [...fs, { id, ...next, state: "uploading", progress: 0 }])
}
return (
<div className="grid max-w-[460px] gap-3">
<ul aria-label="Attachments" className="m-0 grid list-none gap-2 p-0">
{files.map((f) => (
<li key={f.id}>
<Attachment state={f.state} progress={f.progress}>
<AttachmentMedia>
<AttachmentGlyph name={f.kind} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{f.name}</AttachmentTitle>
<AttachmentDescription>{f.state === "error" ? "The connection dropped at 60%." : f.size}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
{f.state === "error" ? (
<AttachmentAction aria-label={`Retry ${f.name}`} onClick={() => setFiles((fs) => fs.map((x) => (x.id === f.id ? { ...x, state: "uploading", progress: 0 } : x)))}>
<AttachmentGlyph name="retry" />
</AttachmentAction>
) : null}
<AttachmentAction aria-label={`Remove ${f.name}`} onClick={() => setFiles((fs) => fs.filter((x) => x.id !== f.id))}>
<AttachmentGlyph name="close" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</li>
))}
</ul>
<Button size="compact" className="justify-self-start" onClick={attach}>
Attach a file
</Button>
</div>
)
}Installation
npx shadcn@latest add @keyline-ui/attachmentRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentGlyph, AttachmentMedia, AttachmentTitle } from "@/components/keyline/ui/attachment"<Attachment state="uploading" progress={40}>
<AttachmentMedia><AttachmentGlyph name="file" /></AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>invoice-DM-4107.pdf</AttachmentTitle>
<AttachmentDescription>212 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove invoice-DM-4107.pdf"><AttachmentGlyph name="close" /></AttachmentAction>
</AttachmentActions>
</Attachment>Props
| Prop | Type | Default | Description |
|---|---|---|---|
state | "idle" | "uploading" | "processing" | "error" | "done" | Default "done" | Where the file stands. |
progress | number | Default — | 0–100 while uploading; on error, where it stopped. |
size | "default" | "compact" | "tile" | Default "default" | compact for a composer, tile for a thumbnail with the name under it. |
layout | "control" | "cell" | Default "control" | cell inside a Bubble, sharing its keylines. |
AttachmentAction | button | Default — | A cell with a glyph. Needs an aria-label that names the file. |
AttachmentTrigger | button | Default — | Makes the whole row open the file. |
AttachmentGlyph | { name: "file" | "image" | "close" | "retry" | "download" } | Default — | Keyline's glyphs for files and their actions. |
Attach the dock photo in the example: it fails part way the first time, so you can see the error and Retry.