{# The components that float above the page. Basecoat's own JavaScript drives all four, and it is already vendored and mounted: it scans for `.popover`, `.dropdown-menu`, `.select` and `.combobox` on load and again on `htmx:afterSwap`, so a swapped-in overlay works with nothing extra. No macro below registers a handler. What they share is id wiring: a trigger and its panel find each other through four related ids, and one wrong id fails silently — the panel opens but is never announced, or never opens at all. Every macro here takes one `id` and derives the rest, which is why they are macros and not snippets to copy. `drawer` is the exception: a native `` that needs no JS from Basecoat, only `showModal()`. `multiselect_scripts()` is the one macro here that adds a file to the page, and only a page using `multiple=true` calls it. It fixes the wire format rather than the widget — see its own comment. #} {% from "ui/attrs.html" import attrs %} {% from "ui/icon.html" import icon %} {# _message(field_id, hint, error) — the paragraph under a scripted control. A second copy of the macro `ui/form.html` calls under every field, written out here rather than imported. Jinja refuses to import a name beginning with an underscore, and renaming that one to make it importable would publish a helper no app template should call. It would also change what `fjkit eject` does with it: a private helper is copied into the file you own, while a public one is re-exported and keeps moving under you. `_GAP` is written out twice for the same reason. It differs from that copy in one way, and the difference is the point: it always renders the paragraph, empty and hidden when there is nothing to say. `js/errors.js` reuses the `

` a control names in `aria-describedby` and creates one where it finds none, inserting it directly after the control. For the two macros below, the control carrying `name` is a hidden input at the end of a wrapper div, so "after" is inside that wrapper and the 422 would draw its red text inside the select box. Reserving the paragraph gives it a target in the right place. #} {% macro _message(field_id, hint=none, error=none) -%} {%- if error %}

{{ error }}

{%- elif hint %}

{{ hint }}

{%- else %} {%- endif -%} {%- endmacro %} {# Panel widths. A popover has to be told how wide to be — its content is out of flow, so it has no column to fill — and upstream does that with a utility at the call site. A closed lookup instead, for the usual two reasons: an app template may not write `w-72` (A2), and an interpolated width renders while missing from the stylesheet. #} {% set _PANEL_WIDTHS = { "auto": "", "sm": "w-40", "default": "w-56", "lg": "w-72", "xl": "w-96", } %} {# popover(id, label, …) — a panel of arbitrary content, anchored to a button. Content arrives through `{% call %}`. The trigger is a `button` macro's worth of parameters rather than a second slot, because a popover whose trigger is not a button is nearly always a `dropdown_menu` or a `tooltip`. `aria-expanded` starts false and Basecoat's JS flips it; `aria-hidden` on the panel works the same way. Both are written here rather than left off: first paint is the state before any JS has run, and a trigger with no `aria-expanded` is announced as a plain button. #} {% macro popover(id, label, variant="outline", size="", side="bottom", align="center", width="lg", icon_name=none) -%}
{%- endmacro %} {# dropdown_menu(id, label, …) — a menu of commands. The panel holds a `role="menu"` labelled by the trigger, and the items are `menu_item` / `menu_group` / `menu_separator` below. Four ids have to line up for that: trigger, popover, menu, and the group headings. All derived. A menu is not a `` is keyboard- and screen-reader- correct for free and costs one element; this one costs ~30 and depends on JS being present. Use it only when the rows need markup a native option cannot hold — an icon, two lines, a swatch — or when you need `multiple`, which a native ` {%- else %} {%- endif %} {%- if as_field %} {{ _message(field_id, hint, error) }} {%- endif %} {%- endmacro %} {# combobox(name, options, …) — a select you can type into. Same contract as `select_menu` — the hidden input carries the value — with a text input in place of the button. `empty` is the message the listbox shows when the filter matches nothing. It rides in a `data-empty` attribute because Basecoat's JS renders it, and a blank one leaves a filtered-to-nothing list looking broken. Filtering is client-side over the options rendered here, until `search` is given a URL. Then it is the server's: the input carries `hx-get`, the reply replaces the listbox, and the root carries `data-filter="manual"`, which is the switch in Basecoat's own JS that stops it filtering a second time. Both halves of that are load-bearing. Without the swap there is nothing new to show; without `manual`, Basecoat re-filters the server's rows against the same box and hides every one that matched on a field the label does not print — search by email, list by name, see nothing. The route is handed `?=` and returns option rows and nothing else:
Ada Lovelace
which is what `innerHTML` on the listbox expects. It is a fragment, so it extends no shell, and the empty result is an empty response — `data-empty` already draws the message. The visible input is deliberately not given a `name`, because a named input inside a `
` is submitted with it, and the box holds a label while the hidden input holds the value. So the typed text reaches the route through `hx-vals` instead of through the element's own value. `refresh()` is Basecoat's, put on the root when it initialises, and it is what re-reads the options after a swap. Nothing calls it for us: htmx fires `htmx:afterSwap` on the listbox, and it reaches the root by bubbling, which is why the attribute is on the root and not on the input that made the request. To act on a pick — with or without `search` — listen on the root: hx_trigger="change target:#c-jump" Basecoat dispatches its own `change` on the root, not on the hidden input, carrying `detail.value`. The markup does not say so, and the obvious guess (`change from:find input[type=hidden]`) silently never fires. `target:` is worth the characters because a native `change` bubbles out of the text box on blur as well, and without the filter a pick costs two identical requests. `multiple` works here too, and reads better than on `select_menu`: Basecoat wraps the text input in a `.combobox-chips` div and renders one chip per pick, so a long selection stays legible instead of becoming a comma-joined line that truncates. That JS draws the chips, so at first paint a pre-filled multiple combobox shows an empty box. Use `select_menu(multiple=true)` where the selection has to be visible with JavaScript off, and read the note on `multiselect_scripts()` either way. `visible_label`, `hint` and `error` behave as on `select_menu`: any one of them wraps the control in a `.field` and gives it the label and the message paragraph an ordinary field has. #} {% macro combobox(name, options=(), selected=none, id=none, placeholder="Select…", empty="No results found.", label=none, multiple=false, close_on_select=false, visible_label=none, hint=none, error=none, search=none, search_param="q", search_delay=250) -%} {%- set field_id = id or ("c-" ~ name) -%} {# Same three parameters as `select_menu`, same rule — see its comment. #} {%- set as_field = visible_label is not none or hint is not none or error is not none -%} {# Same normalisation as `select_menu`, and the same reason: `equalto` against a list would never match, so the shape of `selected` follows `multiple`. #} {%- set values = (selected or []) | list if multiple else ([selected] if selected is not none else []) -%} {%- set chosen = options | selectattr("0", "in", values) | map(attribute=1) | list -%} {%- if as_field %}
{%- if visible_label %}{% endif %} {%- endif %}
{# Empty when `multiple`: this input is the filter box, not the display. The selection lives in the chips beside it, and pre-filling it with a label would filter the list down to that one option before anybody typed. #} {{ icon("chevron-down", 16, "combobox-trigger-icon") }} {# The hidden input carries the 422, same as in `select_menu`. #} {%- if multiple %} {%- else %} {%- endif %}
{%- if as_field %} {{ _message(field_id, hint, error) }}
{%- endif %} {%- endmacro %} {# multiselect_scripts() — what a page loads to be allowed `multiple=true`. {% block scripts %}{{ multiselect_scripts() }}{% endblock %} Per page, never from the shell, for the reason `form_scripts()` is: CHARTER §7 budgets what a page downloads by default, and the answer has to stay "htmx and Basecoat". It buys one line of wire format. Basecoat serialises a multiple selection as `JSON.stringify(values)` in a single hidden input, so without this script the route is handed one string: labels=%5B%22bug%22%2C%22ui%22%5D -> labels: str == '["bug","ui"]' With it, the selection is re-emitted the way HTML has always carried a repeated field, and the FastAPI signature is the ordinary one: labels=bug&labels=ui -> labels: list[str] = Form([]) It covers both halves of `form()`: htmx submits are rewritten in `htmx:configRequest`, browser submits in a capture-phase `submit` handler, and one form is only ever handled by one of the two. `encoding="json"` composes, in one direction: this runs first and json-enc collects the repeats into an array. One caveat — json-enc collects repeats, so a one-item selection arrives as `"bug"`, not `["bug"]`. Give the model a `mode="before"` validator that wraps a bare string in a list; `TaskUpdate` in `examples/fjkit-demo` is the worked example. A page that forgets this macro fails the way every missing-script failure in this kit does: no console error, no visual difference, and a 422 naming a field that looks correctly filled in. #} {% macro multiselect_scripts() -%} {%- endmacro %} {# drawer(id, …) — a panel that slides in from an edge. A native `` opened with `showModal()`, not a popover: a drawer is modal, and `showModal()` gives it the top layer, the backdrop, the focus trap and Esc-to-close without a line of script. `dialog` in this kit is the non-modal one, which is why the two do not share a macro. Opening one needs `showModal()`, so the trigger carries the only inline handler in the kit besides the theme toggle. `drawer_trigger` writes it. #} {% macro drawer(id, title=none, description=none, side="bottom", footer=none, dismissible=true) -%} {# Basecoat styles `.drawer > article`, so the wrapper is load-bearing the same way the dialog's inner div is: the itself is the backdrop owner and the positioning context, the
is the panel that slides. #}
{%- if title or description %}
{% if title %}

{{ title }}

{% endif %} {% if description %}

{{ description }}

{% endif %}
{%- endif %}
{{ caller() }}
{%- if footer or dismissible %}
{%- if footer %}{{ footer }}{% endif %} {%- if dismissible %} {# `closest('dialog').close()` rather than the id: the button is inside the dialog it closes, so the relationship is structural and cannot be broken by a rename. #} {%- endif %}
{%- endif %}
{%- endmacro %} {# drawer_trigger(label, target) — the button that opens one. Separate from `drawer` because the two live in different places on the page: the drawer is usually at the end of the document, the button wherever it belongs. Passing the id twice is the price of that — `drawer` takes it as `id`, this macro as `target`. #} {% macro drawer_trigger(label, target, variant="outline", size="", icon_name=none) -%} {%- endmacro %} {# command(id, …) — a filterable command list, and its ⌘K dialog form. `dialog=true` swaps the root for a ``, upstream's own variant rather than a second component. Everything else — the header input, the `role="menu"`, the ids that tie them together — is identical, so this is one macro with a flag rather than two that would drift. Two things it deliberately does not do: * It does not bind ⌘K. A keystroke is application policy — which key, on which pages, and whether it beats the browser's own — and a UI kit that claims a global shortcut fights the app. Open it with `showModal()` from your own listener. * It does not search the server. The filter is Basecoat's, over the items rendered here. For server-side search this is the wrong component: use an `input` with `hx-get` and swap the listbox from the response. #} {% macro command(id, placeholder="Type a command or search…", empty="No results found.", label="Command menu", dialog=false, bordered=true) -%} <{{ "dialog" if dialog else "div" }} id="{{ id }}" class="{{ "command-dialog" if dialog else ("command border" if bordered else "command") }}" aria-label="{{ label }}"{% if kwargs %}{{ attrs(kwargs) }}{% endif %}>
{{ icon("search", 16) }} {# `aria-expanded="true"` is not a mistake: the list below is always showing whenever this is on screen, so the combobox is permanently expanded. It is the dialog that opens and closes, not the listbox. #}
{%- endmacro %} {# command_item(label, …) — one row of a command list. `data-filter` is what Basecoat matches the typed text against, and it defaults to the label. `keywords` extends that with words that are not on screen — "date event schedule" for a Calendar row — which is the difference between a palette that finds things and one that only confirms what you already typed. #} {% macro command_item(label, keywords=none, icon_name=none, shortcut=none, disabled=false, filter=none, href=none) -%} <{{ "a" if href else "div" }} role="menuitem" data-filter="{{ filter if filter is not none else label }}" {%- if keywords %} data-keywords="{{ keywords }}"{% endif %} {%- if href %} href="{{ href }}"{% endif %} {%- if disabled %} aria-disabled="true"{% endif %} {%- if kwargs %}{{ attrs(kwargs) }}{% endif %}> {%- if icon_name %}{{ icon(icon_name, 16) }}{% endif %} {{ label }} {%- if shortcut %}{{ shortcut }}{% endif -%} {%- endmacro %} {# command_group(heading) — a labelled run of command items. A ``, not the `
` `menu_group` uses: that is what upstream's command markup does, and its CSS selects on it. Otherwise the same shape. #} {% macro command_group(heading=none, id=none) -%} {%- set heading_id = id or ("ch-" ~ (heading | replace(" ", "-") | lower) if heading else none) -%}
{%- if heading %}{{ heading }}{% endif %} {{- caller() -}}
{%- endmacro %}