1111// See the License for the specific language governing permissions and
1212// limitations under the License.
1313
14- import React , { Fragment , useCallback , useEffect , useRef , useState } from 'react' ;
14+ import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1515
1616import { Icon } from '@iconify/react' ;
17+ import { useVirtualizer } from '@tanstack/react-virtual' ;
1718import cx from 'classnames' ;
1819import levenshtein from 'fast-levenshtein' ;
1920
@@ -84,11 +85,11 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
8485 const [ isOpen , setIsOpen ] = useState ( false ) ;
8586 const [ focusedIndex , setFocusedIndex ] = useState ( - 1 ) ;
8687 const [ searchTerm , setSearchTerm ] = useState ( '' ) ;
88+ const [ debouncedSearchTerm , setDebouncedSearchTerm ] = useState ( '' ) ;
8789 const [ isRefetching , setIsRefetching ] = useState ( false ) ;
8890 const containerRef = useRef < HTMLDivElement > ( null ) ;
8991 const optionsRef = useRef < HTMLDivElement > ( null ) ;
9092 const searchInputRef = useRef < HTMLInputElement | HTMLTextAreaElement > ( null ) ;
91- const optionRefs = useRef < Array < HTMLElement | null > > ( [ ] ) ;
9293
9394 const handleRefetch = useCallback ( async ( ) => {
9495 if ( refetchValues == null || isRefetching ) return ;
@@ -101,30 +102,34 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
101102 }
102103 } , [ refetchValues , isRefetching ] ) ;
103104
104- let items : TypedSelectItem [ ] = [ ] ;
105- if ( itemsProp [ 0 ] != null && 'type' in itemsProp [ 0 ] ) {
106- items = ( itemsProp as GroupedSelectItem [ ] ) . flatMap ( item =>
107- item . values . map ( v => ( { ...v , type : item . type } ) )
105+ useEffect ( ( ) => {
106+ const timer = setTimeout ( ( ) => setDebouncedSearchTerm ( searchTerm ) , 150 ) ;
107+ return ( ) => clearTimeout ( timer ) ;
108+ } , [ searchTerm ] ) ;
109+
110+ const items = useMemo < TypedSelectItem [ ] > ( ( ) => {
111+ if ( itemsProp [ 0 ] != null && 'type' in itemsProp [ 0 ] ) {
112+ return ( itemsProp as GroupedSelectItem [ ] ) . flatMap ( item =>
113+ item . values . map ( v => ( { ...v , type : item . type } ) )
114+ ) ;
115+ }
116+ return ( itemsProp as SelectItem [ ] ) . map ( item => ( { ...item , type : '' } ) ) ;
117+ } , [ itemsProp ] ) ;
118+
119+ const filteredItems = useMemo ( ( ) => {
120+ if ( ! searchable ) return items ;
121+ const lowerSearch = debouncedSearchTerm . toLowerCase ( ) ;
122+ const filtered = items . filter ( item =>
123+ item . element . active . props . children . toString ( ) . toLowerCase ( ) . includes ( lowerSearch )
124+ ) ;
125+ if ( debouncedSearchTerm === '' ) {
126+ return filtered . sort ( ( a , b ) => a . key . localeCompare ( b . key ) ) ;
127+ }
128+ return filtered . sort (
129+ ( a , b ) =>
130+ levenshtein . get ( a . key , debouncedSearchTerm ) - levenshtein . get ( b . key , debouncedSearchTerm )
108131 ) ;
109- } else {
110- items = ( itemsProp as SelectItem [ ] ) . map ( item => ( { ...item , type : '' } ) ) ;
111- }
112-
113- const filteredItems = searchable
114- ? items
115- . filter ( item =>
116- item . element . active . props . children
117- . toString ( )
118- . toLowerCase ( )
119- . includes ( searchTerm . toLowerCase ( ) )
120- )
121- . sort ( ( a , b ) => {
122- if ( searchTerm === '' ) {
123- return a . key . localeCompare ( b . key ) ;
124- }
125- return levenshtein . get ( a . key , searchTerm ) - levenshtein . get ( b . key , searchTerm ) ;
126- } )
127- : items ;
132+ } , [ items , debouncedSearchTerm , searchable ] ) ;
128133
129134 const selection = editable ? selectedKey : items . find ( v => v . key === selectedKey ) ;
130135
@@ -147,28 +152,6 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
147152 }
148153 } , [ isOpen , searchable ] ) ;
149154
150- useEffect ( ( ) => {
151- if (
152- focusedIndex !== - 1 &&
153- optionsRef . current !== null &&
154- optionRefs . current [ focusedIndex ] !== null
155- ) {
156- const optionElement = optionRefs . current [ focusedIndex ] ;
157- const optionsContainer = optionsRef . current ;
158-
159- if ( optionElement !== null && optionsContainer !== null ) {
160- const optionRect = optionElement . getBoundingClientRect ( ) ;
161- const containerRect = optionsContainer . getBoundingClientRect ( ) ;
162-
163- if ( optionRect . bottom > containerRect . bottom ) {
164- optionsContainer . scrollTop += optionRect . bottom - containerRect . bottom ;
165- } else if ( optionRect . top < containerRect . top ) {
166- optionsContainer . scrollTop -= containerRect . top - optionRect . top ;
167- }
168- }
169- }
170- } , [ focusedIndex ] ) ;
171-
172155 const handleKeyDown = ( e : React . KeyboardEvent ) : void => {
173156 if ( e . key === 'Enter' ) {
174157 if ( ! isOpen ) {
@@ -245,17 +228,66 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
245228 e . target . value = value ;
246229 } ;
247230
248- const groupedFilteredItems = filteredItems
249- . reduce ( ( acc : GroupedSelectItem [ ] , item ) => {
250- const group = acc . find ( g => g . type === item . type ) ;
251- if ( group != null ) {
252- group . values . push ( item ) ;
253- } else {
254- acc . push ( { type : item . type , values : [ item ] } ) ;
231+ const groupedFilteredItems = useMemo ( ( ) => {
232+ return filteredItems
233+ . reduce ( ( acc : GroupedSelectItem [ ] , item ) => {
234+ const group = acc . find ( g => g . type === item . type ) ;
235+ if ( group != null ) {
236+ group . values . push ( item ) ;
237+ } else {
238+ acc . push ( { type : item . type , values : [ item ] } ) ;
239+ }
240+ return acc ;
241+ } , [ ] )
242+ . sort ( ( a , b ) => a . values . length - b . values . length ) ;
243+ } , [ filteredItems ] ) ;
244+
245+ const showHeaders = useMemo (
246+ ( ) => groupedFilteredItems . length > 1 && groupedFilteredItems . every ( g => g . type !== '' ) ,
247+ [ groupedFilteredItems ]
248+ ) ;
249+
250+ const flatList = useMemo ( ( ) => {
251+ const list : Array <
252+ { type : 'header' ; label : string } | { type : 'option' ; item : TypedSelectItem ; flatIndex : number }
253+ > = [ ] ;
254+ let optionIndex = 0 ;
255+ for ( const group of groupedFilteredItems ) {
256+ if ( showHeaders && group . type !== '' ) {
257+ list . push ( { type : 'header' , label : group . type } ) ;
255258 }
256- return acc ;
257- } , [ ] )
258- . sort ( ( a , b ) => a . values . length - b . values . length ) ;
259+ for ( const item of group . values ) {
260+ list . push ( { type : 'option' , item : item as TypedSelectItem , flatIndex : optionIndex } ) ;
261+ optionIndex ++ ;
262+ }
263+ }
264+ return list ;
265+ } , [ groupedFilteredItems , showHeaders ] ) ;
266+
267+ const longestKey = useMemo (
268+ ( ) =>
269+ filteredItems . reduce ( ( a , b ) => ( a . key . length > b . key . length ? a : b ) , filteredItems [ 0 ] )
270+ ?. key ?? '' ,
271+ [ filteredItems ]
272+ ) ;
273+
274+ const rowVirtualizer = useVirtualizer ( {
275+ count : flatList . length ,
276+ getScrollElement : ( ) => optionsRef . current ,
277+ estimateSize : ( ) => 36 ,
278+ overscan : 500 ,
279+ } ) ;
280+
281+ useEffect ( ( ) => {
282+ if ( focusedIndex !== - 1 ) {
283+ const flatIdx = flatList . findIndex (
284+ entry => entry . type === 'option' && entry . flatIndex === focusedIndex
285+ ) ;
286+ if ( flatIdx !== - 1 ) {
287+ rowVirtualizer . scrollToIndex ( flatIdx , { align : 'auto' } ) ;
288+ }
289+ }
290+ } , [ focusedIndex , flatList , rowVirtualizer ] ) ;
259291
260292 return (
261293 < div ref = { containerRef } className = "relative" onKeyDown = { handleKeyDown } onClick = { onButtonClick } >
@@ -349,28 +381,45 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
349381 No values found
350382 </ div >
351383 ) : (
352- groupedFilteredItems . map ( group => (
353- < Fragment key = { group . type } >
354- { groupedFilteredItems . length > 1 &&
355- groupedFilteredItems . every ( g => g . type !== '' ) &&
356- group . type !== '' ? (
357- < div className = "pl-2" >
358- < DividerWithLabel label = { group . type } />
384+ ( ( ) => {
385+ const virtualItems = rowVirtualizer . getVirtualItems ( ) ;
386+ const paddingTop = virtualItems . length > 0 ? virtualItems [ 0 ] ?. start ?? 0 : 0 ;
387+ const paddingBottom =
388+ virtualItems . length > 0
389+ ? rowVirtualizer . getTotalSize ( ) -
390+ ( virtualItems [ virtualItems . length - 1 ] ?. end ?? 0 )
391+ : 0 ;
392+ return (
393+ < div >
394+ < div
395+ aria-hidden
396+ className = "pl-3 pr-9 whitespace-nowrap overflow-hidden"
397+ style = { { height : 0 , visibility : 'hidden' } }
398+ >
399+ { longestKey }
359400 </ div >
360- ) : null }
361- { group . values . map ( ( item , index ) => (
362- < OptionItem
363- key = { item . key }
364- item = { item }
365- index = { index }
366- optionRefs = { optionRefs }
367- focusedIndex = { focusedIndex }
368- selectedKey = { selectedKey }
369- handleSelection = { handleSelection }
370- />
371- ) ) }
372- </ Fragment >
373- ) )
401+ { paddingTop > 0 && < div style = { { height : paddingTop } } /> }
402+ { virtualItems . map ( virtualItem => {
403+ const entry = flatList [ virtualItem . index ] ;
404+ return entry . type === 'header' ? (
405+ < div key = { virtualItem . key } className = "pl-2" >
406+ < DividerWithLabel label = { entry . label } />
407+ </ div >
408+ ) : (
409+ < OptionItem
410+ key = { virtualItem . key }
411+ item = { entry . item }
412+ index = { entry . flatIndex }
413+ focusedIndex = { focusedIndex }
414+ selectedKey = { selectedKey }
415+ handleSelection = { handleSelection }
416+ />
417+ ) ;
418+ } ) }
419+ { paddingBottom > 0 && < div style = { { height : paddingBottom } } /> }
420+ </ div >
421+ ) ;
422+ } ) ( )
374423 ) }
375424 </ div >
376425 { refetchValues !== undefined && loading !== true && (
@@ -392,26 +441,19 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
392441
393442const OptionItem = ( {
394443 item,
395- optionRefs,
396444 index,
397445 focusedIndex,
398446 selectedKey,
399447 handleSelection,
400448} : {
401449 item : SelectItem ;
402- optionRefs : React . MutableRefObject < Array < HTMLElement | null > > ;
403450 index : number ;
404451 focusedIndex : number ;
405452 selectedKey : string | undefined ;
406453 handleSelection : ( value : string ) => void ;
407454} ) : JSX . Element => {
408455 return (
409456 < div
410- ref = { el => {
411- if ( el !== null ) {
412- optionRefs . current [ index ] = el ;
413- }
414- } }
415457 className = { cx (
416458 'relative cursor-default select-none py-2 pl-3 pr-9' ,
417459 index === focusedIndex && 'bg-indigo-600 text-white' ,
0 commit comments