{# Tabs — roadmap 0.7, pulled forward. Basecoat already ships `.tabs` and its behaviour: its vendored script owns the selection, the `hidden` on the other panels, and the ArrowLeft/Right/Home/End keys. What `components/tabs.css` does not ship is a skin — that file is flex direction and `outline-none`, nothing else. The style pack does ship one, a pill strip; fjkit draws underline tabs, so `fjkit.css` turns the pack's geometry off before painting its own. Read that block before changing anything here: the two skins overlapping already put a phantom scrollbar on the card's edge once. This macro carries the aria wiring the script needs, and nothing else. The contract Basecoat's JS reads: .tabs > [role="tablist"] > [role="tab"][aria-controls=""] .tabs > [role="tabpanel"][id=""] Getting that pairing right by hand is what a component should remove. {% call tabs([{"id": "main", "label": "main.py"}, {"id": "router", "label": "router.py"}], label="File") %} {% call tab_panel("main") %}…{% endcall %} {% call tab_panel("router") %}…{% endcall %} {% endcall %} `items` is a list of dicts, the shape `table` takes its columns in, and extra keys are ignored — so a caller already holding a list of files passes it straight through instead of building a parallel one. It is the light half, so it is a parameter while the panels are the body: the same trade as `card`'s `actions`, because a two-slot call block renders its body once per slot and the body here is every panel's content. Ids are page-unique, like any id. A page with two tab groups over the same subject prefixes them ("file-main", "diff-main"), as it would for any other pair of elements. #} {% from "ui/attrs.html" import attrs %} {# tabs(items, label, selected, orientation) orientation: horizontal | vertical — the value Basecoat's key handler reads to decide whether Left/Right or Up/Down moves between tabs, so it is a closed lookup rather than interpolated: `aria-orientation="sideways"` would leave the arrow keys dead with no other symptom. `selected` names the item that starts open; the first one by default. The server knows which tab the request was for, so this is a parameter and not something a script decides after paint. #} {% macro tabs(items, label="Tabs", selected=none, orientation="horizontal") -%} {%- set body = caller() -%} {%- set orientations = {"horizontal": "horizontal", "vertical": "vertical"} -%} {%- set active = selected if selected is not none else (items[0].id if items else none) -%}
{{- body -}}
{%- endmacro %} {# tab_panel(id, lazy, on, include) — the body for one tab. The id is the one the tab's `aria-controls` named; Basecoat hides and reveals it from there. No `hidden` is rendered here: Basecoat's script sets it on init, and a panel that arrived pre-hidden would be invisible to a reader with JavaScript off. This way the panels stack and every one is legible. `lazy` is a URL, and it is all a panel needs to fetch its own body when it is shown rather than when the page loads. The block's body then stands in until the answer arrives — usually a `skeleton`. Without `lazy` the panel is inert markup, and `on` and `include` are read only alongside it because neither means anything on its own. Three decisions are baked in here so that no app template has to remember them. Each is a way this goes silently wrong when written by hand: * `intersect`, never `revealed`. htmx tests `revealed` with `getBoundingClientRect`, and an element hidden by `display:none` — which is what the `hidden` Basecoat sets means — reports an all-zero rect. `top < innerHeight` and `bottom >= 0` both hold for that rect, so every panel on the page counts as revealed and fetches at load. There is no error and no empty panel to notice; the page is simply not lazy. `intersect` uses an `IntersectionObserver`, which reports a `display:none` element as not intersecting and fires when the tab is chosen. * A filter on every broadcast. The events in `on` are raised on ``, and a hidden panel hears them as well as the open one does. Four tabs would mean four requests per broadcast, three of them for markup nobody is looking at. `[!this.hidden]` is the whole fix, and it is this short only because the trigger sits on the panel — the element carrying the `hidden` — rather than on something inside it. * `innerHTML` onto the panel. An element carrying `intersect` has to survive its own reply. A panel replaced by markup that asked for `intersect` again would be processed while on screen, fire immediately, and loop. `include` is a selector for what the panel has to send: the id it is about, the query it is filtered by. A selector rather than a value carried in the event, because the two paths into this panel disagree on what an event is — `intersect` has none. A panel hidden when the pick happened never heard `task-selected` and has only the page to read, so the id has to be on the page and the route that changes it has to say so **after** its own swap: `@render(..., hx_trigger_after_swap=...)`. See `fjkit.rendering.render`. A lazy panel refetches every time it is shown, not only the first time. That is the trade for not tracking which panels went stale while hidden; tracking that would need a listener on `` — a script, for a page that otherwise has none. {% call tab_panel("tab-detail", lazy=url_for(request, "search_detail"), on=["task-selected"], include="[name=task_id]") %} {{ skeleton(lines=4, label="Loading the detail") }} {% endcall %} #} {% macro tab_panel(id, lazy=none, on=none, include=none) -%} {%- set trigger %}intersect {%- for event in on or [] %}, {{ event }}[!this.hidden] from:body{% endfor %} {%- endset -%}
{{ caller() }}
{%- endmacro %}