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