Data

GroupedTable

Rows under a heading per group, with a subtotal row after each group and a grand total, from a flat list.

#data#table#no-js

Demo

Preview
ItemDateShareAmount
Design2 rows
Figma seatsMar 2, 202612%$540.00
Stock photosMar 14, 20262%$89.50
Subtotal14%$629.50
Engineering3 rows
Cloud hostingMar 1, 202651%$2,310.40
CI minutesMar 9, 20269%$410.00
LaptopsMar 21, 202626%$1,180.00
Subtotal86%$3,900.40
Sales1 row
Conference boothMar 11, 2026——
Subtotal0%$0.00
Total100%$4,529.90
PlayerPoints
Round 1
Ada51
Mika44
Sol47
Round total142
Round 2
Mika48
Ada39
Round total87

Each group is a row heading over its own table body; the subtotal and total rows sum the columns named in totals.

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/grouped_table.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/grouped_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/grouped_table.qwe
let grouped_table_demo_expenses = [    { team: "Design", item: "Figma seats", when: "2026-03-02", amount: 540, share: 0.12 },    { team: "Design", item: "Stock photos", when: "2026-03-14", amount: 89.5, share: 0.02 },    { team: "Engineering", item: "Cloud hosting", when: "2026-03-01", amount: 2310.4, share: 0.51 },    { team: "Engineering", item: "CI minutes", when: "2026-03-09", amount: 410, share: 0.09 },    { team: "Engineering", item: "Laptops", when: "2026-03-21", amount: 1180, share: 0.26 },    { team: "Sales", item: "Conference booth", when: "2026-03-11", amount: null, share: null },]let grouped_table_demo_scores = [    { round: "Round 2", player: "Mika", points: 48 }, { round: "Round 1", player: "Ada", points: 51 },    { round: "Round 1", player: "Mika", points: 44 }, { round: "Round 2", player: "Ada", points: 39 },    { round: "Round 1", player: "Sol", points: 47 },]view GroupedTableDemo() =    <div.demo-stack>        <GroupedTable id="grouped-table-demo-expenses" label="Expenses by team" by="team" totals={["amount", "share"]}                      show_count={true} rows={grouped_table_demo_expenses}                      columns={[                          { key: "item", label: "Item" },                          { key: "when", label: "Date", format: "date" },                          { key: "share", label: "Share", format: "percent" },                          { key: "amount", label: "Amount", format: "money" },                      ]}/>        <GroupedTable id="grouped-table-demo-scores" label="Scores by round" by="round" order="asc" zebra={true}                      grand={false} subtotal_label="Round total" rows={grouped_table_demo_scores}                      totals={["points"]}                      columns={[{ key: "player", label: "Player" }, { key: "points", label: "Points", format: "number" }]}/>        <p.demo-note>Each group is a row heading over its own table body; the subtotal and total rows sum the columns named in <code>totals</code>.</p>    </div>

Source

