TreeTable
Rows that open to show the rows under them, indented by depth, with the arrow keys as an ARIA treegrid.
Demo
| Account | Type | Balance |
|---|---|---|
| Assets | Debit | $184,250.75 |
| Cash | Debit | $42,100.50 |
| Accounts receivable | Debit | $12,150.25 |
| Equipment | Debit | $130,000.00 |
| Liabilities | Credit | -$56,400.00 |
| Equity | Credit | $127,850.75 |
| Part | Qty | Unit cost |
|---|---|---|
| Bicycle | 1 | $420.00 |
| Frame | 1 | $180.00 |
| Wheel | 2 | $65.00 |
| Rim | 1 | $30.00 |
| Spoke | 32 | $0.50 |
| Tyre | 1 | $18.00 |
| Drivetrain | 1 | $110.00 |
| Chain | 1 | $25.00 |
| Cassette | 1 | $45.00 |
Last toggle: nothing yet.
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/tree_table.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/tree_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/tree_table.qwe
let tree_table_demo_accounts = [ { id: "assets", name: "Assets", balance: 184250.75, kind: "Debit", expanded: true, children: [ { id: "cash", name: "Cash", balance: 42100.5, kind: "Debit", children: [ { id: "checking", name: "Checking", balance: 31000.5, kind: "Debit" }, { id: "savings", name: "Savings", balance: 11100, kind: "Debit" }, ] }, { id: "receivable", name: "Accounts receivable", balance: 12150.25, kind: "Debit" }, { id: "equipment", name: "Equipment", balance: 130000, kind: "Debit" }, ] }, { id: "liabilities", name: "Liabilities", balance: -56400, kind: "Credit", children: [ { id: "payable", name: "Accounts payable", balance: -6400, kind: "Credit" }, { id: "loan", name: "Bank loan", balance: -50000, kind: "Credit" }, ] }, { id: "equity", name: "Equity", balance: 127850.75, kind: "Credit" },]let tree_table_demo_parts = [ { name: "Bicycle", qty: 1, unit: 420, children: [ { name: "Frame", qty: 1, unit: 180 }, { name: "Wheel", qty: 2, unit: 65, children: [{ name: "Rim", qty: 1, unit: 30 }, { name: "Spoke", qty: 32, unit: 0.5 }, { name: "Tyre", qty: 1, unit: 18 }] }, { name: "Drivetrain", qty: 1, unit: 110, children: [{ name: "Chain", qty: 1, unit: 25 }, { name: "Cassette", qty: 1, unit: 45 }] }, ] },]component TreeTableDemoLive() { state last = "nothing yet" <div.demo-stack> <TreeTable id="tree-table-demo-parts" label="Bill of materials" rows={tree_table_demo_parts} open_all={true} zebra={true} on_toggle={(rid, open) => { last = `${open ? "opened" : "closed"} ${rid}` }} columns={[{ key: "name", label: "Part" }, { key: "qty", label: "Qty", format: "number" }, { key: "unit", label: "Unit cost", format: "money" }]}/> <p.demo-note>Last toggle: {last}.</p> </div>}view TreeTableDemo() = <div.demo-stack> <TreeTable id="tree-table-demo-accounts" label="Chart of accounts" rows={tree_table_demo_accounts} columns={[{ key: "name", label: "Account" }, { key: "kind", label: "Type" }, { key: "balance", label: "Balance", format: "money" }]}/> <TreeTableDemoLive/> </div>Source
ui/tree_table.qwe
// TreeTable: rows with rows under them, opened and closed by an arrow in the first// column and indented by their depth: accounts and sub-accounts, a bill of// materials, files in folders, with columns of values. It is an ARIA treegrid:// one Tab stop; ↓ ↑ move between rows, → opens a row (then goes into it), ← closes// it (then goes to its parent), Home and End; Enter or Space opens and closes.//// <TreeTable id="accounts" label="Accounts" rows={accounts} columns={[// { key: "name", label: "Account" },// { key: "balance", label: "Balance", format: "money" },// ]}/>//// rows: maps with the columns' fields, children (rows under it) and expanded// (true: open at first). id: a row's own id, unique in the table (its place,// "0-2", by default). columns: { key, label, align?, format?, currency? } as in// DataTable: format "number", "money", "percent" or "date"; numbers line up on// the right. The first column holds the arrows and the indent. open_all: every// row open at first. label: the table's name for screen readers. zebra: shades// every other row shown. on_toggle(id, open): inside one of your components,// called when a row is opened or closed.import ui/theme.qwe use ui_clslet tree_table_css = css`.ui-treetable { min-width: 0; overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-treetable table { width: 100%; border-collapse: separate; border-spacing: 0; }.ui-treetable th, .ui-treetable td { padding: 8px 14px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--ui-border); }.ui-treetable thead th { background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-treetable tbody tr:last-child td { border-bottom: 0; }.ui-treetable tbody tr { outline: none; }.ui-treetable tbody tr:focus-visible td { box-shadow: inset 0 2px 0 var(--ui-accent), inset 0 -2px 0 var(--ui-accent); }.ui-treetable tbody tr:focus-visible td:first-child { box-shadow: inset 2px 2px 0 var(--ui-accent), inset 0 -2px 0 var(--ui-accent); }.ui-treetable tbody tr:focus-visible td:last-child { box-shadow: inset -2px 2px 0 var(--ui-accent), inset 0 -2px 0 var(--ui-accent); }.ui-treetable tbody tr:hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-treetable-zebra tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--ui-subtle) 55%, transparent); }.ui-treetable .ui-treetable-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-treetable-first { display: flex; align-items: center; gap: 4px; padding-left: calc((var(--ui-treetable-level) - 1) * 20px); }.ui-treetable-arrow { flex: none; display: inline-grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-treetable-arrow:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-treetable-arrow:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-treetable-arrow svg { width: 12px; height: 12px; transition: transform .15s; }[aria-expanded="true"] .ui-treetable-arrow svg { transform: rotate(90deg); }.ui-treetable-leaf { flex: none; width: 22px; }.ui-treetable-parent .ui-treetable-first { font-weight: 500; }.ui-treetable-empty td { padding: 28px 14px; text-align: center; color: var(--ui-muted); }@media (prefers-reduced-motion: reduce) { .ui-treetable-arrow svg { transition: none; } }`fn tree_table_fmt(v, format: str? = null, currency: str = "$") -> str { if v == null { return "—" } if format == "date" { return type_of(v) == "str" && date.valid(v) ? date.format(v, "MMM D, YYYY") : str(v) } if type_of(v) != "int" && type_of(v) != "float" { return str(v) } let sign = v < 0 ? "-" : "" match format { "money" => `${sign}${currency}${format_number(abs(v), "en-US", 2)}` "percent" => `${format_number(round(v * 1000) / 10, "en-US")}%` _ => format_number(v, "en-US") }}fn tree_table_right(c: map) -> bool = c.align == "right" || (c.align == null && (c.format == "number" || c.format == "money" || c.format == "percent"))fn tree_table_id(r: map, path: str) -> str = r.id != null ? str(r.id) : path// Every row, nested ones too, in order: { row, id, level, parent, kids, pos, size }.fn tree_table_all(rows: list, level: int = 1, parent = null, prefix: str = "") -> list = flatten(for [i, r] in enumerate(rows) => { let path = prefix == "" ? str(i) : `${prefix}-${i}` let id = tree_table_id(r, path) let kids = r.children ?? [] [{ row: r, id, level, parent, kids: len(kids) > 0, pos: i + 1, size: len(rows) }] + tree_table_all(kids, level + 1, id, path) })// The rows showing: those whose parents are all open.fn tree_table_shown(rows: list, open: list, level: int = 1, parent = null, prefix: str = "") -> list = flatten(for [i, r] in enumerate(rows) => { let path = prefix == "" ? str(i) : `${prefix}-${i}` let id = tree_table_id(r, path) let kids = r.children ?? [] [{ row: r, id, level, parent, kids: len(kids) > 0, pos: i + 1, size: len(rows) }] + (len(kids) > 0 && index_of(open, id) != null ? tree_table_shown(kids, open, level + 1, id, path) : []) })component TreeTable(id: str, columns: list = [], rows: list = [], label: str? = null, open_all: bool = false, zebra: bool = false, empty: str = "Nothing to show.", on_toggle = null, class: str? = null) { style global tree_table_css state open = tree_table_all(rows) | filter((e) => e.kids && (open_all || e.row.expanded == true)) | map((e) => e.id) state active = 0 let shown = tree_table_shown(rows, open) let n = len(shown) let at = active < n ? active : 0 let focus_row = (root, i: int) => { js("queueMicrotask", () => { let el = root.querySelector(`[data-row="${i}"]`) el && el.focus() }) } let toggle = (rid: str) => { let now_open = index_of(open, rid) == null open = now_open ? push(open, rid) : open | filter((x) => x != rid) on_toggle && on_toggle(rid, now_open) } <div class={ui_cls(["ui-treetable", zebra && "ui-treetable-zebra", class])} id={id}> <table role="treegrid" aria-label={label} @keydown={(e) => { // The row the key was pressed on, from the state itself: a `let` above may not // have followed a change made a moment ago. let list = tree_table_shown(rows, open) let n = len(list) let tr = e.target.closest("tr[data-id]") let at = tr != null ? (index_of(list | map((x) => x.id), tr.getAttribute("data-id")) ?? 0) : (active < n ? active : 0) let cur = list[at] if cur == null { return null } let root = e.target.closest(".ui-treetable") let k = e.key let is_open = index_of(open, cur.id) != null if k == "ArrowDown" || k == "ArrowUp" || k == "Home" || k == "End" { e.preventDefault() let to = match k { "ArrowDown" => at + 1 < n ? at + 1 : at "ArrowUp" => at > 0 ? at - 1 : 0 "Home" => 0 _ => n - 1 } active = to focus_row(root, to) } else if k == "ArrowRight" { e.preventDefault() if cur.kids { if !is_open { toggle(cur.id) } else { active = at + 1; focus_row(root, at + 1) } } } else if k == "ArrowLeft" { e.preventDefault() if cur.kids && is_open { toggle(cur.id) } else if cur.parent != null { let to = index_of(list | map((x) => x.id), cur.parent) ?? at active = to focus_row(root, to) } } else if (k == "Enter" || k == " ") && e.target.tagName == "TR" { e.preventDefault() if cur.kids { toggle(cur.id) } } }}> <thead> <tr>{for c in columns => <th scope="col" class={tree_table_right(c) ? "ui-treetable-right" : null}>{c.label ?? c.key}</th>}</tr> </thead> <tbody> {n == 0 && <tr.ui-treetable-empty><td colspan={str(len(columns))}>{empty}</td></tr>} {for [i, e] in enumerate(shown) => <tr key={e.id} role="row" data-row={i} data-id={e.id} class={e.kids ? "ui-treetable-parent" : null} tabindex={i == at ? "0" : "-1"} aria-level={e.level} aria-posinset={e.pos} aria-setsize={e.size} aria-expanded={e.kids ? (index_of(open, e.id) != null ? "true" : "false") : null} @focusin={(ev) => { if ev.target.tagName == "TR" { active = i } }}> {for [ci, c] in enumerate(columns) => <td role="gridcell" class={tree_table_right(c) ? "ui-treetable-right" : null}> {ci == 0 ? <span.ui-treetable-first style={`--ui-treetable-level: ${e.level}`}> {e.kids ? <button.ui-treetable-arrow type="button" tabindex="-1" aria-label={`${index_of(open, e.id) != null ? "Close" : "Open"} ${tree_table_fmt(e.row[c.key], c.format, c.currency ?? "$")}`} @click={() => { active = i; toggle(e.id) }}> <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 2.5L8 6l-3.5 3.5"/></svg> </button> : <span.ui-treetable-leaf aria-hidden="true"></span>} <span>{tree_table_fmt(e.row[c.key], c.format, c.currency ?? "$")}</span> </span> : tree_table_fmt(e.row[c.key], c.format, c.currency ?? "$")} </td>} </tr>} </tbody> </table> </div>}Related
AuditLogWho did what to what and when, newest first, filtered by person and action, searched, and paged.5 views · 7 downloadsComparisonTableFeatures down the side and options across, with ticks, crosses and values in the cells, and one option picked out.5 views · 6 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 downloadsDataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 downloadsDataTableRows of data sorted by their headers, with search, pages, number, money, percent and date columns.5 views · 9 downloadsEditableTableA grid edited in place: text, number, select, checkbox and date cells, checked, sorted, with rows added and deleted.7 views · 5 downloads
Thread
Thread 0 posts
On TreeTable: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.