Attachments
The files staged alongside a message: <r-attachments> holds the list, previews it, validates
what arrives, and owns the object URLs it creates.
Use when a composer needs to show what is about to be sent. It does not collect files: paste, drag-and-drop and a file picker are three separate gestures, each belonging to a different element of a composer, and which of them your app offers is your decision. Call
add()from whichever you wire.
Quick Start
Basic Usage
<r-attachments accept="image/*,.pdf" max-size="5242880" max-count="4"></r-attachments>const strip = document.createElement('r-attachments');
// A file picker
picker.addEventListener('change', () => strip.add(picker.files));
// Paste — only when the clipboard actually carries files. Intercepting every paste
// breaks pasting text, which is what the box is mostly for.
input.addEventListener('paste', (event) => {
if (event.clipboardData?.files.length) {
event.preventDefault();
strip.add(event.clipboardData.files);
}
});
// Drag and drop
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
strip.add(event.dataTransfer.files);
});
composer.append(strip);The strip renders one row per file with a thumbnail (images), the name, the size and a remove
button. count is reflected onto the host, and removed when the strip is empty rather
than set to 0, so an empty strip can take no space:
r-attachments:not([count]) {
display: none;
}Sending
const body = new FormData();
for (const file of strip.files) body.append('files', file);
await fetch('/api/messages', { method: 'POST', body });
strip.clear();files is just the File objects, in order: the shape a request body wants. attachments
is the richer list (id, name, size, type, previewUrl) when you need to render your
own view of the same state.
Rejection is reported, never silent
A file that vanishes because it was 3 MB over a limit nobody mentioned reads as a bug in the page. Every refusal fires an event with the file and the rule it broke:
const explain = {
'too-large': 'That file is larger than 5 MB.',
'type-not-accepted': "That file type isn't accepted here.",
'too-many': 'You can attach at most 4 files.',
duplicate: 'That file is already attached.',
};
strip.addEventListener('attachmentrejected', (event) => {
toast(explain[event.detail.reason]);
});duplicate compares name, size and modification time together, matching what a file manager
treats as the same file. Attaching the same file twice is a slip, not an instruction.
API Reference
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
accept |
accept |
string |
'' |
Comma-separated types or extensions, in the form <input accept> takes. |
maxSize |
max-size |
number |
10 MB |
Largest file accepted, in bytes. |
maxCount |
max-count |
number |
— | Most files that may be staged at once; unlimited when unset. |
attachments |
— | readonly Attachment[] |
[] |
The staged files, in the order they arrived. |
files |
— | File[] |
[] |
Just the files, for building a request body. |
sheet |
sheet |
string |
'' |
CSS injected into the shadow root. |
attachments and files are read-only views. Stage files through add().
Methods
| Method | Returns | Description |
|---|---|---|
add(files) |
Attachment[] |
Stages an iterable of Files; returns the ones that were accepted. |
detach(id) |
boolean |
Removes one attachment by id; false if there was no such id. |
clear() |
void |
Removes everything and revokes its object URLs. |
Events
| Event | Detail | Dispatch | Description |
|---|---|---|---|
attachmentschange |
{ attachments } |
bubbles, composed | The staged list changed. |
attachmentrejected |
{ file, reason } |
bubbles, composed | A file was refused. reason is one of too-large, type-not-accepted, too-many, duplicate. |
Types
interface Attachment {
id: string; // stable for this attachment's life
file: File;
name: string;
size: number;
type: string;
previewUrl: string | null; // object URL for images, null otherwise
}
type AttachmentRejection = 'too-large' | 'type-not-accepted' | 'too-many' | 'duplicate';Parts
list · attachment · thumb · icon · name · size · remove
How previews work
Previews are object URLs, not data URLs. Previewing costs a reference to bytes the browser already holds; reading a 10 MB photo into a base64 string to show a 40px thumbnail costs the string. Build the data URL later, once, in whatever sends.
Every URL the element creates it also revokes, on detach, on clear, and on disconnect. Don't
hold previewUrl past the attachment's life.
Accessibility
A thumbnail's alt text is the file name, not "image": four attachments all announced as "image" have told the reader nothing about which is which. Each remove button is named after its file for the same reason.
Styling
<r-attachments> exposes 17 CSS custom properties of its own, plus the semantic tokens it reads
from the theme. Set one anywhere it inherits from, such as :root, a wrapper, or the element:
r-attachments {
--ran-attachment-background: var(--ran-color-bg-subtle);
}Parts: attachment · icon · list · name · remove · size · thumb
The full list is in style tokens; which token to reach for is the design system.
Best Practices
- Validate on the server too.
acceptandmax-sizeare a courtesy to the person attaching, not a security boundary. - Clear after a successful send, not before. A failed request should leave the files staged so they can be retried.
- Explain every rejection. The event exists so the strip never silently drops a file.