{#- chirp-ui: Data Grid Server-driven interactive data grid — the citable, drop-in composite for sortable columns (with aria-sort + server sort state), row selection bound to a selection bar, sticky header + sticky first column, and HTMX load-more. All state is projected from the typed `chirp_ui.grid_state` helper: the macro renders the `aria_sort` and toggle `next_url` it never computes, so the server's ORDER BY and the rendered headers cannot drift. Selection is the only client concern — one idempotent `chirpuiGridSelection` Alpine factory (in chirpui-alpine.js) owns live in-page toggling. The server seeds checked/indeterminate from `SelectionState` so selection is correct with JavaScript off. Works without Chirp (render the macro with precomputed ColumnSort rows), better with Chirp (template globals + use_chirp_ui Alpine injection). Route + template usage: from chirp_ui import Column, parse_sort, sort_columns, selection_state, sort_query COLS = [Column("name","Name",sortable=True), Column("status","Status",sortable=True,align="center"), Column("seats","Seats",sortable=True,align="right")] @app.get("/users") def users(req): offset = int(req.query.get("offset", 0)) sort = parse_sort(req.query.get("sort"), default_key="name", allowed=tuple(c.key for c in COLS)) rows = query_users(order_by=sort.key, desc=(sort.direction=="desc"), offset=offset, limit=PAGE) cols = sort_columns(COLS, sort, base_url="/users", extra_params={"q": req.query.get("q","")}) sel = selection_state(req.query.getlist("ids"), page_ids=[u.id for u in rows], total=count_users()) ctx = dict(columns=cols, rows=[[u.name,u.status,u.seats] for u in rows], row_ids=[u.id for u in rows], row_labels=[u.name for u in rows], selection=sel, selection_id="users", has_more=offset + PAGE < count_users(), load_more_url=f"/users?offset={offset + PAGE}&sort={sort_query(sort)}") # Load-more fetch → bare rows + an OOB sentinel that refreshes (or, # on the last page, removes) the button (#231). Full page otherwise. if req.headers.get("HX-Target") == "users-grid-body": return Response(render_fragment("data_grid_rows", **ctx)) return Template("users.html", **ctx) (template) from "chirpui/data_grid.html" import data_grid call data_grid(title="Users", columns=columns, rows=rows, row_ids=row_ids, row_labels=row_labels, sort_url="/users", hx_target="#users-grid", selection_id="users", selectable=true, sticky_first_col=true, selection=selection, load_more_url="/users", has_more=has_more) btn("Export", hx={"post":"/users/export","include":"#users-grid"}) end Caveat: select-all is page-scoped (selects the visible page, not the entire result set). Cross-page "select all N matching" is out of scope for v1. -#} {% from "chirpui/filter_bar.html" import filter_row %} {% from "chirpui/selection_bar.html" import selection_bar %} {% from "chirpui/pagination.html" import pagination %} {#- One sortable/static header cell. `col` is a grid_state.ColumnSort. The sortable variant nests a real {% else %} {{ col.label }} {% end %} {% end %} {#- Load-more sentinel — a stable-id container (`#{selection_id}-load-more`) that holds the load-more button. Rendered once by `data_grid` (oob=false) and re-rendered by `data_grid_rows` as an `hx-swap-oob` update (oob=true) on every load-more fetch, so the button refreshes its next-page URL or — when `has_more` is false — empties the container, removing the button exactly when the result set is exhausted (#231). The button keeps hx-boost="false" + hx-select="unset" so a boost-inherited select can't strip the bare- fragment. -#} {% def grid_load_more(selection_id="grid", load_more_url=none, body_id=none, has_more=false, load_more_label="Load more", load_more_trigger="click", load_more_swap="beforeend", oob=false) %} {% set _lm_id = selection_id ~ "-load-more" %} {% set _body = body_id or (selection_id ~ "-grid-body") %}
{% if load_more_url and has_more %} {% end %}
{% end %} {#- Body rows, plus — when called as the load-more fragment with `load_more_url` set — the load-more sentinel emitted as an `hx-swap-oob` update to the stable `#{selection_id}-load-more` container. The load-more route renders THIS fragment: the bare rows (htmx appends them via hx-swap="beforeend" to the grid ) AND the OOB sentinel, which refreshes the button's next-page URL or — when `has_more` is false — removes the button on the last page (#231). The initial page's sentinel is rendered by `data_grid`; pass `selection_id` here so the OOB id matches the rendered container. `row_labels` (optional, parallel to `row_ids`) supplies a clean, plain-text accessible name per row for the select checkbox. The first cell is NOT used for the label: it is frequently rich HTML (avatar + link + badge) — which a screen reader would announce as literal "less-than b greater-than" markup — or an empty spacer cell, which degrades the label to a non-distinguishing bare "Select". When no clean label is given we fall back to the stable row id, never the rendered cell, so every checkbox is distinct and unpolluted. -#} {% def data_grid_rows(columns, rows, row_ids=none, row_labels=none, selectable=false, select_name="ids", selection=none, load_more_url=none, has_more=false, load_more_label="Load more", load_more_trigger="click", load_more_swap="beforeend", selection_id="grid") %} {% for row_data in rows %} {% set _rid = (row_ids[loop.index0] | string) if row_ids is not none and loop.index0 < (row_ids | length) else (loop.index0 | string) %} {% set _rlabel = (row_labels[loop.index0] | string) if row_labels is not none and loop.index0 < (row_labels | length) and row_labels[loop.index0] else _rid %} {% if selectable %} {% end %} {% for cell in row_data %} {% set _col = columns[loop.index0] if loop.index0 < (columns | length) else none %} {{ cell }} {% end %} {% end %} {#- When rendered as the load-more fragment, refresh/remove the button via OOB. -#} {% if load_more_url %} {{ grid_load_more(selection_id=selection_id, load_more_url=load_more_url, has_more=has_more, load_more_label=load_more_label, load_more_trigger=load_more_trigger, load_more_swap=load_more_swap, oob=true) }} {% end %} {% end %} {% def data_grid(title=none, description=none, columns=none, rows=none, row_ids=none, row_labels=none, sort=none, sort_url=none, hx_target=none, selectable=false, selection_id="grid", select_name="ids", selection=none, sticky_header=true, sticky_first_col=false, load_more_url=none, load_more_label="Load more", load_more_trigger="click", load_more_swap="beforeend", has_more=false, current=1, total=1, url_pattern="", filter_action=none, filter_method="get", empty_message="No records found", compact=false, striped=false, cls="", attrs_map=none) %} {# Resolve columns: ColumnSort rows render directly; Column/dict + GridSort are projected here via sort_columns so callers can pass raw declarations. #} {% set _columns = sort_columns(columns, sort, sort_url) if (columns and sort is not none and sort_url) else (columns or []) %} {% set _grid_id = selection_id ~ "-grid" %} {% set _body_id = selection_id ~ "-grid-body" %} {% set _total_rows = (selection.total if selection and selection.total is not none else (rows | length if rows else 0)) %}
rows to the tbody), so nothing inside it should inherit the shell's hx-select="#page-content". Without this, a load-more append into the tbody inherits #page-content from the boosted #main and trips a (false-positive) "blank swap" dev warning, since the fragment has no #page-content. -#} hx-disinherit="hx-select" {{ attrs_map | html_attrs }}> {% if title or description %}
{% if title %}

{{ title }}

{% end %} {% if description %}

{{ description }}

{% end %}
{% end %} {% if filter_action %}
{% call filter_row(action=filter_action, method=filter_method) %}{% slot toolbar %}{% end %}{% end %}
{% end %} {% if selectable %} {% call selection_bar(live_region=true, controlled=true, cls="chirpui-data-grid__selection") %} {# Forward the data_grid default slot (caller bulk-action buttons) into the selection bar. kida shadows a bare {% slot %} inside a nested {% call %}, so capture the caller explicitly. #} {{ caller() if caller is defined else "" }} {% end %} {% end %}
{% if selectable %} {% end %} {% for col in _columns %} {{ grid_head_cell(col, hx_target=(hx_target or ("#" ~ _grid_id)), selection_id=selection_id) }} {% end %} {% if rows %} {{ data_grid_rows(_columns, rows, row_ids=row_ids, row_labels=row_labels, selectable=selectable, select_name=select_name, selection=selection) }} {% else %} {% end %}
{% slot caption %}
{{ empty_message }}
{% if load_more_url and has_more %} {{ grid_load_more(selection_id=selection_id, load_more_url=load_more_url, body_id=_body_id, has_more=has_more, load_more_label=load_more_label, load_more_trigger=load_more_trigger, load_more_swap=load_more_swap) }} {% elif total > 1 and url_pattern %} {% end %}
{% end %}