Own all form styling in components; remove form CSS from input.css

Form controls were styled "at a distance": Django renders bare
<input>/<select>/<textarea>/<label>, so input.css reached in with ID-scoped
#add-form descendant rules plus a global form *:disabled rule and .errorlist.
The #add-form ID specificity forced state rules to climb, needed
:not([data-search-select-search]) carve-outs, and broke on markup changes — it
surfaced as the add_purchase Name/related_game fields not reading as disabled.

Components now own all form styling via utilities on the elements themselves:

- PrimitiveWidgetsMixin stamps INPUT/SELECT/TEXTAREA_CLASS (incl. disabled:
  variants) onto native widgets by type, skipping SearchSelect (self-styled)
  and checkboxes.
- New FormFields(form, *, extras=...) renders label + control + errors + row
  layout with their own classes (replaces form.as_div()); the <form> owns its
  flex layout. extras appends a node into a named field's row (session
  timestamp buttons).
- AddForm/purchase/session render via FormFields; login too — a new
  LoginForm(PrimitiveWidgetsMixin, AuthenticationForm) styles its inputs and
  auth.py renders it via FormFields + a StyledButton (was as_table).
- input.css loses the entire #add-form block, the global :disabled rule, and
  .errorlist. State (disabled:) now lives on the element — no specificity wars,
  no carve-outs, robust to markup edits.

Tests: error rendering uses the component class (not .errorlist); add-form
labels/inputs carry their own classes; e2e login fixtures click the Login
button by text (submit is now a <button>); Name disabled cursor asserted.
CLAUDE.md documents the no-styling-at-a-distance + FormFields conventions.

513 passed; lint/format/ts-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 07:31:53 +02:00
parent b13cc3c324
commit 02798f8858
14 changed files with 217 additions and 257 deletions
+2
View File
@@ -53,6 +53,7 @@ from common.components.primitives import (
A,
AddForm,
ButtonGroup,
FormFields,
Checkbox,
CsrfInput,
Div,
@@ -114,6 +115,7 @@ __all__ = [
"randomid",
"A",
"AddForm",
"FormFields",
"StyledButton",
"ButtonGroup",
"Checkbox",
+76 -3
View File
@@ -600,6 +600,74 @@ def YearPicker(
)
# Form-field rendering. The element classes (label/error/checkbox-row + the
# controls, which carry their own classes via PrimitiveWidgetsMixin) live here,
# not in input.css — no selector reaches across the DOM to style a form.
_LABEL_CLASS = "mb-2.5 text-sm font-medium text-heading"
_FIELD_ERROR_CLASS = "mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]"
# Checkbox + its label share a row (unlike block fields), justified apart.
_CHECKBOX_ROW_CLASS = "flex flex-row justify-between mt-3"
def _field_errors(errors) -> Node | None:
"""Render a form/field ErrorList as a styled <ul>, or None if empty."""
items = [Li(children=[str(error)]) for error in errors]
if not items:
return None
return Ul(attributes=[("class", _FIELD_ERROR_CLASS)], children=items)
def FormFields(form, *, extras: dict[str, Node] | None = None) -> Node:
"""Render a Django form's fields as self-styled component rows.
Replaces ``form.as_div()`` so labels, errors, row layout, and the checkbox
row carry their own classes (no form styling in input.css). Native controls
get their classes from ``PrimitiveWidgetsMixin``; composite widgets
(SearchSelect) self-style. ``extras`` maps a field name to a node appended
inside that field's row (e.g. the session timestamp helper buttons).
"""
extras = extras or {}
rows: list[Node] = []
non_field = _field_errors(form.non_field_errors())
if non_field:
rows.append(non_field)
for field in form:
if field.is_hidden:
rows.append(Safe(str(field)))
continue
is_checkbox = getattr(field.field.widget, "input_type", None) == "checkbox"
label = Label(
attributes=[("for", field.id_for_label), ("class", _LABEL_CLASS)],
children=[str(field.label)],
)
control = Safe(str(field))
errors = _field_errors(field.errors)
extra = extras.get(field.name)
if is_checkbox:
children: list[Node] = [label, control]
if errors:
children.append(errors)
if extra:
children.append(extra)
rows.append(
Div(attributes=[("class", _CHECKBOX_ROW_CLASS)], children=children)
)
else:
children = []
if errors:
children.append(errors)
children.extend([label, control])
if extra:
children.append(extra)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
def AddForm(
form,
*,
@@ -610,18 +678,23 @@ def AddForm(
) -> Node:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
`fields` overrides the default ``FormFields(form)`` field markup (used by the
session form, which lays out its fields manually). `additional_row` holds
extra submit buttons rendered below the main Submit button. `submit_class`
is applied to the main Submit button (the session form passes "" to match
its original markup).
"""
field_markup = fields if fields is not None else Safe(form.as_div())
field_markup = fields if fields is not None else FormFields(form)
submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Element(
"form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
attributes=[
("method", "post"),
("enctype", "multipart/form-data"),
# Form owns its row layout (was the #add-form form{} rule in input.css).
("class", "flex flex-col gap-3"),
],
children=[
CsrfInput(request),
field_markup,