/* ============================================================================
* 서명전에 — TDS Highlight 컴포넌트 (스포트라이트 / 코치마크)
* · open=true 가 되면 children 영역만 빛이 새어 나오고 화면 전체가 어두워져요.
* · 구현 전략: spot 에 box-shadow 0 0 0 100vmax rgba(0,0,0,.7) 로 dim 페인트
* + 별도 fixed 투명 backdrop 으로 외부 클릭(onClick) 캡처. 박스 섀도우는 hit-test
* 영향이 없어 자연스럽게 동작해요.
* · message 는 root 기준 absolute 배치 — X 정렬은 left/center/right, Y 정렬은
* top(spot 위) / bottom(spot 아래). 정렬을 안 주면 spot 의 화면 위치를 보고
* 자동 결정해요.
* · 화살표는 항상 spot 가로 중앙에 절대 배치 — X 정렬에 무관 (TDS 계약).
* · delay 는 초 단위 (TDS 스펙).
* · onExited 는 close → 애니메이션 종료(220ms) 후 한 번 호출.
*
* · props (HighlightProps):
* open* boolean — 표시 여부
* padding number(px) — spot 내부 padding (기본 0)
* delay number(sec) — 표시 전 지연 (기본 0)
* highlighterClassname string — spot 추가 className
* message string | (props: { color, style }) => ReactElement
* messageColor string — 메시지 색 (기본 #ffffff)
* messageXAlignment "left" | "center" | "right" — 미지정 시 자동
* messageYAlignment "top" | "bottom" — 미지정 시 자동
* onClick (e) => void — 외부(backdrop) 클릭
* onExited () => void — close 애니메이션 종료 후
* className, style, aria-* ... 루트
패스스루
* children ReactNode — 강조될 영역
* ========================================================================== */
const {
useState: __hlUseState,
useRef: __hlUseRef,
useEffect: __hlUseEffect,
useLayoutEffect: __hlUseLayoutEffect,
} = React;
const HIGHLIGHT_TRANSITION_MS = 220;
function Highlight({
open = false,
padding = 0,
delay = 0,
highlighterClassname = "",
message,
messageColor = "#ffffff",
messageXAlignment,
messageYAlignment,
onClick,
onExited,
className = "",
style,
children,
...rest
}) {
// visible 은 delay/transition 을 반영한 실제 화면 표시 상태.
const [visible, setVisible] = __hlUseState(false);
const [autoX, setAutoX] = __hlUseState("center");
const [autoY, setAutoY] = __hlUseState("bottom");
const spotRef = __hlUseRef(null);
const exitedTimerRef = __hlUseRef(null);
const enterTimerRef = __hlUseRef(null);
// open prop 변경에 따른 visible 토글 + delay/exit 처리
__hlUseEffect(() => {
// 진입
if (open) {
// 닫힘 타이머가 떠있으면 먼저 정리
if (exitedTimerRef.current) {
clearTimeout(exitedTimerRef.current);
exitedTimerRef.current = null;
}
if (delay > 0) {
enterTimerRef.current = setTimeout(() => {
setVisible(true);
enterTimerRef.current = null;
}, delay * 1000);
} else {
setVisible(true);
}
return () => {
if (enterTimerRef.current) {
clearTimeout(enterTimerRef.current);
enterTimerRef.current = null;
}
};
}
// 닫힘
if (enterTimerRef.current) {
clearTimeout(enterTimerRef.current);
enterTimerRef.current = null;
}
if (visible) {
setVisible(false);
exitedTimerRef.current = setTimeout(() => {
onExited?.();
exitedTimerRef.current = null;
}, HIGHLIGHT_TRANSITION_MS + 40);
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, delay]);
// 자동 정렬 — visible 직후 spot 위치를 보고 결정
__hlUseLayoutEffect(() => {
if (!visible || !spotRef.current) return;
const r = spotRef.current.getBoundingClientRect();
const vw =
typeof window !== "undefined"
? window.innerWidth || document.documentElement.clientWidth
: 0;
const vh =
typeof window !== "undefined"
? window.innerHeight || document.documentElement.clientHeight
: 0;
if (!messageXAlignment && vw > 0) {
const cx = r.left + r.width / 2;
if (cx < vw / 3) setAutoX("left");
else if (cx > (vw * 2) / 3) setAutoX("right");
else setAutoX("center");
}
if (!messageYAlignment && vh > 0) {
// spot 이 화면 위쪽이면 메시지를 아래에, 아래쪽이면 위에 띄움
setAutoY(r.top < vh / 2 ? "bottom" : "top");
}
}, [visible, messageXAlignment, messageYAlignment, padding]);
const xAlign = messageXAlignment || autoX;
const yAlign = messageYAlignment || autoY;
// 언마운트 시 타이머 정리
__hlUseEffect(() => {
return () => {
if (exitedTimerRef.current) clearTimeout(exitedTimerRef.current);
if (enterTimerRef.current) clearTimeout(enterTimerRef.current);
};
}, []);
const handleBackdropClick = (e) => {
onClick?.(e);
};
// message 해석 — string 또는 함수
let messageNode = null;
if (message != null && message !== false) {
const messageStyle = { color: messageColor };
if (typeof message === "function") {
try {
messageNode = message({ color: messageColor, style: messageStyle });
} catch (err) {
if (window.console && console.warn) {
console.warn("[Highlight] message 함수 호출 실패:", err);
}
messageNode = null;
}
} else {
messageNode =
{message} ;
}
}
const rootCls = ["tds-highlight", className].filter(Boolean).join(" ");
const spotCls = [
"tds-highlight__spot",
visible ? "is-open" : "",
highlighterClassname,
]
.filter(Boolean)
.join(" ");
const messageCls = [
"tds-highlight__message",
`tds-highlight__message--x-${xAlign}`,
`tds-highlight__message--y-${yAlign}`,
]
.filter(Boolean)
.join(" ");
const arrowCls = [
"tds-highlight__arrow",
`tds-highlight__arrow--y-${yAlign}`,
]
.filter(Boolean)
.join(" ");
return (
{visible && (
)}
{children}
{visible && messageNode != null && (
<>
{messageNode}
>
)}
);
}
window.Highlight = Highlight;
Object.assign(window, { Highlight });