JsonViewer
A map or list as collapsible JSON, coloured by type, with sizes, expand all and the path of any value.
Demo
- {
- "id":"ord_8f2k1",
- "status":"paid",
- "total":149.9,
- "currency":"EUR",
- "paid":true,
- "coupon":null,
- "customer":{…}3 keys,
- "items":[…]2 items,
- "tags":[],
- "shipping":{…}2 keys
- [
- 0:{
- "flag":"new-editor",
- "on":true,
- "rollout":0.25
- 1:{
- "flag":"dark-mode",
- "on":false,
- "rollout":0
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_viewer.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/json_viewer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/json_viewer.qwe
let json_viewer_demo_order = { id: "ord_8f2k1", status: "paid", total: 149.9, currency: "EUR", paid: true, coupon: null, customer: { name: "Ada Lovelace", email: "ada@example.com", "loyalty tier": "gold" }, items: [ { sku: "KB-01", name: "Mechanical keyboard", qty: 1, price: 119.9 }, { sku: "CB-USB", name: "USB-C cable", qty: 2, price: 15 }, ], tags: [], shipping: { method: "express", address: { city: "London", zip: "NW1 6XE", lines: ["12 Baker St", "Flat 2"] } },}view JsonViewerDemo() = <div.demo-stack> <JsonViewer id="jv-order" label="Order" value={json_viewer_demo_order} expanded={1} root="order"/> <JsonViewer id="jv-list" label="Feature flags" value={[{ flag: "new-editor", on: true, rollout: 0.25 }, { flag: "dark-mode", on: false, rollout: 0 }]} expanded={2} max_height="220px"/> </div>Source
ui/json_viewer.qwe
// JsonViewer: a map or list (an API's answer, a config, a log entry) shown as JSON// whose objects and arrays open and close, with each kind of value in its own// colour and the size of what is closed. The item the keys are on has its path// (.users[0].email) in the bar above, with a button that copies it.//// <JsonViewer id="resp" label="Response" value={json.parse(body)} expanded={2}/>//// id: a prefix for its ids, unique on the page. value: any map, list or value.// label: what screen readers call it. expanded: how many levels are open at first// (1: the top one). max_height: a CSS height past which it scrolls. root: what the// top is called in the bar (the path of the top itself is ".").// It is an ARIA tree: ↓ ↑ move, → opens and goes in, ← closes and goes out, Home,// End, Enter or Space opens and closes; a click on a row does too. Expand all and// Collapse all open and close everything. Paths are written as jq reads them:// .name, .items[2], .["odd key"].import ui/theme.qwe use ui_clslet json_viewer_css = css`.ui-json { min-width: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); overflow: hidden; }.ui-json-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); }.ui-json-bar button { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 9px; 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-json-bar button:hover { border-color: var(--ui-strong); }.ui-json-bar button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-json-bar svg { width: 13px; height: 13px; }.ui-json-path { flex: 1 1 160px; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }.ui-json-path code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 7px; border-radius: 4px; background: var(--ui-surface); color: var(--ui-muted); font: 12px/1.4 var(--ui-mono); }.ui-json-copied { color: var(--ui-success) !important; }.ui-json-tree { margin: 0; padding: 6px 0; list-style: none; overflow: auto; font: 13px/1.5 var(--ui-mono); outline: none; }.ui-json-row { display: flex; align-items: baseline; gap: 4px; min-height: 22px; padding: 1px 12px 1px calc(var(--ui-json-depth) * 18px + 8px); white-space: nowrap; outline: none; }.ui-json-row[role="treeitem"] { cursor: default; }.ui-json-row[aria-expanded] { cursor: pointer; }.ui-json-row[role="treeitem"]:hover { background: var(--ui-subtle); }.ui-json-row:focus-visible { box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-json-row.ui-json-on { background: var(--ui-accent-soft); }.ui-json-tog { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; align-self: center; color: var(--ui-muted); }.ui-json-tog svg { width: 10px; height: 10px; transition: transform .15s; }[aria-expanded="true"] > .ui-json-tog svg { transform: rotate(90deg); }.ui-json-key { color: var(--ui-accent); }.ui-json-idx { color: var(--ui-muted); }.ui-json-punct { color: var(--ui-muted); }.ui-json-comma { margin-left: -4px; }.ui-json-string { color: var(--ui-success); white-space: pre-wrap; overflow-wrap: anywhere; }.ui-json-number { color: var(--ui-info); }.ui-json-boolean { color: var(--ui-warning); }.ui-json-null { color: var(--ui-muted); font-style: italic; }.ui-json-size { margin-left: 4px; padding: 0 6px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font: 11px/1.6 var(--ui-font); }@media (prefers-reduced-motion: reduce) { .ui-json-tog svg { transition: none; } }`fn json_viewer_kind(v) -> str = match type_of(v) { "map" => "object" "list" => "array" "str" => "string" "bool" => "boolean" "null" => "null" _ => "number"}// The path of `key` inside `path`, as jq writes it.fn json_viewer_step(path: str, key) -> str { let base = path == "." ? "" : path type_of(key) == "int" ? `${base}[${key}]` : re.test(key, "^[A-Za-z_][A-Za-z0-9_]*$") ? `${base}.${key}` : `${base}.[${json.stringify(key)}]`}// The entries of a map or list as [key, value] pairs.fn json_viewer_pairs(v) -> list = type_of(v) == "map" ? (for [k, x] in v => [k, x]) : (for [i, x] in enumerate(v) => [i, x])// The paths of every object and array in it, the top included.fn json_viewer_containers(v, path: str = ".") -> list { let kind = json_viewer_kind(v) kind != "object" && kind != "array" ? [] : [path] + flatten(json_viewer_pairs(v) | map((p) => json_viewer_containers(p[1], json_viewer_step(path, p[0]))))}// The paths of the objects and arrays less than `depth` levels down.fn json_viewer_opened(v, depth: int, path: str = ".", level: int = 0) -> list { let kind = json_viewer_kind(v) kind != "object" && kind != "array" || level >= depth ? [] : [path] + flatten(json_viewer_pairs(v) | map((p) => json_viewer_opened(p[1], depth, json_viewer_step(path, p[0]), level + 1)))}// The rows showing: { path, key, kind, value, size, depth, parent, comma }, and a// { close, kind, depth, comma } row after each open object or array.fn json_viewer_rows(v, open: list, key = null, path: str = ".", depth: int = 0, parent = null, comma: bool = false) -> list { let kind = json_viewer_kind(v) let box = kind == "object" || kind == "array" let pairs = box ? json_viewer_pairs(v) : [] let is_open = box && len(pairs) > 0 && index_of(open, path) != null let row = { path, key, kind, value: box ? null : v, size: len(pairs), depth, parent, comma: comma && !is_open, open: is_open } !is_open ? [row] : [row] + flatten(for [i, p] in enumerate(pairs) => json_viewer_rows(p[1], open, p[0], json_viewer_step(path, p[0]), depth + 1, path, i < len(pairs) - 1)) + [{ close: true, path: `${path}/close`, kind, depth, comma }]}component JsonViewer(id: str, value = null, label: str? = null, expanded: int = 1, max_height: str? = null, root: str = "root", class: str? = null) { style global json_viewer_css state open = json_viewer_opened(value, expanded) state active = "." state result = "" // "", "ok" or "bad" if result != "" { every(1500, () => { result = "" }) } let rows = json_viewer_rows(value, open) let items = rows | filter((r) => r.close != true) let at = index_of(items | map((r) => r.path), active) ?? 0 let current = items[at] let shown_path = current.path == "." ? root : current.path let toggle = (path: str) => { open = index_of(open, path) != null ? open | filter((p) => p != path) : push(open, path) } let go = (e, path: str) => { active = path let tree = e.target.closest(".ui-json-tree") js("queueMicrotask", () => { let el = tree.querySelector(`[data-path="${url_encode(path)}"]`) el && el.focus() }) } let chev = () => <svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2L7 5 3.5 8"/></svg> <div class={ui_cls(["ui-json", class])}> <div.ui-json-bar> <button type="button" @click={() => { open = json_viewer_containers(value) }}>Expand all</button> <button type="button" @click={() => { open = [] active = "." }}>Collapse all</button> <div.ui-json-path> <code title={shown_path}><span.ui-sr>Path: </span>{shown_path}</code> <button type="button" class={result == "ok" ? "ui-json-copied" : null} aria-label={`Copy path ${shown_path}`} @click={() => { if js("isSecureContext") != true { result = "bad" } else { js("navigator.clipboard.writeText", current.path).then((_ = null) => { result = "ok" }, (_ = null) => { result = "bad" }) } }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <path d={result == "ok" ? "M20 6 9 17l-5-5" : "M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2ZM4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"}/> </svg> <span aria-hidden="true">{result == "ok" ? "Copied" : result == "bad" ? "Could not copy" : "Copy path"}</span> </button> </div> <span.ui-sr role="status">{result == "ok" ? "Path copied" : result == "bad" ? "Could not copy" : ""}</span> </div> <ul.ui-json-tree role="tree" aria-label={label} style={max_height != null ? `max-height: ${max_height}` : null} @keydown={(e) => { let n = len(items) let k = e.key if k == "ArrowDown" || k == "ArrowUp" || k == "Home" || k == "End" { e.preventDefault() // A ternary, not a `match`: a `match` here is a block the handler waits on, // and a key sent right after this one would find the old row still active. let to = k == "ArrowDown" ? (at + 1 < n ? at + 1 : at) : k == "ArrowUp" ? (at > 0 ? at - 1 : 0) : k == "Home" ? 0 : n - 1 go(e, items[to].path) } else if k == "ArrowRight" { e.preventDefault() if current.size > 0 { if !current.open { toggle(current.path) } else { go(e, items[at + 1].path) } } } else if k == "ArrowLeft" { e.preventDefault() if current.open { toggle(current.path) } else if current.parent != null { go(e, current.parent) } } else if (k == "Enter" || k == " ") && current.size > 0 { e.preventDefault() toggle(current.path) } }}> {for r in rows => r.close == true ? <li.ui-json-row key={r.path} role="none" aria-hidden="true" style={`--ui-json-depth: ${r.depth}`}> <span.ui-json-tog></span> <span.ui-json-punct>{r.kind == "object" ? "}" : "]"}{r.comma ? "," : ""}</span> </li> : <li.ui-json-row key={r.path} role="treeitem" data-path={url_encode(r.path)} class={r.path == active ? "ui-json-on" : null} style={`--ui-json-depth: ${r.depth}`} tabindex={r.path == active ? "0" : "-1"} aria-level={r.depth + 1} aria-selected={r.path == active ? "true" : "false"} aria-expanded={r.size > 0 ? (r.open ? "true" : "false") : null} @click={() => { active = r.path if r.size > 0 { toggle(r.path) } }}> <span.ui-json-tog aria-hidden="true">{r.size > 0 && chev()}</span> {r.key != null && (type_of(r.key) == "int" ? <span.ui-json-idx>{r.key}<span.ui-json-punct>:</span></span> : <span.ui-json-key>{json.stringify(r.key)}<span.ui-json-punct>:</span></span>)} {r.kind == "object" || r.kind == "array" ? [<span.ui-json-punct>{r.kind == "object" ? "{" : "["}{!r.open && (r.size == 0 ? "" : "…")}{!r.open && (r.kind == "object" ? "}" : "]")}</span>, !r.open && r.size > 0 && <span.ui-json-size>{r.kind == "object" ? `${r.size} key${r.size == 1 ? "" : "s"}` : `${r.size} item${r.size == 1 ? "" : "s"}`}</span>, !r.open && r.comma && <span.ui-json-punct.ui-json-comma>,</span>] : <span class={`ui-json-${r.kind}`}>{r.kind == "string" ? json.stringify(r.value) : r.kind == "null" ? "null" : str(r.value)}<span.ui-json-punct>{r.comma ? "," : ""}</span></span>} </li>} </ul> </div>}Related
JsonExportTableJSON records as a table with a type for each column (numbers, money, dates, yes/no, links, nested), sorted by type, exported as CSV, TSV, JSON, JSON Lines, Excel, Markdown, HTML, SQL or XML.8 views · 7 downloadsActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsAuditLogWho did what to what and when, newest first, filtered by person and action, searched, and paged.5 views · 7 downloadsBarcodeA Code 128 barcode drawn as SVG from text, switching to the compact digit set for long numbers, with the text under it.4 views · 9 downloadsChangelogReleases with version, date and label, their changes grouped by kind: Added, Changed, Fixed, Security and the rest.6 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 downloads
Thread
Thread 0 posts
On JsonViewer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.