From 3746ba58d7b8406de1c22d0977fdce8f87c641cd Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 27 Aug 2026 13:32:20 -0700 Subject: [PATCH] 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. --- .../shared/PaginatedSearchSelect.test.tsx | 148 ++++++++++++++++++ .../shared/PaginatedSearchSelect.tsx | 40 ++++- .../components/shared/usePaginatedCombobox.ts | 21 ++- 3 files changed, 200 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx index d36b414b726..310b5363b0b 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx @@ -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 ( + { + onSearchChange(query); + setSearch(query); + }} + onLoadMore={vi.fn()} + /> + ); + } + render(); + + 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([{ label: "Beta Team", value: "team-2" }]); + const [value, setValue] = useState(""); + return ( + <> + + + + ); + } + render(); + + 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( diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx index 6966c669187..bb1730941a6 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx @@ -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(null); + const selected = useMemo(() => { 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(() => { 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 ( 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} diff --git a/ui/litellm-dashboard/src/components/shared/usePaginatedCombobox.ts b/ui/litellm-dashboard/src/components/shared/usePaginatedCombobox.ts index 75171d7d75f..3a51d97cd44 100644 --- a/ui/litellm-dashboard/src/components/shared/usePaginatedCombobox.ts +++ b/ui/litellm-dashboard/src/components/shared/usePaginatedCombobox.ts @@ -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(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) => { const target = event.currentTarget; if (target.scrollHeight === 0) return; @@ -38,5 +53,5 @@ export function usePaginatedCombobox({ } }; - return { handleInputValueChange, handleScroll }; + return { typedQuery, handleInputValueChange, handleOpenChange, handleScroll }; }