FileList
Files as a table or a grid, with kind icons, sizes and dates, sorted, picked, downloaded or deleted.
Demo
The archive is empty.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/file_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/file_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/file_list.qwe
let file_list_demo_files = [ { id: 1, name: "Q3 report.pdf", size: 2483112, modified: "2026-10-02T09:14:00", href: "#file_list" }, { id: 2, name: "Budget 2027.xlsx", size: 84211, modified: "2026-10-21T16:40:00", href: "#file_list" }, { id: 3, name: "Team offsite.jpg", size: 3912004, modified: "2026-09-18T12:00:00", href: "#file_list" }, { id: 4, name: "Contract draft.docx", size: 41250, modified: "2026-10-25T08:05:00", href: "#file_list" }, { id: 5, name: "Brand assets.zip", size: 18440230, modified: "2026-08-30T17:22:00", href: "#file_list" }, { id: 6, name: "notes.md", size: 812, modified: "2026-10-27T21:10:00", href: "#file_list" },]component FileListDemoLive() { state docs = file_list_demo_files state picked = [] <div.demo-stack> <FileList id="fl-live" label="Shared with me" files={docs} layout="grid" selectable={true} order_by="modified" descending={true} on_select={(fs) => { picked = fs | map((f) => f.name) }} on_delete={(f) => { docs = docs | filter((d) => d.id != f.id) }}/> <p.demo-note>{len(picked) == 0 ? "Nothing picked." : `Picked: ${join(picked, ", ")}.`}</p> </div>}view FileListDemo() = <div.demo-stack> <FileList id="fl-docs" label="Project files" files={file_list_demo_files} selectable={true} delete_action="/files/delete"/> <FileListDemoLive/> <FileList id="fl-none" label="Archive" files={[]} empty="The archive is empty."/> </div>Source
ui/file_list.qwe
// FileList: files as a table or a grid of tiles, each with an icon for its kind, its// size and when it was changed; sorted by name, size or date; picked with checkboxes;// downloaded or deleted.//// <FileList label="Contracts" selectable={true} delete_action="/contracts/delete" files={[// { id: 7, name: "Lease 2026.pdf", size: 482113, modified: "2026-09-14T10:02:00", href: "/files/7" },// { id: 8, name: "Budget.xlsx", size: 20340, modified: "2026-10-01", href: "/files/8" },// ]}/>// <FileList files={docs} layout="grid" on_delete={(f) => { docs = docs | filter((d) => d.id != f.id) }}/>//// files: a list of { name, size (bytes), modified (an ISO date), href?, id? }: href is// where it downloads from; id names it (its name when there is none).// layout: "list" (default, a table) or "grid"; buttons above it switch, as a select// and an arrow switch the order. order_by: "name" (default), "size" or "modified";// descending: the other way. label: the list's name ("Files"). empty: what it says// with no files ("No files yet."). id: the start of its parts' ids ("file-list").// selectable: checkboxes, named `name` ("files") with each file's id, so a form// around the list sends those picked; on_select(files), inside one of your// components, is called with the files picked.// Deleting: delete_action makes the list a <form method="post"> to that address,// and each file's Delete button sends `delete` = its id. Inside one of your// components, on_delete(file) is called instead (and "Delete picked" for those picked).import ui/theme.qwe use ui_clslet file_list_css = css`.ui-fl { display: grid; gap: 10px; min-width: 0; margin: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-fl-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }.ui-fl-sum { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-fl-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }.ui-fl-sel { height: 32px; padding: 0 28px 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface) no-repeat right 9px center / 10px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23888' stroke-width='1.5'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); color: var(--ui-fg); font: 13px/1 var(--ui-font); appearance: none;}.ui-fl-tool { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-muted); cursor: pointer;}.ui-fl-sel:focus-visible, .ui-fl-tool:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fl-tool:hover { border-color: var(--ui-strong); color: var(--ui-fg); }.ui-fl-tool[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-fl-tool svg { width: 16px; height: 16px; }.ui-fl-pair { display: inline-flex; }.ui-fl-pair .ui-fl-tool:first-child { border-radius: var(--ui-radius-sm) 0 0 var(--ui-radius-sm); }.ui-fl-pair .ui-fl-tool:last-child { margin-left: -1px; border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; }.ui-fl-scroll { overflow-x: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-fl table { width: 100%; border-collapse: collapse; }.ui-fl th, .ui-fl td { padding: 8px 12px; border-bottom: 1px solid var(--ui-border); text-align: left; white-space: nowrap; }.ui-fl tbody tr:last-child td { border-bottom: 0; }.ui-fl th { background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-fl tbody tr:hover td { background: color-mix(in srgb, var(--ui-subtle) 60%, transparent); }.ui-fl tr.ui-fl-picked td { background: color-mix(in srgb, var(--ui-accent-soft) 70%, transparent); }.ui-fl .ui-fl-num { text-align: right; font-variant-numeric: tabular-nums; color: var(--ui-muted); }.ui-fl .ui-fl-check { width: 36px; padding-right: 0; }.ui-fl input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--ui-accent); vertical-align: middle; }.ui-fl-sort { display: inline-flex; align-items: center; gap: 4px; margin: -4px -6px; padding: 4px 6px; border: 0; border-radius: 4px; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }.ui-fl-sort:hover, [aria-sort] .ui-fl-sort { color: var(--ui-fg); }.ui-fl-sort:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fl-sort svg { width: 11px; height: 11px; }.ui-fl-num .ui-fl-sort { flex-direction: row-reverse; }.ui-fl-name { display: flex; align-items: center; gap: 10px; min-width: 0; }.ui-fl td.ui-fl-main { width: 100%; max-width: 0; }.ui-fl-name a, .ui-fl-name span:last-child { overflow: hidden; text-overflow: ellipsis; color: var(--ui-fg); font-weight: 500; text-decoration: none; }.ui-fl-name a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-fl-name a:focus-visible, .ui-fl-act:focus-visible { outline: none; border-radius: 4px; box-shadow: var(--ui-focus); }.ui-fl-icon { position: relative; flex: none; display: grid; place-items: end center; width: 26px; height: 32px; padding-bottom: 4px; border-radius: 3px 9px 3px 3px; background: color-mix(in srgb, var(--k) 16%, transparent); color: var(--k); font: 700 7.5px/1 var(--ui-font); letter-spacing: .02em; --k: var(--ui-muted);}.ui-fl-icon::before { content: ""; position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 0 0 0 3px; background: color-mix(in srgb, var(--k) 30%, transparent); }.ui-fl-k-image { --k: var(--ui-success); } .ui-fl-k-pdf { --k: var(--ui-danger); } .ui-fl-k-doc { --k: var(--ui-info); }.ui-fl-k-sheet { --k: var(--ui-success); } .ui-fl-k-slides, .ui-fl-k-archive { --k: var(--ui-warning); }.ui-fl-k-audio, .ui-fl-k-video, .ui-fl-k-code { --k: var(--ui-accent); }.ui-fl-acts { display: flex; justify-content: flex-end; gap: 2px; }.ui-fl-act { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-fl-act:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-fl-del:hover { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-fl-act svg { width: 16px; height: 16px; }.ui-fl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(130px, 100%), 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }.ui-fl-tile { position: relative; display: grid; justify-items: center; gap: 6px; padding: 18px 10px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); text-align: center;}.ui-fl-tile:hover { border-color: var(--ui-strong); }.ui-fl-tile.ui-fl-picked { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-fl-tile .ui-fl-icon { width: 44px; height: 54px; padding-bottom: 7px; font-size: 10px; border-radius: 4px 14px 4px 4px; }.ui-fl-tile .ui-fl-icon::before { width: 14px; height: 14px; }.ui-fl-tile > input { position: absolute; top: 8px; left: 8px; }.ui-fl-tile .ui-fl-name { justify-content: center; max-width: 100%; }.ui-fl-tile .ui-fl-name a, .ui-fl-tile .ui-fl-name span:last-child { white-space: nowrap; }.ui-fl-meta { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-fl-empty { padding: 28px 16px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); color: var(--ui-muted); text-align: center; }.ui-fl-bulk { height: 30px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-danger); font: 500 13px/1 var(--ui-font); cursor: pointer;}.ui-fl-bulk:hover { border-color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-fl-bulk:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (max-width: 560px) { .ui-fl .ui-fl-date { display: none; } .ui-fl th, .ui-fl td { padding-left: 8px; padding-right: 8px; } }`let file_list_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// A size in bytes as people read it: 812 B, 14.2 KB, 3 MB. Worked out in whole// numbers, so the server and the browser write the same text.fn file_list_size(n: num) -> str { if n < 1024 { return `${n} B` } let unit = n < 1024 * 1024 ? 1024 : (n < 1024 * 1024 * 1024 ? 1024 * 1024 : 1024 * 1024 * 1024) let tenths = round(n * 10 / unit) let whole = floor(tenths / 10) `${whole}${tenths % 10 == 0 ? "" : `.${tenths % 10}`} ${unit == 1024 ? "KB" : (unit == 1024 * 1024 ? "MB" : "GB")}`}// An ISO date ("2026-03-14", or with a time) as Mar 14, 2026.fn file_list_date(v) -> str { if v == null { return "—" } let p = split(slice(str(v), 0, 10), "-") let month = len(p) == 3 ? file_list_months[(int(p[1]) ?? 1) - 1] : null month != null ? `${month} ${int(p[2]) ?? p[2]}, ${p[0]}` : str(v)}// A file's extension, in lower case ("" when it has none).fn file_list_ext(name: str) -> str { let parts = split(name, ".") len(parts) > 1 ? lower(last(parts)) : ""}// What kind of file a name is: image, pdf, doc, sheet, slides, archive, audio, video, code or file.fn file_list_kind(name: str) -> str = match file_list_ext(name) { "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "heic" => "image" "pdf" => "pdf" "doc" | "docx" | "odt" | "rtf" | "txt" | "md" | "pages" => "doc" "xls" | "xlsx" | "ods" | "csv" | "tsv" | "numbers" => "sheet" "ppt" | "pptx" | "odp" | "key" => "slides" "zip" | "rar" | "7z" | "gz" | "tar" | "tgz" => "archive" "mp3" | "wav" | "ogg" | "flac" | "m4a" => "audio" "mp4" | "mov" | "webm" | "mkv" | "avi" => "video" "js" | "ts" | "py" | "rs" | "go" | "json" | "html" | "css" | "qwe" | "sql" => "code" _ => "file" }// What identifies a file: its id, else its name.fn file_list_key(f: map) -> str = str(get(f, "id") ?? f.name)// The files in order.fn file_list_sorted(files: list, by: str, desc: bool) -> list { let ordered = files | sort_by((f) => match by { "size" => f.size ?? 0 "modified" => str(get(f, "modified") ?? "") _ => lower(f.name) }) desc ? reverse(ordered) : ordered}// The icon of a file: its kind's colour, and its extension.fn file_list_icon(name: str) = <span class={`ui-fl-icon ui-fl-k-${file_list_kind(name)}`} aria-hidden="true">{upper(slice(file_list_ext(name), 0, 4))}</span>// The arrow of a sortable column: both ways when it is not the one sorted by.fn file_list_arrow(on: bool, desc: bool) = <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style={on ? null : "opacity: .35"}> <path d={!on ? "M3.5 4.5L6 2l2.5 2.5M3.5 7.5L6 10l2.5-2.5" : (desc ? "M6 2v8M3 7l3 3 3-3" : "M6 10V2M3 5l3-3 3 3")}/> </svg>// A file's Download link and Delete button.fn file_list_actions(f: map, deletable: bool, on_delete) = <span.ui-fl-acts> {get(f, "href") != null && <a.ui-fl-act href={f.href} download={f.name} aria-label={`Download ${f.name}`} title="Download"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5v8M4.5 7L8 10.5 11.5 7M3 13.5h10"/></svg> </a>} {deletable && (on_delete != null ? <button.ui-fl-act.ui-fl-del type="button" aria-label={`Delete ${f.name}`} title="Delete" @click={() => { on_delete(f) }}>{file_list_bin()}</button> : <button.ui-fl-act.ui-fl-del type="submit" name="delete" value={file_list_key(f)} aria-label={`Delete ${f.name}`} title="Delete">{file_list_bin()}</button>)} </span>fn file_list_bin() = <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11M6 4V2.5h4V4M4 4l.7 9.5h6.6L12 4M6.8 6.5v4.5M9.2 6.5v4.5"/></svg>component FileList(files: list = [], label: str = "Files", layout: str = "list", order_by: str = "name", descending: bool = false, selectable: bool = false, name: str = "files", delete_action: str? = null, empty: str = "No files yet.", id: str = "file-list", on_delete = null, on_select = null, class: str? = null) { style global file_list_css state shape = layout state by = order_by state desc = descending state picked = [] state all_box = null let shown = file_list_sorted(files, by, desc) let keys = files | map((f) => file_list_key(f)) let live = picked | filter((k) => contains(keys, k)) let some = len(live) > 0 let every_one = some && len(live) == len(files) if all_box != null { all_box.indeterminate = some && !every_one } let deletable = on_delete != null || delete_action != null let pick = (k: str, on: bool) => { picked = on ? unique(live + [k]) : live | filter((x) => x != k) on_select && on_select(files | filter((f) => contains(picked, file_list_key(f)))) } let pick_all = (on: bool) => { picked = on ? keys : [] on_select && on_select(on ? files : []) } let sort_on = (k: str) => { if by == k { desc = !desc } else { by = k; desc = k != "name" } } let total = sum(files | map((f) => f.size ?? 0)) let body = <div class={ui_cls(["ui-fl", class])} id={id}> <div.ui-fl-bar> <span.ui-fl-sum aria-live="polite"> {some ? `${len(live)} of ${len(files)} picked` : `${len(files)} ${len(files) == 1 ? "file" : "files"} · ${file_list_size(total)}`} </span> {len(files) > 0 ? <div.ui-fl-tools> {some && on_delete != null && <button.ui-fl-bulk type="button" @click={() => { for f in files | filter((f) => contains(live, file_list_key(f))) => on_delete(f) picked = [] }}>Delete picked</button>} <select.ui-fl-sel aria-label="Sort by" @change={(e) => { by = e.value }}> <option value="name" selected={by == "name"}>Name</option> <option value="size" selected={by == "size"}>Size</option> <option value="modified" selected={by == "modified"}>Modified</option> </select> <button.ui-fl-tool type="button" aria-label={desc ? "Descending: switch to ascending" : "Ascending: switch to descending"} @click={() => { desc = !desc }}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={desc ? "M5 3v10M2 10l3 3 3-3M10 4h4M10 8h3M10 12h2" : "M5 13V3M2 6l3-3 3 3M10 4h2M10 8h3M10 12h4"}/></svg> </button> <span.ui-fl-pair role="group" aria-label="Show as"> <button.ui-fl-tool type="button" aria-label="List" aria-pressed={shape == "list" ? "true" : "false"} @click={() => { shape = "list" }}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M2 4h12M2 8h12M2 12h12"/></svg> </button> <button.ui-fl-tool type="button" aria-label="Grid" aria-pressed={shape == "grid" ? "true" : "false"} @click={() => { shape = "grid" }}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg> </button> </span> </div> : null} </div> {len(files) == 0 ? <div.ui-fl-empty>{empty}</div> : shape == "grid" ? <ul.ui-fl-grid aria-label={label}> {for f in shown => <li key={file_list_key(f)} class={ui_cls(["ui-fl-tile", contains(live, file_list_key(f)) && "ui-fl-picked"])}> {selectable && <input type="checkbox" name={name} value={file_list_key(f)} aria-label={`Pick ${f.name}`} checked={contains(live, file_list_key(f))} @change={(e) => { pick(file_list_key(f), e.checked) }}/>} {file_list_icon(f.name)} <span.ui-fl-name>{get(f, "href") != null ? <a href={f.href} title={f.name}>{f.name}</a> : <span title={f.name}>{f.name}</span>}</span> <span.ui-fl-meta>{file_list_size(f.size ?? 0)} · {file_list_date(get(f, "modified"))}</span> {file_list_actions(f, deletable, on_delete)} </li>} </ul> : <div.ui-fl-scroll> <table aria-label={label}> <thead> <tr> {selectable && <th.ui-fl-check scope="col"> <input type="checkbox" aria-label="Pick all" checked={every_one} @mount={(el) => { all_box = el }} @change={(e) => { pick_all(e.checked) }}/> </th>} <th scope="col" aria-sort={by == "name" ? (desc ? "descending" : "ascending") : null}> <button.ui-fl-sort type="button" @click={() => { sort_on("name") }}>Name{file_list_arrow(by == "name", desc)}</button> </th> <th.ui-fl-num scope="col" aria-sort={by == "size" ? (desc ? "descending" : "ascending") : null}> <button.ui-fl-sort type="button" @click={() => { sort_on("size") }}>Size{file_list_arrow(by == "size", desc)}</button> </th> <th.ui-fl-date scope="col" aria-sort={by == "modified" ? (desc ? "descending" : "ascending") : null}> <button.ui-fl-sort type="button" @click={() => { sort_on("modified") }}>Modified{file_list_arrow(by == "modified", desc)}</button> </th> <th scope="col"><span.ui-sr>Actions</span></th> </tr> </thead> <tbody> {for f in shown => <tr key={file_list_key(f)} class={contains(live, file_list_key(f)) ? "ui-fl-picked" : null}> {selectable && <td.ui-fl-check> <input type="checkbox" name={name} value={file_list_key(f)} aria-label={`Pick ${f.name}`} checked={contains(live, file_list_key(f))} @change={(e) => { pick(file_list_key(f), e.checked) }}/> </td>} <td.ui-fl-main><span.ui-fl-name>{file_list_icon(f.name)} {get(f, "href") != null ? <a href={f.href} title={f.name}>{f.name}</a> : <span title={f.name}>{f.name}</span>}</span></td> <td.ui-fl-num>{file_list_size(f.size ?? 0)}</td> <td.ui-fl-date><time datetime={str(get(f, "modified") ?? "")}>{file_list_date(get(f, "modified"))}</time></td> <td>{file_list_actions(f, deletable, on_delete)}</td> </tr>} </tbody> </table> </div>} </div> delete_action != null ? <form.ui-fl-form method="post" action={delete_action} aria-label={label}>{body}</form> : body}Related
ActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsChangelogReleases with version, date and label, their changes grouped by kind: Added, Changed, Fixed, Security and the rest.6 views · 7 downloadsCsvTableA table made from CSV text, with quoted fields, a header row, numbers lined up on the right and a row limit.6 views · 7 downloadsDataListRecords as rows with a title, a line of facts, a status badge and actions as links or buttons.6 views · 8 downloadsDescriptionListTerms and their details, stacked or in two columns that stack on phones, on a real <dl>.5 views · 7 downloadsKanbanBoardColumns of cards moved by dragging, by keyboard (Space, the arrows, Space) or with each card's arrows.9 views · 6 downloads
Thread
Thread 0 posts
On FileList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.