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
-5
View File
@@ -1,15 +1,10 @@
import type { Metadata } from "next";
import { GlobeIcon } from "@/assets/icons";
import DatePickerOne from "@/components/FormElements/DatePicker/DatePickerOne";
import DatePickerTwo from "@/components/FormElements/DatePicker/DatePickerTwo";
import InputGroup from "@/components/FormElements/InputGroup";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Checkbox } from "@/components/FormElements/checkbox";
import { RadioInput } from "@/components/FormElements/radio";
import { Select } from "@/components/FormElements/select";
import { Switch } from "@/components/FormElements/switch";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
@@ -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;
+100 -79
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) => ({
const newOptions = items.map((item) => ({
...item,
selected: false,
})),
);
}, [items]);
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 = () => {
@@ -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,77 +135,61 @@ 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"
}`}
>
<div className="flex flex-auto flex-wrap gap-2">
{selected.map((index) => (
<div
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"
>
<div className="max-w-full flex-initial">
{options[index]?.text}
</div>
<div className="flex flex-auto flex-row-reverse">
<div
onClick={(e) => remove(index, e)}
className="hover:text-red cursor-pointer pl-2"
>
<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>
))}
{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>
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",
)}
</div>
<div className="flex items-center py-1 pl-1 pr-1">
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"
className="cursor-pointer text-dark-4 outline-none focus:outline-none dark:text-dark-6"
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={`fill-current transition-transform duration-200 ${
isOpen() ? "rotate-180" : ""
}`}
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"
@@ -200,42 +203,60 @@ function MultiSelect<T extends FieldValues>({
fill="currentColor"
/>
</svg>
</button>
</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
className="w-full cursor-pointer rounded-t border-b border-stroke hover:bg-primary/5 dark:border-dark-3"
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={`relative flex w-full items-center border-l-2 p-2 pl-2 ${
option.selected ? "border-primary" : "border-transparent"
}`}
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="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;
@@ -1,7 +1,9 @@
"use client";
import { GlobeIcon, MoneyIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import TagInput from "@/components/FormElements/InputGroup/tag-input";
import { TextAreaGroup } from "@/components/FormElements/InputGroup/text-area";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import FormFooter from "@/components/ui/FormFooter";
@@ -17,8 +19,15 @@ interface IProps {
}
const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
const { errors, handleSubmit, onSubmit, register } =
useCreateCompanyPresenter({
const {
errors,
handleSubmit,
onSubmit,
register,
watch,
setValue,
companyCategories,
} = useCreateCompanyPresenter({
editMode,
defaultValues,
id,
@@ -458,35 +467,56 @@ const CreateCompany = ({ defaultValues, editMode, id }: IProps) => {
)}
</ShowcaseSection>
<ShowcaseSection title="Tags" className="grid grid-cols-1 gap-5">
<InputGroup
<TagInput
{...register("tags.keywords")}
label="Keywords"
name="tags.keywords"
type="text"
label="Keywords"
placeholder="Enter Tag Keywords"
register={register}
setValue={setValue}
watch={watch}
/>
{errors.tags?.keywords && (
<p className="mt-1 text-sm text-red-500">
{errors.tags.keywords.message}
</p>
)}
<InputGroup
<MultiSelect
{...register("tags.categories")}
label="Categories"
value={watch("tags.categories")}
name="tags.categories"
type="text"
placeholder="Enter Tag Categories"
label="Categories"
items={
companyCategories
? companyCategories?.data?.categories?.map((c) => {
return {
text: c.name,
value: c.id,
};
})
: []
}
placeholder="Please select categories"
/>
{errors.tags?.categories && (
<p className="mt-1 text-sm text-red-500">
{errors.tags.categories.message}
</p>
)}
<InputGroup
<TagInput
{...register("tags.cpv_codes")}
label="CPV codes"
name="tags.cpv_codes"
watch={watch}
setValue={setValue}
placeholder="Enter Tag CPV codes"
/>
<TagInput
{...register("tags.certifications")}
label="Certifications"
name="tags.certifications"
watch={watch}
setValue={setValue}
placeholder="Enter Tag Certifications"
/>
<TagInput
{...register("tags.specializations")}
label="Specializations"
name="tags.specializations"
type="text"
watch={watch}
setValue={setValue}
placeholder="Enter Tag Specializations"
/>
{errors.tags?.specializations && (
@@ -1,5 +1,9 @@
"use client";
import { useCreateCompany, useUpdateCompany } from "@/hooks/queries";
import {
useCompanyCategoriesQuery,
useCreateCompany,
useUpdateCompany,
} from "@/hooks/queries";
import { ICreateCompanyCredentials } from "@/lib/api";
import { useIsMutating } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
@@ -17,9 +21,24 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
register,
formState: { errors },
handleSubmit,
watch,
setValue,
reset,
} = useForm<ICreateCompanyCredentials>({ mode: "onChange", defaultValues });
} = useForm<ICreateCompanyCredentials>({
mode: "onChange",
defaultValues: {
...defaultValues,
tags: {
...defaultValues?.tags,
categories: defaultValues?.tags.categories.map((item) => ({
value: item.id,
text: item.name,
})),
},
},
});
const router = useRouter();
const { data: companyCategories } = useCompanyCategoriesQuery();
const { mutate: createCompany, isPending: isCreating } = useCreateCompany();
const { mutate: updateCompany, isPending: isUpdating } = useUpdateCompany(
id as string,
@@ -30,33 +49,6 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
founded_year: data.founded_year ? +data.founded_year : 0,
employee_count: data.employee_count ? +data.employee_count : 0,
annual_revenue: data.annual_revenue ? +data.annual_revenue : 0,
tags: {
keywords: data.tags?.keywords
? (data.tags.keywords as unknown as string)
.split(",")
.map((s) => s.trim())
: [],
categories: data.tags?.categories
? (data.tags.categories as unknown as string)
.split(",")
.map((s) => s.trim())
: [],
specializations: data.tags?.specializations
? (data.tags.specializations as unknown as string)
.split(",")
.map((s) => s.trim())
: [],
certifications: data.tags?.certifications
? (data.tags.certifications as unknown as string)
.split(",")
.map((s) => s.trim())
: [],
cpv_codes: data.tags?.cpv_codes
? (data.tags.cpv_codes as unknown as string)
.split(",")
.map((s) => s.trim())
: [],
},
};
if (editMode) {
updateCompany({
@@ -80,7 +72,10 @@ const useCreateCompanyPresenter = ({ defaultValues, editMode, id }: IProps) => {
isMutating,
isCreating,
isUpdating,
router
router,
watch,
setValue,
companyCategories,
};
};
@@ -1,13 +1,13 @@
"use client";
import { GlobeIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { Select } from "@/components/FormElements/select";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import { FormErrorMessages } from "@/constants/Texts";
import { CreateCustomerCredentials } from "@/lib/api";
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
import InputGroup from "@/components/FormElements/InputGroup";
import { FormErrorMessages } from "@/constants/Texts";
import { Select } from "@/components/FormElements/select";
import MultiSelect from "@/components/FormElements/MultiSelect";
import { GlobeIcon, UserIcon } from "@/assets/icons";
interface IProps {
editMode?: boolean;
+2 -2
View File
@@ -22,7 +22,7 @@ export interface IContactPerson {
export interface ITags {
keywords: string[];
categories: string[];
categories: any[];
specializations: string[];
certifications: string[];
cpv_codes: string[];
@@ -87,7 +87,7 @@ export const companySchema = z.object({
.optional(),
tags: z.object({
keywords: z.array(z.string()).or(z.null()).optional(),
categories: z.array(z.string()).or(z.null()).optional(),
categories: z.array(z.any()).or(z.null()).optional(),
specializations: z.array(z.string()).or(z.null()).optional(),
certifications: z.array(z.string()).or(z.null()).optional(),
cpv_codes: z.array(z.string()).or(z.null()).optional(),