/** * @title Tooltip * @description 마우스를 올리거나 키보드로 포커스했을 때 요소를 짧게 설명하는 떠 있는 라벨이에요. */import * as React from 'react';import { createPortal } from 'react-dom';import { Tooltip as TooltipPrimitiveRadix } from 'radix-ui';import { cn } from '@/lib/utils';// Atlassian @atlaskit/tooltip — tooltip.js / tooltip-container.js / tooltip-shortcut.js 스펙// - 기본 위치 bottom, 지연 300ms(표시·숨김 모두), 대상과 8px 간격, 화살표 없음// - 이미 다른 툴팁이 보이는 중이면 지연·애니메이션 없이 바로 보여준다 (show-immediate)type Side = 'top' | 'right' | 'bottom' | 'left';type Align = 'start' | 'center' | 'end';export type PositionTypeBase = | 'auto' | 'auto-start' | 'auto-end' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end';export type PositionMouseOptions = 'mouse' | 'mouse-x' | 'mouse-y';export type PositionType = PositionTypeBase | PositionMouseOptions;const TOOLTIP_OFFSET = 8;const SHOW_IMMEDIATE_WINDOW = 300;function parsePlacement(placement: PositionTypeBase): { side: Side; align: Align } { const [rawSide, rawAlign] = placement.split('-') as [string, string | undefined]; const side: Side = rawSide === 'auto' ? 'bottom' : (rawSide as Side); const align: Align = rawAlign === 'start' ? 'start' : rawAlign === 'end' ? 'end' : 'center'; return { side, align };}/* ------------------------------------------------------------------------------------------------- * 전역 툴팁 매니저 — 한 번에 하나만 보이고, 연달아 옮겨 다닐 때는 즉시 보여준다 * -----------------------------------------------------------------------------------------------*/let activeHide: (() => void) | null = null;let lastHiddenAt = 0;/* ------------------------------------------------------------------------------------------------- * TooltipShortcut * -----------------------------------------------------------------------------------------------*/function TooltipShortcut({ shortcut }: { shortcut: string[] }) { return ( <div data-slot="tooltip-shortcut" className="flex gap-ds-025 pt-ds-050 pb-ds-025"> {shortcut.map((segment, index) => ( <kbd key={`${segment}-${index}`} className="box-border flex h-5 min-w-0 items-center rounded-ds-xsmall border-(length:--ds-border-width) border-solid border-ds-border-bold bg-transparent px-ds-075 py-ds-025 ds-font-body-small text-inherit shadow-none" > {segment} </kbd> ))} </div> );}/* ------------------------------------------------------------------------------------------------- * TooltipPrimitive — 스타일 없는 하위 컴포넌트. `component` 로 직접 만든 툴팁의 바탕이 돼요. * -----------------------------------------------------------------------------------------------*/export interface TooltipPrimitiveProps extends React.ComponentProps<'div'> { placement?: PositionTypeBase; testId?: string; shortcut?: string[]; truncate?: boolean;}function TooltipPrimitive({ className, children, placement, testId, shortcut, truncate: _truncate, ...props}: TooltipPrimitiveProps) { return ( <div data-slot="tooltip-content" data-placement={placement} data-testid={testId} className={cn('cursor-default', className)} {...props} > {children} {shortcut && shortcut.length > 0 && <TooltipShortcut shortcut={shortcut} />} </div> );}/* ------------------------------------------------------------------------------------------------- * TooltipContainer — 기본 툴팁 모양 (tooltip-container.js) * -----------------------------------------------------------------------------------------------*/const tooltipContainerClass = cn( // base 'z-[9999] box-border max-w-60 rounded-ds-small bg-ds-background-neutral-bold px-ds-075 py-ds-050', 'ds-font-body-small text-ds-text-inverse [overflow-wrap:break-word]', // FadeIn(entranceDirection) — medium 350ms 들어오기, 나갈 때는 절반 'animate-in fade-in-0 duration-[350ms] ease-[cubic-bezier(0.2,0,0,1)]', 'data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-[175ms]', 'data-[instant=true]:animate-none', 'motion-reduce:animate-none',);function TooltipContainer({ className, truncate, ...props }: TooltipPrimitiveProps) { return ( <TooltipPrimitive truncate={truncate} className={cn( tooltipContainerClass, truncate && 'max-w-[420px] overflow-hidden text-ellipsis whitespace-nowrap', className, )} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * Tooltip * -----------------------------------------------------------------------------------------------*/export interface TooltipProps { /** 툴팁 내용. `({ update }) => ReactNode` 함수도 받아요. */ content: React.ReactNode | ((props: { update?: () => void }) => React.ReactNode); /** 기본 모양 대신 쓸 컨테이너. `TooltipPrimitive` 를 바탕으로 만들고 ref·props 를 전달해야 해요. */ component?: React.ComponentType<TooltipPrimitiveProps>; /** 표시·숨김 전에 기다리는 시간(ms) */ delay?: number; /** 툴팁 자체에 `pointer-events: none` 을 걸어요. 툴팁 위로 마우스를 옮기면 바로 닫혀요. */ ignoreTooltipPointerEvents?: boolean; /** 대상을 클릭하면 툴팁을 숨겨요. */ hideTooltipOnClick?: boolean; /** 대상에서 마우스 버튼을 누르는 순간 툴팁을 숨겨요. */ hideTooltipOnMouseDown?: boolean; /** `position` 이 mouse 계열일 때 포인터 기준 위치 */ mousePosition?: PositionTypeBase; /** false 를 돌려주면 툴팁을 띄우지 않아요. */ canAppear?: () => boolean; /** 스크린 리더용 중복 설명(aria-describedby)을 끄요. */ isScreenReaderAnnouncementDisabled?: boolean; onShow?: () => void; onHide?: () => void; /** 대상 기준 위치. `mouse` 는 포인터 옆에 띄워요. */ position?: PositionType; /** 자식을 감쌀 요소. 지정하지 않으면 단일 요소 자식에 바로 붙어요. */ tag?: React.ElementType; /** @deprecated 한 줄로 자르기. 접근성 문제로 권장하지 않아요. */ truncate?: boolean; children: React.ReactNode; testId?: string; /** 툴팁 안에 키보드 단축키를 키 단위로 보여줘요. */ shortcut?: string[]; /** 다른 툴팁이 보이는 중이어도 항상 페이드인으로 나타나요. */ shouldAlwaysFadeIn?: boolean; strategy?: 'absolute' | 'fixed';}function Tooltip({ content, component: Container = TooltipContainer, delay = 300, ignoreTooltipPointerEvents = false, hideTooltipOnClick = false, hideTooltipOnMouseDown = false, mousePosition = 'bottom', canAppear, isScreenReaderAnnouncementDisabled = false, onShow, onHide, position = 'bottom', tag, truncate = false, children, testId, shortcut, shouldAlwaysFadeIn = false,}: TooltipProps) { const [open, setOpen] = React.useState(false); const [instant, setInstant] = React.useState(false); const [point, setPoint] = React.useState<{ x: number; y: number } | null>(null); const timerRef = React.useRef<number | undefined>(undefined); const triggerRef = React.useRef<HTMLElement | null>(null); const pointerRef = React.useRef<{ x: number; y: number } | null>(null); const viaPointerRef = React.useRef(false); const pressRef = React.useRef(false); const dismissedRef = React.useRef(false); const openRef = React.useRef(false); const id = React.useId(); const mouseContentId = `${id}-tooltip`; const isMouse = position === 'mouse' || position === 'mouse-x' || position === 'mouse-y'; const resolved = content === undefined || content === null ? null : typeof content === 'function' ? content({ update: () => {} }) : content; const clearTimer = () => window.clearTimeout(timerRef.current); const onHideRef = React.useRef(onHide); onHideRef.current = onHide; // 전역 매니저가 참조하므로 렌더 사이에 같은 함수로 유지한다 const hideNow = React.useCallback(function hide() { window.clearTimeout(timerRef.current); if (!openRef.current) return; openRef.current = false; setOpen(false); lastHiddenAt = Date.now(); if (activeHide === hide) activeHide = null; onHideRef.current?.(); }, []); const showNow = (immediate: boolean) => { if (activeHide && activeHide !== hideNow) activeHide(); activeHide = hideNow; openRef.current = true; setInstant(immediate); // 마우스 위치는 나타나는 순간의 좌표로 고정 setPoint(viaPointerRef.current ? pointerRef.current : null); setOpen(true); onShow?.(); }; const scheduleShow = () => { if (resolved === null || dismissedRef.current) return; if (canAppear && !canAppear()) return; clearTimer(); if (openRef.current) return; const immediate = !shouldAlwaysFadeIn && (activeHide !== null || Date.now() - lastHiddenAt < SHOW_IMMEDIATE_WINDOW); if (immediate || delay <= 0) { showNow(immediate); } else { timerRef.current = window.setTimeout(() => showNow(false), delay); } }; const scheduleHide = (immediate = false) => { clearTimer(); if (!openRef.current) return; if (immediate || delay <= 0) hideNow(); else timerRef.current = window.setTimeout(hideNow, delay); }; React.useEffect(() => () => { window.clearTimeout(timerRef.current); if (activeHide === hideNow) activeHide = null; }, [hideNow]); // 스크롤하거나 Escape 를 누르면 바로 닫는다 (원본과 동일) React.useEffect(() => { if (!open) return; const onScroll = () => hideNow(); const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { dismissedRef.current = true; hideNow(); } }; window.addEventListener('scroll', onScroll, { capture: true, passive: true }); document.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('scroll', onScroll, { capture: true }); document.removeEventListener('keydown', onKeyDown); }; }, [open, hideNow]); const handleOpenChange = (next: boolean) => { if (next) { scheduleShow(); return; } // Radix 는 누르거나 클릭하면 닫으려 한다. 원본 기본값(hideTooltipOnClick=false)처럼 무시 if (pressRef.current) return; scheduleHide(); }; const triggerHandlers = { onPointerEnter: () => { viaPointerRef.current = true; dismissedRef.current = false; }, onPointerMove: (event: React.PointerEvent<HTMLElement>) => { pointerRef.current = { x: event.clientX, y: event.clientY }; }, onPointerLeave: () => { dismissedRef.current = false; pressRef.current = false; if (openRef.current) scheduleHide(); else clearTimer(); }, onFocus: () => { viaPointerRef.current = false; }, onBlur: () => { dismissedRef.current = false; }, onPointerDown: () => { if (hideTooltipOnMouseDown) { dismissedRef.current = true; scheduleHide(true); } else { pressRef.current = true; } }, onClick: () => { if (hideTooltipOnClick || hideTooltipOnMouseDown) { pressRef.current = false; dismissedRef.current = true; scheduleHide(true); return; } pressRef.current = true; // 같은 클릭에서 Radix 가 부르는 onOpenChange(false) 가 지나간 뒤 플래그를 지운다 window.setTimeout(() => { pressRef.current = false; }, 0); }, }; // mouse 모드는 Radix Content 대신 직접 그리므로 설명 id 를 직접 연결한다 const describedByProps = isMouse && open && !isScreenReaderAnnouncementDisabled ? { 'aria-describedby': mouseContentId } : {}; const Wrapper = tag ?? (React.isValidElement(children) ? null : 'span'); const trigger = Wrapper ? ( <Wrapper data-slot="tooltip-trigger" data-testid={testId ? `${testId}--container` : undefined}> {children} </Wrapper> ) : ( children ); const basePlacement: PositionTypeBase = isMouse ? mousePosition : (position as PositionTypeBase); const { side, align } = parsePlacement(basePlacement); return ( <TooltipPrimitiveRadix.Provider delayDuration={0} skipDelayDuration={0} disableHoverableContent={ignoreTooltipPointerEvents}> <TooltipPrimitiveRadix.Root open={open} onOpenChange={handleOpenChange}> <TooltipPrimitiveRadix.Trigger asChild ref={(node: HTMLElement | null) => { triggerRef.current = node; }} {...triggerHandlers} {...describedByProps} > {trigger} </TooltipPrimitiveRadix.Trigger> {resolved !== null && (isMouse ? ( open && ( <MouseTooltip id={mouseContentId} Container={Container} position={position as PositionMouseOptions} side={side} point={point} triggerRef={triggerRef} instant={instant} shortcut={shortcut} truncate={truncate} testId={testId} placement={basePlacement} ignoreTooltipPointerEvents={ignoreTooltipPointerEvents} onPointerEnter={() => clearTimer()} onPointerLeave={() => scheduleHide()} > {resolved} </MouseTooltip> ) ) : ( <TooltipPrimitiveRadix.Portal> <TooltipPrimitiveRadix.Content asChild side={side} align={align} sideOffset={TOOLTIP_OFFSET} // 중복 낭독을 끄면 Radix 의 숨은 설명 텍스트를 비운다 aria-label={isScreenReaderAnnouncementDisabled ? ' ' : undefined} collisionPadding={TOOLTIP_OFFSET} onEscapeKeyDown={() => { dismissedRef.current = true; scheduleHide(true); }} onPointerDownOutside={() => scheduleHide(true)} > <Container data-instant={instant || undefined} placement={basePlacement} shortcut={shortcut} truncate={truncate} testId={testId} className={cn(ignoreTooltipPointerEvents && 'pointer-events-none')} onPointerEnter={() => clearTimer()} onPointerLeave={() => scheduleHide()} > {resolved} </Container> </TooltipPrimitiveRadix.Content> </TooltipPrimitiveRadix.Portal> ))} </TooltipPrimitiveRadix.Root> </TooltipPrimitiveRadix.Provider> );}/* ------------------------------------------------------------------------------------------------- * MouseTooltip — position="mouse" | "mouse-x" | "mouse-y" * -----------------------------------------------------------------------------------------------*/interface MouseTooltipProps { id: string; Container: React.ComponentType<TooltipPrimitiveProps>; position: PositionMouseOptions; side: Side; point: { x: number; y: number } | null; triggerRef: React.RefObject<HTMLElement | null>; instant: boolean; shortcut?: string[]; truncate?: boolean; testId?: string; placement: PositionTypeBase; ignoreTooltipPointerEvents: boolean; onPointerEnter: () => void; onPointerLeave: () => void; children: React.ReactNode;}function MouseTooltip({ id, Container, position, side, point, triggerRef, instant, shortcut, truncate, testId, placement, ignoreTooltipPointerEvents, onPointerEnter, onPointerLeave, children,}: MouseTooltipProps) { const [mounted, setMounted] = React.useState(false); React.useEffect(() => setMounted(true), []); const style = React.useMemo<React.CSSProperties>(() => { const rect = triggerRef.current?.getBoundingClientRect(); const cx = rect ? rect.left + rect.width / 2 : 0; const cy = rect ? rect.top + rect.height / 2 : 0; // 키보드 포커스로 열리면 포인터 대신 대상 요소 기준 let x = cx; let y = cy; if (point && position === 'mouse') { x = point.x; y = point.y; } else if (point && position === 'mouse-x') { x = point.x; } else if (point && position === 'mouse-y') { y = point.y; } const fromTarget = !point || position !== 'mouse'; const edge = { top: fromTarget && rect && position !== 'mouse-y' ? rect.top : y, bottom: fromTarget && rect && position !== 'mouse-y' ? rect.bottom : y, left: fromTarget && rect && position !== 'mouse-x' ? rect.left : x, right: fromTarget && rect && position !== 'mouse-x' ? rect.right : x, }; switch (side) { case 'top': return { left: x, top: edge.top - TOOLTIP_OFFSET, transform: 'translate(-50%, -100%)' }; case 'left': return { left: edge.left - TOOLTIP_OFFSET, top: y, transform: 'translate(-100%, -50%)' }; case 'right': return { left: edge.right + TOOLTIP_OFFSET, top: y, transform: 'translate(0, -50%)' }; default: return { left: x, top: edge.bottom + TOOLTIP_OFFSET, transform: 'translate(-50%, 0)' }; } }, [point, position, side, triggerRef]); if (!mounted) return null; return createPortal( <div style={{ position: 'fixed', zIndex: 9999, ...style }} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave}> <Container id={id} role="tooltip" data-side={side} data-state="delayed-open" data-instant={instant || undefined} placement={placement} shortcut={shortcut} truncate={truncate} testId={testId} className={cn('w-max', ignoreTooltipPointerEvents && 'pointer-events-none')} > {children} </Container> </div>, document.body, );}export { Tooltip, TooltipPrimitive, TooltipContainer };