fix(ui): let the paginated search select keep what the user types (#38475)

* fix(ui): let the paginated search select keep what the user types

The combobox handed Base UI a freshly built option object for the current
selection every time a page of results came back. Base UI answers a changed
value by rewriting the input with that option's label, so every search response
wiped the query mid-typing and the list never narrowed. Once a user had been
picked in the Usage page filter box, no other user could be reached.

The component now owns the input text. It holds the query while the list is
open, falls back to the selected option's label once the list closes, and
remembers the picked option so its label survives later pages that no longer
carry it, the way the multi-select sibling already does.

* refactor(ui): name the paginated select's search state instead of commenting it

* fix(ui): start a fresh query when typing lands on the selected label

Focusing the filter box without clicking it leaves the caret at the end of the
selected option's label, so the next keystroke extended that label into a query
no server could match. Only a click cleared the box first.

A keystroke that arrives while the box is showing a label is now read as the
start of a new query, wherever in the label it landed.
This commit is contained in:
yuneng-jiang 2026-08-27 13:32:20 -07:00 committed by GitHub
parent 2d5e49d65a
commit 3746ba58d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 200 additions and 9 deletions

View file

@ -142,6 +142,154 @@ describe("PaginatedSearchSelect", () => {
expect(onValueChange).toHaveBeenCalledWith("alias-beta");
});
it("keeps the typed query when a refreshed page of options arrives while a value is selected", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
function ServerBacked() {
const [search, setSearch] = useState("");
const [value, setValue] = useState("alias-alpha");
const freshlyBuiltOptions = OPTIONS.filter((option) => option.label.includes(search)).map((option) => ({
...option,
}));
return (
<PaginatedSearchSelect
options={freshlyBuiltOptions}
value={value}
onValueChange={setValue}
onSearchChange={(query) => {
onSearchChange(query);
setSearch(query);
}}
onLoadMore={vi.fn()}
/>
);
}
render(<ServerBacked />);
const input = screen.getByRole("combobox");
await user.click(input);
await user.type(input, "gamma");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
await waitFor(() => expect(input).toHaveValue("gamma"));
expect(await screen.findByText("gamma-key")).toBeInTheDocument();
});
it("shows the selection again after the popup closes with the query abandoned", async () => {
const user = userEvent.setup();
renderSelect({ value: "alias-alpha" });
const input = screen.getByRole("combobox");
await user.click(input);
expect(input).toHaveValue("");
await user.type(input, "gamma");
await user.keyboard("{Escape}");
await waitFor(() => expect(input).toHaveValue("alias-alpha"));
});
it("puts the unfiltered page back when a typed query is abandoned", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange });
const input = screen.getByRole("combobox");
await user.click(input);
await user.type(input, "gamma");
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));
await user.keyboard("{Escape}");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
});
it("puts the unfiltered page back once an option found by typing is picked", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange });
const input = screen.getByRole("combobox");
await user.click(input);
await user.type(input, "gamma");
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));
await user.click(await screen.findByText("gamma-key"));
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
});
it("keeps the first character when typing is what opened the list", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange });
await user.tab();
await user.keyboard("gamma");
expect(screen.getByRole("combobox")).toHaveValue("gamma");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
});
it("keeps showing a picked option's label after it drops out of the loaded page", async () => {
const user = userEvent.setup();
function Refetching() {
const [options, setOptions] = useState<SearchSelectOption[]>([{ label: "Beta Team", value: "team-2" }]);
const [value, setValue] = useState("");
return (
<>
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={setValue}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
/>
<button type="button" onClick={() => setOptions([])}>
refetch
</button>
</>
);
}
render(<Refetching />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Beta Team"));
await user.click(screen.getByRole("button", { name: "refetch" }));
expect(screen.getByRole("combobox")).toHaveValue("Beta Team");
});
it("starts a fresh query when typing lands after the selected label", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange, value: "alias-alpha" });
const input = screen.getByRole("combobox") as HTMLInputElement;
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
await user.keyboard("gamma");
expect(input).toHaveValue("gamma");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
});
it("starts a fresh query when typing lands inside the selected label", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange, value: "alias-alpha" });
const input = screen.getByRole("combobox") as HTMLInputElement;
input.focus();
input.setSelectionRange(3, 3);
await user.keyboard("g");
expect(input).toHaveValue("g");
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("g"));
});
it("surfaces loading and fetching-more affordances", async () => {
const user = userEvent.setup();
const { unmount } = render(

View file

@ -1,7 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Combobox,
@ -35,6 +35,19 @@ interface PaginatedSearchSelectProps {
"aria-describedby"?: string;
}
const typedInsertion = (previous: string, next: string): string => {
let start = 0;
while (start < previous.length && start < next.length && previous[start] === next[start]) start++;
let end = 0;
while (
end < previous.length - start &&
end < next.length - start &&
previous[previous.length - 1 - end] === next[next.length - 1 - end]
)
end++;
return next.slice(start, next.length - end);
};
export function PaginatedSearchSelect({
options,
value,
@ -54,10 +67,15 @@ export function PaginatedSearchSelect({
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
}: PaginatedSearchSelectProps) {
const [pickedOption, setPickedOption] = useState<SearchSelectOption | null>(null);
const selected = useMemo<SearchSelectOption | null>(() => {
if (value === undefined || value === "") return null;
return options.find((option) => option.value === value) ?? { label: value, value };
}, [options, value]);
return (
options.find((option) => option.value === value) ??
(pickedOption?.value === value ? pickedOption : { label: value, value })
);
}, [options, value, pickedOption]);
const items = useMemo<SearchSelectOption[]>(() => {
if (selected === null) return options;
@ -66,14 +84,24 @@ export function PaginatedSearchSelect({
}, [options, selected]);
const pagination = { onSearchChange, onLoadMore, hasNextPage, isFetchingNextPage };
const { handleInputValueChange, handleScroll } = usePaginatedCombobox(pagination);
const { typedQuery, handleInputValueChange, handleOpenChange, handleScroll } = usePaginatedCombobox(pagination);
return (
<Combobox
items={items}
value={selected}
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
onInputValueChange={(next, eventDetails) => handleInputValueChange(next, eventDetails.reason)}
inputValue={typedQuery ?? selected?.label ?? ""}
onValueChange={(item: SearchSelectOption | null) => {
setPickedOption(item);
onValueChange(item?.value ?? "");
}}
onInputValueChange={(next, eventDetails) =>
handleInputValueChange(
typedQuery === null ? typedInsertion(selected?.label ?? "", next) : next,
eventDetails.reason,
)
}
onOpenChange={(nextOpen, eventDetails) => handleOpenChange(nextOpen, eventDetails.reason)}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
itemToStringLabel={(item: SearchSelectOption) => item.label}
filter={null}

View file

@ -1,7 +1,7 @@
"use client";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import type { UIEvent } from "react";
import { useState, type UIEvent } from "react";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
@ -23,12 +23,27 @@ export function usePaginatedCombobox({
isFetchingNextPage,
}: PaginatedComboboxCallbacks) {
const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS });
const [typedQuery, setTypedQuery] = useState<string | null>(null);
const handleInputValueChange = (next: string, reason: string) => {
if (!SEARCH_REASONS.has(reason)) return;
if (!SEARCH_REASONS.has(reason)) {
setTypedQuery(null);
return;
}
setTypedQuery(next);
debouncedSearch(next);
};
const handleOpenChange = (open: boolean, reason: string) => {
if (!open) {
if (typedQuery) debouncedSearch("");
setTypedQuery(null);
return;
}
const openedByTyping = SEARCH_REASONS.has(reason);
if (!openedByTyping) setTypedQuery("");
};
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (target.scrollHeight === 0) return;
@ -38,5 +53,5 @@ export function usePaginatedCombobox({
}
};
return { handleInputValueChange, handleScroll };
return { typedQuery, handleInputValueChange, handleOpenChange, handleScroll };
}