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:
ryan-crabbe-berri 2026-08-27 14:07:02 -07:00
parent 67c7b97fd2
commit bf86eadd83
3 changed files with 72 additions and 5 deletions

View file

@ -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();

View file

@ -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();

View file

@ -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 ?? ""}`}