Files
tm_panel/src/components/FormElements/MultiSelect.tsx
T
AmirReza Jamali 0ea2ff635b refactor(forms): Enhance MultiSelect with controlled state and styling
This commit overhauls the `MultiSelect` component to improve its functionality, flexibility, and integration with form libraries.

The component is now a controlled component, accepting a `value` prop and using the `onChange` handler to propagate updates. This allows for seamless integration with libraries like React Hook Form.

Key changes include:
- Added `value`, `disabled`, `active`, and `height` props for more granular control over the component's state and appearance.
- Refactored styling logic to use the `cn` utility for cleaner conditional class management.
- Simplified the internal JSX structure and SVGs for better readability and maintenance.
- Updated the component's state management to correctly reflect the incoming `value` prop.
2025-09-20 18:28:53 +03:30

263 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
text: string;
selected: boolean;
}
type MultiSelectProps<T extends FieldValues> = {
label: string;
items: { value: string; text: string }[];
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 newOptions = items.map((item) => ({
...item,
selected: value?.includes(item.value) ?? false,
}));
setOptions(newOptions);
const newSelectedIndices = items
.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 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]?.text}
{!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.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.text}
</div>
))}
</div>
</div>
</div>
{error && (
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
)}
</div>
);
}
export default MultiSelect;