/** * @title Spotlight * @description 화면의 특정 요소를 강조하고 짧은 메시지나 여러 단계 투어로 새 기능을 소개해요. */import * as React from 'react';import { createPortal } from 'react-dom';import { Slot } from 'radix-ui';import { cn } from '@/lib/utils';import { Button, type ButtonProps } from '@/components/ui/button';// Atlassian @atlaskit/onboarding — spotlight-manager / spotlight-target / spotlight / spotlight-card 스펙// - 블랭킷: color.blanket, z-index 700, opacity .22s 전환// - 대상: 블랭킷에 구멍을 뚫어 실제 요소를 그대로 보여주고, 2px color.border.discovery 테두리 + 3초 펄스// - 카드: color.background.discovery.bold, radius.small, shadow.raised, 기본 너비 400px(160~600px)const useIsoLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;const PULSE_KEYFRAMES = `@keyframes steel-spotlight-pulse{0%,33%{box-shadow:0 0 0 2px var(--ds-border-discovery),0 0 0 0 var(--ds-border-discovery)}66%,to{box-shadow:0 0 0 2px var(--ds-border-discovery),0 0 0 10px color-mix(in srgb,var(--ds-border-discovery) 1%,transparent)}}`;/** 펄스 keyframes 를 한 번만 문서에 넣는다 (React 19 style 호이스팅) */function PulseKeyframes() { return ( <style href="steel-spotlight-pulse" precedence="default"> {PULSE_KEYFRAMES} </style> );}const pulseClass = 'shadow-[0_0_0_2px_var(--ds-border-discovery)] animate-[steel-spotlight-pulse_3s_cubic-bezier(0.55,0.055,0.675,0.19)_infinite] motion-reduce:animate-none';/* ------------------------------------------------------------------------------------------------- * SpotlightManager * -----------------------------------------------------------------------------------------------*/export type TargetRef = (element: HTMLElement | null | undefined) => void;interface SpotlightContextValue { targets: Record<string, HTMLElement | undefined>; getTargetRef: (name: string) => TargetRef; blanketIsTinted: boolean; opened: () => void; closed: () => void;}const SpotlightContext = React.createContext<SpotlightContextValue | null>(null);export interface SpotlightManagerProps { /** 블랭킷을 어둡게 칠할지 여부. false 면 투명한 블랭킷이 클릭만 막아요. */ blanketIsTinted?: boolean; /** 보통 앱 전체나 앱의 한 영역 */ children: React.ReactNode; /** 열려 있는 스포트라이트 수가 바뀔 때 */ onOpenChange?: (count: number) => void;}/** 하위 SpotlightTarget 을 이름으로 기억하고 Spotlight 가 찾을 수 있게 해요. */function SpotlightManager({ blanketIsTinted = true, children, onOpenChange }: SpotlightManagerProps) { const [targets, setTargets] = React.useState<Record<string, HTMLElement | undefined>>({}); const refCache = React.useRef(new Map<string, TargetRef>()); const countRef = React.useRef(0); const onOpenChangeRef = React.useRef(onOpenChange); onOpenChangeRef.current = onOpenChange; const getTargetRef = React.useCallback((name: string) => { let ref = refCache.current.get(name); if (!ref) { ref = (element) => { setTargets((prev) => (prev[name] === (element ?? undefined) ? prev : { ...prev, [name]: element ?? undefined })); }; refCache.current.set(name, ref); } return ref; }, []); const opened = React.useCallback(() => { countRef.current += 1; onOpenChangeRef.current?.(countRef.current); }, []); const closed = React.useCallback(() => { countRef.current = Math.max(0, countRef.current - 1); onOpenChangeRef.current?.(countRef.current); }, []); const value = React.useMemo( () => ({ targets, getTargetRef, blanketIsTinted, opened, closed }), [targets, getTargetRef, blanketIsTinted, opened, closed], ); return <SpotlightContext.Provider value={value}>{children}</SpotlightContext.Provider>;}/* ------------------------------------------------------------------------------------------------- * SpotlightTarget * -----------------------------------------------------------------------------------------------*/type RenderChildrenFunction = (props: { targetRef?: TargetRef }) => React.ReactNode;export interface SpotlightTargetProps { /** 하나의 자식 요소. 감싸는 요소 없이 ref 만 붙이려면 `({ targetRef }) => ...` 함수를 써요. */ children: React.ReactNode | RenderChildrenFunction; /** Spotlight 의 `target` 에서 참조할 이름 */ name: string;}function SpotlightTarget({ children, name }: SpotlightTargetProps) { const ctx = React.useContext(SpotlightContext); const targetRef = ctx?.getTargetRef(name); if (typeof children === 'function') return <>{children({ targetRef })}</>; if (!targetRef) return <>{children}</>; return ( <Slot.Root data-slot="spotlight-target" ref={targetRef as React.Ref<HTMLElement>}> {children} </Slot.Root> );}/* ------------------------------------------------------------------------------------------------- * useSpotlight * -----------------------------------------------------------------------------------------------*//** 등록된 스포트라이트 대상의 상태를 알려줘요. */function useSpotlight() { const ctx = React.useContext(SpotlightContext); const targets = ctx?.targets ?? {}; return { isTargetRendered: (target: string) => Boolean(targets[target]), checkVisibility: (target: string) => (options?: { checkOpacity?: boolean; checkVisibilityCSS?: boolean }): boolean => { const node = targets[target]; if (!node) return false; const el = node as HTMLElement & { checkVisibility?: (o?: object) => boolean }; return el.checkVisibility ? el.checkVisibility(options) : true; }, };}/* ------------------------------------------------------------------------------------------------- * SpotlightCard * -----------------------------------------------------------------------------------------------*/export interface SpotlightAction extends Omit<ButtonProps, 'children' | 'appearance'> { key?: string; text?: React.ReactNode; appearance?: ButtonProps['appearance'] | 'subtle-link';}export type Actions = SpotlightAction[];// spotlightButtonTheme — discovery 배경 위 버튼const actionThemeClass: Record<string, string> = { default: 'bg-ds-background-inverse-subtle text-ds-text-inverse after:border-transparent hover:bg-ds-background-inverse-subtle-hovered active:bg-ds-background-inverse-subtle-pressed', subtle: 'bg-transparent text-ds-text-inverse hover:bg-ds-background-inverse-subtle-hovered active:bg-ds-background-inverse-subtle-pressed', 'subtle-link': 'bg-transparent text-ds-text-inverse hover:bg-transparent hover:underline active:bg-transparent active:no-underline',};function SpotlightActionButton({ text, appearance = 'default', className, ...rest }: Omit<SpotlightAction, 'key'>) { const themed = actionThemeClass[appearance]; return ( <Button appearance={appearance === 'subtle-link' ? 'subtle' : appearance} className={cn(themed, className)} {...rest} > {text} </Button> );}function DefaultHeader({ children }: { children?: React.ReactNode }) { return <div className="flex items-baseline justify-between pb-ds-100">{children}</div>;}function DefaultFooter({ children }: { children?: React.ReactNode }) { return ( <div data-testid="spotlight--dialog-footer" className="flex items-center justify-between pt-ds-100"> {children} </div> );}export interface SpotlightCardProps extends Omit<React.ComponentProps<'div'>, 'children'> { /** 푸터 버튼 */ actions?: Actions; /** 푸터 버튼 왼쪽에 놓을 요소 (단계 표시 등) */ actionsBeforeElement?: React.ReactNode; children?: React.ReactNode; /** 헤더·푸터 컨테이너 교체 */ components?: { Header?: React.ComponentType<{ children?: React.ReactNode }>; Footer?: React.ComponentType<{ children?: React.ReactNode }>; }; heading?: React.ReactNode; /** 제목 레벨 (기본 4) */ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6; /** 제목 오른쪽 요소. 여러 단계 흐름의 단계 번호(1/3)에 주로 써요. */ headingAfterElement?: React.ReactNode; /** 제목 위 이미지 (src 문자열 또는 요소) */ image?: string | React.ReactNode; /** 그림자를 없애요. */ isFlat?: boolean; /** px 숫자 또는 '100%'. 160~600px 사이로 제한돼요. */ width?: number | '100%'; testId?: string; headingId?: string;}/** 대화상자 없이 쓰는 온보딩 카드. 레이아웃이 더 자유로워요. */function SpotlightCard({ actions = [], actionsBeforeElement, children, components = {}, heading, headingLevel = 4, headingAfterElement, image, isFlat = false, width = 400, testId, headingId, className, style, ...props}: SpotlightCardProps) { const { Header = DefaultHeader, Footer = DefaultFooter } = components; const HeadingTag = `h${headingLevel}` as 'h4'; return ( <div data-slot="spotlight-card" data-testid={testId} className={cn( 'box-border h-fit min-w-40 max-w-[600px] overflow-auto rounded-ds-small bg-ds-background-discovery-bold text-ds-text-inverse', !isFlat && 'shadow-ds-raised', className, )} style={{ width, ...style }} {...props} > {typeof image === 'string' ? <img src={image} alt="" className="block" /> : image} <div className="flex flex-col px-ds-250 py-ds-200"> {heading || headingAfterElement ? ( <Header> <HeadingTag id={headingId} className="m-0 ds-font-heading-medium text-ds-text-inverse"> {heading} </HeadingTag> {headingAfterElement} </Header> ) : null} <div className="ds-font-body">{children}</div> {actions.length > 0 || actionsBeforeElement ? ( <Footer> {actionsBeforeElement || <span />} <div className="-mx-ds-050 my-ds-0 flex flex-row-reverse"> {actions.map(({ key, text, ...rest }, idx) => ( <div key={key || (typeof text === 'string' ? text : `${idx}`)} className="mx-ds-050 my-ds-0"> <SpotlightActionButton text={text} {...rest} /> </div> ))} </div> </Footer> ) : null} </div> </div> );}/* ------------------------------------------------------------------------------------------------- * SpotlightPulse * -----------------------------------------------------------------------------------------------*/export interface SpotlightPulseProps extends React.ComponentProps<'div'> { /** 펄스 애니메이션 표시 여부 */ pulse?: boolean; radius?: number | string; bgColor?: string; testId?: string;}/** 블랭킷 없이 요소 주변을 펄스로 강조해 새 기능을 가볍게 알려요. */function SpotlightPulse({ pulse = true, radius, bgColor, testId, className, style, children, ...props }: SpotlightPulseProps) { return ( <div data-slot="spotlight-pulse" data-testid={testId} className={cn('inline-block', pulse && pulseClass, className)} style={{ ...style, backgroundColor: bgColor, borderRadius: radius === undefined ? undefined : typeof radius === 'number' ? `${radius}px` : radius, }} {...props} > {pulse && <PulseKeyframes />} {children} </div> );}/* ------------------------------------------------------------------------------------------------- * SpotlightTransition — 단계가 바뀔 때 Spotlight 를 감싸요 (페이드는 Spotlight 자체가 처리) * -----------------------------------------------------------------------------------------------*/function SpotlightTransition({ children }: { children?: React.ReactNode }) { return <>{children}</>;}/* ------------------------------------------------------------------------------------------------- * Spotlight * -----------------------------------------------------------------------------------------------*/export type DialogPlacement = | 'top left' | 'top center' | 'top right' | 'right top' | 'right middle' | 'right bottom' | 'bottom left' | 'bottom center' | 'bottom right' | 'left top' | 'left middle' | 'left bottom';export type ScrollLogicalPosition = 'start' | 'center' | 'end' | 'nearest';export interface SpotlightProps { actions?: Actions; actionsBeforeElement?: React.ReactNode; children?: React.ReactNode; /** 대상 기준 대화상자 위치. 지정하지 않으면 아래, 공간이 없으면 위로 뒤집혀요. */ dialogPlacement?: DialogPlacement; /** 대화상자 너비(px). 160~600px */ dialogWidth?: number; footer?: React.ComponentType<{ children?: React.ReactNode }>; header?: React.ComponentType<{ children?: React.ReactNode }>; heading?: string; headingAfterElement?: React.ReactNode; /** 제목 위 이미지 경로 */ image?: string; /** 대상 주변 펄스 애니메이션 */ pulse?: boolean; /** 대상의 위치·크기 변화를 계속 감시해요. 성능에 영향이 있어 레이아웃이 움직일 때만 써요. */ shouldWatchTarget?: boolean; /** SpotlightTarget 의 name */ target?: string; /** 대상 DOM 노드를 직접 넘길 때 */ targetNode?: HTMLElement; /** targetReplacement 의 배경색 */ targetBgColor?: string; /** 강조된 대상을 클릭했을 때 */ targetOnClick?: (eventData: { event: React.MouseEvent<HTMLElement>; target?: string }) => void; /** 강조 영역 모서리 */ targetRadius?: number | string; /** 대상 대신 강조 영역에 그릴 요소 */ targetReplacement?: React.ComponentType<{ style?: React.CSSProperties }>; /** 기본값 "spotlight". `--dialog`, `--target` 이 뒤에 붙어요. */ testId?: string; /** 대상을 화면 안으로 스크롤할 때의 세로 정렬 */ scrollPositionBlock?: ScrollLogicalPosition; /** 대화상자 접근성 이름 (aria-label) */ label?: string; /** 대화상자 이름을 가리키는 요소 id (aria-labelledby) */ titleId?: string;}const PLACEMENTS: Record<DialogPlacement, { side: 'top' | 'right' | 'bottom' | 'left'; align: 'start' | 'center' | 'end' }> = { 'top left': { side: 'top', align: 'start' }, 'top center': { side: 'top', align: 'center' }, 'top right': { side: 'top', align: 'end' }, 'right top': { side: 'right', align: 'start' }, 'right middle': { side: 'right', align: 'center' }, 'right bottom': { side: 'right', align: 'end' }, 'bottom left': { side: 'bottom', align: 'start' }, 'bottom center': { side: 'bottom', align: 'center' }, 'bottom right': { side: 'bottom', align: 'end' }, 'left top': { side: 'left', align: 'start' }, 'left middle': { side: 'left', align: 'center' }, 'left bottom': { side: 'left', align: 'end' },};const DIALOG_OFFSET = 8;const VIEWPORT_PADDING = 5;type Rect = { top: number; left: number; width: number; height: number };function readRect(node: HTMLElement): Rect { const r = node.getBoundingClientRect(); return { top: r.top, left: r.left, width: r.width, height: r.height };}function sameRect(a: Rect | null, b: Rect) { return !!a && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;}function useTargetRect(node: HTMLElement | undefined, watch: boolean) { const [rect, setRect] = React.useState<Rect | null>(null); useIsoLayoutEffect(() => { if (!node) { setRect(null); return; } const update = () => { const next = readRect(node); setRect((prev) => (sameRect(prev, next) ? prev : next)); }; update(); window.addEventListener('resize', update); window.addEventListener('scroll', update, { capture: true, passive: true }); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null; ro?.observe(node); let raf = 0; if (watch) { const loop = () => { update(); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); } return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, { capture: true }); ro?.disconnect(); cancelAnimationFrame(raf); }; }, [node, watch]); return rect;}function computeDialogPosition(target: Rect, size: { width: number; height: number }, placement?: DialogPlacement) { const vw = window.innerWidth; const vh = window.innerHeight; const { width: w, height: h } = size; let { side, align } = placement ? PLACEMENTS[placement] : { side: 'bottom' as const, align: 'center' as const }; const right = target.left + target.width; const bottom = target.top + target.height; // flip if (side === 'bottom' && bottom + DIALOG_OFFSET + h > vh && target.top - DIALOG_OFFSET - h >= 0) side = 'top'; else if (side === 'top' && target.top - DIALOG_OFFSET - h < 0 && bottom + DIALOG_OFFSET + h <= vh) side = 'bottom'; else if (side === 'right' && right + DIALOG_OFFSET + w > vw && target.left - DIALOG_OFFSET - w >= 0) side = 'left'; else if (side === 'left' && target.left - DIALOG_OFFSET - w < 0 && right + DIALOG_OFFSET + w <= vw) side = 'right'; let top = 0; let left = 0; if (side === 'top' || side === 'bottom') { top = side === 'bottom' ? bottom + DIALOG_OFFSET : target.top - DIALOG_OFFSET - h; left = align === 'start' ? target.left : align === 'end' ? right - w : target.left + target.width / 2 - w / 2; } else { left = side === 'right' ? right + DIALOG_OFFSET : target.left - DIALOG_OFFSET - w; top = align === 'start' ? target.top : align === 'end' ? bottom - h : target.top + target.height / 2 - h / 2; } // preventOverflow (padding 5, altAxis) left = Math.min(Math.max(left, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, vw - w - VIEWPORT_PADDING)); top = Math.min(Math.max(top, VIEWPORT_PADDING), Math.max(VIEWPORT_PADDING, vh - h - VIEWPORT_PADDING)); return { top, left, side };}const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';/** 대상 요소를 강조하고 옆에 온보딩 대화상자를 띄워요. SpotlightManager 안에서 써요. */function Spotlight({ actions, actionsBeforeElement, children, dialogPlacement, dialogWidth = 400, footer, header, heading, headingAfterElement, image, pulse = true, shouldWatchTarget = false, target, targetNode, targetBgColor, targetOnClick, targetRadius, targetReplacement: TargetReplacement, testId = 'spotlight', scrollPositionBlock, label, titleId,}: SpotlightProps) { const ctx = React.useContext(SpotlightContext); const node = targetNode ?? (target ? ctx?.targets[target] : undefined); const rect = useTargetRect(node, shouldWatchTarget); const tinted = ctx?.blanketIsTinted ?? true; const [mounted, setMounted] = React.useState(false); const dialogRef = React.useRef<HTMLDivElement>(null); const [dialogSize, setDialogSize] = React.useState<{ width: number; height: number } | null>(null); const headingId = React.useId(); React.useEffect(() => setMounted(true), []); // 매니저에 열림·닫힘 알리기 React.useEffect(() => { if (!ctx) return; ctx.opened(); return () => ctx.closed(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 대상이 화면 밖이면 스크롤해서 보여준다 (scroll-into-view-if-needed) React.useEffect(() => { if (!node) return; const r = node.getBoundingClientRect(); const outside = r.top < 0 || r.left < 0 || r.bottom > window.innerHeight || r.right > window.innerWidth; if (outside) node.scrollIntoView({ block: scrollPositionBlock ?? 'center', inline: 'nearest' }); }, [node, scrollPositionBlock]); // 대화상자 크기 측정 useIsoLayoutEffect(() => { const el = dialogRef.current; if (!el) return; const measure = () => { const { width, height } = el.getBoundingClientRect(); setDialogSize((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height })); }; measure(); const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null; ro?.observe(el); return () => ro?.disconnect(); }, [mounted, rect !== null, dialogWidth]); // 포커스 잠금: 200ms 뒤 첫 버튼에 포커스 (원본과 동일하게 스크롤 튐 방지) React.useEffect(() => { if (!mounted || !rect) return; const id = window.setTimeout(() => { const el = dialogRef.current; if (!el || el.contains(document.activeElement)) return; const first = el.querySelector<HTMLElement>(FOCUSABLE); (first ?? el).focus({ preventScroll: true }); }, 200); return () => window.clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mounted, rect !== null, heading]); const onDialogKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { if (event.key !== 'Tab' || !dialogRef.current) return; const items = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)); if (items.length === 0) { event.preventDefault(); return; } const first = items[0]; const last = items[items.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; if (!mounted || !rect) return null; const position = dialogSize ? computeDialogPosition(rect, dialogSize, dialogPlacement) : null; const radius = targetRadius === undefined ? undefined : typeof targetRadius === 'number' ? `${targetRadius}px` : targetRadius; const labelledBy = label ? undefined : (titleId ?? (heading ? headingId : undefined)); return createPortal( <> {pulse && <PulseKeyframes />} {/* 블랭킷: 클릭을 막는 바탕. 색은 구멍(box-shadow)이 칠한다 */} <div role="presentation" data-slot="spotlight-blanket" className="fixed inset-0 z-[700] bg-transparent transition-opacity duration-[220ms]" /> {/* 대상 강조 영역: 구멍 뚫린 오버레이 */} <div data-slot="spotlight-target-highlight" data-testid={`${testId}--target`} className={cn( 'fixed z-[701] animate-in fade-in-0 duration-[350ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:animate-none', tinted ? 'shadow-[0_0_0_9999px_var(--ds-blanket)]' : 'shadow-none', )} style={{ top: rect.top, left: rect.left, width: rect.width, height: rect.height, borderRadius: radius }} > {TargetReplacement && ( <TargetReplacement style={{ width: rect.width, height: rect.height, backgroundColor: targetBgColor, borderRadius: radius }} /> )} <div aria-hidden className={cn( 'absolute inset-0 rounded-[inherit]', pulse ? pulseClass : 'shadow-[0_0_0_2px_var(--ds-border-discovery)]', )} /> <div className="absolute inset-0" style={{ cursor: targetOnClick ? 'pointer' : 'auto' }} onClick={targetOnClick ? (event) => targetOnClick({ event, target }) : undefined} /> </div> {/* 대화상자 */} <div ref={dialogRef} role="dialog" aria-modal aria-label={label} aria-labelledby={labelledBy} tabIndex={-1} data-slot="spotlight-dialog" data-side={position?.side} data-testid={`${testId}-container`} onKeyDown={onDialogKeyDown} className={cn( 'fixed z-[800] outline-none', 'animate-in fade-in-0 duration-[350ms] ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:animate-none', 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2', )} style={{ top: position?.top ?? 0, left: position?.left ?? 0, visibility: position ? 'visible' : 'hidden', }} > <SpotlightCard testId={`${testId}--dialog`} width={dialogWidth} actions={actions} actionsBeforeElement={actionsBeforeElement} image={image ? <img src={image} alt={heading ?? ''} className="block h-auto max-w-full" /> : undefined} components={{ Header: header, Footer: footer }} heading={heading} headingId={headingId} headingAfterElement={headingAfterElement} headingLevel={2} > {children} </SpotlightCard> </div> </>, document.body, );}export { Spotlight, SpotlightCard, SpotlightManager, SpotlightPulse, SpotlightTarget, SpotlightTransition, SpotlightContext, useSpotlight,};