diff --git a/apps/web/tests/misc/combobox-order-options.test.ts b/apps/web/tests/misc/combobox-order-options.test.ts new file mode 100644 index 00000000000..5d14498d024 --- /dev/null +++ b/apps/web/tests/misc/combobox-order-options.test.ts @@ -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" }, + ]); + }); +}); diff --git a/apps/web/ui/partners/groups/group-selector.tsx b/apps/web/ui/partners/groups/group-selector.tsx index cbe4a18185b..f4b8917f9d1 100644 --- a/apps/web/ui/partners/groups/group-selector.tsx +++ b/apps/web/ui/partners/groups/group-selector.tsx @@ -119,6 +119,7 @@ export function GroupSelector({ }, })} {...rest} + preserveOrder > {variant === "header" && !selectedOption?.label ? (
diff --git a/packages/ui/src/combobox/index.tsx b/packages/ui/src/combobox/index.tsx index 7a4f14c3474..2a07cfadc36 100644 --- a/packages/ui/src/combobox/index.tsx +++ b/packages/ui/src/combobox/index.tsx @@ -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 = { label: string | ReactNode; @@ -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( @@ -113,6 +119,7 @@ export function Combobox({ matchTriggerWidth, hideSearch = false, forceDropdown = false, + preserveOrder = false, children, }: ComboboxProps) { const isMultiple = isMultipleSelection(multiple, setSelected); @@ -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 diff --git a/packages/ui/src/combobox/order-options.ts b/packages/ui/src/combobox/order-options.ts new file mode 100644 index 00000000000..8922981337e --- /dev/null +++ b/packages/ui/src/combobox/order-options.ts @@ -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( + 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), + ), + ]; +}