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
+15 -22
View File
@@ -4,29 +4,12 @@ registration/login.html)."""
from django.contrib.auth import views as auth_views
from django.http import HttpResponse
from common.components import CsrfInput, Div, Element, Input, Node, Safe
from common.components.primitives import Td, Tr
from common.components import CsrfInput, Div, Element, FormFields, Node, StyledButton
from common.layout import render_page
from games.forms import LoginForm
def _login_content(form, request) -> Node:
table = Element(
"table",
children=[
CsrfInput(request),
Safe(str(form.as_table())),
Tr(
children=[
Td(),
Td(
children=[
Input(type="submit", attributes=[("value", "Login")])
],
),
],
),
],
)
return Div(
[("class", "flex items-center flex-col")],
[
@@ -37,15 +20,25 @@ def _login_content(form, request) -> Node:
),
Element(
"form",
attributes=[("method", "post")],
children=[table],
attributes=[
("method", "post"),
("class", "flex flex-col gap-3 w-full max-w-sm"),
],
children=[
CsrfInput(request),
FormFields(form),
StyledButton([], "Login", type="submit"),
],
),
],
)
class LoginView(auth_views.LoginView):
"""Django's LoginView, but the page body is built in Python."""
"""Django's LoginView, but the page body is built in Python and the form is
our `LoginForm` so its inputs self-style like every other form."""
authentication_form = LoginForm
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
return render_page(
+2 -1
View File
@@ -180,7 +180,8 @@ def add_game(request: HttpRequest) -> HttpResponse:
),
),
title="Add New Game",
scripts=ModuleScript("dist/search_select.js") + ModuleScript("dist/add_game.js"),
scripts=ModuleScript("dist/search_select.js")
+ ModuleScript("dist/add_game.js"),
)
+2 -2
View File
@@ -22,6 +22,7 @@ from common.components import (
CsrfInput,
Div,
Element,
FormFields,
Fragment,
GameLink,
Icon,
@@ -32,7 +33,6 @@ from common.components import (
Node,
PriceConverted,
PurchasePrice,
Safe,
SelectionFields,
StyledButton,
TableRow,
@@ -296,7 +296,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
AddForm(
form,
request=request,
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
fields=Fragment(FormFields(form), _pricing_controls()),
additional_row=_purchase_additional_row(),
),
title="Add New Purchase",
+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