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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user