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
on the page).""" diff --git a/games/static/base.css b/games/static/base.css index 7951eee..0c1bf77 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -2825,6 +2825,10 @@ --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .ring { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .ring-2 { --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);