@@ -5,10 +5,12 @@ export default {
55 mounted ( ) {
66 this . initializeDOMCache ( )
77 this . mode = this . getMode ( )
8+ this . isMultiple = this . el . hasAttribute ( 'data-multiple' )
89 this . hasCreateOption = ! ! this . createOption
10+
911 this . setupEventListeners ( )
1012 this . initializeCreateOption ( )
11- this . updateSelectedOption ( )
13+ this . syncSelectedAttributes ( )
1214 if ( this . mode === 'async' ) {
1315 this . searchInput . dispatchEvent ( new Event ( "input" , { bubbles : true } ) )
1416 }
@@ -17,9 +19,15 @@ export default {
1719
1820 initializeDOMCache ( ) {
1921 this . searchInput = this . el . querySelector ( 'input[data-prima-ref=search_input]' )
20- this . submitInput = this . el . querySelector ( 'input [data-prima-ref=submit_input ]' )
22+ this . submitContainer = this . el . querySelector ( '[data-prima-ref=submit_container ]' )
2123 this . optionsContainer = this . getOptionsContainer ( )
2224 this . createOption = this . optionsContainer ?. querySelector ( '[data-prima-ref=create-option]' )
25+ this . selectionsContainer = this . el . querySelector ( '[data-prima-ref=selections]' )
26+ this . selectionTemplate = this . selectionsContainer ?. querySelector ( '[data-prima-ref=selection-template]' )
27+
28+ // Cache reference element for positioning
29+ const field = this . el . querySelector ( '[data-prima-ref="field"]' )
30+ this . referenceElement = field || this . searchInput
2331 } ,
2432
2533 setupEventListeners ( ) {
@@ -29,7 +37,14 @@ export default {
2937 this . optionsContainer ?. addEventListener ( 'mouseover' , this . onHover . bind ( this ) )
3038 this . searchInput . addEventListener ( 'focus' , ( ) => {
3139 this . searchInput . select ( )
32- this . showOptions ( )
40+ } )
41+ this . searchInput . addEventListener ( 'click' , ( e ) => {
42+ // Toggle options visibility on click
43+ if ( this . isOptionsVisible ( ) ) {
44+ this . hideOptions ( )
45+ } else {
46+ this . showOptions ( )
47+ }
3348 } )
3449 this . searchInput . addEventListener ( 'input' , this . onInput . bind ( this ) )
3550 } ,
@@ -42,7 +57,7 @@ export default {
4257 } else {
4358 this . focusFirstOption ( )
4459 }
45- this . updateSelectedOption ( )
60+ this . syncSelectedAttributes ( )
4661 } ,
4762
4863 destroyed ( ) {
@@ -63,6 +78,71 @@ export default {
6378 return Array . from ( this . optionsContainer ?. querySelectorAll ( '[role=option]:not([data-hidden])' ) || [ ] )
6479 } ,
6580
81+ getRegularOptions ( ) {
82+ return this . optionsContainer ?. querySelectorAll ( '[role=option]:not([data-prima-ref=create-option])' ) || [ ]
83+ } ,
84+
85+ isOptionsVisible ( ) {
86+ if ( ! this . optionsContainer ) return false
87+ return this . optionsContainer . style . display !== 'none'
88+ } ,
89+
90+ getSelectedValues ( ) {
91+ const inputs = this . submitContainer ?. querySelectorAll ( 'input[type="hidden"]' ) || [ ]
92+ return Array . from ( inputs ) . map ( input => input . value )
93+ } ,
94+
95+ getInputName ( ) {
96+ if ( ! this . submitContainer ) return ''
97+ const baseName = this . submitContainer . getAttribute ( 'data-input-name' )
98+ return this . isMultiple ? baseName + '[]' : baseName
99+ } ,
100+
101+ // === SELECTION MANAGEMENT ===
102+ addSelection ( value ) {
103+ if ( ! this . submitContainer ) return
104+
105+ const selectedValues = this . getSelectedValues ( )
106+
107+ // Don't add if already selected
108+ if ( selectedValues . includes ( value ) ) return
109+
110+ // Single-select: clear existing selections first
111+ if ( ! this . isMultiple ) {
112+ this . submitContainer . innerHTML = ''
113+ }
114+
115+ // Create and append hidden input
116+ const input = document . createElement ( 'input' )
117+ input . type = 'hidden'
118+ input . name = this . getInputName ( )
119+ input . value = value
120+ this . submitContainer . appendChild ( input )
121+
122+ // Render pill for multi-select
123+ if ( this . isMultiple ) {
124+ this . appendSelectionPill ( value )
125+ }
126+
127+ this . syncSelectedAttributes ( )
128+ } ,
129+
130+ removeSelection ( value ) {
131+ // Remove hidden input
132+ const inputs = Array . from ( this . submitContainer . querySelectorAll ( 'input[type="hidden"]' ) )
133+ const input = inputs . find ( input => input . value === value )
134+ input ?. remove ( )
135+
136+ if ( this . isMultiple ) {
137+ const pill = this . selectionsContainer ?. querySelector (
138+ `[data-prima-ref="selection-item"][data-value="${ value } "]`
139+ )
140+ pill ?. remove ( )
141+ }
142+
143+ this . syncSelectedAttributes ( )
144+ } ,
145+
66146 // === FOCUS MANAGEMENT ===
67147 setFocus ( el ) {
68148 this . optionsContainer ?. querySelector ( '[role=option][data-focus=true]' ) ?. removeAttribute ( 'data-focus' )
@@ -100,38 +180,68 @@ export default {
100180
101181 // === OPTION SELECTION ===
102182 selectOption ( el ) {
103- const value = el . getAttribute ( 'data-value' )
183+ if ( ! el ) return
184+
185+ let value = el . getAttribute ( 'data-value' )
104186
187+ // Handle create option
105188 if ( value === '__CREATE__' ) {
106- const searchValue = this . searchInput . value
107- this . submitInput . value = searchValue
108- this . searchInput . value = searchValue
189+ value = this . searchInput . value
190+ }
191+
192+ this . addSelection ( value )
193+
194+ if ( this . isMultiple ) {
195+ this . searchInput . value = ''
109196 } else {
110- this . submitInput . value = value
111197 this . searchInput . value = value
112198 }
113199
114- this . updateSelectedOption ( )
115200 this . hideOptions ( )
201+
202+ this . searchInput . focus ( )
116203 } ,
117204
118- updateSelectedOption ( ) {
205+ syncSelectedAttributes ( ) {
119206 if ( ! this . optionsContainer ) return
120207
121- const selectedValue = this . submitInput . value
122- const allOptions = this . optionsContainer . querySelectorAll ( '[role=option]:not([data-prima-ref=create-option])' )
208+ const allOptions = this . getRegularOptions ( )
209+ const selectedValues = this . getSelectedValues ( )
123210
124211 for ( const option of allOptions ) {
125- if ( option . getAttribute ( 'data-value' ) === selectedValue && selectedValue !== '' ) {
212+ const value = option . getAttribute ( 'data-value' )
213+ if ( selectedValues . includes ( value ) ) {
126214 option . setAttribute ( 'data-selected' , 'true' )
127215 } else {
128216 option . removeAttribute ( 'data-selected' )
129217 }
130218 }
131219 } ,
132220
221+ appendSelectionPill ( value ) {
222+ if ( ! this . selectionsContainer || ! this . selectionTemplate ) return
223+
224+ const pill = this . selectionTemplate . content . cloneNode ( true )
225+ const item = pill . querySelector ( '[data-prima-ref="selection-item"]' )
226+ item . dataset . value = value
227+
228+ // Replace all occurrences of __VALUE__ with actual value
229+ item . innerHTML = item . innerHTML . replaceAll ( '__VALUE__' , value )
230+
231+ this . selectionsContainer . appendChild ( pill )
232+ } ,
233+
133234 // === EVENT HANDLERS ===
134235 onClick ( e ) {
236+ // Check for remove button click first
237+ const removeButton = e . target . closest ( '[data-prima-ref="remove-selection"]' )
238+ if ( removeButton ) {
239+ const value = removeButton . getAttribute ( 'data-value' )
240+ this . removeSelection ( value )
241+ this . searchInput . focus ( )
242+ return
243+ }
244+
135245 // Use event delegation to find the closest option element
136246 const optionElement = e . target . closest ( '[role="option"]' )
137247 if ( optionElement ) {
@@ -148,7 +258,16 @@ export default {
148258 } else if ( e . key === "Enter" || e . key === "Tab" ) {
149259 e . preventDefault ( )
150260 this . selectOption ( this . currentlyFocusedOption ( ) )
261+ } else if ( e . key === "Escape" ) {
262+ e . preventDefault ( )
151263 this . hideOptions ( )
264+ } else if ( e . key === "Backspace" && this . isMultiple && this . searchInput . value === '' ) {
265+ // Remove last selection when backspace is pressed on empty input
266+ e . preventDefault ( )
267+ const values = this . getSelectedValues ( )
268+ if ( values . length > 0 ) {
269+ this . removeSelection ( values [ values . length - 1 ] )
270+ }
152271 }
153272 } ,
154273
@@ -194,7 +313,7 @@ export default {
194313 // === OPTION FILTERING & VISIBILITY ===
195314 filterOptions ( searchValue ) {
196315 const q = searchValue . toLowerCase ( )
197- const allOptions = this . optionsContainer ?. querySelectorAll ( '[role=option]:not([data-prima-ref=create-option])' ) || [ ]
316+ const allOptions = this . getRegularOptions ( )
198317 let previouslyFocusedOptionIsHidden = false
199318
200319 for ( const option of allOptions ) {
@@ -246,7 +365,7 @@ export default {
246365 }
247366
248367 try {
249- const { x, y} = await computePosition ( this . searchInput , this . optionsContainer , {
368+ const { x, y} = await computePosition ( this . referenceElement , this . optionsContainer , {
250369 placement : placement ,
251370 middleware : middleware
252371 } )
@@ -261,14 +380,6 @@ export default {
261380 }
262381 } ,
263382
264- setupAutoUpdate ( ) {
265- if ( ! this . optionsContainer ) return
266-
267- this . cleanup = autoUpdate ( this . searchInput , this . optionsContainer , ( ) => {
268- this . positionOptions ( )
269- } )
270- } ,
271-
272383 cleanupAutoUpdate ( ) {
273384 if ( this . cleanup ) {
274385 this . cleanup ( )
@@ -287,9 +398,9 @@ export default {
287398 this . positionOptions ( )
288399 } )
289400
290- // Setup automatic repositioning with floating-ui's autoUpdate
291- this . setupAutoUpdate ( )
292-
401+ this . cleanup = autoUpdate ( this . referenceElement , this . optionsContainer , ( ) => {
402+ this . positionOptions ( )
403+ } )
293404 this . setupClickOutsideHandler ( )
294405 } ,
295406
@@ -298,20 +409,23 @@ export default {
298409 if ( ! this . optionsContainer . contains ( event . target ) && ! this . searchInput . contains ( event . target ) ) {
299410 this . resetOnBlur ( )
300411 document . removeEventListener ( 'click' , handleClickOutside )
301- this . cleanupAutoUpdate ( )
302412 }
303413 }
304414
305415 document . addEventListener ( 'click' , handleClickOutside )
306416 } ,
307417
308418 resetOnBlur ( ) {
309- if ( this . submitInput . value . length > 0 && this . searchInput . value . length > 0 ) {
310- this . searchInput . value = this . submitInput . value
419+ const selectedValues = this . getSelectedValues ( )
420+ const currentValue = selectedValues [ 0 ] || ''
421+
422+ if ( currentValue . length > 0 && this . searchInput . value . length > 0 ) {
423+ this . searchInput . value = currentValue
311424 } else if ( this . searchInput . value . length > 0 ) {
312425 this . searchInput . value = ''
313426 this . searchInput . dispatchEvent ( new Event ( "input" , { bubbles : true } ) )
314- this . submitInput . value = ''
427+ // Clear selections (for single-select)
428+ this . submitContainer . innerHTML = ''
315429 }
316430
317431 this . hideOptions ( )
@@ -326,8 +440,10 @@ export default {
326440 this . cleanupAutoUpdate ( )
327441
328442 this . optionsContainer . addEventListener ( 'phx:hide-end' , ( ) => {
329- for ( const option of this . optionsContainer . querySelectorAll ( '[role=option]' ) ) {
330- this . showOption ( option ) // reset the state
443+ // Reset regular options to visible, but exclude create option since its visibility is managed separately
444+ const regularOptions = this . getRegularOptions ( )
445+ for ( const option of regularOptions ) {
446+ this . showOption ( option )
331447 }
332448 } )
333449 } ,
@@ -363,13 +479,14 @@ export default {
363479 } ,
364480
365481 hasExactMatch ( searchValue ) {
366- const regularOptions = this . optionsContainer ?. querySelectorAll ( '[role=option]:not([data-prima-ref=create-option])' ) || [ ]
482+ const regularOptions = this . getRegularOptions ( )
367483 const hasStaticMatch = Array . from ( regularOptions ) . some ( option =>
368484 option . getAttribute ( 'data-value' ) === searchValue
369485 )
370486
371- // Also check if search value matches current selected value (submit input)
372- const hasSelectedMatch = this . submitInput . value === searchValue
487+ // Also check if search value matches any currently selected value
488+ const selectedValues = this . getSelectedValues ( )
489+ const hasSelectedMatch = selectedValues . includes ( searchValue )
373490
374491 return hasStaticMatch || hasSelectedMatch
375492 }
0 commit comments