Files
invyone/frontend/components/v2/config-panels/_shared/cp/CPPrimitives.tsx
T
gbpark a5bbd1eb7c refactor(numbering-rule): NumberingRule → Input canonical 흡수 + 채번 관리 페이지 분리
- 옛 registry/numbering-rule, registry/v2-numbering-rule, V2NumberingRuleConfigPanel,
  NumberingRuleTemplate 폐기 — InvFieldConfigPanel + InputComponent 로 통합
- input 에 numbering-picker / select-pickers 추가, autonum 타입 흡수
- 채번 관리 전용 admin 페이지(systemMng/numberingRuleList) + CreateDialog +
  SequenceManagementPanel 신설
- backend NumberingRule controller/service/mapper 갱신 (시퀀스 관리 엔드포인트)
- input canonical 진행 노트 + 채번 관리 mockup 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 21:42:13 +09:00

979 lines
28 KiB
TypeScript

"use client";
/**
* ConfigPanel 프리미티브 (시안 panel-input-new 의 CP* 포팅)
* - inline style 위주 (시안의 토큰 활용 패턴 보존)
* - v5 토큰만 사용. 라이트/다크 모두 자동 대응
*/
import React from "react";
import { createPortal } from "react-dom";
import { CP_ICONS } from "./icons";
const CP_FONT = "var(--v5-font-sans)";
const CP_MONO = "var(--v5-font-mono)";
// ── Section ──────────────────────────────────────────────
export function CPSection({
title,
desc,
tone = "default",
children,
style,
}: {
title: React.ReactNode;
desc?: React.ReactNode;
tone?: "default" | "solid";
children?: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div
style={{
marginBottom: 8,
marginTop: 2,
...style,
}}
>
{tone === "solid" ? (
<div
style={{
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "4px 8px",
padding: "6px 10px",
background: "rgba(var(--v5-primary-rgb), 0.05)",
border: "1px solid var(--cp-border)",
borderRadius: 8,
marginBottom: 10,
}}
>
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.04em", color: "var(--cp-text)", whiteSpace: "nowrap" }}>
{title}
</span>
{desc && <span style={{ fontSize: 11, color: "var(--cp-text-muted)", minWidth: 0 }}>{desc}</span>}
</div>
) : (
<div
style={{
display: "flex",
alignItems: "baseline",
flexWrap: "wrap",
gap: "2px 8px",
padding: "0 0 4px",
marginBottom: 6,
backgroundImage:
"linear-gradient(to right, rgba(var(--v5-primary-rgb), 0.55), transparent 75%)",
backgroundSize: "100% 1px",
backgroundPosition: "bottom left",
backgroundRepeat: "no-repeat",
}}
>
<span
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: "-0.01em",
color: "var(--cp-text)",
whiteSpace: "nowrap",
}}
>
{title}
</span>
{desc && (
<span
style={{
fontSize: 10.5,
color: "var(--cp-text-muted)",
letterSpacing: 0,
minWidth: 0,
lineHeight: 1.4,
}}
>
· {desc}
</span>
)}
</div>
)}
<div>{children}</div>
</div>
);
}
// ── Row (label 좌, control 우) ───────────────────────────
export function CPRow({
label,
help,
required,
advanced,
children,
align = "center",
}: {
label: React.ReactNode;
help?: React.ReactNode;
required?: boolean;
advanced?: boolean;
children?: React.ReactNode;
align?: "center" | "top";
}) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(60px, 80px) minmax(0, 1fr)",
gap: 8,
alignItems: align === "top" ? "flex-start" : "center",
padding: "5px 0",
minHeight: 28,
}}
>
<div
style={{
fontSize: 11.5,
fontWeight: 600,
color: "var(--cp-text-sec)",
lineHeight: 1.3,
paddingTop: align === "top" ? 6 : 0,
display: "flex",
alignItems: "center",
gap: 3,
flexWrap: "wrap",
wordBreak: "keep-all",
}}
>
<span>{label}</span>
{required && <span style={{ color: "var(--v5-red)", fontWeight: 700 }}>*</span>}
{advanced && (
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--cp-text-muted)",
textTransform: "uppercase",
padding: "1px 4px",
border: "1px solid var(--cp-border)",
borderRadius: 3,
}}
>
PRO
</span>
)}
</div>
<div style={{ minWidth: 0 }}>
{children}
{help && (
<div style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginTop: 4, lineHeight: 1.4 }}>{help}</div>
)}
</div>
</div>
);
}
// label 위 / control 아래
export function CPStacked({
label,
help,
required,
advanced,
children,
}: {
label: React.ReactNode;
help?: React.ReactNode;
required?: boolean;
advanced?: boolean;
children?: React.ReactNode;
}) {
return (
<div style={{ padding: "4px 0", marginBottom: 6 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--cp-text-sec)",
marginBottom: 5,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<span>{label}</span>
{required && <span style={{ color: "var(--v5-red)" }}>*</span>}
{advanced && (
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: "0.08em",
color: "var(--cp-text-muted)",
textTransform: "uppercase",
padding: "1px 4px",
border: "1px solid var(--cp-border)",
borderRadius: 3,
marginLeft: "auto",
}}
>
PRO
</span>
)}
</div>
{children}
{help && <div style={{ fontSize: 10.5, color: "var(--cp-text-muted)", marginTop: 4, lineHeight: 1.4 }}>{help}</div>}
</div>
);
}
// ── Inputs ──────────────────────────────────────────────
const inputBaseStyle: React.CSSProperties = {
width: "100%",
boxSizing: "border-box",
height: 28,
padding: "0 8px",
fontSize: 12,
fontFamily: CP_FONT,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 6,
color: "var(--cp-text)",
outline: "none",
transition: "border-color .15s, box-shadow .15s",
};
type CPTextProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "prefix"> & {
value?: string | number;
onChange?: (value: string) => void;
placeholder?: string;
mono?: boolean;
suffix?: React.ReactNode;
prefix?: React.ReactNode;
};
export function CPText({ value, onChange, placeholder, mono, suffix, prefix, ...rest }: CPTextProps) {
const [focus, setFocus] = React.useState(false);
return (
<div
style={{
display: "flex",
alignItems: "center",
border: focus ? "1px solid var(--v5-primary)" : "1px solid var(--cp-border)",
borderRadius: 6,
boxShadow: focus ? "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" : "none",
background: "var(--cp-surface)",
height: 28,
transition: "border-color .15s, box-shadow .15s",
overflow: "hidden",
}}
>
{prefix && (
<div
style={{
padding: "0 8px",
fontSize: 11,
color: "var(--cp-text-muted)",
borderRight: "1px solid var(--cp-border-subtle)",
height: "100%",
display: "flex",
alignItems: "center",
}}
>
{prefix}
</div>
)}
<input
{...rest}
value={value ?? ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder={placeholder}
onFocus={(e) => {
setFocus(true);
rest.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
rest.onBlur?.(e);
}}
style={{
flex: 1,
minWidth: 0,
border: "none",
outline: "none",
background: "transparent",
height: "100%",
padding: "0 8px",
fontSize: 12,
fontFamily: mono ? CP_MONO : CP_FONT,
color: "var(--cp-text)",
}}
/>
{suffix && (
<div
style={{
padding: "0 8px",
fontSize: 11,
color: "var(--cp-text-muted)",
borderLeft: "1px solid var(--cp-border-subtle)",
height: "100%",
display: "flex",
alignItems: "center",
}}
>
{suffix}
</div>
)}
</div>
);
}
type CPSelectOption = { value: string; label: React.ReactNode; disabled?: boolean };
type CPSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "value" | "onChange"> & {
value?: string;
onChange?: (value: string) => void;
children?: React.ReactNode;
options?: CPSelectOption[];
placeholder?: string;
/** 검색 박스 노출. 명시 안 하면 옵션 ≥ 12 일 때 자동 */
searchable?: boolean;
/** 옵션 정렬 (label≠value 우선 + ko collator). 명시 안 하면 옵션 ≥ 8 일 때 자동 */
sortable?: boolean;
};
// children 으로 들어온 <option> 엘리먼트들을 옵션 배열로 평탄화
function parseOptionChildren(children: React.ReactNode): CPSelectOption[] {
const out: CPSelectOption[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return;
const el = child as React.ReactElement<{
value?: string | number;
children?: React.ReactNode;
disabled?: boolean;
}>;
if (el.type === "option") {
out.push({
value: String(el.props.value ?? ""),
label: el.props.children ?? String(el.props.value ?? ""),
disabled: el.props.disabled,
});
} else if (el.type === "optgroup") {
// optgroup 안의 option 들을 재귀로 평탄화 (label 은 무시)
out.push(...parseOptionChildren(el.props.children));
}
});
return out;
}
export function CPSelect({
value,
onChange,
children,
options,
placeholder,
disabled,
searchable,
sortable,
...rest
}: CPSelectProps) {
const opts: CPSelectOption[] = options ?? parseOptionChildren(children);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const wrapRef = React.useRef<HTMLDivElement>(null);
const popRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
// popover 좌표 (Portal — overflow:hidden/auto 컨테이너 안에서 잘리지 않도록)
const [popPos, setPopPos] = React.useState<{ top: number; left: number; width: number } | null>(null);
React.useLayoutEffect(() => {
if (!open || !wrapRef.current) {
setPopPos(null);
return;
}
const rect = wrapRef.current.getBoundingClientRect();
setPopPos({ top: rect.bottom + 2, left: rect.left, width: rect.width });
}, [open]);
React.useEffect(() => {
if (!open) return;
// scroll/resize 시 popup 위치를 trigger 에 맞춰 재계산 (이전에는 무조건 close → dropdown 안 스크롤 불가능)
// trigger 가 viewport 밖으로 나가면 그때만 close.
const updatePos = () => {
if (!wrapRef.current) return;
const rect = wrapRef.current.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) {
setOpen(false);
return;
}
setPopPos({ top: rect.bottom + 2, left: rect.left, width: rect.width });
};
window.addEventListener("scroll", updatePos, true);
window.addEventListener("resize", updatePos);
return () => {
window.removeEventListener("scroll", updatePos, true);
window.removeEventListener("resize", updatePos);
};
}, [open]);
// 첫 옵션이 빈값이면 placeholder 후보로 사용 (호환: <option value="">선택...</option>)
const firstEmpty = opts.find((o) => o.value === "");
const placeholderText =
placeholder ??
(typeof firstEmpty?.label === "string" ? firstEmpty.label : "선택");
const realOpts = opts.filter((o) => o.value !== "");
// 자동 결정 — 명시 prop 우선, 없으면 옵션 수 기반
const useSearch = searchable ?? realOpts.length >= 12;
const useSort = sortable ?? realOpts.length >= 8;
const collator = React.useMemo(() => new Intl.Collator("ko"), []);
// 정렬: label !== value 우선 → ko collator (영어/한글 발음순)
const sortedOpts = React.useMemo(() => {
if (!useSort) return realOpts;
return [...realOpts].sort((a, b) => {
const aText = typeof a.label === "string" ? a.label : a.value;
const bText = typeof b.label === "string" ? b.label : b.value;
const aLabeled = aText !== a.value;
const bLabeled = bText !== b.value;
if (aLabeled && !bLabeled) return -1;
if (!aLabeled && bLabeled) return 1;
return collator.compare(aText, bText);
});
}, [realOpts, useSort, collator]);
// 검색 필터
const visibleOpts = React.useMemo(() => {
if (!query) return sortedOpts;
const q = query.toLowerCase();
return sortedOpts.filter((o) => {
const text = typeof o.label === "string" ? o.label : o.value;
return text.toLowerCase().includes(q);
});
}, [sortedOpts, query]);
const current = realOpts.find((o) => o.value === value);
const showPlaceholder = !current;
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const t = e.target as Node;
if (
wrapRef.current && !wrapRef.current.contains(t) &&
popRef.current && !popRef.current.contains(t)
) {
setOpen(false);
}
};
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (query) setQuery("");
else setOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onEsc);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onEsc);
};
}, [open, query]);
// 열릴 때 active 항목으로 스크롤 + 검색 input 포커스
React.useEffect(() => {
if (!open) {
setQuery("");
return;
}
if (useSearch && searchInputRef.current) {
searchInputRef.current.focus();
}
if (listRef.current) {
const active = listRef.current.querySelector('[data-active="true"]') as HTMLElement | null;
if (active) active.scrollIntoView({ block: "nearest" });
}
}, [open, useSearch]);
return (
<div ref={wrapRef} style={{ position: "relative", width: "100%" }}>
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
data-state={open ? "open" : "closed"}
className="cp-select-trigger"
{...(rest as any)}
style={{
...inputBaseStyle,
display: "flex",
alignItems: "center",
textAlign: "left",
paddingRight: 24,
cursor: disabled ? "not-allowed" : "pointer",
color: showPlaceholder ? "var(--cp-text-muted)" : "var(--cp-text)",
backgroundImage:
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10' fill='none' stroke='%239998ad' stroke-width='1.6' stroke-linecap='round'><path d='M2 3.5l3 3 3-3'/></svg>\")",
backgroundRepeat: "no-repeat",
backgroundPosition: "right 8px center",
opacity: disabled ? 0.5 : 1,
fontFamily: CP_FONT,
}}
>
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{showPlaceholder ? placeholderText : current?.label}
</span>
</button>
{open && popPos && typeof document !== "undefined" && createPortal(
<div
ref={popRef}
style={{
position: "fixed",
top: popPos.top,
left: popPos.left,
width: popPos.width,
zIndex: 9999,
background: "var(--cp-surface)",
border: "1px solid var(--cp-border)",
borderRadius: 4,
boxShadow: "0 8px 22px rgba(0,0,0,0.18)",
padding: 4,
color: "var(--cp-text)",
animation: "cp-pop 0.14s var(--v5-ease-enter)",
}}
>
{useSearch && (
<div className="cp-select-search">
<input
ref={searchInputRef}
type="text"
className="cp-select-search-input"
placeholder="검색..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
)}
<div
ref={listRef}
style={{
maxHeight: useSearch ? 240 : 280,
overflowY: "auto",
display: "flex",
flexDirection: "column",
}}
>
{/* placeholder (값 = '') 가 children 으로 들어왔으면 첫 항목으로 노출, 클릭 시 onChange("") */}
{firstEmpty && !query && (
<>
<CPSelectItem
option={firstEmpty}
active={!current}
index={0}
placeholder
onPick={(v) => {
onChange?.(v);
setOpen(false);
}}
/>
{visibleOpts.length > 0 && (
<div
aria-hidden="true"
style={{
height: 1,
margin: "2px 4px 4px",
background: "var(--cp-border-subtle)",
flex: "0 0 auto",
}}
/>
)}
</>
)}
{visibleOpts.map((o, i) => (
<CPSelectItem
key={o.value}
option={o}
active={o.value === value}
index={i + (firstEmpty && !query ? 1 : 0)}
onPick={(v) => {
onChange?.(v);
setOpen(false);
}}
/>
))}
{visibleOpts.length === 0 && (
<div
style={{
padding: "8px",
fontSize: 11,
color: "var(--cp-text-muted)",
textAlign: "center",
}}
>
{query ? `"${query}" 결과 없음` : "옵션이 없습니다"}
</div>
)}
</div>
</div>,
document.body
)}
</div>
);
}
function CPSelectItem({
option,
active,
onPick,
index,
placeholder,
}: {
option: CPSelectOption;
active: boolean;
onPick: (v: string) => void;
index?: number;
placeholder?: boolean;
}) {
// stagger delay — index 가 너무 커지면 cap (대량 옵션 시 오래 끌림 방지)
const delay = index != null ? Math.min(index, 18) * 12 : 0;
return (
<button
type="button"
disabled={option.disabled}
data-active={active}
data-placeholder={placeholder ? "true" : undefined}
onClick={() => !option.disabled && onPick(option.value)}
className="cp-select-item"
style={{
display: "block",
width: "100%",
flex: "0 0 auto",
textAlign: "left",
padding: placeholder ? "4px 8px 4px 6px" : "5px 8px 5px 6px",
fontSize: placeholder ? 11 : 12,
lineHeight: 1.5,
minHeight: placeholder ? 22 : 26,
fontFamily: CP_FONT,
fontStyle: placeholder ? "italic" : "normal",
fontWeight: placeholder ? 400 : active ? 700 : 500,
background: active ? "rgba(var(--v5-primary-rgb), 0.10)" : "transparent",
border: "none",
borderLeft: active ? "2px solid rgb(var(--v5-primary-rgb))" : "2px solid transparent",
borderRadius: 0,
cursor: option.disabled ? "not-allowed" : "pointer",
opacity: option.disabled ? 0.5 : placeholder ? 0.7 : 1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
transition: "background .12s, color .12s, opacity .12s",
animationDelay: `${delay}ms`,
}}
onMouseEnter={(e) => {
const btn = e.currentTarget as HTMLButtonElement;
if (!active && !option.disabled) btn.style.background = "var(--cp-surface-hover)";
}}
onMouseLeave={(e) => {
const btn = e.currentTarget as HTMLButtonElement;
if (!active) btn.style.background = "transparent";
}}
>
{option.label}
</button>
);
}
type CPTextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange"> & {
value?: string;
onChange?: (value: string) => void;
rows?: number;
mono?: boolean;
};
export function CPTextarea({ value, onChange, placeholder, rows = 3, mono, ...rest }: CPTextareaProps) {
const [focus, setFocus] = React.useState(false);
return (
<textarea
{...rest}
value={value ?? ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
onFocus={(e) => {
setFocus(true);
rest.onFocus?.(e);
}}
onBlur={(e) => {
setFocus(false);
rest.onBlur?.(e);
}}
style={{
...inputBaseStyle,
height: "auto",
minHeight: 28 * rows,
padding: 8,
resize: "vertical",
lineHeight: 1.45,
fontFamily: mono ? CP_MONO : CP_FONT,
borderColor: focus ? "var(--v5-primary)" : "var(--cp-border)",
boxShadow: focus ? "0 0 0 3px rgba(var(--v5-primary-rgb),0.12)" : "none",
}}
/>
);
}
export function CPSwitch({
value,
onChange,
labels,
}: {
value?: boolean;
onChange?: (next: boolean) => void;
labels?: [string, string];
}) {
const v = !!value;
return (
<label style={{ display: "inline-flex", alignItems: "center", gap: 8, cursor: "pointer", userSelect: "none" }}>
<div
style={{
width: 28,
height: 16,
borderRadius: 999,
background: v ? "var(--v5-primary)" : "rgba(var(--v5-primary-rgb),0.15)",
position: "relative",
transition: "background .15s",
boxShadow: v ? "0 0 8px rgba(var(--v5-primary-rgb),0.35)" : "none",
}}
>
<div
style={{
width: 12,
height: 12,
borderRadius: 999,
background: "#fff",
position: "absolute",
top: 2,
left: v ? 14 : 2,
transition: "left .18s var(--v5-ease-enter)",
boxShadow: "0 1px 2px rgba(0,0,0,0.15)",
}}
/>
</div>
{labels && <span style={{ fontSize: 11, color: "var(--cp-text-sec)" }}>{v ? labels[0] : labels[1]}</span>}
<input
type="checkbox"
checked={v}
onChange={(e) => onChange && onChange(e.target.checked)}
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
/>
</label>
);
}
export function CPNumber({
value,
onChange,
placeholder,
min,
max,
step,
suffix,
}: {
value?: number | "";
onChange?: (next: number | undefined) => void;
placeholder?: string;
min?: number;
max?: number;
step?: number;
suffix?: React.ReactNode;
}) {
return (
<CPText
type="number"
mono
value={value as any}
onChange={(v) => onChange && onChange(v === "" ? undefined : Number(v))}
placeholder={placeholder}
min={min}
max={max}
step={step}
suffix={suffix}
/>
);
}
export function CPColor({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
border: "1px solid var(--cp-border)",
borderRadius: 6,
background: "var(--cp-surface)",
height: 28,
overflow: "hidden",
}}
>
<div
style={{
width: 26,
height: "100%",
background: value || "#ffffff",
borderRight: "1px solid var(--cp-border-subtle)",
position: "relative",
cursor: "pointer",
}}
>
<input
type="color"
value={value || "#6c5ce7"}
onChange={(e) => onChange && onChange(e.target.value)}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
</div>
<input
value={value || ""}
onChange={(e) => onChange && onChange(e.target.value)}
placeholder="#6c5ce7"
style={{
flex: 1,
border: "none",
outline: "none",
background: "transparent",
height: "100%",
padding: "0 8px",
fontSize: 11.5,
fontFamily: CP_MONO,
color: "var(--cp-text)",
textTransform: "uppercase",
}}
/>
</div>
);
}
// 작은 enum 용 (size, align, variant 등) — 평면 toolbar 스타일 (외곽/내부 박스 없음)
type CPSegmentOption = string | { value: string; label: React.ReactNode };
export function CPSegment({
value,
onChange,
options,
}: {
value?: string;
onChange?: (next: string) => void;
options: CPSegmentOption[];
}) {
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
flexWrap: "wrap",
rowGap: 4,
maxWidth: "100%",
}}
>
{options.map((opt, i) => {
const v = typeof opt === "string" ? opt : opt.value;
const l = typeof opt === "string" ? opt : opt.label;
const active = value === v;
return (
<React.Fragment key={v}>
{i > 0 && (
<span
aria-hidden
style={{
width: 1,
height: 12,
background: "var(--cp-border-subtle)",
margin: "0 6px",
flexShrink: 0,
}}
/>
)}
<button
type="button"
onClick={() => onChange && onChange(v)}
style={{
border: "none",
background: "transparent",
color: active ? "var(--cp-text)" : "var(--cp-text-muted)",
fontSize: 11.5,
fontWeight: active ? 700 : 500,
padding: "2px 4px",
borderRadius: 0,
cursor: "pointer",
fontFamily: CP_FONT,
whiteSpace: "nowrap",
transition: "color .12s, font-weight .12s",
letterSpacing: "-0.005em",
}}
onMouseEnter={(e) => {
if (!active) (e.currentTarget as HTMLButtonElement).style.color = "var(--cp-text-sec)";
}}
onMouseLeave={(e) => {
if (!active) (e.currentTarget as HTMLButtonElement).style.color = "var(--cp-text-muted)";
}}
>
{l}
</button>
</React.Fragment>
);
})}
</div>
);
}
// 작은 아이콘 버튼
export function CPIconBtn({
children,
onClick,
title,
tone = "default",
size = 24,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
title?: string;
tone?: "default" | "primary" | "danger";
size?: number;
}) {
const tones = {
default: { bg: "transparent", color: "var(--cp-text-sec)" },
primary: { bg: "rgba(var(--v5-primary-rgb),0.08)", color: "var(--v5-primary)" },
danger: { bg: "transparent", color: "var(--v5-red)" },
} as const;
const t = tones[tone];
return (
<button
type="button"
onClick={onClick}
title={title}
style={{
width: size,
height: size,
border: "1px solid var(--cp-border)",
background: t.bg,
color: t.color,
borderRadius: 5,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
flexShrink: 0,
}}
>
{children}
</button>
);
}
// re-export 편의용
export { CP_ICONS };