26c092db4a
- Update MultiSelect component to allow items to be optional or null, improving flexibility. - Implement safe handling of items to prevent errors when items are not provided. - Add a message to display when no items are available, enhancing user experience. - Refactor rendering logic for selected items to maintain consistent styling.
272 lines
8.4 KiB
TypeScript
272 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import { cn } from "@/lib/utils";
|
||
import React, { useEffect, useId, useRef, useState } from "react";
|
||
import {
|
||
type FieldErrors,
|
||
type FieldValues,
|
||
type Path,
|
||
type UseFormRegisterReturn,
|
||
} from "react-hook-form";
|
||
|
||
interface Option {
|
||
value: string;
|
||
label: string;
|
||
selected: boolean;
|
||
}
|
||
|
||
type MultiSelectProps<T extends FieldValues> = {
|
||
label: string;
|
||
items?: { value: string; label: string }[] | null;
|
||
value?: string[];
|
||
errors?: FieldErrors<T>;
|
||
required?: boolean;
|
||
placeholder?: string;
|
||
className?: string;
|
||
disabled?: boolean;
|
||
active?: boolean;
|
||
height?: "sm" | "default";
|
||
} & UseFormRegisterReturn<Path<T>>;
|
||
|
||
function MultiSelect<T extends FieldValues>({
|
||
name,
|
||
label,
|
||
items,
|
||
errors,
|
||
placeholder = "Select an option",
|
||
className,
|
||
disabled,
|
||
active,
|
||
height,
|
||
onChange,
|
||
onBlur,
|
||
ref,
|
||
value,
|
||
required,
|
||
}: MultiSelectProps<T>) {
|
||
const id = useId();
|
||
const error = errors && errors[name];
|
||
|
||
const [options, setOptions] = useState<Option[]>([]);
|
||
const [selected, setSelected] = useState<number[]>([]);
|
||
const [show, setShow] = useState(false);
|
||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||
const trigger = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
const safeItems = Array.isArray(items) ? items : [];
|
||
|
||
const newOptions = safeItems.map((item) => ({
|
||
...item,
|
||
selected: value?.includes(item.value) ?? false,
|
||
}));
|
||
setOptions(newOptions);
|
||
|
||
const newSelectedIndices = safeItems
|
||
.map((item, index) => (value?.includes(item.value) ? index : -1))
|
||
.filter((index) => index !== -1);
|
||
setSelected(newSelectedIndices);
|
||
}, [items, value]);
|
||
|
||
const open = () => {
|
||
if (!disabled) {
|
||
setShow(true);
|
||
}
|
||
};
|
||
|
||
const isOpen = () => {
|
||
return show === true;
|
||
};
|
||
|
||
const combinedRef = (el: HTMLDivElement) => {
|
||
trigger.current = el;
|
||
if (typeof ref === "function") {
|
||
ref(el);
|
||
}
|
||
};
|
||
|
||
const handleSelect = (index: number) => {
|
||
if (disabled) return;
|
||
|
||
const newOptions = [...options];
|
||
let newSelected = [...selected];
|
||
|
||
if (!newOptions[index].selected) {
|
||
newOptions[index].selected = true;
|
||
newSelected.push(index);
|
||
} else {
|
||
newOptions[index].selected = false;
|
||
newSelected = newSelected.filter((i) => i !== index);
|
||
}
|
||
|
||
setOptions(newOptions);
|
||
setSelected(newSelected);
|
||
|
||
const selectedValues = newSelected.map((i) => newOptions[i].value);
|
||
onChange?.({ target: { name, value: selectedValues } });
|
||
};
|
||
|
||
const remove = (index: number, event: React.MouseEvent) => {
|
||
if (disabled) return;
|
||
|
||
event.stopPropagation();
|
||
const newOptions = [...options];
|
||
const newSelected = selected.filter((i) => i !== index);
|
||
newOptions[index].selected = false;
|
||
|
||
setOptions(newOptions);
|
||
setSelected(newSelected);
|
||
|
||
const selectedValues = newSelected.map((i) => newOptions[i].value);
|
||
onChange?.({ target: { name, value: selectedValues } });
|
||
};
|
||
|
||
useEffect(() => {
|
||
const clickHandler = ({ target }: MouseEvent) => {
|
||
if (!dropdownRef.current) return;
|
||
if (
|
||
!show ||
|
||
dropdownRef.current.contains(target as Node) ||
|
||
trigger.current?.contains(target as Node)
|
||
)
|
||
return;
|
||
setShow(false);
|
||
};
|
||
|
||
document?.addEventListener("click", clickHandler);
|
||
return () => document?.removeEventListener("click", clickHandler);
|
||
}, [show]);
|
||
|
||
return (
|
||
<div className={className}>
|
||
<label
|
||
htmlFor={id}
|
||
className="text-body-sm font-medium capitalize text-dark dark:text-white"
|
||
>
|
||
{label}
|
||
{required && <span className="ml-1 select-none text-error">*</span>}
|
||
</label>
|
||
|
||
<div className="relative mt-3">
|
||
<div
|
||
ref={combinedRef}
|
||
onClick={open}
|
||
onBlur={onBlur}
|
||
tabIndex={0}
|
||
className={cn(
|
||
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary",
|
||
error && "border-red focus:border-red dark:border-red",
|
||
"flex min-h-[50px] cursor-pointer flex-wrap items-center gap-2 px-3 py-2",
|
||
height === "sm" && "min-h-[42px]",
|
||
disabled && "pointer-events-none opacity-60",
|
||
)}
|
||
data-active={active}
|
||
>
|
||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||
{selected.map((index) => (
|
||
<span
|
||
key={index}
|
||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2.5 py-1 text-sm font-medium text-primary dark:bg-primary/20"
|
||
>
|
||
{options[index]?.label}
|
||
{!disabled && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => remove(index, e)}
|
||
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs hover:bg-primary/20"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</span>
|
||
))}
|
||
{selected.length === 0 && (
|
||
<span className="text-dark-6 dark:text-dark-6">
|
||
{placeholder}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center">
|
||
<svg
|
||
className={cn(
|
||
"fill-current text-dark-4 transition-transform duration-200 dark:text-dark-6",
|
||
isOpen() && "rotate-180",
|
||
)}
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 20 20"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
clipRule="evenodd"
|
||
d="M3.69149 7.09327C3.91613 6.83119 4.31069 6.80084 4.57277 7.02548L9.99936 11.6768L15.4259 7.02548C15.688 6.80084 16.0826 6.83119 16.3072 7.09327C16.5319 7.35535 16.5015 7.74991 16.2394 7.97455L10.4061 12.9745C10.172 13.1752 9.82667 13.1752 9.59261 12.9745L3.75928 7.97455C3.4972 7.74991 3.46685 7.35535 3.69149 7.09327Z"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"absolute left-0 top-full z-40 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-stroke bg-white shadow-lg dark:border-dark-3 dark:bg-dark-2",
|
||
!isOpen() && "hidden",
|
||
)}
|
||
ref={dropdownRef}
|
||
>
|
||
<div className="py-1">
|
||
{options.length === 0 ? (
|
||
<div className="px-3 py-2 text-sm text-dark-6 dark:text-dark-6">
|
||
No items are available
|
||
</div>
|
||
) : (
|
||
options.map((option, index) => (
|
||
<div
|
||
key={index}
|
||
className={cn(
|
||
"flex cursor-pointer items-center px-3 py-2 text-sm text-dark hover:bg-primary/5 dark:text-white dark:hover:bg-primary/5",
|
||
option.selected && "bg-primary/10 dark:bg-primary/20",
|
||
)}
|
||
onClick={() => handleSelect(index)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"mr-3 h-4 w-4 rounded border-2",
|
||
option.selected
|
||
? "border-primary bg-primary"
|
||
: "border-stroke dark:border-dark-3",
|
||
)}
|
||
>
|
||
{option.selected && (
|
||
<svg
|
||
className="h-full w-full text-white"
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
)}
|
||
</div>
|
||
{option.label}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default MultiSelect;
|