COMPONENTS/INPUTS
Attachment.
Choose files, check the details.
In practice
Local file selection with type and size checks, removal and onValueChange(File[]). Default limit 10 MB per file. No upload transport; validate files again on your server.
Installation
Install Coal once. Components and TypeScript declarations are included. React 19 and React DOM 19 are peer dependencies.
Terminal · local package
npm install http://localhost:3100/downloads/chlohal-coal-ui-0.5.0.tgzThis installs the local package. It is not yet published on npm. See installation for stylesheet setup and integration.
Usage
example.tsx
"use client";
import * as React from "react";
import "@chlohal/coal-ui/styles.css";
import { Attachment } from "@chlohal/coal-ui";
export default function Example() {
return (<Attachment label="Project attachments" accept=".txt,.pdf,image/*" multiple maxSize={5 * 1024 * 1024}/>);
}
Source
The original Coal implementation. Use the package export to integrate it.
packages/react/src/attachment.tsx
"use client";
import * as React from "react";
import { cn, useValue } from "./internal.js";
export type AttachmentProps = {
label?: string;
accept?: string;
multiple?: boolean;
maxSize?: number;
disabled?: boolean;
value?: File[];
onValueChange?: (files: File[]) => void;
className?: string;
};
function accepts(file: File, accept?: string) {
return (
!accept ||
accept.split(",").some((rule) => {
const r = rule.trim().toLowerCase();
return r.startsWith(".")
? file.name.toLowerCase().endsWith(r)
: r.endsWith("/*")
? file.type.toLowerCase().startsWith(r.slice(0, -1))
: file.type.toLowerCase() === r;
})
);
}
export function Attachment({
label = "Attach files",
accept,
multiple = false,
maxSize = 10 * 1024 * 1024,
disabled,
value,
onValueChange,
className,
}: AttachmentProps) {
const [files, set] = useValue(value, [], onValueChange);
const [error, setError] = React.useState("");
const id = React.useId();
return (
<div className={cn("coal-attachment", className)}>
<label htmlFor={id}>{label}</label>
<input
id={id}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
aria-describedby={`${id}-hint ${id}-error`}
onChange={(e) => {
const chosen = Array.from(e.target.files ?? []);
const invalid = chosen.filter(
(f) => f.size > maxSize || !accepts(f, accept),
);
setError(
invalid.length
? `Rejected: ${invalid.map((f) => f.name).join(", ")}. Check file type and size.`
: "",
);
set(chosen.filter((f) => !invalid.includes(f)));
e.target.value = "";
}}
/>
<p id={`${id}-hint`}>
Up to {Math.round((maxSize / 1024 / 1024) * 10) / 10} MB per file
{accept ? ` · ${accept}` : ""}
</p>
<p id={`${id}-error`} role="status">
{error}
</p>
<ul>
{files.map((file, i) => (
<li key={`${file.name}-${i}`}>
<span>{file.name}</span>
<button
type="button"
disabled={disabled}
aria-label={`Remove ${file.name}`}
onClick={() => set(files.filter((_, j) => i !== j))}
>
×
</button>
</li>
))}
</ul>
</div>
);
}
Accessibility checklist
- Provide an accessible name for controls, including icon-only buttons.
- Check keyboard navigation, visible focus and disabled states in your application.
- Do not rely on color alone to communicate status.