{{- /* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial */ -}} {{- /* SPDX-FileCopyrightText: 2025-2026 Christian Galo */ -}} {{/* Page anatomy: the form container (spec form-library "The form part owns the container"; spec form-conventions; docs/design-system.md §8 "Forms"; design D4, D9, D10, D12, D20 to D23). Pipeline: forms.Render(spec, binding) in internal/forms, built by the handler from the form's declaration; templates write {{ template "form" .Form }} and nothing else. This part and ui_form_field.html are the only templates in the console that may render a
tag, a form label or a form control; the raw-form-control lint rule holds every other template to it. The part owns the outer element (its id, its data-form id, its htmx verb, target, swap, hx-disable, and role="search" on the Search kind's native form, findings FA-53), the layout family, the form-level error slot, and the commit row. The form-level slot renders on every form and is empty when unused, so a refusal that belongs to no field always has somewhere to land (findings FA-22, FA-26, FA-27); it carries role="alert" so it is announced without a navigation. A 200 body and a 422 body of one form are the same outer element, so either swaps into the same target: htmx 4 swaps every response except 204 and 304, and its own instruction is to design error responses as swap content (lesson L§17). Every form whose refusal is a 422 carries `novalidate`, so pressing the commit with an empty required field reaches the handler and swaps the server's message in under the control. The browser's own bubble is transient, unstyleable, uncapturable, and says nothing the server's message does not; the constraint attributes stay on the controls for assistive technology and for :invalid styling (GOV.UK Frontend takes the same side). Five families, one per kind (design D10 as corrected in round 4; design D1 of staged-rule-changes added the fifth): Stacked one control per row, in the section box the part owns, so a page writes no card of its own and every stacked form is the same width wherever it renders. The box is capped at the readable measure and hugs its fields (maintainer, 2026-09-04: "Hugging definitely"). Dense one row, small controls, the commit at its end, no box (the panel its page opened is already one). Its columns align to the top under one label line, so a field's error renders under its control and grows that column alone while the commit stays level with the controls (design D16 of staged-rule-changes, which retired D22's error block below the row). Bar one input group with a leading magnifier, no box: the shape a search actually has. Its button is outline, because a search is not a commit (D20). Table one row per field, no box: the label and its hint in the first column, the page's own pre-rendered cells next, the control last, and one commit below the table (D21). Rows the caller's own table of records as the body, its cells holding the declaration's controls through the field part and its rows' actions through the row-action part; the staged batch rides as hidden inputs, and one tray under the body, at the body's width, carries the batch's lines, the tray's own fields and the commit. Parsed into every template set by web.ParseUIPartials. */}} {{ define "form" }} {{ if eq .Box "card" }}
{{ template "formTag" . }}
{{ else if eq .Box "alert" }}
{{ template "formTag" . }}
{{ else }} {{ template "formTag" . }} {{ end }} {{ end }} {{/* formTag is the form element itself, which the box wraps. */}} {{ define "formTag" }} {{ if and (not .MessageAfter) (not .Rows) }}{{ template "formMessage" . }}{{ end }} {{ if .Bar }} {{/* Bar (design D20): one input group, no box. The magnifier is an inline SVG because the console ships no icon font and the CSP forbids fetching one. Carry-along state rides as hidden inputs, and the one visible field's label is visually hidden beside the region's own heading. */}} {{ range .Fields }}{{ if eq .Control "hidden" }}{{ template "formField" . }}{{ else }}{{ template "formFieldLabel" . }}{{ end }}{{ end }}
{{ range .Fields }}{{ if ne .Control "hidden" }}{{ template "formFieldControl" . }}{{ end }}{{ end }} {{ template "formCommit" . }}
{{ else if .Table }} {{/* Table (design D21): the label and its hint in the first column, the page's own cells next (a source badge, an effective value), the control last, and one commit below the table. A row's error renders under its control in that cell, where it belongs, and so does its ControlFooter (design D9, typed-config-keys): page-owned content describing the stored override rather than the running value (the integration settings page's Clear action and Pending restart badge), on one line under the control. */}} {{ range .Fields }}{{ if eq .Control "hidden" }}{{ template "formField" . }}{{ end }}{{ end }}
{{ range .Columns }}{{ end }} {{ range .Fields }}{{ if ne .Control "hidden" }} {{ range .Cells }}{{ end }} {{ end }}{{ end }}
{{ .LabelColumn }}{{ . }}{{ .ControlColumn }}
{{ template "formFieldLabel" . }} {{ if .Hint }}
{{ .Hint }}
{{ end }} {{ if .Notice }}
{{ .Notice }}
{{ end }}
{{ . }} {{ template "formFieldControl" . }} {{ if .Error }}
{{ .Error }}
{{ end }} {{ .ControlFooter }}
{{ template "formCommit" . }}
{{ else if .Rows }} {{/* Rows (design D1, D10, D11 of staged-rule-changes): the caller's table of records is the body, so a record's Limit sits in the Limit column rather than in a panel somewhere else on the page. The library writes the page state as hidden inputs (which editors are open, and the whole staged batch), the body, and the tray. Every control in the body comes from the field part through FormView.RowField and every action from the row-action part through FormView.RowAction, so the body carries no control of its own and the anatomy lint holds it to that. The tray is the family's commit region: the binding's message (the caller's heading and its population lines), the staged deltas as a bulleted list with one item per delta, so the batch reads as a group of changes rather than as prose (maintainer, 2026-09-19), the tray's own fields, and the commit with the way out. Each item is a flex row: the sentence grows, the delta's fields follow, Undo sits at the right, and an error takes the next line. The tray renders at the body's width rather than at the stacked measure, because it reads as the table's own footing. With nothing staged and nothing asked for, there is no tray at all. */}} {{ range .Hiddens }} {{ end }} {{ .Body }} {{ if .Tray }}
{{ .Message }}
{{ if .Deltas }} {{ end }} {{ range .Fields }}{{ template "formField" . }}{{ end }}
{{ template "formRowAction" .Apply }}{{ template "formRowAction" .Discard }}
{{ end }} {{ else if .Dense }} {{/* Dense (design D10; D16 of staged-rule-changes): one row, labels above, small controls, each field in its declared column, the commit at the row's end. The columns align to the top under one label line, so an error under a control grows only its column; a column with no label of its own (the commit, a checkbox) carries the label line's height as padding (app.css .app-form-unlabeled) and sits on the control line. */}}
{{ range .Fields }}{{ template "formField" . }}{{ end }}
{{ template "formCommit" . }}
{{ else }} {{/* Stacked (design D10): one control per row, the field column capped at a readable width by app.css, so a create page and its record's edit form look the same (finding FA-12). */}}
{{ range .Fields }}{{ template "formField" . }}{{ if and $.MessageAfter (eq .Name $.MessageAfter) }}{{ template "formMessage" $ }}{{ end }}{{ end }}
{{ if not .CommitHidden }}
{{ template "formCommit" . }}
{{ end }} {{ end }}
{{ end }} {{/* formMessage is the form's own prose region: a preview's explanation of what it is about to do, which belongs to the whole form rather than to one field. A Confirm form renders the region always, empty at rest (app.css collapses an empty one), because confirm-action-modal.js writes each trigger's body text into it after the page has loaded. FormSpec.MessageAfter moves the region below the field it names, for a message that is the consequence of one control (design D23). */}} {{ define "formMessage" }} {{ if .MessageID }}

