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
@@ -0,0 +1,188 @@
import { cn } from "@/lib/utils";
import { KeyboardEvent, useId, useState } from "react";
import {
type FieldErrors,
type FieldValues,
type Path,
type UseFormRegister,
type UseFormRegisterReturn,
type UseFormSetValue,
type UseFormWatch,
} from "react-hook-form";
import { toast } from "react-toastify";
type TagInputProps<T extends FieldValues> = {
name: Path<T>;
label: string;
placeholder: string;
className?: string;
required?: boolean;
disabled?: boolean;
active?: boolean;
value?: string[];
icon?: React.ReactNode;
iconPosition?: "left" | "right";
height?: "sm" | "default";
register?: UseFormRegister<T>;
setValue?: UseFormSetValue<T>;
watch?: UseFormWatch<T>;
errors?: FieldErrors<T>;
maxTags?: number;
allowDuplicates?: boolean;
tagValidator?: (tag: string) => boolean;
} & Partial<UseFormRegisterReturn>;
const TagInput = <T extends FieldValues>({
className,
label,
placeholder,
required,
disabled,
active,
icon,
name,
register,
setValue,
watch,
errors,
maxTags,
allowDuplicates = false,
tagValidator,
onChange,
onBlur,
ref,
...props
}: TagInputProps<T>): React.ReactElement => {
const id = useId();
const [inputValue, setInputValue] = useState("");
const watchedValue = watch ? watch(name) : props.value || [];
const tags: string[] = Array.isArray(watchedValue) ? watchedValue : [];
const error = errors && errors[name];
const addTag = (tag: string) => {
const trimmedTag = tag.trim();
if (!trimmedTag) return;
if (maxTags && tags.length >= maxTags) return;
if (!allowDuplicates && tags.includes(trimmedTag)) {
toast.error("Item already exists");
return;
}
if (tagValidator && !tagValidator(trimmedTag)) return;
const newTags = [...tags, trimmedTag];
if (setValue) {
setValue(name, newTags as any, { shouldValidate: true });
}
setInputValue("");
};
const removeTag = (indexToRemove: number) => {
const newTags = tags.filter((_, index) => index !== indexToRemove);
if (setValue) {
setValue(name, newTags as any, { shouldValidate: true });
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(inputValue);
} else if (e.key === "Backspace" && !inputValue && tags.length > 0) {
removeTag(tags.length - 1);
}
};
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (inputValue) {
addTag(inputValue);
}
onBlur?.(e);
};
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={cn(
"relative mt-3 [&_svg]:absolute [&_svg]:top-1/2 [&_svg]:-translate-y-1/2",
props.iconPosition === "left"
? "[&_svg]:left-4.5"
: "[&_svg]:right-4.5",
)}
>
<div
className={cn(
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent outline-none transition focus-within:border-primary disabled:cursor-default disabled:bg-gray-2 data-[active=true]:border-primary dark:border-dark-3 dark:bg-dark-2 dark:focus-within:border-primary dark:disabled:bg-dark dark:data-[active=true]:border-primary",
error && "border-red focus-within:border-red dark:border-red",
"flex min-h-[50px] flex-wrap items-center gap-2 px-3 py-2",
props.iconPosition === "left" && "pl-12.5",
props.height === "sm" && "min-h-[42px]",
disabled && "pointer-events-none opacity-60",
)}
data-active={active}
>
{tags.map((tag, 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"
>
{tag}
{!disabled && (
<button
type="button"
onClick={() => removeTag(index)}
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs hover:bg-primary/20"
>
×
</button>
)}
</span>
))}
<input
id={id}
type="text"
placeholder={tags.length === 0 ? placeholder : ""}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
disabled={disabled || (maxTags ? tags.length >= maxTags : false)}
className="min-w-[120px] flex-1 bg-transparent text-dark outline-none placeholder:text-dark-6 dark:text-white"
/>
</div>
{icon}
<input
type="hidden"
{...(register ? register(name) : {})}
value={JSON.stringify(tags)}
ref={ref}
/>
</div>
{error && (
<p className="text-red mt-1.5 text-sm">{error.message as string}</p>
)}
{maxTags && (
<p className="mt-1 text-xs text-dark-6 dark:text-dark-6">
{tags.length}/{maxTags} tags
</p>
)}
</div>
);
};
export default TagInput;