{#- chirp-ui: Form field macros Extended field macros with BEM styling. These complement (and can replace) Chirp's built-in form macros from "chirp/forms". Internal: field_wrapper provides label, hint, error display. Standard fields use it; checkbox/toggle/radio/range/input_group have custom layouts. form() htmx usage: call form("/save", hx={"post": "/save", "target": "#result", "swap": "innerHTML"}) ...fields... end hx={} — preferred for htmx attributes. Individual hx_* kwargs override dict keys. Auto-behaviors: hx-select="unset" + hx-disinherit="hx-select" when htmx is detected; hx-on::after-request reset on success for mutating methods. Mutating htmx forms default to hx-sync="this:drop" plus hx-disabled-elt for submit controls to prevent accidental duplicate in-flight submissions. -#} {% def form(action, method="get", enctype=none, density="", cls="", attrs="", attrs_unsafe="", attrs_map=none, hx=none, hx_get=none, hx_post=none, hx_put=none, hx_patch=none, hx_delete=none, hx_target=none, hx_swap=none, hx_trigger=none, hx_include=none, hx_select=none, hx_select_oob=none, hx_disabled_elt=none, hx_sync=none, hx_ext=none, hx_vals=none, hx_reset_on_success=none) %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe or attrs_map")) %} {# @provides _form_density — consumed by: field_wrapper #} {% provide _form_density = density %} {#- hx_reset_on_success: when true, form resets after successful htmx response (2xx). Defaults to true when form has hx-post/put/patch/delete (via params or attrs_map). See https://htmx.org/examples/reset-user-input/ hx_sync: mutating htmx forms default to "this:drop"; pass explicitly or via attrs_map to override, or pass "" to opt out. See https://htmx.org/docs/#synchronization -#} {% set _hx_dict = build_hx_attrs(hx=hx, hx_get=hx_get, hx_post=hx_post, hx_put=hx_put, hx_patch=hx_patch, hx_delete=hx_delete, hx_target=hx_target, hx_swap=hx_swap, hx_trigger=hx_trigger, hx_include=hx_include, hx_ext=hx_ext, hx_vals=hx_vals) %} {% set _has_hx = _hx_dict.get("hx-post") or _hx_dict.get("hx-put") or _hx_dict.get("hx-patch") or _hx_dict.get("hx-delete") or (attrs_map or {}).get("hx-post") or (attrs_map or {}).get("hx-put") or (attrs_map or {}).get("hx-patch") or (attrs_map or {}).get("hx-delete") %} {% set _reset = hx_reset_on_success if hx_reset_on_success is not none else _has_hx %} {% set _sync_from_attrs = (attrs_map or {}).get("hx-sync") %} {% set _disabled_from_attrs = (attrs_map or {}).get("hx-disabled-elt") %} {% set _sync = hx_sync if hx_sync is not none else ("this:drop" if _has_hx and not _sync_from_attrs else none) %} {% set _disabled_elt = hx_disabled_elt if hx_disabled_elt is not none else ("find button, find input[type=submit]" if _has_hx and not _disabled_from_attrs else none) %} {% set _select = hx_select if hx_select is not none else ("unset" if _has_hx else none) %} {% set _disinherit = "hx-select" if _has_hx and hx_select is none else none %}
{% slot %}
{% end %} {% end %} {% def fieldset(legend=none, cls="") %}
{% if legend %}{{ legend }}{% end %} {% slot %}
{% end %} {# Shared wrapper: label, slot (control), hint, errors. modifier adds chirpui-field--X. field_id: override wrapper id (default: "field-{name}"). oob: emit hx-swap-oob="true". #} {% def field_wrapper(name, label=none, errors=none, required=false, hint=none, modifier="", field_id=none, oob=false, appearance="", tone="") %} {% set _fid = field_id if field_id else "field-" ~ name %} {% set _eid = "errors-" ~ name %} {% set _has_errors = errors and errors | field_errors(name) %} {% set _appearance = appearance | validate_appearance_block("field", "") %} {% set _tone = tone | validate_tone_block("field", "") %} {# @consumes _form_density from: form — falls back to "" #} {% set _density = consume("_form_density", "") %}
{% if label %} {% end %} {% slot %} {% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} Usage: from "chirpui/forms.html" import text_field, textarea_field, select_field text_field("title", value=form.title, label="Title", errors=errors, required=true) textarea_field("description", value=form.description, label="Description", rows=6) select_field("status", options=statuses, selected=form.status, label="Status") checkbox_field("active", checked=form.active, label="Active") toggle_field("notifications", checked=true, label="Enable notifications") radio_field("plan", options=plans, selected=form.plan, label="Plan") file_field("avatar", label="Avatar", accept="image/*") date_field("birthday", value=form.birthday, label="Birthday") range_field("volume", value=50, min=0, max=100, label="Volume", show_value=true) hidden_field("id", value=form.id) phone_field("phone", value=form.phone, label="Phone") money_field("amount", value=form.amount, label="Amount") masked_field("ssn", mask="999-99-9999", label="SSN") masked_field("expiry", mask="99/99", label="Card expiry") Note: The field_errors filter is provided by Chirp. When using chirp-ui without Chirp, define your own field_errors filter or pass errors=none. -#} {# Text input with label and error display #} {% def text_field(name, value="", label=none, errors=none, type="text", required=false, placeholder="", hint=none, attrs="", attrs_unsafe="", appearance="", tone="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe")) %} {% call field_wrapper(name, label, errors, required, hint, appearance=appearance, tone=tone) %} {% end %} {% end %} {# Password field with secure browser autocomplete defaults #} {% def password_field(name="password", value="", label=none, errors=none, required=true, placeholder="", hint=none, autocomplete="current-password", attrs="", attrs_unsafe="", appearance="", tone="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe")) %} {% call field_wrapper(name, label, errors, required, hint, appearance=appearance, tone=tone) %} {% end %} {% end %} {# Textarea with label and error display #} {% def textarea_field(name, value="", label=none, errors=none, rows=4, required=false, placeholder="", hint=none, appearance="", tone="") %} {% call field_wrapper(name, label, errors, required, hint, appearance=appearance, tone=tone) %} {% end %} {% end %} {# Select dropdown with label and error display #} {% def select_field(name, options, selected="", label=none, errors=none, required=false, hint=none, appearance="", tone="") %} {% call field_wrapper(name, label, errors, required, hint, appearance=appearance, tone=tone) %} {% end %} {% end %} {# Checkbox with label #} {% def checkbox_field(name, checked=false, label=none, errors=none) %} {% set _has_errors = errors and errors | field_errors(name) %}
{% end %} {# Toggle (switch-style checkbox) #} {# Supports size (sm/md/lg), color variant (success/danger/accent), and label_inside for ON/OFF text #} {% def toggle_field(name, checked=false, label=none, errors=none, size="", variant="", label_inside=false) %} {% set size_class = " chirpui-toggle-wrap--" ~ size if size in ("sm", "lg") else "" %} {% set variant_class = " chirpui-toggle-wrap--" ~ variant if variant in ("success", "danger", "accent") else "" %} {% set _has_errors = errors and errors | field_errors(name) %}
{% end %} {# Radio group with label and error display #} {% def radio_field(name, options, selected="", label=none, errors=none, required=false, hint=none, layout="vertical") %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label %} {{ label }}{% if required %} {% end %} {% end %}
{% for opt in options %} {% end %}
{% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {# Star rating — interactive CSS-only 1-N star picker. Renders radio inputs in reverse DOM order with row-reverse flex so the CSS ~ sibling selector fills stars up to the hovered/checked one. #} {% def star_rating(name, count=5, selected=0, label=none, errors=none, required=false, hint=none, size="") %} {% set size = size | validate_size("star-rating", "") %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label %} {% end %}
{% for i in range(count, 0, -1) %} {% end %}
{% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {# Thumbs up/down — binary sentiment input (radio pair). #} {% def thumbs(name, selected="", label=none, errors=none, required=false, hint=none, size="") %} {% set size = size | validate_size("thumbs", "") %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label %} {% end %}
{% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {# Segmented control form field — connected button-group radio selector. Renamed from segmented_control() to avoid collision with the display-only segmented_control() in segmented_control.html. The old name is kept as a deprecated alias below. #} {% def segmented_control_field(name, options, selected="", label=none, errors=none, required=false, hint=none, size="") %} {% set size = size | validate_size("segmented", "") %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label %} {% end %}
{% for opt in options %} {% set _ov = opt.get("value", "") %} {% end %}
{% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {#- Deprecated alias — use segmented_control_field() instead. -#} {% def segmented_control(name, options, selected="", label=none, errors=none, required=false, hint=none, size="") %} {{ segmented_control_field(name, options, selected=selected, label=label, errors=errors, required=required, hint=hint, size=size) }} {% end %} {# Number scale — horizontal numbered radio row (NPS-style 0-10 or 1-5). #} {% def number_scale(name, min=0, max=10, selected=none, label=none, errors=none, required=false, hint=none, low_label="", high_label="") %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label %} {% end %}
{% for i in range(min, max + 1) %} {% end %}
{% if low_label or high_label %}
{{ low_label }} {{ high_label }}
{% end %} {% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {# File input with label and error display #} {% def file_field(name, label=none, errors=none, accept="", multiple=false, required=false, hint=none) %} {% call field_wrapper(name, label, errors, required, hint, modifier="file") %} {% end %} {% end %} {# Date input with label and error display #} {% def date_field(name, value="", label=none, errors=none, required=false, min=none, max=none, hint=none) %} {% call field_wrapper(name, label, errors, required, hint) %} {% end %} {% end %} {# Range slider with label and error display #} {% def range_field(name, value=50, min=0, max=100, step=1, label=none, errors=none, hint=none, show_value=false) %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label or show_value %}
{% if label %} {% end %} {% if show_value %} {{ value }} {% end %}
{% end %} {% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {# Input with prefix/suffix. Use prefix/suffix params for text, or slot prefix/suffix for custom content (icons, etc). #} {% def input_group(name, prefix=none, suffix=none, value="", label=none, errors=none, type="text", required=false, placeholder="", hint=none, attrs="", attrs_unsafe="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe")) %} {% set _has_errors = errors and errors | field_errors(name) %}
{% if label %} {% end %}
{% if prefix %} {{ prefix }} {% else %} {% slot prefix %} {% end %} {% if suffix %} {{ suffix }} {% else %} {% slot suffix %} {% end %}
{% if hint and not _has_errors %} {{ hint }} {% end %}
{% end %} {# Masked text input — uses Alpine Mask plugin. Pass mask (static) or mask_dynamic (expression). Requires @alpinejs/mask loaded before Alpine core. See https://alpinejs.dev/plugins/mask mask: static pattern, e.g. "99/99/9999", "999-99-9999" mask_dynamic: Alpine expression with $input, e.g. "$money($input)" or credit card logic #} {% def masked_field(name, value="", label=none, errors=none, mask=none, mask_dynamic=none, required=false, placeholder="", hint=none, attrs="", attrs_unsafe="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe")) %} {% call field_wrapper(name, label, errors, required, hint, modifier="masked") %} {% end %} {% end %} {# Phone field — US format (999) 999-9999 by default. Use masked_field for other formats. #} {% def phone_field(name, value="", label=none, errors=none, format="us", required=false, placeholder="", hint=none, attrs="", attrs_unsafe="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe")) %} {% set _mask = "(999) 999-9999" if format == "us" else "9999 999 9999" if format == "uk" else "+9 999 999 9999" if format == "intl" else "(999) 999-9999" %} {% call field_wrapper(name, label, errors, required, hint, modifier="phone") %} {% end %} {% end %} {# Money/currency field — uses Alpine $money(). decimal_sep, thousands_sep, precision optional. $money($input, decimal, thousands, precision) — default ".", ",", 2 #} {% def money_field(name, value="", label=none, errors=none, decimal_sep=".", thousands_sep=",", precision=2, required=false, placeholder="", hint=none, attrs="", attrs_unsafe="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe")) %} {% set _money_expr = "$money($input, '" ~ decimal_sep ~ "', '" ~ thousands_sep ~ "', " ~ precision ~ ")" %} {% call field_wrapper(name, label, errors, required, hint, modifier="money") %} {% end %} {% end %} {# Multi-select dropdown with label and error display #} {% def multi_select_field(name, options, selected=none, label=none, errors=none, required=false, hint=none, size=4) %} {% set selected = selected or [] %} {% call field_wrapper(name, label, errors, required, hint) %} {% end %} {% end %} {# Search input with optional htmx debounced search #} {% def search_field(name, value="", label=none, search_url=none, search_target=none, search_trigger="keyup changed delay:300ms", search_include=none, search_sync=none, placeholder="Search...", errors=none, attrs="", attrs_unsafe="", attrs_map=none, search_attrs_map=none, search_hx_select=none) %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe or attrs_map")) %} {% set _has_errors = errors and errors | field_errors(name) %} {% set _search_live = search_url and search_target %} {% set _search_attrs = search_attrs_map or {} %} {% set _search_select_from_attrs = _search_attrs.get("hx-select") %} {% set _search_disinherit_from_attrs = _search_attrs.get("hx-disinherit") %} {% set _search_sync_from_attrs = _search_attrs.get("hx-sync") %} {% set _search_select = search_hx_select if search_hx_select is not none else ("unset" if _search_live and not _search_select_from_attrs else none) %} {% set _search_disinherit = "hx-select" if _search_select == "unset" and not _search_disinherit_from_attrs else none %} {% set _search_sync = search_sync if search_sync is not none else ("this:replace" if _search_live and not _search_sync_from_attrs else none) %}
{% if label %} {% end %}
{% end %} {# Search bar — composite search input with layout variants. Use inside a form. variant: "solo" (input only, for live search), "with-button" (input + compact submit), "with-icon" (input with ⌕ prefix). With with-button, input flexes; button stays compact. #} {% def search_bar(name, value="", variant="solo", label=none, search_url=none, search_target=none, search_trigger="keyup changed delay:300ms", search_include=none, search_sync=none, placeholder="Search...", button_label="Search", button_icon="⌕", errors=none, attrs="", attrs_unsafe="", attrs_map=none, search_attrs_map=none, search_hx_select=none) %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe or attrs_map")) %} {% set _has_errors = errors and errors | field_errors(name) %} {% set _search_live = search_url and search_target %} {% set _search_attrs = search_attrs_map or {} %} {% set _search_select_from_attrs = _search_attrs.get("hx-select") %} {% set _search_disinherit_from_attrs = _search_attrs.get("hx-disinherit") %} {% set _search_sync_from_attrs = _search_attrs.get("hx-sync") %} {% set _search_select = search_hx_select if search_hx_select is not none else ("unset" if _search_live and not _search_select_from_attrs else none) %} {% set _search_disinherit = "hx-select" if _search_select == "unset" and not _search_disinherit_from_attrs else none %} {% set _search_sync = search_sync if search_sync is not none else ("this:replace" if _search_live and not _search_sync_from_attrs else none) %} {% end %} {# Key-value form — inline key + value inputs + submit. For "Set config value" etc. -#} {% def key_value_form(action, method="post", key_placeholder="", value_placeholder="", submit_label="Set", key_options=none, key_name="key", value_name="value", attrs="", attrs_unsafe="", attrs_map=none, cls="") %} {% set _attrs_raw = attrs_unsafe or (attrs | deprecate_param("attrs", "attrs_unsafe or attrs_map")) %} {% call form(action, method=method, attrs_unsafe=_attrs_raw, attrs_map=attrs_map, cls="chirpui-key-value-form" ~ (" " ~ cls if cls else "")) %}
{% if key_options %} {% for opt in key_options %} {% end %} {% else %} {% end %}
{% from "chirpui/button.html" import btn %} {{ btn(submit_label, type="submit", variant="primary") }}
{% end %} {% end %} {# Hidden field #} {% def hidden_field(name, value="") %} {% end %} {# CSRF hidden field helper. Pass token for non-Chirp environments. #} {% def csrf_hidden(token=none, field_name="_csrf_token") %} {% if token is not none %} {% else %} {% if csrf_field is defined %} {{ csrf_field() }} {% else %} {{ csrf_token() }} {% end %} {% end %} {% end %} {# Form error summary — alert-style box listing all field errors with anchor links. Place at the top of the form. Renders nothing if no errors. oob: emit hx-swap-oob for OOB targeting. #} {% def form_error_summary(errors, id="form-errors", oob=false) %} {% if errors and errors is mapping %} {% set _fields = [] %} {% for fname, msgs in errors.items() %} {% if msgs %}{% set _ = _fields.append(fname) %}{% end %} {% end %} {% if _fields %} {% elif not oob %} {% else %} {% end %} {% else %} {% if oob %} {% end %} {% end %} {% end %} {# Form actions — submit/cancel button row. Use align="end" for right-aligned. btn() defaults to type="button"; pass type="submit" explicitly for form submission: {% call form_actions(align="end") %} {{ btn("Cancel", href="/back") }} {{ btn("Save", type="submit", variant="primary") }} {% end %} #} {% def form_actions(align="start", cls="") %} {% set align_class = " chirpui-form-actions--end" if align == "end" else "" %}
{% slot %}
{% end %}