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
+11 -3
View File
@@ -20,7 +20,7 @@ def authenticated_page(live_server, page: Page, django_user_model) -> Page:
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
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**")
return page
@@ -127,9 +127,14 @@ def test_add_purchase_type_toggles_disabled_fields(
name_input = page.locator("#id_name")
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")
expect(name_input).to_be_enabled()
assert name_input.evaluate("el => getComputedStyle(el).cursor") != "not-allowed"
page.select_option("#id_type", "game")
expect(name_input).to_be_disabled()
@@ -168,7 +173,7 @@ def test_add_purchase_type_game_disables_related_game_search(
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
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")
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
# 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.
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
# cursor doesn't flicker as the pointer crosses the widget.
assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"