Skip to content

Commit

Permalink
feat: add searchable dropdown functionality (#3723)
Browse files Browse the repository at this point in the history
Fixes #3719


Add searchable functionality to mo.ui.dropdown by implementing a new
SearchableSelect component, similar to the existing Multiselect
component.

Changes:
- Add `searchable` boolean parameter to mo.ui.dropdown that defaults to
False
- Create SearchableSelect component for searchable dropdown
functionality
- Update DropdownPlugin to use SearchableSelect when searchable=True
- Add comprehensive tests for searchable dropdown functionality
- Update SearchableSelect to handle single string values instead of
arrays
- Add type declarations for test assertions

Link to Devin run:
https://app.devin.ai/sessions/dd320d3ed4e44aaca33a156e885da89f
Requested by: Myles

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Myles Scolnick <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Feb 10, 2025
1 parent 45c34e1 commit ce90ae8
Show file tree
Hide file tree
Showing 11 changed files with 515 additions and 46 deletions.
3 changes: 2 additions & 1 deletion frontend/src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const Combobox = <TValue,>({
chips = false,
chipsClassName,
keepPopoverOpenOnSelect,
...rest
}: ComboboxProps<TValue>) => {
const [open = false, setOpen] = useControllableState({
prop: openProp,
Expand Down Expand Up @@ -163,7 +164,7 @@ export const Combobox = <TValue,>({
};

return (
<div className={cn("relative")}>
<div className={cn("relative")} {...rest}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild={true}>
<div
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/plugins/impl/DropdownPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { z } from "zod";
import type { IPlugin, IPluginProps } from "../types";
import { NativeSelect } from "../../components/ui/native-select";
import { Labeled } from "./common/labeled";
import { cn } from "@/utils/cn";
import { cn } from "../../utils/cn";
import { SearchableSelect } from "./SearchableSelect";

interface Data {
label: string | null;
options: string[];
allowSelectNone: boolean;
fullWidth: boolean;
searchable: boolean;
}

export class DropdownPlugin implements IPlugin<string[], Data> {
Expand All @@ -23,9 +25,18 @@ export class DropdownPlugin implements IPlugin<string[], Data> {
options: z.array(z.string()),
allowSelectNone: z.boolean(),
fullWidth: z.boolean().default(false),
searchable: z.boolean().default(false),
});

render(props: IPluginProps<string[], Data>): JSX.Element {
if (props.data.searchable) {
const value = props.value.length > 0 ? props.value[0] : null;
const setValue = (newValue: string | null) =>
props.setValue(newValue ? [newValue] : []);
return (
<SearchableSelect {...props.data} value={value} setValue={setValue} />
);
}
return (
<Dropdown {...props.data} value={props.value} setValue={props.setValue} />
);
Expand Down
73 changes: 32 additions & 41 deletions frontend/src/plugins/impl/MultiselectPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Labeled } from "./common/labeled";
import { cn } from "@/utils/cn";
import { Virtuoso } from "react-virtuoso";
import { CommandSeparator } from "../../components/ui/command";
import { multiselectFilterFn } from "./multiselectFilterFn";

interface Data {
label: string | null;
Expand Down Expand Up @@ -81,13 +82,22 @@ const Multiselect = ({
setValue([]);
return;
}
if (maxSelections != null && newValues.length > maxSelections) {
return;
}

// Remove select all and deselect all from the new values
newValues = newValues.filter(
(value) => value !== SELECT_ALL_KEY && value !== DESELECT_ALL_KEY,
);

if (maxSelections === 1) {
// For single selection, just take the last selected value
setValue([newValues[newValues.length - 1]]);
return;
}

if (maxSelections != null && newValues.length > maxSelections) {
// When over max selections, remove oldest selections
newValues = newValues.slice(-maxSelections);
}
setValue(newValues);
};

Expand All @@ -99,32 +109,38 @@ const Multiselect = ({
setValue([]);
};

const shouldShowSelectAll =
options.length > 0 && value.length < options.length;
const shouldShowDeselectAll = options.length > 0 && value.length > 0;
const extraOptions: React.ReactNode[] = [];
const selectAllEnabled = options.length > 0 && value.length < options.length;
const deselectAllEnabled = options.length > 0 && value.length > 0;

// Only show when more than 2 options
const extraOptions = options.length > 2 && (
<>
// Only show select all when maxSelections is not set
if (options.length > 2 && maxSelections == null) {
extraOptions.push(
<ComboboxItem
key={SELECT_ALL_KEY}
value={SELECT_ALL_KEY}
onSelect={handleSelectAll}
disabled={!shouldShowSelectAll}
disabled={!selectAllEnabled}
>
Select all
</ComboboxItem>
</ComboboxItem>,
);
}

if (options.length > 2) {
extraOptions.push(
<ComboboxItem
key={DESELECT_ALL_KEY}
value={DESELECT_ALL_KEY}
onSelect={handleDeselectAll}
disabled={!shouldShowDeselectAll}
disabled={!deselectAllEnabled}
>
Deselect all
</ComboboxItem>
<CommandSeparator />
</>
);
{maxSelections === 1 ? "Deselect" : "Deselect all"}
</ComboboxItem>,
<CommandSeparator key="_separator" />,
);
}

const renderList = () => {
// List virtualization
Expand Down Expand Up @@ -190,28 +206,3 @@ const Multiselect = ({
</Labeled>
);
};

/**
* We override the default filter function which focuses on sorting by relevance with a fuzzy-match,
* instead of filtering out.
* The default filter function is `command-score`.
*
* Our filter function only matches if all words in the value are present in the option.
* This is more strict than the default, but more lenient than an exact match.
*
* Examples:
* - "foo bar" matches "foo bar"
* - "bar foo" matches "foo bar"
* - "foob" does not matches "foo bar"
*/
function multiselectFilterFn(option: string, value: string): number {
const words = value.split(/\s+/);
const match = words.every((word) =>
option.toLowerCase().includes(word.toLowerCase()),
);
return match ? 1 : 0;
}

export const exportedForTesting = {
multiselectFilterFn,
};
120 changes: 120 additions & 0 deletions frontend/src/plugins/impl/SearchableSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { useId, useMemo, useState } from "react";
import { Combobox, ComboboxItem } from "../../components/ui/combobox";
import { Labeled } from "./common/labeled";
import { cn } from "../../utils/cn";
import { multiselectFilterFn } from "./multiselectFilterFn";
import { Virtuoso } from "react-virtuoso";

interface SearchableSelectProps {
options: string[];
value: string | null;
setValue: (value: string | null) => void;
label: string | null;
allowSelectNone: boolean;
fullWidth: boolean;
}

const NONE_KEY = "__none__";

export const SearchableSelect = (props: SearchableSelectProps): JSX.Element => {
const { options, value, setValue, label, allowSelectNone, fullWidth } = props;
const id = useId();
const [searchQuery, setSearchQuery] = useState<string>("");

const filteredOptions = useMemo(() => {
if (!searchQuery) {
return options;
}
return options.filter(
(option) => multiselectFilterFn(option, searchQuery) === 1,
);
}, [options, searchQuery]);

const handleValueChange = (newValue: string | null) => {
if (newValue == null) {
return;
}

if (newValue === NONE_KEY) {
setValue(null);
} else {
setValue(newValue);
}
};

const renderList = () => {
const extraOptions = allowSelectNone ? (
<ComboboxItem key={NONE_KEY} value={NONE_KEY}>
--
</ComboboxItem>
) : null;

if (filteredOptions.length > 200) {
return (
<Virtuoso
style={{ height: "200px" }}
totalCount={filteredOptions.length}
overscan={50}
itemContent={(i: number) => {
const option = filteredOptions[i];

const comboboxItem = (
<ComboboxItem key={option} value={option}>
{option}
</ComboboxItem>
);

if (i === 0) {
return (
<>
{extraOptions}
{comboboxItem}
</>
);
}

return comboboxItem;
}}
/>
);
}

const list = filteredOptions.map((option) => (
<ComboboxItem key={option} value={option}>
{option}
</ComboboxItem>
));

return (
<>
{extraOptions}
{list}
</>
);
};

return (
<Labeled label={label} id={id} fullWidth={fullWidth}>
<Combobox<string>
displayValue={(option) => {
if (option === NONE_KEY) {
return "--";
}
return option;
}}
placeholder="Select..."
multiple={false}
className={cn("w-full", { "w-full": fullWidth })}
value={value ?? NONE_KEY}
onValueChange={handleValueChange}
shouldFilter={false}
search={searchQuery}
onSearchChange={setSearchQuery}
data-testid="marimo-plugin-searchable-dropdown"
>
{renderList()}
</Combobox>
</Labeled>
);
};
Loading

0 comments on commit ce90ae8

Please sign in to comment.