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)."""
|
||||
|
||||
+13
-132
@@ -2369,6 +2369,9 @@
|
||||
.p-3 {
|
||||
padding: calc(var(--spacing) * 3);
|
||||
}
|
||||
.p-3\.5 {
|
||||
padding: calc(var(--spacing) * 3.5);
|
||||
}
|
||||
.p-4 {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -3435,6 +3438,16 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.disabled\:bg-neutral-secondary-strong {
|
||||
&:disabled {
|
||||
background-color: var(--color-neutral-secondary-strong);
|
||||
}
|
||||
}
|
||||
.disabled\:text-fg-disabled {
|
||||
&:disabled {
|
||||
color: var(--color-fg-disabled);
|
||||
}
|
||||
}
|
||||
.has-\[\:disabled\]\:cursor-not-allowed {
|
||||
&:has(*:is(:disabled)) {
|
||||
cursor: not-allowed;
|
||||
@@ -4405,20 +4418,6 @@
|
||||
border-left-color: var(--color-slate-500);
|
||||
}
|
||||
}
|
||||
form input:disabled:not([data-search-select-search]), select:disabled, textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: var(--color-neutral-secondary-strong);
|
||||
color: var(--color-fg-disabled);
|
||||
}
|
||||
.errorlist {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
margin-bottom: calc(var(--spacing) * 1);
|
||||
width: 300px;
|
||||
background-color: var(--color-red-600);
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
padding-left: calc(var(--spacing) * 3);
|
||||
color: var(--color-slate-200);
|
||||
}
|
||||
#button-container button {
|
||||
margin-inline: calc(var(--spacing) * 1);
|
||||
}
|
||||
@@ -4512,124 +4511,6 @@ form input:disabled:not([data-search-select-search]), select:disabled, textarea:
|
||||
margin-bottom: 0.5em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
#add-form {
|
||||
label + select, input, textarea {
|
||||
margin-top: calc(var(--spacing) * 1);
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing) * 3);
|
||||
}
|
||||
.form-row-button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(var(--spacing) * 0);
|
||||
padding: calc(var(--spacing) * 0);
|
||||
button {
|
||||
margin-right: calc(var(--spacing) * 0);
|
||||
&:first-child {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
border-radius: 0;
|
||||
}
|
||||
&:last-child {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
label {
|
||||
margin-bottom: calc(var(--spacing) * 2.5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
--tw-font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-heading);
|
||||
}
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
margin-bottom: calc(var(--spacing) * 3);
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-base);
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
background-color: var(--color-neutral-secondary-medium);
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
padding-block: calc(var(--spacing) * 2.5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-heading);
|
||||
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
&::placeholder {
|
||||
color: var(--color-body);
|
||||
}
|
||||
&:focus {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-base);
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
background-color: var(--color-neutral-secondary-medium);
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
padding-block: calc(var(--spacing) * 2.5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-heading);
|
||||
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
&::placeholder {
|
||||
color: var(--color-body);
|
||||
}
|
||||
&:focus {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-base);
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
border-color: var(--color-default-medium);
|
||||
background-color: var(--color-neutral-secondary-medium);
|
||||
padding: calc(var(--spacing) * 3.5);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
color: var(--color-heading);
|
||||
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
&::placeholder {
|
||||
color: var(--color-body);
|
||||
}
|
||||
&:focus {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
:has(> label + input[type="checkbox"]) {
|
||||
margin-top: calc(var(--spacing) * 3);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
|
||||
+15
-22
@@ -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
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user