Files
member-console/internal/forms/render.go
T
cgalo5758 95a863e05b Offer no tier removal that is certain to be refused or cannot be read
A refused removal preview still asked for a disposition and showed the
Keep and Migrate choices beside a disabled "Apply change" whose reason
showed only in a tooltip. The tier card's rank 0 offered Remove while it
could not read the org types its last-tier guard needs, and the only
tier of a default ladder offered a Remove the act always refuses
(#187).

- The form library gains FormView.WayOutOnly, a commit row with the
  way out alone. CommitHidden still wins over it, and it wins over
  CommitState and CommitLink.
- A removal preview the holder limit or the last-tier guard refuses
  shows its heading and the refusal, with Discard alone, and so does a
  rejected submission whose rebuilt preview finds the refusal.
- The tier card renders rank 0's Remove or Preview removal disabled,
  through the disabled-control part, with the failed read as its
  reason when the org types cannot be read, and with the last-tier
  sentence when the ladder's only tier is an org type's default.
- The reorder preview that cannot read the org types rendered no
  commit row, so the pending order held the table with no Discard. It
  now offers Discard alone.

No sentence is new. Tests pin each state, the rebuild paths and the
precedence of the new field. The design, the plan-ladder-management,
plan-acts and form-library specs, docs/plan-management.md and
docs/design-system.md say the same.

The full suite passes.
2026-10-20 03:59:41 -04:00

1656 lines
64 KiB
Go

// 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 <code>), 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
// 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-render URL (a preview's Discard re-fetches
// the page it sits on). 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), or the lead a stacked form in a panel opens with
// (Issue grant's, grant-resumption D21). Empty for a form 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
}
// ConfirmTrigger is a destructive act a batch form's row offers
// (FormView.RowConfirm): a button that opens the shared confirm dialog
// (shell_confirm_modal.html, confirm-action-modal.js), which posts the act
// itself. The dialog commits it, not this form, so the act carries none of
// the form's fields.
type ConfirmTrigger struct {
// Label is the button's word ("Revoke").
Label string
// Title, Body and ConfirmLabel are the dialog's title, body and commit.
Title string
Body string
ConfirmLabel string
// URL is where the dialog posts, Target its hx-target.
URL string
Target string
// Fields are posted with the act as hidden fields.
Fields map[string]string
}
// confirmAttrs are the attributes that make a button open the shared confirm
// dialog for t: the dialog's words, the route it posts and the hidden fields
// it posts with, the last as the JSON object the dialog's script reads. Built
// here and escaped here, so no template composes one.
func confirmAttrs(t ConfirmTrigger) template.HTMLAttr {
fields := "{}"
if len(t.Fields) > 0 {
b, err := json.Marshal(t.Fields)
if err != nil {
panic(fmt.Sprintf("forms: the confirm trigger %q's fields could not be written: %v", t.Label, err))
}
fields = string(b)
}
attrs := []string{`data-bs-toggle="modal"`, `data-bs-target="#confirmActionModal"`}
for _, kv := range [][2]string{
{"data-action-url", t.URL},
{"data-action-method", "post"},
{"data-action-target", t.Target},
{"data-action-title", t.Title},
{"data-action-body", t.Body},
{"data-action-confirm-label", t.ConfirmLabel},
{"data-action-fields", fields},
} {
attrs = append(attrs, fmt.Sprintf(`%s="%s"`, kv[0], html.EscapeString(kv[1])))
}
return template.HTMLAttr(strings.Join(attrs, " "))
}
// 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
// Action is the concrete URL this row's own commit posts to, the
// declaration's CommitAction filled in for the record, on a form that
// commits row by row (FormSpec.RowCommit). Required there, and refused
// on a form that applies a staged batch, whose commit is the tray's.
Action string
}
// 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.<i>" 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 control of a batch form: a row's Edit or Remove, a
// delta's Undo, the commit, the way out, a row's own commit on a form that
// commits row by row, and a row's destructive act. Every posting one of them
// posts the whole form, so each request carries every open editor's values
// and the whole staged batch (design D2); the destructive act opens the
// shared confirm dialog, which posts the act itself.
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; a commit
// carries one, a row action does not.
Indicator bool
// Opener renders the shared opener glyph before the label and reports
// aria-expanded, for a control that owns a panel (FormView.RowOpener);
// Expanded is that attribute's value.
Opener bool
Expanded bool
// HX carries the control's behaviour: hx-post, hx-vals, hx-include,
// hx-target and hx-swap on a posting control, or the confirm dialog's
// data-* attributes on a destructive act, built here so no template
// composes a 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.<i>.<field>".
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
// LabelAbove renders a checkbox's label above the box, as every other
// field's label sits, with its help icon after it and nothing beside
// the box (grant-resumption D19). Set for a checkbox in the Stacked and
// Dense families; a Rows cell keeps its label beside the box, and the
// Table family places the label in its own column.
LabelAbove 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-<spec>-<field>", 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
}
// CommitLinkView is a commit that is a link out of the console
// (FormView.CommitLink).
type CommitLinkView struct {
Label string
URL 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-<name>".
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
// CommitLink, when non-nil, replaces the ordinary submit button with a
// link that leaves the console, at the weight and size the submit would
// have: a preview whose next step is a page another service hosts (a
// payment the member approves on Stripe's invoice page). It opens in a
// new tab, so the console stays where the member left it. Nil renders
// the ordinary commit; CommitState, when also set, wins.
CommitLink *CommitLinkView
// CommitDescribedBy is the id of an element outside the form that
// states a fact about what committing does, such as who bills the
// charge. The part writes it as aria-describedby on the enabled submit
// button and on a CommitLink, never on a CommitState, whose disabled
// control is described by its own reason. Empty writes nothing.
CommitDescribedBy string
// 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
// WayOutOnly renders the commit row with the way out alone and no
// commit, for a Preview form whose change cannot be applied: the act
// is certain to refuse it, or the preview could not read its outcome.
// It wins over CommitState and CommitLink; CommitHidden wins over it,
// because that suppresses the whole row.
WayOutOnly 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, and a form
// that commits row by row never renders one.
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 := resolveSide(spec, b)
// 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
}
view := newFormView(spec, b, side, formID, target)
view.HX = formHX(spec.Kind, side.method, side.action, target, spec.swap(), b)
view.spec = spec
view.binding = b
view.formID = formID
if view.Rows {
view.focus = resolveRowsFocus(spec, b)
buildRows(&view, spec, b, side.action, target)
}
renderFields(&view, spec, b, side.side)
return view
}
// renderedSide is what the binding's side makes of the declaration: the
// route, the kind, the commit and the way out the view renders.
type renderedSide struct {
side Side
method, action string
kind Kind
commit string
wayOut WayOutView
}
// resolveSide applies the binding's side and overrides to the declaration:
// the method and action, the kind the view renders, the commit label and the
// way out. On the edit side a create form renders as an edit form, takes the
// record-side commit label when one is declared, and has no way out.
func resolveSide(spec FormSpec, b Binding) renderedSide {
s := renderedSide{side: b.Side, method: spec.Method, action: spec.Path, kind: spec.Kind, commit: spec.Commit}
if s.side == EditOnly && spec.EditPath != "" {
s.action = spec.EditPath
if spec.EditMethod != "" {
s.method = spec.EditMethod
}
}
if b.Method != "" {
s.method = b.Method
}
if b.Action != "" {
s.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.
declaredWayOut := spec.WayOut
if b.WayOut != nil {
declaredWayOut = *b.WayOut
}
s.wayOut = WayOutView(declaredWayOut)
if s.side == EditOnly {
if s.kind == KindCreate {
s.kind = KindEdit
}
if spec.CommitEdit != "" {
s.commit = spec.CommitEdit
}
s.wayOut = WayOutView{}
}
return s
}
// newFormView is the view before its fields: the form's id with its
// instance, the target, the family booleans, the table headings' defaults,
// the preview at rest and the confirm's message id.
func newFormView(spec FormSpec, b Binding, side renderedSide, formID, target string) FormView {
view := FormView{
Name: spec.Name,
ID: "form-" + formID,
Kind: side.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: side.commit,
WayOut: side.wayOut,
FormError: b.Errors.FormError(),
FormErrorID: "form-" + formID + "-error",
Method: side.method,
Action: side.action,
NoValidate: side.kind.ServerValidated(),
Box: boxFor(spec.Family, side.kind),
Target: target,
Message: b.Message,
MessageAfter: spec.MessageAfter,
Danger: spec.Kind == KindConfirm,
}
if view.Table && view.LabelColumn == "" {
view.LabelColumn = "Field"
}
if view.Table && 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
// "New default" 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"
}
view.Native = spec.Kind == KindSearch
return view
}
// formHX is the form element's htmx attributes: a search pushes its URL
// and may select from the answer; every other kind disables its commit and
// shows its indicator while the request runs.
func formHX(kind Kind, method, action, target, swap string, b Binding) template.HTMLAttr {
if kind != KindSearch {
return 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))
}
if b.Boosted {
return ""
}
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)
}
return template.HTMLAttr(hx)
}
// renderFields appends the fields the library itself writes, each one
// libraryRenders keeps: in a Rows form the tray's own, autofocused where the
// binding's focus names one; in a table one row per field with one cell per
// declared column; and outside a Rows form, on a submission, the first
// refused field autofocused.
func renderFields(view *FormView, spec FormSpec, b Binding, side Side) {
autofocused := b.Mode != ModeSubmission
for _, f := range spec.Fields {
if !libraryRenders(view.Rows, f, side, b.Values) {
continue
}
fv := renderField(view.formID, spec.Family, f, b)
switch {
case view.Rows:
if view.focus.matches("", f.Name) {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
}
view.Fields = append(view.Fields, fv)
continue
case view.Table:
fv.Cells = padCells(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)
}
}
// libraryRenders reports whether the form part itself writes f: a one-sided
// field renders only when the binding names its side or names no side at
// all, a Rows form's body renders a row's
// fields and its tray a delta's (Fields is, in that family, the tray's own
// fields alone), and a ShowIf field renders only when its condition holds.
func libraryRenders(rows bool, f Field, side Side, values Values) bool {
if side != BothSides && !f.RendersOn(side) {
return false
}
if rows && f.Placement != PlaceTray {
return false
}
return f.Shown(values)
}
// padCells gives a table row 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.
func padCells(cells []template.HTML, columns int) []template.HTML {
for len(cells) < columns {
cells = append(cells, "")
}
return cells[:columns]
}
// 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 {
wrapper := "form-" + formID + "-" + f.Name + suffix
fv := fieldBase(wrapper, name, family, f, b)
bindFieldValue(&fv, f, b)
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:
fv.Options = selectOptions(opts, fv.Value, b.Mode != ModeUnbound && b.Values.Present(f.Name))
case Radio:
fv.Radios = radioOptions(opts, wrapper, fv.Value)
}
describedBy := describeField(&fv, wrapper, f, b)
fv.Attrs = template.HTMLAttr(strings.Join(fieldAttrs(f, fv, opts, describedBy), " "))
return fv
}
// fieldBase is the part of a field's view the declaration and the family
// decide, before any value is bound.
func fieldBase(wrapper, name string, family Family, f Field, b Binding) FieldView {
dense := family == Dense
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,
// A checkbox's label sits above the box wherever labels sit above
// controls (grant-resumption D19); the Rows family's in-cell boxes
// keep theirs beside, and the Table family places its own.
LabelAbove: f.Control == Checkbox && (family == Stacked || dense),
// 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(),
}
if family == Table {
fv.Cells = b.Cells[f.Name]
fv.ControlFooter = b.ControlFooters[f.Name]
}
if fv.Rows == 0 {
fv.Rows = 3
}
if dense {
fv.ColClass = denseColClass(f)
}
// 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 = dense || family == Table || family == Rows
return fv
}
// bindFieldValue carries the binding's value into the view. 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). A Hidden field is the
// exception: it holds what the handler derived for this render, never a
// person's input, and a ShowIf that reads it was already decided from the
// bound value above. Rendering it empty would submit a marker the parse
// then contradicts, dropping the very field this render showed (the site
// create form's custom-domain box). A static row states a fact rather than
// carrying one: its text is the declaration's, never the binding's.
func bindFieldValue(fv *FieldView, f Field, b Binding) {
if b.Mode != ModeUnbound || f.Control == Hidden {
fv.Value = b.Values.Raw(f.Name)
fv.Checked = f.Control == Checkbox && fv.Value == f.CheckboxValue()
}
if f.Control == Static {
fv.Value = f.Value
}
}
// selectOptions marks the chosen option. present distinguishes "bound to
// an explicit value" (Values.Set was called, even with "") from "nothing
// chosen yet": 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), and the value alone cannot tell
// them apart. With nothing chosen the browser selects the first option, so
// marking it keeps the markup honest about what will be submitted (design
// D14).
func selectOptions(opts []Option, value string, present bool) []OptionView {
var out []OptionView
chosen := false
for _, o := range opts {
selected := present && o.Value == value
chosen = chosen || selected
out = append(out, OptionView{Value: o.Value, Label: o.Label, Disabled: o.Disabled, Selected: selected})
}
if !chosen && len(out) > 0 && !present {
out[0].Selected = true
}
return out
}
// radioOptions is one radio per option, each with its own id.
func radioOptions(opts []Option, wrapper, value string) []RadioView {
var out []RadioView
for i, o := range opts {
out = append(out, RadioView{ID: fmt.Sprintf("%s-%d", wrapper, i), Value: o.Value, Label: o.Label, Checked: o.Value == value})
}
return out
}
// describeField sets the ids of the lines under a control and returns them
// in described-by order: 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).
func describeField(fv *FieldView, wrapper string, f Field, b Binding) []string {
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)
}
return describedBy
}
// 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 {
checkRowCommitBinding(spec, b)
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))
}
}
// checkRowCommitBinding holds a batch form's binding to the way the form
// commits (FormSpec.RowCommit). A form that commits row by row stages
// nothing, has no tray, edits one row at a time and needs that row's own
// commit URL; a form that applies a staged batch commits from its tray, so
// a row's own commit URL means nothing there. Like checkRowsBinding it
// panics, because each of these is a programming error the first render
// catches.
func checkRowCommitBinding(spec FormSpec, b Binding) {
if !spec.RowCommit {
for key, rb := range b.Rows {
if rb.Action != "" {
panic(fmt.Sprintf("forms: %s applies a staged batch and its row %q carries an Action; a row's own commit belongs to a form that commits row by row", spec.Name, key))
}
}
return
}
switch {
case len(b.Staged) > 0:
panic(fmt.Sprintf("forms: %s commits row by row and its binding carries Staged; such a form stages nothing", spec.Name))
case b.Tray:
panic(fmt.Sprintf("forms: %s commits row by row and its binding asks for the tray; such a form has none", spec.Name))
case len(b.Rows) > 1:
panic(fmt.Sprintf("forms: %s commits row by row and its binding opens %d rows; one row edits at a time", spec.Name, len(b.Rows)))
}
for key, rb := range b.Rows {
if rb.Action == "" {
panic(fmt.Sprintf("forms: %s commits row by row and its open row %q carries no Action for its commit", spec.Name, key))
}
}
}
// 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 <button>, so the library's default
// target, "this", would resolve to the button rather than to the form.
// Name the form's own element instead, instance suffix included.
target = rowActionTarget(target, view.ID)
view.Tray = len(b.Staged) > 0 || b.Tray
view.TrayFocus = view.Tray && view.focus.set && view.focus.tray
// One hidden input per open editor, so the next request knows which
// rows were open and reads their values (design D2: every action posts
// the whole form).
for _, instance := range sortedRowKeys(b.Rows) {
view.Hiddens = append(view.Hiddens, HiddenInput{Name: "open", Value: instance})
}
for i, d := range b.Staged {
prefix := stagedPrefix(i)
dv := DeltaView{Index: i, Verb: d.Verb, Key: d.Key, Line: d.Line, Error: d.Error}
if d.Error != "" {
dv.ErrorID = "form-" + view.formID + "-" + prefix + "-error"
}
view.Hiddens = append(view.Hiddens,
HiddenInput{Name: prefix + ".verb", Value: d.Verb},
HiddenInput{Name: prefix + ".key", Value: d.Key})
for _, f := range spec.Fields {
if f.Placement == PlaceTray {
continue
}
if f.Placement == PlaceDelta && f.Shown(d.Values) {
fb := Binding{Mode: ModeRecord, Values: d.Values, Options: deltaOptions(b, d)}
fv := renderFieldAs(view.formID, "-"+prefix, prefix+"."+f.Name, Rows, f, fb)
if view.focus.matches(prefix, f.Name) {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
}
dv.Fields = append(dv.Fields, fv)
continue
}
// Every other value the delta carries rides as a hidden input,
// so a batch of any size survives a request whether or not the
// tray shows the value as a control.
if d.Values.Present(f.Name) {
view.Hiddens = append(view.Hiddens, HiddenInput{Name: prefix + "." + f.Name, Value: d.Values.Raw(f.Name)})
}
}
dv.Undo = view.focus.focused(rowActionView("undo", d.Key, undoLabel, "secondary", action, target, "outerHTML", false))
view.Deltas = append(view.Deltas, dv)
}
// A form that commits row by row has no tray, so no tray commit and no
// way out: each open row carries its own commit (FormView.RowCommit).
if spec.RowCommit {
return
}
commitTarget := rowActionTarget(b.CommitTarget, view.ID)
if commitTarget == "" {
commitTarget = target
}
wayOut := discardLabel
if spec.WayOut.Label != "" {
wayOut = spec.WayOut.Label
}
commitSwap := b.CommitSwap
if commitSwap == "" {
commitSwap = "outerHTML"
}
view.Apply = view.focus.focused(rowActionView("apply", "", view.Commit, "primary", spec.CommitAction, commitTarget, commitSwap, true))
view.Discard = view.focus.focused(rowActionView("discard", "", wayOut, "secondary", action, target, "outerHTML", false))
}
// deltaOptions are the option sets one delta's controls offer: its own
// where it declares them, the render's otherwise. A select whose choices
// depend on the record it changes (a stored value the console offers back
// but never writes by itself) needs the former.
func deltaOptions(b Binding, d StagedDelta) map[string][]Option {
return mergeOptions(b.Options, d.Options)
}
// mergeOptions lays own over base: a row's or a delta's option sets win over
// the render's for the fields they name, and the rest fall through.
func mergeOptions(base, own map[string][]Option) map[string][]Option {
if len(own) == 0 {
return base
}
merged := map[string][]Option{}
for k, v := range base {
merged[k] = v
}
for k, v := range own {
merged[k] = v
}
return merged
}
// The library's own words on a batch form's three standing controls. A
// declaration names the commit and may name the way out; nothing names
// Undo, because a delta's line is the library's.
const (
undoLabel = "Undo"
discardLabel = "Discard all"
)
// RowField returns one field of one record's row, for the caller's own cell
// to render through the field part: the declaration's label, its constraint
// attributes and its error wiring, under a name and a wrapper id that carry
// the row. The values and the refusals are that row's (Binding.Rows), so a
// refused staging renders every open editor's own text back.
//
// Two options. "label-hidden" renders the label d-lg-none: the column
// header labels the cell from lg up, and below lg, where the cells stack,
// the label is the only thing that does (design D11). "dense" is for a field
// the body lays out in a dense row of its own beside its table (the add
// form of staged-rule-changes, design D8): the wrapper takes the dense
// family's grid column from the control's rung and the declared Width; its
// error renders under the control as everywhere, and the row aligns to the
// top so a refused column grows alone (design D16). "inline" and
// "inline-fill" are for a field the body lays out in a card of its own
// under the record's row (a grant's Manage, grant-resumption D9): the
// label shows at every width, a checkbox's above its box, and the wrapper
// is a flex item of app.css .app-inline-fields, one line from the large
// breakpoint up and one per line below it; "inline-fill" takes the line's
// free width.
func (v FormView) RowField(instance, field string, options ...string) FieldView {
if v.spec.Family != Rows {
panic(fmt.Sprintf("forms: %s is a %s form; RowField exists in the rows family", v.spec.Name, v.spec.Family))
}
f, ok := v.spec.Field(field)
if !ok {
panic(fmt.Sprintf("forms: %s declares no field %q", v.spec.Name, field))
}
if f.Placement != PlaceRow {
panic(fmt.Sprintf("forms: %s's field %q is placed in the %s, not in a row", v.spec.Name, field, f.Placement))
}
labelNarrowOnly, dense, inline := false, false, ""
for _, o := range options {
switch o {
case "label-hidden":
labelNarrowOnly = true
case "dense":
dense = true
case "inline":
inline = "app-inline-field"
case "inline-fill":
inline = "app-inline-field app-inline-field-fill"
default:
panic(fmt.Sprintf("forms: %s's RowField was passed the option %q; the options are \"label-hidden\", \"dense\", \"inline\" and \"inline-fill\"", v.spec.Name, o))
}
}
rb := v.binding.Rows[instance]
b := Binding{
Mode: ModeRecord,
Values: rb.Values,
Errors: rb.Errors,
Options: mergeOptions(v.binding.Options, rb.Options),
Slots: v.binding.Slots,
}
fv := renderFieldAs(v.formID, "-"+instance, "rows."+instance+"."+field, Rows, f, b)
fv.LabelNarrowOnly = labelNarrowOnly
if dense {
fv.ColClass = denseColClass(f)
}
if inline != "" {
fv.ColClass = inline
fv.LabelAbove = f.Control == Checkbox
}
if v.focus.matches(instance, field) {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
}
return fv
}
// RowAction returns one of a row's posting controls, for the caller's own
// cell to render through the row-action part. Every one of them posts the
// whole form to the declaration's path, carrying the act, the row's key,
// every open editor's values and the whole staged batch, and swaps the
// form's own region back (design D2). The variants are primary, secondary
// and danger; secondary and danger render outline, because solid secondary
// is not in the console's palette and a row's Remove stages a change rather
// than making one.
func (v FormView) RowAction(act, key, label, variant string) RowActionView {
if v.spec.Family != Rows {
panic(fmt.Sprintf("forms: %s is a %s form; RowAction exists in the rows family", v.spec.Name, v.spec.Family))
}
return v.focus.focused(rowActionView(act, key, label, variant, v.Action, rowActionTarget(v.Target, v.ID), "outerHTML", false))
}
// RowOpener returns a row's posting control that owns a panel under the row
// (a grant's Manage card, grant-resumption D9): a RowAction that carries the
// shared opener glyph and reports aria-expanded, so the control states
// whether its panel is open. Closed, act is the act that opens the panel;
// expanded, the same control stays in its cell and posts the act that
// closes it, so opening the panel does not empty the cell and reflow the
// table's columns. The expanded control never takes autofocus: it shares
// its act and key with the panel's own way out, and only one of them may
// carry the attribute.
func (v FormView) RowOpener(act, key, label, variant string, expanded bool) RowActionView {
if v.spec.Family != Rows {
panic(fmt.Sprintf("forms: %s is a %s form; RowOpener exists in the rows family", v.spec.Name, v.spec.Family))
}
a := rowActionView(act, key, label, variant, v.Action, rowActionTarget(v.Target, v.ID), "outerHTML", false)
if !expanded {
a = v.focus.focused(a)
}
a.Opener = true
a.Expanded = expanded
return a
}
// RowCommit returns the commit of the row a form that commits row by row has
// open (FormSpec.RowCommit), for the caller's own cell to render through the
// row-action part: the declaration's commit label, posting the form, which
// holds that one row's fields and nothing else, to the row's own Action, and
// swapping what the binding's CommitTarget and CommitSwap name, as a tray's
// commit does. It carries the in-flight indicator and disables itself, the
// one control of the row a double press could apply twice.
func (v FormView) RowCommit(key string) RowActionView {
if !v.spec.RowCommit {
panic(fmt.Sprintf("forms: %s does not commit row by row; RowCommit exists on a form that declares RowCommit", v.spec.Name))
}
rb, ok := v.binding.Rows[key]
if !ok {
panic(fmt.Sprintf("forms: %s's row %q is not open; only the open row carries a commit", v.spec.Name, key))
}
target := rowActionTarget(v.binding.CommitTarget, v.ID)
if target == "" {
target = rowActionTarget(v.Target, v.ID)
}
swap := v.binding.CommitSwap
if swap == "" {
swap = "outerHTML"
}
return v.focus.focused(rowActionView("commit", key, v.Commit, "primary", rb.Action, target, swap, true))
}
// RowConfirm returns a row's destructive act, for the caller's own cell to
// render through the row-action part: an outline-danger button labelled by
// the trigger, opening the shared confirm dialog with the trigger's words,
// route and hidden fields. The dialog posts the act, so the button posts
// nothing and carries none of the form's fields (a grant's Revoke in its
// editing row, grant-resumption D9).
func (v FormView) RowConfirm(key string, t ConfirmTrigger) RowActionView {
if v.spec.Family != Rows {
panic(fmt.Sprintf("forms: %s is a %s form; RowConfirm exists in the rows family", v.spec.Name, v.spec.Family))
}
a := RowActionView{
Act: "confirm",
Key: key,
Label: t.Label,
Variant: "danger",
Classes: rowActionClasses("danger"),
HX: confirmAttrs(t),
}
return v.focus.focused(a)
}
// rowActionView builds one posting control. hx-vals is JSON built here, so
// no template composes one and a key carrying a quote cannot break out of
// the attribute.
func rowActionView(act, key, label, variant, action, target, swap string, indicator bool) RowActionView {
vals, err := json.Marshal(struct {
Act string `json:"act"`
Key string `json:"key"`
}{Act: act, Key: key})
if err != nil {
panic(fmt.Sprintf("forms: row action %q could not be written: %v", act, err))
}
hx := fmt.Sprintf(`hx-post=%q hx-vals='%s' hx-include="closest form" hx-target=%q hx-swap=%q`,
action, strings.ReplaceAll(string(vals), "'", "&#39;"), target, swap)
if indicator {
// The commit is the one control a double press could apply twice,
// and the form tag's own hx-disable names a submit button this
// family has none of: every control here is a type="button".
hx += ` hx-indicator="find .htmx-indicator" hx-disable="this"`
}
return RowActionView{
Act: act,
Key: key,
Label: label,
Variant: variant,
Classes: rowActionClasses(variant),
Indicator: indicator,
HX: template.HTMLAttr(hx),
}
}
// focused gives a posting control the autofocus attribute when the render
// named it: a row's Edit after a Cancel returns focus to the button the
// person pressed, rather than dropping it at the top of the swapped region
// (design D9). At most one control in a render is ever named, so at most
// one carries the attribute.
func (t focusTarget) focused(a RowActionView) RowActionView {
if t.matchesAction(a.Act, a.Key) {
a.HX = template.HTMLAttr(string(a.HX) + ` autofocus`)
}
return a
}
// rowActionTarget resolves a row action's hx-target. "this" on a form means
// the form element; on the button that carries the attribute it would mean
// the button, so the form's own id stands in for it (formID is FormView.ID,
// which already carries Binding.Instance).
func rowActionTarget(target, formID string) string {
if target == "this" {
return "#" + formID
}
return target
}
// rowActionClasses are the button's classes for one variant: one size for
// every control in the family, outline for the two that are not the commit.
func rowActionClasses(variant string) string {
switch variant {
case "primary":
return "btn btn-sm btn-primary ms-1"
case "secondary":
return "btn btn-sm btn-outline-secondary ms-1"
case "danger":
return "btn btn-sm btn-outline-danger ms-1"
}
panic(fmt.Sprintf("forms: %q is not a row action variant; they are primary, secondary and danger", variant))
}