Skip to content

Commit e299e5a

Browse files
[UI v2] feat: Adds reusable Combobox UI component (#16637)
1 parent 3747d2a commit e299e5a

File tree

5 files changed

+285
-14
lines changed

5 files changed

+285
-14
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { Button } from "@/components/ui/button";
2+
import {
3+
Command,
4+
CommandEmpty,
5+
CommandGroup,
6+
CommandInput,
7+
CommandItem,
8+
CommandList,
9+
} from "@/components/ui/command";
10+
import { Icon } from "@/components/ui/icons";
11+
import {
12+
Popover,
13+
PopoverContent,
14+
PopoverTrigger,
15+
} from "@/components/ui/popover";
16+
import { cn } from "@/lib/utils";
17+
import { createContext, use, useState } from "react";
18+
19+
const ComboboxContext = createContext<{
20+
open: boolean;
21+
setOpen: (open: boolean) => void;
22+
} | null>(null);
23+
24+
const Combobox = ({ children }: { children: React.ReactNode }) => {
25+
const [open, setOpen] = useState(false);
26+
return (
27+
<ComboboxContext.Provider value={{ open, setOpen }}>
28+
<Popover open={open} onOpenChange={setOpen}>
29+
{children}
30+
</Popover>
31+
</ComboboxContext.Provider>
32+
);
33+
};
34+
35+
const ComboboxTrigger = ({
36+
selected = false,
37+
children,
38+
}: { selected?: boolean; withForm?: boolean; children: React.ReactNode }) => {
39+
const comboboxCtx = use(ComboboxContext);
40+
if (!comboboxCtx) {
41+
throw new Error("'ComboboxTrigger' must be a child of `Combobox`");
42+
}
43+
const { open } = comboboxCtx;
44+
45+
return (
46+
<PopoverTrigger asChild className="w-full">
47+
<Button
48+
aria-expanded={open}
49+
variant="outline"
50+
role="combobox"
51+
className={cn(
52+
"w-full justify-between",
53+
selected && "text-muted-foreground",
54+
)}
55+
>
56+
{children}
57+
<Icon id="ChevronsUpDown" className="h-4 w-4 opacity-50" />
58+
</Button>
59+
</PopoverTrigger>
60+
);
61+
};
62+
63+
const ComboboxContent = ({
64+
filter,
65+
children,
66+
}: {
67+
filter?: (value: string, search: string, keywords?: string[]) => number;
68+
children: React.ReactNode;
69+
}) => {
70+
return (
71+
<PopoverContent fullWidth>
72+
<Command filter={filter}>{children}</Command>
73+
</PopoverContent>
74+
);
75+
};
76+
77+
const ComboboxCommandInput = ({ placeholder }: { placeholder?: string }) => {
78+
return <CommandInput placeholder={placeholder} className="h-9" />;
79+
};
80+
81+
const ComboboxCommandList = ({ children }: { children: React.ReactNode }) => {
82+
return <CommandList>{children}</CommandList>;
83+
};
84+
85+
const ComboboxCommandEmtpy = ({ children }: { children: React.ReactNode }) => {
86+
return <CommandEmpty>{children}</CommandEmpty>;
87+
};
88+
89+
const ComboboxCommandGroup = ({ children }: { children: React.ReactNode }) => {
90+
return <CommandGroup>{children}</CommandGroup>;
91+
};
92+
93+
const ComboboxCommandItem = ({
94+
onSelect,
95+
selected = false,
96+
value,
97+
children,
98+
}: {
99+
onSelect: (value: string) => void;
100+
selected?: boolean;
101+
value: string;
102+
children: React.ReactNode;
103+
}) => {
104+
const comboboxCtx = use(ComboboxContext);
105+
if (!comboboxCtx) {
106+
throw new Error("'ComboboxCommandItem' must be a child of `Combobox`");
107+
}
108+
const { setOpen } = comboboxCtx;
109+
110+
return (
111+
<CommandItem
112+
value={value}
113+
onSelect={() => {
114+
setOpen(false);
115+
onSelect(value);
116+
}}
117+
>
118+
{children}
119+
<Icon
120+
id="Check"
121+
className={cn("ml-auto", selected ? "opacity-100" : "opacity-0")}
122+
/>
123+
</CommandItem>
124+
);
125+
};
126+
127+
export {
128+
Combobox,
129+
ComboboxTrigger,
130+
ComboboxContent,
131+
ComboboxCommandInput,
132+
ComboboxCommandList,
133+
ComboboxCommandEmtpy,
134+
ComboboxCommandGroup,
135+
ComboboxCommandItem,
136+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
3+
import { Automation } from "@/api/automations";
4+
import { createFakeAutomation } from "@/mocks";
5+
import { useState } from "react";
6+
7+
import {
8+
Combobox,
9+
ComboboxCommandEmtpy,
10+
ComboboxCommandGroup,
11+
ComboboxCommandInput,
12+
ComboboxCommandItem,
13+
ComboboxCommandList,
14+
ComboboxContent,
15+
ComboboxTrigger,
16+
} from "./combobox";
17+
18+
const meta: Meta<typeof ComboboxStory> = {
19+
title: "UI/Combobox",
20+
component: ComboboxStory,
21+
};
22+
export default meta;
23+
24+
const INFER_AUTOMATION = {
25+
value: "UNASSIGNED" as const,
26+
name: "Infer Automation" as const,
27+
} as const;
28+
29+
const MOCK_DATA = [
30+
createFakeAutomation(),
31+
createFakeAutomation(),
32+
createFakeAutomation(),
33+
createFakeAutomation(),
34+
createFakeAutomation(),
35+
];
36+
37+
const getButtonLabel = (data: Array<Automation>, fieldValue: string) => {
38+
if (fieldValue === INFER_AUTOMATION.value) {
39+
return INFER_AUTOMATION.name;
40+
}
41+
const automation = data?.find((automation) => automation.id === fieldValue);
42+
if (automation?.name) {
43+
return automation.name;
44+
}
45+
return undefined;
46+
};
47+
48+
/** Because ShadCN only filters by `value` and not by a specific field, we need to write custom logic to filter objects by id */
49+
const filterAutomations = (
50+
value: string,
51+
search: string,
52+
data: Array<Automation> | undefined,
53+
) => {
54+
const searchTerm = search.toLowerCase();
55+
const automation = data?.find((automation) => automation.id === value);
56+
if (!automation) {
57+
return 0;
58+
}
59+
const automationName = automation.name.toLowerCase();
60+
if (automationName.includes(searchTerm)) {
61+
return 1;
62+
}
63+
return 0;
64+
};
65+
66+
function ComboboxStory() {
67+
const [selectedAutomationId, setSelectedAutomationId] = useState<
68+
"UNASSIGNED" | (string & {})
69+
>(INFER_AUTOMATION.value);
70+
71+
const buttonLabel = getButtonLabel(MOCK_DATA, selectedAutomationId);
72+
73+
return (
74+
<Combobox>
75+
<ComboboxTrigger selected={Boolean(buttonLabel)}>
76+
{buttonLabel ?? "Select automation"}
77+
</ComboboxTrigger>
78+
<ComboboxContent
79+
filter={(value, search) => filterAutomations(value, search, MOCK_DATA)}
80+
>
81+
<ComboboxCommandInput placeholder="Search for an automation..." />
82+
<ComboboxCommandEmtpy>No automation found</ComboboxCommandEmtpy>
83+
<ComboboxCommandList>
84+
<ComboboxCommandGroup>
85+
<ComboboxCommandItem
86+
selected={selectedAutomationId === INFER_AUTOMATION.value}
87+
onSelect={setSelectedAutomationId}
88+
value={INFER_AUTOMATION.value}
89+
>
90+
{INFER_AUTOMATION.name}
91+
</ComboboxCommandItem>
92+
{MOCK_DATA.map((automation) => (
93+
<ComboboxCommandItem
94+
key={automation.id}
95+
selected={selectedAutomationId === automation.id}
96+
onSelect={setSelectedAutomationId}
97+
value={automation.id}
98+
>
99+
{automation.name}
100+
</ComboboxCommandItem>
101+
))}
102+
</ComboboxCommandGroup>
103+
</ComboboxCommandList>
104+
</ComboboxContent>
105+
</Combobox>
106+
);
107+
}
108+
109+
export const story: StoryObj = { name: "Combobox" };
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export {
2+
Combobox,
3+
ComboboxTrigger,
4+
ComboboxContent,
5+
ComboboxCommandInput,
6+
ComboboxCommandList,
7+
ComboboxCommandEmtpy,
8+
ComboboxCommandGroup,
9+
ComboboxCommandItem,
10+
} from "./combobox";

ui-v2/src/components/ui/icons/constants.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ChevronRight,
1010
ChevronsLeft,
1111
ChevronsRight,
12+
ChevronsUpDown,
1213
CircleArrowOutUpRight,
1314
CircleCheck,
1415
Clock,
@@ -39,6 +40,7 @@ export const ICONS = {
3940
ChevronRight,
4041
ChevronsLeft,
4142
ChevronsRight,
43+
ChevronsUpDown,
4244
CircleArrowOutUpRight,
4345
CircleCheck,
4446
Clock,

ui-v2/src/components/ui/popover.tsx

+28-14
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,39 @@ type PopoverContentProps = React.ComponentProps<
1515
className?: string;
1616
align?: "center" | "start" | "end";
1717
sideOffset?: number;
18+
fullWidth?: boolean;
1819
};
1920

2021
const PopoverContent = React.forwardRef<
2122
React.ElementRef<typeof PopoverPrimitive.Content>,
2223
PopoverContentProps
23-
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
24-
<PopoverPrimitive.Portal>
25-
<PopoverPrimitive.Content
26-
ref={ref}
27-
align={align}
28-
sideOffset={sideOffset}
29-
className={cn(
30-
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
31-
className,
32-
)}
33-
{...props}
34-
/>
35-
</PopoverPrimitive.Portal>
36-
));
24+
>(
25+
(
26+
{
27+
className,
28+
align = "center",
29+
fullWidth = false,
30+
sideOffset = 4,
31+
...props
32+
},
33+
ref,
34+
) => (
35+
<PopoverPrimitive.Portal>
36+
<PopoverPrimitive.Content
37+
ref={ref}
38+
align={align}
39+
sideOffset={sideOffset}
40+
className={cn(
41+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
42+
fullWidth &&
43+
"w-[--radix-popover-trigger-width] max-h-[--radix-popover-content-available-height]",
44+
className,
45+
)}
46+
{...props}
47+
/>
48+
</PopoverPrimitive.Portal>
49+
),
50+
);
3751
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
3852

3953
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

0 commit comments

Comments
 (0)