Skip to content

feat: add search to parameter dropdowns #18729

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Jul 15, 2025
Merged
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
1 change: 1 addition & 0 deletions .github/.linkspector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ ignorePatterns:
- pattern: "docs.github.com"
- pattern: "claude.ai"
- pattern: "splunk.com"
- pattern: "stackoverflow.com/questions"
aliveStatusCodes:
- 200
3 changes: 3 additions & 0 deletions docs/about/contributing/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ All UI-related code is in the `site` folder. Key directories include:
- **util** - Helper functions that can be used across the application
- **static** - Static assets like images, fonts, icons, etc

Do not use barrel files. Imports should be directly from the file that defines
the value.

## Routing

We use [react-router](https://reactrouter.com/en/main) as our routing engine.
Expand Down
2 changes: 1 addition & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"lint:check": " biome lint --error-on-warnings .",
"lint:circular-deps": "dpdm --no-tree --no-warning -T ./src/App.tsx",
"lint:knip": "knip",
"lint:fix": " biome lint --error-on-warnings --write . && knip --fix",
"lint:fix": "biome lint --error-on-warnings --write . && knip --fix",
"lint:types": "tsc -p .",
"playwright:install": "playwright install --with-deps chromium",
"playwright:test": "playwright test --config=e2e/playwright.config.ts",
Expand Down
70 changes: 43 additions & 27 deletions site/src/components/Combobox/Combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,35 @@ import { expect, screen, userEvent, waitFor, within } from "@storybook/test";
import { useState } from "react";
import { Combobox } from "./Combobox";

const options = ["Option 1", "Option 2", "Option 3", "Another Option"];
const simpleOptions = ["Go", "Gleam", "Kotlin", "Rust"];

const ComboboxWithHooks = () => {
const advancedOptions = [
{
displayName: "Go",
value: "go",
icon: "/icon/go.svg",
},
{
displayName: "Gleam",
value: "gleam",
icon: "https://github.com/gleam-lang.png",
},
{
displayName: "Kotlin",
value: "kotlin",
description: "Kotlin 2.1, OpenJDK 24, gradle",
icon: "/icon/kotlin.svg",
},
{
displayName: "Rust",
value: "rust",
icon: "/icon/rust.svg",
},
] as const;

const ComboboxWithHooks = ({
options = advancedOptions,
}: { options?: React.ComponentProps<typeof Combobox>["options"] }) => {
const [value, setValue] = useState("");
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
Expand Down Expand Up @@ -34,17 +60,21 @@ const ComboboxWithHooks = () => {
const meta: Meta<typeof Combobox> = {
title: "components/Combobox",
component: Combobox,
args: { options: advancedOptions },
};

export default meta;
type Story = StoryObj<typeof Combobox>;

export const Default: Story = {
render: () => <ComboboxWithHooks />,
export const Default: Story = {};

export const SimpleOptions: Story = {
args: {
options: simpleOptions,
},
};

export const OpenCombobox: Story = {
render: () => <ComboboxWithHooks />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
Expand All @@ -58,11 +88,7 @@ export const SelectOption: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.click(screen.getByText("Option 1"));

await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Option 1"),
);
await userEvent.click(screen.getByText("Go"));
},
};

Expand All @@ -71,19 +97,13 @@ export const SearchAndFilter: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.type(screen.getByRole("combobox"), "Another");
await userEvent.click(
screen.getByRole("option", { name: "Another Option" }),
);

await userEvent.type(screen.getByRole("combobox"), "r");
await waitFor(() => {
expect(
screen.getByRole("option", { name: "Another Option" }),
).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "Option 1" }),
screen.queryByRole("option", { name: "Kotlin" }),
).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole("option", { name: "Rust" }));
},
};

Expand All @@ -92,16 +112,11 @@ export const EnterCustomValue: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
await userEvent.type(screen.getByRole("combobox"), "Custom Value{enter}");

await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Custom Value"),
);
await userEvent.type(screen.getByRole("combobox"), "Swift{enter}");
},
};

