Skip to content

Commit afd5fed

Browse files
committed
fix(react): update uds fixes for combobox, popover, and text-field
1 parent e0dbddc commit afd5fed

4 files changed

Lines changed: 121 additions & 27 deletions

File tree

packages/react/src/components/ui/combobox.tsx

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
>

packages/react/src/components/ui/popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function PopoverContent({
3232
align={align}
3333
sideOffset={sideOffset}
3434
className={cn(
35-
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) overflow-hidden rounded-3xl ring-0 outline-hidden duration-150 ease-in-out outline-none focus:outline-none',
35+
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-100 w-72 origin-(--radix-popover-content-transform-origin) overflow-hidden rounded-3xl ring-0 outline-hidden duration-150 ease-in-out outline-none focus:outline-none',
3636
className,
3737
)}
3838
{...props}

packages/react/src/components/ui/text-field-group.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ function TextFieldGroup(
286286
))}
287287
<input
288288
className={cn(
289-
'min-w-[104px] flex-1 shrink-0 bg-transparent py-2 outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium',
289+
'placeholder:text-muted-foreground min-w-[104px] flex-1 shrink-0 bg-transparent py-2 outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:opacity-50',
290290
isDisabled && 'cursor-not-allowed opacity-50',
291291
!hasChips && 'px-2',
292292
startAdornment && 'pl-0',
@@ -362,7 +362,7 @@ function TextFieldGroup(
362362
)}
363363
<input
364364
className={cn(
365-
'w-full flex-1 bg-transparent px-3 py-2 outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium',
365+
'placeholder:text-muted-foreground w-full flex-1 bg-transparent px-3 py-2 outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:opacity-50',
366366
isDisabled && 'cursor-not-allowed opacity-50',
367367
hasChips && 'pl-0',
368368
hasChips && 'min-w-[104px]',

packages/react/src/components/ui/text-field.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
7272
)}
7373
<input
7474
className={cn(
75-
'w-full flex-1 bg-transparent px-3 py-2 outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium',
75+
'placeholder:text-muted-foreground w-full flex-1 bg-transparent px-3 py-2 outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:opacity-50',
7676
isDisabled &&
7777
'bg-input-muted text-input-muted-foreground cursor-not-allowed opacity-50',
7878
startAdornment && 'pl-0',

0 commit comments

Comments
 (0)