/** * @title Help Bubble * @description 사용자에게 컴포넌트의 상태나 특정 기능에 대한 추가 정보를 말풍선으로 알려주는 컴포넌트예요. */'use client';import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { XIcon } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: help-bubble.yamltype HelpBubbleSide = 'top' | 'bottom' | 'left' | 'right';type HelpBubbleAlign = 'start' | 'center' | 'end';export type HelpBubblePlacement = HelpBubbleSide | `${HelpBubbleSide}-${Exclude<HelpBubbleAlign, 'center'>}`;function parsePlacement(placement: HelpBubblePlacement): { side: HelpBubbleSide; align: HelpBubbleAlign } { const [side, align] = placement.split('-') as [HelpBubbleSide, HelpBubbleAlign | undefined]; return { side, align: align ?? 'center' };}/** arrow: width 12px / height 8px / cornerRadius 2px */const ARROW_WIDTH = 12;const ARROW_HEIGHT = 8;/** arrow 와 타겟 요소 사이의 거리 */const ARROW_GUTTER = 4;/** arrow 와 root 경계 사이의 최소 간격 */const ARROW_PADDING = 14;/** 말풍선과 화면 경계 사이의 최소 간격 ($dimension.x4) */const OVERFLOW_PADDING = 16;function HelpBubbleArrow() { return ( <PopoverPrimitive.Arrow asChild width={ARROW_WIDTH} height={ARROW_HEIGHT}> <svg data-slot="help-bubble-arrow" viewBox="0 0 12 8" width={ARROW_WIDTH} height={ARROW_HEIGHT} aria-hidden className="block fill-bg-neutral-solid" > <path d="M0 0H12L7.6 5.87a2 2 0 0 1-3.2 0Z" /> </svg> </PopoverPrimitive.Arrow> );}export interface HelpBubbleContentProps extends Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, 'title' | 'children' | 'asChild'> {}interface HelpBubbleSharedProps extends Omit<React.ComponentProps<typeof PopoverPrimitive.Root>, 'children'> { /** 말풍선 제목이에요. */ title: React.ReactNode; /** 제목 아래 보조 설명이에요. */ description?: React.ReactNode; /** 닫기 버튼 표시 여부예요. */ showCloseButton?: boolean; /** 트리거(앵커) 기준 위치예요. `{side}-{align}` 조합이에요. */ placement?: HelpBubblePlacement; /** 바깥 영역을 누르거나 포커스가 밖으로 나가면 닫혀요. */ closeOnInteractOutside?: boolean; /** 열릴 때 말풍선 안으로 포커스를 옮겨요. */ autoFocus?: boolean; /** 말풍선 content 에 전달할 속성이에요. (className, style 등) */ contentProps?: HelpBubbleContentProps; /** z-index 를 기본값(99)에서 더하거나 빼요. */ zIndexOffset?: number; children?: React.ReactNode;}function HelpBubbleContent({ title, description, showCloseButton, placement = 'top', closeOnInteractOutside = true, autoFocus = false, zIndexOffset = 0, contentProps = {},}: Omit<HelpBubbleSharedProps, keyof React.ComponentProps<typeof PopoverPrimitive.Root> | 'children'>) { const { side, align } = parsePlacement(placement); const { className, style, onOpenAutoFocus, onInteractOutside, onFocusOutside, ...rest } = contentProps; return ( <PopoverPrimitive.Portal> <PopoverPrimitive.Content data-slot="help-bubble-content" side={side} align={align} sideOffset={ARROW_GUTTER + ARROW_HEIGHT} arrowPadding={ARROW_PADDING} collisionPadding={OVERFLOW_PADDING} onOpenAutoFocus={(event) => { onOpenAutoFocus?.(event); if (!autoFocus) event.preventDefault(); }} onInteractOutside={(event) => { onInteractOutside?.(event); if (!closeOnInteractOutside) event.preventDefault(); }} onFocusOutside={(event) => { onFocusOutside?.(event); if (!closeOnInteractOutside) event.preventDefault(); }} style={{ zIndex: 99 + zIndexOffset, ...style }} className={cn( 'box-border flex items-start rounded-r3 bg-bg-neutral-solid px-x3 py-x2_5 outline-none', 'max-w-[min(280px,var(--radix-popover-content-available-width))]', 'origin-(--radix-popover-content-transform-origin)', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-90 data-[state=open]:duration-(--rb-duration-d4) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-(--rb-duration-d4) data-[state=closed]:ease-easing', className, )} {...rest} > <HelpBubbleArrow /> <div data-slot="help-bubble-body" className="flex min-w-0 flex-col gap-x0_5 break-words [word-break:keep-all]"> <div data-slot="help-bubble-title" className="whitespace-pre-wrap text-t3 font-bold text-fg-on-neutral-solid"> {title} </div> {description && ( <div data-slot="help-bubble-description" className="whitespace-pre-wrap text-t3 font-regular text-fg-on-neutral-solid" > {description} </div> )} </div> {showCloseButton && ( <PopoverPrimitive.Close data-slot="help-bubble-close-button" aria-label="닫기" className={cn( // targetSize 38px, icon size x3_5(14px) → padding (38-14)/2 = 12px // margin-left = gap(x1) - 12px, margin-right = -12px, margin-block = -12px + x0_5 'flex shrink-0 cursor-pointer items-center justify-center rounded-r3 border-none bg-transparent p-[12px]', '-my-[10px] -mr-[12px] -ml-[8px] text-fg-on-neutral-solid [&_svg]:size-x3_5', 'transition-[scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'active:scale-[0.95]', )} > <XIcon aria-hidden strokeWidth={2.5} /> </PopoverPrimitive.Close> )} </PopoverPrimitive.Content> </PopoverPrimitive.Portal> );}export interface HelpBubbleTriggerProps extends HelpBubbleSharedProps {}/** * `children` 을 누르면 Help Bubble 이 열리고 닫혀요. `children` 은 버튼처럼 포커스 가능한 요소여야 해요. */function HelpBubbleTrigger({ title, description, showCloseButton = false, placement, closeOnInteractOutside, autoFocus, contentProps, zIndexOffset, children, ...rootProps}: HelpBubbleTriggerProps) { return ( <PopoverPrimitive.Root {...rootProps}> <PopoverPrimitive.Trigger data-slot="help-bubble-trigger" asChild> {children} </PopoverPrimitive.Trigger> <HelpBubbleContent title={title} description={description} showCloseButton={showCloseButton} placement={placement} closeOnInteractOutside={closeOnInteractOutside} autoFocus={autoFocus} contentProps={contentProps} zIndexOffset={zIndexOffset} /> </PopoverPrimitive.Root> );}export interface HelpBubbleAnchorProps extends HelpBubbleSharedProps {}/** * `children` 은 위치 기준으로만 쓰여요. 열고 닫기는 `open`/`defaultOpen` 과 닫기 버튼으로 제어해요. */function HelpBubbleAnchor({ title, description, showCloseButton = false, placement, closeOnInteractOutside, autoFocus, contentProps, zIndexOffset, children, ...rootProps}: HelpBubbleAnchorProps) { return ( <PopoverPrimitive.Root {...rootProps}> <PopoverPrimitive.Anchor data-slot="help-bubble-anchor" asChild> {children} </PopoverPrimitive.Anchor> <HelpBubbleContent title={title} description={description} showCloseButton={showCloseButton} placement={placement} closeOnInteractOutside={closeOnInteractOutside} autoFocus={autoFocus} contentProps={contentProps} zIndexOffset={zIndexOffset} /> </PopoverPrimitive.Root> );}export { HelpBubbleTrigger, HelpBubbleAnchor };