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.
This commit is contained in:
AmirReza Jamali
2025-09-20 18:28:53 +03:30
parent e6493f5d83
commit 0ea2ff635b
7 changed files with 388 additions and 159 deletions
+112 -91
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useEffect, useRef, useState, useId } from "react";
import { cn } from "@/lib/utils";
import React, { useEffect, useId, useRef, useState } from "react";
import {
type FieldErrors,
type FieldValues,
@@ -17,10 +18,14 @@ interface Option {
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>({
@@ -28,12 +33,16 @@ function MultiSelect<T extends FieldValues>({
label,
items,
errors,
required,
placeholder = "Select an option",
className,
disabled,
active,
height,
onChange,
onBlur,
ref,
value,
required,
}: MultiSelectProps<T>) {
const id = useId();
const error = errors && errors[name];
@@ -45,16 +54,22 @@ function MultiSelect<T extends FieldValues>({
const trigger = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setOptions(
items.map((item) => ({
...item,
selected: false,
})),
);
}, [items]);
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 = () => {
setShow(true);
if (!disabled) {
setShow(true);
}
};
const isOpen = () => {
@@ -69,6 +84,8 @@ function MultiSelect<T extends FieldValues>({
};
const handleSelect = (index: number) => {
if (disabled) return;
const newOptions = [...options];
let newSelected = [...selected];
@@ -83,11 +100,13 @@ function MultiSelect<T extends FieldValues>({
setOptions(newOptions);
setSelected(newSelected);
const selectedValues = newSelected.map((i) => options[i].value);
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);
@@ -96,7 +115,7 @@ function MultiSelect<T extends FieldValues>({
setOptions(newOptions);
setSelected(newSelected);
const selectedValues = newSelected.map((i) => options[i].value);
const selectedValues = newSelected.map((i) => newOptions[i].value);
onChange?.({ target: { name, value: selectedValues } });
};
@@ -116,126 +135,128 @@ function MultiSelect<T extends FieldValues>({
}, [show]);
return (
<div className={`relative z-50 ${className}`}>
<div className={className}>
<label
htmlFor={id}
className="mb-3 block text-body-sm font-medium text-dark dark:text-white"
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">
<div className="relative mt-3">
<div
ref={combinedRef}
onClick={open}
onBlur={onBlur}
tabIndex={0}
className={`flex min-h-[46px] w-full cursor-pointer items-center rounded-[7px] border-[1.5px] bg-transparent py-2 pl-3 pr-3 outline-none transition focus:border-primary active:border-primary dark:bg-dark-2 ${
error
? "border-red focus:border-red dark:border-red"
: "border-stroke dark:border-dark-3"
}`}
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-auto flex-wrap gap-2">
<div className="flex flex-1 flex-wrap items-center gap-2">
{selected.map((index) => (
<div
<span
key={index}
className="flex items-center justify-center rounded-[5px] border-[.5px] border-stroke bg-gray-2 px-2.5 py-1 text-body-sm font-medium dark:border-dark-3 dark:bg-dark"
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"
>
<div className="max-w-full flex-initial">
{options[index]?.text}
</div>
<div className="flex flex-auto flex-row-reverse">
<div
{options[index]?.text}
{!disabled && (
<button
type="button"
onClick={(e) => remove(index, e)}
className="hover:text-red cursor-pointer pl-2"
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs hover:bg-primary/20"
>
<svg
className="fill-current"
role="button"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.35355 3.35355C9.54882 3.15829 9.54882 2.84171 9.35355 2.64645C9.15829 2.45118 8.84171 2.45118 8.64645 2.64645L6 5.29289L3.35355 2.64645C3.15829 2.45118 2.84171 2.45118 2.64645 2.64645C2.45118 2.84171 2.45118 3.15829 2.64645 3.35355L5.29289 6L2.64645 8.64645C2.45118 8.84171 2.45118 9.15829 2.64645 9.35355C2.84171 9.54882 3.15829 9.54882 3.35355 9.35355L6 6.70711L8.64645 9.35355C8.84171 9.54882 9.15829 9.54882 9.35355 9.35355C9.54882 9.15829 9.54882 8.84171 9.35355 8.64645L6.70711 6L9.35355 3.35355Z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
×
</button>
)}
</span>
))}
{selected.length === 0 && (
<div className="flex-1">
<span className="h-full w-full bg-transparent p-1 px-2 text-dark-5 outline-none dark:text-dark-6">
{placeholder}
</span>
</div>
<span className="text-dark-6 dark:text-dark-6">
{placeholder}
</span>
)}
</div>
<div className="flex items-center py-1 pl-1 pr-1">
<button
type="button"
className="cursor-pointer text-dark-4 outline-none focus:outline-none dark:text-dark-6"
<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"
>
<svg
className={`fill-current transition-transform duration-200 ${
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>
</button>
<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={`max-h-select absolute left-0 top-full z-40 w-full overflow-y-auto rounded bg-white shadow-1 dark:bg-dark-2 dark:shadow-card ${
isOpen() ? "" : "hidden"
}`}
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="flex w-full flex-col">
<div className="py-1">
{options.map((option, index) => (
<div key={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="w-full cursor-pointer rounded-t border-b border-stroke hover:bg-primary/5 dark:border-dark-3"
onClick={() => handleSelect(index)}
className={cn(
"mr-3 h-4 w-4 rounded border-2",
option.selected
? "border-primary bg-primary"
: "border-stroke dark:border-dark-3",
)}
>
<div
className={`relative flex w-full items-center border-l-2 p-2 pl-2 ${
option.selected ? "border-primary" : "border-transparent"
}`}
>
<div className="flex w-full items-center">
<div className="mx-2 leading-6">{option.text}</div>
</div>
</div>
{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;