1- import React , { useState , useRef , useEffect } from "react" ;
1+ import React , {
2+ useState ,
3+ useRef ,
4+ useEffect ,
5+ useMemo ,
6+ useLayoutEffect ,
7+ } from "react" ;
28import { commonStyles } from "../styles/common" ;
39
410interface SearchableSelectProps {
@@ -29,22 +35,29 @@ const SearchableSelect = React.forwardRef<
2935 ) => {
3036 const [ isOpen , setIsOpen ] = useState ( false ) ;
3137 const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
32- const [ filteredOptions , setFilteredOptions ] = useState ( options ) ;
38+ // Memoize filtering to avoid extra state updates and re-renders
39+ const filteredOptions = useMemo ( ( ) => {
40+ const lowered = searchTerm . toLowerCase ( ) ;
41+ if ( ! lowered ) return options ;
42+ return options . filter (
43+ ( option ) =>
44+ option . label . toLowerCase ( ) . includes ( lowered ) ||
45+ option . value . toLowerCase ( ) . includes ( lowered )
46+ ) ;
47+ } , [ searchTerm , options ] ) ;
3348 const [ dropdownPosition , setDropdownPosition ] = useState < "left" | "right" > (
3449 "left"
3550 ) ;
51+ const [ dropdownWidth , setDropdownWidth ] = useState < number | undefined > (
52+ undefined
53+ ) ;
3654 const [ highlightedIndex , setHighlightedIndex ] = useState ( - 1 ) ;
3755 const containerRef = useRef < HTMLDivElement > ( null ) ;
3856 const inputRef = useRef < HTMLInputElement > ( null ) ;
3957
58+ // Reset highlighted index when the search or options change
4059 useEffect ( ( ) => {
41- const filtered = options . filter (
42- ( option ) =>
43- option . label . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) ||
44- option . value . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) )
45- ) ;
46- setFilteredOptions ( filtered ) ;
47- setHighlightedIndex ( - 1 ) ; // Reset highlighted index when options change
60+ setHighlightedIndex ( - 1 ) ;
4861 } , [ searchTerm , options ] ) ;
4962
5063 useEffect ( ( ) => {
@@ -100,24 +113,50 @@ const SearchableSelect = React.forwardRef<
100113 }
101114 } ;
102115
103- const calculateDropdownPosition = ( ) => {
104- if ( ! containerRef . current ) return "left" ;
116+ // Compute side and width so the dropdown can be wider than the trigger but still fit viewport
117+ const computeDropdownLayout = ( ) : {
118+ side : "left" | "right" ;
119+ width : number ;
120+ } => {
121+ if ( ! containerRef . current ) return { side : "left" , width : 240 } ;
105122
106123 const rect = containerRef . current . getBoundingClientRect ( ) ;
107124 const windowWidth = window . innerWidth ;
108- const estimatedDropdownWidth = 300 ; // Approximate width for dropdown content
125+ const VIEWPORT_MARGIN = 16 ; // px
126+ const EXTRA_WIDTH = 240 ; // desired extra room beyond trigger
127+ const MAX_WIDTH = 600 ; // hard cap
128+
129+ const desired = Math . min ( rect . width + EXTRA_WIDTH , MAX_WIDTH ) ;
109130
110- // If dropdown would overflow right edge, position it to the left
111- if ( rect . left + estimatedDropdownWidth > windowWidth ) {
112- return "right" ;
131+ const spaceRight = windowWidth - rect . left - VIEWPORT_MARGIN ; // space if anchored left-0
132+ const spaceLeft = rect . right - VIEWPORT_MARGIN ; // space if anchored right-0
133+
134+ const widthRight = Math . max ( rect . width , Math . min ( desired , spaceRight ) ) ;
135+ const widthLeft = Math . max ( rect . width , Math . min ( desired , spaceLeft ) ) ;
136+
137+ // Prefer the side that can accommodate closer to desired width
138+ if ( widthRight >= widthLeft && widthRight >= rect . width ) {
139+ return { side : "left" , width : widthRight } ;
113140 }
114- return "left" ;
141+ if ( widthLeft > widthRight && widthLeft >= rect . width ) {
142+ return { side : "right" , width : widthLeft } ;
143+ }
144+
145+ // Fallback: clamp to viewport with preference to right anchoring if less overflow
146+ const clamped = Math . max (
147+ rect . width ,
148+ Math . min ( desired , windowWidth - VIEWPORT_MARGIN * 2 )
149+ ) ;
150+ const preferRight = rect . left < windowWidth - rect . right ;
151+ return { side : preferRight ? "left" : "right" , width : clamped } ;
115152 } ;
116153
117154 const handleToggle = ( ) => {
118155 if ( ! disabled ) {
119156 if ( ! isOpen ) {
120- setDropdownPosition ( calculateDropdownPosition ( ) ) ;
157+ const layout = computeDropdownLayout ( ) ;
158+ setDropdownPosition ( layout . side ) ;
159+ setDropdownWidth ( layout . width ) ;
121160 }
122161 setIsOpen ( ! isOpen ) ;
123162 if ( ! isOpen ) {
@@ -126,7 +165,62 @@ const SearchableSelect = React.forwardRef<
126165 }
127166 } ;
128167
129- const selectedOption = options . find ( ( option ) => option . value === value ) ;
168+ const selectedOption = useMemo (
169+ ( ) => options . find ( ( option ) => option . value === value ) ,
170+ [ options , value ]
171+ ) ;
172+
173+ // --- Simple list virtualization for large option sets ---
174+ const listRef = useRef < HTMLDivElement > ( null ) ;
175+ const [ scrollTop , setScrollTop ] = useState ( 0 ) ;
176+ const [ containerHeight , setContainerHeight ] = useState ( 192 ) ; // Tailwind max-h-48 (~192px)
177+
178+ const ITEM_HEIGHT = 32 ; // Approximate item height in px
179+ const OVERSCAN = 5 ;
180+
181+ const totalItems = filteredOptions . length ;
182+ const visibleCount = Math . max ( 1 , Math . ceil ( containerHeight / ITEM_HEIGHT ) ) ;
183+ const startIndex = Math . max (
184+ 0 ,
185+ Math . floor ( scrollTop / ITEM_HEIGHT ) - OVERSCAN
186+ ) ;
187+ const endIndex = Math . min (
188+ totalItems ,
189+ startIndex + visibleCount + OVERSCAN * 2
190+ ) ;
191+ const topPaddingHeight = startIndex * ITEM_HEIGHT ;
192+ const bottomPaddingHeight = Math . max (
193+ 0 ,
194+ ( totalItems - endIndex ) * ITEM_HEIGHT
195+ ) ;
196+
197+ // Measure container height when open and on resize
198+ useLayoutEffect ( ( ) => {
199+ if ( ! isOpen ) return ;
200+ const el = listRef . current ;
201+ if ( ! el ) return ;
202+
203+ const measure = ( ) => {
204+ setContainerHeight ( el . clientHeight || 192 ) ;
205+ } ;
206+ measure ( ) ;
207+
208+ const ro = new ResizeObserver ( measure ) ;
209+ ro . observe ( el ) ;
210+ return ( ) => ro . disconnect ( ) ;
211+ } , [ isOpen ] ) ;
212+
213+ // Reset scroll when opening or when search changes
214+ useEffect ( ( ) => {
215+ if ( isOpen && listRef . current ) {
216+ listRef . current . scrollTop = 0 ;
217+ setScrollTop ( 0 ) ;
218+ }
219+ } , [ isOpen , searchTerm ] ) ;
220+
221+ const handleScroll = ( e : React . UIEvent < HTMLDivElement > ) => {
222+ setScrollTop ( ( e . target as HTMLDivElement ) . scrollTop ) ;
223+ } ;
130224
131225 return (
132226 < div ref = { containerRef } className = { `relative ${ className } ` } >
@@ -151,7 +245,10 @@ const SearchableSelect = React.forwardRef<
151245 ` }
152246 style = { { boxShadow : commonStyles . input . shadow } }
153247 >
154- < span className = { value ? "text-gray-900" : "text-gray-400" } >
248+ < span
249+ className = { value ? "text-gray-900" : "text-gray-400" }
250+ title = { selectedOption ? selectedOption . label : placeholder }
251+ >
155252 { selectedOption ? selectedOption . label : placeholder }
156253 </ span >
157254 < svg
@@ -173,13 +270,13 @@ const SearchableSelect = React.forwardRef<
173270
174271 { isOpen && (
175272 < div
176- className = { `absolute z-50 w-max min-w-full mt-1 ${
273+ className = { `absolute z-50 mt-1 ${
177274 commonStyles . input . base
178- } max-h-60 overflow-auto ${
275+ } max-h-60 overflow-x-hidden ${
179276 dropdownPosition === "right" ? "right-0" : "left-0"
180277 } `}
181278 style = { {
182- maxWidth : `min(calc(100vw - 2rem), 500px)` ,
279+ width : dropdownWidth ,
183280 right : dropdownPosition === "right" ? "0" : undefined ,
184281 left : dropdownPosition === "left" ? "0" : undefined ,
185282 boxShadow : commonStyles . input . shadow ,
@@ -196,33 +293,57 @@ const SearchableSelect = React.forwardRef<
196293 onChange = { ( e ) => setSearchTerm ( e . target . value ) }
197294 onKeyDown = { handleKeyDown }
198295 placeholder = "Search..."
199- className = { `${ commonStyles . input . base } ${ commonStyles . input . size . sm } w-full min-w-48 ` }
296+ className = { `${ commonStyles . input . base } ${ commonStyles . input . size . sm } w-full` }
200297 style = { { boxShadow : commonStyles . input . shadow } }
201298 role = "searchbox"
202299 aria-label = "Search options"
203300 />
204301 </ div >
205302 < div
206- className = "max-h-48 overflow-auto"
303+ ref = { listRef }
304+ className = "max-h-48 overflow-y-auto overflow-x-hidden"
207305 role = "listbox"
208306 aria-label = "Options"
307+ onScroll = { handleScroll }
209308 >
210- { filteredOptions . length > 0 ? (
211- filteredOptions . map ( ( option , index ) => (
212- < div
213- key = { option . value }
214- onClick = { ( ) => handleSelect ( option . value ) }
215- onMouseEnter = { ( ) => setHighlightedIndex ( index ) }
216- className = { `px-3 py-2 text-xs font-medium cursor-pointer hover:bg-gray-100 text-gray-700 border-b border-gray-100 last:border-b-0 ${
217- highlightedIndex === index ? "bg-gray-100" : ""
218- } `}
219- role = "option"
220- aria-selected = { highlightedIndex === index }
221- tabIndex = { - 1 }
222- >
223- { option . label }
224- </ div >
225- ) )
309+ { totalItems > 0 ? (
310+ < >
311+ { topPaddingHeight > 0 && (
312+ < div style = { { height : topPaddingHeight } } />
313+ ) }
314+ { filteredOptions
315+ . slice ( startIndex , endIndex )
316+ . map ( ( option , i ) => {
317+ const absoluteIndex = startIndex + i ;
318+ return (
319+ < div
320+ key = { option . value }
321+ onClick = { ( ) => handleSelect ( option . value ) }
322+ onMouseEnter = { ( ) =>
323+ setHighlightedIndex ( absoluteIndex )
324+ }
325+ className = { `px-3 py-2 text-xs font-medium cursor-pointer hover:bg-gray-100 text-gray-700 border-b border-gray-100 last:border-b-0 ${
326+ highlightedIndex === absoluteIndex
327+ ? "bg-gray-100"
328+ : ""
329+ } `}
330+ role = "option"
331+ aria-selected = { highlightedIndex === absoluteIndex }
332+ aria-label = { option . label }
333+ tabIndex = { - 1 }
334+ style = { { height : ITEM_HEIGHT } }
335+ title = { option . label }
336+ >
337+ < div className = "overflow-x-auto whitespace-nowrap" >
338+ { option . label }
339+ </ div >
340+ </ div >
341+ ) ;
342+ } ) }
343+ { bottomPaddingHeight > 0 && (
344+ < div style = { { height : bottomPaddingHeight } } />
345+ ) }
346+ </ >
226347 ) : (
227348 < div className = "px-3 py-2 text-xs font-medium text-gray-500" >
228349 No options found
0 commit comments