fix(combobox): merged input ref so the field is typeable by mouse

The <input> carried only floating-ui's reference ref, so the component's own
inputRef stayed null. The field container's onMouseDown then preventDefault()-ed
every press (its `e.target !== inputRef.current` guard was always true) and the
compensating inputRef.current?.focus() was a no-op — so clicking the box opened
the list but never focused the input, and mouse users could not type to filter.
Tab focus still worked, which is why it read as 'click, can't type'.

Feed a merged callback ref that assigns the node to both floating-ui's
setReference and inputRef. Fixes the Channel, Your tags, Topic, and Language
sidebar filters at once (all use Combobox); the channel list's 50-row cap is now
reachable past 50 via search. Single-select commit no longer re-focuses (that now
actually fires and would re-open the just-closed list). Regression test presses
the field chrome and asserts the input receives focus.

Verified live: clicking the Channel field focuses it and typing 'cold' narrows
the list to ColdFusion / wehatethecold.
This commit is contained in:
2026-08-22 03:22:47 +02:00
parent cda70164ba
commit d2193ccd1b
2 changed files with 33 additions and 4 deletions
@@ -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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
+20 -4
View File
@@ -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<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement | null>(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({
</span>
))}
<input
ref={pop.refs.setReference}
ref={setInputRef}
{...pop.getReferenceProps({
onChange: (e) => {
setQuery((e.target as HTMLInputElement).value);