JsonTable
JSON 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.
Demo
3,000 rows
| # | id | name | active | plan | score | address | tags | orders | manager | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1000 | Ada Lovelace | ada.0@example.com | false | free | 0 | null | |||
| 1 | 1001 | Grace Hopper | grace.1@example.com | true | enterprise | 3.7 | 1001 | |||
| 2 | 1002 | Alan Turing | alan.2@example.com | true | business | 7.4 | 1002 | |||
| 3 | 1003 | Katherine Johnson | katherine.3@example.com | true | team | 11.1 | 1003 | |||
| 4 | 1004 | Linus Torvalds | linus.4@example.com | true | free | 14.8 | 1004 | |||
| 5 | 1005 | Margaret Hamilton | margaret.5@example.com | true | enterprise | 18.5 | 1005 | |||
| 6 | 1006 | Dennis Ritchie | dennis.6@example.com | true | business | 22.2 | 1006 | |||
| 7 | 1007 | Barbara Liskov | barbara.7@example.com | false | team | 25.9 | 1007 | |||
| 8 | 1008 | Ken Thompson | ken.8@example.com | true | free | 29.6 | 1008 | |||
| 9 | 1009 | Radia Perlman | radia.9@example.com | true | enterprise | 33.3 | null | |||
| 10 | 1010 | Edsger Dijkstra | edsger.10@example.com | true | business | 37 | 1010 | |||
| 11 | 1011 | Ada Lovelace | ada.11@example.com | true | team | 40.7 | 1011 | |||
| 12 | 1012 | Grace Hopper | grace.12@example.com | true | free | 44.4 | 1012 | |||
| 13 | 1013 | Alan Turing | alan.13@example.com | true | enterprise | 48.1 | 1013 | |||
| 14 | 1014 | Katherine Johnson | katherine.14@example.com | false | business | 51.8 | 1014 | |||
| 15 | 1015 | Linus Torvalds | linus.15@example.com | true | team | 55.5 | 1015 | |||
| 16 | 1016 | Margaret Hamilton | margaret.16@example.com | true | free | 59.2 | 1016 | |||
| 17 | 1017 | Dennis Ritchie | dennis.17@example.com | true | enterprise | 62.9 | 1017 | |||
| 18 | 1018 | Barbara Liskov | barbara.18@example.com | true | business | 66.6 | null | |||
| 19 | 1019 | Ken Thompson | ken.19@example.com | true | team | 70.3 | 1019 |
3,000 users and 50,000 events. Open a cell's object or array, open it as the table, go back by the path, search, export.
7 keys
| Key | value |
|---|---|
| name | billing-worker |
| replicas | 3 |
| max-retries | 5 |
| debug | false |
| region | null |
| queues | |
| database |
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/json_table.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/json_table.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/json_table.qwe
let json_table_demo_names = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Katherine Johnson", "Linus Torvalds", "Margaret Hamilton", "Dennis Ritchie", "Barbara Liskov", "Ken Thompson", "Radia Perlman", "Edsger Dijkstra"]let json_table_demo_cities = ["Berlin", "Lisbon", "London", "Madrid", "New York", "Oslo", "Paris", "São Paulo", "Tokyo", "Toronto"]let json_table_demo_plans = ["free", "team", "business", "enterprise"]let json_table_demo_tags = ["beta", "vip", "churn-risk", "partner", "trial", "invoice"]let json_table_demo_kinds = ["login", "view", "order", "refund", "logout"]// An API's answer: { meta, users (3,000), events (50,000) }, made the same way on// the server and in the browser.fn json_table_demo_data(n: int, events: int) -> map = { meta: { source: "accounts-api", version: 3, generated: "2026-10-28T09:00:00Z", paging: { page: 1, per_page: n, total: n } }, users: for i in range(0, n) => { let name = json_table_demo_names[i % 11] { id: 1000 + i, name, email: `${lower(split(name, " ")[0])}.${i}@example.com`, active: i % 7 != 0, plan: json_table_demo_plans[(i * 3) % 4], score: ((i * 37) % 1000) / 10, address: { city: json_table_demo_cities[i % 10], zip: `${10000 + (i * 13) % 89999}`, geo: { lat: ((i * 7) % 180) - 90, lng: ((i * 11) % 360) - 180 } }, tags: take(drop(json_table_demo_tags, i % 3), i % 4), orders: for j in range(0, i % 4) => { number: `ORD-${20000 + i * 4 + j}`, total: ((i * 7919 + j * 104729) % 50000) / 100, items: for k in range(0, j + 1) => { sku: `SKU-${(i + k) % 97}`, qty: k + 1 }, }, manager: i % 9 == 0 ? null : 1000 + (i % 50), } }, events: for i in range(0, events) => { at: 1767225600 + i * 61, kind: json_table_demo_kinds[(i * 7) % 5], user: 1000 + (i * 31) % n },}component JsonTableDemoLive() { let data = json_table_demo_data(3000, 50000) <JsonTable id="jt-api" label="Accounts API" value={data} path={["users"]} filename="accounts" height={400}/>}view JsonTableDemo() = <div.demo-stack> <JsonTableDemoLive/> <p.demo-note>3,000 users and 50,000 events. Open a cell's object or array, open it as the table, go back by the path, search, export.</p> <JsonTable id="jt-config" label="Settings" height={240} filename="settings" value={{ name: "billing-worker", replicas: 3, "max-retries": 5, debug: false, region: null, queues: ["invoices", "refunds", "payouts"], database: { url: "postgres://db.internal/billing", pool: 20, timeout_ms: 1500.5 }, }}/> </div>Source
ui/json_table.qwe
// JsonTable: JSON as a table to dig into. A list of records is a table with a// column for each key; a map is a table of its keys and values; a list of values is// one column. A cell holding an object or array opens below its row as indented// lines, which open further, and Open makes any of them the table, with a path bar// back up. Every line, opened ones too, is one height and only those in view are// drawn, so a list of 100,000 items scrolls as one of ten does. The search box keeps// the rows where any key or value, at any depth, has what is typed, and marks it.// Export saves the table (the rows found) as JSON or CSV, and each opened object or// array as JSON.//// <JsonTable id="api" label="Orders API" value={json.parse(body)} path={["data", "orders"]}/>//// id: a prefix for its ids, unique on the page. value: any map, list or value.// path: where it starts, as keys and indexes from the top (["data", "orders"]).// root: what the top is called in the path bar ("root"). label: what screen readers// call it. search: the search box (true). filename: how exported files' names start// ("data": data-orders.json). row_height: each line's height in pixels (34).// height: the table's height in pixels (420). overscan: lines drawn beyond each// edge (8). max_columns: the most columns a list of records shows (24), keys first// seen first; the others are still searched and exported. empty: what it says with// no rows ("Nothing to show").// It is an ARIA treegrid: ↓ ↑, Page Down, Page Up, Home and End move; → opens the// line (on a row, its first closed cell), ← closes it or goes to the line it is in;// Enter opens an object or array as the table, and Backspace goes back up the path.import ui/theme.qwe use ui_clslet json_table_css = css`.ui-jt { display: grid; gap: 10px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-jt-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 14px; min-width: 0; }.ui-jt-path ol { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; margin: 0; padding: 0; list-style: none; }.ui-jt-path li { display: flex; align-items: center; gap: 2px; min-width: 0; }.ui-jt-path li + li::before { content: "›"; padding: 0 3px; color: var(--ui-muted); }.ui-jt-path button { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 7px; border: 0; border-radius: 4px; background: none; color: var(--ui-accent); font: 500 13px/1.3 var(--ui-mono); cursor: pointer; }.ui-jt-path button:hover { background: var(--ui-subtle); }.ui-jt-path button[aria-current] { color: var(--ui-fg); cursor: default; }.ui-jt-path button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-jt-jq { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 8px; border-radius: 4px; background: var(--ui-subtle); color: var(--ui-muted); font: 12px/1.4 var(--ui-mono); }.ui-jt-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }.ui-jt-search { flex: 0 1 280px; min-width: 160px; 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-jt-search::placeholder { color: var(--ui-muted); opacity: 1; }.ui-jt-search:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-jt-count { flex: 1; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-jt-export { display: flex; gap: 6px; }.ui-jt-btn { display: inline-flex; align-items: center; gap: 5px; height: 30px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 12px/1 var(--ui-font); cursor: pointer; }.ui-jt-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-jt-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-jt-btn svg { width: 13px; height: 13px; }.ui-jt-scroll { overflow: auto; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); overscroll-behavior: contain; container-type: inline-size; }.ui-jt table { width: 100%; table-layout: fixed; border-collapse: separate; border-spacing: 0; outline: none; }.ui-jt table:focus-visible { box-shadow: inset var(--ui-focus); }.ui-jt th, .ui-jt td { box-sizing: border-box; height: var(--ui-jt-rh); padding: 0 12px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-shadow: inset 0 -1px 0 var(--ui-border); }.ui-jt thead 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; }.ui-jt-headcol { width: 64px; }.ui-jt-entries .ui-jt-headcol { width: 30%; }.ui-jt tbody th { color: var(--ui-muted); font: 12px/1 var(--ui-mono); font-weight: 400; }.ui-jt-entries tbody th { color: var(--ui-accent); font-size: 13px; }.ui-jt tbody tr:not(.ui-jt-pad):hover > * { background: color-mix(in srgb, var(--ui-subtle) 70%, transparent); }.ui-jt table:focus-visible tr.ui-jt-active > * { background: var(--ui-subtle); }.ui-jt table:focus-visible tr.ui-jt-active > :first-child { box-shadow: inset 3px 0 0 var(--ui-accent), inset 0 -1px 0 var(--ui-border); }.ui-jt .ui-jt-pad td { height: auto; padding: 0; box-shadow: none; }.ui-jt-empty td { color: var(--ui-muted); text-align: center; }.ui-jt-string { color: var(--ui-fg); }.ui-jt-number { color: var(--ui-info); font-variant-numeric: tabular-nums; }.ui-jt-boolean { color: var(--ui-warning); }.ui-jt-null, .ui-jt-none { color: var(--ui-muted); font-style: italic; }.ui-jt mark { padding: 0 1px; border-radius: 2px; background: color-mix(in srgb, var(--ui-warning) 35%, transparent); color: inherit; }.ui-jt-cell { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 2px 7px 2px 4px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-subtle); color: var(--ui-fg); font: 12px/1.3 var(--ui-mono); cursor: pointer; }.ui-jt-cell:hover { border-color: var(--ui-strong); }.ui-jt-cell[aria-expanded="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-jt-chev { flex: none; width: 10px; height: 10px; color: var(--ui-muted); transition: transform .15s; }[aria-expanded="true"] > .ui-jt-chev, [aria-expanded="true"] .ui-jt-tog .ui-jt-chev { transform: rotate(90deg); }.ui-jt-node td { padding: 0; background: color-mix(in srgb, var(--ui-subtle) 45%, transparent); }.ui-jt-node[aria-expanded] { cursor: pointer; }.ui-jt-line { position: sticky; left: 0; box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100cqw; max-width: 100%; height: 100%; padding-right: 8px; font: 13px/1 var(--ui-mono); }.ui-jt-tog { flex: none; display: inline-flex; justify-content: center; width: 14px; }.ui-jt-key { flex: none; color: var(--ui-accent); }.ui-jt-idx { flex: none; color: var(--ui-muted); }.ui-jt-val { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-jt-size { flex: none; padding: 0 6px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font: 11px/1.6 var(--ui-font); }.ui-jt-acts { display: flex; gap: 4px; margin-left: auto; opacity: .55; }.ui-jt-node:hover .ui-jt-acts, .ui-jt-node.ui-jt-active .ui-jt-acts { opacity: 1; }.ui-jt-acts .ui-jt-btn { height: 24px; padding: 0 8px; font-size: 11px; }@media (prefers-reduced-motion: reduce) { .ui-jt-chev { transition: none; } }`fn json_table_kind(v) -> str = match type_of(v) { "map" => "object" "list" => "array" "str" => "string" "bool" => "boolean" "null" => "null" _ => "number"}fn json_table_box(v) -> bool = type_of(v) == "map" || type_of(v) == "list"fn json_table_size(v) -> int = type_of(v) == "map" ? len(keys(v)) : len(v)// "3 keys", "1 item": what a closed object or array holds.fn json_table_size_text(v) -> str { let n = json_table_size(v) type_of(v) == "map" ? `${json_table_count(n)} key${n == 1 ? "" : "s"}` : `${json_table_count(n)} item${n == 1 ? "" : "s"}`}// The entries of a map or list as [key, value] pairs.fn json_table_pairs(v) -> list = type_of(v) == "map" ? (for [k, x] in v => [k, x]) : (for [i, x] in enumerate(v) => [i, x])// The value at `segs` (keys and indexes) inside v, or null.fn json_table_at(v, segs: list) { if len(segs) == 0 { return v } let k = segs[0] let next = type_of(v) == "map" ? get(v, str(k)) : (type_of(v) == "list" && type_of(k) == "int" && k >= 0 && k < len(v) ? v[k] : null) json_table_at(next, slice(segs, 1, len(segs)))}// The path as jq writes it: .users[3].address, .["odd key"], or "." for the top.fn json_table_jq(segs: list) -> str { let s = join(segs | map((k) => type_of(k) == "int" ? `[${k}]` : (re.test(k, "^[A-Za-z_][A-Za-z0-9_]*$") ? `.${k}` : `.[${json.stringify(k)}]`)), "") s == "" ? "." : (starts_with(s, "[") ? "." + s : s)}// A whole number with commas: 10000 is "10,000".fn json_table_count(n: int) -> str { let s = str(n) len(s) <= 3 ? s : json_table_count(floor(n / 1000)) + "," + slice(str(1000 + n % 1000), 1, 4)}// A number as JSON writes it, the same on the server and in the browser (where a// whole float such as 2.0 is 2).fn json_table_num(v) -> str = type_of(v) == "float" && v == floor(v) && abs(v) < 1000000000000000 ? str(floor(v)) : str(v)// A value as one line of text: strings as they are, objects and arrays as their size.fn json_table_text(v) -> str = match json_table_kind(v) { "string" => v "null" => "null" "boolean" => v ? "true" : "false" "number" => json_table_num(v) _ => json_table_size_text(v)}// Whether any key or value inside v, at any depth, has `needle` (in lower case).fn json_table_has(v, needle: str) -> bool = json_table_box(v) ? any(json_table_pairs(v), (p) => (type_of(p[0]) == "str" && contains(lower(p[0]), needle)) || json_table_has(p[1], needle)) : contains(lower(json_table_text(v)), needle)// The text cut where it has `needle` (in lower case): [{ t, hit }].fn json_table_parts(text: str, needle: str) -> list { let at = needle == "" ? null : index_of(lower(text), needle) at == null ? [{ t: text, hit: false }] : [{ t: slice(text, 0, at), hit: false }, { t: slice(text, at, at + len(needle)), hit: true }] + json_table_parts(slice(text, at + len(needle), len(text)), needle)}// How the table shows v: { mode, columns, rows }. mode is "records" (a list of// maps: a column for each key), "values" (any other list), "entries" (a map: its// keys and values) or "value". Each row is { head, segs, cells: [{ col, v, none, segs }] },// segs being where it is inside v; none marks a key a record does not have.fn json_table_model(v, most: int) -> map { let kind = type_of(v) if kind == "list" && len(v) > 0 && all(v, (x) => type_of(x) == "map") { let columns = take(unique(flatten(v | map((x) => keys(x)))), most) return { mode: "records", columns, rows: for [i, x] in enumerate(v) => { head: str(i), segs: [i], cells: for c in columns => { col: c, v: get(x, c), none: !has(x, c), segs: [i, c] }, } } } if kind == "list" { return { mode: "values", columns: ["value"], rows: for [i, x] in enumerate(v) => { head: str(i), segs: [i], cells: [{ col: "value", v: x, none: false, segs: [i] }], } } } if kind == "map" { return { mode: "entries", columns: ["value"], rows: for [k, x] in v => { head: k, segs: [k], cells: [{ col: "value", v: x, none: false, segs: [k] }], } } } { mode: "value", columns: ["value"], rows: [{ head: "", segs: [], cells: [{ col: "value", v, none: false, segs: [] }] }] }}// An opened object or array, and what is opened inside it: lines of// { kind: "node", depth, label, v, segs, id, open }.fn json_table_nodes(label, v, segs: list, depth: int, open: list) -> list { let id = json.stringify(segs) let is_open = json_table_box(v) && index_of(open, id) != null let line = { kind: "node", depth, label, v, segs, id, open: is_open } !is_open ? [line] : [line] + flatten(json_table_pairs(v) | map((p) => json_table_nodes(p[0], p[1], push(segs, p[0]), depth + 1, open)))}// Every line: each row, and under it the cells opened in it.fn json_table_lines(rows: list, open: list) -> list = flatten(rows | map((r) => [{ kind: "row", row: r, id: json.stringify(r.segs), depth: 0, open: false }] + flatten(r.cells | filter((c) => json_table_box(c.v) && index_of(open, json.stringify(c.segs)) != null) | map((c) => json_table_nodes(last(c.segs), c.v, c.segs, 1, open)))))// One CSV field: quoted when it has a comma, quote or line break.fn json_table_csv_field(v) -> str { let s = v == null ? "" : (json_table_box(v) ? json.stringify(v) : json_table_text(v)) re.test(s, "[\",\\r\\n]") ? `"${join(split(s, "\""), "\"\"")}"` : s}// The rows as CSV: a column for each key of records, else the index or key and the value.fn json_table_csv(mode: str, columns: list, rows: list) -> str { let header = mode == "records" ? columns : [mode == "entries" ? "key" : "index", "value"] let body = rows | map((r) => join((mode == "records" ? [] : [json_table_csv_field(r.head)]) + (r.cells | map((c) => json_table_csv_field(c.none ? null : c.v))), ",")) join([join(header | map((h) => json_table_csv_field(h)), ",")] + body, "\r\n") + "\r\n"}// A file name from the path: data, data-users, data-users-3-orders.fn json_table_file(base: str, segs: list) -> str = join([base] + (segs | map((k) => re.replace(lower(str(k)), "[^a-z0-9_]+", "-"))), "-")component JsonTable(id: str, value = null, path: list = [], root: str = "root", label: str? = null, search: bool = true, filename: str = "data", row_height: int = 34, height: int = 420, overscan: int = 8, max_columns: int = 24, empty: str = "Nothing to show", class: str? = null) { style global json_table_css state at_path = path state open = [] state query = "" state top = 0 state active = 0 state said = "" let leaf = json_table_at(value, at_path) let model = json_table_model(leaf, max_columns) let needle = lower(trim(query)) let rows = needle == "" ? model.rows : model.rows | filter((r) => (model.mode == "entries" && contains(lower(r.head), needle)) || any(r.cells, (c) => !c.none && json_table_has(c.v, needle))) let lines = json_table_lines(rows, open) let total = len(lines) 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, ln: lines[k] }) let at = active < total ? active : max([0, total - 1]) let ncols = len(model.columns) + 1 let noun = model.mode == "entries" ? "key" : (model.mode == "values" ? "item" : "row") let toggle = (segs: list) => { let key = json.stringify(segs) open = index_of(open, key) != null ? open | filter((p) => p != key) : push(open, key) } // Moves the keys to line k, scrolling it into view below the header. let go = (e, k: int) => { active = k let box = e.target.closest(".ui-jt-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 restart = (e) => { top = 0 active = 0 let box = e.target.closest(".ui-jt").querySelector(".ui-jt-scroll") box.scrollTop = 0 } let move_to = (e, segs: list) => { at_path = segs open = [] query = "" restart(e) } let save = (text: str, name: str, mime: str) => { let url = js("URL.createObjectURL", js_new("Blob", [text], { type: mime })) let a = js("document.createElement", "a") a.href = url a.download = name a.click() js("requestAnimationFrame", (t = null) => { js("URL.revokeObjectURL", url) }) said = `Saved ${name}` } let found = () => model.mode == "entries" ? reduce(rows, {}, (m, r) => set(m, r.head, r.cells[0].v)) : (model.mode == "value" ? leaf : rows | map((r) => json_table_at(leaf, r.segs))) let chev = () => <svg.ui-jt-chev viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 2L7 5 3.5 8"/></svg> let marked = (text: str) => for p in json_table_parts(text, needle) => p.hit ? <mark>{p.t}</mark> : p.t let shown = (v) => <span class={`ui-jt-${json_table_kind(v)}`}>{json_table_kind(v) == "string" ? marked(v) : json_table_text(v)}</span> <div class={ui_cls(["ui-jt", model.mode == "entries" && "ui-jt-entries", class])} style={`--ui-jt-rh: ${row_height}px`}> <div.ui-jt-bar> <nav.ui-jt-path aria-label={label != null ? `${label}: path` : "Path"}> <ol> <li><button type="button" aria-current={len(at_path) == 0 ? "location" : null} @click={(e) => { if len(at_path) > 0 { move_to(e, []) } }}>{root}</button></li> {for [i, k] in enumerate(at_path) => <li key={i}> <button type="button" aria-current={i == len(at_path) - 1 ? "location" : null} @click={(e) => { if i < len(at_path) - 1 { move_to(e, take(at_path, i + 1)) } }}> {type_of(k) == "int" ? `[${k}]` : k} </button> </li>} </ol> </nav> <code.ui-jt-jq title="The path, as jq writes it">{json_table_jq(at_path)}</code> </div> <div.ui-jt-tools> {search && <input.ui-jt-search type="search" placeholder="Search keys and values" aria-controls={`${id}-grid`} aria-label={label != null ? `Search ${lower(label)}` : "Search"} value={query} @input={(e) => { query = e.value restart(e) }}/>} <span.ui-jt-count aria-live="polite"> {needle == "" ? `${json_table_count(len(rows))} ${noun}${len(rows) == 1 ? "" : "s"}` : `${json_table_count(len(rows))} of ${json_table_count(len(model.rows))} ${noun}s`} </span> <div.ui-jt-export> <button.ui-jt-btn type="button" aria-label={`Export ${needle == "" ? "the table" : "the rows found"} as JSON`} @click={() => { save(json.stringify(found(), true) + "\n", json_table_file(filename, at_path) + ".json", "application/json") }}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M3 13h10"/></svg> JSON </button> <button.ui-jt-btn type="button" aria-label={`Export ${needle == "" ? "the table" : "the rows found"} as CSV`} @click={() => { save(json_table_csv(model.mode, model.columns, rows), json_table_file(filename, at_path) + ".csv", "text/csv") }}> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M3 13h10"/></svg> CSV </button> </div> <span.ui-sr role="status">{said}</span> </div> <div.ui-jt-scroll style={`height: ${height}px`} @scroll={(e) => { top = e.scrollTop }}> <table id={`${id}-grid`} role="treegrid" tabindex="0" aria-label={label} style={`min-width: ${64 + len(model.columns) * 150}px`} aria-rowcount={total + 1} aria-colcount={ncols} aria-activedescendant={total > 0 ? `${id}-line-${at}` : null} @keydown={(e) => { let page = max([1, floor(height / row_height) - 2]) let ln = total > 0 ? lines[at] : null let to = match e.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 } if e.target.tagName != "TABLE" { null } else if e.key == "Backspace" && len(at_path) > 0 { e.preventDefault() move_to(e, take(at_path, len(at_path) - 1)) } else if ln == null { null } else if to != null { e.preventDefault() go(e, to) } else if e.key == "ArrowRight" { e.preventDefault() if ln.kind == "node" { if json_table_box(ln.v) && !ln.open { toggle(ln.segs) } } else { let shut = find(ln.row.cells, (c) => json_table_box(c.v) && index_of(open, json.stringify(c.segs)) == null) if shut != null { toggle(shut.segs) } } } else if e.key == "ArrowLeft" { e.preventDefault() if ln.kind == "node" && ln.open { toggle(ln.segs) } else if ln.kind == "node" { // The line it is in: the nearest one above, one level up. let k = at - 1 while k > 0 && lines[k].depth >= ln.depth { k -= 1 } go(e, k) } else { let mine = ln.row.cells | map((c) => json.stringify(c.segs)) open = open | filter((p) => index_of(mine, p) == null) } } else if e.key == "Enter" || e.key == " " { e.preventDefault() if ln.kind == "node" && json_table_box(ln.v) { if e.key == "Enter" { move_to(e, at_path + ln.segs) } else { toggle(ln.segs) } } } }}> <colgroup> <col.ui-jt-headcol/> {for c in model.columns => <col/>} </colgroup> <thead> <tr aria-rowindex="1"> <th scope="col">{model.mode == "entries" ? "Key" : "#"}</th> {for c in model.columns => <th scope="col" title={c}>{c}</th>} </tr> </thead> <tbody> {total == 0 ? <tr.ui-jt-empty><td colspan={str(ncols)}>{empty}</td></tr> : [ <tr.ui-jt-pad aria-hidden="true"><td colspan={str(ncols)} style={`height: ${from_i * row_height}px`}></td></tr>, for w in window => w.ln.kind == "row" ? <tr key={`r${w.ln.id}`} id={`${id}-line-${w.k}`} aria-rowindex={w.k + 2} aria-level="1" class={w.k == at ? "ui-jt-active" : null} @mousedown={(e) => { active = w.k if e.target.tagName != "BUTTON" { e.preventDefault(); e.target.closest("table").focus() } }}> <th scope="row">{model.mode == "entries" ? marked(w.ln.row.head) : w.ln.row.head}</th> {for c in w.ln.row.cells => <td> {c.none ? <span.ui-jt-none aria-label="none">—</span> : json_table_box(c.v) ? <button.ui-jt-cell type="button" tabindex="-1" aria-expanded={index_of(open, json.stringify(c.segs)) != null ? "true" : "false"} aria-label={`${c.col}: ${json_table_size_text(c.v)}`} @click={() => { toggle(c.segs) }}> {chev()}{type_of(c.v) == "map" ? `{${json_table_size(c.v)}}` : `[${json_table_size(c.v)}]`} </button> : shown(c.v)} </td>} </tr> : <tr.ui-jt-node key={`n${w.ln.id}`} id={`${id}-line-${w.k}`} aria-rowindex={w.k + 2} aria-level={str(w.ln.depth + 1)} aria-expanded={json_table_box(w.ln.v) ? (w.ln.open ? "true" : "false") : null} class={w.k == at ? "ui-jt-active" : null} @mousedown={(e) => { active = w.k if e.target.tagName != "BUTTON" { e.preventDefault(); e.target.closest("table").focus() } }} @click={(e) => { if json_table_box(w.ln.v) && e.target.closest("button") == null { toggle(w.ln.segs) } }}> <td colspan={str(ncols)}> <div.ui-jt-line style={`padding-left: ${w.ln.depth * 18 + 8}px`}> <span.ui-jt-tog>{json_table_box(w.ln.v) ? chev() : null}</span> {type_of(w.ln.label) == "int" ? <span.ui-jt-idx>{`[${w.ln.label}]`}</span> : <span.ui-jt-key>{marked(w.ln.label)}</span>} {json_table_box(w.ln.v) ? <span.ui-jt-size>{json_table_size_text(w.ln.v)}</span> : <span.ui-jt-val>{shown(w.ln.v)}</span>} {json_table_box(w.ln.v) ? <span.ui-jt-acts> <button.ui-jt-btn type="button" tabindex="-1" aria-label={`Open ${json_table_jq(at_path + w.ln.segs)} as the table`} @click={(e) => { move_to(e, at_path + w.ln.segs) }}>Open</button> <button.ui-jt-btn type="button" tabindex="-1" aria-label={`Export ${json_table_jq(at_path + w.ln.segs)} as JSON`} @click={() => { save(json.stringify(w.ln.v, true) + "\n", json_table_file(filename, at_path + w.ln.segs) + ".json", "application/json") }}>JSON</button> </span> : null} </div> </td> </tr>, <tr.ui-jt-pad aria-hidden="true"><td colspan={str(ncols)} style={`height: ${(total - to_i) * row_height}px`}></td></tr>, ]} </tbody> </table> </div> </div>}Related
DataGridSort, search, filter by a column's values, pick rows and page through them, in one table.5 views · 8 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 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 downloadsDataTableRows of data sorted by their headers, with search, pages, number, money, percent and date columns.5 views · 9 downloads
Thread
Thread 0 posts
On JsonTable: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.