fix(ui): cap rendered MultiSelect options and stop matching descriptions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
gabriele 2026-10-05 10:26:36 +00:00
parent 254c2f6b3b
commit c5c7061c3e
2 changed files with 83 additions and 10 deletions

View file

@ -1,13 +1,19 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MultiSelect, type MultiSelectOption } from "./MultiSelect";
import { MAX_VISIBLE_OPTIONS, MultiSelect, type MultiSelectOption } from "./MultiSelect";
const OPTIONS: MultiSelectOption[] = [
{ value: "vs-alpha", label: "alpha-kb (vs-alpha)" },
{ value: "vs-beta", label: "beta-kb (vs-beta)", description: "second store" },
];
const LARGE_OPTIONS: MultiSelectOption[] = Array.from({ length: 5000 }, (_, index) => ({
value: `tag-${index}`,
label: `tag-${index}`,
description: "shared spend tag description",
}));
const renderMultiSelect = (props: Partial<React.ComponentProps<typeof MultiSelect>> = {}) => {
const onValueChange = vi.fn();
render(<MultiSelect options={OPTIONS} onValueChange={onValueChange} placeholder="Select stores" {...props} />);
@ -69,6 +75,58 @@ describe("MultiSelect", () => {
expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument();
});
it("caps rendered options and reports the number of matching options", async () => {
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
await openPopup(input);
expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS);
expect(screen.getByText("Showing first 100 of 5000 matches. Type to narrow results.")).toBeInTheDocument();
});
it("does not match the option description", async () => {
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
await userEvent.type(input, "shared spend");
expect(screen.queryAllByRole("option")).toHaveLength(0);
expect(await screen.findByText("No options found")).toBeInTheDocument();
});
it("filters by label and updates the match hint", async () => {
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
await userEvent.type(input, "tag-49");
expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS);
expect(screen.getByText("Showing first 100 of 111 matches. Type to narrow results.")).toBeInTheDocument();
});
it("shows an under-cap label match without the match hint", async () => {
const { input } = renderMultiSelect({ options: LARGE_OPTIONS });
await userEvent.type(input, "tag-4999");
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(screen.getByRole("option", { name: /tag-4999/ })).toBeInTheDocument();
expect(screen.queryByText(/Showing first/)).not.toBeInTheDocument();
});
it("keeps the custom option available when matching results exceed the cap", async () => {
const { onValueChange, input } = renderMultiSelect({ options: LARGE_OPTIONS, allowCustomValues: true });
await userEvent.type(input, "tag");
await userEvent.click(screen.getByRole("option", { name: 'Create "tag"' }));
expect(onValueChange).toHaveBeenCalledWith(["tag"]);
});
it("renders a selected chip outside the visible option slice", async () => {
const { input } = renderMultiSelect({ options: LARGE_OPTIONS, value: ["tag-4999"] });
await openPopup(input);
expect(screen.getByLabelText("tag-4999")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(MAX_VISIBLE_OPTIONS);
});
it("labels an unknown selected value with its raw id", () => {
renderMultiSelect({ value: ["vs-deleted"] });

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Combobox,
ComboboxChip,
@ -15,6 +15,8 @@ import {
useComboboxAnchor,
} from "@/components/ui/combobox";
export const MAX_VISIBLE_OPTIONS = 100;
export interface MultiSelectOption {
label: string;
value: string;
@ -46,8 +48,7 @@ const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
return (
!normalizedQuery ||
option.label.toLowerCase().includes(normalizedQuery) ||
option.value.toLowerCase().includes(normalizedQuery) ||
(option.description?.toLowerCase().includes(normalizedQuery) ?? false)
option.value.toLowerCase().includes(normalizedQuery)
);
};
@ -65,10 +66,19 @@ export function MultiSelect({
}: MultiSelectProps) {
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const safeOptions = options.filter(
(option): option is MultiSelectOption =>
option != null && typeof option.value === "string" && option.value.length > 0,
const safeOptions = useMemo(
() =>
options.filter(
(option): option is MultiSelectOption =>
option != null && typeof option.value === "string" && option.value.length > 0,
),
[options],
);
const matchingOptions = useMemo(
() => safeOptions.filter((option) => matchesQuery(option, query)),
[safeOptions, query],
);
const visibleOptions = matchingOptions.slice(0, MAX_VISIBLE_OPTIONS);
const selectedOptions = value
.filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0)
.map(
@ -82,8 +92,8 @@ export function MultiSelect({
const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
const items =
allowCustomValues && customOption && !customOptionExists
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
: safeOptions;
? [{ label: `Create "${customOption}"`, value: customOption }, ...visibleOptions]
: visibleOptions;
const canClear = (selected: MultiSelectOption[]) => selected.length > 0 && !disabled && !loading;
@ -99,13 +109,13 @@ export function MultiSelect({
<Combobox
multiple
items={items}
filter={null}
value={selectedOptions}
onValueChange={handleValueChange}
inputValue={query}
onInputValueChange={setQuery}
isItemEqualToValue={(option: MultiSelectOption, selected: MultiSelectOption) => option.value === selected.value}
itemToStringLabel={(option: MultiSelectOption) => option.label}
filter={matchesQuery}
disabled={disabled || loading}
>
<ComboboxChips render={<div ref={anchor} />} className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
@ -142,6 +152,11 @@ export function MultiSelect({
</ComboboxItem>
)}
</ComboboxList>
{matchingOptions.length > MAX_VISIBLE_OPTIONS && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
Showing first {MAX_VISIBLE_OPTIONS} of {matchingOptions.length} matches. Type to narrow results.
</div>
)}
</ComboboxContent>
</Combobox>
);