@@ -29,6 +29,11 @@ export default class extends Controller {
2929 this . updateBadges ( )
3030 this . updateClearButton ( )
3131 this . updateInputTrigger ( )
32+
33+ // Track mouse state to distinguish click-focus from tab-focus
34+ this . _mouseDown = false
35+ this . element . addEventListener ( "mousedown" , ( ) => { this . _mouseDown = true } )
36+ this . element . addEventListener ( "mouseup" , ( ) => { setTimeout ( ( ) => { this . _mouseDown = false } , 0 ) } )
3237 }
3338
3439 disconnect ( ) {
@@ -48,7 +53,12 @@ export default class extends Controller {
4853 }
4954
5055 openPopover ( event ) {
51- if ( event && event . type !== "focus" ) event . preventDefault ( )
56+ if ( event && event . type !== "focusin" && event . type !== "focus" ) event . preventDefault ( )
57+
58+ // focusin/focus: only open on keyboard focus (tab), not mouse click
59+ if ( event && ( event . type === "focusin" || event . type === "focus" ) ) {
60+ if ( this . _mouseDown || this . triggerTarget . ariaExpanded === "true" || this . _closingPopover ) return
61+ }
5262
5363 this . updatePopoverPosition ( )
5464 this . updatePopoverWidth ( )
@@ -57,17 +67,19 @@ export default class extends Controller {
5767 this . itemTargets . forEach ( item => item . ariaCurrent = "false" )
5868 this . popoverTarget . showPopover ( )
5969
70+ // Always show all items on open; filter only on user typing
71+ this . applyFilter ( "" )
72+
6073 if ( this . hasBadgeInputTarget ) {
6174 this . badgeInputTarget . value = ""
62- this . applyFilter ( "" )
63- } else if ( this . hasInputTriggerTarget ) {
64- this . applyFilter ( this . inputTriggerTarget . value )
6575 }
6676 }
6777
6878 closePopover ( ) {
79+ this . _closingPopover = true
6980 this . triggerTarget . ariaExpanded = "false"
7081 this . popoverTarget . hidePopover ( )
82+ setTimeout ( ( ) => this . _closingPopover = false , 200 )
7183 }
7284
7385 handlePopoverToggle ( event ) {
@@ -149,14 +161,19 @@ export default class extends Controller {
149161 }
150162
151163 updateTriggerContent ( ) {
164+ if ( ! this . hasTriggerContentTarget ) return
165+
152166 const checkedInputs = this . inputTargets . filter ( input => input . checked )
153167
154168 if ( checkedInputs . length === 0 ) {
155169 this . triggerContentTarget . innerText = this . triggerTarget . dataset . placeholder
170+ this . triggerContentTarget . classList . add ( "text-muted-foreground" )
156171 } else if ( this . termValue && checkedInputs . length > 1 ) {
157172 this . triggerContentTarget . innerText = `${ checkedInputs . length } ${ this . termValue } `
173+ this . triggerContentTarget . classList . remove ( "text-muted-foreground" )
158174 } else {
159175 this . triggerContentTarget . innerText = checkedInputs . map ( ( input ) => this . inputContent ( input ) ) . join ( ", " )
176+ this . triggerContentTarget . classList . remove ( "text-muted-foreground" )
160177 }
161178 }
162179
@@ -166,25 +183,48 @@ export default class extends Controller {
166183 this . inputTriggerTarget . value = checked ? this . inputContent ( checked ) : ""
167184 }
168185
169- // NOTE: badge HTML mirrors ComboboxBadge Ruby component. Update both if styles change.
186+ // NOTE: badge classes mirror ComboboxBadge Ruby component. Update both if styles change.
170187 updateBadges ( ) {
171188 if ( ! this . hasBadgeContainerTarget ) return
172189
173- this . badgeContainerTarget . innerHTML = ""
190+ // Remove existing badges
191+ this . triggerTarget . querySelectorAll ( "[data-combobox-badge]" ) . forEach ( el => el . remove ( ) )
192+
193+ const checkedInputs = this . inputTargets . filter ( input => input . checked )
194+
195+ // Toggle trigger height: h-9 when empty, h-auto min-h-9 when badges exist
196+ if ( checkedInputs . length > 0 ) {
197+ this . triggerTarget . classList . remove ( "h-9" )
198+ this . triggerTarget . classList . add ( "h-auto" , "min-h-9" )
199+ } else {
200+ this . triggerTarget . classList . remove ( "h-auto" , "min-h-9" , "pt-1.5" )
201+ this . triggerTarget . classList . add ( "h-9" )
202+ }
174203
175- this . inputTargets . filter ( input => input . checked ) . forEach ( input => {
204+ checkedInputs . forEach ( input => {
176205 const badge = document . createElement ( "span" )
206+ badge . setAttribute ( "data-combobox-badge" , "" )
177207 badge . className = "inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
178208 badge . dataset . value = input . value
179209
180- const label = document . createTextNode ( this . inputContent ( input ) )
181- badge . appendChild ( label )
210+ badge . appendChild ( document . createTextNode ( this . inputContent ( input ) . trim ( ) ) )
182211
183212 const btn = document . createElement ( "button" )
184213 btn . type = "button"
185- btn . dataset . action = "ruby-ui--combobox#removeBadge"
186214 btn . setAttribute ( "aria-label" , "Remove" )
187- btn . className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
215+ btn . className = "rounded-sm opacity-50 hover:opacity-100 focus-visible:outline-none"
216+
217+ btn . addEventListener ( "click" , ( e ) => {
218+ e . preventDefault ( )
219+ e . stopPropagation ( )
220+ e . stopImmediatePropagation ( )
221+ const target = this . inputTargets . find ( i => i . value === input . value )
222+ if ( target ) {
223+ target . checked = false
224+ this . updateBadges ( )
225+ this . updateClearButton ( )
226+ }
227+ } )
188228
189229 const svg = document . createElementNS ( "http://www.w3.org/2000/svg" , "svg" )
190230 svg . setAttribute ( "xmlns" , "http://www.w3.org/2000/svg" )
@@ -196,6 +236,7 @@ export default class extends Controller {
196236 svg . setAttribute ( "stroke-width" , "2" )
197237 svg . setAttribute ( "stroke-linecap" , "round" )
198238 svg . setAttribute ( "stroke-linejoin" , "round" )
239+ svg . classList . add ( "pointer-events-none" )
199240
200241 const path1 = document . createElementNS ( "http://www.w3.org/2000/svg" , "path" )
201242 path1 . setAttribute ( "d" , "M18 6 6 18" )
@@ -207,8 +248,18 @@ export default class extends Controller {
207248 btn . appendChild ( svg )
208249 badge . appendChild ( btn )
209250
210- this . badgeContainerTarget . appendChild ( badge )
251+ // Insert badge directly in trigger, before the text input
252+ this . badgeInputTarget . insertAdjacentElement ( "beforebegin" , badge )
211253 } )
254+
255+ // Add top padding only when badges wrap to multiple lines
256+ // Class "pt-1.5" is referenced in ComboboxBadgeTrigger for Tailwind to compile it
257+ const badges = this . triggerTarget . querySelectorAll ( "[data-combobox-badge]" )
258+ if ( badges . length > 0 && this . badgeInputTarget . offsetTop > badges [ 0 ] . offsetTop ) {
259+ this . triggerTarget . classList . add ( "pt-1.5" )
260+ } else {
261+ this . triggerTarget . classList . remove ( "pt-1.5" )
262+ }
212263 }
213264
214265 updateClearButton ( ) {
@@ -255,17 +306,19 @@ export default class extends Controller {
255306
256307 this . emptyStateTarget . classList . toggle ( "hidden" , resultCount !== 0 )
257308
258- // Auto-highlight first visible result
309+ // Auto-highlight first visible result (without scrolling to avoid page jump)
310+ this . itemTargets . forEach ( item => item . ariaCurrent = "false" )
259311 const firstVisible = this . inputTargets . find ( i => ! i . parentElement . classList . contains ( "hidden" ) )
260312 if ( firstVisible ) {
261313 this . selectedItemIndex = 0
262- this . focusSelectedInput ( )
314+ firstVisible . parentElement . ariaCurrent = "true"
263315 }
264316 }
265317
266318 // Keyboard
267319
268- keyDownPressed ( ) {
320+ keyDownPressed ( event ) {
321+ event . preventDefault ( )
269322 if ( this . selectedItemIndex !== null ) {
270323 this . selectedItemIndex ++
271324 } else {
@@ -275,7 +328,8 @@ export default class extends Controller {
275328 this . focusSelectedInput ( )
276329 }
277330
278- keyUpPressed ( ) {
331+ keyUpPressed ( event ) {
332+ event . preventDefault ( )
279333 if ( this . selectedItemIndex !== null ) {
280334 this . selectedItemIndex --
281335 } else {
0 commit comments