VirtualTable
A table of thousands of rows that draws only those in view, sorted and searched across all of them.
Demo
10,000 rows
| ORD-10001 | Acme Corp | Berlin | 2026-01-01 | Paid | 5 |
| ORD-10002 | Tyrell Corp | Madrid | 2026-02-10 | Open | 84.19 |
| ORD-10003 | Globex | Paris | 2026-03-19 | Overdue | 163.38 |
| ORD-10004 | Umbrella Health | Toronto | 2026-04-28 | Draft | 242.57 |
| ORD-10005 | Initech | London | 2026-05-09 | Refunded | 321.76 |
| ORD-10006 | Vandelay Imports | Oslo | 2026-06-18 | Paid | 400.95 |
| ORD-10007 | Northwind Traders | Tokyo | 2026-08-27 | Open | 480.14 |
| ORD-10008 | Wayne Enterprises | Lisbon | 2026-09-08 | Overdue | 559.33 |
| ORD-10009 | Pied Piper | New York | 2026-10-17 | Draft | 638.52 |
| ORD-10010 | Wonka Foods | São Paulo | 2026-11-26 | Refunded | 717.71 |
| ORD-10011 | Soylent | Berlin | 2026-01-06 | Paid | 796.9 |
| ORD-10012 | Cyberdyne | Madrid | 2026-02-15 | Open | 876.09 |
| ORD-10013 | Stark Industries | Paris | 2026-03-24 | Overdue | 955.28 |
| ORD-10014 | Acme Corp | Toronto | 2026-04-05 | Draft | 1,034.47 |
| ORD-10015 | Tyrell Corp | London | 2026-05-14 | Refunded | 1,113.66 |
| ORD-10016 | Globex | Oslo | 2026-07-23 | Paid | 1,192.85 |
| ORD-10017 | Umbrella Health | Tokyo | 2026-08-04 | Open | 1,272.04 |
Click a row, or press Enter on one.
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/virtual_table.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/virtual_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/virtual_table.qwe
let virtual_table_demo_customers = ["Acme Corp", "Globex", "Initech", "Northwind Traders", "Pied Piper", "Soylent", "Stark Industries", "Tyrell Corp", "Umbrella Health", "Vandelay Imports", "Wayne Enterprises", "Wonka Foods", "Cyberdyne"]let virtual_table_demo_cities = ["Berlin", "Lisbon", "London", "Madrid", "New York", "Oslo", "Paris", "São Paulo", "Tokyo", "Toronto"]let virtual_table_demo_status = ["Paid", "Open", "Overdue", "Draft", "Refunded"]// 10,000 orders, made the same way on the server and in the browser.fn virtual_table_demo_orders(n: int) -> list = for i in range(0, n) => { let day = (i * 37) % 365 let month = floor(day / 31) + 1 { number: `ORD-${10001 + i}`, customer: virtual_table_demo_customers[(i * 7) % 13], city: virtual_table_demo_cities[(i * 3) % 10], placed: `2026-${month < 10 ? "0" : ""}${month}-${day % 28 + 1 < 10 ? "0" : ""}${day % 28 + 1}`, status: virtual_table_demo_status[(i * 11) % 5], total: ((i * 7919) % 250000) / 100 + 5, } }component VirtualTableDemoLive() { state last = null let orders = virtual_table_demo_orders(10000) <div.demo-stack> <VirtualTable id="vt-orders" label="Orders" search={true} rows={orders} height={380} on_select={(r) => { last = r.number }} columns={[ { key: "number", label: "Order", sortable: true, width: "120px" }, { key: "customer", label: "Customer", sortable: true }, { key: "city", label: "City", sortable: true, width: "120px" }, { key: "placed", label: "Placed", sortable: true, width: "120px" }, { key: "status", label: "Status", sortable: true, width: "116px" }, { key: "total", label: "Total", format: "number", sortable: true, width: "110px" }, ]}/> <p.demo-note>{last == null ? "Click a row, or press Enter on one." : `Opened ${last}.`}</p> </div>}view VirtualTableDemo() = <VirtualTableDemoLive/>Source
ui/virtual_table.qwe
// VirtualTable: a table of thousands of rows that stays fast: only the rows in// view, and a few around them, are on the page, between two empty rows of the// right height. Sorting (by clicking a header) and the search box go through// every row. The header stays in view as it scrolls.//// <VirtualTable id="orders" label="Orders" search={true} order_by="placed" descending={true} rows={orders}// columns={[// { key: "number", label: "Order", sortable: true, width: "110px" },// { key: "customer", label: "Customer", sortable: true },// { key: "total", label: "Total", format: "number", sortable: true },// ]}/>//// id: a prefix for its ids, unique on the page. columns: what to show, in order,// each { key, label, sortable?, align?, width?, format? }: format "number" groups// the digits (12,345.6) and lines it up on the right, as align: "right" does;// width is a CSS width (the others share what is left). rows: a list of maps.// order_by, descending: how it is sorted at first. search: a box above it that// keeps the rows where any column has what is typed. row_height: each row's// height in pixels (36; longer text is cut with …). height: the table's height in// pixels (420). overscan: rows drawn beyond each edge (6). min_width: the CSS width// below which it scrolls sideways ("720px"), as on a phone. label: the table's name.// empty: what it says with no rows ("Nothing to show").// It is an ARIA grid that knows its full size (aria-rowcount): ↓ ↑, Page Down, Page// Up, Home and End move the row the keys are on, Enter picks it.// on_select(row): inside one of your components, called with the row picked.import ui/theme.qwe use ui_clslet virtual_table_css = css`.ui-vt { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-vt-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; }.ui-vt-search { width: min(100%, 280px); height: 36px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: inherit; }.ui-vt-search::placeholder { color: var(--ui-muted); opacity: 1; }.ui-vt-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-vt-count { color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-vt-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); overscroll-behavior: contain; }.ui-vt table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; outline: none; }.ui-vt table:focus-visible { box-shadow: inset var(--ui-focus); }.ui-vt th, .ui-vt td { box-sizing: border-box; height: var(--ui-vt-rh); padding: 0 14px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-vt td { box-shadow: inset 0 -1px 0 var(--ui-border); }.ui-vt th { position: sticky; top: 0; z-index: 1; background: var(--ui-subtle); color: var(--ui-muted); box-shadow: inset 0 -1px 0 var(--ui-border); font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }.ui-vt .ui-vt-right { text-align: right; font-variant-numeric: tabular-nums; }.ui-vt-sort { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; margin: 0 -6px; padding: 4px 6px; border: 0; border-radius: 4px; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }.ui-vt-sort:hover { color: var(--ui-fg); }.ui-vt-sort:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-vt-right .ui-vt-sort { flex-direction: row-reverse; }.ui-vt-arrow { flex: none; width: 12px; height: 12px; opacity: .35; }[aria-sort] .ui-vt-sort { color: var(--ui-fg); }[aria-sort] .ui-vt-arrow { opacity: 1; color: var(--ui-accent); }.ui-vt tbody tr:not(.ui-vt-pad) { cursor: pointer; }.ui-vt tbody tr:not(.ui-vt-pad):hover td { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-vt tr[aria-selected="true"] td { background: var(--ui-accent-soft); }.ui-vt table:focus-visible tr.ui-vt-active td { background: var(--ui-subtle); }.ui-vt table:focus-visible tr.ui-vt-active td:first-child { box-shadow: inset 3px 0 0 var(--ui-accent), inset 0 -1px 0 var(--ui-border); }.ui-vt table:focus-visible tr.ui-vt-active[aria-selected="true"] td { background: var(--ui-accent-soft); }.ui-vt .ui-vt-pad td { height: auto; padding: 0; box-shadow: none; }.ui-vt-empty td { color: var(--ui-muted); text-align: center; }`// A whole number with commas, for the count: 10000 is "10,000".fn virtual_table_count(n: int) -> str { let s = str(n) len(s) <= 3 ? s : virtual_table_count(floor(n / 1000)) + "," + slice(str(1000 + n % 1000), 1, 4)}// A number with its digits grouped and at most two decimals: 12345.678 is "12,345.68".fn virtual_table_number(v) -> str { if type_of(v) != "int" && type_of(v) != "float" { return v == null ? "—" : str(v) } let cents = round(abs(v) * 100) let frac = cents % 100 let tail = frac == 0 ? "" : (frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${slice(str(100 + frac), 1, 3)}`) `${v < 0 ? "-" : ""}${virtual_table_count(floor(cents / 100))}${tail}`}fn virtual_table_text(v, format: str?) -> str = format == "number" ? virtual_table_number(v) : (v == null ? "—" : str(v))fn virtual_table_right(c: map) -> bool = c.align == "right" || (c.align == null && c.format == "number")// The row a key moves to from row `at` of `total`, or null for a key that does not move.// A top-level fn: a `match` in the handler would make what follows it, `preventDefault`// and the move, wait a turn, and a second key pressed in that time would start from the// old row.fn virtual_table_key_to(key: str, at, total, page) = match key { "ArrowDown" => min([total - 1, at + 1]) "ArrowUp" => max([0, at - 1]) "PageDown" => min([total - 1, at + page]) "PageUp" => max([0, at - page]) "Home" => 0 "End" => total - 1 _ => null}component VirtualTable(id: str, columns: list = [], rows: list = [], order_by: str? = null, descending: bool = false, search: bool = false, row_height: int = 36, height: int = 420, overscan: int = 6, min_width: str = "720px", label: str? = null, empty: str = "Nothing to show", on_select = null, class: str? = null) { style global virtual_table_css state sort_key = order_by state sort_desc = descending state query = "" state top = 0 state active = 0 state picked = null // the index in `rows` of the row picked let needle = lower(trim(query)) let numbered = for [i, r] in enumerate(rows) => { i, r } let found = needle == "" ? numbered : numbered | filter((x) => any(columns, (c) => contains(lower(virtual_table_text(x.r[c.key], c.format)), needle))) let ordered = sort_key == null ? found : found | sort_by((x) => { let v = x.r[sort_key] type_of(v) == "str" ? lower(v) : v }) let sorted = sort_desc ? reverse(ordered) : ordered let total = len(sorted) let from_i = max([0, floor(top / row_height) - overscan]) let to_i = min([total, ceil((top + height) / row_height) + overscan]) let window = range(from_i, to_i) | map((k) => { k, x: sorted[k] }) let at = active < total ? active : max([0, total - 1]) // Moves the keys to row k, scrolling it into view below the header. let go = (e, k: int) => { active = k let box = e.target.closest(".ui-vt-scroll") let body = box.clientHeight - row_height let y = k * row_height if y < box.scrollTop { box.scrollTop = y top = y } else if y + row_height > box.scrollTop + body { box.scrollTop = y + row_height - body top = y + row_height - body } } let pick = (k: int) => { active = k picked = sorted[k].i on_select && on_select(sorted[k].r) } let restart = (e) => { active = 0 top = 0 let box = e.target.closest(".ui-vt").querySelector(".ui-vt-scroll") box.scrollTop = 0 } <div class={ui_cls(["ui-vt", class])} style={`--ui-vt-rh: ${row_height}px`}> <div.ui-vt-top> {search && <input.ui-vt-search type="search" placeholder="Search" aria-controls={`${id}-grid`} aria-label={label != null ? `Search ${lower(label)}` : "Search"} value={query} @input={(e) => { query = e.value restart(e) }}/>} <span.ui-vt-count aria-live="polite"> {needle == "" ? `${virtual_table_count(total)} row${total == 1 ? "" : "s"}` : `${virtual_table_count(total)} of ${virtual_table_count(len(rows))} rows`} </span> </div> <div.ui-vt-scroll style={`height: ${height}px`} @scroll={(e) => { top = e.scrollTop }}> <table id={`${id}-grid`} role="grid" tabindex="0" aria-label={label} style={`min-width: ${min_width}`} aria-rowcount={total + 1} aria-colcount={len(columns)} aria-activedescendant={total > 0 ? `${id}-row-${at}` : null} @keydown={(e) => { let page = max([1, floor(height / row_height) - 2]) let to = virtual_table_key_to(e.key, at, total, page) if e.target.tagName != "TABLE" || total == 0 { null } else if to != null { e.preventDefault() go(e, to) } else if e.key == "Enter" || e.key == " " { e.preventDefault() pick(at) } }}> <colgroup>{for c in columns => <col style={c.width != null ? `width: ${c.width}` : null}/>}</colgroup> <thead> <tr aria-rowindex="1"> {for c in columns => <th scope="col" role="columnheader" class={virtual_table_right(c) ? "ui-vt-right" : null} aria-sort={sort_key == c.key ? (sort_desc ? "descending" : "ascending") : null}> {c.sortable == true ? <button.ui-vt-sort type="button" @click={(e) => { if sort_key == c.key { sort_desc = !sort_desc } else { sort_key = c.key; sort_desc = false } restart(e) }}> <span style="overflow: hidden; text-overflow: ellipsis">{c.label ?? c.key}</span> <svg.ui-vt-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-vt-empty><td colspan={str(len(columns))}>{empty}</td></tr> : [ <tr.ui-vt-pad aria-hidden="true"><td colspan={str(len(columns))} style={`height: ${from_i * row_height}px`}></td></tr>, for w in window => <tr key={w.k} id={`${id}-row-${w.k}`} aria-rowindex={w.k + 2} class={w.k == at ? "ui-vt-active" : null} aria-selected={picked == w.x.i ? "true" : "false"} @mousedown={(e) => { e.preventDefault(); e.target.closest("table").focus() }} @click={() => { pick(w.k) }}> {for c in columns => <td class={virtual_table_right(c) ? "ui-vt-right" : null}> {virtual_table_text(w.x.r[c.key], c.format)} </td>} </tr>, <tr.ui-vt-pad aria-hidden="true"><td colspan={str(len(columns))} style={`height: ${(total - to_i) * row_height}px`}></td></tr>, ]} </tbody> </table> </div> </div>}Related
AuditLogWho 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 downloadsDataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 downloadsDataTableRows of data sorted by their headers, with search, pages, number, money, percent and date columns.5 views · 9 downloadsEditableTableA grid edited in place: text, number, select, checkbox and date cells, checked, sorted, with rows added and deleted.7 views · 5 downloads
Thread
Thread 0 posts
On VirtualTable: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.