Doc File
A file attachment showing its name, type and size with a download link and an icon and colour for its kind (PDF, document, spreadsheet, presentation, archive, image, audio, video, code), as in the mail attachments; empty, it takes a chosen or dropped file of any kind.
npx shadcn@latest add @velaui/doc-filepnpm dlx velaui add @velaui/doc-fileyarn dlx shadcn@latest add @velaui/doc-filebunx --bun shadcn@latest add @velaui/doc-fileveni add doc-fileInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/doc-filepnpm dlx velaui add @velaui/doc-fileyarn dlx shadcn@latest add @velaui/doc-filebunx --bun shadcn@latest add @velaui/doc-fileExamples
- Default
- Attached
- Empty read only
Usage
Props
| Prop | Type | Default |
|---|---|---|
className | string | — |
mime | string | — |
name | string | — |
onFileChange | (file: DocFileInfo) => void | — |
onUpload | (file: File) => string | Promise<string> | — |
readOnly | boolean | — |
size | number | 0 |
url | string | — |
Takes these and every prop of DocFileProps.
Dependencies
- npm
- none besides React
- Installs with it
- 10 icons
Source
components/ui/doc-file.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Code01 } from "@/components/icons/code-01";
import { FileAttach01 } from "@/components/icons/file-attach-01";
import { FileAttach02 } from "@/components/icons/file-attach-02";
import { FileAttach03 } from "@/components/icons/file-attach-03";
import { Image as ImageIcon } from "@/components/icons/image";
import { Package01 } from "@/components/icons/package-01";
import { Play01 } from "@/components/icons/play-01";
import { Presentation01 } from "@/components/icons/presentation-01";
import { Table } from "@/components/icons/table";
import { VideoOn } from "@/components/icons/video-on";
import { cn } from "@/lib/utils";
export interface DocFileInfo {
name: string;
/** Bytes. */
size: number;
/** MIME type; empty when the browser could not tell. */
mime: string;
/** Where the file can be fetched from. */
url: string;
}
/** `1.4 MB`, `812 KB`, `37 B`: decimal units, one decimal place from MB up. */
export function formatFileSize(bytes: number) {
if (bytes < 1000) return `${bytes} B`;
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} KB`;
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
}
/** The label for a file's type: its extension in capitals (`PDF`, `ZIP`), else what its MIME type says, else `FILE`. */
export function fileTypeLabel({ name, mime }: { name: string; mime?: string }) {
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1];
if (ext) return ext.toUpperCase();
const sub = mime?.split("/")[1]?.split(/[+;]/)[0];
// `text/plain` is a text file and `octet-stream` is just "some bytes": neither is worth printing as a type.
return !sub || sub === "octet-stream" ? "FILE" : sub === "plain" ? "TXT" : sub.toUpperCase();
}
export type DocFileKind =
"pdf" | "document" | "spreadsheet" | "presentation" | "archive" | "image" | "audio" | "video" | "code" | "other";
const EXTENSIONS: [DocFileKind, string][] = [
["pdf", "pdf"],
["document", "doc docx odt rtf txt md pages"],
["spreadsheet", "xls xlsx xlsm ods csv tsv numbers"],
["presentation", "ppt pptx odp key"],
["archive", "zip rar 7z tar gz tgz bz2 xz"],
["image", "png jpg jpeg gif webp avif svg bmp ico heic tiff"],
["audio", "mp3 wav ogg flac m4a aac"],
["video", "mp4 mov webm mkv avi m4v"],
["code", "js jsx ts tsx json py rb go rs java c cpp cs php html css scss sql sh yml yaml xml toml"],
];
/** What kind of file this is, from its extension, else its MIME type: it picks the icon and its colour. */
export function fileKind({ name, mime }: { name: string; mime?: string }): DocFileKind {
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1].toLowerCase() ?? "";
const byExtension = ext ? EXTENSIONS.find(([, list]) => list.split(" ").includes(ext)) : undefined;
if (byExtension) return byExtension[0];
const top = mime?.split("/")[0];
return top === "image" || top === "audio" || top === "video" ? top : "other";
}
/**
* The look of each kind, as the mail attachments have it: a tinted square with a coloured glyph. PDF and Word keep
* mail's red and blue; the other kinds take a palette hue of their own so a row of files reads at a glance.
*/
const KIND_STYLE: Record<
DocFileKind,
{ icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; field: string; glyph: string }
> = {
pdf: { icon: FileAttach01, field: "var(--color-blue-100)", glyph: "var(--color-negative-middle)" },
document: { icon: FileAttach02, field: "var(--color-blue-50)", glyph: "var(--color-blue-600)" },
spreadsheet: { icon: Table, field: "var(--color-green-100)", glyph: "var(--color-green-600)" },
presentation: { icon: Presentation01, field: "var(--color-orange-100)", glyph: "var(--color-orange-600)" },
archive: { icon: Package01, field: "var(--color-amber-100)", glyph: "var(--color-amber-600)" },
image: { icon: ImageIcon, field: "var(--color-purple-100)", glyph: "var(--color-purple-600)" },
audio: { icon: Play01, field: "var(--color-pink-100)", glyph: "var(--color-pink-600)" },
video: { icon: VideoOn, field: "var(--color-violet-100)", glyph: "var(--color-violet-600)" },
code: { icon: Code01, field: "var(--color-slate-100)", glyph: "var(--color-slate-600)" },
other: { icon: FileAttach03, field: "var(--color-blue-50)", glyph: "var(--color-blue-600)" },
};
/**
* Turns a chosen file into what the block shows. `upload` is the host's storage and returns the URL; without it the
* file is held by an object URL, which works until the page closes, so a host that keeps documents should pass one.
*/
export async function readDocFile({
file,
upload,
}: {
file: File;
upload?: (file: File) => string | Promise<string>;
}): Promise<DocFileInfo> {
return {
name: file.name,
size: file.size,
mime: file.type,
url: await (upload ? upload(file) : URL.createObjectURL(file)),
};
}
/** The file's icon: a tinted square with a glyph in the colour of its kind. */
function FileGlyph({ name, mime }: { name: string; mime?: string }) {
const { icon: Icon, field, glyph } = KIND_STYLE[fileKind({ name, mime })];
return (
<span
aria-hidden
className="flex size-10 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: field }}
>
<Icon className="size-5" style={{ color: glyph }} />
</span>
);
}
export interface DocFileProps extends Partial<DocFileInfo> {
readOnly?: boolean;
/** A file was chosen or dropped; its name, size, type and URL. */
onFileChange?: (file: DocFileInfo) => void;
/** The host's storage: gets the file, returns its URL. Without it the file lives in an object URL. */
onUpload?: (file: File) => string | Promise<string>;
className?: string;
}
/** A file attachment: its name, type and size with a download link; empty, it takes a chosen or dropped file of any kind. */
function DocFile({ name, size = 0, mime, url, readOnly, onFileChange, onUpload, className }: DocFileProps) {
const [over, setOver] = React.useState(false);
const [error, setError] = React.useState<string>();
const take = async (file: File | undefined) => {
if (!file) return;
try {
setError(undefined);
onFileChange?.(await readDocFile({ file, upload: onUpload }));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const picker = (label: string) => (
<label className="cursor-pointer rounded px-2 py-1 text-sm text-text-muted hover:bg-bg-surface-2 pointer-coarse:min-h-11 pointer-coarse:content-center">
{label}
<input type="file" className="sr-only" onChange={e => (void take(e.target.files?.[0]), (e.target.value = ""))} />
</label>
);
return (
<div className={cn("my-2", className)}>
{name ? (
<div className="flex items-center gap-3 rounded-md border border-border-default px-3 py-2">
<FileGlyph name={name} mime={mime} />
<div className="min-w-0 flex-1">
{url ? (
<a href={url} download={name} className="block truncate text-sm font-medium hover:underline">
{name}
</a>
) : (
<span className="block truncate text-sm font-medium">{name}</span>
)}
<span className="text-xs text-text-muted">
{fileTypeLabel({ name, mime })} · {formatFileSize(size)}
</span>
</div>
{!readOnly && picker("Replace")}
</div>
) : readOnly ? (
<div className="flex h-16 items-center justify-center rounded-md border-2 border-dashed border-border-default text-sm text-text-muted">
No file attached
</div>
) : (
<div
className={cn(
"flex flex-wrap items-center gap-2 rounded-md border-2 border-dashed p-3 text-sm text-text-muted",
over ? "border-primary-default bg-bg-surface-2" : "border-border-default",
)}
onDragOver={e => (e.preventDefault(), setOver(true))}
onDragLeave={() => setOver(false)}
onDrop={e => {
e.preventDefault();
e.stopPropagation();
setOver(false);
void take(e.dataTransfer.files[0]);
}}
>
Drop a file here, or
{picker("choose a file")}
</div>
)}
{error && (
<p role="alert" className="mt-1 text-sm text-negative-middle">
{error}
</p>
)}
</div>
);
}
export { DocFile };