diff --git a/common/components/search_select.py b/common/components/search_select.py index 98dd265..bfbc12c 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -63,15 +63,21 @@ LabeledOption = tuple[str, str] # widget reads as a single clickable field; the pills wrapper uses `contents` # so its pills/hidden inputs flow as direct participants of that row, inline # 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 + focus styling mirror a native input (INPUT_CLASS): border-default-medium +# normally, brand border + ring on focus. The search input is the focusable +# element, so the focus state is expressed on the wrapper with focus-within: (and +# the inner input suppresses its own ring — see _SEARCH_CLASS). # The widget owns its disabled appearance: when any control inside it is # :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 " - f"rounded-base bg-neutral-secondary-medium {DISABLED_WITHIN_CLASS}" + "relative flex flex-wrap items-center gap-1 p-2 rounded-base " + "bg-neutral-secondary-medium border border-default-medium " + "focus-within:border-brand focus-within:ring-1 focus-within:ring-brand " + f"{DISABLED_WITHIN_CLASS}" ) _PILLS_CLASS = "contents" # disabled:cursor-not-allowed matches the wrapper's cursor so hovering across diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py index 13bda40..34b43e7 100644 --- a/e2e/test_widgets_e2e.py +++ b/e2e/test_widgets_e2e.py @@ -154,6 +154,30 @@ def test_add_purchase_related_game_is_flat_game_search( expect(related).to_have_attribute("data-search-url", "/api/games/search") +def test_searchselect_border_matches_native_input( + authenticated_page: Page, live_server +): + """A SearchSelect's wrapper has the same border as a native input, and turns + brand on focus (via focus-within on the wrapper, since the inner search box + is what's focused).""" + 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]") + border = "el => getComputedStyle(el).borderColor" + + rest = price.evaluate(border) + assert wrapper.evaluate(border) == rest # same border at rest + + search.focus() + focused_wrapper = wrapper.evaluate(border) + price.focus() + focused_input = price.evaluate(border) + assert focused_wrapper == focused_input # same brand border on focus + assert focused_wrapper != rest # focus actually changes it + + def test_add_game_syncs_sort_name_from_name(authenticated_page: Page, live_server): """Typing into Name live-fills Sort name (sync bound to the add form, not the navbar logout form which is the first