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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user