Data

DataGrid

Sort, search, filter by a column's values, pick rows and page through them, in one table.

#data#table#sort#filter#search#interactive

Demo

Preview
Status
#2024Northwind TradersPaidSep 26, 2026$120.56
#2023Acme CorpOpenSep 15, 2026$41.37
#2022GlobexOverdueSep 4, 2026$862.18
#2021InitechDraftAug 24, 2026$782.99
#2020Umbrella HealthPaidAug 13, 2026$703.80
#2019Wonka FoodsOpenAug 2, 2026$624.61
#2018Northwind TradersOverdueJul 22, 2026$545.42
#2017Acme CorpDraftJul 11, 2026$466.23
#2016GlobexPaidJun 30, 2026$387.04
#2015InitechOpenJun 19, 2026$307.85
1–10 of 32

on_select: nothing

Order
#2003Initech$257.57
#2007Wonka Foods$574.33
#2011Acme Corp$891.09
#2015Initech$307.85
#2019Wonka Foods$624.61
#2023Acme Corp$41.37
#2027Initech$358.13
#2031Wonka 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.qwe
main.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>}
Thread
58Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.