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,
+3 -58
View File
@@ -127,19 +127,9 @@
}
}
/* Standalone form controls get a distinct disabled surface. The SearchSelect's
inner search box is excluded: it's a composite widget that owns its disabled
look on the wrapper (has-[:disabled] in _CONTAINER_CLASS), so painting the
inner input here would render a nested box inside the wrapper. */
form input:disabled:not([data-search-select-search]),
select:disabled,
textarea:disabled {
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
/* Form controls (incl. disabled state) and form-field markup (labels, errors,
rows) are styled by utilities on the elements themselves — see
PrimitiveWidgetsMixin and FormFields. No form styling lives here. */
#button-container button {
@apply mx-1;
@@ -181,51 +171,6 @@ textarea:disabled {
padding-left: 1em;
}
#add-form {
label + select, input, textarea {
@apply mt-1;
}
form {
@apply flex flex-col gap-3;
}
.form-row-button-group {
display: flex;
flex-direction: row;
@apply gap-0 p-0;
button {
@apply mr-0;
&:first-child {
@apply rounded-e-none;
}
&:nth-child(2) {
@apply rounded-none;
}
&:last-child {
@apply rounded-s-none;
}
}
}
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]):not([data-search-select-search]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
select {
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
}
textarea {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
}
:has(> label + input[type="checkbox"]) {
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;