{# Form controls. Every field takes the same four presentation parameters — `label`, `hint`, `error`, `id` — and wires them the same way: the control gets an id, the label points at it, and `hint`/`error` render into one `

` the control names in `aria-describedby`. An `error` replaces the hint rather than stacking on it: two messages under one control is two things to read when only one of them matters. A rejected submit fills the same `

` from the browser — `js/errors.js` reads FastAPI's 422 and writes each message under the control its `loc` names. Pass `error=` for a message known at first paint. #} {% from "ui/attrs.html" import attrs %} {% from "ui/button.html" import button %} {# form(action, method, target, swap) — the

element itself. {% call form(action=url_for(request, "tasks_create"), target="#board") %} {% call field_row("1fr auto") %}…{% endcall %} {% endcall %} `target` decides which kind of form this is, and decides all of it: form(action=…, target="#board") -> hx-post + hx-target + hx-swap form(action=…) -> action= + method= The htmx form also sets `hx-disinherit="hx-target hx-swap"`: a control inside it that fires its own request names its own target, or swaps into itself — never into the form's target by inheritance. A form with a target is an htmx form; one without is an ordinary POST needing no JavaScript. Same macro and same fields either way, and that symmetry is what makes progressive enhancement cheap. `hx-post` without a target would swap the reply into the form itself, which a caller who omitted `target` never meant. `method` is where that symmetry runs out, and it runs out quietly. htmx issues all five verbs; a browser issues two: form(action=…, method="put", target="#form") -> hx-put form(action=…, method="put") -> method="post" The second is not a bug being papered over: every browser treats `` as GET, which drops the body entirely. A save that becomes a read is worse than a save that arrives as a POST. See `_NATIVE_METHODS` below. `encoding` decides what the submit carries, which is a different question from what it targets: form(action=…, target="#board") -> title=Ship+it form(action=…, target="#board", encoding="json") -> {"title":"Ship it"} JSON is for a route that declares its body as a model — `def create(payload: TaskCreate)` — because FastAPI reads such a body as JSON and refuses a form outright. Nothing else changes: the values arrive as strings either way, pydantic's lax mode reads `"3"` as an int and `"on"` as `True`, and `js/errors.js` draws a rejected submit under the fields with everything still typed in them. Three limits, all of them the extension's rather than this macro's: - No `target`, no JSON. A browser submitting natively sends urlencoded or multipart and nothing else, so `encoding="json"` on a form with no `target` is ignored rather than silently changing what a no-JS submit posts. - No nesting. Every field lands at the top level of the object, so `name="items.0.title"` posts that string as a key. A model with a nested shape needs a flat DTO in front of it. - No file upload. `JSON.stringify` has nothing to say about a `File`. The page also has to load the extension — `form_scripts()`, below. `reset_on_success` resets only on success, and only since 0.3. It used to emit an unconditional `this.reset()`, and `htmx:afterRequest` fires whether or not the request succeeded, so a rejected submit cleared the box the person had just typed into. Keeping what was typed is how a rejected form comes back, and this parameter is what guards it. #} {# `json` is an htmx extension and `multipart` is not — it is a property of the request, which is why the two leave by different doors and there is no single table. `multipart` also has to work with JavaScript off, so it is the one encoding that reaches the native path as well. #} {% set _ENCODINGS = {"urlencoded": none, "json": "json-enc"} %} {# What each `method` is called on each path. The asymmetry is why there are two tables and not one. htmx issues any of the five. A browser submitting a form natively issues two: anything else in a `` is treated as GET, which drops the body — a save that silently becomes a read. So the native table answers `post` to everything that is not a read, and a `method="put"` form with no `target` posts rather than quietly losing what was typed. Use `_method`-style tunnelling in the route where the server has to know. #} {% set _HX_METHODS = { "get": "hx-get", "post": "hx-post", "put": "hx-put", "patch": "hx-patch", "delete": "hx-delete", } %} {% set _NATIVE_METHODS = {"get": "get", "post": "post", "put": "post", "patch": "post", "delete": "post"} %} {% macro form(action=none, method="post", target=none, swap="outerHTML", reset_on_success=false, card=true, encoding="urlencoded", progress=none) -%} {# Only on the htmx path: `hx-ext` is an instruction to htmx, and a form without a target is submitted by the browser, which has never heard of it. #} {%- set extension = _ENCODINGS.get(encoding) if target else none -%} {% if card %}
{{ caller() }}
{% else %}{{ caller() }}{% endif %}
{%- endmacro %} {# form_scripts() — the script a page loads to be allowed `encoding="json"`. {% block scripts %}{{ form_scripts() }}{% endblock %} Per page, never from the shell. CHARTER §7 budgets what a page downloads by default and the answer has to stay "htmx and Basecoat", so the 1,012 bytes only some forms need are opted into by the page that has one — the way `chart_scripts()` opts into Plotly. `defer` keeps it after htmx: deferred scripts run in document order, htmx is deferred in the shell's head, and the extension calls `htmx.defineExtension` as soon as it runs. An ordinary ` {%- endmacro %} {# field_row(template) — fields side by side on wide screens, stacked on narrow. `template` is a closed lookup key, not a raw grid-template string: an interpolated `sm:grid-cols-[…]` would not exist in the stylesheet. #} {% set _ROWS = { "wide-then-actions": "sm:grid-cols-[1fr_8.5rem_8.5rem_auto]", "two": "sm:grid-cols-2", "three": "sm:grid-cols-3", "four": "sm:grid-cols-2 lg:grid-cols-4", "field-and-button": "sm:grid-cols-[1fr_auto]", } %} {% macro field_row(template="two", gap=3) -%} {%- set _GAP = {2: "gap-2", 3: "gap-3", 4: "gap-4"} -%}
{{- caller() -}}
{%- endmacro %} {# text_field(name, label, …) — label + control + hint, wired together by id. `type` is the whole of fjkit's answer to dates, times and numbers, and there is no `date_field` next to this one on purpose. `type="date"` is a native ``: the calendar, the keyboard handling, the locale-correct display of a value that still travels as `YYYY-MM-DD`, and the screen-reader announcement are all the browser's, in every browser this kit supports. A macro wrapping it would add a name and nothing else, which is the case CHARTER §2.3 rejects, and a scripted picker would trade all of that for a control whose accessibility fjkit then owns — §2.4's first row. The types that behave as a field here: text email password url tel search number date time datetime-local month week `min`, `max` and `step` are not parameters; they reach the input through `**kwargs` like any other attribute, so a date range is `text_field("due", type="date", min="2026-01-01")`. `file` is deliberately not in that list. An upload is not a value the field round-trips — `value=""` cannot restore it after a 422 — and it needs `form(encoding="multipart")` to reach the server at all. Not an enumeration in code. Narrowing `type` to a list would turn every new input type HTML gains into a fjkit release, and the failure it would prevent — a typo'd type — is one the browser already handles by falling back to `text`. #} {% macro text_field(name, label=none, value="", placeholder="", type="text", required=false, hint=none, error=none, id=none) -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{% endif %} {{ _message(field_id, hint, error) }}
{%- endmacro %} {# select_field(name, label, options, selected) `options` is a list of (value, label) pairs. The caller decides the labels, so the macro never reaches into a domain enum it should not know about. A native {{ _message(field_id, hint, error) }} {%- endmacro %} {% macro select_field(name, label=none, options=(), selected=none, id=none, blank=none, hint=none, error=none) -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{% endif %} {{ _message(field_id, hint, error) }}
{%- endmacro %} {# _message(field_id, hint, error) — the single

under a control. One paragraph, never two: an error replaces the hint. Every field points `aria-describedby` at this id, and the id is derived from the control's rather than passed in, so a caller cannot get the two out of step. #} {% macro _message(field_id, hint=none, error=none) -%} {%- if error %}

{{ error }}

{%- elif hint %}

{{ hint }}

{%- endif -%} {%- endmacro %} {# textarea_field(name, label, …) — multi-line text. No `rows` by default: the stylesheet sets `field-sizing-content` with a minimum height, so the control grows with what is typed. Pass `rows` only to fix a height against that. #} {% macro textarea_field(name, label=none, value="", placeholder="", rows=none, required=false, hint=none, error=none, id=none) -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{% endif %} {{ _message(field_id, hint, error) }}
{%- endmacro %} {# checkbox_field(name, label, checked) — one box, label to its right. {{ checkbox_field("notify", label="Email me when it moves", hint="Only for tasks you own.") }} An unchecked box sends nothing at all. A route that needs to tell "off" from "absent" — a PATCH of a partial form — pairs this with a hidden field; a plain create route reads the absence as false and is done. #} {% macro checkbox_field(name, label=none, checked=false, value="on", hint=none, error=none, id=none) -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{% endif %} {{ _message(field_id, hint, error) }}
{%- endmacro %} {# switch_field(name, label, checked) — the same checkbox, read as a setting. Same element and same wire format as `checkbox_field`: the stylesheet and the screen reader both key off `role="switch"`, so a route cannot tell the two apart and does not need to. Label first, control at the far right — a switch reads as a state being flipped rather than a box being ticked, and settings rows align their controls on one edge. #} {% macro switch_field(name, label=none, checked=false, value="on", hint=none, error=none, id=none) -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{% endif %} {{ _message(field_id, hint, error) }}
{%- endmacro %} {# radio_group(name, label, options, selected) `options` is the same list of (value, label) pairs `select_field` takes, so swapping one for the other is a one-word edit. Which one to use follows from the options: a radio group shows them all at once and costs a line each, so it wins up to about five and loses after that. A real
/, not a div with a label: that is what makes the group announce itself as one control with N choices rather than N unrelated boxes, and arrow-key navigation between them comes from the browser. #} {% macro radio_group(name, label=none, options=(), selected=none, hint=none, error=none, id=none) -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{{ label }}{% endif %}
{% for option_value, text in options %} {%- set option_id = field_id ~ "-" ~ loop.index0 %}
{% endfor %}
{{ _message(field_id, hint, error) }}
{%- endmacro %} {# fieldset(legend, hint) — a named group of fields inside one form. {% call fieldset("Notifications", hint="Applies to this board only.") %} {{ switch_field("email", label="Email") }} {{ switch_field("digest", label="Weekly digest") }} {% endcall %} Use it when a form has more than one subject. One fieldset wrapping a whole form says nothing the
did not already say. The legend is sized as a heading (`data-variant="legend"`), one step up from the field labels under it. `radio_group` writes its own legend at `data-variant="label"`, because there the legend is the field's label and has to match every other label on the form. #} {% macro fieldset(legend=none, hint=none) -%}
{% if legend %}{{ legend }}{% endif %} {% if hint %}

{{ hint }}

{% endif %} {{ caller() }}
{%- endmacro %} {# range_field(name, label, …) — a native . Native, for the reason `select_field` is native: keyboard, screen reader and touch behaviour arrive correct and free, where a scripted slider has to re-implement all three and usually re-implements two. Basecoat paints the filled part of the track from a `--slider-value` custom property and updates it from its own JS as the thumb moves. The starting value has to be set here, or the fill sits at upstream's 20% default until the first drag — a bar that disagrees on first paint with the number beside it. `output` is a live region showing the current value, off by default: a slider whose own text already reads "Volume 40%" does not need it, and a second announcement on every keypress is noise. #} {% macro range_field(name, label=none, value=50, min=0, max=100, step=1, hint=none, error=none, id=none, output=false) -%} {%- set field_id = id or ("f-" ~ name) -%} {%- set span = (max - min) or 1 -%} {%- set filled = (((value - min) / span) * 100) | round(2) -%}
{% if label %} {% endif %} {{ _message(field_id, hint, error) }}
{%- endmacro %} {# input_group_field(name, label, start, end, …) — an input with addons. `start` and `end` are rendered markup, the same way `card` takes `actions`: {% set count %}{{ n }} results{% endset %} {{ input_group_field("q", "Search", end=count) }} Slots rather than a caller block, because both are optional and a caller cannot say "nothing here": `{% call %}` would render an empty addon, and an empty addon still takes its padding. Basecoat strips the border from the inner control and paints it on the group, so the input deliberately carries no `class="input"` — giving it one draws a second box inside the first. `revealable` puts a Show/Hide toggle in the end group, for the one field that always wants one: {{ input_group_field("password", label="Password", type="password", required=true, autocomplete="current-password", revealable=true) }} {% block scripts %}{{ reveal_scripts() }}{% endblock %} It lives here rather than in `text_field` because the reveal has to sit inside the input's box, and `.input-group` is the only markup in the kit with a place for it: a plain `.input` followed by a button is two boxes. `reveal_show` and `reveal_hide` are the button's two labels. They are parameters rather than constants so that a Spanish sign-in page is not a fork of the kit; the script reads both off the element and never holds an English string. A page that forgets `reveal_scripts()` gets a button that does nothing on click. That is the loudest of this kit's missing-script failures and still quiet: the field keeps working, and only the affordance is broken. #} {% macro input_group_field(name, label=none, value="", placeholder="", type="text", start=none, end=none, required=false, hint=none, error=none, id=none, revealable=false, reveal_show="Show", reveal_hide="Hide") -%} {%- set field_id = id or ("f-" ~ name) -%}
{% if label %}{% endif %}
{# `data-align` decides the order rather than the source order — the CSS sets `order-first` / `order-last` — so the input stays first in the markup, where the label association reads naturally. #} {%- if start %}{{ start }}{% endif %} {# The reveal button shares the end group with whatever the caller passed, and comes after it: an addon is usually a unit (a currency, a count) and the control that changes the field belongs next to the field's edge. #} {%- if end or revealable %} {{- end if end }} {%- if revealable %} {# `type="button"`, or the first thing a reveal does is submit the form. `aria-pressed` is the state — this is a toggle, not a command — and `aria-controls` is how `js/reveal.js` finds the input without knowing that ids here are `f-`. #} {{ button(reveal_show, variant="ghost", size="xs", aria_controls=field_id, aria_pressed="false", data_fjkit_reveal=true, data_show=reveal_show, data_hide=reveal_hide) }} {%- endif %} {%- endif %}
{{ _message(field_id, hint, error) }}
{%- endmacro %} {# reveal_scripts() — the script behind `input_group_field(revealable=true)`. Per page, never from the shell, for the reason `form_scripts()` and `multiselect_scripts()` are: CHARTER §7 budgets what a page downloads by default, and the answer has to stay "htmx and Basecoat". A sign-in page is one page. It buys a toggle that survives a rejected submit. The listener is on `document`, so a button arriving in a swapped-in panel works as well as one present at first paint — the case that matters, because the form a password lives in is the form most likely to come back with a 422 and a fresh copy of itself. #} {% macro reveal_scripts() -%} {%- endmacro %}