Remove MultiSelect component

This commit is contained in:
Bram Suurd 2024-11-13 19:04:39 +01:00
parent a3b2a476c1
commit 64fd9a5dcd

View File

@ -1,387 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandEmpty,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { Check, X as RemoveIcon } from "lucide-react";
import React, {
KeyboardEvent,
createContext,
forwardRef,
useCallback,
useContext,
useState,
} from "react";
interface MultiSelectorProps
extends React.ComponentPropsWithoutRef<typeof CommandPrimitive> {
values: string[];
onValuesChange: (value: string[]) => void;
loop?: boolean;
}
interface MultiSelectContextProps {
value: string[];
onValueChange: (value: any) => void;
open: boolean;
setOpen: (value: boolean) => void;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
activeIndex: number;
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
ref: React.RefObject<HTMLInputElement>;
handleSelect: (e: React.SyntheticEvent<HTMLInputElement>) => void;
}
const MultiSelectContext = createContext<MultiSelectContextProps | null>(null);
const useMultiSelect = () => {
const context = useContext(MultiSelectContext);
if (!context) {
throw new Error("useMultiSelect must be used within MultiSelectProvider");
}
return context;
};
/**
* MultiSelect Docs: {@link: https://shadcn-extension.vercel.app/docs/multi-select}
*/
// TODO : expose the visibility of the popup
const MultiSelector = ({
values: value,
onValuesChange: onValueChange,
loop = false,
className,
children,
dir,
...props
}: MultiSelectorProps) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState<boolean>(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
const [isValueSelected, setIsValueSelected] = React.useState(false);
const [selectedValue, setSelectedValue] = React.useState("");
const onValueChangeHandler = useCallback(
(val: string) => {
if (value.includes(val)) {
onValueChange(value.filter((item) => item !== val));
} else {
onValueChange([...value, val]);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[value],
);
const handleSelect = React.useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
e.preventDefault();
const target = e.currentTarget;
const selection = target.value.substring(
target.selectionStart ?? 0,
target.selectionEnd ?? 0,
);
setSelectedValue(selection);
setIsValueSelected(selection === inputValue);
},
[inputValue],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
const target = inputRef.current;
if (!target) return;
const moveNext = () => {
const nextIndex = activeIndex + 1;
setActiveIndex(
nextIndex > value.length - 1 ? (loop ? 0 : -1) : nextIndex,
);
};
const movePrev = () => {
const prevIndex = activeIndex - 1;
setActiveIndex(prevIndex < 0 ? value.length - 1 : prevIndex);
};
const moveCurrent = () => {
const newIndex =
activeIndex - 1 <= 0
? value.length - 1 === 0
? -1
: 0
: activeIndex - 1;
setActiveIndex(newIndex);
};
switch (e.key) {
case "ArrowLeft":
if (dir === "rtl") {
if (value.length > 0 && (activeIndex !== -1 || loop)) {
moveNext();
}
} else {
if (value.length > 0 && target.selectionStart === 0) {
movePrev();
}
}
break;
case "ArrowRight":
if (dir === "rtl") {
if (value.length > 0 && target.selectionStart === 0) {
movePrev();
}
} else {
if (value.length > 0 && (activeIndex !== -1 || loop)) {
moveNext();
}
}
break;
case "Backspace":
case "Delete":
if (value.length > 0) {
if (activeIndex !== -1 && activeIndex < value.length) {
onValueChangeHandler(value[activeIndex]);
moveCurrent();
} else {
if (target.selectionStart === 0) {
if (selectedValue === inputValue || isValueSelected) {
onValueChangeHandler(value[value.length - 1]);
}
}
}
}
break;
case "Enter":
setOpen(true);
break;
case "Escape":
if (activeIndex !== -1) {
setActiveIndex(-1);
} else if (open) {
setOpen(false);
}
break;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[value, inputValue, activeIndex, loop],
);
return (
<MultiSelectContext.Provider
value={{
value,
onValueChange: onValueChangeHandler,
open,
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
ref: inputRef,
handleSelect,
}}
>
<Command
onKeyDown={handleKeyDown}
className={cn(
"overflow-visible bg-transparent flex flex-col space-y-2",
className,
)}
dir={dir}
{...props}
>
{children}
</Command>
</MultiSelectContext.Provider>
);
};
const MultiSelectorTrigger = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { value, onValueChange, activeIndex } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return (
<div
ref={ref}
className={cn(
"flex flex-wrap gap-1 p-1 py-2 ring-1 ring-muted rounded-lg bg-background",
{
"ring-1 focus-within:ring-ring": activeIndex === -1,
},
className,
)}
{...props}
>
{value.map((item, index) => (
<Badge
key={item}
className={cn(
"px-1 rounded-xl flex items-center gap-1",
activeIndex === index && "ring-2 ring-muted-foreground ",
)}
variant={"secondary"}
>
<span className="text-xs">{item}</span>
<button
aria-label={`Remove ${item} option`}
aria-roledescription="button to remove option"
type="button"
onMouseDown={mousePreventDefault}
onClick={() => onValueChange(item)}
>
<span className="sr-only">Remove {item} option</span>
<RemoveIcon className="h-4 w-4 hover:stroke-destructive" />
</button>
</Badge>
))}
{children}
</div>
);
});
MultiSelectorTrigger.displayName = "MultiSelectorTrigger";
const MultiSelectorInput = forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => {
const {
setOpen,
inputValue,
setInputValue,
activeIndex,
setActiveIndex,
handleSelect,
ref: inputRef,
} = useMultiSelect();
return (
<CommandPrimitive.Input
{...props}
tabIndex={0}
ref={inputRef}
value={inputValue}
onValueChange={activeIndex === -1 ? setInputValue : undefined}
onSelect={handleSelect}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
onClick={() => setActiveIndex(-1)}
className={cn(
"ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1",
className,
activeIndex !== -1 && "caret-transparent",
)}
/>
);
});
MultiSelectorInput.displayName = "MultiSelectorInput";
const MultiSelectorContent = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ children }, ref) => {
const { open } = useMultiSelect();
return (
<div ref={ref} className="relative">
{open && children}
</div>
);
});
MultiSelectorContent.displayName = "MultiSelectorContent";
const MultiSelectorList = forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, children }, ref) => {
return (
<CommandList
ref={ref}
className={cn(
"p-2 flex flex-col gap-2 rounded-md scrollbar-thin scrollbar-track-transparent transition-colors scrollbar-thumb-muted-foreground dark:scrollbar-thumb-muted scrollbar-thumb-rounded-lg w-full absolute bg-background shadow-md z-10 border border-muted top-0",
className,
)}
>
{children}
<CommandEmpty>
<span className="text-muted-foreground">No results found</span>
</CommandEmpty>
</CommandList>
);
});
MultiSelectorList.displayName = "MultiSelectorList";
const MultiSelectorItem = forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
{ value: string } & React.ComponentPropsWithoutRef<
typeof CommandPrimitive.Item
>
>(({ className, value, children, ...props }, ref) => {
const { value: Options, onValueChange, setInputValue } = useMultiSelect();
const mousePreventDefault = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const isIncluded = Options.includes(value);
return (
<CommandItem
ref={ref}
{...props}
onSelect={() => {
onValueChange(value);
setInputValue("");
}}
className={cn(
"rounded-md cursor-pointer px-2 py-1 transition-colors flex justify-between ",
className,
isIncluded && "opacity-50 cursor-default",
props.disabled && "opacity-50 cursor-not-allowed",
)}
onMouseDown={mousePreventDefault}
>
{children}
{isIncluded && <Check className="h-4 w-4" />}
</CommandItem>
);
});
MultiSelectorItem.displayName = "MultiSelectorItem";
export {
MultiSelector,
MultiSelectorContent,
MultiSelectorInput,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
};