@@ -44,8 +44,12 @@ export function Combobox({
4444 const [ hasTyped , setHasTyped ] = React . useState ( false ) ;
4545 const [ focusedChipIndex , setFocusedChipIndex ] = React . useState ( - 1 ) ;
4646 const [ isFocused , setIsFocused ] = React . useState ( false ) ;
47+ const [ visibleChipCount , setVisibleChipCount ] = React . useState ( 1 ) ;
4748 const inputRef = React . useRef < HTMLInputElement > ( null ) ;
4849 const chipRefs = React . useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
50+ const measureRefs = React . useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
51+ const measureBadgeRef = React . useRef < HTMLSpanElement > ( null ) ;
52+ const chipsContainerRef = React . useRef < HTMLDivElement > ( null ) ;
4953 const containerRef = React . useRef < HTMLDivElement > ( null ) ;
5054
5155 const portalContainer = usePortalContainer ( ) ;
@@ -114,12 +118,15 @@ export function Combobox({
114118 }
115119 } ;
116120
117- const handleChipRemove = ( valueToRemove : string ) => {
121+ const handleChipRemove = ( valueToRemove : string , refocusInput = true ) => {
118122 if ( disabled ) return ;
119123
120124 if ( multiple ) {
121125 const newValues = selectedValues . filter ( ( v ) => v !== valueToRemove ) ;
122126 onChange ?.( newValues as string [ ] ) ;
127+ if ( refocusInput ) {
128+ requestAnimationFrame ( ( ) => inputRef . current ?. focus ( ) ) ;
129+ }
123130 }
124131 } ;
125132
@@ -150,7 +157,7 @@ export function Combobox({
150157 case 'Backspace' :
151158 event . preventDefault ( ) ;
152159 if ( selectedOptions [ chipIndex ] ) {
153- handleChipRemove ( selectedOptions [ chipIndex ] . value ) ;
160+ handleChipRemove ( selectedOptions [ chipIndex ] . value , false ) ;
154161 }
155162 if ( selectedOptions . length > 1 ) {
156163 if ( chipIndex === 0 ) {
@@ -307,6 +314,59 @@ export function Combobox({
307314 chipRefs . current = chipRefs . current . slice ( 0 , selectedOptions . length ) ;
308315 } , [ selectedOptions . length ] ) ;
309316
317+ const showAllChips = isFocused || open || ! showSelectedCount ;
318+
319+ React . useLayoutEffect ( ( ) => {
320+ if ( ! multiple || showAllChips ) return ;
321+ if ( ! containerRef . current || selectedOptions . length === 0 ) {
322+ setVisibleChipCount ( 1 ) ;
323+ return ;
324+ }
325+
326+ const measureVisible = ( ) => {
327+ // Every dimension is read from the live DOM so nothing drifts from the styles.
328+ const textFieldEl = inputRef . current ?. closest ( '.group' ) as HTMLElement | null ;
329+ if ( ! textFieldEl ) return ;
330+
331+ const fieldStyles = window . getComputedStyle ( textFieldEl ) ;
332+ const paddingX = parseFloat ( fieldStyles . paddingLeft ) + parseFloat ( fieldStyles . paddingRight ) ;
333+ const inputMinWidth = inputRef . current
334+ ? parseFloat ( window . getComputedStyle ( inputRef . current ) . minWidth ) || 0
335+ : 0 ;
336+ const gap = chipsContainerRef . current
337+ ? parseFloat ( window . getComputedStyle ( chipsContainerRef . current ) . columnGap ) || 0
338+ : 0 ;
339+
340+ // Space the chips can occupy on the row, leaving room for the input and one gap before it.
341+ const availableWidth = textFieldEl . clientWidth - paddingX - inputMinWidth - gap ;
342+ const chips = measureRefs . current . filter ( Boolean ) as HTMLDivElement [ ] ;
343+ const badgeWidth = measureBadgeRef . current ?. offsetWidth ?? 0 ;
344+
345+ let usedWidth = 0 ;
346+ let count = 0 ;
347+
348+ for ( let i = 0 ; i < chips . length ; i ++ ) {
349+ const chipWidth = ( chips [ i ] ?. offsetWidth ?? 0 ) + ( i > 0 ? gap : 0 ) ;
350+ const isLastChip = i === chips . length - 1 ;
351+ const needsBadge = ! isLastChip && chips . length - ( count + 1 ) > 0 ;
352+ const requiredWidth = usedWidth + chipWidth + ( needsBadge ? gap + badgeWidth : 0 ) ;
353+
354+ if ( requiredWidth > availableWidth && count > 0 ) break ;
355+
356+ usedWidth += chipWidth ;
357+ count ++ ;
358+ }
359+
360+ setVisibleChipCount ( Math . max ( 1 , count ) ) ;
361+ } ;
362+
363+ measureVisible ( ) ;
364+
365+ const observer = new ResizeObserver ( measureVisible ) ;
366+ if ( containerRef . current ) observer . observe ( containerRef . current ) ;
367+ return ( ) => observer . disconnect ( ) ;
368+ } , [ selectedOptions , isFocused , open , showSelectedCount , multiple ] ) ;
369+
310370 const displayValue =
311371 ! multiple && selectedOptions . length > 0 && selectedOptions [ 0 ]
312372 ? selectedOptions [ 0 ] . label
@@ -333,26 +393,35 @@ export function Combobox({
333393 placeholder = { multiple && selectedOptions . length > 0 ? 'Add more...' : placeholder }
334394 className = { cn (
335395 className ,
336- multiple && selectedOptions . length > 0 && 'h-auto min-h-10 py-0.5 pl-1.5' ,
337396 multiple &&
338397 selectedOptions . length > 0 &&
339- '[&_input]:min-w-[104px] [&_input]:flex-shrink-0' ,
398+ 'relative h-auto min-h-10 justify-start py-0.5 pr-10 pl-3 [&_input]:min-w-[104px] [&>div:last-child]:absolute [&>div:last-child]:top-1.5 [&>div:last-child]:right-1.5' ,
399+ multiple &&
400+ selectedOptions . length > 0 &&
401+ showAllChips &&
402+ 'flex-wrap items-center gap-1 [&>div:first-child]:contents' ,
340403 ) }
341404 disabled = { disabled }
342405 autoComplete = "off"
343406 startAdornment = {
344407 multiple &&
345408 selectedOptions . length > 0 && (
346409 < div
347- className = "mr-0.5 flex shrink flex-wrap gap-1 py-0.5"
410+ ref = { chipsContainerRef }
411+ className = { cn (
412+ 'shrink' ,
413+ showAllChips
414+ ? 'contents'
415+ : 'mr-0.5 flex flex-nowrap gap-1 overflow-hidden py-0.5' ,
416+ ) }
348417 onClick = { ( e ) => {
349418 e . stopPropagation ( ) ;
350419 e . preventDefault ( ) ;
351420 } }
352421 >
353- { ( isFocused || open || ! showSelectedCount
422+ { ( showAllChips
354423 ? selectedOptions
355- : selectedOptions . slice ( 0 , 1 )
424+ : selectedOptions . slice ( 0 , visibleChipCount )
356425 ) . map ( ( option , index ) => (
357426 < div
358427 key = { option . value }
@@ -387,10 +456,45 @@ export function Combobox({
387456 </ Chip >
388457 </ div >
389458 ) ) }
390- { ! isFocused && ! open && selectedOptions . length > 1 && showSelectedCount && (
391- < span className = "text-muted-foreground border-border flex h-4 items-center self-center border-r px-0.5 pr-1.5 text-xs" >
392- +{ selectedOptions . length - 1 } more
393- </ span >
459+ { ! isFocused &&
460+ ! open &&
461+ selectedOptions . length > visibleChipCount &&
462+ showSelectedCount && (
463+ < span className = "text-muted-foreground border-border flex h-4 items-center self-center border-r px-0.5 pr-1.5 text-xs" >
464+ +{ selectedOptions . length - visibleChipCount } more
465+ </ span >
466+ ) }
467+ { /* Hidden measurement row — always renders all chips to measure real widths */ }
468+ { showSelectedCount && ! isFocused && ! open && (
469+ < div
470+ className = "pointer-events-none invisible absolute flex gap-1"
471+ aria-hidden = "true"
472+ >
473+ { selectedOptions . map ( ( option , index ) => (
474+ < div
475+ key = { option . value }
476+ ref = { ( el ) => {
477+ measureRefs . current [ index ] = el ;
478+ } }
479+ >
480+ < Chip
481+ variant = "secondary"
482+ size = "sm"
483+ onDelete = { ( ) => { } }
484+ className = "max-w-xs"
485+ >
486+ < span className = "truncate" > { option . label } </ span >
487+ </ Chip >
488+ </ div >
489+ ) ) }
490+ { /* Widest possible badge ("+N more"), measured so its real width is reserved */ }
491+ < span
492+ ref = { measureBadgeRef }
493+ className = "text-muted-foreground border-border flex h-4 items-center self-center border-r px-0.5 pr-1.5 text-xs"
494+ >
495+ +{ Math . max ( 1 , selectedOptions . length - 1 ) } more
496+ </ span >
497+ </ div >
394498 ) }
395499 </ div >
396500 )
@@ -419,7 +523,7 @@ export function Combobox({
419523
420524 < PopoverPrimitive . Portal container = { popoverContainer } >
421525 < PopoverPrimitive . Content
422- className = "bg-popover text-popover-foreground shadow-bevel-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-1000 min-w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-3xl ring-0 duration-300 ease-in-out outline-none focus:outline-none"
526+ className = "bg-popover text-popover-foreground shadow-bevel-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 min-w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-3xl ring-0 duration-300 ease-in-out outline-none focus:outline-none"
423527 align = "start"
424528 sideOffset = { 8 }
425529 >
0 commit comments