Data

TreeTable

Rows that open to show the rows under them, indented by depth, with the arrow keys as an ARIA treegrid.

#data#table#keyboard#interactive

Demo

Preview
AccountTypeBalance
AssetsDebit$184,250.75
Accounts receivableDebit$12,150.25
EquipmentDebit$130,000.00
EquityCredit$127,850.75
PartQtyUnit cost
Bicycle1$420.00
Frame1$180.00
Wheel2$65.00
Rim1$30.00
Spoke32$0.50
Tyre1$18.00
Drivetrain1$110.00
Chain1$25.00
Cassette1$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.qwe
main.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>}
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.