11import { FormItem , FormMessage } from '@/components/ui/form'
2- import { Input } from '@/components/ui/input'
2+ import { Button } from '@/components/ui/button'
3+ import { Avatar , AvatarFallback } from '@/components/ui/avatar'
4+ import { Command , CommandEmpty , CommandInput , CommandItem , CommandList } from '@/components/ui/command'
5+ import { Popover , PopoverContent , PopoverTrigger } from '@/components/ui/popover'
36import { Skeleton } from '@/components/ui/skeleton'
47import { useGetAllCores } from '@/service/api'
5- import { Search , Check } from 'lucide-react'
6- import { useState } from 'react'
8+ import { Check , ChevronDown , Loader2 } from 'lucide-react'
9+ import { useState , useEffect , useRef , useCallback } from 'react'
710import { Control , FieldPath , FieldValues , useController } from 'react-hook-form'
811import { useTranslation } from 'react-i18next'
12+ import { useDebouncedSearch } from '@/hooks/use-debounced-search'
13+ import useDirDetection from '@/hooks/use-dir-detection'
914import { cn } from '@/lib/utils'
1015
16+ const PAGE_SIZE = 20
17+
1118interface CoresSelectorProps < T extends FieldValues > {
1219 control : Control < T >
1320 name : FieldPath < T >
@@ -17,13 +24,29 @@ interface CoresSelectorProps<T extends FieldValues> {
1724
1825export default function CoresSelector < T extends FieldValues > ( { control, name, onCoreChange, placeholder } : CoresSelectorProps < T > ) {
1926 const { t } = useTranslation ( )
20- const [ searchQuery , setSearchQuery ] = useState ( '' )
27+ const dir = useDirDetection ( )
2128
2229 const { field } = useController ( {
2330 control,
2431 name,
2532 } )
2633
34+ // Pagination and search state
35+ const [ offset , setOffset ] = useState ( 0 )
36+ const [ cores , setCores ] = useState < any [ ] > ( [ ] )
37+ const [ hasMore , setHasMore ] = useState ( true )
38+ const [ isLoading , setIsLoading ] = useState ( false )
39+ const [ dropdownOpen , setDropdownOpen ] = useState ( false )
40+ const listRef = useRef < HTMLDivElement > ( null )
41+ const { debouncedSearch : coreSearch , setSearch : setCoreSearchInput } = useDebouncedSearch ( '' , 300 )
42+
43+ // Handle debounced search side effects
44+ useEffect ( ( ) => {
45+ setOffset ( 0 )
46+ setCores ( [ ] )
47+ setHasMore ( true )
48+ } , [ coreSearch ] )
49+
2750 const { data : coresData , isLoading : coresLoading } = useGetAllCores ( undefined , {
2851 query : {
2952 staleTime : 5 * 60 * 1000 , // 5 minutes
@@ -34,95 +57,154 @@ export default function CoresSelector<T extends FieldValues>({ control, name, on
3457 } ,
3558 } )
3659
37- const selectedCoreId = field . value as number | null | undefined
38- const filteredCores = ( coresData ?. cores || [ ] ) . filter ( ( core : any ) =>
39- core . name . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) )
40- )
60+ // Update cores when data is fetched
61+ useEffect ( ( ) => {
62+ if ( coresData ?. cores ) {
63+ const allCores = coresData . cores
64+ const filteredCores = coreSearch
65+ ? allCores . filter ( ( core : any ) =>
66+ core . name . toLowerCase ( ) . includes ( coreSearch . toLowerCase ( ) )
67+ )
68+ : allCores
69+
70+ // Simulate pagination
71+ const paginatedCores = filteredCores . slice ( 0 , offset + PAGE_SIZE )
72+ setCores ( paginatedCores )
73+ setHasMore ( paginatedCores . length < filteredCores . length )
74+ setIsLoading ( false )
75+ }
76+ } , [ coresData , coreSearch , offset ] )
4177
42- const handleCoreSelect = ( coreId : number ) => {
43- // Toggle selection: if clicking on already selected core, deselect it
44- if ( selectedCoreId === coreId ) {
45- field . onChange ( null )
46- onCoreChange ?.( null )
47- } else {
48- field . onChange ( coreId )
49- onCoreChange ?.( coreId )
78+ const handleScroll = useCallback ( ( ) => {
79+ if ( ! listRef . current || isLoading || ! hasMore ) return
80+ const { scrollTop, scrollHeight, clientHeight } = listRef . current
81+ if ( scrollHeight - scrollTop - clientHeight < 100 ) {
82+ setIsLoading ( true )
83+ setOffset ( prev => prev + PAGE_SIZE )
5084 }
85+ } , [ isLoading , hasMore ] )
86+
87+ useEffect ( ( ) => {
88+ const el = listRef . current
89+ if ( ! el ) return
90+ el . addEventListener ( 'scroll' , handleScroll )
91+ return ( ) => el . removeEventListener ( 'scroll' , handleScroll )
92+ } , [ handleScroll ] )
93+
94+ const selectedCoreId = field . value as number | null | undefined
95+
96+ const handleCoreSelect = ( coreId : number | null ) => {
97+ field . onChange ( coreId )
98+ onCoreChange ?.( coreId )
99+ setDropdownOpen ( false )
51100 }
52101
53102 const selectedCore = coresData ?. cores ?. find ( ( core : any ) => core . id === selectedCoreId )
54103
55104 if ( coresLoading ) {
56105 return (
57106 < FormItem >
58- < div className = "space-y-4" >
59- < div className = "relative" >
60- < Search className = "absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
61- < Skeleton className = "h-10 w-full pl-8" />
62- </ div >
63- < div className = "max-h-[200px] space-y-2 overflow-y-auto rounded-md border p-2" >
64- { Array . from ( { length : 5 } ) . map ( ( _ , index ) => (
65- < div key = { index } className = "flex items-center gap-2 rounded-md p-2" >
66- < Skeleton className = "h-4 w-full" />
67- </ div >
68- ) ) }
69- </ div >
107+ < div className = "relative mb-3 w-full max-w-xs sm:mb-4 sm:max-w-sm lg:max-w-md" dir = { dir } >
108+ < Skeleton className = "h-8 w-full sm:h-9" />
70109 </ div >
110+ < FormMessage />
71111 </ FormItem >
72112 )
73113 }
74114
75115 return (
76116 < FormItem >
77- < div className = "space-y-4" >
78- < div className = "relative" >
79- < Search className = "absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
80- < Input
81- placeholder = { placeholder || t ( 'search' , { defaultValue : 'Search' } ) + ' ' + t ( 'cores' , { defaultValue : 'cores' } ) }
82- value = { searchQuery }
83- onChange = { e => setSearchQuery ( e . target . value ) }
84- className = "pl-8"
85- />
86- </ div >
117+ < div className = "relative mb-3 w-full max-w-xs sm:mb-4 sm:max-w-sm lg:max-w-md" dir = { dir } >
118+ < Popover open = { dropdownOpen } onOpenChange = { setDropdownOpen } >
119+ < PopoverTrigger asChild >
120+ < Button
121+ variant = "outline"
122+ className = { cn (
123+ 'h-8 w-full justify-between px-2 transition-colors hover:bg-muted/50 sm:h-9 sm:px-3' ,
124+ 'min-w-0 text-xs font-medium sm:text-sm'
125+ ) }
126+ >
127+ < div className = { cn (
128+ 'flex min-w-0 flex-1 items-center gap-1 sm:gap-2' ,
129+ dir === 'rtl' ? 'flex-row-reverse' : 'flex-row'
130+ ) } >
131+ < Avatar className = "h-4 w-4 flex-shrink-0 sm:h-5 sm:w-5" >
132+ < AvatarFallback className = "bg-muted text-xs font-medium" >
133+ { selectedCore ?. name ?. charAt ( 0 ) . toUpperCase ( ) || 'C' }
134+ </ AvatarFallback >
135+ </ Avatar >
136+ < span className = "truncate text-xs sm:text-sm" >
137+ { selectedCore ?. name || placeholder || t ( 'advanceSearch.selectCore' , { defaultValue : 'Select Core' } ) }
138+ </ span >
139+ </ div >
140+ < ChevronDown className = "ml-1 h-3 w-3 flex-shrink-0 text-muted-foreground" />
141+ </ Button >
142+ </ PopoverTrigger >
143+ < PopoverContent
144+ className = "w-64 p-1 sm:w-72 lg:w-80"
145+ sideOffset = { 4 }
146+ align = { dir === 'rtl' ? 'end' : 'start' }
147+ >
148+ < Command >
149+ < CommandInput
150+ placeholder = { placeholder || t ( 'search' , { defaultValue : 'Search' } ) }
151+ onValueChange = { setCoreSearchInput }
152+ className = "mb-1 h-7 text-xs sm:h-8 sm:text-sm"
153+ />
154+ < CommandList ref = { listRef } >
155+ < CommandEmpty >
156+ < div className = "py-3 text-center text-xs text-muted-foreground sm:py-4 sm:text-sm" >
157+ { t ( 'advanceSearch.noCoresFound' , { defaultValue : 'No cores found' } ) }
158+ </ div >
159+ </ CommandEmpty >
87160
88- < div className = "max-h-[200px] space-y-2 overflow-y-auto rounded-md border p-2" >
89- { filteredCores . length === 0 ? (
90- < div className = "flex w-full flex-col gap-2 rounded-md p-4 text-center" >
91- < span className = "text-sm text-muted-foreground" >
92- { searchQuery
93- ? t ( 'advanceSearch.noCoresFound' , { defaultValue : 'No cores found' } )
94- : t ( 'advanceSearch.noCoresAvailable' , { defaultValue : 'No cores available' } )
95- }
96- </ span >
97- </ div >
98- ) : (
99- filteredCores . map ( ( core : any ) => (
100- < button
101- key = { core . id }
102- type = "button"
103- onClick = { ( ) => handleCoreSelect ( core . id ) }
104- className = { cn (
105- "flex w-full cursor-pointer items-center justify-between gap-2 rounded-md p-2 text-left hover:bg-accent" ,
106- selectedCoreId === core . id && "bg-accent"
107- ) }
108- >
109- < span className = "text-sm" > { core . name } </ span >
110- { selectedCoreId === core . id && (
111- < Check className = "h-4 w-4 text-primary" />
161+ { /* "None" option to deselect */ }
162+ < CommandItem
163+ onSelect = { ( ) => handleCoreSelect ( null ) }
164+ className = { cn (
165+ 'flex min-w-0 items-center gap-2 px-2 py-1.5 text-xs sm:text-sm' ,
166+ dir === 'rtl' ? 'flex-row-reverse' : 'flex-row'
167+ ) }
168+ >
169+ < Avatar className = "h-4 w-4 flex-shrink-0 sm:h-5 sm:w-5" >
170+ < AvatarFallback className = "bg-primary/10 text-xs font-medium" > N</ AvatarFallback >
171+ </ Avatar >
172+ < span className = "flex-1 truncate" > { t ( 'none' , { defaultValue : 'None' } ) } </ span >
173+ < div className = "flex flex-shrink-0 items-center gap-1" >
174+ { ! selectedCoreId && < Check className = "h-3 w-3 text-primary" /> }
175+ </ div >
176+ </ CommandItem >
177+
178+ { cores . map ( ( core : any ) => (
179+ < CommandItem
180+ key = { core . id }
181+ onSelect = { ( ) => handleCoreSelect ( core . id ) }
182+ className = { cn (
183+ 'flex min-w-0 items-center gap-2 px-2 py-1.5 text-xs sm:text-sm' ,
184+ dir === 'rtl' ? 'flex-row-reverse' : 'flex-row'
185+ ) }
186+ >
187+ < Avatar className = "h-4 w-4 flex-shrink-0 sm:h-5 sm:w-5" >
188+ < AvatarFallback className = "bg-muted text-xs font-medium" >
189+ { core . name . charAt ( 0 ) . toUpperCase ( ) }
190+ </ AvatarFallback >
191+ </ Avatar >
192+ < span className = "flex-1 truncate" > { core . name } </ span >
193+ < div className = "flex flex-shrink-0 items-center gap-1" >
194+ { selectedCoreId === core . id && < Check className = "h-3 w-3 text-primary" /> }
195+ </ div >
196+ </ CommandItem >
197+ ) ) }
198+
199+ { isLoading && (
200+ < div className = "flex justify-center py-2" >
201+ < Loader2 className = "h-3 w-3 animate-spin text-muted-foreground" />
202+ </ div >
112203 ) }
113- </ button >
114- ) )
115- ) }
116- </ div >
117-
118- { selectedCore && (
119- < div className = "text-sm text-muted-foreground" >
120- { t ( 'advanceSearch.selectedCore' , {
121- defaultValue : 'Selected: {{name}}' ,
122- name : selectedCore . name
123- } ) }
124- </ div >
125- ) }
204+ </ CommandList >
205+ </ Command >
206+ </ PopoverContent >
207+ </ Popover >
126208 </ div >
127209 < FormMessage />
128210 </ FormItem >
0 commit comments