1- import { debounce , throttle } from '@/utils/dom.ts'
1+ import { debounce , throttle } from '@/utils/dom.ts'
22
33export class CustomSelect {
44 rootEl : Element
55
66 multiple : boolean
77
8+ fetchUrl : string | null = null
9+ private fetchAbortController : AbortController | null = null
10+
811 /* Dropdown elements */
912 dropdown : HTMLElement
1013 dropdownOptions : Map < string , HTMLElement > = new Map ( )
@@ -36,6 +39,8 @@ export class CustomSelect {
3639 constructor ( rootEl : Element , multiple : boolean ) {
3740 this . multiple = multiple
3841
42+ this . fetchUrl = rootEl . getAttribute ( 'data-fetchurl' )
43+
3944 this . rootEl = rootEl
4045
4146 this . dropdown = rootEl . querySelector ( '.ss-dropdown' ) !
@@ -114,7 +119,10 @@ export class CustomSelect {
114119
115120 this . dropdownSearch ?. addEventListener (
116121 'input' ,
117- debounce ( ( _e ) => this . search ( ) , 250 ) ,
122+ debounce ( ( _e ) => {
123+ this . search ( )
124+ this . fetchOptions ( )
125+ } , 250 ) ,
118126 )
119127
120128 this . dropdownSearch ?. addEventListener ( 'keydown' , ( e ) => {
@@ -148,8 +156,8 @@ export class CustomSelect {
148156 )
149157
150158 this . update ( )
151-
152159 this . initLivewire ( )
160+ this . fetchOptions ( )
153161 }
154162
155163 initLivewire = ( ) => {
@@ -170,11 +178,11 @@ export class CustomSelect {
170178 return
171179 }
172180
173- window [ 'Livewire' ] . hook ( 'morph.updated' , ( { el } ) =>
181+ window [ 'Livewire' ] . hook ( 'morph.updated' , ( { el } ) =>
174182 this . onLivewireUpdate ( el ) ,
175183 )
176184
177- window [ 'Livewire' ] . hook ( 'element.init' , ( { el } ) =>
185+ window [ 'Livewire' ] . hook ( 'element.init' , ( { el } ) =>
178186 /* Timeout required because element.init is launched BEFORE wire:model takes effect */
179187 setTimeout ( ( ) => this . onLivewireUpdate ( el ) , 50 ) ,
180188 )
@@ -198,18 +206,13 @@ export class CustomSelect {
198206 this . dropdown . classList . remove ( 'hidden' )
199207 setTimeout ( ( ) => this . dropdownSearch ?. focus ( ) , 25 )
200208
201- this . setActive ( this . dropdownOptions . keys ( ) . next ( ) . value )
209+ this . setActive ( this . dropdownOptions . keys ( ) . next ( ) . value ?? null )
202210 }
203211
204212 close = ( withFocus : boolean = true ) => {
205213 this . isOpen = false
206214 this . dropdown . classList . add ( 'hidden' )
207215
208- if ( this . dropdownSearch ) {
209- this . dropdownSearch . value = ''
210- this . dropdownSearch . dispatchEvent ( new Event ( 'input' ) )
211- }
212-
213216 if ( withFocus ) {
214217 this . uiBox . focus ( )
215218 }
@@ -219,8 +222,90 @@ export class CustomSelect {
219222 this . isOpen ? this . close ( ) : this . open ( )
220223 }
221224
225+ fetchOptions = async ( ) => {
226+ if ( ! this . fetchUrl ) {
227+ return
228+ }
229+
230+ if ( this . fetchAbortController ) {
231+ this . fetchAbortController . abort ( ) // Cancel the previous fetch
232+ }
233+
234+ this . fetchAbortController = new AbortController ( ) // Create a new AbortController
235+ const signal = this . fetchAbortController . signal // Get the AbortSignal
236+
237+ let url = new URL ( this . fetchUrl , window . location . origin ) // Use URL constructor to handle existing params
238+ const searchString = this . dropdownSearch ?. value . trim ( )
239+
240+ if ( searchString ) {
241+ url . searchParams . append ( 'q' , searchString )
242+ }
243+
244+ this . dropdown . classList . add ( 'loading' )
245+
246+ try {
247+ const response = await fetch ( url . toString ( ) , { signal } )
248+
249+ if ( ! response . ok ) {
250+ throw new Error ( `HTTP error! Status: ${ response . status } ` )
251+ }
252+
253+ const data : { [ key : string ] : string } = await response . json ( ) // Type assertion for the response
254+
255+ // Clear existing options (except the empty value)
256+ const emptyValue = this . emptyValue // Save it before removing the HTML
257+
258+ const selectedOptions = new Map < string , string > ( )
259+
260+ // Remove all options aside from the empty one, and keep track of the selected ones
261+ for ( const optEl of this . select . options ) {
262+ if ( optEl . value === emptyValue ) continue
263+ if ( optEl . selected ) selectedOptions . set ( optEl . value , optEl . innerText )
264+ }
265+
266+ this . select . innerHTML = ''
267+ if ( ! this . multiple ) {
268+ const emptyOption = document . createElement ( 'option' )
269+ emptyOption . value = emptyValue
270+ this . select . appendChild ( emptyOption )
271+ }
272+
273+ // Add the fetched options
274+ for ( const valueKey in data ) {
275+ const value = valueKey . toString ( )
276+ const optEl = document . createElement ( 'option' )
277+ optEl . value = value
278+ optEl . innerText = data [ value ] // Ensure innerText is set
279+ if ( selectedOptions . has ( value ) ) {
280+ optEl . selected = true
281+ selectedOptions . delete ( value )
282+ }
283+ this . select . appendChild ( optEl )
284+ }
285+
286+ // If there are some selectedOptions not available anymore, we still need to add them to the select element or it would lose the reference
287+ for ( const [ value , label ] of selectedOptions ) {
288+ const optEl = document . createElement ( 'option' )
289+ optEl . value = value
290+ optEl . innerText = label // Ensure innerText is set
291+ optEl . selected = true
292+ optEl . setAttribute ( 'data-hidden' , 'true' ) // Those options should be hidden in the dropdown
293+ this . select . appendChild ( optEl )
294+ }
295+
296+ this . populateDropdown ( ) // Update the dropdown UI
297+ this . update ( ) // Update the selected value display
298+ } catch ( error ) {
299+ console . error ( '[SearchSelect] Error fetching options:' , error )
300+ // Optionally, display an error message to the user
301+ } finally {
302+ this . dropdown . classList . remove ( 'loading' )
303+ this . fetchAbortController = null
304+ }
305+ }
306+
222307 search = ( ) => {
223- if ( ! this . dropdownSearch ) {
308+ if ( ! this . dropdownSearch || this . fetchUrl !== null ) {
224309 return
225310 }
226311
@@ -235,10 +320,10 @@ export class CustomSelect {
235320 const toHide : HTMLElement [ ] = [ ]
236321
237322 for ( const [ key , opt ] of this . dropdownOptions ) {
238- const shouldShow = s === '' || this . optionsSearchText . get ( key ) ?. includes ( s )
323+ const shouldShow =
324+ s === '' || this . optionsSearchText . get ( key ) ?. includes ( s )
239325
240326 if ( shouldShow ) {
241-
242327 if ( opt . classList . contains ( 'hidden' ) ) {
243328 toShow . push ( opt )
244329 }
@@ -255,8 +340,8 @@ export class CustomSelect {
255340
256341 // Do all work in a single frame, avoiding multiple browser reflow & repaint
257342 requestAnimationFrame ( ( ) => {
258- toShow . forEach ( opt => opt . classList . remove ( 'hidden' ) )
259- toHide . forEach ( opt => opt . classList . add ( 'hidden' ) )
343+ toShow . forEach ( ( opt ) => opt . classList . remove ( 'hidden' ) )
344+ toHide . forEach ( ( opt ) => opt . classList . add ( 'hidden' ) )
260345
261346 this . setActive ( newActive )
262347 } )
@@ -271,30 +356,45 @@ export class CustomSelect {
271356
272357 const optionsWrapper = this . dropdown . querySelector ( '.ss-options' ) !
273358
274- this . select . querySelectorAll ( 'option' ) . forEach ( ( option ) => {
275- if ( option . value === this . emptyValue ) return
359+ for ( const optEl of this . select . options ) {
360+ if ( optEl . value === this . emptyValue ) continue
276361
277- if ( this . dropdownOptions . has ( option . value ) ) {
278- existingValues . delete ( option . value )
362+ // For each options in the root select element, add the equivalent option in the dropdown
279363
280- this . dropdownOptions
281- . get ( option . value ) !
282- . querySelector ( 'span' ) ! . innerText = option . innerText
283- return
364+ // To improve performance, do not recreate element if it already exists
365+ if ( this . dropdownOptions . has ( optEl . value ) ) {
366+ existingValues . delete ( optEl . value )
367+
368+ const dropdownOption = this . dropdownOptions . get ( optEl . value ) !
369+ dropdownOption . querySelector ( 'span' ) ! . innerText = optEl . innerText
370+
371+ if ( optEl . hasAttribute ( 'data-hidden' ) ) {
372+ dropdownOption . classList . add ( 'hidden' )
373+ } else {
374+ dropdownOption . classList . remove ( 'hidden' )
375+ }
376+ continue
284377 }
285378
286379 const dropdownOption = ( template . content . cloneNode ( true ) as HTMLElement )
287380 . firstElementChild as HTMLElement
288381
289- dropdownOption . setAttribute ( 'data-key' , option . value )
290- dropdownOption . querySelector ( 'span' ) ! . innerText = option . label
382+ dropdownOption . setAttribute ( 'data-key' , optEl . value )
383+ dropdownOption . querySelector ( 'span' ) ! . innerText = optEl . label
384+
385+ if ( optEl . hasAttribute ( 'data-hidden' ) ) {
386+ dropdownOption . classList . add ( 'hidden' )
387+ } else {
388+ dropdownOption . classList . remove ( 'hidden' )
389+ }
291390
292391 optionsWrapper . appendChild ( dropdownOption )
293392
294- this . dropdownOptions . set ( option . value , dropdownOption )
295- this . optionsSearchText . set ( option . value , option . label . toLowerCase ( ) )
296- } )
393+ this . dropdownOptions . set ( optEl . value , dropdownOption )
394+ this . optionsSearchText . set ( optEl . value , optEl . label . toLowerCase ( ) )
395+ }
297396
397+ // Existing values not removed at the previous step are no longer available, we can remove them
298398 existingValues . forEach ( ( val ) => {
299399 this . dropdownOptions . get ( val ) ?. remove ( )
300400
@@ -355,7 +455,7 @@ export class CustomSelect {
355455
356456 this . dropdownOptions
357457 . get ( key )
358- ?. scrollIntoView ( { block : 'nearest' , behavior : 'smooth' } )
458+ ?. scrollIntoView ( { block : 'nearest' , behavior : 'smooth' } )
359459 }
360460
361461 this . active = key
0 commit comments