/* ============================================================================ * 서명전에 — TDS BottomSheet 컴포넌트 * · 화면 하단에서 슬라이드 업으로 나타나는 패널. * · 서브 컴포넌트: * - BottomSheet.Header — 제목 (기본 t4) * - BottomSheet.HeaderDescription — 부제목 (기본 t6) * - BottomSheet.CTA — 단일 주요 버튼 * - BottomSheet.DoubleCTA — 좌우로 나뉜 2버튼 * - BottomSheet.Select — 선택지(라디오) 목록 * * · props (TDS 스펙 기준): * open* — 열림 여부 * onClose — 닫힘 요청 콜백 (ESC, 딤 클릭, 뒤로가기) * header — 제목 영역 (주로 BottomSheet.Header) * headerDescription — 부제목 영역 * cta — 하단 CTA 영역 (주로 BottomSheet.CTA/DoubleCTA) * children — 본문 * className — 시트 영역 class * dimmerClassName — 딤 영역 class * disableDimmer — true 면 딤 제거 * hasTextField — 내부에 키보드 포커스가 있을 때 키보드 위로 올림 * maxHeight — 기본 높이 (px, 숫자) * expandedMaxHeight — 확장 상태 최대 높이 (px, 숫자) * expandBottomSheet — 드래그 확장 허용 * expandBottomSheetWhenScroll — 스크롤로도 확장 * ctaContentGap — CTA 와 본문 사이 여백 (기본 34) * ariaLabelledBy, ariaDescribedBy — 접근성 ID * onEntered, onExited, onExpanded * onDimmerClick * onHandlerTouchStart, onHandlerTouchEnd * portalContainer — UNSAFE_disableFocusLock=true 이 아니면 document.body * UNSAFE_disableFocusLock, UNSAFE_ignoreDimmerClick, UNSAFE_ignoreBackEvent * a11yIncludeHeaderInScroll, disableChildrenDragging * * · 스펙 중 모바일 제스처(드래그로 확장/닫기) 는 스캐폴드 상태. * API(props + 콜백) 는 전부 수신하지만, 현재 포팅 단계에서는 maxHeight/ * expandedMaxHeight 를 기본값으로만 적용하고 그래버 터치 이벤트는 * onHandlerTouchStart/End 로 패스스루. 네이티브 wrapper 도입 시 * 제스처 바인딩을 추가해요. * ========================================================================== */ function BottomSheet({ open, onClose, header, headerDescription, cta, children, className = "", dimmerClassName = "", disableDimmer = false, hasTextField = false, maxHeight, expandedMaxHeight, expandBottomSheet = false, expandBottomSheetWhenScroll = false, ctaContentGap = 34, ariaLabelledBy, ariaDescribedBy, onEntered, onExited, onExpanded, onDimmerClick, onHandlerTouchStart, onHandlerTouchEnd, UNSAFE_disableFocusLock = false, UNSAFE_ignoreDimmerClick = false, UNSAFE_ignoreBackEvent = false, a11yIncludeHeaderInScroll = true, disableChildrenDragging = false, portalContainer, ...rest }) { const [expanded, setExpanded] = useState(false); // ESC 키 닫기 — UNSAFE_ignoreBackEvent 로 억제 가능 (뒤로가기와 동일 취급). useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === "Escape" && !UNSAFE_ignoreBackEvent) onClose?.(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [open, onClose, UNSAFE_ignoreBackEvent]); // open 이 true → false 바뀔 때 onExited, 반대로 → true 일 때 onEntered. // (transition end 가 아닌 mount 타이밍 기준 — TDS 스펙이 보장하는 최소치) const prevOpen = React.useRef(open); useEffect(() => { if (prevOpen.current !== open) { if (open) onEntered?.(); else onExited?.(); prevOpen.current = open; } }, [open, onEntered, onExited]); // open 이 false 로 바뀌면 expanded 상태도 초기화 useEffect(() => { if (!open && expanded) { setExpanded(false); } }, [open, expanded]); const handleDimmerClick = (e) => { onDimmerClick?.(e); if (!UNSAFE_ignoreDimmerClick) onClose?.(); }; const sheetMaxHeight = expanded && expandedMaxHeight ? `${expandedMaxHeight}px` : maxHeight ? `${maxHeight}px` : undefined; const rootCls = [ "bottom-sheet-root", open ? "is-open" : "", hasTextField ? "bottom-sheet-root--textfield" : "", ] .filter(Boolean) .join(" "); const sheetCls = [ "bottom-sheet", expanded ? "bottom-sheet--expanded" : "", cta ? "bottom-sheet--has-cta" : "", className, ] .filter(Boolean) .join(" "); const dimCls = ["bottom-sheet-root__dimmer", dimmerClassName] .filter(Boolean) .join(" "); // 본문 영역이 CTA 아래로 가려지지 않도록 CTA 가 있을 때 padding-bottom 을 더 준다. const bodyStyle = cta ? { paddingBottom: `${ctaContentGap}px` } : undefined; return (
{!disableDimmer && (
)}
{(header || headerDescription) && (
{header} {headerDescription}
)}
{children}
{cta &&
{cta}
}
); } /* -------------------- BottomSheet.Header -------------------- */ /* 문자열을 받으면 h1 로 감싸고 t4 를 적용. ReactNode 면 그대로 전달. */ function BottomSheetHeader({ children, className = "", ...rest }) { const cls = ["bottom-sheet-header", className].filter(Boolean).join(" "); if (typeof children === "string" || typeof children === "number") { return

{children}

; } return
{children}
; } /* -------------------- BottomSheet.HeaderDescription -------------------- */ function BottomSheetHeaderDescription({ children, className = "", ...rest }) { const cls = ["bottom-sheet-header-desc", className] .filter(Boolean) .join(" "); return

{children}

; } /* -------------------- BottomSheet.CTA -------------------- */ /* 하단 영역 전체 너비 주요 버튼. TDS Button (color="primary" variant="fill" * size="xlarge" display="block") 위에 .bottom-sheet-cta 클래스로 시트 컨텍스트 * 스타일을 얹는다. loading 등 추가 prop 도 그대로 패스스루. */ function BottomSheetCTA({ children, className = "", onClick, disabled, loading, type = "button", ...rest }) { const cls = ["bottom-sheet-cta", className].filter(Boolean).join(" "); return ( ); } /* -------------------- BottomSheet.DoubleCTA -------------------- */ /* leftButton / rightButton 두 자리. 일반적으로 좌=취소/약한 버튼, 우=주요 버튼. */ function BottomSheetDoubleCTA({ leftButton, rightButton, className = "", ...rest }) { const cls = ["bottom-sheet-double-cta", className] .filter(Boolean) .join(" "); return (
{leftButton}
{rightButton}
); } /* -------------------- BottomSheet.Select -------------------- */ /* options: [{ name, value, className?, disabled?, hideUnCheckedCheckBox? }] * onChange: (e) => void — e.target.value 로 선택값 전달 (radio input 이벤트 그대로) */ function BottomSheetSelect({ options = [], onChange, value, className = "", animation = true, animationDelay = 0, ...rest }) { const cls = ["bottom-sheet-select", className] .filter(Boolean) .join(" "); // 라디오 그룹 이름은 한 페이지에서 여러 Select 가 있어도 겹치지 않게 랜덤. const groupNameRef = React.useRef( "bs-select-" + Math.random().toString(36).slice(2, 8) ); return ( ); } /* -------------------- 서브컴포넌트 부착 -------------------- */ BottomSheet.Header = BottomSheetHeader; BottomSheet.HeaderDescription = BottomSheetHeaderDescription; BottomSheet.CTA = BottomSheetCTA; BottomSheet.DoubleCTA = BottomSheetDoubleCTA; BottomSheet.Select = BottomSheetSelect; window.BottomSheet = BottomSheet; Object.assign(window, { BottomSheet });