Files
invyone/frontend/lib/registry/components/input/select-pickers.tsx
T
DDD1542 3883031c0b feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs
INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:50 +09:00

831 lines
30 KiB
TypeScript

"use client";
/**
* Input 컴포넌트의 select 계열 picker 묶음.
*
* 단계적 이식 (옛 선택 컴포넌트 → 핵심만 InvField canonical 모델로):
* - SingleSelectPicker → InvField choice.single.list (드롭다운, 단일)
* - MultiSelectPicker → InvField choice.multi.list (TODO Phase B.2)
* - TagPicker → InvField choice.multi.tags (TODO Phase B.3)
*
* 외형 통일: outer wrapper transparent + container border 가 box 역할.
* 모든 picker 는 className prop 으로 border/bg override 가능.
*/
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Search, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SingleSelectPickerProps {
value?: string;
onChange?: (value: string) => void;
options: SelectOption[];
placeholder?: string;
searchable?: boolean;
allowClear?: boolean;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const SingleSelectPicker = React.forwardRef<HTMLDivElement, SingleSelectPickerProps>(
({ value, onChange, options, placeholder = "선택", searchable, allowClear, disabled, readonly, className }, ref) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) {
setOpen(false);
setSearchQuery("");
}
};
const escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", escHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", escHandler);
};
}, [open]);
const filteredOptions =
searchable && searchQuery
? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase()))
: options;
const handleSelect = (v: string) => {
onChange?.(v);
setOpen(false);
setSearchQuery("");
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange?.("");
};
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full", (disabled || readonly) && "cursor-not-allowed", className)}
>
<div
ref={ref}
className={cn(
"flex h-full w-full cursor-pointer items-center gap-1 px-2 text-sm",
(disabled || readonly) && "cursor-not-allowed",
)}
onClick={() => {
if (!disabled && !readonly) setOpen((v) => !v);
}}
>
<span className={cn("flex-1 truncate", !selected && "text-muted-foreground")}>
{selected?.label || placeholder}
</span>
{allowClear && selected && !disabled && !readonly && (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={handleClear}
>
</button>
)}
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
{open && (
<div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 max-h-64 overflow-auto rounded-md border shadow-md">
{searchable && (
<div className="border-border bg-popover sticky top-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="검색"
className="border-border bg-background h-7 w-full rounded border pr-2 pl-7 text-xs outline-none"
autoFocus
/>
</div>
</div>
)}
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
filteredOptions.map((opt) => (
<button
key={opt.value}
type="button"
disabled={opt.disabled}
className={cn(
"hover:bg-accent block w-full px-3 py-1.5 text-left text-sm",
value === opt.value && "bg-accent font-medium",
opt.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
{opt.label}
</button>
))
)}
</div>
)}
</div>
);
},
);
SingleSelectPicker.displayName = "SingleSelectPicker";
// ─────────────────────────────────────────────────────────
// MultiSelectPicker — InvField choice.multi.list (다중 선택, 라벨 join 트리거)
// ─────────────────────────────────────────────────────────
interface MultiSelectPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
options: SelectOption[];
placeholder?: string;
searchable?: boolean;
allowClear?: boolean;
maxSelect?: number;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const MultiSelectPicker = React.forwardRef<HTMLDivElement, MultiSelectPickerProps>(
(
{ value, onChange, options, placeholder = "선택", searchable, allowClear, maxSelect, disabled, readonly, className },
ref,
) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
// value 정규화 — undefined/string 방어
const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : [];
const selectedOptions = options.filter((o) => selectedValues.includes(o.value));
const displayLabel = selectedOptions.length === 0 ? "" : selectedOptions.map((o) => o.label).join(", ");
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) {
setOpen(false);
setSearchQuery("");
}
};
const escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setSearchQuery("");
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", escHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", escHandler);
};
}, [open]);
const filteredOptions =
searchable && searchQuery
? options.filter((o) => o.label.toLowerCase().includes(searchQuery.toLowerCase()))
: options;
const handleToggle = (v: string) => {
if (disabled || readonly) return;
if (selectedValues.includes(v)) {
onChange?.(selectedValues.filter((x) => x !== v));
} else {
if (maxSelect && selectedValues.length >= maxSelect) return; // 차단
onChange?.([...selectedValues, v]);
}
};
const handleClearAll = (e: React.MouseEvent) => {
e.stopPropagation();
onChange?.([]);
};
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full", (disabled || readonly) && "cursor-not-allowed", className)}
>
<div
ref={ref}
className={cn(
"flex h-full w-full cursor-pointer items-center gap-1 px-2 text-sm",
(disabled || readonly) && "cursor-not-allowed",
)}
onClick={() => {
if (!disabled && !readonly) setOpen((v) => !v);
}}
>
<span className={cn("flex-1 truncate", selectedOptions.length === 0 && "text-muted-foreground")}>
{displayLabel || placeholder}
</span>
{allowClear && selectedOptions.length > 0 && !disabled && !readonly && (
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={handleClearAll}
>
</button>
)}
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
{open && (
<div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 max-h-64 overflow-auto rounded-md border shadow-md">
{searchable && (
<div className="border-border bg-popover sticky top-0 border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="검색"
className="border-border bg-background h-7 w-full rounded border pr-2 pl-7 text-xs outline-none"
autoFocus
/>
</div>
</div>
)}
{maxSelect && (
<div className="text-muted-foreground border-border border-b px-3 py-1 text-[10px]">
{selectedValues.length} / {maxSelect}
</div>
)}
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
filteredOptions.map((opt) => {
const checked = selectedValues.includes(opt.value);
const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect;
return (
<button
key={opt.value}
type="button"
disabled={opt.disabled || blocked}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
checked && "bg-accent/60",
(opt.disabled || blocked) && "cursor-not-allowed opacity-50 hover:bg-transparent",
)}
onClick={() => handleToggle(opt.value)}
>
<span
className={cn(
"border-border flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border",
checked && "border-primary bg-primary text-primary-foreground",
)}
>
{checked && <Check className="h-3 w-3" />}
</span>
<span className="flex-1 truncate">{opt.label}</span>
</button>
);
})
)}
</div>
)}
</div>
);
},
);
MultiSelectPicker.displayName = "MultiSelectPicker";
// ─────────────────────────────────────────────────────────
// RadioPicker — single 의 displayMode=radio (옵션을 라디오 button list)
// ─────────────────────────────────────────────────────────
interface RadioPickerProps {
value?: string;
onChange?: (value: string) => void;
options: SelectOption[];
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const RadioPicker = React.forwardRef<HTMLDivElement, RadioPickerProps>(
({ value, onChange, options, disabled, readonly, className }, ref) => {
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap content-start items-start gap-x-3 gap-y-1 px-2 py-1 text-sm",
(disabled || readonly) && "cursor-not-allowed",
className,
)}
>
{options.length === 0 ? (
<span className="text-muted-foreground text-xs"> </span>
) : (
options.map((opt) => {
const checked = value === opt.value;
const optDisabled = disabled || readonly || opt.disabled;
return (
<label
key={opt.value}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5",
optDisabled && "cursor-not-allowed",
)}
>
<input
type="radio"
checked={checked}
disabled={optDisabled}
onChange={() => !optDisabled && onChange?.(opt.value)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="select-none">{opt.label}</span>
</label>
);
})
)}
</div>
);
},
);
RadioPicker.displayName = "RadioPicker";
// ─────────────────────────────────────────────────────────
// CheckboxListPicker — multi 의 displayMode=checkbox (옵션을 체크박스 list)
// ─────────────────────────────────────────────────────────
interface CheckboxListPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
options: SelectOption[];
maxSelect?: number;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const CheckboxListPicker = React.forwardRef<HTMLDivElement, CheckboxListPickerProps>(
({ value, onChange, options, maxSelect, disabled, readonly, className }, ref) => {
const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : [];
const handleToggle = (v: string) => {
if (disabled || readonly) return;
if (selectedValues.includes(v)) {
onChange?.(selectedValues.filter((x) => x !== v));
} else {
if (maxSelect && selectedValues.length >= maxSelect) return;
onChange?.([...selectedValues, v]);
}
};
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap content-start items-start gap-x-3 gap-y-1 px-2 py-1 text-sm",
(disabled || readonly) && "cursor-not-allowed",
className,
)}
>
{options.length === 0 ? (
<span className="text-muted-foreground text-xs"> </span>
) : (
options.map((opt) => {
const checked = selectedValues.includes(opt.value);
const blocked = !checked && !!maxSelect && selectedValues.length >= maxSelect;
const optDisabled = disabled || readonly || opt.disabled || blocked;
return (
<label
key={opt.value}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5",
optDisabled && "cursor-not-allowed",
)}
>
<input
type="checkbox"
checked={checked}
disabled={optDisabled}
onChange={() => !optDisabled && handleToggle(opt.value)}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="select-none">{opt.label}</span>
</label>
);
})
)}
</div>
);
},
);
CheckboxListPicker.displayName = "CheckboxListPicker";
// ─────────────────────────────────────────────────────────
// TogglePicker — single.boolean 의 mode=toggle (Y/N 토글 스위치)
// value: boolean | "Y"/"N" | "true"/"false" | 1/0 모두 받음
// ─────────────────────────────────────────────────────────
interface TogglePickerProps {
value?: unknown;
onChange?: (value: boolean) => void;
trueLabel?: string;
falseLabel?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
function isTruthy(v: unknown): boolean {
if (typeof v === "boolean") return v;
if (typeof v === "number") return v !== 0;
if (typeof v === "string") {
const s = v.toLowerCase();
return s === "true" || s === "y" || s === "yes" || s === "1";
}
return false;
}
export const TogglePicker = React.forwardRef<HTMLDivElement, TogglePickerProps>(
({ value, onChange, trueLabel = "예", falseLabel = "아니오", disabled, readonly, className }, ref) => {
const checked = isTruthy(value);
const lockClick = !!disabled || !!readonly;
return (
<div
ref={ref}
className={cn(
"flex h-full w-full items-center gap-2 px-2 text-sm",
lockClick && "cursor-not-allowed",
className,
)}
>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={lockClick}
onClick={() => !lockClick && onChange?.(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors outline-none",
checked ? "bg-primary" : "bg-muted",
lockClick ? "cursor-not-allowed" : "cursor-pointer",
)}
>
<span
className={cn(
"bg-background inline-block h-4 w-4 transform rounded-full shadow transition-transform",
checked ? "translate-x-[18px]" : "translate-x-[2px]",
)}
/>
</button>
<span className="text-foreground select-none">{checked ? trueLabel : falseLabel}</span>
</div>
);
},
);
TogglePicker.displayName = "TogglePicker";
// ─────────────────────────────────────────────────────────
// TagPicker — multi.tags (chip 입력. Enter / 구분자 로 추가, Backspace / ✕ 로 제거)
// ─────────────────────────────────────────────────────────
interface TagPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
placeholder?: string;
maxSelect?: number;
/** 자동 분리 키. ',' 또는 ' ' 등 */
separator?: string;
disabled?: boolean;
readonly?: boolean;
className?: string;
}
export const TagPicker = React.forwardRef<HTMLDivElement, TagPickerProps>(
(
{
value,
onChange,
placeholder = "태그 입력 후 Enter",
maxSelect,
separator,
disabled,
readonly,
className,
},
ref,
) => {
const [input, setInput] = useState("");
const tags: string[] = Array.isArray(value) ? value : [];
const lockEdit = !!disabled || !!readonly;
const commitTags = (raw: string) => {
const parts = separator ? raw.split(separator) : [raw];
const next = [...tags];
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed || next.includes(trimmed)) continue;
if (maxSelect && next.length >= maxSelect) break;
next.push(trimmed);
}
if (next.length !== tags.length) onChange?.(next);
};
const removeTag = (idx: number) => {
onChange?.(tags.filter((_, i) => i !== idx));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || (separator && e.key === separator)) {
e.preventDefault();
if (input.trim()) {
commitTags(input);
setInput("");
}
} else if (e.key === "Backspace" && input === "" && tags.length > 0) {
removeTag(tags.length - 1);
}
};
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-wrap items-center gap-1 px-2 py-1 text-sm",
"content-start",
lockEdit && "cursor-not-allowed",
className,
)}
style={{ alignItems: "flex-start" }}
>
{tags.map((tag, i) => (
<span
key={i}
className="bg-accent text-accent-foreground inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs"
>
<span>{tag}</span>
{!lockEdit && (
<button
type="button"
onClick={() => removeTag(i)}
className="text-muted-foreground hover:text-foreground leading-none"
aria-label={`${tag} 제거`}
>
</button>
)}
</span>
))}
{!lockEdit && (!maxSelect || tags.length < maxSelect) && (
<input
type="text"
value={input}
onChange={(e) => {
const next = e.target.value;
if (separator && next.includes(separator)) {
commitTags(next);
setInput("");
return;
}
setInput(next);
}}
onKeyDown={handleKeyDown}
onBlur={() => {
if (input.trim()) {
commitTags(input);
setInput("");
}
}}
placeholder={tags.length === 0 ? placeholder : ""}
className="min-w-[80px] flex-1 bg-transparent text-sm leading-6 outline-none"
disabled={lockEdit}
/>
)}
</div>
);
},
);
TagPicker.displayName = "TagPicker";
// ─────────────────────────────────────────────────────────
// SwapPicker — multi 의 mode=swap (양쪽 list 간 이동 UI)
//
// 왼쪽 (available) 가운데 오른쪽 (selected)
// ┌──────────────┐ ┌─────┐ ┌──────────────┐
// │ opt A │ │ → │ │ opt B ✓ │
// │ opt C │ │ ← │ │ opt D │
// │ opt E │ └─────┘ └──────────────┘
// └──────────────┘
//
// 항목 클릭 → 같은 패널 내 highlight (다중 선택). 이동 버튼은 highlight 된 항목들을
// 반대편으로 이동. 이동 후 highlight 자동 해제.
//
// 순서 정책:
// - selected 순서가 value 배열 순서. left→right 이동 시 끝에 append.
// - right→left 이동 시 항목 제거 (available 표시 순서는 options 배열 순서를 유지).
// - options 배열에 없는 value 가 들어와도 selected 에 표시 (label = value fallback).
//
// disabled / readonly: 이동 버튼 + 항목 클릭 모두 비활성. maxSelect: 좌→우 이동
// 가능 한도 초과분은 잘림.
// ─────────────────────────────────────────────────────────
interface SwapPickerProps {
value?: string[];
onChange?: (values: string[]) => void;
options: SelectOption[];
maxSelect?: number;
disabled?: boolean;
readonly?: boolean;
className?: string;
/** 왼쪽 패널 헤더 라벨. 기본 "선택 가능" */
availableLabel?: string;
/** 오른쪽 패널 헤더 라벨. 기본 "선택됨" */
selectedLabel?: string;
}
export const SwapPicker = React.forwardRef<HTMLDivElement, SwapPickerProps>(
(
{
value,
onChange,
options,
maxSelect,
disabled,
readonly,
className,
availableLabel = "선택 가능",
selectedLabel = "선택됨",
},
ref,
) => {
const selectedValues: string[] = Array.isArray(value) ? value : value ? [String(value)] : [];
const selectedSet = new Set(selectedValues);
// option lookup — options 에 없는 value 도 label = value 로 fallback.
const optionLookup = new Map<string, SelectOption>(options.map((o) => [o.value, o]));
const getLabel = (v: string): string => optionLookup.get(v)?.label ?? v;
// available 패널 (options 순서 보존, selected 제외)
const available = options.filter((o) => !selectedSet.has(o.value));
// selected 패널 (value 배열 순서 그대로)
const selected = selectedValues.map((v) => ({
value: v,
label: getLabel(v),
disabled: optionLookup.get(v)?.disabled,
}));
// 패널 내 highlight 상태
const [highlightLeft, setHighlightLeft] = useState<Set<string>>(new Set());
const [highlightRight, setHighlightRight] = useState<Set<string>>(new Set());
const lockEdit = !!(disabled || readonly);
const toggleHighlight = (side: "left" | "right", v: string) => {
if (lockEdit) return;
const setter = side === "left" ? setHighlightLeft : setHighlightRight;
setter((prev) => {
const next = new Set(prev);
if (next.has(v)) next.delete(v);
else next.add(v);
return next;
});
};
const moveToSelected = () => {
if (lockEdit) return;
if (highlightLeft.size === 0) return;
const additions = available
.map((o) => o.value)
.filter((v) => highlightLeft.has(v) && !optionLookup.get(v)?.disabled);
if (additions.length === 0) {
setHighlightLeft(new Set());
return;
}
let next = [...selectedValues, ...additions];
if (maxSelect && next.length > maxSelect) {
next = next.slice(0, maxSelect);
}
onChange?.(next);
setHighlightLeft(new Set());
};
const moveToAvailable = () => {
if (lockEdit) return;
if (highlightRight.size === 0) return;
const next = selectedValues.filter((v) => !highlightRight.has(v));
onChange?.(next);
setHighlightRight(new Set());
};
const canMoveRight = !lockEdit && highlightLeft.size > 0;
const canMoveLeft = !lockEdit && highlightRight.size > 0;
const atLimit = !!maxSelect && selectedValues.length >= maxSelect;
const renderPanel = (
side: "left" | "right",
items: Array<{ value: string; label: string; disabled?: boolean }>,
headerLabel: string,
highlight: Set<string>,
) => (
<div className="flex h-full min-w-0 flex-1 flex-col rounded-sm border border-border bg-background">
<div className="border-b border-border px-2 py-1 text-[11px] font-medium text-muted-foreground">
{headerLabel}
<span className="ml-1 text-[10px] text-muted-foreground/70">({items.length})</span>
</div>
<ul className="flex-1 overflow-auto py-1 text-sm">
{items.length === 0 ? (
<li className="px-2 py-1 text-xs text-muted-foreground"> </li>
) : (
items.map((it) => {
const optDisabled = lockEdit || it.disabled;
const isHi = highlight.has(it.value);
return (
<li
key={it.value}
onClick={() => !optDisabled && toggleHighlight(side, it.value)}
className={cn(
"cursor-pointer select-none truncate px-2 py-0.5",
isHi && "bg-primary/15 text-primary",
!isHi && "hover:bg-muted/50",
optDisabled && "cursor-not-allowed opacity-60",
)}
title={it.label}
>
{it.label}
</li>
);
})
)}
</ul>
</div>
);
return (
<div
ref={ref}
className={cn(
"flex h-full w-full items-stretch gap-1.5 px-1 py-1",
lockEdit && "cursor-not-allowed",
className,
)}
>
{renderPanel("left", available, availableLabel, highlightLeft)}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={moveToSelected}
disabled={!canMoveRight || atLimit}
title={atLimit ? `최대 ${maxSelect}개까지` : "선택"}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-sm border border-border bg-background",
(!canMoveRight || atLimit) && "cursor-not-allowed opacity-40",
canMoveRight && !atLimit && "hover:bg-muted",
)}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={moveToAvailable}
disabled={!canMoveLeft}
title="선택 해제"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-sm border border-border bg-background",
!canMoveLeft && "cursor-not-allowed opacity-40",
canMoveLeft && "hover:bg-muted",
)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
</div>
{renderPanel("right", selected, selectedLabel, highlightRight)}
</div>
);
},
);
SwapPicker.displayName = "SwapPicker";