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 ``data-*`` attributes wired up by ``ts/search_select.ts`` (compiled to
``games/static/js/dist/search_select.js``). ``games/static/js/dist/search_select.js``).
**Disabling**: this is a composite widget — its ``id`` sits on the wrapper **Field id / label association**: when ``SearchSelect`` is used as a Django form
``<div>``, which has no ``disabled`` state of its own. To disable it, set widget, the field ``id`` (e.g. ``id_related_game``) is placed on the inner
``disabled`` on the inner search ``<input>`` (``[data-search-select-search]``); search ``<input>`` (``[data-search-select-search]``), making it a real labelable
the wrapper then greys itself via the ``has-[:disabled]:`` utilities in control. ``<label for="id_X">`` therefore focuses the search box, and
``_CONTAINER_CLASS``. The inner input is excluded from the global ``document.querySelector('#id_X').disabled`` behaves as for a native input.
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 **Disabling**: set ``disabled`` directly on the field id (or on the inner
control's ``disabled`` — never styles. (See ``ts/add_purchase.ts`` gating ``[data-search-select-search]`` input). The wrapper greys itself via the
``related_game`` on the type field.) ``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 Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``. inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
@@ -301,6 +303,8 @@ def SearchSelect(
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
] ]
if id:
search_attrs.append(("id", id))
if autofocus: if autofocus:
search_attrs.append(("autofocus", "")) search_attrs.append(("autofocus", ""))
if search_value: if search_value:
@@ -345,7 +349,6 @@ def SearchSelect(
prefetch=prefetch, prefetch=prefetch,
sync_url="true" if sync_url else "false", sync_url="true" if sync_url else "false",
class_=_CONTAINER_CLASS, class_=_CONTAINER_CLASS,
id_=id or None,
)[*children] )[*children]
+29 -12
View File
@@ -161,14 +161,15 @@ def test_searchselect_border_matches_native_input(
page = authenticated_page page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}") page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
price = page.locator("#id_price") # always-enabled native input price = page.locator("#id_price") # always-enabled native input
wrapper = page.locator("#id_platform") # #id_platform is now on the inner <input>; find the wrapper by name attr.
search = page.locator("#id_platform [data-search-select-search]") wrapper = page.locator("search-select[name='platform']")
search_input = page.locator("#id_platform")
border = "el => getComputedStyle(el).borderColor" border = "el => getComputedStyle(el).borderColor"
rest = price.evaluate(border) rest = price.evaluate(border)
assert wrapper.evaluate(border) == rest # same border at rest assert wrapper.evaluate(border) == rest # same border at rest
search.focus() search_input.focus()
focused_wrapper = wrapper.evaluate(border) focused_wrapper = wrapper.evaluate(border)
price.focus() price.focus()
focused_input = price.evaluate(border) focused_input = price.evaluate(border)
@@ -189,19 +190,21 @@ def test_add_game_syncs_sort_name_from_name(authenticated_page: Page, live_serve
def test_add_purchase_type_game_disables_related_game_search( def test_add_purchase_type_game_disables_related_game_search(
authenticated_page: Page, live_server authenticated_page: Page, live_server
): ):
"""When Type is 'game', the related-game SearchSelect is disabled — the """When Type is 'game', the related-game SearchSelect is disabled.
real disable target is the inner search input, not the wrapper <div> #id_related_game is the inner search <input> (the real labelable control),
(a <div> ignores the disabled property).""" and the <search-select> wrapper fades via has-[:disabled]:opacity-50."""
page = authenticated_page page = authenticated_page
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") # #id_related_game is now on the inner <input data-search-select-search>
search = page.locator("#id_related_game [data-search-select-search]") search_input = page.locator("#id_related_game")
# The wrapper has no id; find it by the stable `name` attribute.
wrapper = page.locator("search-select[name='related_game']")
name = page.locator("#id_name") name = page.locator("#id_name")
opacity = "el => getComputedStyle(el).opacity" opacity = "el => getComputedStyle(el).opacity"
bg = "el => getComputedStyle(el).backgroundColor" bg = "el => getComputedStyle(el).backgroundColor"
page.select_option("#id_type", "game") page.select_option("#id_type", "game")
expect(search).to_be_disabled() expect(search_input).to_be_disabled()
# A disabled SearchSelect must look identical to a disabled native input: # A disabled SearchSelect must look identical to a disabled native input:
# both fade (opacity-50) over the same surface. # both fade (opacity-50) over the same surface.
assert wrapper.evaluate(opacity) == "0.5" assert wrapper.evaluate(opacity) == "0.5"
@@ -209,16 +212,30 @@ def test_add_purchase_type_game_disables_related_game_search(
assert wrapper.evaluate(bg) == name.evaluate(bg) assert wrapper.evaluate(bg) == name.evaluate(bg)
# The inner input stays transparent (no nested box) with the same not-allowed # The inner input stays transparent (no nested box) with the same not-allowed
# cursor (no flicker across the widget). # cursor (no flicker across the widget).
assert search.evaluate(bg) == "rgba(0, 0, 0, 0)" assert search_input.evaluate(bg) == "rgba(0, 0, 0, 0)"
assert search.evaluate("el => getComputedStyle(el).cursor") == "not-allowed" assert search_input.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_input).to_be_enabled()
# Enabled, both return to full opacity. # Enabled, both return to full opacity.
assert wrapper.evaluate(opacity) == "1" assert wrapper.evaluate(opacity) == "1"
assert name.evaluate(opacity) == "1" assert name.evaluate(opacity) == "1"
def test_label_click_focuses_search_select(authenticated_page: Page, live_server):
"""Clicking a <label for="id_X"> on a SearchSelect field must focus the
search input — confirmed now that id is on the real <input> control."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
# related_game is disabled when type is "game" (the default); switch so it
# is enabled, otherwise clicking the label for a disabled control fails.
page.select_option("#id_type", "dlc")
label = page.locator("label[for='id_related_game']")
search_input = page.locator("#id_related_game")
label.click()
expect(search_input).to_be_focused()
def test_add_game_sync_stops_once_sort_name_edited( def test_add_game_sync_stops_once_sort_name_edited(
authenticated_page: Page, live_server authenticated_page: Page, live_server
): ):
+1 -6
View File
@@ -53,12 +53,7 @@ onSwap("#id_separate_prices", (checkbox) => {
}); });
function setupElementHandlers(): void { function setupElementHandlers(): void {
// related_game is a SearchSelect: its #id_related_game wrapper is a <div> disableElementsWhenTrue("#id_type", "game", ["#id_name", "#id_related_game"]);
// (ignores `disabled`), so target the inner search <input> instead.
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_game [data-search-select-search]",
]);
} }
onSwap("#id_type", (typeSelect) => { onSwap("#id_type", (typeSelect) => {