{#
field_editor(field, value, parent_table, readonly=false, error="")
-------------------------------------------------------
Dispatcher macro: renders one form field per SqlType kind.
Arguments
---------
field : FormField — { name, kind, value, nullable, readonly, fk_table }
value : string — current field value (pre-coerced for date/time)
parent_table : string — the *owning* model's table name. Used in the FK
picker URL (`/admin/api/<parent_table>/<field.name>/options`)
because the handler looks the column up on the
PARENT model to follow `col.fk_target` to the
real target. Passing the target here would 404
with "no field `<fk_col>` on `<target>`".
readonly : bool — override to force read-only (e.g. preview mode)
error : string — inline validation message; empty = no error shown
is_long : bool — upgrade Text to textarea for long values (>200 chars)
Phase-3 cut
-----------
ForeignKey renders a plain number input showing the FK id.
The async searchable combobox (phase 3) replaces this.
#}
{% macro field_editor(field, value, parent_table="", readonly=false, error="", is_long=false) %}
{% set is_readonly = readonly or field.readonly %}
<div class="space-y-1.5 mb-md">
{# --- Label row --- #}
<div class="flex items-center gap-xs">
<label for="f_{{ field.name }}" class="font-label-md text-label-md text-on-surface-variant select-none">
{{ field.name | replace("_", " ") | title }}
</label>
{% if not field.nullable and not is_readonly %}
<span class="text-error font-label-sm text-label-sm" aria-label="required">*</span>
{% endif %}
{% if field.nullable %}
<span class="text-[10px] text-outline bg-surface-container-high px-1.5 py-0.5 rounded font-label-sm">(nullable)</span>
{% endif %}
{% if is_readonly %}
<span class="text-[10px] text-outline bg-surface-container-high px-1.5 py-0.5 rounded font-label-sm">(read-only)</span>
{% endif %}
</div>
{# --- Input by kind --- #}
{% if field.kind == "password" %}
{# Password + confirm pair (create forms only for password_field models) #}
<div class="space-y-sm">
<input
type="password"
id="f_{{ field.name }}"
name="{{ field.name }}"
placeholder="New password"
required
autocomplete="new-password"
class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50"
/>
<input
type="password"
name="{{ field.name }}_confirm"
placeholder="Confirm password"
required
autocomplete="new-password"
class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50"
/>
</div>
{% elif field.kind == "bool" %}
{# Toggle switch #}
<label class="inline-flex items-center gap-sm cursor-pointer {% if is_readonly %}opacity-60 pointer-events-none{% endif %}">
<div class="relative">
<input
type="checkbox"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="true"
{% if value == "true" %}checked{% endif %}
{% if is_readonly %}disabled{% endif %}
class="sr-only peer"
/>
<div class="w-10 h-5 bg-surface-container-high border border-outline-variant rounded-full peer-checked:bg-primary peer-checked:border-primary transition-all"></div>
<div class="absolute top-0.5 left-0.5 w-4 h-4 bg-outline rounded-full peer-checked:bg-on-primary peer-checked:translate-x-5 transition-all"></div>
</div>
<span class="text-body-sm text-on-surface-variant">{{ field.name | replace("_", " ") | title }}</span>
</label>
{% elif field.kind == "markdown" or field.kind == "rte" %}
{# #[umbral(widget = "markdown" | "rte")] — features.md #4.
A tall textarea carrying data-widget so a progressive-
enhancement script can later mount a split-pane markdown editor
or an RTE without changing this markup. With no JS it degrades
to a usable textarea — the stored value is rendered on the
display side via the `{{ value | markdown }}` filter. The help
hint below (from #[umbral(help = "...")]) tells the editor what
syntax to type.
Do not emit native required: EasyMDE/Quill hide this textarea,
and browser validation cannot focus hidden controls. The server
still validates the field and re-renders sheet errors. #}
<textarea
id="f_{{ field.name }}"
name="{{ field.name }}"
data-widget="{{ field.kind }}"
rows="10"
{% if not field.nullable and not is_readonly %}aria-required="true" data-required="true"{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md font-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
>{{ value }}</textarea>
{% elif field.kind == "code" %}
{# #[umbral(widget = "code")] — features.md #4. A textarea that
admin.js enhances into a CodeMirror editor (JSON syntax + line
numbers); degrades to a mono textarea with no JS. For JSON /
structured text on any String / Json column.
CodeMirror hides the original textarea, so required validation
belongs to the server response path for the same reason as the
markdown/RTE widgets above. #}
<textarea
id="f_{{ field.name }}"
name="{{ field.name }}"
data-widget="code"
rows="10"
{% if not field.nullable and not is_readonly %}aria-required="true" data-required="true"{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md font-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
>{{ value }}</textarea>
{% elif field.kind == "textarea" or is_long %}
{# Prose / unbounded TEXT / FullText. Body font, no Format button. #}
<textarea
id="f_{{ field.name }}"
name="{{ field.name }}"
rows="4"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
>{{ value }}</textarea>
{% elif field.kind == "json" %}
{# JSON / Array. Monospace + Format button. #}
<div class="relative">
<textarea
id="f_{{ field.name }}"
name="{{ field.name }}"
rows="5"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md font-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all resize-y placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
>{{ value }}</textarea>
{% if not is_readonly %}
<button
type="button"
class="absolute top-2 right-2 px-sm py-xs text-[10px] bg-surface-container-high border border-outline-variant rounded text-on-surface-variant hover:text-on-surface transition-colors font-label-sm"
onclick="(function(btn){var ta=btn.closest('.relative').querySelector('textarea');try{ta.value=JSON.stringify(JSON.parse(ta.value),null,2);}catch(e){}})(this)"
title="Format JSON"
>Format</button>
{% endif %}
</div>
{% elif field.kind == "select" %}
{# Closed-set enum field (#[umbral(choices)]). Variant list lives on
field.choices as (value, label) pairs. #}
<select
id="f_{{ field.name }}"
name="{{ field.name }}"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
>
{% if field.nullable %}<option value=""{% if value == "" %} selected{% endif %}>—</option>{% endif %}
{% for opt in field.choices %}
<option value="{{ opt.value }}"{% if value == opt.value %} selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
{% elif field.kind == "fk" %}
{# Async FK combobox — never preloads the whole table.
Endpoint: {{ admin_base }}/api/<parent_table>/<field.name>/options — the
handler looks the column up on the parent model to follow
`col.fk_target` to the target. On edit-mode load,
/options/resolve?ids=<current> resolves the label.
`fk_table` (= the target table name) is kept on the dom node
as `data-fk-table` because the picker JS reads it for label
formatting; it is NOT what the URL `{table}` segment expects. #}
{% set fk_table = field.fk_table | default("") %}
<div class="relative fk-picker" data-field="{{ field.name }}" data-fk-table="{{ fk_table }}">
<input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ value }}" />
<div
data-fk-active
class="{% if not value %}hidden {% endif %}mb-xs rounded-xl border border-primary/20 bg-primary-container px-md py-sm"
>
<div class="flex items-start justify-between gap-sm">
<div class="min-w-0">
<p class="text-label-sm text-on-primary-container uppercase tracking-wider">Selected</p>
<p class="text-body-sm font-medium text-on-primary-container truncate" data-fk-active-label>
{% if value %}Loading selected option{% else %}Selected option{% endif %}
</p>
<p class="text-[11px] text-on-primary-container tabular-nums" data-fk-active-value>{% if value %}#{{ value }}{% endif %}</p>
</div>
{% if field.nullable and not is_readonly %}
<button type="button" data-fk-clear class="flex-shrink-0 inline-flex items-center gap-xs rounded-lg border border-primary/20 bg-surface-container px-sm py-xs text-label-sm text-on-surface-variant hover:text-on-surface transition-colors">Clear</button>
{% endif %}
</div>
</div>
<input
type="text"
id="fk_text_{{ field.name }}"
placeholder="{% if value %}Search to replace {{ field.name | replace('_id', '') | replace('_', ' ') | title }}{% else %}Search {{ field.name | replace('_id', '') | replace('_', ' ') | title }}{% endif %}"
autocomplete="off"
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
{% if is_readonly %}disabled{% endif %}
hx-get="{{ admin_base }}/api/{{ parent_table }}/{{ field.name }}/options"
hx-trigger="input changed delay:250ms, focus"
hx-target="#fk_options_{{ field.name }}"
hx-swap="innerHTML"
hx-include="this"
name="search"
/>
{% if value and not is_readonly %}
<span id="fk_resolve_{{ field.name }}"
hx-get="{{ admin_base }}/api/{{ parent_table }}/{{ field.name }}/options/resolve?ids={{ value }}"
hx-trigger="load"
hx-swap="none"
hx-on:htmx:after-request="if(window.umbral&&umbral.fkResolve)umbral.fkResolve('{{ field.name }}', event)"
></span>
{% endif %}
<div id="fk_options_{{ field.name }}" class="fk-options hidden absolute left-0 right-0 top-full mt-xs z-30 bg-surface-container border border-outline-variant rounded-xl shadow-lg max-h-64 overflow-y-auto">
{# Populated by HTMX #}
</div>
</div>
{% elif field.kind == "multiselect" %}
{# MultiChoice<E> — closed-set, multi-valued.
Each variant gets a checkbox chip. A hidden CSV input is the
single field actually posted, kept in sync by the small JS
block below. Pure DOM; no HTMX, no async fetch — every variant
is known statically from field.choices. #}
<div class="multichoice-picker" data-field="{{ field.name }}">
<input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ value }}" />
{% set selected_csv = "," ~ value ~ "," %}
<div class="flex flex-wrap gap-xs">
{% for opt in field.choices %}
{% set marker = "," ~ opt.value ~ "," %}
{% set is_selected = marker in selected_csv %}
<label class="inline-flex items-center gap-xs px-sm py-xs bg-surface-container-low border {% if is_selected %}border-primary text-primary bg-primary/5{% else %}border-outline-variant text-on-surface-variant{% endif %} rounded-full cursor-pointer hover:bg-surface-container-high transition-colors text-body-sm select-none">
<input type="checkbox"
data-mc-value="{{ opt.value }}"
{% if is_selected %}checked{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-3.5 h-3.5 accent-primary"/>
<span>{{ opt.label }}</span>
</label>
{% endfor %}
</div>
</div>
{% elif field.kind == "m2m" %}
{# M2M chip multi-select picker.
NOTE: M2M is wired but no Model field uses it until the ORM ships M2M.
The chip picker is ready for that day. #}
<div class="fk-picker m2m-picker relative" data-field="{{ field.name }}">
<div id="chips_{{ field.name }}" class="flex flex-wrap gap-xs mb-xs">
{# Chips rendered by JS from hidden input #}
</div>
<input type="hidden" id="f_{{ field.name }}" name="{{ field.name }}" value="{{ value }}" />
<input type="text"
id="m2m_text_{{ field.name }}"
placeholder="Add {{ field.name | replace('_', ' ') | title }}..."
autocomplete="off"
class="w-full bg-surface-container-low border border-outline-variant rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20"
{% if is_readonly %}disabled{% endif %}
/>
<div class="fk-options hidden absolute left-0 right-0 top-full mt-xs z-30 bg-surface-container border border-outline-variant rounded-xl shadow-lg max-h-48 overflow-y-auto"></div>
</div>
{% elif field.kind == "number" %}
{# Integer / BigInt / Real / Double #}
<input
type="number"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="{{ value }}"
step="any"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-data-mono focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all tabular-nums placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% elif field.kind == "date" %}
<input
type="date"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="{{ value }}"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% elif field.kind == "time" %}
<input
type="time"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="{{ value }}"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% elif field.kind == "datetime-local" %}
<input
type="datetime-local"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="{{ value }}"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% elif field.kind == "file" %}
{# FileField / #[umbral(widget="file")] — native file upload. The
stored value is the storage key; an empty file part on submit is
skipped server-side so an edit without re-choosing keeps the
current key. A `<input type="file">` carries no `value`, so the
current key is surfaced as a download link instead. #}
{% if field.value %}
<p class="text-body-sm text-on-surface-variant mb-xs flex items-center gap-xs">
<i data-lucide="paperclip" class="w-[14px] h-[14px] flex-shrink-0"></i>
<span>Current:</span>
<a href="{{ field.value_url }}" target="_blank" rel="noopener" class="text-primary hover:underline truncate">{{ field.value }}</a>
</p>
{% endif %}
<input
type="file"
id="f_{{ field.name }}"
name="{{ field.name }}"
{% if not field.nullable and not field.value and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% elif field.kind == "image" %}
{# ImageField / #[umbral(widget="image")] — native file upload with a
thumbnail preview of the current value. Same empty-part-skip
semantics as `file`; `accept="image/*"` nudges the picker toward
images. #}
{% if field.value %}
<div class="mb-sm">
<img src="{{ field.value_url }}" alt="{{ field.name }}" class="rounded-xl border border-outline-variant max-h-40 w-auto object-contain bg-surface-container-low" />
<p class="text-body-sm text-on-surface-variant mt-xs flex items-center gap-xs">
<i data-lucide="image" class="w-[14px] h-[14px] flex-shrink-0"></i>
<a href="{{ field.value_url }}" target="_blank" rel="noopener" class="text-primary hover:underline">View full image</a>
</p>
</div>
{% endif %}
<input
type="file"
id="f_{{ field.name }}"
name="{{ field.name }}"
accept="image/*"
{% if not field.nullable and not field.value and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% else %}
{# Default: text input (Text, Uuid, Inet, etc.) #}
<input
type="text"
id="f_{{ field.name }}"
name="{{ field.name }}"
value="{{ value }}"
{% if not field.nullable and not is_readonly %}required{% endif %}
{% if is_readonly %}disabled{% endif %}
class="w-full bg-surface-container-low border {% if error %}border-error{% else %}border-outline-variant{% endif %} rounded-xl px-md py-sm text-on-surface text-body-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all placeholder:text-outline/50 {% if is_readonly %}opacity-70 cursor-default{% endif %}"
/>
{% endif %}
{# --- Help text (#[umbral(help = "...")]) --- #}
{% if field.help %}
<p class="text-body-sm text-on-surface-variant mt-0.5">{{ field.help }}</p>
{% endif %}
{# --- Inline validation error --- #}
{% if error %}
<p class="text-body-sm text-error mt-0.5 flex items-center gap-xs">
<i data-lucide="alert-circle" class="w-[14px] h-[14px] flex-shrink-0"></i>
{{ error }}
</p>
{% endif %}
</div>
{% endmacro %}