/* ============================================================================
* 서명전에 — TDS AgreementV4 컴포넌트
* · 동의 화면(약관/개인정보/마케팅 수신 등)을 일관된 레이아웃으로 짜는 빌더.
* 여러 슬롯(left / middle / right)과 하위 컴포넌트(Checkbox/Text/Badge/
* Description/Header/RightArrow/Necessity/Pressable/IndentPushable/
* Collapsible/Group)를 조합해 서비스 곳곳의 동의 화면(가입 동의·개인정보
* 수집 동의·마케팅 수신 동의·결제 동의)을 한 줄로 짤 수 있어요.
*
* · 구조 (단일 row):
* ┌─────────┬────────────────────────────────┬─────────┐
* │ left │ middle │ right │
* │ Checkbox│ Text(+ necessity) / Pressable │ Badge │
* │ │ │ /Arrow │
* └─────────┴────────────────────────────────┴─────────┘
*
* · 서브 (총 15):
* AgreementV4.Text — 동의 항목 텍스트 (necessity 슬롯, onPressEnd)
* AgreementV4.Badge — 시각 강조 뱃지 (variant fill/clear, bg/text color)
* AgreementV4.Checkbox — 선택용 체크박스 (variant checkbox/dot/hidden)
* AgreementV4.Necessity — 필수/선택 표시 (variant mandatory/optional)
* AgreementV4.RightArrow — 접기/펼치기 트리거 (collapsed → 회전)
* AgreementV4.Description — 부가 설명 (variant box/normal, indent)
* AgreementV4.Header — 섹션 제목 (variant 6종, indent)
* AgreementV4.Pressable — 클릭 감지 래퍼 (체크박스 제외 영역)
* AgreementV4.IndentPushable — 시각적 계층 컨테이너 (pushed)
* AgreementV4.IndentPushableTrigger — 클릭 시 들여쓰기 토글
* AgreementV4.IndentPushableContent — 들여쓰기 적용되는 영역
* AgreementV4.Collapsible — 접고 펼치는 컨테이너 (collapsed)
* AgreementV4.CollapsibleTrigger — 접고 펼치기 트리거 (자동 화살표 회전)
* AgreementV4.CollapsibleContent — 펼쳐졌을 때 보이는 영역
* AgreementV4.Group — 여러 동의 항목 그룹화 (showGradient)
*
* · variant (root + Header):
* "xLarge" / "large" / "medium" / "medium-title" / "small" / "small-last"
* 각 variant 별 padding/font-size/line-height 가 토큰으로 자동 매핑.
*
* · indent 시스템:
* - root indent (number, 0~4) — 16px × indent 만큼 좌측 padding 추가.
* - IndentPushable.pushed=true → 그 안의 IndentPushableContent 자식들이
* indent +1 자동 적용 (자식이 직접 indent 를 주면 그게 우선).
*
* · 접근성:
* - Checkbox 는 내부적으로 native + role="checkbox".
* - Necessity mandatory/optional 은 aria-label="필수"/"선택" 로 자동 레이블.
* - RightArrow 는 collapsed prop 으로 aria-expanded 자동 부여.
* - Collapsible 의 trigger 는 자동으로 aria-expanded + aria-controls 부여.
* - Pressable 는 button role + Enter/Space 키보드 활성화.
*
* · 의존: Checkbox(#129) — 미로드 시 native input 폴백.
* Badge / Paragraph 는 자체 스타일링 (외부 의존 없음 — 인라인 처리).
*
* · 색은 spec 의 `adaptive.X` 별칭을 위해 window.adaptive 글로벌도 함께 정의 —
* `adaptive.yellow50` 같은 호출이 자동으로 `var(--tds-yellow-50)` 로 풀려요.
* ========================================================================== */
const __AGV4_VARIANTS = ["xLarge", "large", "medium", "medium-title", "small", "small-last"];
const __AGV4_CHECKBOX_VARIANTS = ["checkbox", "dot", "hidden"];
const __AGV4_BADGE_VARIANTS = ["fill", "clear"];
const __AGV4_NECESSITY_VARIANTS = ["optional", "mandatory"];
const __AGV4_DESCRIPTION_VARIANTS = ["box", "normal"];
const __AGV4_INDENT_MAX = 4;
// =============================================================================
// adaptive 글로벌 — TDS 스펙의 `adaptive.yellow50` 같은 호출을 색상 토큰으로 풀어줌
// =============================================================================
(function __agv4InitAdaptive() {
if (typeof window === "undefined") return;
if (window.adaptive && typeof window.adaptive === "object") return;
const palette = ["grey", "blue", "red", "orange", "yellow", "green", "teal", "purple"];
const steps = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
const a = {};
palette.forEach(function (hue) {
steps.forEach(function (step) {
// 예: yellow50 → var(--tds-yellow-50)
a[`${hue}${step}`] = `var(--tds-${hue}-${step})`;
});
});
// 추가 별칭
a.white = "#ffffff";
a.black = "#000000";
a.transparent = "transparent";
window.adaptive = a;
})();
// =============================================================================
// 보조 — chev SVG (RightArrow 전용)
// =============================================================================
function __AGV4ChevRight({ size = 20, color, ariaHidden = true }) {
return (
);
}
// =============================================================================
// 컨텍스트 — IndentPushable 의 pushed 상태 / Collapsible 의 collapsed 상태
// =============================================================================
const __AGV4PushableCtx = React.createContext({ pushed: false });
const __AGV4CollapsibleCtx = React.createContext({ collapsed: true, contentId: null, toggle: null });
// =============================================================================
// 보조 — variant 검증 + 폴백
// =============================================================================
function __agv4Variant(value, fallback) {
if (__AGV4_VARIANTS.includes(value)) return value;
if (window.console && console.warn) {
console.warn(`[AgreementV4] variant 는 ${__AGV4_VARIANTS.join(" | ")} 만 가능. 받은 값: ${value}. "${fallback}" 폴백.`);
}
return fallback;
}
function __agv4ClampIndent(n) {
if (typeof n !== "number" || !isFinite(n) || n < 0) return 0;
if (n > __AGV4_INDENT_MAX) return __AGV4_INDENT_MAX;
return Math.floor(n);
}
// =============================================================================
// 루트 AgreementV4 — left / middle / right 슬롯 빌더
// =============================================================================
function AgreementV4({
variant,
indent,
left,
middle,
right,
onPressEnd,
className = "",
style,
...rest
}) {
variant = __agv4Variant(variant, "large");
// IndentPushable.pushed 가 true 면 자식 indent +1 (자식 직접 지정 시 그게 우선)
const pushCtx = React.useContext(__AGV4PushableCtx);
const baseIndent = __agv4ClampIndent(typeof indent === "number" ? indent : (pushCtx.pushed ? 1 : 0));
const cls = [
"tds-agv4",
`tds-agv4--${variant}`,
`tds-agv4--indent-${baseIndent}`,
onPressEnd ? "is-pressable" : "",
className,
].filter(Boolean).join(" ");
const handleClick = function (e) {
if (typeof onPressEnd === "function") onPressEnd(e);
};
const handleKey = function (e) {
if (typeof onPressEnd !== "function") return;
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
onPressEnd(e);
}
};
// 행 자체에 onPressEnd 를 주면 role=button 으로 클릭 가능 영역
const interactiveProps = onPressEnd
? { role: "button", tabIndex: 0, onClick: handleClick, onKeyDown: handleKey }
: {};
return (
{left ?
{left}
: null}
{middle ?
{middle}
: null}
{right ?
{right}
: null}
);
}
// =============================================================================
// AgreementV4.Text — 동의 항목 텍스트 (necessity prefix 슬롯 + onPressEnd)
// =============================================================================
function AgreementV4Text({
necessity,
onPressEnd,
children,
className = "",
style,
...rest
}) {
const cls = [
"tds-agv4__text",
onPressEnd ? "is-pressable" : "",
className,
].filter(Boolean).join(" ");
const handleClick = function (e) {
if (typeof onPressEnd === "function") onPressEnd(e);
};
const handleKey = function (e) {
if (typeof onPressEnd !== "function") return;
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
onPressEnd(e);
}
};
const interactiveProps = onPressEnd
? { role: "button", tabIndex: 0, onClick: handleClick, onKeyDown: handleKey }
: {};
return (
{necessity ? {necessity} : null}
{children}
);
}
// =============================================================================
// AgreementV4.Badge — 시각 강조 뱃지 (fill/clear, bgColor/textColor)
// =============================================================================
function AgreementV4Badge({
variant,
textColor,
bgColor,
children,
className = "",
style,
...rest
}) {
if (!__AGV4_BADGE_VARIANTS.includes(variant)) {
if (window.console && console.warn) {
console.warn(`[AgreementV4.Badge] variant 는 fill | clear. 받은 값: ${variant}. "clear" 폴백.`);
}
variant = "clear";
}
const styleMerged = Object.assign({}, style || {});
if (textColor) styleMerged.color = textColor;
if (variant === "fill") {
if (!bgColor && window.console && console.warn) {
console.warn(`[AgreementV4.Badge] variant="fill" 일 때는 bgColor 가 필수에요.`);
}
if (bgColor) styleMerged.backgroundColor = bgColor;
}
const cls = [
"tds-agv4__badge",
`tds-agv4__badge--${variant}`,
className,
].filter(Boolean).join(" ");
return (
{children}
);
}
// =============================================================================
// AgreementV4.Checkbox — 선택 체크박스 (checkbox/dot/hidden, controlled/uncontrolled)
// =============================================================================
function AgreementV4Checkbox({
variant = "checkbox",
checked,
defaultChecked,
onCheckedChange,
motionVariant = "weak",
transitionDelay = 0,
className = "",
style,
...rest
}) {
if (!__AGV4_CHECKBOX_VARIANTS.includes(variant)) {
if (window.console && console.warn) {
console.warn(`[AgreementV4.Checkbox] variant 는 checkbox | dot | hidden. 받은 값: ${variant}. "checkbox" 폴백.`);
}
variant = "checkbox";
}
const isControlled = typeof checked === "boolean";
const [internal, setInternal] = React.useState(!!defaultChecked);
const current = isControlled ? checked : internal;
const handleChange = function (e) {
if (!isControlled) setInternal(!current);
if (typeof onCheckedChange === "function") onCheckedChange(!current);
};
// 체크박스 자체에 motion delay (스펙: 설정값 + 0.1초 자동 추가)
const delayMs = Math.max(0, (typeof transitionDelay === "number" && isFinite(transitionDelay) ? transitionDelay : 0) + 0.1) * 1000;
const motionStyle = Object.assign(
{ transitionDelay: `${delayMs}ms` },
style || {}
);
const cls = [
"tds-agv4__checkbox",
`tds-agv4__checkbox--${variant}`,
`tds-agv4__checkbox--motion-${motionVariant === "strong" ? "strong" : "weak"}`,
current ? "is-checked" : "",
className,
].filter(Boolean).join(" ");
return (
);
}
// =============================================================================
// AgreementV4.Necessity — 필수/선택 표시 (mandatory=blue / optional=grey)
// =============================================================================
function AgreementV4Necessity({
variant,
children,
className = "",
style,
...rest
}) {
if (!__AGV4_NECESSITY_VARIANTS.includes(variant)) {
if (window.console && console.warn) {
console.warn(`[AgreementV4.Necessity] variant 는 mandatory | optional. 받은 값: ${variant}. "optional" 폴백.`);
}
variant = "optional";
}
const cls = [
"tds-agv4__necessity",
`tds-agv4__necessity--${variant}`,
className,
].filter(Boolean).join(" ");
// 자동 aria-label (필수/선택)
const autoLabel = variant === "mandatory" ? "필수" : "선택";
return (
{children}
);
}
// =============================================================================
// AgreementV4.RightArrow — 접고 펼치는 트리거 화살표 (collapsed → 90도 회전)
// =============================================================================
function AgreementV4RightArrow({
collapsed,
onArrowClick,
className = "",
style,
...rest
}) {
// Collapsible 컨텍스트와 자동 연동 (호출자가 직접 collapsed/onArrowClick 주면 그게 우선)
const collCtx = React.useContext(__AGV4CollapsibleCtx);
const effCollapsed = typeof collapsed === "boolean" ? collapsed : !!collCtx.collapsed;
const cls = [
"tds-agv4__right-arrow",
effCollapsed ? "is-collapsed" : "is-expanded",
className,
].filter(Boolean).join(" ");
const handleClick = function (e) {
if (typeof onArrowClick === "function") onArrowClick(e);
else if (typeof collCtx.toggle === "function") collCtx.toggle();
};
// 클릭 가능한 화살표 — Collapsible 안에선 자동 연동 / 단독 사용 시 onArrowClick 만
const isClickable = typeof onArrowClick === "function" || typeof collCtx.toggle === "function";
return (
);
}
// =============================================================================
// AgreementV4.Description — 부가 설명 (box=박스 강조 / normal=평문)
// =============================================================================
function AgreementV4Description({
variant,
indent = 0,
children,
className = "",
style,
...rest
}) {
if (!__AGV4_DESCRIPTION_VARIANTS.includes(variant)) {
if (window.console && console.warn) {
console.warn(`[AgreementV4.Description] variant 는 box | normal. 받은 값: ${variant}. "normal" 폴백.`);
}
variant = "normal";
}
const safeIndent = __agv4ClampIndent(indent);
const cls = [
"tds-agv4__description",
`tds-agv4__description--${variant}`,
`tds-agv4__description--indent-${safeIndent}`,
className,
].filter(Boolean).join(" ");
return (
{children}
);
}
// =============================================================================
// AgreementV4.Header — 섹션 제목 (variant 6종 + indent)
// =============================================================================
function AgreementV4Header({
variant,
indent = 0,
children,
className = "",
style,
...rest
}) {
variant = __agv4Variant(variant, "large");
const safeIndent = __agv4ClampIndent(indent);
const cls = [
"tds-agv4__header",
`tds-agv4__header--${variant}`,
`tds-agv4__header--indent-${safeIndent}`,
className,
].filter(Boolean).join(" ");
// 자동 heading role + aria-level (variant 별)
const ariaLevel = (variant === "xLarge") ? 1
: (variant === "large") ? 2
: (variant === "medium" || variant === "medium-title") ? 3
: 4;
return (
{children}
);
}
// =============================================================================
// AgreementV4.Pressable — 클릭 감지 래퍼 (체크박스 제외 영역 단일 액션화)
// =============================================================================
function AgreementV4Pressable({
onPressEnd,
children,
className = "",
style,
...rest
}) {
const cls = [
"tds-agv4__pressable",
className,
].filter(Boolean).join(" ");
const handleClick = function (e) {
if (typeof onPressEnd === "function") onPressEnd(e);
};
const handleKey = function (e) {
if (typeof onPressEnd !== "function") return;
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
onPressEnd(e);
}
};
return (
{children}
);
}
// =============================================================================
// AgreementV4.IndentPushable — 시각적 계층 컨테이너 (pushed → 자식 indent +1)
// =============================================================================
function AgreementV4IndentPushable({
pushed,
defaultPushed,
onPushedChange,
children,
className = "",
style,
...rest
}) {
const isControlled = typeof pushed === "boolean";
const [internal, setInternal] = React.useState(typeof defaultPushed === "boolean" ? defaultPushed : true);
const current = isControlled ? pushed : internal;
const toggle = React.useCallback(function () {
const next = !current;
if (!isControlled) setInternal(next);
if (typeof onPushedChange === "function") onPushedChange(next);
}, [current, isControlled, onPushedChange]);
// children 중 IndentPushableTrigger / IndentPushableContent 만 자기 컨텍스트 적용
// (기존 children 그대로 두고 컨텍스트로 push 신호 전달)
const ctxValue = React.useMemo(function () {
return { pushed: !!current, toggle: toggle };
}, [current, toggle]);
const cls = [
"tds-agv4__indent-pushable",
current ? "is-pushed" : "",
className,
].filter(Boolean).join(" ");
return (
<__AGV4PushableCtx.Provider value={ctxValue}>
{children}
);
}
// =============================================================================
// AgreementV4.IndentPushableTrigger — 클릭 시 들여쓰기 토글 (컨텍스트에서 toggle)
// =============================================================================
function AgreementV4IndentPushableTrigger({
children,
className = "",
style,
...rest
}) {
const ctx = React.useContext(__AGV4PushableCtx);
const cls = [
"tds-agv4__indent-pushable-trigger",
className,
].filter(Boolean).join(" ");
const handleClick = function (e) {
if (typeof ctx.toggle === "function") ctx.toggle();
};
const handleKey = function (e) {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
if (typeof ctx.toggle === "function") ctx.toggle();
}
};
return (
{children}
);
}
// =============================================================================
// AgreementV4.IndentPushableContent — pushed 시 자식들이 indent +1 (컨텍스트 활성)
// =============================================================================
function AgreementV4IndentPushableContent({
children,
className = "",
style,
...rest
}) {
// PushableCtx 는 위에서 이미 활성됐으므로 자식 AgreementV4 는 자동으로 pushed 반영.
// 이 래퍼는 단순한 시각 그룹 + max-height transition 슬롯.
const cls = [
"tds-agv4__indent-pushable-content",
className,
].filter(Boolean).join(" ");
return (
{children}
);
}
// =============================================================================
// AgreementV4.Collapsible — 접고 펼치는 컨테이너 (collapsed=true 면 hidden)
// =============================================================================
function AgreementV4Collapsible({
collapsed,
defaultCollapsed,
onCollapsedChange,
children,
className = "",
style,
...rest
}) {
const isControlled = typeof collapsed === "boolean";
// defaultCollapsed 는 첫 렌더 시 collapsed 상태(hidden) — 기본 true
const [internal, setInternal] = React.useState(typeof defaultCollapsed === "boolean" ? defaultCollapsed : true);
const current = isControlled ? collapsed : internal;
// 자동 contentId — useId 폴백 시퀀스 (다중 인스턴스 충돌 방지)
const idRef = React.useRef(null);
if (idRef.current === null) {
idRef.current = `tds-agv4-collapsible-${(AgreementV4Collapsible.__seq = (AgreementV4Collapsible.__seq || 0) + 1)}`;
}
const toggle = React.useCallback(function () {
const next = !current;
if (!isControlled) setInternal(next);
if (typeof onCollapsedChange === "function") onCollapsedChange(next);
}, [current, isControlled, onCollapsedChange]);
const ctxValue = React.useMemo(function () {
return { collapsed: !!current, contentId: idRef.current, toggle: toggle };
}, [current, toggle]);
const cls = [
"tds-agv4__collapsible",
current ? "is-collapsed" : "is-expanded",
className,
].filter(Boolean).join(" ");
return (
<__AGV4CollapsibleCtx.Provider value={ctxValue}>
{children}
);
}
// =============================================================================
// AgreementV4.CollapsibleTrigger — 자동 aria-expanded + aria-controls + 클릭 토글
// =============================================================================
function AgreementV4CollapsibleTrigger({
children,
className = "",
style,
...rest
}) {
const ctx = React.useContext(__AGV4CollapsibleCtx);
const cls = [
"tds-agv4__collapsible-trigger",
className,
].filter(Boolean).join(" ");
const handleClick = function (e) {
if (typeof ctx.toggle === "function") ctx.toggle();
};
const handleKey = function (e) {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
if (typeof ctx.toggle === "function") ctx.toggle();
}
};
return (
{children}
);
}
// =============================================================================
// AgreementV4.CollapsibleContent — collapsed=true 면 hidden (렌더는 유지 → a11y)
// =============================================================================
function AgreementV4CollapsibleContent({
children,
className = "",
style,
...rest
}) {
const ctx = React.useContext(__AGV4CollapsibleCtx);
const cls = [
"tds-agv4__collapsible-content",
ctx.collapsed ? "is-hidden" : "is-visible",
className,
].filter(Boolean).join(" ");
return (
{children}
);
}
// =============================================================================
// AgreementV4.Group — 여러 동의 항목 그룹화 (showGradient 좌측 세로 라인)
// =============================================================================
function AgreementV4Group({
showGradient = true,
children,
className = "",
style,
...rest
}) {
const cls = [
"tds-agv4__group",
showGradient ? "tds-agv4__group--gradient" : "",
className,
].filter(Boolean).join(" ");
return (
{children}
);
}
// =============================================================================
// 서브 컴포넌트 attach + 글로벌 등록
// =============================================================================
AgreementV4.Text = AgreementV4Text;
AgreementV4.Badge = AgreementV4Badge;
AgreementV4.Checkbox = AgreementV4Checkbox;
AgreementV4.Necessity = AgreementV4Necessity;
AgreementV4.RightArrow = AgreementV4RightArrow;
AgreementV4.Description = AgreementV4Description;
AgreementV4.Header = AgreementV4Header;
AgreementV4.Pressable = AgreementV4Pressable;
AgreementV4.IndentPushable = AgreementV4IndentPushable;
AgreementV4.IndentPushableTrigger = AgreementV4IndentPushableTrigger;
AgreementV4.IndentPushableContent = AgreementV4IndentPushableContent;
AgreementV4.Collapsible = AgreementV4Collapsible;
AgreementV4.CollapsibleTrigger = AgreementV4CollapsibleTrigger;
AgreementV4.CollapsibleContent = AgreementV4CollapsibleContent;
AgreementV4.Group = AgreementV4Group;
window.AgreementV4 = AgreementV4;
Object.assign(window, { AgreementV4 });