// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package forms import ( "encoding/json" "fmt" "html" "html/template" "sort" "strings" ) // Rendering (design D5, D9, D11, D12; spec form-library "One declaration // renders create, edit and the refused submission"). One declaration, one // render, three inputs: unbound (a create page, defaults only), bound to a // record (an edit form: values, no errors), bound to a submission (a 422: // the raw submitted strings, every field's error, the form-level error). // The create page and the edit form of one entity therefore cannot drift, // because there is one field list and the mode is the only difference // (lessons L§1, L§2; finding FA-12). // // Everything the two parts render is computed here, in Go: ids, the // described-by wiring, the constraint attributes, the option list's // selected entry, the commit row. A template never composes an attribute, // so no caller can render a control without its label or its error slot // (lessons L§9, L§10). // Mode is which of the three inputs a render carries. type Mode string const ( // ModeUnbound is a create page: defaults only, no values, no errors. ModeUnbound Mode = "unbound" // ModeRecord is an edit form bound to a record: the record's values // and no errors. An edit form never opens showing errors (L§6, L§7). ModeRecord Mode = "record" // ModeSubmission is a 422: the raw submitted strings and every error. ModeSubmission Mode = "submission" ) // Binding is one render's input. type Binding struct { // Mode is which of the three inputs this is. Mode Mode // Side is the side being rendered, which decides whether a one-sided // field appears. Empty renders every declared field, which is right // for a form that has only one side (a sub-record panel, a settings // form, a search form). Side Side // Action is the concrete URL the form submits to, with any route // placeholders already filled. Empty falls back to the declaration's // Path (or EditPath on the edit side). Action string // Method overrides the declaration's method for this render. Empty // falls back to the declaration's Method (or EditMethod on the edit // side). Method string // Values are the record's or the submission's values. Values Values // Errors are the submission's refusals; nil in the other two modes. Errors *Errors // Options carry the option sets of the fields whose options are // loaded per request. The same map is passed to ParseWith, so what // the control offered and what the handler accepts are one list. Options map[string][]Option // Slots carry the first content of a field's declared slot region // (the product name's duplicate warning at page load). Slots map[string]template.HTML // Cells carry a Table-family row's middle cells, keyed by field name, // in FormSpec.Columns order. The page pre-renders them because what // they hold is the page's (a source badge, a Not set badge, an // effective value in a ), not the library's (design D21). A // field with no entry renders empty cells; a family other than Table // ignores them. Cells map[string][]template.HTML // ControlFooters carry a Table-family row's own content under its // control, in the same cell, keyed by field name; page-owned for the // same reason Cells is (design D9, typed-config-keys): the integration // settings page's Clear action and Pending restart badge describe the // stored override, not the running value, so they render here rather // than in one of Cells' columns. A field with no entry renders nothing // extra; a family other than Table ignores it. ControlFooters map[string]template.HTML // Instance disambiguates a form rendered more than once on one page: a // sub-record form repeated per row (Extend tier, once per delivery) or // a form repeated once per record on a listing (the org-type // default-change form, once per type's card). It is suffixed onto // every id the render computes, so a page with several instances // carries no duplicate DOM id; data-form stays the declaration's own // name, so the capture and coverage tooling still see one form. // Wrapper ids stay stable within one instance across its three modes // (design D11), which is what the contract is for. Empty for a form // that renders once. Instance string // Target overrides the declaration's Target for this render, for a // form whose swap target is per-instance (the org-type card's id // carries the type). Empty falls back to the declaration's Target. Target string // WayOut overrides the declaration's WayOut for this render, for a // way out that carries a per-instance id (Extend's Cancel closes the // panel named after its own delivery). Nil falls back to the // declaration's WayOut. WayOut *WayOut // Message is one block of prose rendered inside the section box, above // the fields: a preview's explanation of what it is about to do, which // belongs to the whole form rather than to one field (design D10's // preview idiom). Empty for every other kind, and for a preview with // nothing to say beyond its fields. Message template.HTML // HXSelect and HXSelectOOB narrow a Search form's request to one // embedded list panel (operator-list-scale's ListNav.Target/SyncSelect): // HXSelect carves the panel's own markup out of the GET response, // HXSelectOOB refreshes sibling panels from the same response. Both // empty for a standalone list's search, which sets Boosted instead // (form-conventions, "Every mutation form's no-JS behavior is a // recorded decision"). HXSelect string HXSelectOOB string // Body is the caller's own fragment the Rows family renders in place of // a section box: the table of records whose cells hold the // declaration's controls (spec form-library "A batch form renders the // caller's rows and one tray"). A body fragment usually needs the view // it will sit inside, because its cells call FormView.RowField, so the // common order is Bind, render the fragment with the view in scope, set // FormView.Body, render the form part. Setting it here instead is the // same thing for a body that needs nothing from the view. Refused on // any other family. Body template.HTML // Staged is the batch the tray shows, in the order the tray lists it; // the index is the delta's identity in the hidden fields the render // writes and ParseBatch reads back. Refused outside the Rows family. Staged []StagedDelta // Rows are the open editors, keyed by the record key the caller passes // to RowField as the instance. A record with no entry renders at rest, // which is the caller's own markup and none of the library's business. // Refused outside the Rows family. Rows map[string]RowBinding // Autofocus names the control that takes focus on a successful render // (design D9; spec form-library "A binding names the control that takes // focus"). A refusal ignores it and focuses the first invalid control // instead, as every other family does. Autofocus Focus // CommitTarget is the hx-target of the Rows family's commit, which // applies the batch and therefore usually re-renders more than the form // region a row action re-renders. Empty falls back to the form's own // target. CommitTarget string // CommitSwap is the hx-swap of that commit; "outerHTML" when empty, as // it is for every other control of the form. A commit whose target is a // container the page keeps (an operator body whose response is its // contents rather than a wrapper) sets "innerHTML" here, because // replacing the container itself would leave the next request with no // target to swap into. CommitSwap string // Tray renders the tray with no staged delta, for a batch form whose // tray says something at rest. Empty batch and no Tray renders no tray, // no commit and no way out. Tray bool // Boosted renders a Search form with no explicit htmx attributes at // all, relying entirely on the page's own hx-boost inheritance for // both the scripted behaviour and the native no-JS fallback: the right // choice for a standalone list's own search, whose target is the page // itself and not a selector this declaration could name. An embedded // list sets HXSelect/HXSelectOOB instead, never this. Boosted bool } // StagedDelta is one staged change to one record: what the tray lists, what // the hidden fields carry, and what ParseBatch reads back (design D1's // "Rows family contract", D3). Verb and Key are opaque to the library: it // writes them out, reads them back, and never interprets either. type StagedDelta struct { // Verb is the caller's word for what this delta does ("add", "edit", // "remove"). Verb string // Key is the record key: a record's identifier, or the caller's own // name for a record that does not exist yet. Key string // Values are the delta's field values, keyed by the declaration's bare // field names. A field the tray renders as a control (PlaceDelta) // renders from here; every other declared field present here rides as a // hidden input, so the whole batch survives every request. Values Values // Line is the delta's narrative on its tray line, rendered by the // caller: the library knows verbs and keys, not what a change means. Line template.HTML // Error is a refusal on this delta, rendered on its line with the rest // of the batch intact (design D3: every request re-validates the batch). Error string // Options are this delta's runtime option sets, keyed by field name, // for a control whose choices depend on the record (the rule change's // policy select offers a dormant stored value as its own option). // A field with no entry here falls back to Binding.Options. Options map[string][]Option } // RowBinding is one open editor: the values its controls carry and the // refusals on them, keyed by the declaration's bare field names. type RowBinding struct { Values Values Errors *Errors // Options are this row's runtime option sets, keyed by field name, for a // control whose choices depend on the record (the rule row's policy // select offers a dormant stored value as its own option). A field with // no entry here falls back to Binding.Options. Options map[string][]Option } // Focus names the control a successful render gives focus to (design D9). // The zero value focuses nothing, which is what every render did before // this existed. type Focus struct { // Field is the declared field's name. Field string // Instance is the row key the field renders under, or "staged." for // a delta's own control. Empty names a field the library renders once // (a tray field). Instance string // Tray focuses the tray's message region instead of a control; the // region renders tabindex="-1" so it can take focus at all. Tray bool // Act and Key name a posting control instead of a control that carries // a value: the row's own Edit after a Cancel, say, so focus returns to // the button the person pressed to open the editor rather than falling // to the top of the swapped region. Act string Key string } // HiddenInput is one name and value the Rows family writes out so a request // carries the whole page state: the open editors' keys and the staged // batch. The part writes them from here and composes no name of its own. type HiddenInput struct { Name string Value string } // RowActionView is one posting control of a batch form: a row's Edit or // Remove, a delta's Undo, the commit, the way out. Every one of them posts // the whole form, so each request carries every open editor's values and // the whole staged batch (design D2). type RowActionView struct { Act string Key string Label string Variant string // Classes are the button's classes, decided by the variant. Classes string // Indicator renders the in-flight spinner inside the button; the commit // carries one, a row action does not. Indicator bool // HX carries hx-post, hx-vals, hx-include, hx-target and hx-swap, built // here so no template composes a posting control by hand. HX template.HTMLAttr } // DeltaView is one staged delta's tray line. type DeltaView struct { Index int Verb string Key string Line template.HTML Error string ErrorID string // Fields are the delta's own controls (Placement PlaceDelta), named // "staged..". Fields []FieldView // Undo drops this delta from the batch. Undo RowActionView } // OptionView is one rendered option. type OptionView struct { Value string Label string Disabled bool Selected bool } // RadioView is one rendered radio in a group. type RadioView struct { ID string Value string Label string Checked bool Attrs template.HTMLAttr } // FieldView is one rendered field: everything the formField part writes. type FieldView struct { Name string Label string Control Control // InputType is the type attribute for the controls that are inputs. InputType string // LabelHidden renders the label visually hidden, for a dense row or a // bar with no room for it. The label is never absent (finding FA-51). LabelHidden bool // LabelNarrowOnly renders the label d-lg-none: a Rows body's editing // cells stack below lg, where the column headers that label them are // off to the side, and hide their own labels again from lg up where the // headers do the work (design D11). Set by the label-hidden option of // FormView.RowField, and false everywhere else. LabelNarrowOnly bool // Optional renders the "(optional)" suffix after the label. A bar and // a table never do: their fields are optional by nature, so the marker // states nothing (design D20, D21). Optional bool // WrapperID is the stable per-field id, "form--", so // inline validation can be added later without rewriting the DOM // (lessons L§18, L§19). WrapperID string // ControlID is what the label points at. ControlID string // ColClass is the dense row's grid column; empty in every other family. // Small renders the -sm control a dense row and a table row use. ColClass string Small bool Value string // Checked is a checkbox's state; CheckboxValue is what it submits. Checked bool CheckboxValue string Placeholder string Rows int Options []OptionView Radios []RadioView Help HelpIcon Hint string HintID string Error string ErrorID string // Cells are the Table family's middle cells for this row, in the // declaration's Columns order, pre-rendered by the page // (Binding.Cells). Empty in every other family. Cells []template.HTML // ControlFooter is the Table family's own content under this row's // control, in the control's own cell, pre-rendered by the page // (Binding.ControlFooters). Empty in every other family, and for a // field with nothing to show there. ControlFooter template.HTML // Notice is the declared line under the control for a one-sidedness a // person would be surprised by, and NoticeID is what the control's // aria-describedby names it by. A one-sided field says nothing about // its side unless it declares a Notice. Notice string NoticeID string // SlotID and SlotHTML are the field's declared live region. SlotID string SlotHTML template.HTML // Attrs carries every attribute derived from the declaration: // required, maxlength, min, max, pattern, inputmode, autocomplete, // aria-describedby, aria-invalid, autofocus, and the data-* and hx-* // escape hatch. Built here so a template cannot write one by hand and // the browser's constraints and the server's rules cannot differ. Attrs template.HTMLAttr } // Invalid reports whether the field was refused. func (f FieldView) Invalid() bool { return f.Error != "" } // HasErrors reports whether this render carries any refusal: a field error // or the form-level error. A page uses it to force a collapsed sub-record // panel open, so a refused submission's errors are never hidden behind a // closed panel (design D9). func (v FormView) HasErrors() bool { if v.FormError != "" { return true } for _, f := range v.Fields { if f.Error != "" { return true } } // A batch form's refusals mostly live where its controls do: on an open // editor's field or on a staged delta's line, neither of which is in // Fields (the body renders the one, the tray the other). for _, d := range v.Deltas { if d.Error != "" { return true } } for _, rb := range v.binding.Rows { if rb.Errors.Any() { return true } } return false } // WayOutView is the rendered way out. type WayOutView struct { Kind WayOutKind Label string URL string PanelID string HXTarget string HXSwap string } // Shown reports whether the form offers a way out at all. func (w WayOutView) Shown() bool { return w.Kind != WayOutNone && w.Label != "" } // DisabledControlView is the shape the shared disabledControl part reads // (internal/server.DisabledControl carries the same fields; internal/forms // cannot import internal/server, which imports it, so this is the same // shape declared on this side of the boundary). Set on FormView.CommitState // to gate a preview's commit on a precondition the declaration cannot check // itself (design D10; org-types "The commit waits for dispositions"). type DisabledControlView struct { ID string Label string Classes string Reason string Visible bool } // FormView is the whole rendered form: what the form part writes. type FormView struct { Name string // ID is the outer element's id, "form-". ID string Kind Kind // Dense, Bar, Table and Rows report the layout family, so the part can // pick its shape; all four false is Stacked (design D10, D20, D21, and // D1 of staged-rule-changes). Dense bool Bar bool Table bool Rows bool // Wide drops the bar's width cap (FormSpec.Wide). Bar only. Wide bool // Columns, LabelColumn and ControlColumn are the Table family's // headings: the label column first, the declaration's Columns next, // the control column last. Columns []string LabelColumn string ControlColumn string Fields []FieldView Commit string WayOut WayOutView // FormError is the refusal that belongs to no field. Its slot renders // on every form and is empty when unused, so a refusal without a // field name always has somewhere to land (findings FA-22, FA-26). FormError string FormErrorID string // Native reports a search form, which renders a real method and // action and keeps a true no-JavaScript fallback (spec // form-conventions "Every mutation form's no-JS behavior is a // recorded decision"). Native bool // Method and Action are the native attributes, set when Native. Method string Action string // NoValidate emits the form's `novalidate` attribute, so the refusal a // person sees is the server's, rendered under the control, and not // the browser's transient bubble (maintainer, 2026-09-03: pressing // Save on a cleared name produced no feedback at all). NoValidate bool // Box names the section box the part renders around the form (spec // form-library, "The form part owns the container"): "card" for a // create page or an edit form, "alert" for the preview idiom's muted // box (design D10), and empty for a dense sub-record row, whose panel // is already its box, for a bar, which sits in a list's header, and // for a settings table, which is its own shape. A stacked box is capped at the // readable measure and hugs its fields, so the box ends where the // fields end rather than running the width of the content column // (maintainer, 2026-09-04: "Hugging definitely"). Box string // HX carries the htmx attributes: the verb and its path, the target, // the swap, and hx-disable on the commit so a double submit is // impossible (finding FA-20). HX template.HTMLAttr // Target is the resolved hx-target (the declaration's, or Binding. // Target), for a WayOutDiscard button to reuse as its own hx-target // and hx-select: it re-fetches the page that owns this target and // carves the same region back out. Target string // CommitState, when non-nil, replaces the ordinary submit button with // the shared disabled-control idiom: a preview whose commit waits on a // precondition the declaration cannot itself check (design D10; // org-types "The commit waits for dispositions"). Nil renders the // ordinary enabled commit. Its Classes are the render's, never the // caller's: the part reads them through CommitControl, which overwrites // whatever was set with the classes the enabled commit would carry, so // a disabled commit and the way out beside it are the same height in // every state (design D23). CommitState *DisabledControlView // CommitHidden renders no commit row at all, neither the commit nor // the way out: a Preview form at rest has nothing to apply and nothing // to discard, so it shows only its fields until a choice produces a // message (design D23; maintainer, 2026-09-04: Discard with nothing to // discard). CommitHidden bool // Message is prose rendered inside the section box (Binding.Message's // doc), before the fields, or after the field MessageAfter names. Message template.HTML // MessageAfter is the field name the message follows // (FormSpec.MessageAfter). Empty puts the message before the fields. MessageAfter string // MessageID is the message region's id. Every other kind renders the // region only when Message is set; a Confirm form renders it 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 already loaded (design D4's Confirm kind; task 5.6). MessageID string // Danger reports a Confirm form's commit, which renders btn-danger by // default rather than btn-primary (design D12: "A destructive commit // renders btn-danger only inside the confirm modal"). The JS // (confirm-action-modal.js) still reassigns the class per trigger on // every open, as it does today; this is only the rendered default. Danger bool // The Rows family's own view (design D1's "Rows family contract"). Body // is the caller's table, settable after Bind because the fragment that // produces it calls RowField on this view; everything else the library // builds. Body template.HTML // Hiddens are the open editors' keys and the staged batch, as hidden // inputs the part writes and ParseBatch reads back. Hiddens []HiddenInput // Deltas are the tray's lines, one per staged delta. Deltas []DeltaView // Tray reports whether the tray renders at all: an empty batch with no // Binding.Tray renders no tray, no commit and no way out. Tray bool // TrayFocus renders the tray's message region focusable and focused, // for the render that follows a staging or an undo (design D9). TrayFocus bool // Apply and Discard are the tray's commit and way out, emitted through // the same row-action part as every other posting control of the form. Apply RowActionView Discard RowActionView // spec and binding are what RowField and RowAction read; focus is the // one control this render gives autofocus to, resolved once so at most // one control can carry it however many rows the body renders. spec FormSpec binding Binding formID string focus focusTarget } // focusTarget is the resolved answer to "which control carries autofocus": // a field under an instance, the tray's message region, or nothing. type focusTarget struct { field string instance string act string key string action bool tray bool set bool } // matches reports whether this field, under this instance, is the one. func (t focusTarget) matches(instance, field string) bool { return t.set && !t.tray && !t.action && t.instance == instance && t.field == field } // matchesAction reports whether this posting control is the one. func (t focusTarget) matchesAction(act, key string) bool { return t.set && t.action && t.act == act && t.key == key } // CommitClasses are the commit button's classes: filled by default, // btn-danger inside the confirm modal, outline in a bar (a search is not // a commit, so every bar's button is outline, the landing surface's "Look // up" included; design D20), and btn-sm only in a dense row. func (v FormView) CommitClasses() string { classes := "btn btn-primary" switch { case v.Danger: classes = "btn btn-danger" case v.Bar: classes = "btn btn-outline-secondary" } if v.Dense { classes += " btn-sm" } return classes } // CommitControl is the disabled commit the part renders in place of the // submit button, with the classes the enabled commit would have carried // (design D23): the caller declares the id, the label and the reason; the // weight and the size are the family's, so Apply change and Discard are // the same height whether the commit is live or waiting. func (v FormView) CommitControl() *DisabledControlView { if v.CommitState == nil { return nil } control := *v.CommitState control.Classes = v.CommitClasses() return &control } // Render builds the view the two parts write, from one declaration and // one binding. func Render(spec FormSpec, b Binding) FormView { checkRowsBinding(spec, b) side := b.Side method, action := spec.Method, spec.Path if side == EditOnly && spec.EditPath != "" { action = spec.EditPath if spec.EditMethod != "" { method = spec.EditMethod } } if b.Method != "" { method = b.Method } if b.Action != "" { action = b.Action } // One declaration serves a record's create page and its edit form, so // the rendered kind follows the side: the capture utility and the // sheet then name what is on screen rather than what the declaration // was filed under. kind := spec.Kind commit := spec.Commit declaredWayOut := spec.WayOut if b.WayOut != nil { declaredWayOut = *b.WayOut } wayOut := WayOutView(declaredWayOut) if side == EditOnly { if kind == KindCreate { kind = KindEdit } if spec.CommitEdit != "" { commit = spec.CommitEdit } wayOut = WayOutView{} } // formID disambiguates a form rendered more than once on one page // (Binding.Instance); data-form stays spec.Name so the capture and // coverage tooling see one declaration regardless of how many // instances render. formID := spec.Name if b.Instance != "" { formID = formID + "-" + b.Instance } target := spec.target() if b.Target != "" { target = b.Target } swap := spec.swap() view := FormView{ Name: spec.Name, ID: "form-" + formID, Kind: kind, Dense: spec.Family == Dense, Bar: spec.Family == Bar, Table: spec.Family == Table, Rows: spec.Family == Rows, Wide: spec.Wide, Columns: spec.Columns, LabelColumn: spec.LabelColumn, ControlColumn: spec.ControlColumn, Commit: commit, WayOut: wayOut, FormError: b.Errors.FormError(), FormErrorID: "form-" + formID + "-error", Method: method, Action: action, NoValidate: kind.ServerValidated(), Box: boxFor(spec.Family, kind), Target: target, Message: b.Message, MessageAfter: spec.MessageAfter, Danger: spec.Kind == KindConfirm, } if view.Table { if view.LabelColumn == "" { view.LabelColumn = "Field" } if view.ControlColumn == "" { view.ControlColumn = "Value" } } // A preview at rest has nothing to apply and nothing to discard, so it // renders no commit row at all: the org-type card at rest is its // "Change default to" select alone, and choosing a candidate is what // brings Apply change and Discard (design D23). if spec.Kind == KindPreview && b.Message == "" { view.CommitHidden = true } if spec.Kind == KindConfirm { view.MessageID = "form-" + formID + "-message" } if spec.Kind == KindSearch { view.Native = true if !b.Boosted { hx := fmt.Sprintf(`hx-get=%q hx-target=%q hx-swap=%q hx-push-url="true"`, action, target, swap) if b.HXSelect != "" { hx += fmt.Sprintf(` hx-select=%q`, b.HXSelect) } if b.HXSelectOOB != "" { hx += fmt.Sprintf(` hx-select-oob=%q`, b.HXSelectOOB) } view.HX = template.HTMLAttr(hx) } } else { view.HX = template.HTMLAttr(fmt.Sprintf( `%s=%q hx-target=%q hx-swap=%q hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator"`, hxVerb(method), action, target, swap)) } view.spec = spec view.binding = b view.formID = formID if view.Rows { view.focus = resolveRowsFocus(spec, b) buildRows(&view, spec, b, action, target) } autofocused := b.Mode != ModeSubmission for _, f := range spec.Fields { if side != BothSides && !f.RendersOn(side) { continue } if view.Rows && f.Placement != PlaceTray { // The body renders a row's fields and the tray renders a // delta's; Fields is what the library itself writes, which in // this family is the tray's own fields alone. continue } if !f.Shown(b.Values) { continue } fv := renderField(formID, spec.Family, f, b) if view.Rows { if view.focus.matches("", f.Name) { fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`) } view.Fields = append(view.Fields, fv) continue } if view.Table { // Every row carries exactly one cell per declared column, so a // page that supplied too few (or none at all) still renders a // table whose rows line up with its headings. for len(fv.Cells) < len(spec.Columns) { fv.Cells = append(fv.Cells, "") } fv.Cells = fv.Cells[:len(spec.Columns)] } if !autofocused && fv.Invalid() { fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`) autofocused = true } view.Fields = append(view.Fields, fv) } return view } // boxFor decides the section box the part renders around the form. A // dense row's box is the panel its page already opened, a bar's is the // list header it sits in, and a table is its own shape; a stacked form // owns its own, so a page writes no card of its own and every stacked // form is the same width on every surface. func boxFor(family Family, kind Kind) string { if family != Stacked { return "" } switch kind { case KindPreview: return "alert" case KindConfirm: return "" } return "card" } // hxVerb maps an HTTP method to its htmx attribute. func hxVerb(method string) string { switch strings.ToUpper(method) { case "GET": return "hx-get" case "PUT": return "hx-put" case "PATCH": return "hx-patch" case "DELETE": return "hx-delete" } return "hx-post" } // denseColClass is a field's grid column in a dense row (design D2): a // hidden field takes no column, because the template renders it with no // wrapper at all; a checkbox, radio or static field takes its own content's // width; every other control takes a fraction of the row from its rung and // its declared Width. func denseColClass(f Field) string { switch f.Control { case Hidden: return "" case Checkbox, Radio, Static: return "col-12 col-xl-auto" } fraction, _ := FieldFraction(f.Control, f.Width) return fraction.ColClass() } // renderField builds one field's view. formID is spec.Name, suffixed with // Binding.Instance when the form renders more than once on one page; // family decides the column class, the control size, whether a hidden // label is honoured, whether the "(optional)" marker renders at all, and // where the error goes. func renderField(formID string, family Family, f Field, b Binding) FieldView { return renderFieldAs(formID, "", f.Name, family, f, b) } // renderFieldAs is renderField with the two things a Rows form changes: the // suffix that makes a wrapper id unique per row or per delta, and the // submitted name, which carries the row or the delta it belongs to. Every // id the view computes derives from the wrapper, so one suffix moves the // control's id, its hint's, its notice's, its error's, its slot's and each // radio's together, and the described-by wiring follows. func renderFieldAs(formID, suffix, name string, family Family, f Field, b Binding) FieldView { dense := family == Dense wrapper := "form-" + formID + "-" + f.Name + suffix fv := FieldView{ Name: name, Label: f.Label, Control: f.Control, InputType: f.Control.InputType(), // A bar and a dense row have no room for a visible label; a // stacked form and a table always do, so a declaration that asks // to hide one there is ignored rather than obeyed (form- // conventions, "Its input SHALL carry a label, visually hidden // where the row has no room"). LabelHidden: (dense || family == Bar) && f.HideLabel, // Neither a bar nor a table renders "(optional)": a bar's one // field is optional by nature and its label is hidden anyway, and // every setting is optional by nature, so the marker on every row // said nothing (maintainer, 2026-09-04: "kinda useless"; design // D20, D21). Optional: f.Optional && family != Bar && family != Table, WrapperID: wrapper, ControlID: wrapper + "-control", Placeholder: f.Placeholder, Rows: f.Rows, Help: f.Help, Hint: f.Hint, Notice: f.Notice, CheckboxValue: f.CheckboxValue(), Cells: b.Cells[f.Name], ControlFooter: b.ControlFooters[f.Name], } if family != Table { fv.Cells = nil fv.ControlFooter = "" } if fv.Rows == 0 { fv.Rows = 3 } if dense { fv.ColClass = denseColClass(f) fv.Small = true } if family == Table || family == Rows { // A table row is one line tall; small controls fit it and full-size // ones would set every row's height from the control alone. A Rows // body's controls sit in the caller's table cells, where the same // holds, and its tray's fields sit beside small buttons. fv.Small = true } // Values. An unbound render carries none; the other two carry the // record's or the submission's, and a refused field keeps its raw // text so nothing an operator typed is thrown away (finding FA-23). if b.Mode != ModeUnbound { fv.Value = b.Values.Raw(f.Name) if f.Control == Checkbox { fv.Checked = b.Values.Raw(f.Name) == f.CheckboxValue() } } if f.Control == Static { // A static row states a fact rather than carrying one: its text is // the declaration's, never the binding's, and nothing is submitted. fv.Value = f.Value } if b.Errors != nil { fv.Error = b.Errors.Get(f.Name) } opts := f.Options if runtime, ok := b.Options[f.Name]; ok { opts = runtime } switch f.Control { case Select: // present distinguishes "bound to an explicit value" (Values.Set // was called, even with "") from "nothing chosen yet" (the name was // never set): a select can offer more than one empty-valued option // at once (a disabled "Choose a ..." placeholder alongside an // enabled "None" that deliberately clears the column, the org-type // default-change select), and fv.Value == "" alone cannot tell them // apart, so a deliberately-chosen "None" would otherwise render the // placeholder selected instead of the choice the operator made. present := b.Mode != ModeUnbound && b.Values.Present(f.Name) chosen := false for _, o := range opts { selected := present && o.Value == fv.Value if selected { chosen = true } fv.Options = append(fv.Options, OptionView{ Value: o.Value, Label: o.Label, Disabled: o.Disabled, Selected: selected, }) } // With no value chosen the browser selects the first option, which // is the placeholder or, on the product create page, the default // that creates the set with the product (design D14). Marking it // explicitly keeps the rendered markup honest about what will be // submitted. if !chosen && len(fv.Options) > 0 && !present { fv.Options[0].Selected = true } case Radio: for i, o := range opts { fv.Radios = append(fv.Radios, RadioView{ ID: fmt.Sprintf("%s-%d", wrapper, i), Value: o.Value, Label: o.Label, Checked: o.Value == fv.Value, }) } } // The described-by wiring: the hint keeps its id and stays visible // beside the error, and the error carries its own id, so a screen // reader hears why a control was refused and not only that it was // (findings FA-48, FA-49). var describedBy []string if f.Hint != "" { fv.HintID = wrapper + "-hint" describedBy = append(describedBy, fv.HintID) } if fv.Notice != "" { fv.NoticeID = wrapper + "-notice" describedBy = append(describedBy, fv.NoticeID) } if fv.Error != "" { fv.ErrorID = wrapper + "-error" describedBy = append(describedBy, fv.ErrorID) } if f.Slot != "" { fv.SlotID = wrapper + "-" + f.Slot fv.SlotHTML = b.Slots[f.Name] describedBy = append(describedBy, fv.SlotID) } fv.Attrs = template.HTMLAttr(strings.Join(fieldAttrs(f, fv, opts, describedBy), " ")) return fv } // fieldAttrs builds the attribute list the control carries, in one place, // from the declaration and from nowhere else (spec form-library // "Constraint attributes derive from the declaration"). func fieldAttrs(f Field, fv FieldView, opts []Option, describedBy []string) []string { var attrs []string add := func(key, value string) { attrs = append(attrs, fmt.Sprintf(`%s="%s"`, key, html.EscapeString(value))) } switch f.Control { case Checkbox, Hidden, Static: // A checkbox is never required: its absence is false, and the // invariants refuse the combination. A hidden field carries no // constraint the browser could enforce, and a static row carries // no control at all. case Select: // A select's requiredness is carried by its option set: an empty // value is submittable exactly when an option offers one, so the // attribute follows the same fact the server checks. if !optionExists(opts, "") { attrs = append(attrs, "required") } case Radio: // A radio group has no "choose nothing" member the way a select's // enabled empty option does — there is no convention for an empty // radio button — so its requiredness is the field's own Optional // flag, like every other control (parse.go mirrors this). if !f.Optional { attrs = append(attrs, "required") } default: if !f.Optional { attrs = append(attrs, "required") } if f.MaxLen > 0 { add("maxlength", fmt.Sprint(f.MaxLen)) } if f.Min != "" { add("min", f.Min) } if f.Max != "" { add("max", f.Max) } if f.Step != "" { add("step", f.Step) } if f.Pattern != "" { add("pattern", f.Pattern) } if f.Inputmode != "" { add("inputmode", f.Inputmode) } } if f.Autocomplete != "" && f.Control != Checkbox && f.Control != Hidden && f.Control != Static { add("autocomplete", f.Autocomplete) } if len(describedBy) > 0 { add("aria-describedby", strings.Join(describedBy, " ")) } if fv.Error != "" { add("aria-invalid", "true") } // The escape hatch, last, in a stable order so the rendered markup is // the same on every run (the capture utility compares pixels). if len(f.Attrs) > 0 { keys := make([]string, 0, len(f.Attrs)) for k := range f.Attrs { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { add(k, f.Attrs[k]) } } return attrs } // The Rows family (design D1's "Rows family contract"; spec form-library "A // batch form renders the caller's rows and one tray"). The library owns the // form element, the hidden page state, the tray and every posting control; // the caller owns the table between them and renders each cell's control // through RowField and each row's actions through RowAction, so a batch // form's markup is still the declaration's everywhere a control appears. // checkRowsBinding refuses a binding that carries the Rows family's own // input on a form of another family. It panics rather than rendering // something half right: a body, a batch or an open editor on a stacked form // is a programming error, caught the first time the page renders, the way // two forms registering under one name are caught at boot. func checkRowsBinding(spec FormSpec, b Binding) { if spec.Family == Rows { return } switch { case b.Body != "": panic(fmt.Sprintf("forms: %s is a %s form and its binding carries a Body; a body fragment belongs to the rows family", spec.Name, spec.Family)) case len(b.Staged) > 0: panic(fmt.Sprintf("forms: %s is a %s form and its binding carries Staged; a staged batch belongs to the rows family", spec.Name, spec.Family)) case len(b.Rows) > 0: panic(fmt.Sprintf("forms: %s is a %s form and its binding carries Rows; open editors belong to the rows family", spec.Name, spec.Family)) } } // rowsRefused reports whether this render is a refusal. A batch form's // refusals land in three places, because its state does: the form level and // its tray fields on Binding.Errors, an open editor's fields on its own // RowBinding, and a delta's on its line. func rowsRefused(b Binding) bool { if b.Errors.Any() { return true } for _, rb := range b.Rows { if rb.Errors.Any() { return true } } for _, d := range b.Staged { if d.Error != "" { return true } } return false } // resolveRowsFocus decides the one control that carries autofocus. A // refusal focuses the first invalid control and ignores what the binding // asked for, as every other family does; a successful render honours the // binding; neither one focuses anything it was not told to (spec // form-library "A binding names the control that takes focus"). // // "First" is the open editors in key order, each read in the declaration's // field order, then the tray's own fields: a map has no order of its own, so // the render picks one and keeps it, and the same refusal focuses the same // control on every run. func resolveRowsFocus(spec FormSpec, b Binding) focusTarget { if rowsRefused(b) { for _, instance := range sortedRowKeys(b.Rows) { rb := b.Rows[instance] for _, f := range spec.Fields { if f.Placement != PlaceRow || !f.Shown(rb.Values) { continue } if rb.Errors.Get(f.Name) != "" { return focusTarget{field: f.Name, instance: instance, set: true} } } } for _, f := range spec.Fields { if f.Placement == PlaceTray && b.Errors.Get(f.Name) != "" { return focusTarget{field: f.Name, set: true} } } return focusTarget{} } if b.Autofocus.Tray { return focusTarget{tray: true, set: true} } if b.Autofocus.Act != "" { return focusTarget{act: b.Autofocus.Act, key: b.Autofocus.Key, action: true, set: true} } if b.Autofocus.Field != "" { return focusTarget{field: b.Autofocus.Field, instance: b.Autofocus.Instance, set: true} } return focusTarget{} } // sortedRowKeys orders the open editors, so the hidden inputs, the focus // rule and the tests all read one order rather than the map's. func sortedRowKeys(rows map[string]RowBinding) []string { keys := make([]string, 0, len(rows)) for k := range rows { keys = append(keys, k) } sort.Strings(keys) return keys } // stagedPrefix is a delta's name and instance prefix. The index is the // delta's identity across a request: the render writes it, ParseBatch reads // it back, and nothing else numbers a batch. func stagedPrefix(i int) string { return fmt.Sprintf("staged.%d", i) } // buildRows fills the view's Rows-family parts: the hidden page state, the // tray's lines, and the three controls the tray carries. func buildRows(view *FormView, spec FormSpec, b Binding, action, target string) { view.Body = b.Body // Every posting control here is a