DataGrid
Sort, search, filter by a column's values, pick rows and page through them, in one table.
Demo
| Status | |||||
|---|---|---|---|---|---|
| #2024 | Northwind Traders | Paid | Sep 26, 2026 | $120.56 | |
| #2023 | Acme Corp | Open | Sep 15, 2026 | $41.37 | |
| #2022 | Globex | Overdue | Sep 4, 2026 | $862.18 | |
| #2021 | Initech | Draft | Aug 24, 2026 | $782.99 | |
| #2020 | Umbrella Health | Paid | Aug 13, 2026 | $703.80 | |
| #2019 | Wonka Foods | Open | Aug 2, 2026 | $624.61 | |
| #2018 | Northwind Traders | Overdue | Jul 22, 2026 | $545.42 | |
| #2017 | Acme Corp | Draft | Jul 11, 2026 | $466.23 | |
| #2016 | Globex | Paid | Jun 30, 2026 | $387.04 | |
| #2015 | Initech | Open | Jun 19, 2026 | $307.85 |
1–10 of 32
on_select: nothing
| Order | ||
|---|---|---|
| #2003 | Initech | $257.57 |
| #2007 | Wonka Foods | $574.33 |
| #2011 | Acme Corp | $891.09 |
| #2015 | Initech | $307.85 |
| #2019 | Wonka Foods | $624.61 |
| #2023 | Acme Corp | $41.37 |
| #2027 | Initech | $358.13 |
| #2031 | Wonka Foods | $674.89 |
8 rows
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/data_grid.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/data_grid.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/data_grid.qwe
let data_grid_demo_customers = ["Northwind Traders", "Acme Corp", "Globex", "Initech", "Umbrella Health", "Wonka Foods"]let data_grid_demo_statuses = ["Paid", "Open", "Overdue", "Draft"]// 32 orders, made the same way on the server and in the browser.let data_grid_demo_orders = for i in range(0, 32) => { id: 2000 + i, number: `#${2000 + i}`, customer: data_grid_demo_customers[(i * 5) % 6], status: data_grid_demo_statuses[(i * 7) % 4], placed: date.add_days("2026-01-05", (i * 11) % 270), total: ((i * 7919) % 90000) / 100 + 20,}component DataGridDemoLive() { state picked = [] <div.demo-stack> <DataGrid id="data-grid-demo-orders" label="Orders" rows={data_grid_demo_orders} search={true} page_size={10} selectable={true} name="order" order_by="placed" descending={true} columns={[ { key: "number", label: "Order", sortable: true }, { key: "customer", label: "Customer", sortable: true, filter: true }, { key: "status", label: "Status", filter: true }, { key: "placed", label: "Placed", format: "date", sortable: true }, { key: "total", label: "Total", format: "money", sortable: true }, ]} on_select={(ids) => { picked = ids }}/> <p.demo-note>on_select: {len(picked) == 0 ? "nothing" : join(picked, ", ")}</p> </div>}view DataGridDemo() = <div.demo-stack> <DataGridDemoLive/> <DataGrid id="data-grid-demo-small" label="Open invoices" zebra={true} empty="No open invoice." rows={data_grid_demo_orders | filter((o) => o.status == "Open")} columns={[{ key: "number", label: "Order" }, { key: "customer", label: "Customer", sortable: true }, { key: "total", label: "Total", format: "money", sortable: true }]}/> </div>Source
ui/data_grid.qwe
// DataGrid: one table that sorts by its headers, filters by a search box and by// the values of chosen columns, picks rows with checkboxes and shows them a page// at a time: the table of a records screen.//// <DataGrid id="orders" label="Orders" rows={orders} search={true} page_size={10} selectable={true} name="order"// order_by="placed" descending={true}// columns={[// { key: "number", label: "Order", sortable: true },// { key: "customer", label: "Customer", sortable: true, filter: true },// { key: "status", label: "Status", filter: true },// { key: "placed", label: "Placed", format: "date", sortable: true },// { key: "total", label: "Total", format: "money", sortable: true },// ]}// on_select={(ids) => { picked = ids }}/>//// rows: maps with the columns' fields and an id (unique; the row's place by// default). columns: { key, label, align?, format?, currency?, sortable?, filter? }// as in DataTable (format "number", "money", "percent" or "date"); filter: true// adds a select above the table with the column's values, which keeps the rows// with the one picked. search: a box that keeps the rows where any column's text// has what is typed. Clear, beside them, undoes both. order_by and descending:// how it starts sorted; a sortable header sorts by it, again the other way.// page_size: rows per page, chosen from page_sizes ([10, 25, 50]) in the footer,// with Previous and Next and "11–20 of 132". selectable: a checkbox on each row// and one in the header that checks the page; "3 selected" in the footer; name:// a <form> gets each checked id as a field of that name. selected: the ids checked// at first. label: the table's name. empty: what it says when no row is left.// zebra: shades every other row. height: a CSS height past which it scrolls// inside, with the header kept in view. on_select(ids): inside one of your// components, called with the ids checked, in the rows' order.import ui/theme.qwe use ui_clslet data_grid_css = css`.ui-datagrid { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-datagrid-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ui-datagrid-search, .ui-datagrid-filter { height: 36px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: inherit; }.ui-datagrid-search { width: min(100%, 260px); padding: 0 12px 0 34px; background: var(--ui-surface) no-repeat 11px 50% / 15px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='M20 20l-4-4'/%3E%3C/svg%3E"); }.ui-datagrid-filter { padding: 0 30px 0 10px; appearance: none; -webkit-appearance: none; background: var(--ui-surface) no-repeat right 10px center / 10px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); }.ui-datagrid-search:focus, .ui-datagrid-filter:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-datagrid-filter.ui-datagrid-on { border-color: var(--ui-accent); color: var(--ui-accent); background-color: var(--ui-accent-soft); }.ui-datagrid-clear { height: 36px; padding: 0 10px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-accent); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-datagrid-clear:hover { background: var(--ui-accent-soft); }.ui-datagrid-clear:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-datagrid-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-datagrid table { width: 100%; border-collapse: separate; border-spacing: 0; }.ui-datagrid th, .ui-datagrid td { padding: 8px 14px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--ui-border); }.ui-datagrid tbody tr:last-child td { border-bottom: 0; }.ui-datagrid th { position: sticky; top: 0; z-index: 1; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-datagrid .ui-datagrid-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-datagrid-sort { display: inline-flex; align-items: center; gap: 4px; margin: -4px -6px; padding: 4px 6px; border: 0; border-radius: 4px; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }.ui-datagrid-sort:hover { color: var(--ui-fg); }.ui-datagrid-sort:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-datagrid-right .ui-datagrid-sort { flex-direction: row-reverse; }.ui-datagrid-arrow { width: 12px; height: 12px; opacity: .35; }[aria-sort] .ui-datagrid-sort { color: var(--ui-fg); }[aria-sort] .ui-datagrid-arrow { opacity: 1; color: var(--ui-accent); }.ui-datagrid-box { width: 1%; padding-right: 4px; }.ui-datagrid input[type="checkbox"] { width: 16px; height: 16px; margin: 0; vertical-align: middle; accent-color: var(--ui-accent); cursor: pointer; }.ui-datagrid input[type="checkbox"]:focus-visible { outline: none; box-shadow: var(--ui-focus); border-radius: 3px; }.ui-datagrid tbody tr:hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-datagrid tbody tr[aria-selected="true"] td { background: var(--ui-accent-soft); }.ui-datagrid-zebra tbody tr:nth-child(even):not([aria-selected="true"]) td { background: color-mix(in srgb, var(--ui-subtle) 55%, transparent); }.ui-datagrid-empty td { padding: 28px 14px; text-align: center; color: var(--ui-muted); white-space: normal; }.ui-datagrid-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-datagrid-foot > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }.ui-datagrid-picked { color: var(--ui-fg); font-weight: 500; }.ui-datagrid-size { height: 30px; padding: 0 26px 0 8px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); appearance: none; -webkit-appearance: none; color: var(--ui-fg); font: 13px/1 var(--ui-font); background: var(--ui-surface) no-repeat right 8px center / 9px url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); }.ui-datagrid-size:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-datagrid-pages { display: flex; gap: 6px; }.ui-datagrid-pages button { height: 30px; padding: 0 11px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-datagrid-pages button:hover:not(:disabled) { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-datagrid-pages button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-datagrid-pages button:disabled { opacity: .5; cursor: not-allowed; }`fn data_grid_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 data_grid_right(c: map) -> bool = c.align == "right" || (c.align == null && (c.format == "number" || c.format == "money" || c.format == "percent"))component DataGrid(id: str, columns: list = [], rows: list = [], order_by: str? = null, descending: bool = false, search: bool = false, page_size: int? = null, page_sizes: list = [10, 25, 50], selectable: bool = false, selected: list = [], name: str? = null, label: str? = null, empty: str = "Nothing to show.", zebra: bool = false, height: str? = null, on_select = null, class: str? = null) { style global data_grid_css let keyed = for [i, r] in enumerate(rows) => { id: r.id != null ? str(r.id) : str(i), row: r } state sort_key = order_by state sort_desc = descending state query = "" state filters = {} state page = 0 state size = page_size state picked = selected | map((x) => str(x)) state head = null let filterable = columns | filter((c) => c.filter == true) let choices = (c: map) => sort(unique(rows | map((r) => r[c.key]) | filter((v) => v != null)) | map((v) => str(v))) let needle = lower(trim(query)) let text = (r: map, c: map) => data_grid_fmt(r[c.key], c.format, c.currency ?? "$") let found = keyed | filter((k) => all(filterable, (c) => (filters[c.key] ?? "") == "" || str(k.row[c.key]) == filters[c.key])) | filter((k) => needle == "" || any(columns, (c) => contains(lower(text(k.row, c)), needle))) let ordered = sort_key == null ? found : found | sort_by((k) => { let v = k.row[sort_key] type_of(v) == "str" ? lower(v) : v }) let sorted = sort_desc ? reverse(ordered) : ordered let total = len(sorted) let per = size ?? (total > 0 ? total : 1) let pages = total == 0 ? 1 : ceil(total / per) let at = page >= pages ? pages - 1 : page let shown = size == null ? sorted : slice(sorted, at * per, at * per + per) let shown_ids = shown | map((k) => k.id) let page_all = len(shown) > 0 && all(shown_ids, (x) => index_of(picked, x) != null) let page_some = !page_all && any(shown_ids, (x) => index_of(picked, x) != null) let filtered = needle != "" || any(filterable, (c) => (filters[c.key] ?? "") != "") if head != null { head.indeterminate = page_some } let set_picked = (next: list) => { picked = keyed | map((k) => k.id) | filter((x) => index_of(next, x) != null) on_select && on_select(picked) } <div class={ui_cls(["ui-datagrid", zebra && "ui-datagrid-zebra", class])} id={id}> {(search || len(filterable) > 0) && <div.ui-datagrid-bar> {search && <input.ui-datagrid-search type="search" placeholder="Search" value={query} aria-label={label != null ? `Search ${lower(label)}` : "Search"} @input={(e) => { query = e.value; page = 0 }}/>} {for c in filterable => <select class={ui_cls(["ui-datagrid-filter", (filters[c.key] ?? "") != "" && "ui-datagrid-on"])} aria-label={`Filter by ${lower(c.label ?? c.key)}`} data-filter={c.key} @change={(e) => { filters = set(filters, c.key, e.value); page = 0 }}> <option value="" selected={(filters[c.key] ?? "") == ""}>All: {c.label ?? c.key}</option> {for v in choices(c) => <option value={v} selected={filters[c.key] == v}>{v}</option>} </select>} {filtered && <button.ui-datagrid-clear type="button" @click={() => { query = ""; filters = {}; page = 0 }}>Clear</button>} </div>} <div.ui-datagrid-scroll style={height != null ? `max-height: ${height}` : null}> <table aria-label={label} aria-multiselectable={selectable ? "true" : null}> <thead> <tr> {selectable && <th scope="col" class="ui-datagrid-box"> <input type="checkbox" checked={page_all} disabled={len(shown) == 0} aria-checked={page_some ? "mixed" : (page_all ? "true" : "false")} aria-label={page_all ? "Unselect the rows shown" : "Select the rows shown"} @mount={(el) => { head = el }} @change={() => { set_picked(page_all ? picked | filter((x) => index_of(shown_ids, x) == null) : unique(picked + shown_ids)) }}/> </th>} {for c in columns => <th scope="col" class={data_grid_right(c) ? "ui-datagrid-right" : null} aria-sort={sort_key == c.key ? (sort_desc ? "descending" : "ascending") : null}> {c.sortable == true ? <button.ui-datagrid-sort type="button" @click={() => { if sort_key == c.key { sort_desc = !sort_desc } else { sort_key = c.key; sort_desc = false } page = 0 }}> {c.label ?? c.key} <svg.ui-datagrid-arrow viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d={sort_key != c.key ? "M3.5 4.5L6 2l2.5 2.5M3.5 7.5L6 10l2.5-2.5" : (sort_desc ? "M6 2v8M3 7l3 3 3-3" : "M6 10V2M3 5l3-3 3 3")}/> </svg> </button> : (c.label ?? c.key)} </th>} </tr> </thead> <tbody> {total == 0 && <tr.ui-datagrid-empty><td colspan={str(len(columns) + (selectable ? 1 : 0))}>{empty}</td></tr>} {for k in shown => { let on = selectable && index_of(picked, k.id) != null <tr key={k.id} data-id={k.id} aria-selected={selectable ? (on ? "true" : "false") : null}> {selectable && <td class="ui-datagrid-box"> <input type="checkbox" checked={on} aria-label={`Select ${columns[0] != null ? text(k.row, columns[0]) : k.id}`} @change={() => { set_picked(on ? picked | filter((x) => x != k.id) : push(picked, k.id)) }}/> </td>} {for c in columns => <td class={data_grid_right(c) ? "ui-datagrid-right" : null}>{text(k.row, c)}</td>} </tr> }} </tbody> </table> </div> <div.ui-datagrid-foot> <div> <span aria-live="polite">{total == 0 ? "No rows" : size == null ? `${total} ${total == 1 ? "row" : "rows"}` : `${at * per + 1}–${at * per + len(shown)} of ${total}`}</span> {selectable && len(picked) > 0 && <span.ui-datagrid-picked role="status">{len(picked)} selected</span>} </div> <div> {page_size != null && <select.ui-datagrid-size aria-label="Rows per page" @change={(e) => { size = int(e.value); page = 0 }}> {for s in page_sizes => <option value={str(s)} selected={s == size}>{s} per page</option>} </select>} {pages > 1 && <div.ui-datagrid-pages> <button type="button" disabled={at == 0} @click={() => { page = at - 1 }}>Previous</button> <button type="button" disabled={at >= pages - 1} @click={() => { page = at + 1 }}>Next</button> </div>} </div> </div> {name != null && (for rid in picked => <input type="hidden" name={name} value={rid}/>)} </div>}Related
AuditLogWho did what to what and when, newest first, filtered by person and action, searched, and paged.5 views · 7 downloadsDataTableRows of data sorted by their headers, with search, pages, number, money, percent and date columns.5 views · 9 downloadsJsonTableJSON as a table: open nested objects and arrays in place or as the table, search at every depth, export as JSON or CSV, thousands of lines kept fast.9 views · 7 downloadsLogViewerLog lines with each level in its colour, buttons to show or hide levels, a search that marks matches, wrapping and following the end.8 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 downloads
Thread
Thread 0 posts
On DataGrid: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.