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