a5bbd1eb7c
- 옛 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>
979 lines
28 KiB
TypeScript
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 };
|