@mrjf/tabelle
v0.3.0
Published
a single-page, URL-addressable list table — sort, filter, group; every view is an address
Downloads
173
Readme
tabelle
The URL is the interface. Every sort, every filter, every view of the table is an address you can share, bookmark, and query.
| Field | Value |
| --- | --- |
| Package | tabelle |
| Artifact | one ES module + one theme stylesheet (calendar/map companions live in-repo, not yet published) |
| Dependencies | none |
| Inspiration | swiss.ziki.boo |
| In production | the table listings on lite.cat sites (bayai.lite.cat, …) and mve.cat sites |
| Pairs with | sapi — the page and the API share one query function |
01 — Install
$ npm install github:mrjf/tabelleOr copy the two files; they are the whole library.
$ cp node_modules/tabelle/listtable.js .
$ cp node_modules/tabelle/themes/swiss.css .02 — Use
One table element, one dataset, one query function, one column spec.
<link rel="stylesheet" href="themes/swiss.css">
<h1>swiss graphic design</h1>
<table id="works"></table>
<script type="module">
import { initListTable } from "./listtable.js";
import query from "./query.js";
initListTable({
table: "#works",
data,
query, // query(data, params) -> rows
rowHref: (r) => r.url, // click a row, go to its source
defaultSort: "designer", // the order the data already arrives in
columns: [
{ id: "designer", param: "designer", sortable: true, group: true,
width: "30%", variants: ["strong"],
key: (r) => r.designer, label: (r) => r.designer, value: (r) => r.designer },
{ id: "work", label: (r) => r.work, href: (r) => r.url, width: "40%" },
{ id: "year", param: "year", sortable: true, variants: ["mono"],
key: (r) => r.year, label: (r) => String(r.year), value: (r) => String(r.year) },
{ id: "kind", param: "kind", group: true, variants: ["tag", "muted"],
key: (r) => r.kind, label: (r) => r.kind, value: (r) => r.kind },
],
});
</script>All state lives in the URL. ?designer=Max+Bill&sort=-year is a view; reload it, share it, point an agent at it.
03 — Column spec
| Key | Meaning |
| --- | --- |
| id | cell class name; also the sort key when param is absent |
| name | column name revealed on header hover (defaults to id) |
| param | URL parameter the column filters on (omit for display-only) |
| sortable | include in header sort cycling (uses param as the sort key) |
| group | collapse runs of equal values into one rowspan cell; its label sticks under the header |
| width | colgroup width, e.g. "27%" |
| variants | theme hook class suffixes, e.g. ["strong"] → .lt-strong |
| key(row) | identity used for grouping and constant detection |
| label(row) | display text |
| value(row) | URL parameter value |
| href(row) | render the cell as a plain external link instead of a filter |
| multi(row) | per-row list of { label, value } filter chips (e.g. showtimes) |
04 — Interaction model
| Gesture | Result |
| --- | --- |
| click a header | sort cycles: descending → ascending → unsorted; the defaultSort column just toggles |
| hover a header | the column's name appears (headers stay bare otherwise) |
| click a cell value | filter to that value; click it again to clear |
| hover a cell value | underline — the sign that a click here filters; rows the filter would remove fade back |
| click a filtered header | clear that filter (× appears on hover) |
| click anywhere else in a row | navigate to the row's source link |
| hover where a click would go external | the destination URL appears in a corner status chip |
| middle-click / modifier-click a row | open the source in a new tab |
| Escape | clear all filters and sorting |
| back / forward | walk your view history; state is the URL |
Constants are lifted: when every visible row shares a value, its column header carries it instead of repeating it down the page. Grouped values pin under the header while their rows scroll past, with a hairline marking the extent of the run.
The sort arrow always tells the truth: declare defaultSort ("key" or "-key") when the dataset already arrives ordered, and that column shows a fainter arrow even before anyone clicks — the default order is real, it just isn't a URL state.
05 — Theme
themes/swiss.css is the complete look of a page: monochrome stone palette, IBM Plex, hairline rules, a sticky title and header stack, automatic dark mode. Structural rules the engine depends on live in the theme — a theme is not decoration over a default.
| Hook | Use |
| --- | --- |
| --bg --fg --muted --line | palette, light and dark |
| --title-size --title-block | sticky title geometry |
| .lt-strong | medium weight |
| .lt-mono | IBM Plex Mono, small |
| .lt-muted | secondary ink |
| .lt-tag | uppercase, letterspaced |
| .lt-nowrap | no wrapping |
06 — One query function, two consumers
query(data, params) is the only logic tabelle asks of you, and it is the same function a sapi site serves to agents as query.js. The page filters with it in the browser; clients run it locally against data.json. One function, so the page and the API can never disagree.
Sort semantics live there too: a sort key need not be a raw field. The demo's query.js keeps a SORT_KEYS map of derived comparables — sort=designer orders by last name, because that is how people sort names — and the engine never knows; it only writes ?sort= into the URL.
07 — Title-bar chrome
| Export | Renders |
| --- | --- |
| initListTable(config) | the table; returns { apply, rows } |
| initDownload({ container, filename, rows }) | a download dropdown: json / csv / xml of the current view |
| initAbout({ container, html }) | an about dropdown in the title bar |
| initSubscribe({ container, feeds }) | a subscribe dropdown: feed selector + google / apple / outlook / ics / rss links |
The download dropdown is on by default: initListTable adds it to the page's <h1> without being asked. Disable it with download: false, name the file with downloadFilename, or point chromeContainer at a different title element.
Companions (not yet published)
calendar.js and map.js live in this repo but are not part of the published @mrjf/tabelle package yet — they'll ship in a later release. Until then, vendor a copy from this repo if you want them.
Calendar companion
tabelle/calendar.js exports initCalendar({ container, events, date, end, label }) — one bare-bones Sunday-first week grid covering the rows, showing only the weeks that contain events; there are no month headers (the first of a month is labeled aug 1), and multi-day events land on every day they cover. It holds no state: pair its returned render() with initListTable's onApply(rows) hook and the calendar mirrors every filter and sort.
Map companion
tabelle/map.js exports initMap({ container, events, where, places, onHover }) — real outlines on the barest tiles: a Leaflet map over CARTO's no-label basemap (light/dark follows the color scheme; OpenStreetMap data, attribution kept — the tiles require it), carrying nothing except what the rows mention. Every place becomes a labeled dot (places is a { name: [lat, lon] } legend); a row whose where is a trajectory ("A → B", any number of stops) draws a hairline through its stops, and a single-place row draws a ring around its dot. The view refits to the filtered rows on every render(). Bring your own Leaflet (global L, or pass leaflet:) — tabelle itself stays dependency-free; override tiles with tileUrl/tileAttribution. Same contract as the calendar: render(), highlight(row|null), onHover.
Hovering an entry in any companion spotlights it everywhere: cross-wire each side's onHover to the others' highlight.
let calendar;
const table = initListTable({
...,
onApply: () => calendar?.render(),
onHover: (row) => calendar?.highlight(row),
});
calendar = initCalendar({
container: "#calendar",
events: () => table.rows(),
date: (r) => r.start, end: (r) => r.end, label: (r) => r.title,
onHover: (row) => table.highlight(row),
});08 — Demo
demo/index.html is one self-contained file — engine, theme, data, and query inlined. Open it straight from disk; no server, no build step, no network. Filtering and sorting work on file:// (the URL just can't reflect state there).
$ open demo/index.html| Part | Role |
| --- | --- |
| demo/data.json | the dataset — the Swiss graphic design canon |
| demo/query.js | the query function, sapi-conformant |
| demo/schema.json | JSON Schema + x-sapi parameter docs |
| demo/page.js | column spec and page chrome |
| demo/index.html | the build product — do not edit by hand |
Edit the parts, then rebuild with npm run build:demo (or npm run demo to build and serve). Served over http, the demo directory is also a complete sapi site: agents can query data.json with query.js instead of reading the page.
License: MIT
