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
+28 -33
View File
@@ -15,13 +15,13 @@ from common.components import (
AddForm,
ButtonGroup,
Div,
FormFields,
Fragment,
Icon,
ModuleScript,
NameWithIcon,
Node,
Popover,
Safe,
SearchField,
SessionDeviceSelector,
SessionTimestampButtons,
@@ -193,39 +193,34 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
return list_sessions(request, search_string=request.GET.get("search_string", ""))
def _session_fields(form) -> Fragment:
"""Manual per-field layout for the session form.
def _timestamp_buttons(field_name: str) -> Node:
"""The now/toggle/copy helper buttons appended to a timestamp field's row."""
this_side = "start" if field_name == "timestamp_start" else "end"
other_side = "end" if field_name == "timestamp_start" else "start"
return SessionTimestampButtons(
class_="flex flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field_name, data_type="now", size="xs")["Set to now"],
StyledButton(data_target=field_name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field_name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
Mirrors the old add_session.html: each field gets its label and widget,
and the timestamp fields gain a row of now/toggle/copy helper buttons.
"""
rows: list[Node] = []
for field in form:
children: list[Node | str] = [
Safe(str(field.label_tag())),
Safe(str(field)),
]
if field.name in ("timestamp_start", "timestamp_end"):
this_side = "start" if field.name == "timestamp_start" else "end"
other_side = "end" if field.name == "timestamp_start" else "start"
children.append(
SessionTimestampButtons(
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
hx_boost="false",
)[
StyledButton(data_target=field.name, data_type="now", size="xs")[
"Set to now"
],
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
"Toggle text"
],
StyledButton(data_target=field.name, data_type="copy", size="xs")[
f"Copy {this_side} value to {other_side}"
],
]
)
rows.append(Div(children=children))
return Fragment(*rows, separator="\n")
def _session_fields(form) -> Node:
"""Session form fields via the shared renderer, with timestamp helper
buttons appended to the two timestamp rows."""
return FormFields(
form,
extras={
name: _timestamp_buttons(name)
for name in ("timestamp_start", "timestamp_end")
},
)
@login_required