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:
@@ -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;
|
||||
Reference in New Issue
Block a user