Skip to content

Commit

Permalink
[UI v2] feat: Adds reusable Combobox UI component (#16637)
Browse files Browse the repository at this point in the history
  • Loading branch information
devinvillarosa authored Jan 9, 2025
1 parent 3747d2a commit e299e5a
Show file tree
Hide file tree
Showing 5 changed files with 285 additions and 14 deletions.
136 changes: 136 additions & 0 deletions ui-v2/src/components/ui/combobox/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Icon } from "@/components/ui/icons";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { createContext, use, useState } from "react";

const ComboboxContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);

const Combobox = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = useState(false);
return (
<ComboboxContext.Provider value={{ open, setOpen }}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</ComboboxContext.Provider>
);
};

const ComboboxTrigger = ({
selected = false,
children,
}: { selected?: boolean; withForm?: boolean; children: React.ReactNode }) => {
const comboboxCtx = use(ComboboxContext);
if (!comboboxCtx) {
throw new Error("'ComboboxTrigger' must be a child of `Combobox`");
}
const { open } = comboboxCtx;

return (
<PopoverTrigger asChild className="w-full">
<Button
aria-expanded={open}
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selected && "text-muted-foreground",
)}
>
{children}
<Icon id="ChevronsUpDown" className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
);
};

const ComboboxContent = ({
filter,
children,
}: {
filter?: (value: string, search: string, keywords?: string[]) => number;
children: React.ReactNode;
}) => {
return (
<PopoverContent fullWidth>
<Command filter={filter}>{children}</Command>
</PopoverContent>
);
};

const ComboboxCommandInput = ({ placeholder }: { placeholder?: string }) => {
return <CommandInput placeholder={placeholder} className="h-9" />;
};

const ComboboxCommandList = ({ children }: { children: React.ReactNode }) => {
return <CommandList>{children}</CommandList>;
};

const ComboboxCommandEmtpy = ({ children }: { children: React.ReactNode }) => {
return <CommandEmpty>{children}</CommandEmpty>;
};

const ComboboxCommandGroup = ({ children }: { children: React.ReactNode }) => {
return <CommandGroup>{children}</CommandGroup>;
};

const ComboboxCommandItem = ({
onSelect,
selected = false,
value,
children,
}: {
onSelect: (value: string) => void;
selected?: boolean;
value: string;
children: React.ReactNode;
}) => {
const comboboxCtx = use(ComboboxContext);
if (!comboboxCtx) {
throw new Error("'ComboboxCommandItem' must be a child of `Combobox`");
}
const { setOpen } = comboboxCtx;

return (
<CommandItem
value={value}
onSelect={() => {
setOpen(false);
onSelect(value);
}}
>
{children}
<Icon
id="Check"
className={cn("ml-auto", selected ? "opacity-100" : "opacity-0")}
/>
</CommandItem>
);
};

export {
Combobox,
ComboboxTrigger,
ComboboxContent,
ComboboxCommandInput,
ComboboxCommandList,
ComboboxCommandEmtpy,
ComboboxCommandGroup,
ComboboxCommandItem,
};
109 changes: 109 additions & 0 deletions ui-v2/src/components/ui/combobox/comboxbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from "@storybook/react";

import { Automation } from "@/api/automations";
import { createFakeAutomation } from "@/mocks";
import { useState } from "react";

import {
Combobox,
ComboboxCommandEmtpy,
ComboboxCommandGroup,
ComboboxCommandInput,
ComboboxCommandItem,
ComboboxCommandList,
ComboboxContent,
ComboboxTrigger,
} from "./combobox";

const meta: Meta<typeof ComboboxStory> = {
title: "UI/Combobox",
component: ComboboxStory,
};
export default meta;

const INFER_AUTOMATION = {
value: "UNASSIGNED" as const,
name: "Infer Automation" as const,
} as const;

const MOCK_DATA = [
createFakeAutomation(),
createFakeAutomation(),
createFakeAutomation(),
createFakeAutomation(),
createFakeAutomation(),
];

const getButtonLabel = (data: Array<Automation>, fieldValue: string) => {
if (fieldValue === INFER_AUTOMATION.value) {
return INFER_AUTOMATION.name;
}
const automation = data?.find((automation) => automation.id === fieldValue);
if (automation?.name) {
return automation.name;
}
return undefined;
};

/** Because ShadCN only filters by `value` and not by a specific field, we need to write custom logic to filter objects by id */
const filterAutomations = (
value: string,
search: string,
data: Array<Automation> | undefined,
) => {
const searchTerm = search.toLowerCase();
const automation = data?.find((automation) => automation.id === value);
if (!automation) {
return 0;
}
const automationName = automation.name.toLowerCase();
if (automationName.includes(searchTerm)) {
return 1;
}
return 0;
};

function ComboboxStory() {
const [selectedAutomationId, setSelectedAutomationId] = useState<
"UNASSIGNED" | (string & {})
>(INFER_AUTOMATION.value);

const buttonLabel = getButtonLabel(MOCK_DATA, selectedAutomationId);

return (
<Combobox>
<ComboboxTrigger selected={Boolean(buttonLabel)}>
{buttonLabel ?? "Select automation"}
</ComboboxTrigger>
<ComboboxContent
filter={(value, search) => filterAutomations(value, search, MOCK_DATA)}
>
<ComboboxCommandInput placeholder="Search for an automation..." />
<ComboboxCommandEmtpy>No automation found</ComboboxCommandEmtpy>
<ComboboxCommandList>
<ComboboxCommandGroup>
<ComboboxCommandItem
selected={selectedAutomationId === INFER_AUTOMATION.value}
onSelect={setSelectedAutomationId}
value={INFER_AUTOMATION.value}
>
{INFER_AUTOMATION.name}
</ComboboxCommandItem>
{MOCK_DATA.map((automation) => (
<ComboboxCommandItem
key={automation.id}
selected={selectedAutomationId === automation.id}
onSelect={setSelectedAutomationId}
value={automation.id}
>
{automation.name}
</ComboboxCommandItem>
))}
</ComboboxCommandGroup>
</ComboboxCommandList>
</ComboboxContent>
</Combobox>
);
}

export const story: StoryObj = { name: "Combobox" };
10 changes: 10 additions & 0 deletions ui-v2/src/components/ui/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
Combobox,
ComboboxTrigger,
ComboboxContent,
ComboboxCommandInput,
ComboboxCommandList,
ComboboxCommandEmtpy,
ComboboxCommandGroup,
ComboboxCommandItem,
} from "./combobox";
2 changes: 2 additions & 0 deletions ui-v2/src/components/ui/icons/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
CircleArrowOutUpRight,
CircleCheck,
Clock,
Expand Down Expand Up @@ -39,6 +40,7 @@ export const ICONS = {
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
CircleArrowOutUpRight,
CircleCheck,
Clock,
Expand Down
42 changes: 28 additions & 14 deletions ui-v2/src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,39 @@ type PopoverContentProps = React.ComponentProps<
className?: string;
align?: "center" | "start" | "end";
sideOffset?: number;
fullWidth?: boolean;
};

const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
PopoverContentProps
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"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",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
>(
(
{
className,
align = "center",
fullWidth = false,
sideOffset = 4,
...props
},
ref,
) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"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",
fullWidth &&
"w-[--radix-popover-trigger-width] max-h-[--radix-popover-content-available-height]",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

0 comments on commit e299e5a

Please sign in to comment.