mirror of
https://github.com/hanzoai/dataroom.git
synced 2026-10-30 18:41:07 +00:00
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { type VariantProps, cva } from "class-variance-authority";
|
|
import { CheckIcon, PlusIcon, TagIcon, WandSparkles } from "lucide-react";
|
|
|
|
import { TagColorProps } from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
import TagBadge from "../links/link-sheet/tags/tag-badge";
|
|
import { Icon } from "../shared/icons";
|
|
import LoadingSpinner from "./loading-spinner";
|
|
|
|
/**
|
|
* Variants for the multi-select component to handle different styles.
|
|
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
|
*/
|
|
const multiSelectVariants = cva(
|
|
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default:
|
|
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
|
|
secondary:
|
|
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
destructive:
|
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
inverted: "inverted",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "default",
|
|
},
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Props for MultiSelect component
|
|
*/
|
|
interface MultiSelectProps<
|
|
TMeta = { color: string; description: string | null },
|
|
> extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
VariantProps<typeof multiSelectVariants> {
|
|
/**
|
|
* An array of option objects to be displayed in the multi-select component.
|
|
* Each option object has a label, value, and an optional icon.
|
|
*/
|
|
options: ComboboxOption<TMeta>[];
|
|
searchPlaceholder?: string;
|
|
inputClassName?: string;
|
|
createLabel?: (search: string) => React.ReactNode;
|
|
onCreate?: (search: string) => Promise<boolean>;
|
|
optionClassName?: string;
|
|
|
|
/**
|
|
* Callback function triggered when the selected values change.
|
|
* Receives an array of the new selected values.
|
|
*/
|
|
onValueChange: (value: string[]) => void;
|
|
|
|
/** The default selected values when the component mounts. */
|
|
defaultValue?: string[];
|
|
|
|
/** update dynamic selected values when the value change. */
|
|
value: string[];
|
|
|
|
/**
|
|
* Placeholder text to be displayed when no values are selected.
|
|
* Optional, defaults to "Select options".
|
|
*/
|
|
placeholder?: string;
|
|
|
|
/**
|
|
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
|
|
* Optional, defaults to 0 (no animation).
|
|
*/
|
|
animation?: number;
|
|
|
|
/**
|
|
* Maximum number of items to display. Extra selected items will be summarized.
|
|
* Optional, defaults to 3.
|
|
*/
|
|
maxCount?: number;
|
|
|
|
/**
|
|
* The modality of the popover. When set to true, interaction with outside elements
|
|
* will be disabled and only popover content will be visible to screen readers.
|
|
* Optional, defaults to false.
|
|
*/
|
|
modalPopover?: boolean;
|
|
|
|
/**
|
|
* If true, renders the multi-select component as a child of another component.
|
|
* Optional, defaults to false.
|
|
*/
|
|
asChild?: boolean;
|
|
|
|
/**
|
|
* Additional class names to apply custom styles to the multi-select component.
|
|
* Optional, can be used to add custom styles.
|
|
*/
|
|
className?: string;
|
|
|
|
/**
|
|
* Additional class names to apply to the popover content.
|
|
* Optional, can be used to customize popover width and styles.
|
|
*/
|
|
popoverClassName?: string;
|
|
|
|
isPopoverOpen: boolean;
|
|
setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
loading?: boolean;
|
|
triggerIcon?: React.ReactNode;
|
|
}
|
|
|
|
export type ComboboxOption<
|
|
TMeta = { color: string; description: string | null },
|
|
> = {
|
|
label: string | React.ReactNode;
|
|
value: string;
|
|
icon?: Icon | React.ReactNode;
|
|
meta?: TMeta;
|
|
separatorAfter?: boolean;
|
|
};
|
|
|
|
export const MultiSelect = React.forwardRef<
|
|
HTMLButtonElement,
|
|
MultiSelectProps
|
|
>(
|
|
(
|
|
{
|
|
options,
|
|
onValueChange,
|
|
variant,
|
|
defaultValue = [],
|
|
placeholder = "Select options",
|
|
animation = 0,
|
|
maxCount = 3,
|
|
modalPopover = false,
|
|
asChild = false,
|
|
className,
|
|
popoverClassName,
|
|
value,
|
|
searchPlaceholder = "Search...",
|
|
inputClassName,
|
|
optionClassName,
|
|
onCreate,
|
|
createLabel,
|
|
setIsPopoverOpen,
|
|
isPopoverOpen,
|
|
loading,
|
|
triggerIcon,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
// const [selectedValues, setSelectedValues] =
|
|
// React.useState<string[]>(defaultValue);
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
const [isAnimating, setIsAnimating] = React.useState(false);
|
|
const [search, setSearch] = React.useState("");
|
|
|
|
const handleInputKeyDown = (
|
|
event: React.KeyboardEvent<HTMLInputElement>,
|
|
) => {
|
|
if (event.key === "Enter") {
|
|
setIsPopoverOpen(true);
|
|
} else if (event.key === "Backspace" && !event.currentTarget.value) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const newSelectedValues = [...value];
|
|
newSelectedValues.pop();
|
|
// setSelectedValues(newSelectedValues);
|
|
onValueChange(newSelectedValues);
|
|
}
|
|
};
|
|
|
|
const toggleOption = (option: string) => {
|
|
const newSelectedValues = value.includes(option)
|
|
? value.filter((value) => value !== option)
|
|
: [...value, option];
|
|
// setSelectedValues(newSelectedValues);
|
|
onValueChange(newSelectedValues);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
// setSelectedValues([]);
|
|
onValueChange([]);
|
|
};
|
|
|
|
const handleTogglePopover = () => {
|
|
setIsPopoverOpen((prev) => !prev);
|
|
};
|
|
|
|
// const clearExtraOptions = () => {
|
|
// const newSelectedValues = value.slice(0, maxCount);
|
|
// // setSelectedValues(newSelectedValues);
|
|
// onValueChange(newSelectedValues);
|
|
// };
|
|
|
|
// const toggleAll = () => {
|
|
// if (value.length === options.length) {
|
|
// handleClear();
|
|
// } else {
|
|
// const allValues = options.map((option) => option.value);
|
|
// // setSelectedValues(allValues);
|
|
// onValueChange(allValues);
|
|
// }
|
|
// };
|
|
// flex w-full rounded-none rounded-l-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm
|
|
return (
|
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
ref={ref}
|
|
{...props}
|
|
onClick={handleTogglePopover}
|
|
className={cn(
|
|
"flex h-auto w-full items-center justify-between rounded-md border border-input bg-inherit px-3 py-1.5 hover:bg-inherit focus:border-muted-foreground focus:outline-none focus:ring-1 focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent [&_svg]:pointer-events-auto",
|
|
className,
|
|
)}
|
|
>
|
|
{triggerIcon || (
|
|
<TagIcon className="!size-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
{loading ? (
|
|
<div className="mx-auto flex w-full items-center justify-between">
|
|
<LoadingSpinner className="size-4 shrink-0" />
|
|
</div>
|
|
) : value.length > 0 ? (
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{value.slice(0, maxCount).map((value) => {
|
|
const option = options.find((o) => o.value === value);
|
|
return (
|
|
<TagBadge
|
|
key={value}
|
|
name={option?.label as string}
|
|
color={option?.meta?.color as TagColorProps}
|
|
/>
|
|
);
|
|
})}
|
|
{value.length > maxCount && (
|
|
<span
|
|
className="my-auto block whitespace-nowrap rounded-md border px-2 py-0.5 text-sm text-foreground dark:border-gray-500"
|
|
style={{ animationDuration: `${animation}s` }}
|
|
>
|
|
+{value.length - maxCount} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mx-auto flex w-full items-center justify-between">
|
|
<span className="py-[3px] text-sm font-normal text-muted-foreground">
|
|
{placeholder}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className={cn(
|
|
"w-auto p-0 sm:w-[600px] sm:max-w-[35rem]",
|
|
popoverClassName,
|
|
)}
|
|
align="start"
|
|
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
placeholder={searchPlaceholder}
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
noIcon
|
|
wrapperClassName="px-0"
|
|
className={cn(
|
|
"grow border-0 py-3 outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm",
|
|
inputClassName,
|
|
)}
|
|
onKeyDown={handleInputKeyDown}
|
|
/>
|
|
<ScrollArea>
|
|
<CommandList>
|
|
<CommandGroup>
|
|
{loading ? (
|
|
<CommandItem className="justify-center">
|
|
<LoadingSpinner className="size-4 shrink-0" />
|
|
</CommandItem>
|
|
) : options.length > 0 ? (
|
|
options.map((option) => {
|
|
const isSelected = value.includes(option.value);
|
|
const IconComponent = option.icon;
|
|
return (
|
|
<CommandItem
|
|
key={option.value}
|
|
onSelect={() => toggleOption(option.value)}
|
|
className="cursor-pointer gap-2 py-2"
|
|
data-Value={option.value}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
|
isSelected
|
|
? "bg-primary text-primary-foreground"
|
|
: "opacity-50 [&_svg]:invisible",
|
|
)}
|
|
>
|
|
<CheckIcon className="h-4 w-4" />
|
|
</div>
|
|
{IconComponent && isReactNode(IconComponent) ? (
|
|
IconComponent
|
|
) : typeof IconComponent === "function" ? (
|
|
<IconComponent className="h-5 w-4" />
|
|
) : null}
|
|
<span>{option.label}</span>
|
|
</CommandItem>
|
|
);
|
|
})
|
|
) : (
|
|
<CommandItem className="justify-center">
|
|
<span>No tags available</span>
|
|
</CommandItem>
|
|
)}
|
|
</CommandGroup>
|
|
{search.length > 0 && onCreate && (
|
|
<CommandGroup>
|
|
<CommandItem
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 whitespace-nowrap",
|
|
optionClassName,
|
|
)}
|
|
onSelect={async () => {
|
|
setIsCreating(true);
|
|
const success = await onCreate?.(search);
|
|
if (success) {
|
|
setSearch("");
|
|
setIsPopoverOpen(false);
|
|
}
|
|
setIsCreating(false);
|
|
}}
|
|
>
|
|
{isCreating ? (
|
|
<LoadingSpinner className="size-4 shrink-0" />
|
|
) : (
|
|
<PlusIcon className="size-4 shrink-0" />
|
|
)}
|
|
<p className="grow truncate">
|
|
{createLabel?.(search) || `Create "${search}"`}
|
|
</p>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</ScrollArea>
|
|
</Command>
|
|
</PopoverContent>
|
|
{animation > 0 && value.length > 0 && (
|
|
<WandSparkles
|
|
className={cn(
|
|
"my-2 h-3 w-3 cursor-pointer bg-background text-foreground",
|
|
isAnimating ? "" : "text-muted-foreground",
|
|
)}
|
|
onClick={() => setIsAnimating(!isAnimating)}
|
|
/>
|
|
)}
|
|
</Popover>
|
|
);
|
|
},
|
|
);
|
|
|
|
MultiSelect.displayName = "MultiSelect";
|
|
|
|
const isReactNode = (element: any): element is React.ReactNode =>
|
|
React.isValidElement(element);
|