ui/grouped_table.qwe
// GroupedTable: rows under a heading for each group, with a subtotal row after// each group and a grand total at the end: invoices by customer, expenses by// team, results by round.////   <GroupedTable label="Expenses" by="team" totals={["amount"]} rows={expenses}//                 columns={[//                     { key: "item", label: "Item" },//                     { key: "when", label: "Date", format: "date" },//                     { key: "amount", label: "Amount", format: "money" },//                 ]}/>//// rows: a list of maps. by: the field that names each row's group; the groups come// in the order of their first rows, or sorted by name with order: "asc" or "desc".// columns: what to show, in order, each { key, label, align?, format?, currency? }:// format "number" (1,234.5), "money" ($1,234.50, or the column's currency: "€"),// "percent" (0.125 is 12.5%) or "date" ("2026-03-14" shown as Mar 14, 2026). Numbers// line up on the right unless align says "left". A null shows as a dash.// totals: the keys of the columns summed in each group's subtotal row and in the// grand total; without any, there are no such rows. grand: false leaves the grand// total out. subtotal_label ("Subtotal") and total_label ("Total") name those rows.// show_count: "4 rows" beside each group's name. label: the table's name for screen// readers. id: the element's id. zebra: shades every other row. Each group is its own <tbody> under a// row heading, so screen readers know which group a row is in. On a narrow screen// it scrolls sideways inside its own box.import ui/theme.qwe use ui_clslet grouped_table_css = css`.ui-grouped { 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-grouped table { width: 100%; border-collapse: separate; border-spacing: 0; }.ui-grouped th, .ui-grouped td { padding: 9px 14px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--ui-border); }.ui-grouped thead th { background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600;  letter-spacing: .03em; text-transform: uppercase; }.ui-grouped .ui-grouped-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-grouped-group th { background: color-mix(in srgb, var(--ui-accent-soft) 55%, var(--ui-surface)); font-weight: 600; }.ui-grouped-name { color: var(--ui-fg); }.ui-grouped-count { margin-left: 8px; color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-grouped-zebra tbody tr:nth-child(odd):not(.ui-grouped-group):not(.ui-grouped-sub) td { background: color-mix(in srgb, var(--ui-subtle) 55%, transparent); }.ui-grouped tbody tr:not(.ui-grouped-group):hover td { background: var(--ui-subtle); }.ui-grouped-sub th, .ui-grouped-sub td { background: var(--ui-subtle); color: var(--ui-fg); font-weight: 600; font-size: 13px; }.ui-grouped-sub th { font-weight: 500; color: var(--ui-muted); }.ui-grouped-total th, .ui-grouped-total td { border-bottom: 0; border-top: 2px solid var(--ui-strong); background: var(--ui-surface); font-weight: 700; }.ui-grouped-empty td { padding: 28px 14px; text-align: center; color: var(--ui-muted); white-space: normal; }`// How a column shows a value: a dash for null, dates as Mar 14, 2026, numbers by their format.fn grouped_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")    }}// Whether a column lines up on the right: numbers do, unless it says otherwise.fn grouped_table_right(c: map) -> bool =    c.align == "right" || (c.align == null && (c.format == "number" || c.format == "money" || c.format == "percent"))// The sum of a field over rows (nulls and text count as 0).fn grouped_table_sum(rows: list, key: str) -> num =    sum(rows | map((r) => type_of(r[key]) == "int" || type_of(r[key]) == "float" ? r[key] : 0))// A subtotal or total row: the label in the first column that is not summed, the sums in theirs.view GroupedTableTotals(columns: list, rows: list, totals: list, text: str, class: str) {    style global grouped_table_css    let first = index_of(columns | map((c) => index_of(totals, c.key) == null), true)    <tr class={class}>        {for [i, c] in enumerate(columns) => index_of(totals, c.key) != null            ? <td class={grouped_table_right(c) ? "ui-grouped-right" : null}>                  {grouped_table_fmt(grouped_table_sum(rows, c.key), c.format, c.currency ?? "$")}              </td>            : i == first ? <th scope="row">{text}</th> : <td></td>}    </tr>}view GroupedTable(columns: list, rows: list, by: str, totals: list = [], grand: bool = true, order: str? = null,                  subtotal_label: str = "Subtotal", total_label: str = "Total", show_count: bool = false,                  empty: str = "Nothing to show.", label: str? = null, zebra: bool = false, id: str? = null, class: str? = null) {    style global grouped_table_css    let names = unique(rows | map((r) => str(r[by] ?? "")))    let ordered = order == "asc" ? sort(names) : order == "desc" ? reverse(sort(names)) : names    let groups = ordered | map((g) => { name: g, rows: rows | filter((r) => str(r[by] ?? "") == g) })    let summed = len(totals) > 0    <div class={ui_cls(["ui-grouped", zebra && "ui-grouped-zebra", class])} id={id}>        <table aria-label={label}>            <thead>                <tr>{for c in columns => <th scope="col" class={grouped_table_right(c) ? "ui-grouped-right" : null}>{c.label ?? c.key}</th>}</tr>            </thead>            {len(groups) == 0 && <tbody><tr.ui-grouped-empty><td colspan={str(len(columns))}>{empty}</td></tr></tbody>}            {for g in groups => <tbody>                <tr.ui-grouped-group>                    <th scope="rowgroup" colspan={str(len(columns))}>                        <span.ui-grouped-name>{g.name}</span>                        {show_count && <span.ui-grouped-count>{len(g.rows)} {len(g.rows) == 1 ? "row" : "rows"}</span>}                    </th>                </tr>                {for r in g.rows => <tr>                    {for c in columns => <td class={grouped_table_right(c) ? "ui-grouped-right" : null}>                        {grouped_table_fmt(r[c.key], c.format, c.currency ?? "$")}                    </td>}                </tr>}                {summed && <GroupedTableTotals columns={columns} rows={g.rows} totals={totals} text={subtotal_label} class="ui-grouped-sub"/>}            </tbody>}            {summed && grand && len(groups) > 0 && <tfoot>                <GroupedTableTotals columns={columns} rows={rows} totals={totals} text={total_label} class="ui-grouped-total"/>            </tfoot>}        </table>    </div>}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.