/* ============================================================================ * 서명전에 — 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 ( ); } // ============================================================================= // 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 });