- Introduced new documents detailing the implementation of visual separation for three-level category dropdowns. - Updated the `flattenTree` function in both `V2Select.tsx` and `UnifiedSelect.tsx` to use Non-Breaking Space (`\u00A0`) for indentation, ensuring proper visual hierarchy. - Included a checklist to track the implementation progress and verification of the changes. - Documented the rationale behind the changes, including the issues with HTML whitespace collapsing and the decisions made to enhance user experience. These updates aim to improve the clarity and usability of the category selection interface in the application.
1351 lines
47 KiB
TypeScript
1351 lines
47 KiB
TypeScript
"use client";
|
|
|
|
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
|
|
|
/**
|
|
* V2Select
|
|
*
|
|
* 통합 선택 컴포넌트
|
|
* - dropdown: 드롭다운 선택
|
|
* - radio: 라디오 버튼 그룹
|
|
* - check: 체크박스 그룹
|
|
* - tag: 태그 선택
|
|
* - toggle: 토글 스위치
|
|
* - swap: 스왑 선택 (좌우 이동)
|
|
*/
|
|
|
|
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";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components";
|
|
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import V2FormContext from "./V2FormContext";
|
|
|
|
/**
|
|
* 드롭다운 선택 컴포넌트
|
|
*/
|
|
const DropdownSelect = forwardRef<
|
|
HTMLButtonElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string | string[];
|
|
onChange?: (value: string | string[]) => void;
|
|
placeholder?: string;
|
|
searchable?: boolean;
|
|
multiple?: boolean;
|
|
maxSelect?: number;
|
|
allowClear?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
}
|
|
>(
|
|
(
|
|
{
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = "선택",
|
|
searchable,
|
|
multiple,
|
|
maxSelect,
|
|
allowClear = true,
|
|
disabled,
|
|
className,
|
|
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(() => {
|
|
if (!value) return false;
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
return value !== "";
|
|
}, [value]);
|
|
|
|
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
|
if (!searchable && !multiple) {
|
|
return (
|
|
<div className="group relative w-full">
|
|
<Select
|
|
value={typeof value === "string" ? value : (value?.[0] ?? "")}
|
|
onValueChange={(v) => onChange?.(v)}
|
|
disabled={disabled}
|
|
>
|
|
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
|
|
<SelectTrigger
|
|
ref={ref}
|
|
className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)}
|
|
style={style}
|
|
>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options
|
|
.filter((option) => option.value != null && option.value !== "")
|
|
.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{/* 초기화 버튼 (값이 있을 때만 표시) */}
|
|
{allowClear && hasValue && !disabled && (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
className="absolute top-1/2 right-7 z-10 -translate-y-1/2 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
onChange?.("");
|
|
}}
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}}
|
|
>
|
|
<X className="h-3.5 w-3.5 opacity-40 transition-opacity hover:opacity-100" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 검색 가능 또는 다중 선택 → Combobox 사용
|
|
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
|
|
const safeOptions = useMemo(() => options.filter((o) => o.value != null && o.value !== ""), [options]);
|
|
|
|
const selectedValues = useMemo(() => {
|
|
if (!value) return [];
|
|
return Array.isArray(value) ? value : [value];
|
|
}, [value]);
|
|
|
|
const selectedLabels = useMemo(() => {
|
|
return safeOptions
|
|
.filter((o) => selectedValues.includes(o.value))
|
|
.map((o) => o.label)
|
|
.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)
|
|
? selectedValues.filter((v) => v !== selectedValue)
|
|
: maxSelect && selectedValues.length >= maxSelect
|
|
? selectedValues
|
|
: [...selectedValues, selectedValue];
|
|
onChange?.(newValues);
|
|
} else {
|
|
onChange?.(selectedValue);
|
|
setOpen(false);
|
|
}
|
|
},
|
|
[multiple, selectedValues, maxSelect, onChange],
|
|
);
|
|
|
|
const handleClear = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onChange?.(multiple ? [] : "");
|
|
},
|
|
[multiple, onChange],
|
|
);
|
|
|
|
const displayText =
|
|
selectedLabels.length > 0 ? (multiple ? selectedLabels.join(", ") : selectedLabels[0]) : placeholder;
|
|
const isPlaceholder = selectedLabels.length === 0;
|
|
|
|
return (
|
|
<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,
|
|
)}
|
|
style={style}
|
|
>
|
|
<span
|
|
ref={textRef}
|
|
className="flex-1 truncate text-left"
|
|
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
|
>
|
|
{displayText}
|
|
</span>
|
|
<div className="ml-2 flex items-center gap-1">
|
|
{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="bg-popover animate-in fade-in-0 zoom-in-95 absolute bottom-full left-0 z-50 mb-1 rounded-md border px-3 py-1.5 shadow-md">
|
|
<div className="space-y-0.5 text-xs">
|
|
{selectedLabels.map((label, i) => (
|
|
<div key={i}>{label}</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
DropdownSelect.displayName = "DropdownSelect";
|
|
|
|
/**
|
|
* 라디오 선택 컴포넌트
|
|
*/
|
|
const RadioSelect = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
>(({ options, value, onChange, disabled, className }, ref) => {
|
|
return (
|
|
<RadioGroup
|
|
ref={ref}
|
|
value={value ?? ""}
|
|
onValueChange={onChange}
|
|
disabled={disabled}
|
|
className={cn("flex flex-wrap gap-4", className)}
|
|
>
|
|
{options.map((option) => (
|
|
<div key={option.value} className="flex items-center space-x-2">
|
|
<RadioGroupItem value={option.value} id={`radio-${option.value}`} />
|
|
<Label htmlFor={`radio-${option.value}`} className="cursor-pointer text-sm">
|
|
{option.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
);
|
|
});
|
|
RadioSelect.displayName = "RadioSelect";
|
|
|
|
/**
|
|
* 체크박스 선택 컴포넌트
|
|
*/
|
|
const CheckSelect = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string[];
|
|
onChange?: (value: string[]) => void;
|
|
maxSelect?: number;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
|
|
const handleChange = useCallback(
|
|
(optionValue: string, checked: boolean) => {
|
|
if (checked) {
|
|
if (maxSelect && value.length >= maxSelect) return;
|
|
onChange?.([...value, optionValue]);
|
|
} else {
|
|
onChange?.(value.filter((v) => v !== optionValue));
|
|
}
|
|
},
|
|
[value, maxSelect, onChange],
|
|
);
|
|
|
|
return (
|
|
<div ref={ref} className={cn("flex flex-wrap gap-4", className)}>
|
|
{options.map((option) => (
|
|
<div key={option.value} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`check-${option.value}`}
|
|
checked={value.includes(option.value)}
|
|
onCheckedChange={(checked) => handleChange(option.value, checked as boolean)}
|
|
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
|
|
/>
|
|
<Label htmlFor={`check-${option.value}`} className="cursor-pointer text-sm">
|
|
{option.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|
|
CheckSelect.displayName = "CheckSelect";
|
|
|
|
/**
|
|
* 태그 선택 컴포넌트
|
|
*/
|
|
const TagSelect = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string[];
|
|
onChange?: (value: string[]) => void;
|
|
maxSelect?: number;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
|
|
const handleToggle = useCallback(
|
|
(optionValue: string) => {
|
|
const isSelected = value.includes(optionValue);
|
|
if (isSelected) {
|
|
onChange?.(value.filter((v) => v !== optionValue));
|
|
} else {
|
|
if (maxSelect && value.length >= maxSelect) return;
|
|
onChange?.([...value, optionValue]);
|
|
}
|
|
},
|
|
[value, maxSelect, onChange],
|
|
);
|
|
|
|
return (
|
|
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
|
|
{options.map((option) => {
|
|
const isSelected = value.includes(option.value);
|
|
return (
|
|
<Badge
|
|
key={option.value}
|
|
variant={isSelected ? "default" : "outline"}
|
|
className={cn("cursor-pointer transition-colors", disabled && "cursor-not-allowed opacity-50")}
|
|
onClick={() => !disabled && handleToggle(option.value)}
|
|
>
|
|
{option.label}
|
|
{isSelected && <X className="ml-1 h-3 w-3" />}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
TagSelect.displayName = "TagSelect";
|
|
|
|
/**
|
|
* 태그박스 선택 컴포넌트 (태그 형태 + 체크박스 드롭다운)
|
|
* - 선택된 값들이 태그(Badge)로 표시됨
|
|
* - 클릭하면 체크박스 목록이 드롭다운으로 열림
|
|
*/
|
|
const TagboxSelect = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string[];
|
|
onChange?: (value: string[]) => void;
|
|
placeholder?: string;
|
|
maxSelect?: number;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
}
|
|
>(({ options, value = [], onChange, placeholder = "선택하세요", maxSelect, disabled, className, style }, ref) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// 선택된 옵션들의 라벨 가져오기
|
|
const selectedOptions = useMemo(() => options.filter((o) => value.includes(o.value)), [options, value]);
|
|
|
|
// 체크박스 토글 핸들러
|
|
const handleToggle = useCallback(
|
|
(optionValue: string) => {
|
|
const isSelected = value.includes(optionValue);
|
|
if (isSelected) {
|
|
onChange?.(value.filter((v) => v !== optionValue));
|
|
} else {
|
|
if (maxSelect && value.length >= maxSelect) return;
|
|
onChange?.([...value, optionValue]);
|
|
}
|
|
},
|
|
[value, maxSelect, onChange],
|
|
);
|
|
|
|
// 태그 제거 핸들러
|
|
const handleRemove = useCallback(
|
|
(e: React.MouseEvent, optionValue: string) => {
|
|
e.stopPropagation();
|
|
onChange?.(value.filter((v) => v !== optionValue));
|
|
},
|
|
[value, onChange],
|
|
);
|
|
|
|
// 🔧 높이 처리: style.height가 있으면 minHeight로 사용 (기본 40px 보장)
|
|
const triggerStyle: React.CSSProperties = {
|
|
minHeight: style?.height || 40,
|
|
height: style?.height || "auto",
|
|
maxWidth: "100%", // 🔧 부모 컨테이너를 넘지 않도록
|
|
};
|
|
|
|
return (
|
|
<div ref={ref} className={cn("w-full max-w-full overflow-hidden", className)} style={{ width: style?.width }}>
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
"border-input bg-background ring-offset-background flex w-full max-w-full cursor-pointer flex-wrap items-center gap-1.5 overflow-hidden rounded-md border px-3 py-2 text-sm",
|
|
"hover:border-primary/50 focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
)}
|
|
style={triggerStyle}
|
|
>
|
|
{selectedOptions.length > 0 ? (
|
|
<>
|
|
{selectedOptions.map((option) => (
|
|
<Badge key={option.value} variant="secondary" className="flex items-center gap-1 px-2 py-0.5">
|
|
{option.label}
|
|
<X
|
|
className="hover:text-destructive h-3 w-3 cursor-pointer"
|
|
onClick={(e) => !disabled && handleRemove(e, option.value)}
|
|
/>
|
|
</Badge>
|
|
))}
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
)}
|
|
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
|
</div>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<div className="max-h-[300px] overflow-auto p-2">
|
|
{options.map((option) => {
|
|
const isSelected = value.includes(option.value);
|
|
return (
|
|
<div
|
|
key={option.value}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
|
|
"hover:bg-accent hover:text-accent-foreground",
|
|
isSelected && "bg-accent/50",
|
|
)}
|
|
onClick={() => !disabled && handleToggle(option.value)}
|
|
>
|
|
<Checkbox checked={isSelected} disabled={disabled} className="pointer-events-none" />
|
|
<span>{option.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{options.length === 0 && (
|
|
<div className="text-muted-foreground py-2 text-center text-sm">옵션이 없습니다</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
});
|
|
TagboxSelect.displayName = "TagboxSelect";
|
|
|
|
/**
|
|
* 토글 선택 컴포넌트 (Boolean용)
|
|
*/
|
|
const ToggleSelect = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
>(({ options, value, onChange, disabled, className }, ref) => {
|
|
// 토글은 2개 옵션만 지원
|
|
const [offOption, onOption] =
|
|
options.length >= 2
|
|
? [options[0], options[1]]
|
|
: [
|
|
{ value: "false", label: "아니오" },
|
|
{ value: "true", label: "예" },
|
|
];
|
|
|
|
const isOn = value === onOption.value;
|
|
|
|
return (
|
|
<div ref={ref} className={cn("flex items-center gap-3", className)}>
|
|
<span className={cn("text-sm", !isOn && "font-medium")}>{offOption.label}</span>
|
|
<Switch
|
|
checked={isOn}
|
|
onCheckedChange={(checked) => onChange?.(checked ? onOption.value : offOption.value)}
|
|
disabled={disabled}
|
|
/>
|
|
<span className={cn("text-sm", isOn && "font-medium")}>{onOption.label}</span>
|
|
</div>
|
|
);
|
|
});
|
|
ToggleSelect.displayName = "ToggleSelect";
|
|
|
|
/**
|
|
* 스왑 선택 컴포넌트 (좌우 이동 방식)
|
|
*/
|
|
const SwapSelect = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
options: SelectOption[];
|
|
value?: string[];
|
|
onChange?: (value: string[]) => void;
|
|
maxSelect?: number;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
>(({ options, value = [], onChange, disabled, className }, ref) => {
|
|
const available = useMemo(() => options.filter((o) => !value.includes(o.value)), [options, value]);
|
|
|
|
const selected = useMemo(() => options.filter((o) => value.includes(o.value)), [options, value]);
|
|
|
|
const handleMoveRight = useCallback(
|
|
(optionValue: string) => {
|
|
onChange?.([...value, optionValue]);
|
|
},
|
|
[value, onChange],
|
|
);
|
|
|
|
const handleMoveLeft = useCallback(
|
|
(optionValue: string) => {
|
|
onChange?.(value.filter((v) => v !== optionValue));
|
|
},
|
|
[value, onChange],
|
|
);
|
|
|
|
const handleMoveAllRight = useCallback(() => {
|
|
onChange?.(options.map((o) => o.value));
|
|
}, [options, onChange]);
|
|
|
|
const handleMoveAllLeft = useCallback(() => {
|
|
onChange?.([]);
|
|
}, [onChange]);
|
|
|
|
return (
|
|
<div ref={ref} className={cn("flex items-stretch gap-2", className)}>
|
|
{/* 왼쪽: 선택 가능 */}
|
|
<div className="flex min-h-0 flex-1 flex-col rounded-md border">
|
|
<div className="bg-muted shrink-0 border-b p-2 text-xs font-medium">선택 가능</div>
|
|
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
|
{available.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className={cn(
|
|
"hover:bg-accent cursor-pointer rounded p-2 text-sm",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
)}
|
|
onClick={() => !disabled && handleMoveRight(option.value)}
|
|
>
|
|
{option.label}
|
|
</div>
|
|
))}
|
|
{available.length === 0 && <div className="text-muted-foreground p-2 text-xs">항목 없음</div>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙: 이동 버튼 */}
|
|
<div className="flex flex-col justify-center gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleMoveAllRight}
|
|
disabled={disabled || available.length === 0}
|
|
>
|
|
<ArrowLeftRight className="h-4 w-4 rotate-180" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={handleMoveAllLeft}
|
|
disabled={disabled || selected.length === 0}
|
|
>
|
|
<ArrowLeftRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 오른쪽: 선택됨 */}
|
|
<div className="flex min-h-0 flex-1 flex-col rounded-md border">
|
|
<div className="bg-primary/10 shrink-0 border-b p-2 text-xs font-medium">선택됨</div>
|
|
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
|
{selected.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className={cn(
|
|
"hover:bg-accent flex cursor-pointer items-center justify-between rounded p-2 text-sm",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
)}
|
|
onClick={() => !disabled && handleMoveLeft(option.value)}
|
|
>
|
|
<span>{option.label}</span>
|
|
<X className="h-3 w-3 opacity-50" />
|
|
</div>
|
|
))}
|
|
{selected.length === 0 && <div className="text-muted-foreground p-2 text-xs">선택 없음</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
SwapSelect.displayName = "SwapSelect";
|
|
|
|
/**
|
|
* 메인 V2Select 컴포넌트
|
|
*/
|
|
export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) => {
|
|
const {
|
|
id,
|
|
label,
|
|
required,
|
|
readonly,
|
|
disabled,
|
|
style,
|
|
size,
|
|
config: configProp,
|
|
value,
|
|
onChange,
|
|
onFormDataChange,
|
|
tableName,
|
|
columnName,
|
|
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
|
|
} = props;
|
|
|
|
// config가 없으면 기본값 사용
|
|
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
|
|
|
|
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
|
|
const allComponents = (props as any).allComponents as any[] | undefined;
|
|
|
|
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
|
const [loading, setLoading] = useState(false);
|
|
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
|
|
|
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
|
|
const rawSource = config.source;
|
|
const categoryTable = (config as any).categoryTable;
|
|
const categoryColumn = (config as any).categoryColumn;
|
|
|
|
// category 소스 유지 (category_values 테이블에서 로드)
|
|
const source = rawSource;
|
|
const codeGroup = config.codeGroup;
|
|
|
|
const entityTable = config.entityTable;
|
|
const entityValueColumn = config.entityValueColumn || config.entityValueField;
|
|
const entityLabelColumn = config.entityLabelColumn || config.entityLabelField;
|
|
const table = config.table;
|
|
const valueColumn = config.valueColumn;
|
|
const labelColumn = config.labelColumn;
|
|
const apiEndpoint = config.apiEndpoint;
|
|
const staticOptions = config.options;
|
|
const configFilters = config.filters;
|
|
|
|
// 계층 코드 연쇄 선택 관련
|
|
const hierarchical = config.hierarchical;
|
|
const parentField = config.parentField;
|
|
|
|
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
|
|
const formContext = useContext(V2FormContext);
|
|
|
|
/**
|
|
* 필터 조건을 API 전달용 JSON으로 변환
|
|
* field/user 타입은 런타임 값으로 치환
|
|
*/
|
|
const resolvedFiltersJson = useMemo(() => {
|
|
if (!configFilters || configFilters.length === 0) return undefined;
|
|
|
|
const resolved: Array<{ column: string; operator: string; value: unknown }> = [];
|
|
|
|
for (const f of configFilters) {
|
|
const vt = f.valueType || "static";
|
|
|
|
// isNull/isNotNull은 값 불필요
|
|
if (f.operator === "isNull" || f.operator === "isNotNull") {
|
|
resolved.push({ column: f.column, operator: f.operator, value: null });
|
|
continue;
|
|
}
|
|
|
|
let resolvedValue: unknown = f.value;
|
|
|
|
if (vt === "field" && f.fieldRef) {
|
|
// 다른 폼 필드 참조
|
|
if (formContext) {
|
|
resolvedValue = formContext.getValue(f.fieldRef);
|
|
} else {
|
|
const fd = (props as any).formData;
|
|
resolvedValue = fd?.[f.fieldRef];
|
|
}
|
|
// 참조 필드 값이 비어있으면 이 필터 건너뜀
|
|
if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue;
|
|
} else if (vt === "user" && f.userField) {
|
|
// 로그인 사용자 정보 참조 (props에서 가져옴)
|
|
const userMap: Record<string, string | undefined> = {
|
|
companyCode: (props as any).companyCode,
|
|
userId: (props as any).userId,
|
|
deptCode: (props as any).deptCode,
|
|
userName: (props as any).userName,
|
|
};
|
|
resolvedValue = userMap[f.userField];
|
|
if (!resolvedValue) continue;
|
|
}
|
|
|
|
resolved.push({ column: f.column, operator: f.operator, value: resolvedValue });
|
|
}
|
|
|
|
return resolved.length > 0 ? JSON.stringify(resolved) : undefined;
|
|
}, [configFilters, formContext, props]);
|
|
|
|
// 부모 필드의 값 계산
|
|
const parentValue = useMemo(() => {
|
|
if (!hierarchical || !parentField) return null;
|
|
|
|
// FormContext가 있으면 거기서 값 가져오기
|
|
if (formContext) {
|
|
const val = formContext.getValue(parentField);
|
|
return val as string | null;
|
|
}
|
|
|
|
return null;
|
|
}, [hierarchical, parentField, formContext]);
|
|
|
|
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
|
|
useEffect(() => {
|
|
// 계층 구조인 경우 부모 값이 변경되면 다시 로드
|
|
if (hierarchical && source === "code") {
|
|
setOptionsLoaded(false);
|
|
}
|
|
}, [parentValue, hierarchical, source]);
|
|
|
|
// 필터 조건이 변경되면 옵션 다시 로드
|
|
useEffect(() => {
|
|
if (resolvedFiltersJson !== undefined) {
|
|
setOptionsLoaded(false);
|
|
}
|
|
}, [resolvedFiltersJson]);
|
|
|
|
useEffect(() => {
|
|
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
|
|
if (optionsLoaded && source !== "static") {
|
|
return;
|
|
}
|
|
|
|
const loadOptions = async () => {
|
|
if (source === "static") {
|
|
setOptions(staticOptions || []);
|
|
setOptionsLoaded(true);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
let fetchedOptions: SelectOption[] = [];
|
|
|
|
if (source === "code" && codeGroup) {
|
|
// 계층 구조 사용 시 자식 코드만 로드
|
|
if (hierarchical) {
|
|
const params = new URLSearchParams();
|
|
if (parentValue) {
|
|
params.append("parentCodeValue", parentValue);
|
|
}
|
|
const queryString = params.toString();
|
|
const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`;
|
|
const response = await apiClient.get(url);
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({
|
|
value: item.value,
|
|
label: item.label,
|
|
}));
|
|
}
|
|
} else {
|
|
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
|
|
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
|
value: item.value,
|
|
label: item.label,
|
|
}));
|
|
}
|
|
}
|
|
} else if (source === "db" && table) {
|
|
// DB 테이블에서 로드
|
|
const dbParams: Record<string, any> = {
|
|
value: valueColumn || "id",
|
|
label: labelColumn || "name",
|
|
};
|
|
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
|
|
const response = await apiClient.get(`/entity/${table}/options`, {
|
|
params: dbParams,
|
|
});
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
fetchedOptions = data.data;
|
|
}
|
|
} else if (source === "entity" && entityTable) {
|
|
// 엔티티(참조 테이블)에서 로드
|
|
const valueCol = entityValueColumn || "id";
|
|
const labelCol = entityLabelColumn || "name";
|
|
const entityParams: Record<string, any> = { value: valueCol, label: labelCol };
|
|
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
|
|
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
|
params: entityParams,
|
|
});
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
fetchedOptions = data.data;
|
|
}
|
|
} else if (source === "api" && apiEndpoint) {
|
|
// 외부 API에서 로드
|
|
const response = await apiClient.get(apiEndpoint);
|
|
const data = response.data;
|
|
if (Array.isArray(data)) {
|
|
fetchedOptions = data;
|
|
}
|
|
} else if (source === "category") {
|
|
// 카테고리에서 로드 (category_values 테이블)
|
|
// tableName, columnName은 props에서 가져옴
|
|
const catTable = categoryTable || tableName;
|
|
const catColumn = categoryColumn || columnName;
|
|
|
|
if (catTable && catColumn) {
|
|
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
// 트리 구조를 평탄화하여 옵션으로 변환
|
|
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
|
|
const flattenTree = (
|
|
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
|
|
depth: number = 0,
|
|
): SelectOption[] => {
|
|
const result: SelectOption[] = [];
|
|
for (const item of items) {
|
|
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
|
result.push({
|
|
value: item.valueCode, // 🔧 valueCode를 value로 사용
|
|
label: prefix + item.valueLabel,
|
|
});
|
|
if (item.children && item.children.length > 0) {
|
|
result.push(...flattenTree(item.children, depth + 1));
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
fetchedOptions = flattenTree(data.data);
|
|
}
|
|
}
|
|
} else if (source === "select" || source === "distinct") {
|
|
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
|
|
const isValidColumnName = columnName && !columnName.startsWith("comp_");
|
|
if (tableName && isValidColumnName) {
|
|
const distinctParams: Record<string, any> = {};
|
|
if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
|
|
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
|
|
params: distinctParams,
|
|
});
|
|
const data = response.data;
|
|
if (data.success && data.data) {
|
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
|
value: String(item.value),
|
|
label: String(item.label),
|
|
}));
|
|
}
|
|
} else if (!isValidColumnName) {
|
|
// columnName이 없거나 유효하지 않으면 빈 옵션
|
|
}
|
|
}
|
|
|
|
// null/undefined value 필터링 (cmdk 크래시 방지)
|
|
const sanitized = fetchedOptions
|
|
.filter((o) => o.value != null && String(o.value) !== "")
|
|
.map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) }));
|
|
setOptions(sanitized);
|
|
setOptionsLoaded(true);
|
|
} catch (error) {
|
|
console.error("옵션 로딩 실패:", error);
|
|
setOptions([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadOptions();
|
|
}, [
|
|
source,
|
|
entityTable,
|
|
entityValueColumn,
|
|
entityLabelColumn,
|
|
codeGroup,
|
|
table,
|
|
valueColumn,
|
|
labelColumn,
|
|
apiEndpoint,
|
|
staticOptions,
|
|
optionsLoaded,
|
|
hierarchical,
|
|
parentValue,
|
|
resolvedFiltersJson,
|
|
]);
|
|
|
|
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
|
|
const resolvedValue = useMemo(() => {
|
|
if (!value || options.length === 0) return value;
|
|
|
|
const resolveOne = (v: string): string => {
|
|
if (options.some((o) => o.value === v)) return v;
|
|
const trimmed = v.trim();
|
|
const match = options.find((o) => {
|
|
const cleanLabel = o.label.replace(/^[\s└]+/, "").trim();
|
|
return cleanLabel === trimmed;
|
|
});
|
|
return match ? match.value : v;
|
|
};
|
|
|
|
if (Array.isArray(value)) {
|
|
const resolved = value.map(resolveOne);
|
|
return resolved.every((v, i) => v === value[i]) ? value : resolved;
|
|
}
|
|
|
|
// 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx")
|
|
if (typeof value === "string" && value.includes(",")) {
|
|
const parts = value.split(",");
|
|
const resolved = parts.map((p) => resolveOne(p.trim()));
|
|
const result = resolved.join(",");
|
|
return result === value ? value : result;
|
|
}
|
|
|
|
return resolveOne(value);
|
|
}, [value, options]);
|
|
|
|
// 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환)
|
|
useEffect(() => {
|
|
if (!onChange || options.length === 0 || !value || value === resolvedValue) return;
|
|
onChange(resolvedValue as string | string[]);
|
|
}, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
|
|
const autoFillTargets = useMemo(() => {
|
|
if (source !== "entity" || !entityTable || !allComponents) return [];
|
|
|
|
const targets: Array<{ sourceField: string; targetColumnName: string }> = [];
|
|
for (const comp of allComponents) {
|
|
if (comp.id === id) continue;
|
|
|
|
// overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음)
|
|
const ov = (comp as any).overrides || {};
|
|
const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || "";
|
|
|
|
// 방법1: entityJoinTable 속성이 있는 경우
|
|
const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable;
|
|
const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn;
|
|
if (joinTable === entityTable && joinColumn) {
|
|
targets.push({ sourceField: joinColumn, targetColumnName: compColumnName });
|
|
continue;
|
|
}
|
|
|
|
// 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit)
|
|
if (compColumnName.includes(".")) {
|
|
const [prefix, actualColumn] = compColumnName.split(".");
|
|
if (prefix === entityTable && actualColumn) {
|
|
targets.push({ sourceField: actualColumn, targetColumnName: compColumnName });
|
|
}
|
|
}
|
|
}
|
|
return targets;
|
|
}, [source, entityTable, allComponents, id]);
|
|
|
|
// 엔티티 autoFill 적용 래퍼
|
|
const handleChangeWithAutoFill = useCallback(
|
|
(newValue: string | string[]) => {
|
|
onChange?.(newValue);
|
|
|
|
if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return;
|
|
|
|
const selectedKey = typeof newValue === "string" ? newValue : newValue[0];
|
|
if (!selectedKey) return;
|
|
|
|
const valueCol = entityValueColumn || "id";
|
|
|
|
apiClient
|
|
.get(`/table-management/tables/${entityTable}/data-with-joins`, {
|
|
params: {
|
|
page: 1,
|
|
size: 1,
|
|
search: JSON.stringify({ [valueCol]: selectedKey }),
|
|
autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }),
|
|
},
|
|
})
|
|
.then((res) => {
|
|
const responseData = res.data?.data;
|
|
const rows = responseData?.data || responseData?.rows || [];
|
|
if (rows.length > 0) {
|
|
const fullData = rows[0];
|
|
for (const target of autoFillTargets) {
|
|
const sourceValue = fullData[target.sourceField];
|
|
if (sourceValue !== undefined) {
|
|
onFormDataChange(target.targetColumnName, sourceValue);
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.catch((err) => console.error("autoFill 조회 실패:", err));
|
|
},
|
|
[onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn],
|
|
);
|
|
|
|
// 모드별 컴포넌트 렌더링
|
|
const renderSelect = () => {
|
|
if (loading) {
|
|
return <div className="text-muted-foreground flex h-full items-center text-sm">로딩 중...</div>;
|
|
}
|
|
|
|
const isDisabled = disabled || readonly;
|
|
|
|
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
|
|
const heightStyle: React.CSSProperties | undefined = componentHeight ? { height: componentHeight } : undefined;
|
|
|
|
// 🔧 디자인 모드용: 옵션이 없고 dropdown/combobox가 아닌 모드일 때 source 정보 표시
|
|
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap"];
|
|
if (options.length === 0 && nonDropdownModes.includes(config.mode || "dropdown")) {
|
|
// 데이터 소스 정보 기반 메시지 생성
|
|
let sourceInfo = "";
|
|
if (source === "static") {
|
|
sourceInfo = "정적 옵션 설정 필요";
|
|
} else if (source === "code") {
|
|
sourceInfo = codeGroup ? `공통코드: ${codeGroup}` : "공통코드 설정 필요";
|
|
} else if (source === "entity") {
|
|
sourceInfo = entityTable ? `엔티티: ${entityTable}` : "엔티티 설정 필요";
|
|
} else if (source === "category") {
|
|
const catInfo = categoryTable || tableName || columnName;
|
|
sourceInfo = catInfo ? `카테고리: ${catInfo}` : "카테고리 설정 필요";
|
|
} else if (source === "db") {
|
|
sourceInfo = table ? `테이블: ${table}` : "테이블 설정 필요";
|
|
} else if (!source || source === "distinct") {
|
|
// distinct 또는 미설정인 경우 - 컬럼명 기반으로 표시
|
|
sourceInfo = columnName ? `컬럼: ${columnName}` : "데이터 소스 설정 필요";
|
|
} else {
|
|
sourceInfo = `소스: ${source}`;
|
|
}
|
|
|
|
// 모드 이름 한글화
|
|
const modeNames: Record<string, string> = {
|
|
radio: "라디오",
|
|
check: "체크박스",
|
|
checkbox: "체크박스",
|
|
tag: "태그",
|
|
tagbox: "태그박스",
|
|
toggle: "토글",
|
|
swap: "스왑",
|
|
};
|
|
const modeName = modeNames[config.mode || ""] || config.mode;
|
|
|
|
return (
|
|
<div className="text-muted-foreground flex h-full items-center justify-center rounded border border-dashed p-2 text-xs">
|
|
<span className="opacity-70">
|
|
[{modeName}] {sourceInfo}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
switch (config.mode) {
|
|
case "dropdown":
|
|
case "combobox":
|
|
return (
|
|
<DropdownSelect
|
|
options={options}
|
|
value={resolvedValue}
|
|
onChange={handleChangeWithAutoFill}
|
|
placeholder="선택"
|
|
searchable={config.mode === "combobox" ? true : config.searchable}
|
|
multiple={config.multiple}
|
|
maxSelect={config.maxSelect}
|
|
allowClear={config.allowClear}
|
|
disabled={isDisabled}
|
|
style={heightStyle}
|
|
/>
|
|
);
|
|
|
|
case "radio":
|
|
return (
|
|
<RadioSelect
|
|
options={options}
|
|
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
|
onChange={(v) => handleChangeWithAutoFill(v)}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "check":
|
|
case "checkbox":
|
|
return (
|
|
<CheckSelect
|
|
options={options}
|
|
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
|
onChange={handleChangeWithAutoFill}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "tag":
|
|
return (
|
|
<TagSelect
|
|
options={options}
|
|
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
|
onChange={handleChangeWithAutoFill}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "tagbox":
|
|
return (
|
|
<TagboxSelect
|
|
options={options}
|
|
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
|
onChange={handleChangeWithAutoFill}
|
|
placeholder={config.placeholder || "선택하세요"}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
style={heightStyle}
|
|
/>
|
|
);
|
|
|
|
case "toggle":
|
|
return (
|
|
<ToggleSelect
|
|
options={options}
|
|
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
|
onChange={(v) => handleChangeWithAutoFill(v)}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "swap":
|
|
return (
|
|
<SwapSelect
|
|
options={options}
|
|
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
|
onChange={handleChangeWithAutoFill}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<DropdownSelect
|
|
options={options}
|
|
value={resolvedValue}
|
|
onChange={handleChangeWithAutoFill}
|
|
disabled={isDisabled}
|
|
style={heightStyle}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
const showLabel = label && style?.labelDisplay !== false;
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
// 라벨 위치 및 높이 계산
|
|
const labelPos = style?.labelPosition || "top";
|
|
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
|
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
|
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
|
const labelGapValue = style?.labelGap || "8px";
|
|
|
|
// 커스텀 스타일 감지
|
|
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
|
const hasCustomBackground = !!style?.backgroundColor;
|
|
const hasCustomRadius = !!style?.borderRadius;
|
|
|
|
const customTextStyle: React.CSSProperties = {};
|
|
if (style?.color) customTextStyle.color = style.color;
|
|
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
|
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
|
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
|
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
|
|
|
const labelElement = showLabel ? (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
|
|
...(labelPos === "bottom"
|
|
? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 }
|
|
: {}),
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: getAdaptiveLabelColor(style?.labelColor),
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
}}
|
|
className="text-sm font-medium whitespace-nowrap"
|
|
>
|
|
{label}
|
|
{required && <span className="ml-0.5 text-amber-500">*</span>}
|
|
</Label>
|
|
) : null;
|
|
|
|
const selectContent = (
|
|
<div
|
|
className={cn(
|
|
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
|
|
hasCustomBorder && "**:data-[slot=select-trigger]:border-0! [&_.border]:border-0! [&_button]:border-0!",
|
|
(hasCustomBorder || hasCustomRadius) &&
|
|
"**:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none! [&_button]:rounded-none!",
|
|
hasCustomBackground && "**:data-[slot=select-trigger]:bg-transparent! [&_button]:bg-transparent!",
|
|
)}
|
|
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
|
|
>
|
|
{renderSelect()}
|
|
</div>
|
|
);
|
|
|
|
if (isHorizLabel && showLabel) {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className={cn(
|
|
"flex flex-col gap-1",
|
|
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
|
|
isDesignMode && "pointer-events-none",
|
|
)}
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
gap: labelGapValue,
|
|
}}
|
|
>
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: getAdaptiveLabelColor(style?.labelColor),
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
}}
|
|
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
|
|
>
|
|
{label}
|
|
{required && <span className="ml-0.5 text-amber-500">*</span>}
|
|
</Label>
|
|
<div
|
|
className={cn(
|
|
"w-full min-w-0 flex-1",
|
|
hasCustomBorder && "**:data-[slot=select-trigger]:border-0! [&_.border]:border-0! [&_button]:border-0!",
|
|
(hasCustomBorder || hasCustomRadius) &&
|
|
"**:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none! [&_button]:rounded-none!",
|
|
hasCustomBackground && "**:data-[slot=select-trigger]:bg-transparent! [&_button]:bg-transparent!",
|
|
)}
|
|
style={{ ...(hasCustomText ? customTextStyle : {}) }}
|
|
>
|
|
{renderSelect()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className={cn("relative", isDesignMode && "pointer-events-none")}
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{labelElement}
|
|
{selectContent}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
V2Select.displayName = "V2Select";
|
|
|
|
export default V2Select;
|