33import { useResizeObserver } from "@fern-ui/react-commons" ;
44
55import * as DropdownMenu from "@radix-ui/react-dropdown-menu" ;
6- import { Check , Info } from "lucide-react" ;
6+ import { Check , Info , Search } from "lucide-react" ;
77import {
88 type ComponentProps ,
99 cloneElement ,
@@ -84,6 +84,7 @@ export declare namespace FernDropdown {
8484 } ;
8585 triggerAsChild ?: boolean ;
8686 radioGroupProps ?: ComponentProps < typeof DropdownMenu . RadioGroup > ;
87+ searchable ?: boolean ;
8788 }
8889}
8990
@@ -105,19 +106,33 @@ export const FernDropdown = forwardRef<HTMLButtonElement, PropsWithChildren<Fern
105106 onClick,
106107 contentProps,
107108 triggerAsChild = true ,
108- radioGroupProps = { }
109+ radioGroupProps = { } ,
110+ searchable = false
109111 } ,
110112 ref
111113 ) : ReactElement => {
112114 const [ isOpen , setOpen ] = useState ( defaultOpen ) ;
115+ const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
116+ const searchInputRef = useRef < HTMLInputElement > ( null ) ;
117+
113118 const handleOpenChange = useCallback (
114119 ( toOpen : boolean ) => {
115120 setOpen ( toOpen ) ;
116- if ( toOpen && onOpen != null ) {
117- onOpen ( ) ;
121+ if ( toOpen ) {
122+ if ( onOpen != null ) {
123+ onOpen ( ) ;
124+ }
125+ // Reset search when opening
126+ setSearchTerm ( "" ) ;
127+ // Focus search input when opening if searchable
128+ if ( searchable ) {
129+ setTimeout ( ( ) => {
130+ searchInputRef . current ?. focus ( ) ;
131+ } , 0 ) ;
132+ }
118133 }
119134 } ,
120- [ onOpen ]
135+ [ onOpen , searchable ]
121136 ) ;
122137
123138 const isValueSelected = useCallback (
@@ -126,81 +141,142 @@ export const FernDropdown = forwardRef<HTMLButtonElement, PropsWithChildren<Fern
126141 } ,
127142 [ value ]
128143 ) ;
129- const renderDropdownContent = ( ) => (
130- < DropdownMenu . Content
131- sideOffset = { 4 }
132- collisionPadding = { 4 }
133- side = { side }
134- align = { align }
135- hideWhenDetached
136- { ...contentProps }
137- className = { cn ( "fern-dropdown [&_svg]:size-icon" , contentProps ?. className ) }
138- >
139- < FernTooltipProvider >
140- < FernScrollArea rootClassName = "min-h-0 shrink" className = "p-1" scrollbars = "vertical" >
141- { Array . isArray ( value ) ? (
142- < div onClick = { onClick } >
143- { options . map ( ( option , idx ) =>
144- option . type === "value" ? (
145- < FernDropdownItemMultiSelect
146- key = { option . value }
147- option = { option }
148- isSelected = { isValueSelected ( option . value ) }
149- onToggle = {
150- onValueChange ??
151- ( ( ) => {
152- void 0 ;
153- } )
154- }
155- dropdownMenuElement = { dropdownMenuElement }
156- container = { container }
157- />
158- ) : option . type === "separator" ? (
159- < DropdownMenu . Separator
160- key = { idx }
161- className = "bg-border-default mx-2 my-1 h-px"
162- />
163- ) : null
164- ) }
144+
145+ // filter options based on search term
146+ const filteredOptions = useCallback ( ( ) => {
147+ if ( ! searchable || ! searchTerm . trim ( ) ) {
148+ return options ;
149+ }
150+
151+ const lowerSearchTerm = searchTerm . toLowerCase ( ) ;
152+ return options . filter ( ( option ) => {
153+ if ( option . type === "separator" ) {
154+ return true ; // Always include separators
155+ }
156+ if ( option . type === "auth" ) {
157+ return (
158+ option . key . toLowerCase ( ) . includes ( lowerSearchTerm ) ||
159+ option . value . toLowerCase ( ) . includes ( lowerSearchTerm )
160+ ) ;
161+ }
162+ if ( option . type === "product" ) {
163+ return (
164+ option . title . toLowerCase ( ) . includes ( lowerSearchTerm ) ||
165+ ( option . subtitle ?. toLowerCase ( ) . includes ( lowerSearchTerm ) ?? false ) ||
166+ option . value . toLowerCase ( ) . includes ( lowerSearchTerm )
167+ ) ;
168+ }
169+ if ( option . type === "value" ) {
170+ const labelText = typeof option . label === "string" ? option . label : option . value ;
171+ const helperText = typeof option . helperText === "string" ? option . helperText : "" ;
172+ return (
173+ labelText . toLowerCase ( ) . includes ( lowerSearchTerm ) ||
174+ helperText . toLowerCase ( ) . includes ( lowerSearchTerm ) ||
175+ option . value . toLowerCase ( ) . includes ( lowerSearchTerm )
176+ ) ;
177+ }
178+ return false ;
179+ } ) ;
180+ } , [ searchable , searchTerm , options ] ) ;
181+
182+ const renderDropdownContent = ( ) => {
183+ const optionsToRender = filteredOptions ( ) ;
184+
185+ return (
186+ < DropdownMenu . Content
187+ sideOffset = { 4 }
188+ collisionPadding = { 4 }
189+ side = { side }
190+ align = { align }
191+ hideWhenDetached
192+ { ...contentProps }
193+ className = { cn ( "fern-dropdown [&_svg]:size-icon" , contentProps ?. className ) }
194+ >
195+ < FernTooltipProvider >
196+ { searchable && (
197+ < div className = "border-border-default border-b p-2" >
198+ < div className = "relative flex items-center" >
199+ < Search className = "text-text-muted absolute left-2 size-4" />
200+ < input
201+ ref = { searchInputRef }
202+ type = "text"
203+ value = { searchTerm }
204+ onChange = { ( e ) => setSearchTerm ( e . target . value ) }
205+ placeholder = "Search..."
206+ className = "bg-background-default text-text-primary placeholder:text-text-muted w-full rounded border-none py-1.5 pl-8 pr-2 text-sm outline-none"
207+ onKeyDown = { ( e ) => {
208+ // Prevent dropdown from closing on key events
209+ e . stopPropagation ( ) ;
210+ } }
211+ />
212+ </ div >
165213 </ div >
166- ) : (
167- < DropdownMenu . RadioGroup
168- value = { value }
169- onValueChange = { onValueChange }
170- onClick = { onClick }
171- { ...radioGroupProps }
172- >
173- { options . map ( ( option , idx ) =>
174- option . type === "value" ? (
175- < FernDropdownItemValue
176- key = { option . value }
177- option = { option }
178- value = { value }
179- dropdownMenuElement = { dropdownMenuElement }
180- container = { container }
181- />
182- ) : option . type === "product" ? (
183- < FernProductItem key = { option . id } option = { option } dense = { option . dense } />
184- ) : option . type === "auth" ? (
185- < FernDropdownItemAuth key = { option . key } option = { option } />
186- ) : option . type === "separator" ? (
187- < DropdownMenu . Separator
188- key = { idx }
189- className = "bg-border-default mx-2 my-1 h-px"
190- />
191- ) : (
192- < DropdownMenu . Separator
193- key = { idx }
194- className = "bg-border-default mx-2 my-1 h-px"
195- />
196- )
197- ) }
198- </ DropdownMenu . RadioGroup >
199214 ) }
200- </ FernScrollArea >
201- </ FernTooltipProvider >
202- </ DropdownMenu . Content >
203- ) ;
215+ < FernScrollArea rootClassName = "min-h-0 shrink" className = "p-1" scrollbars = "vertical" >
216+ { Array . isArray ( value ) ? (
217+ < div onClick = { onClick } >
218+ { optionsToRender . map ( ( option , idx ) =>
219+ option . type === "value" ? (
220+ < FernDropdownItemMultiSelect
221+ key = { option . value }
222+ option = { option }
223+ isSelected = { isValueSelected ( option . value ) }
224+ onToggle = {
225+ onValueChange ??
226+ ( ( ) => {
227+ void 0 ;
228+ } )
229+ }
230+ dropdownMenuElement = { dropdownMenuElement }
231+ container = { container }
232+ />
233+ ) : option . type === "separator" ? (
234+ < DropdownMenu . Separator
235+ key = { idx }
236+ className = "bg-border-default mx-2 my-1 h-px"
237+ />
238+ ) : null
239+ ) }
240+ </ div >
241+ ) : (
242+ < DropdownMenu . RadioGroup
243+ value = { value }
244+ onValueChange = { onValueChange }
245+ onClick = { onClick }
246+ { ...radioGroupProps }
247+ >
248+ { optionsToRender . map ( ( option , idx ) =>
249+ option . type === "value" ? (
250+ < FernDropdownItemValue
251+ key = { option . value }
252+ option = { option }
253+ value = { value }
254+ dropdownMenuElement = { dropdownMenuElement }
255+ container = { container }
256+ />
257+ ) : option . type === "product" ? (
258+ < FernProductItem key = { option . id } option = { option } dense = { option . dense } />
259+ ) : option . type === "auth" ? (
260+ < FernDropdownItemAuth key = { option . key } option = { option } />
261+ ) : option . type === "separator" ? (
262+ < DropdownMenu . Separator
263+ key = { idx }
264+ className = "bg-border-default mx-2 my-1 h-px"
265+ />
266+ ) : (
267+ < DropdownMenu . Separator
268+ key = { idx }
269+ className = "bg-border-default mx-2 my-1 h-px"
270+ />
271+ )
272+ ) }
273+ </ DropdownMenu . RadioGroup >
274+ ) }
275+ </ FernScrollArea >
276+ </ FernTooltipProvider >
277+ </ DropdownMenu . Content >
278+ ) ;
279+ } ;
204280
205281 return (
206282 < DropdownMenu . Root onOpenChange = { handleOpenChange } open = { isOpen } modal = { false } defaultOpen = { defaultOpen } >
0 commit comments