fix(search-select): move field id to inner search input (issue #30)

The id (e.g. id_related_game) sat on the <search-select> wrapper, a
non-labelable custom element. Consequences:
- <label for="id_X"> focused nothing (a11y gap)
- .disabled / .focus() on #id_X silently no-oped
- add_purchase.ts needed a [data-search-select-search] descendant
  workaround to gate related_game on the type field

id is now on the [data-search-select-search] <input>, making it a real
labelable, disableable control. add_purchase.ts drops the workaround
and gates via #id_related_game directly. E2e tests updated; new test
asserts label-click focuses the search box.

Closes #30
This commit is contained in:
2026-06-20 18:34:07 +02:00
parent b3fa7fac96
commit b816c68cb8
3 changed files with 43 additions and 28 deletions
+13 -10
View File
@@ -9,15 +9,17 @@ This module imports only from ``common.components`` — it has no Django-forms o
``data-*`` attributes wired up by ``ts/search_select.ts`` (compiled to
``games/static/js/dist/search_select.js``).
**Disabling**: this is a composite widget — its ``id`` sits on the wrapper
``<div>``, which has no ``disabled`` state of its own. To disable it, set
``disabled`` on the inner search ``<input>`` (``[data-search-select-search]``);
the wrapper then greys itself via the ``has-[:disabled]:`` utilities in
``_CONTAINER_CLASS``. The inner input is excluded from the global
disabled-input surface (``common/input.css``) so it stays transparent — the
widget reads as one faded element, not a nested box. Callers toggle only the
control's ``disabled`` — never styles. (See ``ts/add_purchase.ts`` gating
``related_game`` on the type field.)
**Field id / label association**: when ``SearchSelect`` is used as a Django form
widget, the field ``id`` (e.g. ``id_related_game``) is placed on the inner
search ``<input>`` (``[data-search-select-search]``), making it a real labelable
control. ``<label for="id_X">`` therefore focuses the search box, and
``document.querySelector('#id_X').disabled`` behaves as for a native input.
**Disabling**: set ``disabled`` directly on the field id (or on the inner
``[data-search-select-search]`` input). The wrapper greys itself via the
``has-[:disabled]:`` utilities in ``_CONTAINER_CLASS``. The inner input stays
transparent — the widget reads as one faded element, not a nested box. Callers
toggle only the control's ``disabled`` — never styles.
Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
@@ -301,6 +303,8 @@ def SearchSelect(
("autocomplete", "off"),
("class", _SEARCH_CLASS),
]
if id:
search_attrs.append(("id", id))
if autofocus:
search_attrs.append(("autofocus", ""))
if search_value:
@@ -345,7 +349,6 @@ def SearchSelect(
prefetch=prefetch,
sync_url="true" if sync_url else "false",
class_=_CONTAINER_CLASS,
id_=id or None,
)[*children]