diff --git a/package-lock.json b/package-lock.json index 7ffcafe..06ca6f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", + "@tanstack/react-virtual": "^3.13.6", "@vercel/analytics": "^1.2.2", "@vercel/speed-insights": "^1.0.10", "class-variance-authority": "^0.7.0", @@ -1082,6 +1083,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz", + "integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz", + "integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", diff --git a/package.json b/package.json index ff1c69f..42a4674 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", + "@tanstack/react-virtual": "^3.13.6", "@vercel/analytics": "^1.2.2", "@vercel/speed-insights": "^1.0.10", "class-variance-authority": "^0.7.0", diff --git a/src/components/multi-select-virtual.tsx b/src/components/multi-select-virtual.tsx new file mode 100644 index 0000000..d7b562a --- /dev/null +++ b/src/components/multi-select-virtual.tsx @@ -0,0 +1,456 @@ +import * as React from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + CheckIcon, + XCircle, + ChevronDown, + XIcon, + WandSparkles, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * 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", + }, + } +); + +type Option = { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; +}; + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** + * 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: Option[]; + + /** + * 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[]; + + /** + * 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; +} + +export const MultiSelectVirtual = React.forwardRef< + HTMLButtonElement, + MultiSelectProps +>( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select options", + animation = 0, + maxCount = 3, + modalPopover = false, + asChild = false, + className, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleInputKeyDown = ( + event: React.KeyboardEvent + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + return ( + + + + + setIsPopoverOpen(false)} + > + + + {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} + + ); + } +); + +const VirtualCommand = ({ + options, + selectedValues, + placeholderSearch, + selectAllText, + handleInputKeyDown, + toggleAll, + toggleOption, + handleClear, + setIsPopoverOpen, +}: { + options: Option[]; + selectedValues: string[]; + placeholderSearch: string; + selectAllText: string; + handleInputKeyDown: (event: React.KeyboardEvent) => void; + toggleAll: () => void; + toggleOption: (option: string) => void; + handleClear: () => void; + setIsPopoverOpen: (open: boolean) => void; +}) => { + const [filteredOptions, setFilteredOptions] = + React.useState(options); + + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredOptions.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 32, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + const handleSearch = (value: string) => { + setFilteredOptions( + options.filter((option) => + option.label + .toLocaleLowerCase() + .includes(value.toLocaleLowerCase() ?? []) + ) + ); + }; + + return ( + + + +
+ No results found. + + +
+ +
+ ({selectAllText}) +
+ {virtualItems.map((item) => { + const option = filteredOptions[item.index]; + + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + className="absolute top-10 left-1 w-full cursor-pointer bg-transparent" + style={{ + height: `${item.size}px`, + transform: `translateY(${item.start}px)`, + }} + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+ + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="max-w-full flex-1 cursor-pointer justify-center" + > + Close + +
+
+
+
+
+ ); +}; + +MultiSelectVirtual.displayName = "MultiSelectVirtual";