export const NoResults: Story = {
render: () => <ComboboxWithHooks />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
Expand All @@ -120,10 +135,11 @@ export const ClearSelectedOption: Story = {
const canvas = within(canvasElement);

await userEvent.click(canvas.getByRole("button"));
// const goOption = screen.getByText("Go");
// First select an option
await userEvent.click(screen.getByRole("option", { name: "Option 1" }));
await userEvent.click(await screen.findByRole("option", { name: "Go" }));
// Then clear it by selecting it again
await userEvent.click(screen.getByRole("option", { name: "Option 1" }));
await userEvent.click(await screen.findByRole("option", { name: "Go" }));

await waitFor(() =>
expect(canvas.getByRole("button")).toHaveTextContent("Select option"),
Expand Down
95 changes: 78 additions & 17 deletions site/src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
Command,
Expand All @@ -12,22 +13,36 @@ import {
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { Check, ChevronDown, CornerDownLeft } from "lucide-react";
import type { FC, KeyboardEventHandler } from "react";
import { Info } from "lucide-react";
import { type FC, type KeyboardEventHandler, useState } from "react";
import { cn } from "utils/cn";

interface ComboboxProps {
value: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this was here originally, but I can't help but feel like selectedValue would be more clear, especially to avoid ambiguity with inputValue

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think value is well enough established as "the current value of the input". what I would really like to change is inputValue -> searchValue.

options?: readonly string[];
options?: Readonly<Array<string | ComboboxOption>>;
placeholder?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
inputValue: string;
onInputChange: (value: string) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
inputValue?: string;
onInputChange?: (value: string) => void;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
onSelect: (value: string) => void;
}

type ComboboxOption = {
icon?: string;
displayName: string;
value: string;
description?: string;
};

export const Combobox: FC<ComboboxProps> = ({
value,
options = [],
Expand All @@ -39,16 +54,37 @@ export const Combobox: FC<ComboboxProps> = ({
onKeyDown,
onSelect,
}) => {
const [managedOpen, setManagedOpen] = useState(false);
const [managedInputValue, setManagedInputValue] = useState("");

const optionsMap = new Map<string, ComboboxOption>(
options.map((option) =>
typeof option === "string"
? [option, { displayName: option, value: option }]
: [option.value, option],
),
);
const optionObjects = [...optionsMap.values()];
const showIcons = optionObjects.some((it) => it.icon);

const isOpen = open ?? managedOpen;

return (
<Popover open={open} onOpenChange={onOpenChange}>
<Popover
open={isOpen}
onOpenChange={(newOpen) => {
setManagedOpen(newOpen);
onOpenChange?.(newOpen);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
aria-expanded={isOpen}
className="w-72 justify-between group"
>
<span className={cn(!value && "text-content-secondary")}>
{value || placeholder}
{optionsMap.get(value)?.displayName || value || placeholder}
</span>
<ChevronDown className="size-icon-sm text-content-secondary group-hover:text-content-primary" />
</Button>
Expand All @@ -57,8 +93,11 @@ export const Combobox: FC<ComboboxProps> = ({
<Command>
<CommandInput
placeholder="Search or enter custom value"
value={inputValue}
onValueChange={onInputChange}
value={inputValue ?? managedInputValue}
onValueChange={(newValue) => {
setManagedInputValue(newValue);
onInputChange?.(newValue);
}}
onKeyDown={onKeyDown}
/>
<CommandList>
Expand All @@ -70,18 +109,40 @@ export const Combobox: FC<ComboboxProps> = ({
</span>
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
{optionObjects.map((option) => (
<CommandItem
key={option}
value={option}
key={option.value}
value={option.value}
keywords={[option.displayName]}
onSelect={(currentValue) => {
onSelect(currentValue === value ? "" : currentValue);
}}
>
{option}
{value === option && (
<Check className="size-icon-sm ml-auto" />
{showIcons && (
<Avatar
size="sm"
src={option.icon}
fallback={option.value}
/>
)}
{option.displayName}
<div className="flex flex-row items-center ml-auto gap-1">
{value === option.value && (
<Check className="size-icon-sm" />
)}
{option.description && (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={10}>
{option.description}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</CommandItem>
))}
</CommandGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ export const MultiSelectCombobox = forwardRef<
}}
>
{isLoading ? (
<>{loadingIndicator}</>
loadingIndicator
) : (
<>
{EmptyItem()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,48 @@ export const Dropdown: Story = {
type: "string",
options: [
{
name: "Option 1",
value: { valid: true, value: "option1" },
description: "this is option 1",
icon: "",
name: "Nissa, Worldsoul Speaker",
value: { valid: true, value: "nissa" },
description:
"Zendikar still seems so far off, but Chandra is my home.",
icon: "/emojis/1f7e2.png",
},
{
name: "Option 2",
value: { valid: true, value: "option2" },
description: "this is option 2",
icon: "",
name: "Canopy Spider",
value: { valid: true, value: "spider" },
description:
"It keeps the upper reaches of the forest free of every menace . . . except for the spider itself.",
icon: "/emojis/1f7e2.png",
},
{
name: "Option 3",
value: { valid: true, value: "option3" },
description: "this is option 3",
icon: "",
name: "Ajani, Nacatl Pariah",
value: { valid: true, value: "ajani" },
description: "His pride denied him; his brother did not.",
icon: "/emojis/26aa.png",
},
{
name: "Glowing Anemone",
value: { valid: true, value: "anemone" },
description: "Beautiful to behold, terrible to be held.",
icon: "/emojis/1f535.png",
},
{
name: "Springmantle Cleric",
value: { valid: true, value: "cleric" },
description: "Hope and courage bloom in her wake.",
icon: "/emojis/1f7e2.png",
},
{
name: "Aegar, the Freezing Flame",
value: { valid: true, value: "aegar" },
description:
"Though Phyrexian machines could adapt to extremes of heat or cold, they never figured out how to adapt to both at once.",
icon: "/emojis/1f308.png",
},
],
styling: {
placeholder: "Select a creature",
},
},
},
};
Expand Down
Loading
Loading