mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): take whole-selection edits verbatim in the paginated search select
Select the picked label on focus and snapshot whether the pre-edit selection covered the whole input; when it did, the next input value is a full replacement, so skip the typedInsertion diff that mangles pastes sharing a prefix or suffix with the label.
This commit is contained in:
parent
67c7b97fd2
commit
bf86eadd83
3 changed files with 72 additions and 5 deletions
|
|
@ -69,6 +69,38 @@ describe("PaginatedMultiSelect", () => {
|
|||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back when a typed query is abandoned by closing", 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"), { timeout: 2000 });
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("puts the unfiltered page back when the popup is dismissed by clicking away", 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"), { timeout: 2000 });
|
||||
|
||||
await user.click(document.body);
|
||||
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""), { timeout: 2000 });
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("selects multiple values and reports them cumulatively", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -276,6 +276,29 @@ describe("PaginatedSearchSelect", () => {
|
|||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
|
||||
});
|
||||
|
||||
it("highlights the picked label on focus so typing starts over", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelect({ value: "alias-alpha" });
|
||||
|
||||
await user.tab();
|
||||
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement;
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe("alias-alpha".length);
|
||||
});
|
||||
|
||||
it("takes a paste over the highlighted label wholesale even when it shares a prefix", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderSelect({ onSearchChange, value: "alias-alpha" });
|
||||
|
||||
await user.tab();
|
||||
await user.paste("alias-alphabet");
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("alias-alphabet");
|
||||
await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alias-alphabet"));
|
||||
});
|
||||
|
||||
it("starts a fresh query when typing lands inside the selected label", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState, type SyntheticEvent } from "react";
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -68,6 +68,13 @@ export function PaginatedSearchSelect({
|
|||
"aria-describedby": ariaDescribedBy,
|
||||
}: PaginatedSearchSelectProps) {
|
||||
const [pickedOption, setPickedOption] = useState<SearchSelectOption | null>(null);
|
||||
const wholeSelectionRef = useRef(false);
|
||||
|
||||
const snapshotWholeSelection = (event: SyntheticEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
wholeSelectionRef.current =
|
||||
input.value.length > 0 && input.selectionStart === 0 && input.selectionEnd === input.value.length;
|
||||
};
|
||||
|
||||
const selected = useMemo<SearchSelectOption | null>(() => {
|
||||
if (value === undefined || value === "") return null;
|
||||
|
|
@ -95,12 +102,14 @@ export function PaginatedSearchSelect({
|
|||
setPickedOption(item);
|
||||
onValueChange(item?.value ?? "");
|
||||
}}
|
||||
onInputValueChange={(next, eventDetails) =>
|
||||
onInputValueChange={(next, eventDetails) => {
|
||||
const replacedWholeInput = wholeSelectionRef.current;
|
||||
wholeSelectionRef.current = false;
|
||||
handleInputValueChange(
|
||||
typedQuery === null ? typedInsertion(selected?.label ?? "", next) : next,
|
||||
typedQuery === null && !replacedWholeInput ? 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}
|
||||
|
|
@ -111,6 +120,9 @@ export function PaginatedSearchSelect({
|
|||
id={inputId}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onKeyDown={snapshotWholeSelection}
|
||||
onPaste={snapshotWholeSelection}
|
||||
placeholder={placeholder}
|
||||
showClear={value !== undefined && value !== ""}
|
||||
className={`w-full ${className ?? ""}`}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue