{% comment %} One row of a formset. Minimal markup: a hairline and a header are all that set one related object apart from the next, rather than a card or a box. A row opens with a header: the object's own label on the left, the remove control on the right. The label matters because a row usually edits a related record, and "which one am I editing" is otherwise unanswerable from the page. A saved row shows the object's string; an unsaved one is named by its model, since str() on an unsaved instance reads `Thing object (None)`. Above the header, every row but the first carries a hairline . The rule leads the row rather than sitting between rows, so that a removed row takes its own separator with it; a rule rendered between two rows would be orphaned the moment either was hidden. `first` comes from the set's loop, and defaults to False so that a row cloned from the empty-form template - which is only ever appended after the others - always gets one. Then every hidden field, every visible field except DELETE rendered through crispy (so a row's field matches a single form's field exactly), and DELETE as a hidden input rather than a checkbox. The form's own non-field errors render above its fields. The remove control is an icon alone. Its text survives as the accessible name, and it is transparent until the row is hovered or something inside it takes focus - `group-focus-within`, not hover alone, or the control would be unreachable by keyboard. The removed state is the `mvpFormsetRow` Alpine component in mvp/static/js/formset.js; the x-data attribute only seeds it from form.DELETE.value. A removed row is hidden, never detached - its inputs stay in the document so the removal survives an invalid submission and re-render. Removing dispatches an event the set listens for (context.delete_semantics). The control needs BOTH `can-delete` and a DELETE field on this row. Under `can_delete_extra=False` Django gives DELETE only to the initial forms while `formset.can_delete` stays True, so the set-wide flag alone would put a Remove button on an extra row that has no way to record the removal - the row would hide and its data would still save. `layout` picks between the two presentations, and the row renders both rather than the set choosing once, because the choice is per width as well as per developer. Under `tabular`, from the `sm` breakpoint up, the fields become one grid row on the column tracks in `grid-style`, each field's own label goes screen-reader-only because the set's header row now names the column, and the remove control moves into the trailing column. Below `sm` every one of those reverts and the row is the stacked row exactly - three columns of inputs are unusable on a phone, and the stacked layout is already the right answer at that width, so it is reused rather than reinvented. The label is demoted, never dropped. It is what names the input to a screen reader; a column header is not associated with the cells beneath it and does not do that job. See the `as_crispy_cell` filter. Help text goes the same way as the label and for the same reason: restating it under every cell of a column costs more height than the layout saves. The set's heading row carries one copy, and the `` crispy renders it in is hidden here from the same breakpoint. Errors are `

` and are untouched. Errors keep their stacked treatment on purpose. A field's error renders in its own cell under the control, and the form's non-field errors render full width above the grid, so an invalid row grows taller than its neighbours. Django's own admin makes the same trade in `edit_inline/tabular.html`: an error that broke no rhythm would be an error nobody noticed. {% endcomment %} {% load i18n crispy_forms_filters mvp %} {% if form %}

{% if not first %}{% endif %} {% firstof label form|formset_row_label as row_label %} {% if row_label or can_delete and form.DELETE %}
{{ row_label }} {% if can_delete and form.DELETE %} {% endif %}
{% endif %} {{ form|as_crispy_errors }} {% for field in form.hidden_fields %}{{ field }}{% endfor %} {% comment %} The grid wrapper opens and closes conditionally rather than the field loop being written out twice, the same idiom as the optional fieldset in cotton/form/field.html. djlint cannot pair tags across a branch. {% endcomment %} {# djlint:off #} {% if layout == "tabular" %}
{% endif %} {% for field in form.visible_fields %} {% if field.name == "DELETE" %} {% elif layout == "tabular" %} {{ field|as_crispy_cell }} {% else %} {{ field|as_crispy_field }} {% endif %} {% endfor %} {% comment %} The trailing column is present on every row of a set that can delete, so the columns before it line up whether or not this particular row has a DELETE field to record a removal with. Below `sm` the row's own header carries the control instead, so this one is not drawn there. {% endcomment %} {% if layout == "tabular" %} {% if can_delete %} {% endif %}
{% endif %} {# djlint:on #}
{% endif %}