3883031c0b
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>
831 lines
30 KiB
TypeScript
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";
|