@@ -44,24 +44,18 @@ 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 ( ) ;
5256 const reactId = React . useId ( ) ;
5357 const inputId = `combobox-input-${ reactId } ` ;
5458
55- const [ popoverContainer , setPopoverContainer ] = React . useState < HTMLElement | null > ( null ) ;
56-
57- React . useEffect ( ( ) => {
58- if ( ! open ) return ;
59- const dialogContent = containerRef . current ?. closest < HTMLElement > (
60- '[data-slot="dialog-content"]' ,
61- ) ;
62- setPopoverContainer ( dialogContent ?? portalContainer ) ;
63- } , [ open , portalContainer ] ) ;
64-
6559 const selectedValues = React . useMemo ( ( ) => {
6660 if ( multiple ) {
6761 return Array . isArray ( value ) ? value : value ? [ value ] : [ ] ;
@@ -114,12 +108,15 @@ export function Combobox({
114108 }
115109 } ;
116110
117- const handleChipRemove = ( valueToRemove : string ) => {
111+ const handleChipRemove = ( valueToRemove : string , refocusInput = true ) => {
118112 if ( disabled ) return ;
119113
120114 if ( multiple ) {
121115 const newValues = selectedValues . filter ( ( v ) => v !== valueToRemove ) ;
122116 onChange ?.( newValues as string [ ] ) ;
117+ if ( refocusInput ) {
118+ requestAnimationFrame ( ( ) => inputRef . current ?. focus ( ) ) ;
119+ }
123120 }
124121 } ;
125122
@@ -150,7 +147,7 @@ export function Combobox({
150147 case 'Backspace' :
151148 event . preventDefault ( ) ;
152149 if ( selectedOptions [ chipIndex ] ) {
153- handleChipRemove ( selectedOptions [ chipIndex ] . value ) ;
150+ handleChipRemove ( selectedOptions [ chipIndex ] . value , false ) ;
154151 }
155152 if ( selectedOptions . length > 1 ) {
156153 if ( chipIndex === 0 ) {
@@ -307,6 +304,59 @@ export function Combobox({
307304 chipRefs . current = chipRefs . current . slice ( 0 , selectedOptions . length ) ;
308305 } , [ selectedOptions . length ] ) ;
309306
307+ const showAllChips = isFocused || open || ! showSelectedCount ;
308+
309+ React . useLayoutEffect ( ( ) => {
310+ if ( ! multiple || showAllChips ) return ;
311+ if ( ! containerRef . current || selectedOptions . length === 0 ) {
312+ setVisibleChipCount ( 1 ) ;
313+ return ;
314+ }
315+
316+ const measureVisible = ( ) => {
317+ // Every dimension is read from the live DOM so nothing drifts from the styles.
318+ const textFieldEl = inputRef . current ?. closest ( '.group' ) as HTMLElement | null ;
319+ if ( ! textFieldEl ) return ;
320+
321+ const fieldStyles = window . getComputedStyle ( textFieldEl ) ;
322+ const paddingX = parseFloat ( fieldStyles . paddingLeft ) + parseFloat ( fieldStyles . paddingRight ) ;
323+ const inputMinWidth = inputRef . current
324+ ? parseFloat ( window . getComputedStyle ( inputRef . current ) . minWidth ) || 0
325+ : 0 ;
326+ const gap = chipsContainerRef . current
327+ ? parseFloat ( window . getComputedStyle ( chipsContainerRef . current ) . columnGap ) || 0
328+ : 0 ;
329+
330+ // Space the chips can occupy on the row, leaving room for the input and one gap before it.
331+ const availableWidth = textFieldEl . clientWidth - paddingX - inputMinWidth - gap ;
332+ const chips = measureRefs . current . filter ( Boolean ) as HTMLDivElement [ ] ;
333+ const badgeWidth = measureBadgeRef . current ?. offsetWidth ?? 0 ;
334+
335+ let usedWidth = 0 ;
336+ let count = 0 ;
337+
338+ for ( let i = 0 ; i < chips . length ; i ++ ) {
339+ const chipWidth = ( chips [ i ] ?. offsetWidth ?? 0 ) + ( i > 0 ? gap : 0 ) ;
340+ const isLastChip = i === chips . length - 1 ;
341+ const needsBadge = ! isLastChip && chips . length - ( count + 1 ) > 0 ;
342+ const requiredWidth = usedWidth + chipWidth + ( needsBadge ? gap + badgeWidth : 0 ) ;
343+
344+ if ( requiredWidth > availableWidth && count > 0 ) break ;
345+
346+ usedWidth += chipWidth ;
347+ count ++ ;
348+ }
349+
350+ setVisibleChipCount ( Math . max ( 1 , count ) ) ;
351+ } ;
352+
353+ measureVisible ( ) ;
354+
355+ const observer = new ResizeObserver ( measureVisible ) ;
356+ if ( containerRef . current ) observer . observe ( containerRef . current ) ;
357+ return ( ) => observer . disconnect ( ) ;
358+ } , [ selectedOptions , isFocused , open , showSelectedCount , multiple ] ) ;
359+
310360 const displayValue =
311361 ! multiple && selectedOptions . length > 0 && selectedOptions [ 0 ]
312362 ? selectedOptions [ 0 ] . label
@@ -333,26 +383,35 @@ export function Combobox({
333383 placeholder = { multiple && selectedOptions . length > 0 ? 'Add more...' : placeholder }
334384 className = { cn (
335385 className ,
336- multiple && selectedOptions . length > 0 && 'h-auto min-h-10 py-0.5 pl-1.5' ,
337386 multiple &&
338387 selectedOptions . length > 0 &&
339- '[&_input]:min-w-[104px] [&_input]:flex-shrink-0' ,
388+ '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' ,
389+ multiple &&
390+ selectedOptions . length > 0 &&
391+ showAllChips &&
392+ 'flex-wrap items-center gap-1 [&>div:first-child]:contents' ,
340393 ) }
341394 disabled = { disabled }
342395 autoComplete = "off"
343396 startAdornment = {
344397 multiple &&
345398 selectedOptions . length > 0 && (
346399 < div
347- className = "mr-0.5 flex shrink flex-wrap gap-1 py-0.5"
400+ ref = { chipsContainerRef }
401+ className = { cn (
402+ 'shrink' ,
403+ showAllChips
404+ ? 'contents'
405+ : 'mr-0.5 flex flex-nowrap gap-1 overflow-hidden py-0.5' ,
406+ ) }
348407 onClick = { ( e ) => {
349408 e . stopPropagation ( ) ;
350409 e . preventDefault ( ) ;
351410 } }
352411 >
353- { ( isFocused || open || ! showSelectedCount
412+ { ( showAllChips
354413 ? selectedOptions
355- : selectedOptions . slice ( 0 , 1 )
414+ : selectedOptions . slice ( 0 , visibleChipCount )
356415 ) . map ( ( option , index ) => (
357416 < div
358417 key = { option . value }
@@ -387,10 +446,45 @@ export function Combobox({
387446 </ Chip >
388447 </ div >
389448 ) ) }
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 >
449+ { ! isFocused &&
450+ ! open &&
451+ selectedOptions . length > visibleChipCount &&
452+ showSelectedCount && (
453+ < span className = "text-muted-foreground border-border flex h-4 items-center self-center border-r px-0.5 pr-1.5 text-xs" >
454+ +{ selectedOptions . length - visibleChipCount } more
455+ </ span >
456+ ) }
457+ { /* Hidden measurement row — always renders all chips to measure real widths */ }
458+ { showSelectedCount && ! isFocused && ! open && (
459+ < div
460+ className = "pointer-events-none invisible absolute flex gap-1"
461+ aria-hidden = "true"
462+ >
463+ { selectedOptions . map ( ( option , index ) => (
464+ < div
465+ key = { option . value }
466+ ref = { ( el ) => {
467+ measureRefs . current [ index ] = el ;
468+ } }
469+ >
470+ < Chip
471+ variant = "secondary"
472+ size = "sm"
473+ onDelete = { ( ) => { } }
474+ className = "max-w-xs"
475+ >
476+ < span className = "truncate" > { option . label } </ span >
477+ </ Chip >
478+ </ div >
479+ ) ) }
480+ { /* Widest possible badge ("+N more"), measured so its real width is reserved */ }
481+ < span
482+ ref = { measureBadgeRef }
483+ className = "text-muted-foreground border-border flex h-4 items-center self-center border-r px-0.5 pr-1.5 text-xs"
484+ >
485+ +{ Math . max ( 1 , selectedOptions . length - 1 ) } more
486+ </ span >
487+ </ div >
394488 ) }
395489 </ div >
396490 )
@@ -417,9 +511,9 @@ export function Combobox({
417511 </ PopoverPrimitive . Trigger >
418512 </ div >
419513
420- < PopoverPrimitive . Portal container = { popoverContainer } >
514+ < PopoverPrimitive . Portal container = { portalContainer } >
421515 < 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"
516+ 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"
423517 align = "start"
424518 sideOffset = { 8 }
425519 >
0 commit comments