/* ============================================================================ * 서명전에 — TDS Toast 컴포넌트 * · 화면 상/하단에 잠깐 떠올라 작업 완료·이벤트를 알리는 피드백 메시지. * 오버레이로 본문을 가리지 않으며 자동으로 사라지고, 사용자가 손가락으로 * 스와이프(상단=위로 / 하단=아래로)해 즉시 닫을 수도 있어요. * * · 4 컴포넌트: * Toast — 본체 (Portal 마운트, controlled open + onClose) * Toast.Icon — leftAddon 용 아이콘 (name + frameShape) * Toast.Lottie — leftAddon 용 Lottie (src + frameShape, lottie-web 없으면 SVG 폴백) * Toast.Button — 하단 토스트 우측 액션 버튼 (children + onClick) * * · 라이프사이클 (controlled): * open=false → mount 안 함. * open false→true: 마운트 + RAF 두 번 후 visible 클래스 부여 → slide+fade in. * visible=true 동안 duration 타이머 시작 (button 있으면 기본 5000, 아니면 3000). * 호버/터치/포커스 동안 일시정지(보고 있는 동안 안 사라지게). * 타이머 만료 OR 드래그 닫힘 OR 외부에서 open=false → onClose() 호출. * open=false 감지 → visible 해제 → 280ms 후 unmount + onExited(). * * · 시각: * grey-900 배경 / 흰 텍스트 / 14px 라운드 / shadow-elevation-medium / * 가로 가운데 정렬, max-width: calc(100vw - 32px), min-width: 0. * 좌측 leftAddon (24×24 기본) + 본문 + 우측 button(있으면) 한 행 정렬, gap 12px. * position=top: top env(safe-area-inset-top) + 16px, slide -8px. * position=bottom: bottom env(safe-area-inset-bottom) + 16px, slide +8px. * higherThanCTA + bottom: bottom 값에 88px 추가 (FixedBottomCTA 영역 회피). * * · 드래그-닫기: * pointerdown → setPointerCapture → pointermove 로 dy 추적. * top: dy < -40px 면 닫힘 / bottom: dy > 40px 면 닫힘 (반대 방향은 50% 감쇠). * pointerup 시 임계 미만이면 위치 복원 (transform reset). * * · 접근성: * role="status" + aria-live (기본 polite, 중요한 알림은 assertive). * 텍스트가 곧 스크린 리더 발화 — 별도 라벨 불필요. * Toast.Icon / Toast.Lottie 의 시각 요소는 aria-hidden=true. * Toast.Button 은 안에 있는 button 그대로 — 키보드 접근 가능. * * · 모션: * slide+fade 280ms cubic-bezier(0.22, 1, 0.36, 1). * prefers-reduced-motion 시 transform 없이 fade 만 120ms. * ========================================================================== */ const { useState: __toUseState, useEffect: __toUseEffect, useRef: __toUseRef } = React; const __TOAST_DEFAULT_DURATION = 3000; const __TOAST_DURATION_WITH_BUTTON = 5000; const __TOAST_EXIT_MS = 280; const __TOAST_DRAG_THRESHOLD = 40; /* ============================================================================ * Toast (root) * ========================================================================== */ function Toast({ open, position, text, leftAddon, button, duration, onClose, onExited, higherThanCTA = false, "aria-live": ariaLive = "polite", portalContainer, className = "", style, id, ...rest }) { // ----- props 검증 ----- if (position !== "top" && position !== "bottom") { if (window.console && console.warn) { console.warn( `[Toast] position 은 필수 — "top" | "bottom". 받은 값: ${position}. "bottom" 폴백.` ); } position = "bottom"; } if (typeof text !== "string" || text.length === 0) { if (window.console && console.warn) { console.warn("[Toast] text 는 필수 (비어 있지 않은 string)."); } } if (button && position === "top") { if (window.console && console.warn) { console.warn("[Toast] button 은 position='bottom' 에서만 사용 가능. 무시함."); } button = null; } if (higherThanCTA && position === "top") { if (window.console && console.warn) { console.warn("[Toast] higherThanCTA 는 position='bottom' 에서만 의미 있음. 무시함."); } higherThanCTA = false; } // ----- state ----- const [mounted, setMounted] = __toUseState(!!open); const [visible, setVisible] = __toUseState(false); const [dragDy, setDragDy] = __toUseState(0); const [paused, setPaused] = __toUseState(false); const elRef = __toUseRef(null); const timerRef = __toUseRef(null); const exitTimerRef = __toUseRef(null); const rafRef = __toUseRef(null); const pointerStateRef = __toUseRef(null); // {pointerId, startY, dy} // 효과적 duration — button 있으면 5초, 아니면 3초. 사용자가 명시하면 그 값. const effectiveDuration = typeof duration === "number" && isFinite(duration) && duration > 0 ? duration : button ? __TOAST_DURATION_WITH_BUTTON : __TOAST_DEFAULT_DURATION; // ----- 마운트/언마운트 + visible 토글 ----- __toUseEffect(function () { if (open) { // 1) 마운트 (mounted=false → true) setMounted(true); // 2) 다음 프레임에 visible=true 로 transition 시작 // RAF 두 번 — 첫 RAF 는 마운트 후 paint, 두 번째 RAF 는 transition 시작점. const id1 = requestAnimationFrame(function () { rafRef.current = requestAnimationFrame(function () { setVisible(true); }); }); return function () { cancelAnimationFrame(id1); if (rafRef.current) cancelAnimationFrame(rafRef.current); }; } else { // open=false → visible 해제 → exit 타이머 → 언마운트 setVisible(false); } }, [open]); // ----- 자동 닫힘 타이머 ----- __toUseEffect(function () { if (!visible || !open) return; if (paused) return; timerRef.current = setTimeout(function () { if (typeof onClose === "function") onClose(); }, effectiveDuration); return function () { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }; }, [visible, open, paused, effectiveDuration, onClose]); // ----- exit 후 unmount + onExited ----- __toUseEffect(function () { if (mounted && !open && !visible) { exitTimerRef.current = setTimeout(function () { setMounted(false); setDragDy(0); if (typeof onExited === "function") onExited(); }, __TOAST_EXIT_MS); return function () { if (exitTimerRef.current) { clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } }; } }, [mounted, open, visible, onExited]); // ----- 드래그-닫기 ----- const handlePointerDown = function (e) { if (!visible) return; // 좌클릭 / 터치 / 펜만 if (e.button !== undefined && e.button !== 0) return; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } pointerStateRef.current = { pointerId: e.pointerId, startY: e.clientY, dy: 0, }; setPaused(true); }; const handlePointerMove = function (e) { const s = pointerStateRef.current; if (!s || s.pointerId !== e.pointerId) return; let dy = e.clientY - s.startY; // 닫는 방향이 아닌 쪽은 50% 감쇠 (저항감) if (position === "top" && dy > 0) dy = dy * 0.5; if (position === "bottom" && dy < 0) dy = dy * 0.5; s.dy = dy; setDragDy(dy); }; const handlePointerEnd = function (e) { const s = pointerStateRef.current; if (!s || s.pointerId !== e.pointerId) return; const finalDy = s.dy; pointerStateRef.current = null; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ } setDragDy(0); setPaused(false); const closingMet = (position === "top" && finalDy < -__TOAST_DRAG_THRESHOLD) || (position === "bottom" && finalDy > __TOAST_DRAG_THRESHOLD); if (closingMet) { if (typeof onClose === "function") onClose(); } }; // ----- pause on hover/focus ----- const handlePauseEnter = function () { setPaused(true); }; const handlePauseLeave = function () { setPaused(false); }; if (!mounted) return null; // ----- 클래스 ----- const cls = [ "tds-toast", `tds-toast--${position}`, visible ? "is-visible" : "is-leaving", higherThanCTA ? "tds-toast--higher-cta" : "", button ? "tds-toast--has-button" : "", leftAddon ? "tds-toast--has-left" : "", paused ? "is-paused" : "", className, ] .filter(Boolean) .join(" "); // ----- inline style — 드래그 transform 주입 ----- // 드래그 중에는 base translate 위에 dy 만 더해진 transform 으로 즉시 추적 // (CSS visible/leaving 클래스의 transform 보다 우선) const dragStyle = dragDy !== 0 ? { transform: `translate3d(-50%, ${dragDy}px, 0)`, transition: "none", } : undefined; const finalStyle = { ...(style || {}), ...(dragStyle || {}) }; // ----- portal 결정 ----- const portalRoot = portalContainer || (typeof document !== "undefined" ? document.body : null); if (!portalRoot) return null; return ReactDOM.createPortal(