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:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 "
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user