{{ .Message }}

{{ else if .Message }}
{{ .Message }}
{{ end }} {{ end }} {{/* formRowAction is one posting control of a batch form (design D2 of staged-rule-changes; spec form-library "A batch form renders the caller's rows and one tray"): a row's Edit or Remove, a staged delta's Undo, the tray's commit, the tray's way out. Pipeline: forms.RowActionView, from FormView.RowAction in the caller's body or from the view's own Apply, Discard and each delta's Undo. It is a type="button" rather than a submit, because one form carries several acts: the act and the row's key ride in hx-vals, the whole form rides in hx-include, and the response swaps the form's own region back, so every open editor's typed values and the whole staged batch survive every press. The classes and the htmx attributes are the view's, built in Go, so no template composes a posting control by hand. */}} {{ define "formRowAction" }} {{ end }} {{/* formCommit is the commit row (design D12, D20, D23; spec form-conventions "The commit's size, width, order and in-flight state are fixed"): the commit first, at its natural width, never stretched, with its in-flight indicator inside it, then the way out as btn-outline-secondary. Solid btn-secondary is not in the palette. An always-open edit form declares no way out, because nothing is left by cancelling (finding FA-19). The commit's classes come from the view (FormView.CommitClasses), so a disabled commit is the same weight and the same height as the enabled one it stands in for. */}} {{ define "formCommit" }} {{ if .CommitState }}{{ template "disabledControl" .CommitControl }} {{ else }} {{ end }} {{ with .WayOut }}{{ if .Shown }} {{ if eq .Kind "link" }}{{ if .HXTarget }}{{ else }}{{ .Label }}{{ end }} {{ else if eq .Kind "close-panel" }} {{ else if eq .Kind "dismiss-modal" }} {{ else if eq .Kind "discard" }} {{ end }} {{ end }}{{ end }} {{ end }}