Unify disabled appearance across all form controls

Disabled controls looked inconsistent: the SearchSelect faded (opacity-50)
while native inputs used a solid strong surface. Standardize on the faded look
(opacity-50) the user preferred, via shared constants so every form element
matches:

- DISABLED_CONTROL_CLASS (disabled:opacity-50 disabled:cursor-not-allowed) on
  the control — native inputs/select/textarea via PrimitiveWidgetsMixin, plus
  the Checkbox component (previously had no disabled style).
- DISABLED_WITHIN_CLASS (has-[:disabled]: wrapper variant) for composite
  controls like SearchSelect whose disabled state lives on an inner element.

e2e asserts a disabled SearchSelect and the Name input fade identically
(opacity 0.5) and return to 1 when enabled. CLAUDE.md documents the shared
disabled constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 07:52:44 +02:00
parent 02798f8858
commit 29ba3e66e9
7 changed files with 50 additions and 34 deletions
+2 -1
View File
@@ -191,6 +191,7 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
- **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. - **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). - **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. - **Disabled form controls share one look**: every form element fades the same way when disabled, via the shared constants in `primitives.py``DISABLED_CONTROL_CLASS` (`disabled:opacity-50 disabled:cursor-not-allowed`, put on the control: native inputs via the mixin, `Checkbox`, etc.) and `DISABLED_WITHIN_CLASS` (the `has-[:disabled]:` wrapper variant, for composite controls like `SearchSelect` whose disabled state lives on an inner element). Reuse these constants; don't hand-roll a different disabled style per control.
- **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 wrapper fades itself via `DISABLED_WITHIN_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.
+4
View File
@@ -53,6 +53,8 @@ from common.components.primitives import (
A, A,
AddForm, AddForm,
ButtonGroup, ButtonGroup,
DISABLED_CONTROL_CLASS,
DISABLED_WITHIN_CLASS,
FormFields, FormFields,
Checkbox, Checkbox,
CsrfInput, CsrfInput,
@@ -115,6 +117,8 @@ __all__ = [
"randomid", "randomid",
"A", "A",
"AddForm", "AddForm",
"DISABLED_CONTROL_CLASS",
"DISABLED_WITHIN_CLASS",
"FormFields", "FormFields",
"StyledButton", "StyledButton",
"ButtonGroup", "ButtonGroup",
+9 -1
View File
@@ -46,6 +46,13 @@ _SIZE_CLASSES = {
"xl": "px-6 py-3.5 text-base", "xl": "px-6 py-3.5 text-base",
} }
# Shared disabled appearance for every form control, so all form elements look
# the same when disabled. Put on the control itself (DISABLED_CONTROL_CLASS) or,
# for composite controls whose disabled state lives on an inner element (e.g.
# SearchSelect), on the wrapper via :has() (DISABLED_WITHIN_CLASS).
DISABLED_CONTROL_CLASS = "disabled:opacity-50 disabled:cursor-not-allowed"
DISABLED_WITHIN_CLASS = "has-[:disabled]:opacity-50 has-[:disabled]:cursor-not-allowed"
# ── Generic leaf elements ──────────────────────────────────────────────────── # ── Generic leaf elements ────────────────────────────────────────────────────
# A whitelist of plain tags, each turned into a builder over `Element`. The # A whitelist of plain tags, each turned into a builder over `Element`. The
@@ -404,7 +411,8 @@ def Checkbox(
("value", value), ("value", value),
( (
"class", "class",
"rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand", "rounded border-default-medium bg-neutral-secondary-medium "
f"text-brand focus:ring-brand {DISABLED_CONTROL_CLASS}",
), ),
] + attributes ] + attributes
if checked: if checked:
+15 -7
View File
@@ -34,7 +34,14 @@ from typing import TypedDict
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.primitives import (
DISABLED_WITHIN_CLASS,
Div,
Input,
Pill,
Span,
Template,
)
# Both comboboxes are wired by ts/search_select.ts (compiled to dist/). # Both comboboxes are wired by ts/search_select.ts (compiled to dist/).
_SEARCH_SELECT_MEDIA = Media(js=("dist/search_select.js",)) _SEARCH_SELECT_MEDIA = Media(js=("dist/search_select.js",))
@@ -58,17 +65,18 @@ LabeledOption = tuple[str, str]
# with the search input. The options panel is absolute, so it sits outside the # with the search input. The options panel is absolute, so it sits outside the
# flex flow. (border omitted intentionally — see if it's needed later.) # flex flow. (border omitted intentionally — see if it's needed later.)
# The widget owns its disabled appearance: when any control inside it is # The widget owns its disabled appearance: when any control inside it is
# :disabled (e.g. add_purchase.ts disabling the search input), the wrapper greys # :disabled (e.g. add_purchase.ts disabling the search input), the wrapper fades
# itself via :has() — callers only toggle the control's `disabled`, never styles. # via :has() — the same opacity-50 a disabled native input uses (see
# _DISABLED_CONTROL in games/forms.py), so the two look identical. Callers only
# toggle the control's `disabled`, never styles.
_CONTAINER_CLASS = ( _CONTAINER_CLASS = (
"relative flex flex-wrap items-center gap-1 p-2 " "relative flex flex-wrap items-center gap-1 p-2 "
"rounded-base bg-neutral-secondary-medium " f"rounded-base bg-neutral-secondary-medium {DISABLED_WITHIN_CLASS}"
"has-[:disabled]:opacity-50 has-[:disabled]:cursor-not-allowed"
) )
_PILLS_CLASS = "contents" _PILLS_CLASS = "contents"
# disabled:cursor-not-allowed matches the wrapper's cursor so hovering across # disabled:cursor-not-allowed matches the wrapper's cursor so hovering across
# the whole widget stays consistent the inner input is excluded from the # the whole widget stays consistent (the wrapper handles the faded look via
# global disabled rule (input.css), which would otherwise have set it. # has-[:disabled]:opacity-50).
_SEARCH_CLASS = ( _SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
"focus:ring-0 focus:outline-hidden placeholder:text-body " "focus:ring-0 focus:outline-hidden placeholder:text-body "
+14 -12
View File
@@ -174,25 +174,27 @@ def test_add_purchase_type_game_disables_related_game_search(
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]")
name = page.locator("#id_name")
opacity = "el => getComputedStyle(el).opacity"
bg = "el => getComputedStyle(el).backgroundColor"
page.select_option("#id_type", "game") page.select_option("#id_type", "game")
expect(search).to_be_disabled() expect(search).to_be_disabled()
# The component greys itself via has-[:disabled] when the input is disabled. # A disabled SearchSelect must look identical to a disabled native input:
assert wrapper.evaluate("el => getComputedStyle(el).opacity") == "0.5" # both fade (opacity-50) over the same surface.
# The disabled inner input stays transparent (excluded from the global assert wrapper.evaluate(opacity) == "0.5"
# disabled-input surface) so the widget reads as one element, not a nested assert name.evaluate(opacity) == "0.5"
# box. transparent is mode-independent, so this holds in light and dark. assert wrapper.evaluate(bg) == name.evaluate(bg)
assert ( # The inner input stays transparent (no nested box) with the same not-allowed
search.evaluate("el => getComputedStyle(el).backgroundColor") # cursor (no flicker across the widget).
== "rgba(0, 0, 0, 0)" assert search.evaluate(bg) == "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" assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc") page.select_option("#id_type", "dlc")
expect(search).to_be_enabled() expect(search).to_be_enabled()
assert wrapper.evaluate("el => getComputedStyle(el).opacity") == "1" # Enabled, both return to full opacity.
assert wrapper.evaluate(opacity) == "1"
assert name.evaluate(opacity) == "1"
def test_add_game_sync_stops_once_sort_name_edited( def test_add_game_sync_stops_once_sort_name_edited(
+4 -6
View File
@@ -4,6 +4,7 @@ from django.db import transaction
from common.components import ( from common.components import (
DEFAULT_PREFETCH, DEFAULT_PREFETCH,
DISABLED_CONTROL_CLASS,
SearchSelect, SearchSelect,
SearchSelectOption, SearchSelectOption,
render, render,
@@ -28,12 +29,9 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
# Form controls self-style: these utility strings live on the elements (applied # 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 # 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 # selector reaching in to style them. The disabled appearance is the shared
# element too — no specificity wars, robust to markup changes. # DISABLED_CONTROL_CLASS so every form element looks the same disabled.
_DISABLED_CONTROL = ( _DISABLED_CONTROL = DISABLED_CONTROL_CLASS
"disabled:bg-neutral-secondary-strong disabled:text-fg-disabled "
"disabled:cursor-not-allowed"
)
INPUT_CLASS = ( INPUT_CLASS = (
"mb-3 bg-neutral-secondary-medium border border-default-medium text-heading " "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 " "text-sm rounded-base focus:ring-brand focus:border-brand block w-full "
+2 -7
View File
@@ -3438,14 +3438,9 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.disabled\:bg-neutral-secondary-strong { .disabled\:opacity-50 {
&:disabled { &:disabled {
background-color: var(--color-neutral-secondary-strong); opacity: 50%;
}
}
.disabled\:text-fg-disabled {
&:disabled {
color: var(--color-fg-disabled);
} }
} }
.has-\[\:disabled\]\:cursor-not-allowed { .has-\[\:disabled\]\:cursor-not-allowed {