DataTable
Rows of data sorted by their headers, with search, pages, number, money, percent and date columns.
Demo
| Tax | |||||
|---|---|---|---|---|---|
| INV-1036 | Northwind Traders | Sep 28, 2026 | Open | 5% | $2,501.17 |
| INV-1045 | Cyberdyne | Sep 19, 2026 | Paid | 8% | $14,229.00 |
| INV-1053 | Tyrell Corp | Sep 9, 2026 | Paid | 20% | $12,750.37 |
| INV-1055 | Northwind Traders | Aug 27, 2026 | Paid | 8% | $10,653.00 |
| INV-1044 | Globex | Aug 14, 2026 | Draft | 0% | $821.03 |
| INV-1052 | Globex | Aug 13, 2026 | Paid | 20% | $9,943.99 |
| INV-1049 | Umbrella Health | Jul 22, 2026 | Open | 8% | $6,324.45 |
| INV-1034 | Pied Piper | Jul 2, 2026 | Paid | 5% | $14,904.00 |
| INV-1046 | Wonka Foods | Jun 20, 2026 | Open | 0% | $9,000.55 |
| INV-1032 | Acme Corp | Jun 19, 2026 | Overdue | 5% | $1,157.02 |
1–10 of 26
| Team | Booked | ||
|---|---|---|---|
| Ada Lovelace | Engineering | 1,284.5 | 92% |
| Grace Hopper | Engineering | 1,190 | 87.5% |
| Katherine Johnson | Research | 978.25 | 70% |
| Hedy Lamarr | Design | 1,012 | — |
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_table.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/data_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/data_table.qwe
let data_table_demo_invoices = [ { number: "INV-1031", customer: "Stark Industries", issued: "2026-03-13", status: "Paid", total: 11758.71, tax: 0.125 }, { number: "INV-1032", customer: "Acme Corp", issued: "2026-06-19", status: "Overdue", total: 1157.02, tax: 0.05 }, { number: "INV-1033", customer: "Northwind Traders", issued: "2026-02-14", status: "Paid", total: 7596.92, tax: 0 }, { number: "INV-1034", customer: "Pied Piper", issued: "2026-07-02", status: "Paid", total: 14904.0, tax: 0.05 }, { number: "INV-1035", customer: "Vandelay Imports", issued: "2026-01-19", status: "Paid", total: 10589.48, tax: 0.05 }, { number: "INV-1036", customer: "Northwind Traders", issued: "2026-09-28", status: "Open", total: 2501.17, tax: 0.05 }, { number: "INV-1037", customer: "Pied Piper", issued: "2026-02-19", status: "Draft", total: 5635.66, tax: 0.05 }, { number: "INV-1038", customer: "Acme Corp", issued: "2026-04-12", status: "Draft", total: 1862.06, tax: 0 }, { number: "INV-1039", customer: "Soylent", issued: "2026-01-20", status: "Draft", total: 3802.54, tax: 0.125 }, { number: "INV-1040", customer: "Wayne Enterprises", issued: "2026-06-15", status: "Open", total: 10589.85, tax: 0.08 }, { number: "INV-1041", customer: "Umbrella Health", issued: "2026-04-26", status: "Paid", total: 3334.0, tax: 0 }, { number: "INV-1042", customer: "Soylent", issued: "2026-05-17", status: "Paid", total: 8972.68, tax: 0.2 }, { number: "INV-1043", customer: "Umbrella Health", issued: "2026-02-04", status: "Paid", total: 9273.36, tax: 0.08 }, { number: "INV-1044", customer: "Globex", issued: "2026-08-14", status: "Draft", total: 821.03, tax: 0 }, { number: "INV-1045", customer: "Cyberdyne", issued: "2026-09-19", status: "Paid", total: 14229.0, tax: 0.08 }, { number: "INV-1046", customer: "Wonka Foods", issued: "2026-06-20", status: "Open", total: 9000.55, tax: 0 }, { number: "INV-1047", customer: "Tyrell Corp", issued: "2026-02-09", status: "Draft", total: 8596.88, tax: 0 }, { number: "INV-1048", customer: "Northwind Traders", issued: "2026-05-21", status: "Draft", total: 10453.0, tax: 0.2 }, { number: "INV-1049", customer: "Umbrella Health", issued: "2026-07-22", status: "Open", total: 6324.45, tax: 0.08 }, { number: "INV-1050", customer: "Globex", issued: "2026-02-16", status: "Paid", total: 1174.11, tax: 0.05 }, { number: "INV-1051", customer: "Wonka Foods", issued: "2026-04-13", status: "Open", total: 7110.18, tax: 0 }, { number: "INV-1052", customer: "Globex", issued: "2026-08-13", status: "Paid", total: 9943.99, tax: 0.2 }, { number: "INV-1053", customer: "Tyrell Corp", issued: "2026-09-09", status: "Paid", total: 12750.37, tax: 0.2 }, { number: "INV-1054", customer: "Initech", issued: "2026-03-03", status: "Paid", total: 3270.77, tax: 0.05 }, { number: "INV-1055", customer: "Northwind Traders", issued: "2026-08-27", status: "Paid", total: 10653.0, tax: 0.08 }, { number: "INV-1056", customer: "Northwind Traders", issued: "2026-03-14", status: "Overdue", total: 9678.49, tax: 0.125 },]let data_table_demo_team = [ { name: "Ada Lovelace", role: "Engineering", hours: 1284.5, utilisation: 0.92 }, { name: "Grace Hopper", role: "Engineering", hours: 1190, utilisation: 0.875 }, { name: "Katherine Johnson", role: "Research", hours: 978.25, utilisation: 0.7 }, { name: "Hedy Lamarr", role: "Design", hours: 1012, utilisation: null },]view DataTableDemo() = <div.demo-stack> <DataTable label="Invoices" search={true} page_size={10} order_by="issued" descending={true} rows={data_table_demo_invoices} empty="No invoice matches." columns={[ { key: "number", label: "Invoice", sortable: true }, { key: "customer", label: "Customer", sortable: true }, { key: "issued", label: "Issued", format: "date", sortable: true }, { key: "status", label: "Status", sortable: true }, { key: "tax", label: "Tax", format: "percent" }, { key: "total", label: "Total", format: "money", sortable: true }, ]}/> <DataTable label="Team" zebra={true} rows={data_table_demo_team} columns={[ { key: "name", label: "Name", sortable: true }, { key: "role", label: "Team" }, { key: "hours", label: "Hours", format: "number", sortable: true }, { key: "utilisation", label: "Booked", format: "percent" }, ]}/> </div>Source
ui/data_table.qwe
// DataTable: rows of data, sorted by clicking a column's header, with an optional// search box and pages.//// <DataTable label="Invoices" search={true} page_size={10} order_by="due" rows={invoices}// columns={[// { key: "number", label: "Invoice", sortable: true },// { key: "customer", label: "Customer", sortable: true },// { key: "due", label: "Due", format: "date", sortable: true },// { key: "total", label: "Total", format: "money", sortable: true },// ]}/>//// columns: what to show, in order. Each is { key, label, align?, sortable?, format? }:// key is the field of each row; format "number" (1,234.5), "money" ($1,234.50, or// the column's currency: "€"), "percent" (0.125 is 12.5%) or "date" (an ISO date,// "2026-03-14", shown as Mar 14, 2026). Numbers line up on the right unless align// says "left"; align: "right" does it for any column. A null shows as a dash.// rows: a list of maps. order_by: the key it starts sorted by; descending: the other way.// Clicking a sortable header sorts by it (aria-sort says which way), again the// other way. search: a box above it that keeps the rows where any column's text// has what is typed, in any case. page_size: rows per page, with Previous and Next// and "21–40 of 132". empty: what it says when no row is left.// label: the table's name for screen readers. height: a CSS height past which it// scrolls inside, with its header kept in view. zebra: shades every other row.// On a narrow screen it scrolls sideways inside its own box.import ui/theme.qwe use ui_clslet data_table_css = css`.ui-dt { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-dt-search { width: min(100%, 280px); height: 36px; padding: 0 12px 0 34px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); 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"); color: var(--ui-fg); font: inherit; }.ui-dt-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-dt-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); }.ui-dt-scroll:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-dt table { width: 100%; border-collapse: separate; border-spacing: 0; }.ui-dt th, .ui-dt td { padding: 9px 14px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--ui-border); }.ui-dt tbody tr:last-child td { border-bottom: 0; }.ui-dt 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-dt .ui-dt-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-dt-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-dt-sort:hover { color: var(--ui-fg); }.ui-dt-sort:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-dt-right .ui-dt-sort { flex-direction: row-reverse; }.ui-dt-arrow { width: 12px; height: 12px; opacity: .35; }[aria-sort] .ui-dt-sort { color: var(--ui-fg); }[aria-sort] .ui-dt-arrow { opacity: 1; color: var(--ui-accent); }.ui-dt tbody tr:hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-dt-zebra tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--ui-subtle) 55%, transparent); }.ui-dt-zebra tbody tr:hover td { background: var(--ui-subtle); }.ui-dt-empty td { padding: 28px 14px; text-align: center; color: var(--ui-muted); white-space: normal; }.ui-dt-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-dt-pages { display: flex; gap: 6px; }.ui-dt-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-dt-pages button:hover:not(:disabled) { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-dt-pages button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-dt-pages button:disabled { opacity: .5; cursor: not-allowed; }`let data_table_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// n (a whole number, 0 or more) with at least `width` digits: data_table_pad(7, 2) is "07".fn data_table_pad(n: int, width: int) -> str { let s = str(n) len(s) < width ? repeat("0", width - len(s)) + s : s}// A whole number with commas: 1234567 is "1,234,567".fn data_table_group(n: int) -> str = n < 1000 ? str(n) : data_table_group(floor(n / 1000)) + "," + data_table_pad(n % 1000, 3)// How a column shows a value. Worked out in whole numbers, so the server and the// browser write the same text.fn data_table_fmt(v, format: str?, currency: str = "$") -> str { if v == null { return "—" } if format == "date" { let p = split(slice(str(v), 0, 10), "-") let month = data_table_months[(int(p[1]) ?? 1) - 1] return len(p) == 3 && month != null ? `${month} ${int(p[2]) ?? p[2]}, ${p[0]}` : str(v) } if type_of(v) != "int" && type_of(v) != "float" { return str(v) } let sign = v < 0 ? "-" : "" match format { "money" => { let cents = round(abs(v) * 100) `${sign}${currency}${data_table_group(floor(cents / 100))}.${data_table_pad(cents % 100, 2)}` } "percent" => { let tenths = round(abs(v) * 1000) let tenth = tenths % 10 `${sign}${data_table_group(floor(tenths / 10))}${tenth != 0 ? `.${tenth}` : ""}%` } _ => { let cents = round(abs(v) * 100) let frac = cents % 100 let tail = frac == 0 ? "" : (frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${data_table_pad(frac, 2)}`) `${sign}${data_table_group(floor(cents / 100))}${tail}` } }}// Whether a column lines up on the right: numbers do, unless it says otherwise.fn data_table_right(c: map) -> bool = c.align == "right" || (c.align == null && (c.format == "number" || c.format == "money" || c.format == "percent"))component DataTable(columns: list, rows: list, order_by: str? = null, descending: bool = false, search: bool = false, page_size: int? = null, empty: str = "Nothing to show.", label: str? = null, height: str? = null, zebra: bool = false, class: str? = null) { style global data_table_css state sort_key = order_by state sort_desc = descending state query = "" state page = 0 let needle = lower(trim(query)) let found = needle == "" ? rows : rows | filter((r) => any(columns, (c) => contains(lower(data_table_fmt(r[c.key], c.format, c.currency ?? "$")), needle))) let ordered = sort_key == null ? found : found | sort_by((r) => { let v = r[sort_key] type_of(v) == "str" ? lower(v) : v }) let sorted = sort_desc ? reverse(ordered) : ordered let total = len(sorted) let size = page_size ?? (total > 0 ? total : 1) let pages = total == 0 ? 1 : ceil(total / size) let at = page >= pages ? pages - 1 : page let shown = page_size == null ? sorted : slice(sorted, at * size, at * size + size) let first_n = at * size + 1 let last_n = at * size + len(shown) <div class={ui_cls(["ui-dt", zebra && "ui-dt-zebra", class])}> {search && <input.ui-dt-search type="search" placeholder="Search" aria-label={label != null ? `Search ${lower(label)}` : "Search"} value={query} @input={(e) => { query = e.value; page = 0 }}/>} <div.ui-dt-scroll style={height != null ? `max-height: ${height}` : null}> <table aria-label={label}> <thead> <tr> {for c in columns => <th scope="col" class={data_table_right(c) ? "ui-dt-right" : null} aria-sort={sort_key == c.key ? (sort_desc ? "descending" : "ascending") : null}> {c.sortable == true ? <button.ui-dt-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-dt-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-dt-empty><td colspan={str(len(columns))}>{empty}</td></tr> : (for r in shown => <tr> {for c in columns => <td class={data_table_right(c) ? "ui-dt-right" : null}> {data_table_fmt(r[c.key], c.format, c.currency ?? "$")} </td>} </tr>)} </tbody> </table> </div> {page_size != null && <div.ui-dt-foot> <span aria-live="polite">{total == 0 ? "No rows" : `${first_n}–${last_n} of ${total}`}</span> {pages > 1 && <div.ui-dt-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>}Related
DataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 downloadsAuditLogWho 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 downloadsEditableTableA grid edited in place: text, number, select, checkbox and date cells, checked, sorted, with rows added and deleted.7 views · 5 downloadsExpandableTableRows with a button that opens a panel of details under them: labelled fields, a note, or your own markup.5 views · 6 downloads
Thread
Thread 0 posts
On DataTable: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.