Data

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.

#file#grid#keyboard#interactive

Demo

Preview
8 items1 picked
  • Contracts12 items
  • Photos1 item
  • ArchiveFolder
  • Q3 report.pdf2.4 MB
  • Budget 2027.xlsx82.2 KB
  • Team offsite.jpg3.7 MB
  • Brand assets.zip17.6 MB
  • notes.md812 B

Picked: d1. Nothing opened.

3 items
  • Contracts12 items
  • Photos1 item
  • ArchiveFolder
0 items
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.qwe
main.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>}
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

On FolderGrid: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.