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
+29 -12
View File
@@ -161,14 +161,15 @@ def test_searchselect_border_matches_native_input(
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
price = page.locator("#id_price") # always-enabled native input
wrapper = page.locator("#id_platform")
search = page.locator("#id_platform [data-search-select-search]")
# #id_platform is now on the inner <input>; find the wrapper by name attr.
wrapper = page.locator("search-select[name='platform']")
search_input = page.locator("#id_platform")
border = "el => getComputedStyle(el).borderColor"
rest = price.evaluate(border)
assert wrapper.evaluate(border) == rest # same border at rest
search.focus()
search_input.focus()
focused_wrapper = wrapper.evaluate(border)
price.focus()
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(
authenticated_page: Page, live_server
):
"""When Type is 'game', the related-game SearchSelect is disabled — the
real disable target is the inner search input, not the wrapper <div>
(a <div> ignores the disabled property)."""
"""When Type is 'game', the related-game SearchSelect is disabled.
#id_related_game is the inner search <input> (the real labelable control),
and the <search-select> wrapper fades via has-[:disabled]:opacity-50."""
page = authenticated_page
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]")
# #id_related_game is now on the inner <input 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")
opacity = "el => getComputedStyle(el).opacity"
bg = "el => getComputedStyle(el).backgroundColor"
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:
# both fade (opacity-50) over the same surface.
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)
# 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"
assert search_input.evaluate(bg) == "rgba(0, 0, 0, 0)"
assert search_input.evaluate("el => getComputedStyle(el).cursor") == "not-allowed"
page.select_option("#id_type", "dlc")
expect(search).to_be_enabled()
expect(search_input).to_be_enabled()
# Enabled, both return to full opacity.
assert wrapper.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(
authenticated_page: Page, live_server
):