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:
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.db import transaction
|
||||
|
||||
from common.components import (
|
||||
@@ -25,6 +26,30 @@ custom_datetime_widget = forms.DateTimeInput(
|
||||
)
|
||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
# Form controls self-style: these utility strings live on the elements (applied
|
||||
# by PrimitiveWidgetsMixin), so there is no form styling in input.css and no
|
||||
# selector reaching in to style them. The disabled: variants put state on the
|
||||
# element too — no specificity wars, robust to markup changes.
|
||||
_DISABLED_CONTROL = (
|
||||
"disabled:bg-neutral-secondary-strong disabled:text-fg-disabled "
|
||||
"disabled:cursor-not-allowed"
|
||||
)
|
||||
INPUT_CLASS = (
|
||||
"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 "
|
||||
f"px-3 py-2.5 shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
|
||||
)
|
||||
SELECT_CLASS = (
|
||||
"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 "
|
||||
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
|
||||
)
|
||||
TEXTAREA_CLASS = (
|
||||
"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 "
|
||||
f"shadow-xs placeholder:text-body {_DISABLED_CONTROL}"
|
||||
)
|
||||
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
@@ -60,6 +85,20 @@ class PrimitiveWidgetsMixin:
|
||||
if isinstance(field, forms.BooleanField):
|
||||
field.widget = PrimitiveCheckboxWidget()
|
||||
# Maintain the field's explicit required status (usually False for booleans)
|
||||
continue
|
||||
widget = field.widget
|
||||
# SearchSelect is a self-styled composite component; never stamp the
|
||||
# native-control classes onto it.
|
||||
if isinstance(widget, SearchSelectWidget):
|
||||
continue
|
||||
if isinstance(widget, forms.Select):
|
||||
control_class = SELECT_CLASS
|
||||
elif isinstance(widget, forms.Textarea):
|
||||
control_class = TEXTAREA_CLASS
|
||||
else:
|
||||
control_class = INPUT_CLASS
|
||||
existing = widget.attrs.get("class", "")
|
||||
widget.attrs["class"] = f"{existing} {control_class}".strip()
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
@@ -420,3 +459,8 @@ class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
widgets = {
|
||||
"timestamp": custom_datetime_widget,
|
||||
}
|
||||
|
||||
|
||||
class LoginForm(PrimitiveWidgetsMixin, AuthenticationForm):
|
||||
"""Django's auth form with our primitive widget styling so login inputs
|
||||
self-style like every other form (no styling-at-a-distance)."""
|
||||
|
||||
Reference in New Issue
Block a user