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);