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>
576 lines
19 KiB
TypeScript
576 lines
19 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* IconPicker — cp 톤 아이콘 picker
|
|
*
|
|
* Codex 검토 (2026-04-29 Y4) 권고: stats/button 두 cp 패널 공용 UI 일관성 확보.
|
|
* 외부 사용처 (layout/admin/dash) 는 별개 `MenuIconPicker` 사용 → 영향 없음.
|
|
*
|
|
* 시그니처 / 동작 (외부 클릭 닫기 없음 등) 은 이전과 동일. 시각 톤만 cp 변수 + 28px 표준 사이즈로 통일.
|
|
*/
|
|
|
|
import React, { useState, useMemo, useRef, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import * as LucideIcons from "lucide-react";
|
|
|
|
// lucide-react에서 실제 아이콘만 추출 (유틸/타입 제외)
|
|
const ICON_ENTRIES = Object.entries(LucideIcons).filter(
|
|
([name, comp]) =>
|
|
typeof comp === "object" &&
|
|
name !== "default" &&
|
|
name !== "createLucideIcon" &&
|
|
name !== "icons" &&
|
|
name[0] === name[0].toUpperCase() &&
|
|
!name.endsWith("Icon"),
|
|
);
|
|
|
|
// 자주 쓰는 아이콘 (상단 표시)
|
|
const POPULAR = [
|
|
"Search", "Plus", "Edit", "Trash2", "Save", "Check", "X", "ChevronDown",
|
|
"ChevronRight", "Settings", "User", "Users", "Mail", "Calendar", "Clock",
|
|
"File", "Folder", "Download", "Upload", "Eye", "EyeOff", "Lock", "Unlock",
|
|
"Star", "Heart", "Home", "ArrowLeft", "ArrowRight", "RefreshCw", "Filter",
|
|
"BarChart3", "PieChart", "TrendingUp", "DollarSign", "ShoppingCart", "Package",
|
|
];
|
|
|
|
/**
|
|
* 한글 키워드 → 아이콘 매핑 (검색 시 alias 로 사용).
|
|
* lucide 영문 이름과 함께 매치. 자주 쓰일만한 도메인 키워드 위주.
|
|
*/
|
|
const ICON_KO: Record<string, string[]> = {
|
|
// 사용자 / 사원
|
|
User: ["사용자", "유저", "사람", "회원", "사원"],
|
|
Users: ["사용자", "유저", "사원", "회원", "그룹", "팀", "사람들"],
|
|
UserPlus: ["사용자추가", "사원추가", "회원가입", "등록"],
|
|
UserMinus: ["사용자삭제", "사원삭제", "탈퇴"],
|
|
UserCheck: ["사용자승인", "사원승인", "인증"],
|
|
UserX: ["사용자거부", "사원거부", "차단"],
|
|
UserCog: ["사용자설정", "권한"],
|
|
Contact: ["연락처", "주소록"],
|
|
Briefcase: ["가방", "업무", "직장", "프로젝트", "근무"],
|
|
Building: ["건물", "회사", "조직"],
|
|
Building2: ["빌딩", "회사", "지점", "사업장"],
|
|
|
|
// 액션
|
|
Plus: ["추가", "더하기", "생성", "신규"],
|
|
Minus: ["빼기", "제거", "감소"],
|
|
Edit: ["편집", "수정", "변경"],
|
|
Edit2: ["편집", "수정", "변경"],
|
|
Edit3: ["편집", "수정"],
|
|
Pencil: ["연필", "수정", "편집", "쓰기"],
|
|
Trash: ["휴지통", "삭제", "버리기"],
|
|
Trash2: ["휴지통", "삭제", "제거"],
|
|
Save: ["저장", "보관"],
|
|
Check: ["체크", "확인", "완료", "승인"],
|
|
CheckCircle: ["완료", "성공", "확인"],
|
|
CheckCircle2: ["완료", "성공"],
|
|
X: ["닫기", "취소", "엑스"],
|
|
XCircle: ["닫기", "실패", "취소"],
|
|
Copy: ["복사"],
|
|
Clipboard: ["클립보드", "복사", "붙여넣기"],
|
|
Send: ["전송", "보내기", "발송"],
|
|
Printer: ["인쇄", "프린트"],
|
|
Share: ["공유", "공유하기"],
|
|
Share2: ["공유"],
|
|
Link: ["링크", "연결"],
|
|
Link2: ["링크", "연결"],
|
|
Unlink: ["연결끊기"],
|
|
Power: ["전원", "켜기", "끄기"],
|
|
LogIn: ["로그인", "입장"],
|
|
LogOut: ["로그아웃", "나가기", "퇴장"],
|
|
|
|
// 검색 / 필터
|
|
Search: ["검색", "찾기", "조회", "탐색"],
|
|
Filter: ["필터", "거르기", "조건"],
|
|
SlidersHorizontal: ["조정", "필터", "설정"],
|
|
ListFilter: ["필터", "정렬"],
|
|
|
|
// 화살표 / 이동
|
|
ArrowLeft: ["왼쪽", "뒤로", "이전"],
|
|
ArrowRight: ["오른쪽", "다음", "앞으로"],
|
|
ArrowUp: ["위", "상승"],
|
|
ArrowDown: ["아래", "하강"],
|
|
ArrowUpDown: ["정렬", "위아래"],
|
|
ChevronLeft: ["왼쪽", "이전"],
|
|
ChevronRight: ["오른쪽", "다음"],
|
|
ChevronUp: ["위"],
|
|
ChevronDown: ["아래", "펼침", "드롭다운"],
|
|
ChevronsLeft: ["맨앞", "처음"],
|
|
ChevronsRight: ["맨뒤", "마지막"],
|
|
ChevronsUpDown: ["펼침접힘"],
|
|
MoveLeft: ["이동", "왼쪽"],
|
|
MoveRight: ["이동", "오른쪽"],
|
|
|
|
// 시간
|
|
Calendar: ["달력", "캘린더", "날짜", "일정"],
|
|
CalendarDays: ["달력", "일정"],
|
|
CalendarCheck: ["일정확인", "예약"],
|
|
CalendarClock: ["일정", "예약시간"],
|
|
Clock: ["시계", "시간"],
|
|
Clock3: ["시간"],
|
|
Clock4: ["시간"],
|
|
Timer: ["타이머", "시간측정"],
|
|
AlarmClock: ["알람", "알람시계"],
|
|
Hourglass: ["모래시계", "대기"],
|
|
|
|
// 파일 / 폴더 / 문서
|
|
File: ["파일", "문서"],
|
|
FileText: ["문서", "텍스트"],
|
|
FileSpreadsheet: ["스프레드시트", "엑셀"],
|
|
FileImage: ["이미지파일"],
|
|
FileVideo: ["동영상파일"],
|
|
FilePlus: ["파일추가"],
|
|
FileMinus: ["파일삭제"],
|
|
FileX: ["파일닫기"],
|
|
FileCheck: ["파일확인"],
|
|
Files: ["파일들", "여러파일"],
|
|
Folder: ["폴더", "디렉토리"],
|
|
FolderOpen: ["열린폴더"],
|
|
FolderPlus: ["폴더추가"],
|
|
FolderMinus: ["폴더삭제"],
|
|
Archive: ["아카이브", "보관", "압축"],
|
|
|
|
// 다운로드 / 업로드
|
|
Download: ["다운로드", "내려받기"],
|
|
Upload: ["업로드", "올리기"],
|
|
CloudDownload: ["클라우드다운로드"],
|
|
CloudUpload: ["클라우드업로드"],
|
|
CloudOff: ["오프라인"],
|
|
Cloud: ["클라우드"],
|
|
|
|
// 시각
|
|
Eye: ["눈", "보기", "표시", "조회"],
|
|
EyeOff: ["숨기기", "안보기"],
|
|
|
|
// 보안
|
|
Lock: ["잠금", "자물쇠", "보안"],
|
|
Unlock: ["잠금해제"],
|
|
Shield: ["방패", "보안", "보호"],
|
|
ShieldCheck: ["보안인증"],
|
|
Key: ["열쇠", "키", "비밀번호"],
|
|
KeyRound: ["키"],
|
|
|
|
// 통신
|
|
Mail: ["메일", "이메일", "편지"],
|
|
MailOpen: ["메일열기"],
|
|
MailPlus: ["메일작성"],
|
|
MessageSquare: ["메시지", "댓글"],
|
|
MessageCircle: ["메시지"],
|
|
Phone: ["전화", "폰"],
|
|
PhoneCall: ["통화"],
|
|
Smartphone: ["스마트폰", "휴대폰"],
|
|
Bell: ["알림", "벨", "종"],
|
|
BellOff: ["알림끄기"],
|
|
|
|
// 위치
|
|
MapPin: ["위치", "지도", "핀"],
|
|
Map: ["지도"],
|
|
Navigation: ["네비게이션", "방향"],
|
|
Home: ["홈", "집", "메인"],
|
|
Building3: ["건물"],
|
|
Compass: ["나침반"],
|
|
Globe: ["지구", "글로벌", "전세계"],
|
|
|
|
// 상태 / 좋아요
|
|
Star: ["별", "즐겨찾기", "별점"],
|
|
Heart: ["하트", "좋아요", "즐겨찾기"],
|
|
ThumbsUp: ["좋아요", "추천"],
|
|
ThumbsDown: ["싫어요"],
|
|
Bookmark: ["북마크", "즐겨찾기"],
|
|
Award: ["수상", "메달", "상"],
|
|
Trophy: ["트로피", "우승"],
|
|
Flag: ["깃발", "신고", "표시"],
|
|
|
|
// 화폐 / 결제
|
|
DollarSign: ["달러", "돈", "금액", "매출"],
|
|
CircleDollarSign: ["달러"],
|
|
Banknote: ["지폐", "돈", "현금"],
|
|
Wallet: ["지갑"],
|
|
CreditCard: ["카드", "신용카드", "결제"],
|
|
Receipt: ["영수증"],
|
|
Coins: ["동전", "돈"],
|
|
Percent: ["퍼센트", "비율", "할인"],
|
|
|
|
// 카트 / 쇼핑
|
|
ShoppingCart: ["카트", "장바구니", "쇼핑"],
|
|
ShoppingBag: ["쇼핑백", "주문"],
|
|
Store: ["상점", "매장"],
|
|
Tag: ["태그", "라벨", "꼬리표"],
|
|
Tags: ["태그", "라벨"],
|
|
|
|
// 박스 / 패키지
|
|
Package: ["패키지", "상자", "포장", "재고"],
|
|
PackageOpen: ["박스열기", "배송", "포장"],
|
|
PackagePlus: ["박스추가", "입고"],
|
|
PackageMinus: ["박스삭제", "출고"],
|
|
Box: ["박스", "상자"],
|
|
Boxes: ["박스", "재고"],
|
|
Truck: ["트럭", "배송"],
|
|
Container: ["컨테이너", "보관"],
|
|
Warehouse: ["창고", "재고"],
|
|
|
|
// 차트 / 통계
|
|
BarChart: ["막대그래프", "차트", "통계"],
|
|
BarChart2: ["막대차트"],
|
|
BarChart3: ["막대그래프", "차트", "통계", "KPI"],
|
|
BarChart4: ["막대차트"],
|
|
PieChart: ["파이차트", "비율", "원그래프"],
|
|
LineChart: ["선그래프", "추세"],
|
|
AreaChart: ["영역차트"],
|
|
TrendingUp: ["상승", "증가", "추세"],
|
|
TrendingDown: ["하강", "감소"],
|
|
Activity: ["활동", "추적"],
|
|
Gauge: ["게이지", "측정"],
|
|
|
|
// 설정 / 도구
|
|
Settings: ["설정", "환경설정", "옵션"],
|
|
Settings2: ["설정"],
|
|
Cog: ["설정", "톱니"],
|
|
Sliders: ["조정", "슬라이더"],
|
|
Wrench: ["렌치", "도구", "수리"],
|
|
Hammer: ["망치", "도구"],
|
|
|
|
// 새로고침
|
|
RefreshCw: ["새로고침", "재로드"],
|
|
RefreshCcw: ["새로고침"],
|
|
RotateCw: ["회전"],
|
|
RotateCcw: ["회전"],
|
|
|
|
// 정보 / 경고
|
|
Info: ["정보", "안내"],
|
|
AlertCircle: ["경고", "주의"],
|
|
AlertTriangle: ["경고", "위험"],
|
|
AlertOctagon: ["위험", "중지"],
|
|
HelpCircle: ["도움말", "물음표"],
|
|
CircleAlert: ["경고"],
|
|
|
|
// 메뉴
|
|
Menu: ["메뉴", "햄버거"],
|
|
MoreHorizontal: ["더보기", "추가메뉴"],
|
|
MoreVertical: ["더보기"],
|
|
List: ["목록", "리스트"],
|
|
ListChecks: ["체크리스트"],
|
|
Grid: ["그리드", "격자"],
|
|
Grid3x3: ["그리드"],
|
|
LayoutGrid: ["그리드", "레이아웃"],
|
|
|
|
// 데이터 / DB
|
|
Database: ["데이터베이스", "DB", "저장소"],
|
|
Server: ["서버"],
|
|
HardDrive: ["하드드라이브", "저장소"],
|
|
Cpu: ["CPU", "프로세서"],
|
|
Cable: ["케이블", "연결"],
|
|
|
|
// 일정 / 작업
|
|
ClipboardList: ["체크리스트", "할일"],
|
|
ClipboardCheck: ["완료", "확인"],
|
|
CheckSquare: ["체크박스", "완료"],
|
|
Square: ["사각형", "박스"],
|
|
|
|
// 기타 자주 쓰이는
|
|
Hash: ["해시", "번호", "샵"],
|
|
AtSign: ["골뱅이", "이메일", "아이디"],
|
|
Bookmark2: ["북마크"],
|
|
Pin: ["핀", "고정"],
|
|
PinOff: ["고정해제"],
|
|
Sun: ["해", "라이트모드"],
|
|
Moon: ["달", "다크모드"],
|
|
Zap: ["번개", "빠른"],
|
|
Sparkles: ["반짝", "특수효과"],
|
|
Gift: ["선물"],
|
|
Crown: ["왕관", "VIP"],
|
|
Bot: ["로봇", "봇", "자동화"],
|
|
Code: ["코드", "개발"],
|
|
Terminal: ["터미널", "콘솔"],
|
|
Bug: ["버그", "벌레", "오류"],
|
|
ListTodo: ["할일", "투두"],
|
|
GitBranch: ["분기", "브랜치"],
|
|
Workflow: ["워크플로우", "흐름"],
|
|
Network: ["네트워크"],
|
|
};
|
|
|
|
interface IconPickerProps {
|
|
value?: string;
|
|
onChange: (iconName: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export const IconPicker: React.FC<IconPickerProps> = ({ value, onChange, className }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [focused, setFocused] = useState(false);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const [popoverPos, setPopoverPos] = useState<{
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
maxHeight: number;
|
|
} | null>(null);
|
|
|
|
// 트리거 위치 잡고 popover 열기 (Portal 사용 — 부모 overflow:hidden 영향 회피)
|
|
const handleToggle = () => {
|
|
if (!open && triggerRef.current) {
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
const top = rect.bottom + 2;
|
|
// popover 높이 = 화면 안에 들어맞게. 너무 크지 않게 360 상한.
|
|
const maxHeight = Math.min(360, window.innerHeight - top - 16);
|
|
setPopoverPos({
|
|
top,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
maxHeight: Math.max(180, maxHeight),
|
|
});
|
|
}
|
|
setOpen((v) => !v);
|
|
};
|
|
|
|
// popover 떠 있는 동안 외부 scroll / resize 발생 시 닫기. 단, popover 안의
|
|
// 그리드 자체 스크롤은 제외 — 휠로 아이콘 목록 스크롤 시 닫히지 않게.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onScroll = (e: Event) => {
|
|
const target = e.target;
|
|
if (target instanceof Node && popoverRef.current?.contains(target)) {
|
|
return; // popover 내부 스크롤은 무시
|
|
}
|
|
setOpen(false);
|
|
};
|
|
const onResize = () => setOpen(false);
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", onResize);
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", onResize);
|
|
};
|
|
}, [open]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search.trim()) {
|
|
const popularSet = new Set(POPULAR);
|
|
const popular = ICON_ENTRIES.filter(([n]) => popularSet.has(n));
|
|
const rest = ICON_ENTRIES.filter(([n]) => !popularSet.has(n)).slice(0, 300 - popular.length);
|
|
return [...popular, ...rest];
|
|
}
|
|
const raw = search.trim();
|
|
const q = raw.toLowerCase();
|
|
const hasHangul = /[가-힣]/.test(raw);
|
|
return ICON_ENTRIES.filter(([name]) => {
|
|
// 영문 lucide 이름 매치
|
|
if (name.toLowerCase().includes(q)) return true;
|
|
// 한글 alias 매치 (한글 검색 시점 우선)
|
|
const aliases = ICON_KO[name];
|
|
if (aliases) {
|
|
for (const ko of aliases) {
|
|
if (hasHangul ? ko.includes(raw) : ko.toLowerCase().includes(q)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}).slice(0, 300);
|
|
}, [search]);
|
|
|
|
const SelectedIcon = value ? (LucideIcons as any)[value] : null;
|
|
|
|
return (
|
|
<div style={{ position: "relative" }} className={className}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
onClick={handleToggle}
|
|
onFocus={() => setFocused(true)}
|
|
onBlur={() => setFocused(false)}
|
|
style={{
|
|
width: "100%",
|
|
height: 28,
|
|
padding: "0 8px",
|
|
fontSize: 12,
|
|
background: "var(--cp-surface)",
|
|
border: `1px solid ${
|
|
focused
|
|
? "hsl(var(--primary) / 0.5)"
|
|
: "var(--cp-border)"
|
|
}`,
|
|
borderRadius: 6,
|
|
color: "var(--cp-text)",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
fontFamily: "var(--v5-font-sans)",
|
|
boxShadow: focused
|
|
? "0 0 0 3px hsl(var(--primary) / 0.12)"
|
|
: undefined,
|
|
transition: "border-color .14s ease, box-shadow .14s ease",
|
|
textAlign: "left",
|
|
}}
|
|
>
|
|
{SelectedIcon ? (
|
|
<>
|
|
<SelectedIcon size={14} />
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: 11,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{value}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span style={{ flex: 1, color: "var(--cp-text-muted)" }}>
|
|
아이콘 선택...
|
|
</span>
|
|
)}
|
|
<LucideIcons.ChevronDown
|
|
size={12}
|
|
style={{ color: "var(--cp-text-muted)", flexShrink: 0 }}
|
|
/>
|
|
</button>
|
|
|
|
{open && popoverPos && createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
style={{
|
|
position: "fixed",
|
|
top: popoverPos.top,
|
|
left: popoverPos.left,
|
|
width: popoverPos.width,
|
|
height: popoverPos.maxHeight,
|
|
background: "var(--cp-surface)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 6,
|
|
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.12)",
|
|
zIndex: 9999,
|
|
overflow: "hidden",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
{/* 검색 */}
|
|
<div
|
|
style={{
|
|
padding: 6,
|
|
borderBottom: "1px solid var(--cp-border-subtle)",
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="아이콘 검색 (한/영) — 사원, users…"
|
|
autoFocus
|
|
style={{
|
|
width: "100%",
|
|
height: 24,
|
|
padding: "0 8px",
|
|
fontSize: 11,
|
|
background: "var(--cp-bg-subtle)",
|
|
border: "1px solid var(--cp-border)",
|
|
borderRadius: 4,
|
|
color: "var(--cp-text)",
|
|
outline: "none",
|
|
fontFamily: "var(--v5-font-sans)",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 그리드 — popover 안에서 flex 로 늘어나며 휠 스크롤 활성 */}
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(6, 1fr)",
|
|
gap: 2,
|
|
padding: 6,
|
|
overflowY: "auto",
|
|
minHeight: 0,
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{/* 선택 해제 */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onChange("");
|
|
setOpen(false);
|
|
}}
|
|
title="없음"
|
|
style={{
|
|
height: 28,
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
color: "var(--cp-text-muted)",
|
|
fontSize: 9,
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
--
|
|
</button>
|
|
{filtered.map(([name, Icon]) => {
|
|
const IconComp = Icon as React.FC<{ size?: number }>;
|
|
const active = name === value;
|
|
return (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
onClick={() => {
|
|
onChange(name);
|
|
setOpen(false);
|
|
setSearch("");
|
|
}}
|
|
title={name}
|
|
style={{
|
|
height: 28,
|
|
background: active
|
|
? "hsl(var(--primary) / 0.10)"
|
|
: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
color: active ? "hsl(var(--primary))" : "var(--cp-text)",
|
|
borderRadius: 4,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
transition: "background .12s ease",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!active)
|
|
(e.currentTarget as HTMLButtonElement).style.background =
|
|
"var(--cp-surface-hover, rgba(0,0,0,0.04))";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!active)
|
|
(e.currentTarget as HTMLButtonElement).style.background =
|
|
"transparent";
|
|
}}
|
|
>
|
|
<IconComp size={14} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{filtered.length === 0 && (
|
|
<div
|
|
style={{
|
|
padding: 12,
|
|
textAlign: "center",
|
|
color: "var(--cp-text-muted)",
|
|
fontSize: 10,
|
|
}}
|
|
>
|
|
“{search}” 결과 없음
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default IconPicker;
|