Skip to content

Commit ae427eb

Browse files
authored
Merge pull request #374 from auth0/fix/update-uds-fixes-on-uic
fix(react): update uds fixes for combobox, popover, and text-field
2 parents a4b15a0 + c3b95a9 commit ae427eb

4 files changed

Lines changed: 120 additions & 16 deletions

File tree

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

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

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)