diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/Combobox.dom.test.tsx
index 7fb2a690..e675a58f 100644
--- a/frontend/src/components/Combobox.dom.test.tsx
+++ b/frontend/src/components/Combobox.dom.test.tsx
@@ -64,6 +64,19 @@ describe("Combobox", () => {
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
});
+ it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
+ // Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
+ // onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
+ // always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
+ // typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
+ wrap();
+ const input = screen.getByRole("combobox");
+ const field = input.parentElement as HTMLElement;
+ expect(document.activeElement).not.toBe(input);
+ fireEvent.mouseDown(field); // press the field padding, not the input itself
+ expect(document.activeElement).toBe(input);
+ });
+
it("keeps the option list unmounted until the field is focused", () => {
wrap();
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx
index fe459da5..963a64a1 100644
--- a/frontend/src/components/Combobox.tsx
+++ b/frontend/src/components/Combobox.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
@@ -83,7 +83,7 @@ export default function Combobox({
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
- const inputRef = useRef(null);
+ const inputRef = useRef(null);
const fade = useScrollFade();
// Remember each value's display label as options flow through, so a selected chip can name itself
@@ -100,6 +100,19 @@ export default function Combobox({
onOpenChange?.(next);
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
+ // The input is the combobox reference for floating-ui AND our own focus target (the field's
+ // click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
+ // node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
+ // inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
+ // the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
+ // every render.
+ const setInputRef = useCallback(
+ (node: HTMLInputElement | null) => {
+ pop.refs.setReference(node);
+ inputRef.current = node;
+ },
+ [pop.refs],
+ );
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
@@ -129,11 +142,14 @@ export default function Combobox({
onChange(
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
+ // Keep the field focused so the user can keep picking / typing; the list stays open.
+ inputRef.current?.focus();
} else {
+ // Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
+ // immediately re-open the list (now that the merged ref makes focus() actually work).
onChange([value]);
setOpenAnd(false);
}
- inputRef.current?.focus();
};
return (
@@ -172,7 +188,7 @@ export default function Combobox({
))}
{
setQuery((e.target as HTMLInputElement).value);