{# Form field macros — render the appropriate input for each field type. Contract: ~/.claude/skills/ux-architect/components/form-field.md (UX-017) v0.62 CSS refactor (complete): every branch (search-select, ref-entity, combobox, multi_select, tags, picker, range, color, rich_text, slider, plus the standard else-block) now emits semantic classes from `components/form.css`. The `aria-invalid="true"` attribute drives the destructive-border state via `.dz-form-input[aria-invalid="true"]` in CSS — no more conditional border-class concatenation per branch. #} {% macro render_field(field, values={}, errors={}) %} {% set value = values.get(field.name, field.default) if values else field.default %} {% set error = errors.get(field.name, "") if errors else "" %} {% set field_id = "field-" ~ field.name %} {% set error_id = "error-" ~ field.name %} {% set hint_id = "hint-" ~ field.name if field.help is defined and field.help else "" %} {% set described_by = ([error_id] if error else []) + ([hint_id] if hint_id else []) %}
{% if field.type == "checkbox" %} {# Checkbox: label wraps input + text (inline row) #} {% if field.help is defined and field.help %}

{{ field.help }}

{% endif %} {% elif field.source %} {# UX-028: search-select dynamic search with autofill #} {% if field.help is defined and field.help %}

{{ field.help }}

{% endif %} {% include "fragments/search_select.html" %} {% if error %} {% endif %} {% elif field.ref_entity %} {# Entity-ref fields render as TomSelect comboboxes with remote load (#939). The framework previously had two paths here — one Alpine + x-for that populated an empty select, one TomSelect that loaded via its own callback. The Alpine path was the implicit default and the TomSelect path opt-in via widget=combobox; both fetched the same FK list endpoint with different lifecycles, contending over the same DOM node when both fired (the wrapper-on-wrapper smell from #927). The single path now: empty {# Pre-selected value: the TomSelect load callback resolves the human label on first focus/preload; until then this option preserves form integrity (the right ID is submitted) across re-render. #} {% if values.get(field.name) %} {% endif %} {% if errors.get(field.name) %} {% endif %} {# ── Vendored widget overrides (Phase 4) ─────────────────────────── #} {% elif field.widget == "combobox" %} {# UX-009: combobox widget — TomSelect wrapper #} {% if field.help %}

{{ field.help }}

{% endif %} {% if errors.get(field.name) %} {% endif %} {% elif field.widget == "multi_select" %} {# UX-021: multiselect widget — TomSelect with remove_button plugin #} {% if field.help %}

{{ field.help }}

{% endif %} {% if errors.get(field.name) %} {% endif %} {% elif field.widget == "tags" %} {# UX-022: tags widget — TomSelect with create:true #} {% if field.help %}

{{ field.help }}

{% endif %} {% if errors.get(field.name) %} {% endif %} {% elif field.widget == "picker" and field.type in ("date", "datetime") %} {# UX-010: datepicker widget (single) — Flatpickr wrapper #} {% if field.help %}

{{ field.help }}

{% endif %} {% if errors.get(field.name) %} {% endif %} {% elif field.widget == "range" and field.type in ("date", "datetime") %} {# UX-010: datepicker widget (range) — Flatpickr wrapper #} {% if field.help %}

{{ field.help }}

{% endif %} {% if errors.get(field.name) %} {% endif %} {% elif field.widget == "color" %} {# UX-024: native (#976 — dropped Pickr). Native input covers every actual usage in the example apps. The hex display next to it is a small read-out of the current value; it stays in sync via the dzColorWidget Alpine binding (no JS library needed). #} {% if field.help %}

{{ field.help }}

{% endif %}
{% if errors.get(field.name) %} {% endif %} {% elif field.widget == "rich_text" %} {# UX-025 / #977 cycle 4: richtext widget — dz-richtext (Dazzle-native). #} {% if field.help %}

{{ field.help }}

{% endif %} {%- set _rt_opts = {} -%} {%- if field.extra is defined and field.extra -%} {%- if field.extra.rich_text_toolbar -%} {%- set _ = _rt_opts.update({'toolbar': field.extra.rich_text_toolbar}) -%} {%- endif -%} {%- if field.extra.rich_text_max_length -%} {%- set _ = _rt_opts.update({'maxLength': field.extra.rich_text_max_length}) -%} {%- endif -%} {%- endif -%}
{% if errors.get(field.name) %} {% endif %} {% elif field.widget == "slider" %} {# UX-023: slider widget — native + dzRangeTooltip controller #} {% if field.help %}

{{ field.help }}

{% endif %}
{% if errors.get(field.name) %} {% endif %} {% else %} {# Standard fields: label above #} {% if field.help is defined and field.help %}

{{ field.help }}

{% endif %} {% if field.type == "textarea" %} {% elif field.type == "select" %} {% elif field.type == "date" %} {% elif field.type == "datetime" %} {% elif field.type == "money" %} {# UX-026: money widget — dzMoney Alpine, semantic CSS chrome #} {% set minor_val = values.get(field.name ~ '_minor', '') if values else '' %} {% if field.extra.get('currency_fixed', true) %} {# Pinned currency — static prefix symbol #}
{% else %} {# Unpinned currency — dropdown selector #}
{% endif %} {% elif field.type == "number" %} {% elif field.type == "email" %} {% elif field.type == "file" %} {# UX-027: file upload — Alpine dzFileUpload + semantic CSS #}
{# File preview (shown when file exists) #}
{# Dropzone (shown when no file) #} {# Upload progress #}
{# Client-side error message — distinct from server errors #}
{% else %} {# Default: text input #} {% endif %} {% endif %} {% if error %} {% endif %}
{% endmacro %}