1
+ /* eslint-disable no-restricted-syntax */
1
2
"use client" ;
2
3
3
4
import { Button } from "@/components/ui/button" ;
@@ -9,7 +10,14 @@ import {
9
10
import { Separator } from "@/components/ui/separator" ;
10
11
import { cn } from "@/lib/utils" ;
11
12
import { CheckIcon , ChevronDown , SearchIcon , XIcon } from "lucide-react" ;
12
- import * as React from "react" ;
13
+ import {
14
+ forwardRef ,
15
+ useCallback ,
16
+ useEffect ,
17
+ useMemo ,
18
+ useRef ,
19
+ useState ,
20
+ } from "react" ;
13
21
import { useShowMore } from "../../lib/useShowMore" ;
14
22
import { ScrollShadow } from "../ui/ScrollShadow/ScrollShadow" ;
15
23
import { Input } from "../ui/input" ;
@@ -24,6 +32,7 @@ interface MultiSelectProps
24
32
selectedValues : string [ ] ;
25
33
onSelectedValuesChange : ( value : string [ ] ) => void ;
26
34
placeholder : string ;
35
+ searchPlaceholder ?: string ;
27
36
28
37
/**
29
38
* Maximum number of items to display. Extra selected items will be summarized.
@@ -32,12 +41,16 @@ interface MultiSelectProps
32
41
maxCount ?: number ;
33
42
34
43
className ?: string ;
44
+
45
+ overrideSearchFn ?: (
46
+ option : { value : string ; label : string } ,
47
+ searchTerm : string ,
48
+ ) => boolean ;
49
+
50
+ renderOption ?: ( option : { value : string ; label : string } ) => React . ReactNode ;
35
51
}
36
52
37
- export const MultiSelect = React . forwardRef <
38
- HTMLButtonElement ,
39
- MultiSelectProps
40
- > (
53
+ export const MultiSelect = forwardRef < HTMLButtonElement , MultiSelectProps > (
41
54
(
42
55
{
43
56
options,
@@ -50,10 +63,10 @@ export const MultiSelect = React.forwardRef<
50
63
} ,
51
64
ref ,
52
65
) => {
53
- const [ isPopoverOpen , setIsPopoverOpen ] = React . useState ( false ) ;
54
- const [ searchValue , setSearchValue ] = React . useState ( "" ) ;
66
+ const [ isPopoverOpen , setIsPopoverOpen ] = useState ( false ) ;
67
+ const [ searchValue , setSearchValue ] = useState ( "" ) ;
55
68
56
- const handleInputKeyDown = React . useCallback (
69
+ const handleInputKeyDown = useCallback (
57
70
( event : React . KeyboardEvent < HTMLInputElement > ) => {
58
71
if ( event . key === "Enter" ) {
59
72
setIsPopoverOpen ( true ) ;
@@ -66,7 +79,7 @@ export const MultiSelect = React.forwardRef<
66
79
[ selectedValues , onSelectedValuesChange ] ,
67
80
) ;
68
81
69
- const toggleOption = React . useCallback (
82
+ const toggleOption = useCallback (
70
83
( option : string ) => {
71
84
const newSelectedValues = selectedValues . includes ( option )
72
85
? selectedValues . filter ( ( value ) => value !== option )
@@ -76,30 +89,62 @@ export const MultiSelect = React.forwardRef<
76
89
[ selectedValues , onSelectedValuesChange ] ,
77
90
) ;
78
91
79
- const handleClear = React . useCallback ( ( ) => {
92
+ const handleClear = useCallback ( ( ) => {
80
93
onSelectedValuesChange ( [ ] ) ;
81
94
} , [ onSelectedValuesChange ] ) ;
82
95
83
96
const handleTogglePopover = ( ) => {
84
97
setIsPopoverOpen ( ( prev ) => ! prev ) ;
85
98
} ;
86
99
87
- const clearExtraOptions = React . useCallback ( ( ) => {
100
+ const clearExtraOptions = useCallback ( ( ) => {
88
101
const newSelectedValues = selectedValues . slice ( 0 , maxCount ) ;
89
102
onSelectedValuesChange ( newSelectedValues ) ;
90
103
} , [ selectedValues , onSelectedValuesChange , maxCount ] ) ;
91
104
92
105
// show 50 initially and then 20 more when reaching the end
93
106
const { itemsToShow, lastItemRef } = useShowMore < HTMLButtonElement > ( 50 , 20 ) ;
94
107
95
- const optionsToShow = React . useMemo ( ( ) => {
108
+ const { overrideSearchFn } = props ;
109
+
110
+ const optionsToShow = useMemo ( ( ) => {
111
+ const filteredOptions : {
112
+ label : string ;
113
+ value : string ;
114
+ } [ ] = [ ] ;
115
+
96
116
const searchValLowercase = searchValue . toLowerCase ( ) ;
97
- const filteredOptions = options . filter ( ( option ) => {
98
- return option . label . toLowerCase ( ) . includes ( searchValLowercase ) ;
99
- } ) ;
100
117
101
- return filteredOptions . slice ( 0 , itemsToShow ) ;
102
- } , [ options , searchValue , itemsToShow ] ) ;
118
+ for ( let i = 0 ; i <= options . length - 1 ; i ++ ) {
119
+ if ( filteredOptions . length >= itemsToShow ) {
120
+ break ;
121
+ }
122
+ if ( overrideSearchFn ) {
123
+ if ( overrideSearchFn ( options [ i ] , searchValLowercase ) ) {
124
+ filteredOptions . push ( options [ i ] ) ;
125
+ }
126
+ } else {
127
+ if ( options [ i ] . label . toLowerCase ( ) . includes ( searchValLowercase ) ) {
128
+ filteredOptions . push ( options [ i ] ) ;
129
+ }
130
+ }
131
+ }
132
+
133
+ return filteredOptions ;
134
+ } , [ options , searchValue , itemsToShow , overrideSearchFn ] ) ;
135
+
136
+ // scroll to top when options change
137
+ const popoverElRef = useRef < HTMLDivElement > ( null ) ;
138
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
139
+ useEffect ( ( ) => {
140
+ const scrollContainer =
141
+ popoverElRef . current ?. querySelector ( "[data-scrollable]" ) ;
142
+ if ( scrollContainer ) {
143
+ scrollContainer . scrollTo ( {
144
+ top : 0 ,
145
+ } ) ;
146
+ }
147
+ } , [ searchValue ] ) ;
103
148
104
149
return (
105
150
< Popover open = { isPopoverOpen } onOpenChange = { setIsPopoverOpen } >
@@ -109,7 +154,7 @@ export const MultiSelect = React.forwardRef<
109
154
{ ...props }
110
155
onClick = { handleTogglePopover }
111
156
className = { cn (
112
- "flex h-auto min-h-10 w-full items-center justify-between rounded-md border bg-inherit p-3 hover:bg-inherit" ,
157
+ "flex h-auto min-h-10 w-full items-center justify-between rounded-md border border-border bg-inherit p-3 hover:bg-inherit" ,
113
158
className ,
114
159
) }
115
160
>
@@ -119,10 +164,14 @@ export const MultiSelect = React.forwardRef<
119
164
< div className = "flex flex-wrap items-center gap-1.5" >
120
165
{ selectedValues . slice ( 0 , maxCount ) . map ( ( value ) => {
121
166
const option = options . find ( ( o ) => o . value === value ) ;
167
+ if ( ! option ) {
168
+ return null ;
169
+ }
170
+
122
171
return (
123
172
< ClosableBadge
124
173
key = { value }
125
- label = { option ? .label || "" }
174
+ label = { option . label }
126
175
onClose = { ( ) => toggleOption ( value ) }
127
176
/>
128
177
) ;
@@ -170,20 +219,21 @@ export const MultiSelect = React.forwardRef<
170
219
</ Button >
171
220
</ PopoverTrigger >
172
221
< PopoverContent
173
- className = "p-0"
222
+ className = "z-[10001] p-0"
174
223
align = "center"
175
224
sideOffset = { 10 }
176
225
onEscapeKeyDown = { ( ) => setIsPopoverOpen ( false ) }
177
226
style = { {
178
227
width : "var(--radix-popover-trigger-width)" ,
179
228
maxHeight : "var(--radix-popover-content-available-height)" ,
180
229
} }
230
+ ref = { popoverElRef }
181
231
>
182
232
< div >
183
233
{ /* Search */ }
184
234
< div className = "relative" >
185
235
< Input
186
- placeholder = "Search"
236
+ placeholder = { props . searchPlaceholder || "Search" }
187
237
value = { searchValue }
188
238
onChange = { ( e ) => setSearchValue ( e . target . value ) }
189
239
className = "!h-auto rounded-b-none border-0 border-border border-b py-4 pl-10 focus-visible:ring-0 focus-visible:ring-offset-0"
@@ -193,7 +243,7 @@ export const MultiSelect = React.forwardRef<
193
243
</ div >
194
244
195
245
< ScrollShadow
196
- scrollableClassName = "max-h-[350px] p-1"
246
+ scrollableClassName = "max-h-[min(calc(var(--radix-popover-content-available-height)-60px), 350px) ] p-1"
197
247
className = "rounded"
198
248
>
199
249
{ /* List */ }
@@ -213,7 +263,7 @@ export const MultiSelect = React.forwardRef<
213
263
aria-selected = { isSelected }
214
264
onClick = { ( ) => toggleOption ( option . value ) }
215
265
variant = "ghost"
216
- className = "flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2"
266
+ className = "flex w-full cursor-pointer justify-start gap-3 rounded-sm px-3 py-2 text-left "
217
267
ref = {
218
268
i === optionsToShow . length - 1 ? lastItemRef : undefined
219
269
}
@@ -229,7 +279,11 @@ export const MultiSelect = React.forwardRef<
229
279
< CheckIcon className = "size-4" />
230
280
</ div >
231
281
232
- < span > { option . label } </ span >
282
+ < div className = "min-w-0 grow" >
283
+ { props . renderOption
284
+ ? props . renderOption ( option )
285
+ : option . label }
286
+ </ div >
233
287
</ Button >
234
288
) ;
235
289
} ) }
0 commit comments