Skip to content

Commit 9ffe6ac

Browse files
author
Guru
committed
feat: dropdown component
1 parent f7272cc commit 9ffe6ac

File tree

1 file changed

+321
-0
lines changed

1 file changed

+321
-0
lines changed
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import React, { useState, useEffect, useRef } from "react";
2+
import { FaChevronDown, FaChevronUp } from "react-icons/fa";
3+
import { HiCheckCircle } from "react-icons/hi2";
4+
import { MdOutlineRadioButtonChecked, MdOutlineRadioButtonUnchecked } from "react-icons/md";
5+
import { cn } from "./Card";
6+
7+
interface Option {
8+
name: string;
9+
value: string;
10+
icon?: React.ReactNode;
11+
endSlot?: React.ReactNode;
12+
startSlot?: React.ReactNode;
13+
}
14+
15+
export interface DropdownProps
16+
extends Omit<
17+
React.HTMLAttributes<HTMLDivElement>,
18+
"options" | "defaultValue" | "onChange"
19+
> {
20+
value?: string | string[];
21+
options: Option[];
22+
defaultValue?: string | string[];
23+
placeholder?: string;
24+
onChange?: (value: string[] | string) => void;
25+
pill?: boolean;
26+
inputSize?: "sm" | "md" | "lg";
27+
showArrow?: boolean;
28+
EndSlot?: React.ReactNode;
29+
StartSlot?: React.ReactNode;
30+
showSelectHint?: boolean;
31+
multiple?: boolean;
32+
showValue?: boolean;
33+
classes?: {
34+
container?: string;
35+
inputContainer?: string;
36+
placeholder?: string;
37+
inputIcon?: string;
38+
input?: string;
39+
optionContainer?: string;
40+
optionItem?: string;
41+
optionIcon?: string;
42+
optionEndSlot?: string;
43+
optionStartSlot?: string;
44+
startSlot?: string;
45+
endSlot?: string;
46+
arrow?: string;
47+
};
48+
}
49+
50+
const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
51+
(
52+
{
53+
value,
54+
className,
55+
options,
56+
defaultValue,
57+
placeholder = "Select...",
58+
onChange,
59+
pill = true,
60+
inputSize = "md",
61+
classes,
62+
showArrow = true,
63+
StartSlot,
64+
EndSlot,
65+
showSelectHint = true,
66+
multiple = false,
67+
showValue = true,
68+
...props
69+
},
70+
ref,
71+
) => {
72+
const initialSelectedOptions = Array.isArray(defaultValue)
73+
? options.filter((option) => defaultValue.includes(option.value))
74+
: options.filter((option) => option.value === defaultValue);
75+
76+
const [isOpen, setIsOpen] = useState(false);
77+
const [selectedOption, setSelectedOptions] = useState<Option[]>(
78+
initialSelectedOptions,
79+
);
80+
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
81+
const [isFocused, setIsFocused] = useState(false);
82+
83+
const dropdownRef = useRef<HTMLDivElement>(null);
84+
85+
const handleOutsideClick = (event: MouseEvent) => {
86+
if (
87+
dropdownRef.current &&
88+
!dropdownRef.current.contains(event.target as Node)
89+
) {
90+
setIsOpen(false);
91+
}
92+
};
93+
94+
useEffect(() => {
95+
document.addEventListener("mousedown", handleOutsideClick);
96+
return () => {
97+
document.removeEventListener("mousedown", handleOutsideClick);
98+
};
99+
}, []);
100+
101+
const handleOptionClick = (option: Option) => {
102+
if (multiple) {
103+
setSelectedOptions((prev) => {
104+
if (prev.find((item) => item.value === option.value)) {
105+
return prev.filter((item) => item.value !== option.value);
106+
}
107+
if (onChange)
108+
onChange([...prev, option].map((option) => option.value));
109+
return [...prev, option];
110+
});
111+
} else {
112+
setSelectedOptions([option]);
113+
if (onChange) onChange(option.value);
114+
setIsOpen(false);
115+
}
116+
};
117+
118+
const handleKeyDown = (event: React.KeyboardEvent) => {
119+
if (!isFocused) return;
120+
121+
if (isOpen) {
122+
if (event.key === "ArrowDown") {
123+
setHighlightedIndex((prevIndex) => (prevIndex + 1) % options.length);
124+
} else if (event.key === "ArrowUp") {
125+
setHighlightedIndex(
126+
(prevIndex) => (prevIndex - 1 + options.length) % options.length,
127+
);
128+
} else if (event.key === "Enter") {
129+
handleOptionClick(options[highlightedIndex]);
130+
}
131+
} else if (event.key === "Tab" || event.key === "Enter") {
132+
setIsOpen(true);
133+
}
134+
};
135+
136+
const renderSelectedOptions = () => {
137+
if (multiple) {
138+
if (selectedOption.length > 2) {
139+
return (
140+
<p className="flex items-center">
141+
<span className="mr-1">{selectedOption[0].name},</span>
142+
<span className="mr-1">{selectedOption[1].name}</span>
143+
<span className="mr-1">+{selectedOption.length - 2}</span>
144+
</p>
145+
);
146+
} else {
147+
return selectedOption.map((option) => (
148+
<span key={option.value} className="mr-1">
149+
{option.name}
150+
</span>
151+
));
152+
}
153+
} else {
154+
return selectedOption[0]?.name;
155+
}
156+
};
157+
158+
const isSelected = (option: Option) => {
159+
return multiple
160+
? selectedOption.find((item) => item.value === option.value)
161+
: selectedOption?.[0]?.value === option.value;
162+
};
163+
164+
useEffect(() => {
165+
if (!value) return;
166+
167+
const selectedValues: Option[] = [];
168+
if (Array.isArray(value)) {
169+
selectedValues.push(...options.filter((o) => value.includes(o.value)));
170+
} else {
171+
selectedValues.push(...options.filter((o) => o.value === value));
172+
}
173+
setSelectedOptions(selectedValues);
174+
}, [options, value]);
175+
176+
return (
177+
<div
178+
className={cn("relative w-full", classes?.container)}
179+
ref={dropdownRef}
180+
onFocus={() => setIsFocused(true)}
181+
onBlur={() => setIsFocused(false)}
182+
{...props}
183+
>
184+
<div
185+
ref={ref}
186+
tabIndex={0}
187+
className={cn(
188+
`flex items-center justify-between p-2 bg-app-white dark:bg-app-gray-800 cursor-pointer w-full rounded-2xl py-2 px-5 h-10 outline-none focus:outline-none focus:ring-0`,
189+
{
190+
"rounded-full": pill,
191+
"h-8 py-2": inputSize === "sm",
192+
"h-12 py-3.5": inputSize === "lg",
193+
},
194+
classes?.inputContainer,
195+
)}
196+
onClick={() => setIsOpen(!isOpen)}
197+
onKeyDown={handleKeyDown}
198+
>
199+
{selectedOption ? (
200+
<div
201+
className={cn(
202+
"flex items-center gap-x-2 text-sm font-normal text-app-gray-900 dark:text-app-white",
203+
classes?.input,
204+
)}
205+
>
206+
{!multiple && selectedOption?.[0]?.icon && (
207+
<span
208+
className={cn(
209+
"text-base font-normal h-5 w-5",
210+
classes?.inputIcon,
211+
)}
212+
>
213+
{selectedOption?.[0]?.icon}
214+
</span>
215+
)}
216+
{StartSlot && (
217+
<div className={cn(classes?.startSlot)}>{StartSlot}</div>
218+
)}
219+
{showValue && renderSelectedOptions()}
220+
</div>
221+
) : (
222+
<span
223+
className={cn(
224+
"text-app-gray-400 text-sm font-normal",
225+
classes?.placeholder,
226+
)}
227+
>
228+
{placeholder}
229+
</span>
230+
)}
231+
<div className="flex items-center justify-end gap-x-1 ml-2">
232+
{EndSlot && (
233+
<div className={cn(classes?.endSlot, "mx-2")}>{EndSlot}</div>
234+
)}
235+
{showArrow && (
236+
<span>
237+
{isOpen ? (
238+
<FaChevronUp
239+
className={cn(
240+
"text-app-gray-400 text-sm font-medium",
241+
classes?.arrow,
242+
)}
243+
/>
244+
) : (
245+
<FaChevronDown
246+
className={cn(
247+
"text-app-gray-400 text-sm font-medium",
248+
classes?.arrow,
249+
)}
250+
/>
251+
)}
252+
</span>
253+
)}
254+
</div>
255+
</div>
256+
{isOpen && (
257+
<ul
258+
className={cn(
259+
"absolute left-0 w-max mt-2 overflow-auto bg-app-white dark:bg-app-gray-900 dark:border-gray-700 border rounded-lg shadow max-h-60 transition-all duration-300 ease-in-out transform origin-top scale-y-100 opacity-100 z-20 py-1",
260+
classes?.optionContainer,
261+
)}
262+
>
263+
{options.map((option, index) => (
264+
<li
265+
key={option.value}
266+
className={cn(
267+
`flex items-center justify-between py-2 px-4 cursor-pointer gap-x-6 text-sm text-app-gray-500 dark:text-app-gray-400`,
268+
{
269+
"text-app-gray-900 dark:text-app-white":
270+
index === highlightedIndex || isSelected(option),
271+
},
272+
classes?.optionItem,
273+
)}
274+
onClick={() => handleOptionClick(option)}
275+
onMouseEnter={() => setHighlightedIndex(index)}
276+
>
277+
<div className="flex items-center gap-x-2">
278+
{option.startSlot && (
279+
<div className={cn(classes?.optionStartSlot)}>
280+
{option.startSlot}
281+
</div>
282+
)}
283+
{option.icon && (
284+
<div className={cn("h-5 w-5", classes?.optionIcon)}>
285+
{option.icon}
286+
</div>
287+
)}
288+
<p>{option.name}</p>
289+
</div>
290+
<div className="flex items-center gap-x-2">
291+
{option.endSlot && (
292+
<div className={cn(classes?.optionEndSlot)}>
293+
{option.endSlot}
294+
</div>
295+
)}
296+
{showSelectHint && (
297+
<div>
298+
{isSelected(option) ? (
299+
multiple ? (
300+
<HiCheckCircle className="text-xl font-bold text-app-primary-600 dark:text-app-primary-500" />
301+
) : (
302+
<MdOutlineRadioButtonChecked className="text-app-primary-600 dark:text-app-primary-500 text-xl font-bold" />
303+
)
304+
) : (
305+
<MdOutlineRadioButtonUnchecked className="text-app-gray-400 dark:text-app-gray-500 text-xl font-bold" />
306+
)}
307+
</div>
308+
)}
309+
</div>
310+
</li>
311+
))}
312+
</ul>
313+
)}
314+
</div>
315+
);
316+
},
317+
);
318+
319+
Dropdown.displayName = "Dropdown";
320+
321+
export { Dropdown };

0 commit comments

Comments
 (0)