{# The data table: structure, sortable headers, batch selection and the pagination that goes under it. {% call table( columns=[{"label": "Task", "sort": "asc", "sort_url": "/tasks?o=-title"}, {"label": "Owner"}, {"width": "min"}], rows=tasks, target="#board", empty_title="Nothing here", empty_description="No task matches this filter.") %} {% for task in tasks %}{{ task_row(request, task) }}{% endfor %} {% endcall %} {{ pagination(page, pages, url_for(request, "tasks_page"), target="#board") }} Passing `rows` gives the macro the empty case, so no caller repeats the "if there are rows … else …" branch. Pass `rows=none` to always render the body yourself. Rows are a macro call in the caller's loop, never `{% include %}`: include costs a template lookup plus a fresh context object per iteration, and the gap widens linearly with row count. #} {% from "ui/attrs.html" import attrs %} {% from "ui/button.html" import button %} {% from "ui/data.html" import empty_state %} {% from "ui/icon.html" import icon %} {% set _ALIGN = {"start": "text-left", "center": "text-center", "end": "text-right"} %} {% set _WIDTH = {"min": "w-px", "narrow": "w-16", "auto": ""} %} {# `aria-sort` is what a screen reader reads out of the header; the glyph is what everyone else reads. Both are keyed off one value, so they cannot disagree — an arrow pointing up over `aria-sort="descending"` is the usual way this component goes wrong. A third state is in neither table: sortable, but not the column currently sorted. It falls through to `aria-sort="none"` and the two-headed chevron. Without that chevron a sortable column looks exactly like a fixed one. #} {% set _ARIA_SORT = {"asc": "ascending", "desc": "descending"} %} {% set _SORT_ICON = {"asc": "arrow-up", "desc": "arrow-down"} %} {# table(columns, rows, …) — the and its header row. A column is a dict, and every key is optional: label header text align start | center | end width min | narrow | auto sort asc | desc | none this column's current state sort_url where clicking the header goes; its presence is what makes the column sortable select true, for the batch-selection column group a heading over this column, in a row above the labels `sort_url` is a URL the caller built, not a column key the macro turns into one. The router already owns the query string — it is the thing that knows which parameter carries the order, what the other filters are, and that clicking the sorted column reverses it rather than re-applying it. A macro that guessed would be wrong on the first page with two filters on it. `target` decides how the header links travel, and decides it once for the whole table: table(columns, target="#board") -> href + hx-get + hx-target + hx-swap table(columns) -> href Both render an ``, so sorting works with JavaScript off and a sorted view is a URL somebody can send to a colleague. `target` adds the swap on top, and `hx-push-url` keeps the address bar honest about which one is shown — without it the browser's back button leaves the page and the URL describes a sort order nobody is looking at. `resizable=true` gives every labelled data column a handle on its trailing edge, which a visitor drags, or focuses and moves with the arrow keys. The page loads `resize_scripts()`; without it the handles stay hidden and the table is the ordinary one. A resizable table needs `id=`, because the widths are stored in the browser under that id and a table without one would share its key with every other table on the site. The select column and the `width="min"` columns get no handle: they hold a control or a number whose width the macro already chose. `group` adds a second header row, for the tables whose columns come in sets — a population's "n" and "%", a quarter's three months: columns=[{"label": "Category"}, {"label": "n", "align": "end", "group": "Dose 1 (N=12)"}, {"label": "%", "align": "end", "group": "Dose 1 (N=12)"}, {"label": "n", "align": "end", "group": "Dose 2 (N=9)"}, {"label": "%", "align": "end", "group": "Dose 2 (N=9)"}] Neighbouring columns with the same `group` share one centred cell above them, and a column with none spans both rows. The heading is a key on the column rather than a separate list of spans, so a group cannot be wider or narrower than the columns under it: adding a column to a set is one dict, and nothing elsewhere has to be recounted. Two sets that must stay apart need different text, which a population heading has anyway. A grouped table cannot also be `resizable`. The handles store a width per column label, and in a grouped table a label — "n", "%" — names a column only together with its group, on a header row of its own. Caller attributes (`id=`, `hx_get=`, `aria_label=`, the rest of `**kwargs`) land together on the element that stands for the table: the `
` when there are rows, and a `
` around the empty state when there are none. Aim a selector or a swap by id or by attribute, which finds either one, and not by tag: `table#feed` misses the empty state. They stay on the `
` rather than the `.table-container` around it, because the column widths are stored under the table's id and its accessible name belongs on the table. To replace the whole table, target an element around the call, as the demo's `#records` does. The macro's output is the `.table-container` and the table inside it, so an `outerHTML` swap aimed at the table's own id puts one container inside the other. #} {% macro table(columns, rows=none, empty_title="Nothing here", empty_description=none, empty_icon="list", target=none, swap="outerHTML", push_url=true, select_name="selected", select_label="Select all rows", resizable=false) -%} {% if rows is none or rows %} {% if resizable and not kwargs.get("id") %} {{- fjkit_raise("table(resizable=true) needs an id: it is the key the column widths are stored under.") }} {% endif %} {% set head = namespace(grouped=false) %} {% for column in columns if column.get("group") %}{% set head.grouped = true %}{% endfor %} {% if resizable and head.grouped %} {{- fjkit_raise("table(resizable=true) cannot take a column with a group: the stored widths are keyed by column label, and a grouped label is not unique.") }} {% endif %}
{% if head.grouped %} {# One pass, emitting a group's cell when its run ends: by then the counter holds its width, and a column outside any group has already been written in its place between them. #} {% set run = namespace(span=0) %} {% for column in columns %} {% if not column.get("group") %} {{- _header(column, target, swap, push_url, select_name, select_label, false, rowspan=2) }} {% else %} {% set run.span = run.span + 1 %} {% if loop.last or loop.nextitem.get("group") != column.group %} {% set run.span = 0 %} {% endif %} {% endif %} {% endfor %} {% for column in columns if column.get("group") %} {{- _header(column, target, swap, push_url, select_name, select_label, false) }} {% endfor %} {% else %} {% for column in columns %} {{- _header(column, target, swap, push_url, select_name, select_label, resizable) }} {% endfor %} {% endif %} {{ caller() }}
1 %} colspan="{{ run.span }}"{% endif %}> {{- column.group -}}
{% elif kwargs %} {# A table that polls, or that a swap targets by id, has to keep doing so while empty, so the caller's attributes go on a wrapper here. Same attributes, different tag: see the signature comment. #} {{ empty_state(empty_title, empty_description, empty_icon) }} {% else %} {{ empty_state(empty_title, empty_description, empty_icon) }} {% endif %} {%- endmacro %} {# One ``. Private, and not because the file is tidier that way: a header cell is only ever correct in the company of the rest of its row, and a caller who could reach this one would be able to build a table whose header count and column count disagree. #} {% macro _header(column, target, swap, push_url, select_name, select_label, resizable, rowspan=none) -%} {%- if column.get("select") -%} {# `role="checkbox"` is the implicit role of the element already. It is written out because Basecoat's table rules select on it — `[&:has([role=checkbox])]:pe-0` — to take the trailing padding off a column that holds nothing but a tick box. #} {%- else -%} {%- set sort = column.get("sort") -%} {%- set sort_url = column.get("sort_url") -%} {%- set handle = resizable and column.get("label") and column.get("width") != "min" -%} {%- if sort_url -%} {# `-mx-2` cancels the cell's own padding, so a sortable header's text starts on the same pixel as a fixed one's. Without it a table reads as two ragged columns of headings. #} {{- column.get("label", "") }} {# aria-hidden: `aria-sort` on the cell has already said this, and a screen reader that reads the glyph too says "ascending" twice. #} {%- else -%} {{ column.get("label", "") }} {%- endif -%} {# Beside the sort link, never inside it, or every drag would also sort. The role says what the control does and the label says which column, so the markup carries no English a translated page would have to replace. Hidden by the stylesheet until `js/resize.js` runs: a focusable separator that does nothing is worse than none. #} {%- if handle -%} {%- endif -%} {%- endif -%} {%- endmacro %} {# cell(value, tone, numeric, align) — one . Exists so a row macro never writes `class="text-muted-foreground"`. `tone` is a closed lookup, and no colour can be passed through it. #} {% set _TONES = { "strong": "font-medium", "muted": "text-muted-foreground", "success": "text-success", "warning": "text-warning", "destructive": "text-destructive", } %} {% macro cell(value=none, tone=none, numeric=false, align=none) -%} {%- if value is not none %}{{ value }}{% else %}{{ caller() }}{% endif -%} {%- endmacro %} {# select_cell(value, name, checked, label) — the row half of the batch-selection column, matching a `{"select": true}` header. {{ select_cell(task.id, label="Select " ~ task.title) }} … It is an ordinary checkbox with an ordinary `name`, so the selection reaches the server the way a checkbox group always has — `selected=3&selected=7` — and a route reads it as `selected: list[int] = Query([])`. Nothing here invents a wire format. Delivering it is the caller's job: either the table sits inside a `form()`, or a bulk-action button carries `hx_include="[data-fjkit-select]"`. One attribute either way. Always pass `label`. Without one, a column of tick boxes announces itself as "checkbox, checkbox, checkbox", and the row is the only thing that tells them apart. It falls back to the value, which is an id rather than a label. `data-state="selected"` on the `` tints a picked row; Basecoat's table rules carry it. `js/select.js` maintains it after the first click. Write it yourself on a row the server renders as already picked. #} {% macro select_cell(value, name="selected", checked=false, label=none) -%} {%- endmacro %} {# select_scripts() — the script behind `{"select": true}` and `select_cell`. Per page, never from the shell, for the reason `form_scripts()` and `reveal_scripts()` are: CHARTER §4.2 budgets what a page downloads by default, and the answer has to stay "htmx and Basecoat". A page with a batch-select table is one page. The row boxes work without it — they are checkboxes, and a form posts them. It adds the header box that ticks and clears the column, the dash for a partial selection, and the tint on picked rows. A page that forgets it gets a header checkbox that ticks only itself, which reads as a bug in the kit rather than a missing script. #} {% macro select_scripts() -%} {%- endmacro %} {# resize_scripts() — the script behind `table(resizable=true)`. Per page, for the reason `select_scripts()` is. It shows the handles, follows a pointer drag or the arrow keys, and stores each table's widths in `localStorage` under `fjkit:column-widths-by-label:`, one per column label. A column the app adds, removes or moves keeps its own width rather than inheriting its neighbour's; a column whose label changes starts at its natural width. Home or a double-click gives a column back its natural width. A table htmx swaps in, or morphs, gets its stored widths again without a reload. A page that forgets it gets an ordinary table: the handles never appear. #} {% macro resize_scripts() -%} {%- endmacro %} {# select_count(name, label, zero) — the live "3 selected" readout for a toolbar. `js/select.js` writes the number, keyed by the same `name` the checkboxes carry, so a page with two independent selections gets two independent counts. Both strings travel in the markup, so the script contains no English. `{n}` in `label` is where the number goes. `zero` stands there before anything is picked; pass `none` and the readout hides itself instead, so a control that only makes sense with a selection needs no second mechanism to hide it. It needs `select_scripts()`. Without it the readout stays frozen on `zero`. #} {% macro select_count(name="selected", label="{n} selected", zero="None selected") -%} {{ zero if zero is not none }} {%- endmacro %} {# page_size(url, per_page, options, …) — the "Rows per page" control that sits opposite `pagination`. {{ page_size(url_for(request, "records_page"), per_page, options=[12, 25, 50, 100], keep={"o": sort}, target="#records") }} A ``, because a ` {%- endfor %} {%- if target %} {%- else %} {{ button(apply_label, variant="outline", size="sm", type="submit") }} {%- endif %} {%- endmacro %} {# row_actions() — the trailing cell of per-row buttons. Right-aligned and tight, because a column of actions that drifts left of the table edge reads as a data column. #} {% macro row_actions() -%} {%- endmacro %} {# pagination(page, pages, url, …) — the page strip that goes under a table. {{ pagination(page, pages, url_for(request, "records_page"), total=total, per_page=per_page, target="#records") }} `url` is the address of the list *without* a page parameter; the macro appends one. Same division of labour as `sort_url` above: the router owns the query string, because it is the only thing that knows which filters are on. Passing a URL that already carries `page=` produces a link with two of them, and which one wins is the framework's business, not this macro's. `target` works exactly as it does on `table()`: every link is a real `href`, and a target adds `hx-get` on top. A paginated list has to survive JavaScript being off, because "page 4 of the results" is a thing people bookmark and send to each other. It renders **nothing** when there is one page or fewer. A strip reading "1" with both arrows greyed out is chrome that says nothing, and the alternative is every caller writing the same `{% if pages > 1 %}` around it — the same reason `table()` owns its empty state. `window` is how many pages sit either side of the current one. First and last are always shown, and the jump between them and the window is an ellipsis, so the strip is a fixed width whether there are nine pages or nine thousand. `total` and `per_page` are optional and travel together: given both, the strip grows the "1–25 of 312" line that tells a person whether paging further is worth it. Given neither, the buttons sit alone at the trailing edge. #} {% macro pagination(page, pages, url, param="page", total=none, per_page=none, target=none, swap="outerHTML", push_url=true, window=1, label="Pagination") -%} {%- if pages > 1 -%} {# Clamped rather than trusted. `page` arrives from a query string, and a strip built around page 0 or page 900 of 9 renders links to nowhere with no error anywhere. #} {%- set current = [[page, 1] | max, pages] | min -%} {%- set lo = [2, current - window] | max -%} {%- set hi = [pages - 1, current + window] | min -%} {%- set summary = total is not none and per_page is not none -%} {%- endif -%} {%- endmacro %} {# The three pieces a strip is made of. Private for the same reason `_header` is: a page link is only meaningful inside the strip that counted the pages. #} {# The htmx half of every link in the strip is `none` when there is no target, and `attrs()` drops a `none`, so one call site covers both kinds of link without a branch around the markup. #} {% macro _page(n, current, url, param, target, swap, push_url) -%} {%- set href = url ~ ("&" if "?" in url else "?") ~ param ~ "=" ~ n -%} {{ button(n, variant="secondary" if n == current else "ghost", size="icon-sm", href=href, aria_current="page" if n == current else none, hx_get=href if target else none, hx_target=target, hx_swap=swap if target else none, hx_push_url="true" if target and push_url else none) }} {%- endmacro %} {# A step that has nowhere to go renders as a disabled `
{{ caller() }}