Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/web/tests/misc/combobox-order-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { orderComboboxOptions } from "../../../../packages/ui/src/combobox/order-options";

const options = [
{ value: "a", label: "A", first: true },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
{ value: "d", label: "D" },
];

describe("orderComboboxOptions", () => {
it("moves the selection to the top, after options marked first", () => {
expect(
orderComboboxOptions(options, [{ value: "c", label: "C" }], ""),
).toEqual([
{ value: "a", label: "A", first: true },
{ value: "c", label: "C" },
{ value: "b", label: "B" },
{ value: "d", label: "D" },
]);
});

it("keeps the incoming order while a search is active", () => {
expect(
orderComboboxOptions(options, [{ value: "c", label: "C" }], "b"),
).toEqual(options);
});

it("keeps the caller's order when preserveOrder is set", () => {
expect(
orderComboboxOptions(options, [{ value: "c", label: "C" }], "", true),
).toBe(options);
});

it("appends a selection that is missing from the options without reordering", () => {
expect(
orderComboboxOptions(options, [{ value: "z", label: "Z" }], "", true),
).toEqual([...options, { value: "z", label: "Z" }]);
});

it("still shows a selection that is missing from the options", () => {
expect(
orderComboboxOptions(options, [{ value: "z", label: "Z" }], ""),
).toEqual([
{ value: "a", label: "A", first: true },
{ value: "z", label: "Z" },
{ value: "b", label: "B" },
{ value: "c", label: "C" },
{ value: "d", label: "D" },
]);
});
});
1 change: 1 addition & 0 deletions apps/web/ui/partners/groups/group-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function GroupSelector({
},
})}
{...rest}
preserveOrder
>
{variant === "header" && !selectedOption?.label ? (
<div className="h-6 w-[120px] animate-pulse rounded bg-neutral-100" />
Expand Down
24 changes: 10 additions & 14 deletions packages/ui/src/combobox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Check2, CheckboxIcon, Icon, LoadingSpinner, Plus } from "../icons";
import { Popover, PopoverProps } from "../popover";
import { ScrollContainer } from "../scroll-container";
import { Tooltip } from "../tooltip";
import { orderComboboxOptions } from "./order-options";

export type ComboboxOption<TMeta = any> = {
label: string | ReactNode;
Expand Down Expand Up @@ -70,6 +71,11 @@ export type ComboboxProps<
matchTriggerWidth?: boolean;
hideSearch?: boolean;
forceDropdown?: boolean;
/**
* Keep `options` in the order they were passed.
* By default, selected options move to the top when the menu closes.
*/
preserveOrder?: boolean;
}>;

function isMultipleSelection(
Expand Down Expand Up @@ -113,6 +119,7 @@ export function Combobox({
matchTriggerWidth,
hideSearch = false,
forceDropdown = false,
preserveOrder = false,
children,
}: ComboboxProps<boolean | undefined, any>) {
const isMultiple = isMultipleSelection(multiple, setSelected);
Expand Down Expand Up @@ -164,20 +171,9 @@ export function Combobox({
};

const sortOptions = useCallback(
(options: ComboboxOption[], search: string) => {
return search === ""
? [
...options.filter(
(o) => o.first && !selected.some((s) => s.value === o.value),
),
...selected,
...options.filter(
(o) => !o.first && !selected.some((s) => s.value === o.value),
),
]
: options;
},
[selected],
(options: ComboboxOption[], search: string) =>
orderComboboxOptions(options, selected, search, preserveOrder),
[preserveOrder, selected],
);

// Actually sort the options when needed
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/src/combobox/order-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
type OrderableOption = {
value: string;
first?: boolean;
};

/**
* Selected options move to the top when the menu closes, after any option
* marked `first`. Pass `preserveOrder` to keep the caller's order instead.
* A selection that isn't in `options` is still appended so it stays visible.
* A non-empty search always keeps the incoming order so filtering stays stable.
*/
export function orderComboboxOptions<T extends OrderableOption>(
options: T[],
selected: T[],
search: string,
preserveOrder = false,
): T[] {
if (search !== "") return options;

if (preserveOrder) {
const missingSelected = selected.filter(
(option) =>
!options.some((candidate) => candidate.value === option.value),
);

return missingSelected.length > 0
? [...options, ...missingSelected]
: options;
}

return [
...options.filter(
(option) =>
option.first && !selected.some((s) => s.value === option.value),
),
...selected,
...options.filter(
(option) =>
!option.first && !selected.some((s) => s.value === option.value),
),
];
}
Loading