feat: Improve V2Select multi-select dropdown item display

- Enhanced the display of selected items in the V2Select component to show labels in a comma-separated format, improving user visibility without needing to open the dropdown.
- Implemented tooltip functionality that activates only when the text is truncated, allowing users to see all selected items at a glance.
- Updated the DropdownSelect component to ensure consistent behavior across all screens using this component.
- Added necessary imports and state management for tooltip visibility and text truncation detection.

Made-with: Cursor
This commit is contained in:
2026-03-04 10:11:48 +09:00
parent cfd49020a0
commit 2b324d083b
7 changed files with 919 additions and 83 deletions

View File

@@ -12,7 +12,7 @@
* - swap: 스왑 선택 (좌우 이동)
*/
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
@@ -57,6 +57,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
style,
}, ref) => {
const [open, setOpen] = useState(false);
const textRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [hoverTooltip, setHoverTooltip] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
@@ -129,6 +132,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
useEffect(() => {
const el = textRef.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, [selectedLabels]);
const handleSelect = useCallback((selectedValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(selectedValue)
@@ -148,91 +158,109 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
onChange?.(multiple ? [] : "");
}, [multiple, onChange]);
const displayText = selectedLabels.length > 0
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
: placeholder;
const isPlaceholder = selectedLabels.length === 0;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent", // 표준 Select와 동일한 투명 배경
"border-input shadow-xs", // 표준 Select와 동일한 테두리
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
className,
)}
style={style}
>
<span
className="truncate flex-1 text-left"
{...(selectedLabels.length === 0 ? { "data-placeholder": placeholder } : {})}
>
{selectedLabels.length > 0
? multiple
? `${selectedLabels.length}개 선택됨`
: selectedLabels[0]
: placeholder}
</span>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onPointerDown={(e) => {
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
<div
className="relative w-full"
onMouseEnter={() => { if (isTruncated && multiple) setHoverTooltip(true); }}
onMouseLeave={() => setHoverTooltip(false)}
>
<Popover open={open} onOpenChange={(isOpen) => {
setOpen(isOpen);
if (isOpen) setHoverTooltip(false);
}}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent",
"border-input shadow-xs",
"h-6 px-2 py-0 text-sm",
className,
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
style={style}
>
<span
ref={textRef}
className="truncate flex-1 text-left"
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
>
{displayText}
</span>
<div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(itemValue, search) => {
if (!search) return 1;
const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{hoverTooltip && !open && (
<div className="absolute bottom-full left-0 z-50 mb-1 rounded-md border bg-popover px-3 py-1.5 shadow-md animate-in fade-in-0 zoom-in-95">
<div className="space-y-0.5 text-xs">
{selectedLabels.map((label, i) => (
<div key={i}>{label}</div>
))}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(itemValue, search) => {
if (!search) return 1;
const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
);
});
DropdownSelect.displayName = "DropdownSelect";