From d22ffd89c1e975a2171fa4303ae1d7fc7b837489 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:04:51 +0300 Subject: [PATCH] Fix search input focus on Safari iOS --- .../elements/Select.browser.test.tsx | 8 ++++ app/components/elements/Select.tsx | 39 +++++++++++++------ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/app/components/elements/Select.browser.test.tsx b/app/components/elements/Select.browser.test.tsx index 5efe7baf5..e7fd2d299 100644 --- a/app/components/elements/Select.browser.test.tsx +++ b/app/components/elements/Select.browser.test.tsx @@ -69,6 +69,14 @@ describe("SendouSelect", () => { .not.toBeInTheDocument(); }); + test("focuses the search input when opening", async () => { + const screen = await render(); + + await screen.getByRole("button").click(); + + await expect.element(screen.getByRole("combobox")).toHaveFocus(); + }); + test("shows the empty state when nothing matches the search", async () => { const screen = await render(); diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index 3c0d183ad..0ec529d50 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import { ChevronsUpDown, Search, X } from "lucide-react"; import * as React from "react"; +import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { SendouButton } from "~/components/elements/Button"; @@ -276,6 +277,21 @@ export function SendouSelect({ scrollIntoView(targetKey); }; + /** + * Focus has to move while the tap that opened the popover is still fresh, + * so it happens as the popover toggles rather than on the next frame. + * iOS Safari can still refuse it, hence the retry. + */ + const focusPopoverContent = () => { + const target = search ? searchInputRef.current : listboxRef.current; + if (!target) return; + + target.focus(); + if (document.activeElement !== target) { + requestAnimationFrame(() => target.focus()); + } + }; + const onTriggerKeyDown = (event: React.KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); @@ -344,21 +360,20 @@ export function SendouSelect({ const next = event.newState === "open"; if (next === open) return; - setOpenState(next); - onOpenChange?.(next); if (next) { focusStore.set(currentKey); - // the toggle event's render mounts the options synchronously, so they - // are registered by the time this runs - requestAnimationFrame(() => { - if (search) { - searchInputRef.current?.focus(); - } else { - listboxRef.current?.focus(); - } - scrollIntoView(currentKey); - }); + } + // focus moves into the popover right after this, so its content cannot + // wait for React to schedule the render + flushSync(() => setOpenState(next)); + onOpenChange?.(next); + + if (next) { + focusPopoverContent(); + // the options mount with the render this toggle triggers, so they are + // registered by the time the next frame runs + requestAnimationFrame(() => scrollIntoView(currentKey)); } else { setSearchValue(""); focusStore.set(null);