diff --git a/CLAUDE.md b/CLAUDE.md index a47d03d..ed1ee3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - **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 `
`, 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 `
`, 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/.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. diff --git a/common/components/__init__.py b/common/components/__init__.py index 80c281d..f1a5659 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -53,6 +53,8 @@ from common.components.primitives import ( A, AddForm, ButtonGroup, + DISABLED_CONTROL_CLASS, + DISABLED_WITHIN_CLASS, FormFields, Checkbox, CsrfInput, @@ -115,6 +117,8 @@ __all__ = [ "randomid", "A", "AddForm", + "DISABLED_CONTROL_CLASS", + "DISABLED_WITHIN_CLASS", "FormFields", "StyledButton", "ButtonGroup", diff --git a/common/components/primitives.py b/common/components/primitives.py index a4d3abf..3c4ed0b 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -46,6 +46,13 @@ _SIZE_CLASSES = { "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 ──────────────────────────────────────────────────── # A whitelist of plain tags, each turned into a builder over `Element`. The @@ -404,7 +411,8 @@ def Checkbox( ("value", value), ( "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 if checked: diff --git a/common/components/search_select.py b/common/components/search_select.py index 7e219b4..98dd265 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -34,7 +34,14 @@ from typing import TypedDict 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/). _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 # flex flow. (border omitted intentionally — see if it's needed later.) # 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 -# itself via :has() — callers only toggle the control's `disabled`, never styles. +# :disabled (e.g. add_purchase.ts disabling the search input), the wrapper fades +# 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 = ( "relative flex flex-wrap items-center gap-1 p-2 " - "rounded-base bg-neutral-secondary-medium " - "has-[:disabled]:opacity-50 has-[:disabled]:cursor-not-allowed" + f"rounded-base bg-neutral-secondary-medium {DISABLED_WITHIN_CLASS}" ) _PILLS_CLASS = "contents" # disabled:cursor-not-allowed matches the wrapper's cursor so hovering across -# the whole widget stays consistent — the inner input is excluded from the -# global disabled rule (input.css), which would otherwise have set it. +# the whole widget stays consistent (the wrapper handles the faded look via +# has-[:disabled]:opacity-50). _SEARCH_CLASS = ( "flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading " "focus:ring-0 focus:outline-hidden placeholder:text-body " diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py index 4b79743..13bda40 100644 --- a/e2e/test_widgets_e2e.py +++ b/e2e/test_widgets_e2e.py @@ -174,25 +174,27 @@ def test_add_purchase_type_game_disables_related_game_search( 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]") + name = page.locator("#id_name") + opacity = "el => getComputedStyle(el).opacity" + bg = "el => getComputedStyle(el).backgroundColor" page.select_option("#id_type", "game") expect(search).to_be_disabled() - # The component greys itself via has-[:disabled] when the input is disabled. - assert wrapper.evaluate("el => getComputedStyle(el).opacity") == "0.5" - # 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)" - ) - # The inner input carries the same not-allowed cursor as the wrapper, so the - # cursor doesn't flicker as the pointer crosses the widget. + # A disabled SearchSelect must look identical to a disabled native input: + # both fade (opacity-50) over the same surface. + assert wrapper.evaluate(opacity) == "0.5" + assert name.evaluate(opacity) == "0.5" + assert wrapper.evaluate(bg) == name.evaluate(bg) + # The inner input stays transparent (no nested box) with the same not-allowed + # cursor (no flicker across the widget). + assert search.evaluate(bg) == "rgba(0, 0, 0, 0)" assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed" page.select_option("#id_type", "dlc") 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( diff --git a/games/forms.py b/games/forms.py index c7a06e0..5b86573 100644 --- a/games/forms.py +++ b/games/forms.py @@ -4,6 +4,7 @@ from django.db import transaction from common.components import ( DEFAULT_PREFETCH, + DISABLED_CONTROL_CLASS, SearchSelect, SearchSelectOption, 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 # 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" -) +# selector reaching in to style them. The disabled appearance is the shared +# DISABLED_CONTROL_CLASS so every form element looks the same disabled. +_DISABLED_CONTROL = DISABLED_CONTROL_CLASS 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 " diff --git a/games/static/base.css b/games/static/base.css index 9b44923..7951eee 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -3438,14 +3438,9 @@ cursor: not-allowed; } } - .disabled\:bg-neutral-secondary-strong { + .disabled\:opacity-50 { &:disabled { - background-color: var(--color-neutral-secondary-strong); - } - } - .disabled\:text-fg-disabled { - &:disabled { - color: var(--color-fg-disabled); + opacity: 50%; } } .has-\[\:disabled\]\:cursor-not-allowed {