feat(universal-form-modal): Select 필드 직접 입력(Combobox) 모드 추가

SelectOptionConfig에 allowCustomInput 옵션 추가
FieldDetailSettingsModal에 "직접 입력 허용" Switch UI 추가
CascadingSelectField에 Combobox 모드 구현 (Popover+Command)
SelectField에 Combobox 모드 구현
목록 선택과 직접 입력 동시 지원
This commit is contained in:
SeongHyun Kim 2026-01-13 18:44:59 +09:00
parent ef27e0e38f
commit cf97db7fbf
3 changed files with 199 additions and 1 deletions

View File

@ -19,7 +19,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -42,6 +44,7 @@ import { TableSectionRenderer } from "./TableSectionRenderer";
/**
* 🔗 Select
* allowCustomInput이 true이면 Combobox
*/
interface CascadingSelectFieldProps {
fieldId: string;
@ -51,6 +54,7 @@ interface CascadingSelectFieldProps {
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
allowCustomInput?: boolean;
}
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
@ -61,12 +65,20 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
onChange,
placeholder,
disabled,
allowCustomInput = false,
}) => {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value || "");
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
// value가 외부에서 변경되면 inputValue도 동기화
useEffect(() => {
setInputValue(value || "");
}, [value]);
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
@ -82,6 +94,79 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
const isDisabled = disabled || !parentValue || loading;
// Combobox 형태 (직접 입력 허용)
if (allowCustomInput) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-full">
<Input
id={fieldId}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
onChange(e.target.value);
}}
placeholder={getPlaceholder()}
disabled={isDisabled}
className="w-full pr-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
onClick={() => !isDisabled && setOpen(!open)}
disabled={isDisabled}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty>
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</CommandEmpty>
<CommandGroup>
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
setInputValue(option.label);
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 기본 Select 형태 (목록에서만 선택)
return (
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
<SelectTrigger id={fieldId} className="w-full" size="default">
@ -1503,6 +1588,7 @@ export function UniversalFormModalComponent({
onChange={onChangeHandler}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
allowCustomInput={field.selectOptions?.allowCustomInput}
/>
);
}
@ -1534,6 +1620,7 @@ export function UniversalFormModalComponent({
onChange={onChangeHandler}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
allowCustomInput={field.selectOptions?.allowCustomInput}
/>
);
}
@ -2243,6 +2330,7 @@ export function UniversalFormModalComponent({
}
// Select 필드 컴포넌트 (옵션 로딩 포함)
// allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
interface SelectFieldProps {
fieldId: string;
value: any;
@ -2256,6 +2344,10 @@ interface SelectFieldProps {
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value || "");
const allowCustomInput = optionConfig?.allowCustomInput || false;
useEffect(() => {
if (optionConfig) {
@ -2266,6 +2358,82 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
}
}, [fieldId, optionConfig, loadOptions]);
// value가 외부에서 변경되면 inputValue도 동기화
useEffect(() => {
// 선택된 값이 있으면 해당 라벨을 표시, 없으면 value 그대로 표시
const selectedOption = options.find((opt) => opt.value === value);
setInputValue(selectedOption ? selectedOption.label : value || "");
}, [value, options]);
// Combobox 형태 (직접 입력 허용)
if (allowCustomInput) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-full">
<Input
id={fieldId}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
onChange(e.target.value);
}}
placeholder={loading ? "로딩 중..." : placeholder}
disabled={disabled || loading}
className="w-full pr-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
onClick={() => !disabled && !loading && setOpen(!open)}
disabled={disabled || loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
setInputValue(option.label);
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 기본 Select 형태 (목록에서만 선택)
return (
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
<SelectTrigger size="default">

View File

@ -480,6 +480,31 @@ export function FieldDetailSettingsModal({
</HelpText>
</div>
{/* 직접 입력 허용 - 모든 Select 타입에 공통 적용 */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex flex-col">
<span className="text-[10px] font-medium"> </span>
<span className="text-[9px] text-muted-foreground">
+
</span>
</div>
<Switch
checked={localField.selectOptions?.allowCustomInput || false}
onCheckedChange={(checked) =>
updateField({
selectOptions: {
...localField.selectOptions,
allowCustomInput: checked,
},
})
}
/>
</div>
<HelpText>
, .
.
</HelpText>
{localField.selectOptions?.type === "table" && (
<div className="space-y-3 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>

View File

@ -32,6 +32,11 @@ export interface SelectOptionConfig {
noOptionsMessage?: string; // 옵션 없음 메시지
clearOnParentChange?: boolean; // 부모 변경 시 값 초기화 (기본: true)
};
// 직접 입력 허용 (모든 Select 타입에 공통 적용)
// true: Combobox 형태로 목록 선택 + 직접 입력 가능
// false/undefined: 기본 Select 형태로 목록에서만 선택 가능
allowCustomInput?: boolean;
}
// 채번규칙 설정