AttachmentRow
One attached file — a preview, the name, a line of meta, the stage its upload is at, and the actions that act on it.
import { AttachmentRow, AttachmentState } from "@glint/components/attachment-row.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 220px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
AttachmentRow { icon: IconSet.FileText; name: "release-notes.md"; meta: "Markdown · 12 KB"; removable: true; remove-label: "Remove release-notes.md"; }
AttachmentRow { icon: IconSet.Image; name: "screenshot.png"; meta: "Uploading — 40%"; state: AttachmentState.uploading; progress: 0.4; } }}Usage
import { AttachmentRow, AttachmentState } from "@glint/components/attachment-row.slint";import { IconSet } from "@lucide";
export component AppWindow inherits Window { in property <float> sent;
VerticalLayout { AttachmentRow { icon: IconSet.FileText; name: "report.pdf"; meta: "PDF · 240 KB"; state: AttachmentState.uploading; progress: root.sent; removable: true; // An icon-only control is a nameless button without this. remove-label: "Remove report.pdf"; removed => { /* drop it from your model */ } } }}The row is standalone-renderable. It carries its own surface and reads nothing from its container, so it looks the same inside a message bubble, in a composer, or on a plain page. That surface is built from the alpha-based overlay tokens, which composite over whatever background the container happens to have instead of fighting it with an opaque fill.
state is the upload’s lifecycle and every value has a treatment of its own —
and none of them is only a color. A working row carries a
Progress the accessibility tree can read; a
failed one wears the destructive edge and says why on its meta line,
because a color is a second channel and never the only one.
The row publishes what a call site would otherwise have to restate:
media-size for a gutter to line up against, working for the two states
that are a transfer, and edge-color, surface-color and meta-color for a
slotted action that wants to match the row it is sitting on.
Examples
Upload states
idle is the empty slot before anything was sent. uploading shows how far
the transfer has got, processing has no number to give and spins instead,
error says why in words and wears the destructive edge, and done — the
default — is a file that is simply there.
import { AttachmentRow, AttachmentState } from "@glint/components/attachment-row.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 380px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 10px; alignment: center;
AttachmentRow { icon: IconSet.Paperclip; name: "Attach a file"; meta: "Nothing sent yet"; state: AttachmentState.idle; } AttachmentRow { icon: IconSet.Image; name: "diagram.png"; meta: "Uploading — 65%"; state: AttachmentState.uploading; progress: 0.65; } AttachmentRow { icon: IconSet.FileText; name: "transcript.txt"; meta: "Extracting the text"; state: AttachmentState.processing; } AttachmentRow { icon: IconSet.FileText; name: "budget.xlsx"; // The reason is on the meta line, never in the edge alone. meta: "Upload failed — the file is larger than 25 MB"; state: AttachmentState.error; } AttachmentRow { icon: IconSet.FileText; name: "notes.md"; meta: "Markdown · 4 KB"; state: AttachmentState.done; } }}Sizes
size compresses the media square, the padding and both type sizes together.
xs is the inline chip a turn mentions a file with, sm the dense composer
strip, default the row on its own.
import { AttachmentRow, AttachmentSize } from "@glint/components/attachment-row.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 260px; background: Tokens.color-background;
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
AttachmentRow { size: AttachmentSize.xs; icon: IconSet.FileText; name: "notes.md"; } AttachmentRow { size: AttachmentSize.sm; icon: IconSet.FileText; name: "notes.md"; meta: "4 KB"; } AttachmentRow { size: AttachmentSize.default; icon: IconSet.FileText; name: "notes.md"; meta: "Markdown · 4 KB"; } }}The card
orientation: vertical puts a large preview above the file name — the only
shape an image attachment reads in, since a 32px square can show a thumbnail
but never a legible preview. preview-height is how tall that preview is.
The axis stays a property rather than becoming a second component because the actions are placed rather than laid out (ADR-0035): the slot rides the trailing corner of whichever shape the row is — the trailing edge of a row, the top corner of a card — so nothing conditional ever contains it.
import { AttachmentOrientation, AttachmentRow } from "@glint/components/attachment-row.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 300px; background: Tokens.color-background;
HorizontalLayout { padding: 24px; spacing: 16px; alignment: center;
VerticalLayout { alignment: center; AttachmentRow { width: 190px; orientation: AttachmentOrientation.horizontal; icon: IconSet.Image; name: "cover.png"; meta: "PNG · 1.2 MB"; removable: true; remove-label: "Remove cover.png"; } }
VerticalLayout { alignment: center; AttachmentRow { width: 190px; orientation: AttachmentOrientation.vertical; preview-height: 110px; icon: IconSet.Image; name: "cover.png"; meta: "PNG · 1.2 MB"; removable: true; remove-label: "Remove cover.png"; } } }}Actions
The one @children is the actions: any number of them, sitting before the
remove control, which stays the trailing one — so the destructive action is
always in the same place. removable is what draws it, and remove-label is
what assistive technology addresses it by, because an icon-only control is a
nameless button without one.
import { AttachmentRow, AttachmentState } from "@glint/components/attachment-row.slint";import { Button, ButtonSize, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 240px; background: Tokens.color-background;
in-out property <string> last: "Nothing taken yet.";
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
AttachmentRow { icon: IconSet.FileText; name: "backup.tar.gz"; meta: "Upload failed — the connection dropped"; state: AttachmentState.error; removable: true; remove-label: "Remove backup.tar.gz"; removed => { root.last = "Removed backup.tar.gz."; }
Button { variant: ButtonVariant.ghost; size: ButtonSize.icon-sm; accessible-label: "Retry the upload"; leading-icon: IconSet.RotateCw; clicked => { root.last = "Retrying the upload."; } } }
Text { text: root.last; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}A row that opens the file
interactive makes the whole row a control — opening or previewing the file.
Its control layer sits under everything, so a press the actions did not want
falls through to it and one they did never reaches it: the trigger and the
actions cannot trap each other. action-label is what the row is announced
as, defaulting to name.
import { AttachmentRow } from "@glint/components/attachment-row.slint";import { Button, ButtonSize, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 200px; background: Tokens.color-background;
in-out property <string> last: "Nothing taken yet.";
VerticalLayout { padding: 24px; spacing: 12px; alignment: center;
AttachmentRow { icon: IconSet.FileText; name: "spec.pdf"; meta: "PDF · 88 KB"; interactive: true; action-label: "Open spec.pdf"; clicked => { root.last = "Opened spec.pdf."; }
Button { variant: ButtonVariant.ghost; size: ButtonSize.icon-sm; accessible-label: "Download spec.pdf"; leading-icon: IconSet.Download; clicked => { root.last = "Downloaded spec.pdf — the row was not opened."; } } }
Text { text: root.last; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}AttachmentGroup — the composer strip
Several attachments sit in one named group that scrolls sideways: the
composer’s attachment tray. It is a group rather than a list because its items
are files you put there, not rows of a model it owns — so it takes them
through @children, and label is the name everything in it is announced
under.
The scrolling is the library’s one surface, so an attachment past the edge is
reachable from the keyboard for free. content-takes-focus is the one thing
the strip cannot answer for itself: Slint’s tab walk is pre-order, so the
strip’s own stop lands in front of whatever its rows own. Leave it false and
the strip takes the stop and the arrows scroll it, which is right for inert
rows; set it true when the rows are live and the extra stop in front of them
is not worth its cost.
import { AttachmentGroup, AttachmentRow, AttachmentSize } from "@glint/components/attachment-row.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 460px; height: 200px; background: Tokens.color-background;
VerticalLayout { padding: 24px; alignment: center;
AttachmentGroup { label: "Attachments on this message";
AttachmentRow { width: 180px; size: AttachmentSize.sm; icon: IconSet.FileText; name: "notes.md"; meta: "4 KB"; } AttachmentRow { width: 180px; size: AttachmentSize.sm; icon: IconSet.Image; name: "diagram.png"; meta: "1.2 MB"; } AttachmentRow { width: 180px; size: AttachmentSize.sm; icon: IconSet.FileText; name: "budget.xlsx"; meta: "36 KB"; } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
name | in string | no default | File name, shown on the first line; elides when the row is too narrow. |
meta | in string | no default | Secondary line — size, type, and the reason an upload failed. A failed upload has to say why in words: the destructive edge is a second channel, never the only one. |
icon | in LucideIcon | IconSet.Paperclip | Leading icon, normally selected from IconSet in lucide-slint. Ignored while thumbnail holds an image. |
thumbnail | in image | no default | Preview image; when set it replaces the icon in the media square, and fills the preview of a vertical card. |
removable | in bool | false | Show the trailing remove action. |
remove-label | in string | @tr("Remove") | Name assistive technology announces for the remove action. The control is icon-only, so without a name it reads as an anonymous button — override it to translate it, or to say what is being removed. |
state | in AttachmentState | AttachmentState.done | Where the upload stands. |
progress | in float | 0.0 | How far a transfer has got, 0..1. Read only while state is uploading; processing has no number to show and spins instead. |
size | in AttachmentSize | AttachmentSize.default | Density. |
orientation | in AttachmentOrientation | AttachmentOrientation.horizontal | Row or card. |
preview-height | in length | 120px | How tall a card's preview is. Ignored by the horizontal row, whose media is the media-size square. |
interactive | in bool | false | Makes the whole row activatable — opening or previewing the file. The actions keep their own presses; the row's layer sits behind them. |
action-label | in string | root.name | What assistive technology announces the row as while it is a control. |
media-size | out length | no default | The leading square's side, published because a call site laying several rows out against a gutter needs the same number. |
working | out bool | no default | Whether a transfer is under way — what puts a Progress on the row. |
edge-color | out color | no default | The row's edge, for slotted actions that want to match it. |
surface-color | out color | no default | The surface behind the row. An idle attachment is the emptier slot. |
meta-color | out color | no default | The meta line's colour — destructive while the reason it carries is a failure. |
Callbacks
| Callback | Description |
|---|---|
removed() | Fired when the remove action is activated by pointer or keyboard. |
clicked() | Fired when an interactive row is activated by pointer, by Enter or Space, or through the accessible default action. |
Enums
| Enum | Values |
|---|---|
AttachmentState | idle, uploading, processing, error, done |
AttachmentSize | xs, sm, default |
AttachmentOrientation | horizontal, vertical |
AttachmentGroup
The strip several attachments sit in — a labeled group that scrolls its rows sideways.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
label | in string | @tr("Attachments") | What the group is called. Everything in it is announced under this name, so a composer with two trays can tell them apart. |
spacing | in length | Tokens.spacing-sm | Gap between two attachments. |
content-takes-focus | in bool | false | Whether the rows slotted in here answer the keyboard themselves — an interactive AttachmentRow does, and so does one carrying actions; a strip of plain done rows does not. The strip cannot answer this for itself: what it holds is the consumer's, and Slint's tab walk is pre-order, so the scrolling surface's own stop would land *in front of* whatever the rows own. Left false the strip takes the stop and the arrows scroll it, which is right for inert rows and costs one extra Tab in front of live ones. |
Accessibility
- The remove control is named. It is icon-only, so
remove-labelis what a screen reader addresses it by; it is also yours to translate, and to say what is being removed where a strip holds several files. - No affordance, no tab stop. A row without
removableoffers nothing to click where the control would sit and nothing for Tab to land on, and a row that is notinteractiveis not announced as a control at all. - An interactive row is a button named by
action-label, activated by the pointer, by Enter, by Space and by the accessible default action — one path, so they cannot drift apart. - The trigger never traps the actions. The row’s control layer sits under the slotted actions and the remove control, so each keeps its own press and its own place in the tab order.
- A failed upload says why. The destructive edge is the second channel;
the reason belongs on
meta, where assistive technology finds it. - A transfer is readable, not merely visible.
uploadingandprocessingput a realProgresson the row rather than animating the title, so how far it has got is in the accessibility tree. - The group is a
groupboxnamed bylabel, so a composer with two trays can tell them apart. - The strip is a stop in the reading order for as long as it has somewhere to scroll (ADR-0032): End walks it to an attachment past the edge without a pointer.
- An attachment past the edge is still reachable. The only thing hiding it
is the scrolling surface’s own
Flickable, so it keeps its place in the tab order — and Tabing onto a control inside it scrolls the strip to it, because the reveal happens in the window that moved the focus rather than in the group (ADR-0038).