FolderGrid
Folders and files as tiles picked like a file manager: click, Ctrl/⌘ and Shift, arrow keys by row, Space, Ctrl+A, Enter or double-click to open.
Demo
- Contracts
- Photos
- Archive
- Q3 report.pdf
- Budget 2027.xlsx
- Team offsite.jpg
- Brand assets.zip
- notes.md
Picked: d1. Nothing opened.
- Contracts
- Photos
- Archive
The trash 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/folder_grid.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/folder_grid.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/folder_grid.qwe
let folder_grid_demo_items = [ { id: "f1", name: "Contracts", kind: "folder", count: 12 }, { id: "f2", name: "Photos", kind: "folder", count: 1 }, { id: "f3", name: "Archive", kind: "folder" }, { id: "d1", name: "Q3 report.pdf", size: 2483112 }, { id: "d2", name: "Budget 2027.xlsx", size: 84211 }, { id: "d3", name: "Team offsite.jpg", size: 3912004 }, { id: "d4", name: "Brand assets.zip", size: 18440230 }, { id: "d5", name: "notes.md", size: 812 },]component FolderGridDemoLive() { state picked = ["d1"] state opened = "Nothing opened." <div.demo-stack> <FolderGrid id="fg-drive" name="picked" label="My drive" items={folder_grid_demo_items} selected={["d1"]} on_select={(ids) => { picked = ids }} on_open={(it) => { opened = `Opened ${it.name}.` }}/> <p.demo-note>Picked: {len(picked) == 0 ? "none" : join(picked, ", ")}. {opened}</p> </div>}view FolderGridDemo() = <div.demo-stack> <FolderGridDemoLive/> <FolderGrid id="fg-single" label="Pick a cover" multiple={false} items={take(folder_grid_demo_items, 3)}/> <FolderGrid id="fg-empty" label="Trash" items={[]} empty="The trash is empty."/> </div>Source
ui/folder_grid.qwe
// FolderGrid: folders and files as tiles with icons, picked as in a file manager:// a click picks one, Ctrl or ⌘ adds or takes one away, Shift picks a range; the// check on each tile (on hover, and always on touch screens) adds or takes away.// Double-click or Enter opens. With the keyboard it is a listbox: the arrow keys// move (up and down by a row), Space picks, Ctrl+A picks all, Escape picks none.//// <FolderGrid id="drive" name="picked" items={[// { id: "f1", name: "Contracts", kind: "folder", count: 12 },// { id: "d7", name: "Lease 2026.pdf", size: 482113, href: "/files/d7" },// ]} on_open={(it) => { path = it.id }}/>//// id: a prefix for its ids. items: maps of { id, name, kind?, size?, count?,// modified?, href? }: kind "folder" for a folder (otherwise the extension says// what the file is); count, a folder's items; size, a file's bytes; modified, an// ISO date; href, where opening it goes when there is no on_open.// selected: the ids picked at first. multiple: more than one may be picked (true).// name: the picked ids go in hidden inputs of this name, for a form around it.// label: what screen readers call it ("Files"). empty: what it says with no items.// on_select(ids): inside one of your components, called with the ids picked.// on_open(item): called with the item opened (double-click or Enter).import ui/theme.qwe use ui_clslet folder_grid_css = css`.ui-folder-grid { display: grid; gap: 8px; min-width: 0; font: 13px/1.35 var(--ui-font); color: var(--ui-fg); }.ui-folder-grid-bar { display: flex; justify-content: space-between; gap: 8px; color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-folder-grid-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 8px; margin: 0; padding: 4px; list-style: none; outline: none; }.ui-folder-grid-tile { position: relative; display: grid; justify-items: center; gap: 6px; padding: 16px 8px 10px; border: 1px solid transparent; border-radius: var(--ui-radius-sm); cursor: default; user-select: none; -webkit-user-select: none; text-align: center; outline: none; }.ui-folder-grid-tile:hover { background: var(--ui-subtle); }.ui-folder-grid-tile:focus-visible { box-shadow: var(--ui-focus); border-color: var(--ui-accent); }.ui-folder-grid-tile[aria-selected="true"] { border-color: color-mix(in srgb, var(--ui-accent) 45%, transparent); background: var(--ui-accent-soft); }.ui-folder-grid-check { position: absolute; top: 6px; left: 6px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 1.5px solid var(--ui-strong); border-radius: 50%; background: var(--ui-surface); color: var(--ui-accent-fg); cursor: pointer; opacity: 0; }.ui-folder-grid-tile:hover .ui-folder-grid-check, .ui-folder-grid-tile[aria-selected="true"] .ui-folder-grid-check { opacity: 1; }.ui-folder-grid-tile[aria-selected="true"] .ui-folder-grid-check { border-color: var(--ui-accent); background: var(--ui-accent); }.ui-folder-grid-check svg { width: 10px; height: 10px; }@media (hover: none) { .ui-folder-grid-check { opacity: 1; } }.ui-folder-grid-folder { width: 56px; height: 44px; color: var(--ui-accent); }.ui-folder-grid-file { --k: var(--ui-muted); position: relative; display: grid; place-items: end center; width: 38px; height: 48px; padding-bottom: 6px; border-radius: 3px 12px 3px 3px; background: color-mix(in srgb, var(--k) 16%, var(--ui-surface)); color: var(--k); font: 700 9px/1 var(--ui-font); }.ui-folder-grid-file::before { content: ""; position: absolute; top: 0; right: 0; width: 12px; height: 12px; border-radius: 0 0 0 3px; background: color-mix(in srgb, var(--k) 32%, var(--ui-surface)); }.ui-folder-grid-k-image { --k: var(--ui-success); } .ui-folder-grid-k-pdf { --k: var(--ui-danger); } .ui-folder-grid-k-doc { --k: var(--ui-info); }.ui-folder-grid-k-sheet { --k: var(--ui-success); } .ui-folder-grid-k-archive { --k: var(--ui-warning); } .ui-folder-grid-k-media { --k: var(--ui-accent); }.ui-folder-grid-name { max-width: 100%; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow-wrap: anywhere; font-weight: 500; }.ui-folder-grid-meta { color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; }.ui-folder-grid-empty { padding: 28px 16px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius-sm); color: var(--ui-muted); text-align: center; }`fn folder_grid_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) `${floor(tenths / 10)}${tenths % 10 == 0 ? "" : `.${tenths % 10}`} ${unit == 1024 ? "KB" : (unit == 1024 * 1024 ? "MB" : "GB")}`}fn folder_grid_ext(name: str) -> str { let parts = split(name, ".") len(parts) > 1 ? lower(last(parts)) : ""}fn folder_grid_kind(name: str) -> str = match folder_grid_ext(name) { "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "image" "pdf" => "pdf" "doc" | "docx" | "txt" | "md" | "odt" | "rtf" => "doc" "xls" | "xlsx" | "csv" | "ods" => "sheet" "zip" | "gz" | "tar" | "7z" | "rar" => "archive" "mp3" | "wav" | "mp4" | "mov" | "webm" => "media" _ => "file" }// What is under a tile's name: a folder's count, a file's size.fn folder_grid_meta(it: map) -> str = it.kind == "folder" ? (it.count != null ? `${it.count} ${it.count == 1 ? "item" : "items"}` : "Folder") : (it.size != null ? folder_grid_size(it.size) : upper(folder_grid_ext(it.name)))component FolderGrid(id: str, items: list = [], selected: list = [], multiple: bool = true, name: str? = null, label: str = "Files", empty: str = "This folder is empty.", on_select = null, on_open = null, class: str? = null) { style global folder_grid_css state picked = selected state anchor = 0 state active = 0 let ids = items | map((it) => str(it.id)) let live = picked | filter((p) => contains(ids, str(p))) let at = active < len(items) ? active : 0 let tell = (next: list) => { picked = next on_select && on_select(next) } let focus_on = (e, i: int) => { active = i let box = e.target.closest(".ui-folder-grid-list") js("queueMicrotask", () => { let el = box.querySelector(`[data-i="${i}"]`) el && el.focus() }) } let toggle = (i: int) => { let k = ids[i] anchor = i tell(contains(live, k) ? live | filter((p) => p != k) : (multiple ? push(live, k) : [k])) } let pick = (e, i: int) => { active = i if multiple && e.shiftKey { let lo = min([anchor, i]) let hi = max([anchor, i]) tell(unique(live + slice(ids, lo, hi + 1))) } else if multiple && (e.ctrlKey || e.metaKey) { toggle(i) } else { anchor = i tell([ids[i]]) } } let open = (it: map) => { if on_open != null { on_open(it) } else if it.href != null { navigate(it.href) } } // How many tiles share the first one's row: the grid's columns now. let columns = (e) => { let tiles = js("Array.from", e.target.closest(".ui-folder-grid-list").querySelectorAll("[role=option]")) let top = tiles[0].offsetTop max([1, len(tiles | filter((t) => t.offsetTop == top))]) } <div class={ui_cls(["ui-folder-grid", class])}> <div.ui-folder-grid-bar> <span>{len(items)} {len(items) == 1 ? "item" : "items"}</span> <span aria-live="polite">{len(live) > 0 ? `${len(live)} picked` : ""}</span> </div> {len(items) == 0 ? <div.ui-folder-grid-empty>{empty}</div> : <ul.ui-folder-grid-list id={id} role="listbox" aria-label={label} aria-multiselectable={multiple ? "true" : "false"} @keydown={(e) => { let n = len(items) let k = e.key // The tile the key was pressed on (focus may have come without a focus event). let here = int(e.target.dataset.i ?? "") let at = here != null && here < n ? here : at let cols = k == "ArrowUp" || k == "ArrowDown" ? columns(e) : 1 let to = match k { "ArrowRight" => min([n - 1, at + 1]) "ArrowLeft" => max([0, at - 1]) "ArrowDown" => at + cols < n ? at + cols : at "ArrowUp" => at - cols >= 0 ? at - cols : at "Home" => 0 "End" => n - 1 _ => null } if to != null { e.preventDefault() focus_on(e, to) } else if k == " " { e.preventDefault() toggle(at) } else if k == "Enter" { e.preventDefault() open(items[at]) } else if k == "Escape" { tell([]) } else if (k == "a" || k == "A") && (e.ctrlKey || e.metaKey) && multiple { e.preventDefault() tell(ids) } }}> {for [i, it] in enumerate(items) => { let on = contains(live, str(it.id)) let folder = it.kind == "folder" <li.ui-folder-grid-tile key={str(it.id)} id={`${id}-item-${i}`} data-i={i} role="option" aria-selected={on ? "true" : "false"} tabindex={i == at ? "0" : "-1"} title={it.name} @focusin={() => { active = i }} @click={(e) => { pick(e, i) }} @dblclick={() => { open(it) }}> <span.ui-folder-grid-check role="presentation" @click={(e) => { e.stopPropagation(); active = i; toggle(i) }}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg> </span> {folder ? <svg.ui-folder-grid-folder viewBox="0 0 56 44" aria-hidden="true"> <path fill="currentColor" opacity=".55" d="M4 4h16l5 5h27a4 4 0 0 1 4 4v3H0V8a4 4 0 0 1 4-4Z"/> <rect fill="currentColor" y="12" width="56" height="32" rx="4"/> </svg> : <span class={`ui-folder-grid-file ui-folder-grid-k-${folder_grid_kind(it.name)}`} aria-hidden="true">{upper(slice(folder_grid_ext(it.name), 0, 4))}</span>} <span.ui-folder-grid-name>{it.name}</span> <span.ui-folder-grid-meta>{folder_grid_meta(it)}</span> </li> }} </ul>} {name != null && for k in live => <input type="hidden" name={name} value={k} key={k}/>} </div>}Related
CsvTableA table made from CSV text, with quoted fields, a header row, numbers lined up on the right and a row limit.6 views · 7 downloadsEmailPreviewAn email as a client shows it: subject, labels and star, sender with picture, to and cc, the date, the message and attachments to download.6 views · 7 downloadsFileListFiles as a table or a grid, with kind icons, sizes and dates, sorted, picked, downloaded or deleted.7 views · 6 downloadsFilePreviewA look at a file before opening it: an image, a PDF in an object, the start of a text file, a player, or an icon, with name, size and a download link.5 views · 7 downloadsFormulaGridA small spreadsheet: formulas with ranges and SUM AVG MIN MAX COUNT IF, dependency-ordered recalculation, exact decimals, keyboard movement and errors in the cell.2 views · 6 downloadsTeamCardA person in a team grid: square photo or initials, name, role, a line, and icon links to email, GitHub, LinkedIn, X or a site.6 views · 8 downloads
Thread
Thread 0 posts
On FolderGrid: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.