{# 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 `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. #} {% 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") -%} {% if rows is none or rows %}
{% for column in columns %} {{- _header(column, target, swap, push_url, select_name, select_label) }} {% endfor %} {{ caller() }}
{% 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) -%} {%- 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") -%} {%- 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 -%} {%- 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 §7 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 %} {# 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() -%}
{{ caller() }}
{%- 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 `