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:
@@ -189,6 +189,8 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
|
|||||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||||
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
||||||
|
- **No styling-at-a-distance; elements carry their own classes**: `input.css` is document bootstrapping only (Tailwind import, theme, fonts, resets) — it contains **no form/component styling and no selectors that reach across the DOM** (`#id descendant`, `form input:disabled`, etc.) to style something a component owns. An element's appearance, **including state** (`disabled:`, `has-[:disabled]:`, `focus:`), comes from utility classes on that element, emitted by its component. This keeps state composable (no specificity wars) and robust to markup edits.
|
||||||
|
- **Forms render via `FormFields`/`AddForm`, never `form.as_div()`**: `FormFields(form, *, extras=...)` (in `primitives.py`) renders label + control + errors + row layout with their own classes; native controls get their classes from `PrimitiveWidgetsMixin` (`games/forms.py`, which stamps `INPUT/SELECT/TEXTAREA_CLASS` incl. `disabled:` variants by widget type, skipping SearchSelect + checkbox). Every form is on this path, including login (`LoginForm(PrimitiveWidgetsMixin, AuthenticationForm)`). `extras` appends a node into a named field's row (e.g. the session timestamp buttons).
|
||||||
- **Disabling composite widgets**: a composite widget (e.g. `SearchSelect`) carries its `id` on a wrapper `<div>`, which has no `disabled` state — setting `.disabled` on it is a no-op. Disable the inner control (for `SearchSelect`, the `[data-search-select-search]` input); the **component owns its disabled *look*** via `has-[:disabled]:` utilities on its container class, so callers toggle only the control's `disabled`, never styles.
|
- **Disabling composite widgets**: a composite widget (e.g. `SearchSelect`) carries its `id` on a wrapper `<div>`, which has no `disabled` state — setting `.disabled` on it is a no-op. Disable the inner control (for `SearchSelect`, the `[data-search-select-search]` input); the **component owns its disabled *look*** via `has-[:disabled]:` utilities on its container class, so callers toggle only the control's `disabled`, never styles.
|
||||||
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
||||||
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
|
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from common.components.primitives import (
|
|||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
FormFields,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
@@ -114,6 +115,7 @@ __all__ = [
|
|||||||
"randomid",
|
"randomid",
|
||||||
"A",
|
"A",
|
||||||
"AddForm",
|
"AddForm",
|
||||||
|
"FormFields",
|
||||||
"StyledButton",
|
"StyledButton",
|
||||||
"ButtonGroup",
|
"ButtonGroup",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
|
|||||||
@@ -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(
|
def AddForm(
|
||||||
form,
|
form,
|
||||||
*,
|
*,
|
||||||
@@ -610,18 +678,23 @@ def AddForm(
|
|||||||
) -> Node:
|
) -> Node:
|
||||||
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
"""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
|
session form, which lays out its fields manually). `additional_row` holds
|
||||||
extra submit buttons rendered below the main Submit button. `submit_class`
|
extra submit buttons rendered below the main Submit button. `submit_class`
|
||||||
is applied to the main Submit button (the session form passes "" to match
|
is applied to the main Submit button (the session form passes "" to match
|
||||||
its original markup).
|
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 []
|
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||||
|
|
||||||
inner_form = Element(
|
inner_form = Element(
|
||||||
"form",
|
"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=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
field_markup,
|
field_markup,
|
||||||
|
|||||||
+3
-58
@@ -127,19 +127,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Standalone form controls get a distinct disabled surface. The SearchSelect's
|
/* Form controls (incl. disabled state) and form-field markup (labels, errors,
|
||||||
inner search box is excluded: it's a composite widget that owns its disabled
|
rows) are styled by utilities on the elements themselves — see
|
||||||
look on the wrapper (has-[:disabled] in _CONTAINER_CLASS), so painting the
|
PrimitiveWidgetsMixin and FormFields. No form styling lives here. */
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
#button-container button {
|
#button-container button {
|
||||||
@apply mx-1;
|
@apply mx-1;
|
||||||
@@ -181,51 +171,6 @@ textarea:disabled {
|
|||||||
padding-left: 1em;
|
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 {
|
@layer utilities {
|
||||||
.toast-container {
|
.toast-container {
|
||||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
|||||||
page.goto(f"{live_server.url}{reverse('login')}")
|
page.goto(f"{live_server.url}{reverse('login')}")
|
||||||
page.fill('input[name="username"]', "tester")
|
page.fill('input[name="username"]', "tester")
|
||||||
page.fill('input[name="password"]', "secret123")
|
page.fill('input[name="password"]', "secret123")
|
||||||
page.click('input[type="submit"]')
|
page.click('button:has-text("Login")')
|
||||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
|||||||
page.goto(f"{live_server.url}{reverse('login')}")
|
page.goto(f"{live_server.url}{reverse('login')}")
|
||||||
page.fill('input[name="username"]', "tester")
|
page.fill('input[name="username"]', "tester")
|
||||||
page.fill('input[name="password"]', "secret123")
|
page.fill('input[name="password"]', "secret123")
|
||||||
page.click('input[type="submit"]')
|
page.click('button:has-text("Login")')
|
||||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -20,7 +20,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
|||||||
page.goto(f"{live_server.url}{reverse('login')}")
|
page.goto(f"{live_server.url}{reverse('login')}")
|
||||||
page.fill('input[name="username"]', "tester")
|
page.fill('input[name="username"]', "tester")
|
||||||
page.fill('input[name="password"]', "secret123")
|
page.fill('input[name="password"]', "secret123")
|
||||||
page.click('input[type="submit"]')
|
page.click('button:has-text("Login")')
|
||||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||||
return page
|
return page
|
||||||
|
|
||||||
@@ -127,9 +127,14 @@ def test_add_purchase_type_toggles_disabled_fields(
|
|||||||
|
|
||||||
name_input = page.locator("#id_name")
|
name_input = page.locator("#id_name")
|
||||||
expect(name_input).to_be_disabled()
|
expect(name_input).to_be_disabled()
|
||||||
|
# The Name field (a plain input) self-styles its disabled state via the
|
||||||
|
# INPUT_CLASS disabled: variants — not a global rule. not-allowed is
|
||||||
|
# mode-independent, so it holds in light and dark.
|
||||||
|
assert name_input.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
|
||||||
|
|
||||||
page.select_option("#id_type", "dlc")
|
page.select_option("#id_type", "dlc")
|
||||||
expect(name_input).to_be_enabled()
|
expect(name_input).to_be_enabled()
|
||||||
|
assert name_input.evaluate("el => getComputedStyle(el).cursor") != "not-allowed"
|
||||||
|
|
||||||
page.select_option("#id_type", "game")
|
page.select_option("#id_type", "game")
|
||||||
expect(name_input).to_be_disabled()
|
expect(name_input).to_be_disabled()
|
||||||
@@ -168,7 +173,7 @@ def test_add_purchase_type_game_disables_related_game_search(
|
|||||||
page = authenticated_page
|
page = authenticated_page
|
||||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||||
wrapper = page.locator("#id_related_game")
|
wrapper = page.locator("#id_related_game")
|
||||||
search = page.locator('#id_related_game [data-search-select-search]')
|
search = page.locator("#id_related_game [data-search-select-search]")
|
||||||
|
|
||||||
page.select_option("#id_type", "game")
|
page.select_option("#id_type", "game")
|
||||||
expect(search).to_be_disabled()
|
expect(search).to_be_disabled()
|
||||||
@@ -177,7 +182,10 @@ def test_add_purchase_type_game_disables_related_game_search(
|
|||||||
# The disabled inner input stays transparent (excluded from the global
|
# The disabled inner input stays transparent (excluded from the global
|
||||||
# disabled-input surface) so the widget reads as one element, not a nested
|
# disabled-input surface) so the widget reads as one element, not a nested
|
||||||
# box. transparent is mode-independent, so this holds in light and dark.
|
# box. transparent is mode-independent, so this holds in light and dark.
|
||||||
assert search.evaluate("el => getComputedStyle(el).backgroundColor") == "rgba(0, 0, 0, 0)"
|
assert (
|
||||||
|
search.evaluate("el => getComputedStyle(el).backgroundColor")
|
||||||
|
== "rgba(0, 0, 0, 0)"
|
||||||
|
)
|
||||||
# The inner input carries the same not-allowed cursor as the wrapper, so the
|
# The inner input carries the same not-allowed cursor as the wrapper, so the
|
||||||
# cursor doesn't flicker as the pointer crosses the widget.
|
# cursor doesn't flicker as the pointer crosses the widget.
|
||||||
assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
|
assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
@@ -25,6 +26,30 @@ custom_datetime_widget = forms.DateTimeInput(
|
|||||||
)
|
)
|
||||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
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):
|
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||||
@@ -60,6 +85,20 @@ class PrimitiveWidgetsMixin:
|
|||||||
if isinstance(field, forms.BooleanField):
|
if isinstance(field, forms.BooleanField):
|
||||||
field.widget = PrimitiveCheckboxWidget()
|
field.widget = PrimitiveCheckboxWidget()
|
||||||
# Maintain the field's explicit required status (usually False for booleans)
|
# 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):
|
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||||
@@ -420,3 +459,8 @@ class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
widgets = {
|
widgets = {
|
||||||
"timestamp": custom_datetime_widget,
|
"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 {
|
.p-3 {
|
||||||
padding: calc(var(--spacing) * 3);
|
padding: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.p-3\.5 {
|
||||||
|
padding: calc(var(--spacing) * 3.5);
|
||||||
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -3435,6 +3438,16 @@
|
|||||||
cursor: not-allowed;
|
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-\[\:disabled\]\:cursor-not-allowed {
|
||||||
&:has(*:is(:disabled)) {
|
&:has(*:is(:disabled)) {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -4405,20 +4418,6 @@
|
|||||||
border-left-color: var(--color-slate-500);
|
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 {
|
#button-container button {
|
||||||
margin-inline: calc(var(--spacing) * 1);
|
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;
|
margin-bottom: 0.5em;
|
||||||
padding-left: 1em;
|
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 {
|
@layer utilities {
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
+15
-22
@@ -4,29 +4,12 @@ registration/login.html)."""
|
|||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
from common.components import CsrfInput, Div, Element, Input, Node, Safe
|
from common.components import CsrfInput, Div, Element, FormFields, Node, StyledButton
|
||||||
from common.components.primitives import Td, Tr
|
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
|
from games.forms import LoginForm
|
||||||
|
|
||||||
|
|
||||||
def _login_content(form, request) -> Node:
|
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(
|
return Div(
|
||||||
[("class", "flex items-center flex-col")],
|
[("class", "flex items-center flex-col")],
|
||||||
[
|
[
|
||||||
@@ -37,15 +20,25 @@ def _login_content(form, request) -> Node:
|
|||||||
),
|
),
|
||||||
Element(
|
Element(
|
||||||
"form",
|
"form",
|
||||||
attributes=[("method", "post")],
|
attributes=[
|
||||||
children=[table],
|
("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):
|
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:
|
def render_to_response(self, context, **response_kwargs) -> HttpResponse:
|
||||||
return render_page(
|
return render_page(
|
||||||
|
|||||||
+2
-1
@@ -180,7 +180,8 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
title="Add New Game",
|
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,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
Element,
|
||||||
|
FormFields,
|
||||||
Fragment,
|
Fragment,
|
||||||
GameLink,
|
GameLink,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -32,7 +33,6 @@ from common.components import (
|
|||||||
Node,
|
Node,
|
||||||
PriceConverted,
|
PriceConverted,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
Safe,
|
|
||||||
SelectionFields,
|
SelectionFields,
|
||||||
StyledButton,
|
StyledButton,
|
||||||
TableRow,
|
TableRow,
|
||||||
@@ -296,7 +296,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
AddForm(
|
AddForm(
|
||||||
form,
|
form,
|
||||||
request=request,
|
request=request,
|
||||||
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
|
fields=Fragment(FormFields(form), _pricing_controls()),
|
||||||
additional_row=_purchase_additional_row(),
|
additional_row=_purchase_additional_row(),
|
||||||
),
|
),
|
||||||
title="Add New Purchase",
|
title="Add New Purchase",
|
||||||
|
|||||||
+28
-33
@@ -15,13 +15,13 @@ from common.components import (
|
|||||||
AddForm,
|
AddForm,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Div,
|
Div,
|
||||||
|
FormFields,
|
||||||
Fragment,
|
Fragment,
|
||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
NameWithIcon,
|
NameWithIcon,
|
||||||
Node,
|
Node,
|
||||||
Popover,
|
Popover,
|
||||||
Safe,
|
|
||||||
SearchField,
|
SearchField,
|
||||||
SessionDeviceSelector,
|
SessionDeviceSelector,
|
||||||
SessionTimestampButtons,
|
SessionTimestampButtons,
|
||||||
@@ -193,39 +193,34 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
|||||||
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
||||||
|
|
||||||
|
|
||||||
def _session_fields(form) -> Fragment:
|
def _timestamp_buttons(field_name: str) -> Node:
|
||||||
"""Manual per-field layout for the session form.
|
"""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.
|
def _session_fields(form) -> Node:
|
||||||
"""
|
"""Session form fields via the shared renderer, with timestamp helper
|
||||||
rows: list[Node] = []
|
buttons appended to the two timestamp rows."""
|
||||||
for field in form:
|
return FormFields(
|
||||||
children: list[Node | str] = [
|
form,
|
||||||
Safe(str(field.label_tag())),
|
extras={
|
||||||
Safe(str(field)),
|
name: _timestamp_buttons(name)
|
||||||
]
|
for name in ("timestamp_start", "timestamp_end")
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertIn("dist/add_game.js", html)
|
self.assertIn("dist/add_game.js", html)
|
||||||
self.assertIn("submit_and_redirect", html)
|
self.assertIn("submit_and_redirect", html)
|
||||||
self.assertIn("Submit & Create Purchase", html) # & correctly escaped
|
self.assertIn("Submit & Create Purchase", html) # & correctly escaped
|
||||||
|
# Fields self-style: label + control carry their own classes (no #add-form
|
||||||
|
# / form CSS in input.css).
|
||||||
|
self.assertIn("mb-2.5 text-sm font-medium text-heading", html) # _LABEL_CLASS
|
||||||
|
self.assertIn("bg-neutral-secondary-medium", html) # INPUT_CLASS surface
|
||||||
|
self.assertNoEscapedTags(html)
|
||||||
|
|
||||||
|
def test_form_errors_render_with_component_class(self):
|
||||||
|
"""Invalid submits re-render field errors via FormFields' own class, not
|
||||||
|
Django's .errorlist (which no longer exists in the CSS)."""
|
||||||
|
# Non-empty but invalid (name is required) so the form binds and
|
||||||
|
# re-renders with errors — an empty {} POST is falsy and stays unbound.
|
||||||
|
response = self.client.post(reverse("games:add_game"), {"status": "u"})
|
||||||
|
html = response.content.decode()
|
||||||
|
self.assertIn("bg-red-600", html) # _FIELD_ERROR_CLASS
|
||||||
|
self.assertNotIn('class="errorlist"', html)
|
||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
|
|
||||||
def test_add_purchase_form(self):
|
def test_add_purchase_form(self):
|
||||||
@@ -264,8 +279,9 @@ class RenderedPagesTest(TestCase):
|
|||||||
"<!DOCTYPE html>", # full Page() layout
|
"<!DOCTYPE html>", # full Page() layout
|
||||||
"Please log in to continue",
|
"Please log in to continue",
|
||||||
"csrfmiddlewaretoken",
|
"csrfmiddlewaretoken",
|
||||||
|
'name="username"', # auth form fields rendered via FormFields
|
||||||
'type="submit"',
|
'type="submit"',
|
||||||
'value="Login"',
|
">Login<", # StyledButton submit (was an <input value="Login">)
|
||||||
"</html>",
|
"</html>",
|
||||||
]:
|
]:
|
||||||
self.assertIn(marker, html)
|
self.assertIn(marker, html)
|
||||||
|
|||||||
Reference in New Issue
Block a user