{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tooltip",
  "type": "registry:ui",
  "title": "Tooltip",
  "description": "요소를 가리켰을 때 짧은 보충 설명을 띄우는 말풍선입니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-use-controllable-state"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/tooltip.tsx",
      "content": "\"use client\";\n\nimport {\n  type FocusEvent,\n  type HTMLAttributes,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  cloneElement,\n  isValidElement,\n  useEffect,\n  useId,\n  useRef,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { useControllableState } from \"../lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------- */\n/*  Tooltip (components/tooltip.md, styles/atom/elements/tooltip.css)         */\n/*    <Tooltip content=\"Ctrl S 로도 저장할 수 있어요\" title=\"저장\" placement=\"bottom\"> */\n/*      <Button>저장</Button>                                                  */\n/*    </Tooltip>                                                              */\n/*    자식(트리거)은 초점을 받을 수 있는 요소 하나여야 합니다.                      */\n/* -------------------------------------------------------------------------- */\n\nexport type TooltipPlacement = \"top\" | \"bottom\" | \"start\" | \"end\";\nexport type TooltipAlign = \"start\" | \"center\" | \"end\";\n\nconst B = \"atom-tooltip\";\n\ntype TriggerProps = {\n  \"aria-describedby\"?: string;\n  onPointerEnter?: (e: PointerEvent<HTMLElement>) => void;\n  onPointerLeave?: (e: PointerEvent<HTMLElement>) => void;\n  onFocus?: (e: FocusEvent<HTMLElement>) => void;\n  onBlur?: (e: FocusEvent<HTMLElement>) => void;\n  onKeyDown?: (e: ReactKeyboardEvent<HTMLElement>) => void;\n};\n\nexport type TooltipProps = Omit<HTMLAttributes<HTMLSpanElement>, \"content\" | \"title\"> & {\n  ref?: Ref<HTMLSpanElement>;\n  /** 툴팁에 담을 텍스트입니다. */\n  content: ReactNode;\n  /** 굵은 첫 줄입니다. */\n  title?: ReactNode;\n  /** 대상 기준 방향입니다. */\n  placement?: TooltipPlacement;\n  /** 그 변을 따라 어디에 설지 정합니다. */\n  align?: TooltipAlign;\n  /** 꼬리를 그릴지 여부입니다. */\n  arrow?: boolean;\n  /** 노출까지의 지연(ms)입니다. */\n  openDelay?: number;\n  /** 포인터가 떠난 뒤 닫기까지의 지연(ms)입니다. */\n  closeDelay?: number;\n  /** 제어 모드에서 노출 여부를 지정합니다. */\n  open?: boolean;\n  /** 열림 상태가 바뀌어야 할 때 호출됩니다(hover · focus · Esc). */\n  onOpenChange?: (open: boolean) => void;\n  /** 트리거. 초점을 받을 수 있는 요소 하나(`<button>` 등). aria-describedby 가 붙습니다. */\n  children: ReactElement;\n};\n\nfunction TooltipRoot({\n  ref,\n  content,\n  title,\n  placement = \"top\",\n  align = \"center\",\n  arrow = true,\n  openDelay = 100,\n  closeDelay = 300,\n  open: openProp,\n  onOpenChange,\n  className,\n  children,\n  ...rest\n}: TooltipProps) {\n  const id = useId();\n  const tipId = `${id}-tip`;\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: false, onChange: onOpenChange });\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const clear = () => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n  };\n  const schedule = (next: boolean, delay: number) => {\n    clear();\n    if (next === open) return;\n    if (delay <= 0) setOpen(next);\n    else timer.current = setTimeout(() => setOpen(next), delay);\n  };\n  useEffect(() => clear, []);\n\n  // Esc — 초점을 잃지 않고 닫습니다. hover 로만 연 경우에도 동작하도록 document 에서 받습니다.\n  const setOpenRef = useRef(setOpen);\n  useEffect(() => {\n    setOpenRef.current = setOpen;\n  });\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        clear();\n        setOpenRef.current(false);\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open]);\n\n  const trigger = isValidElement<TriggerProps>(children) ? children : null;\n  const tp = trigger?.props ?? {};\n\n  return (\n    <span {...rest} ref={ref} className={cn(`${B}__root`, className)}>\n      {trigger &&\n        cloneElement(trigger, {\n          \"aria-describedby\": cn(tp[\"aria-describedby\"], tipId),\n          onPointerEnter: (e) => {\n            tp.onPointerEnter?.(e);\n            if (e.pointerType !== \"touch\") schedule(true, openDelay);\n          },\n          onPointerLeave: (e) => {\n            tp.onPointerLeave?.(e);\n            schedule(false, closeDelay);\n          },\n          // 키보드 초점은 지연 없이 엽니다 — 초점을 옮긴 것 자체가 의도이기 때문입니다.\n          onFocus: (e) => {\n            tp.onFocus?.(e);\n            schedule(true, 0);\n          },\n          onBlur: (e) => {\n            tp.onBlur?.(e);\n            schedule(false, 0);\n          },\n        })}\n      <div\n        className={cn(B, mod(B, \"placement\", placement, \"top\"), mod(B, \"align\", align, \"center\"))}\n        role=\"tooltip\"\n        id={tipId}\n        data-state={open ? \"open\" : \"closed\"}\n        // 말풍선 위로 포인터가 올라가도 닫히지 않습니다(확대 사용자가 읽을 수 있도록).\n        onPointerEnter={clear}\n        onPointerLeave={() => schedule(false, closeDelay)}\n      >\n        {title !== undefined ? (\n          <>\n            <p className={`${B}__title`}>{title}</p>\n            <p>{content}</p>\n          </>\n        ) : (\n          content\n        )}\n        {arrow && <span className={`${B}__arrow`} />}\n      </div>\n    </span>\n  );\n}\n\nexport const Tooltip = Object.assign(TooltipRoot, {});\n",
      "type": "registry:ui",
      "target": "components/ui/tooltip.tsx"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/button.css",
      "content": "/* ============================================================================\n   Element — Button\n   ----------------------------------------------------------------------------\n   atom-button\n     --size_{xsmall|small|medium|large}      medium is the default\n     --variant_{brand-solid|neutral-solid|neutral-weak|critical-solid\n                |brand-outline|neutral-outline|ghost}\n     --layout_icon-only\n     --width_full\n   states: :hover  [data-pressed]  [disabled]  [data-loading]\n\n   Loaded by both the card preview and the detail page, so\n   this file is the only definition of the component.\n   ========================================================================== */\n\n.atom-button {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: var(--atom-x2);\n  height: 47px;\n  padding-inline: var(--atom-x5);\n  border-radius: var(--atom-radius-r2);\n  font-size: 15px;\n  font-weight: 600;\n  line-height: 1;\n  white-space: nowrap;\n  user-select: none;\n  transition: filter var(--atom-duration-fast) ease,\n    background-color var(--atom-duration-fast) ease;\n}\n\n.atom-button__icon {\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n}\n\n.atom-button__label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* --- variants ------------------------------------------------------------- */\n.atom-button--variant_neutral-solid {\n  background: var(--atom-bg-neutral-solid);\n  color: var(--atom-fg-on-solid);\n}\n\n.atom-button--variant_brand-solid {\n  background: var(--atom-primary-600);\n  color: #fff;\n}\n\n.atom-button--variant_critical-solid {\n  background: var(--atom-bg-critical-solid);\n  color: #fff;\n}\n\n.atom-button--variant_neutral-weak {\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral);\n}\n\n.atom-button--variant_brand-outline {\n  background: transparent;\n  color: var(--atom-primary-600);\n  box-shadow: inset 0 0 0 1px currentColor;\n}\n\n.atom-button--variant_neutral-outline {\n  background: transparent;\n  color: var(--atom-fg-neutral);\n  box-shadow: inset 0 0 0 1px var(--atom-border-default);\n}\n\n.atom-button--variant_ghost {\n  background: transparent;\n  color: var(--atom-fg-neutral);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-button--size_xsmall {\n  height: 32px;\n  gap: var(--atom-x1);\n  padding-inline: var(--atom-x3);\n  border-radius: var(--atom-radius-full);\n  font-size: 13px;\n}\n\n.atom-button--size_xsmall .atom-button__icon {\n  width: 14px;\n  height: 14px;\n}\n\n.atom-button--size_small {\n  height: 40px;\n  padding-inline: var(--atom-x4);\n  font-size: 14px;\n}\n\n.atom-button--size_small .atom-button__icon {\n  width: 16px;\n  height: 16px;\n}\n\n.atom-button--size_large {\n  height: 54px;\n  padding-inline: var(--atom-x6);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n.atom-button--size_large .atom-button__icon {\n  width: 20px;\n  height: 20px;\n}\n\n/* --- layout -------------------------------------------------------------- */\n.atom-button--layout_icon-only {\n  width: 47px;\n  padding-inline: 0;\n}\n\n.atom-button--layout_icon-only.atom-button--size_xsmall {\n  width: 32px;\n}\n\n.atom-button--layout_icon-only.atom-button--size_small {\n  width: 40px;\n}\n\n.atom-button--layout_icon-only.atom-button--size_large {\n  width: 54px;\n}\n\n.atom-button--width_full {\n  width: 100%;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-button:hover:not([disabled]):not([data-loading]) {\n  filter: brightness(0.94);\n}\n\n.atom-button--variant_ghost:hover:not([disabled]),\n.atom-button--variant_neutral-outline:hover:not([disabled]),\n.atom-button--variant_brand-outline:hover:not([disabled]) {\n  filter: none;\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-button[data-pressed] {\n  filter: brightness(0.88);\n}\n\n.atom-button[disabled] {\n  opacity: 0.35;\n  cursor: not-allowed;\n  filter: none;\n}\n\n.atom-button[data-loading] {\n  cursor: progress;\n}\n\n.atom-button[data-loading] .atom-button__label {\n  opacity: 0.45;\n}\n\n.atom-button__spinner {\n  width: 16px;\n  height: 16px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-full);\n  border: 2px solid currentColor;\n  border-top-color: transparent;\n  animation: atom-button-spin 700ms linear infinite;\n}\n\n@keyframes atom-button-spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/button.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/tooltip.css",
      "content": "/* ============================================================================\n   Element — Help Bubble (Tooltip)\n   ----------------------------------------------------------------------------\n   A tooltip cannot place itself. It needs something to be anchored to, and that\n   is what __root is for: it wraps the trigger and the bubble so the bubble can\n   be positioned against the trigger's box. Outside a __root the bubble is a\n   plain in-flow chip — which is what the card preview and the doc stages draw.\n\n   Placement is two axes: which side of the trigger, and how the bubble lines up\n   along that side. Four sides x three alignments is twelve positions, and the\n   arrow follows both. What CSS still cannot do is notice that the bubble ran\n   off the viewport; flipping and shifting needs measured coordinates, so a\n   floating library owns that at runtime — see the Specification.\n\n   atom-tooltip__root                    positioning context: trigger + bubble\n   atom-tooltip\n     --placement_{top|bottom|start|end}      top is the default\n     --align_{start|end}                     centred on the trigger by default\n     [data-state=\"closed|open\"]              closed is display:none\n     __title                                 optional bold first line\n     __arrow                                 optional; drop it and nothing shifts\n   ========================================================================== */\n\n.atom-tooltip__root {\n  position: relative;\n  display: inline-flex;\n}\n\n.atom-tooltip {\n  position: relative;\n  max-width: 240px;\n  padding: var(--atom-x2) var(--atom-x3);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-tooltip);\n  box-shadow: var(--atom-shadow-s2);\n  color: var(--atom-fg-tooltip);\n  font-size: 13px;\n  line-height: 1.5;\n  text-align: start;\n  word-break: keep-all;\n  overflow-wrap: break-word;\n}\n\n.atom-tooltip__title {\n  font-weight: 700;\n}\n\n/* --- arrow ---------------------------------------------------------------- */\n/* clip-path, not a rotated square. The tail is 12x6 — a squat triangle, not a\n   45-degree corner — and a rotated square would need a clipping box of its own\n   to keep its corners from poking through the rounded edge of the bubble.\n   -5px rather than -6px: one pixel of overlap, so no hairline seam shows\n   between tail and bubble when the page lands on a half pixel. */\n.atom-tooltip__arrow {\n  position: absolute;\n  inset-inline-start: 50%;\n  bottom: -5px;\n  width: 12px;\n  height: 6px;\n  translate: -50% 0;\n  background: var(--atom-bg-tooltip);\n  clip-path: polygon(0 0, 100% 0, 50% 100%);\n}\n\n/* The arrow flips with the placement modifier whether or not the bubble sits in\n   a __root, so a bubble drawn on its own still points the right way. */\n.atom-tooltip--placement_bottom .atom-tooltip__arrow {\n  top: -5px;\n  bottom: auto;\n  clip-path: polygon(50% 0, 100% 100%, 0 100%);\n}\n\n.atom-tooltip--placement_start .atom-tooltip__arrow {\n  inset-inline: auto -5px;\n  top: 50%;\n  bottom: auto;\n  width: 6px;\n  height: 12px;\n  translate: 0 -50%;\n  clip-path: polygon(0 0, 100% 50%, 0 100%);\n}\n\n.atom-tooltip--placement_end .atom-tooltip__arrow {\n  inset-inline: -5px auto;\n  top: 50%;\n  bottom: auto;\n  width: 6px;\n  height: 12px;\n  translate: 0 -50%;\n  clip-path: polygon(100% 0, 100% 100%, 0 50%);\n}\n\n/* --- placement — only inside a __root ------------------------------------- */\n/* Centring uses `translate`, not `transform`, because `transform` is spent on\n   the open animation. The two compose, so the bubble can stay centred while it\n   slides in. */\n/* `width: max-content` is load-bearing, not a nicety. An absolutely positioned\n   box with `width: auto` is shrink-to-fit, and its available width is the\n   containing block minus the used inset — here `50%` of a trigger that is often\n   an icon button, so ~12px. Shrink-to-fit then falls back to min-content and\n   the bubble collapses into a column one word wide. max-content opts out of\n   that; `max-width: 240px` on the base still caps it. */\n.atom-tooltip__root > .atom-tooltip {\n  position: absolute;\n  z-index: 1;\n  width: max-content;\n  /* 5px of the arrow sticks out; 4px past its tip is the gap seed, Codeit and\n     Spectrum all land on. */\n  bottom: calc(100% + 9px);\n  inset-inline-start: 50%;\n  translate: -50% 0;\n}\n\n.atom-tooltip__root > .atom-tooltip--placement_bottom {\n  top: calc(100% + 9px);\n  bottom: auto;\n}\n\n.atom-tooltip__root > .atom-tooltip--placement_start {\n  top: 50%;\n  bottom: auto;\n  inset-inline: auto calc(100% + 9px);\n  translate: 0 -50%;\n}\n\n.atom-tooltip__root > .atom-tooltip--placement_end {\n  top: 50%;\n  bottom: auto;\n  inset-inline: calc(100% + 9px) auto;\n  translate: 0 -50%;\n}\n\n/* --- align — the cross axis ----------------------------------------------- */\n/* A tooltip wider than its trigger overflows the container when it is centred.\n   Aligning it to the near edge is the manual answer to that, and it is also\n   what a floating library falls back to when it shifts a bubble inward. */\n/* `--placement_top` is the default and therefore optional, so these cannot\n   require the class — `--align_start` on its own has to mean top + start.\n   Excluding the two side placements is what \"top or bottom\" means once the top\n   class is allowed to be absent. */\n.atom-tooltip__root\n  > .atom-tooltip--align_start:not(.atom-tooltip--placement_start):not(\n    .atom-tooltip--placement_end\n  ) {\n  inset-inline: 0 auto;\n  translate: none;\n}\n\n.atom-tooltip__root\n  > .atom-tooltip--align_end:not(.atom-tooltip--placement_start):not(\n    .atom-tooltip--placement_end\n  ) {\n  inset-inline: auto 0;\n  translate: none;\n}\n\n.atom-tooltip__root > .atom-tooltip--placement_start.atom-tooltip--align_start,\n.atom-tooltip__root > .atom-tooltip--placement_end.atom-tooltip--align_start {\n  top: 0;\n  bottom: auto;\n  translate: none;\n}\n\n.atom-tooltip__root > .atom-tooltip--placement_start.atom-tooltip--align_end,\n.atom-tooltip__root > .atom-tooltip--placement_end.atom-tooltip--align_end {\n  top: auto;\n  bottom: 0;\n  translate: none;\n}\n\n/* The arrow leaves the centre with it. 12px in is one radius plus a little, so\n   the tail never rides onto the rounded corner. */\n.atom-tooltip--align_start:not(.atom-tooltip--placement_start):not(\n    .atom-tooltip--placement_end\n  )\n  .atom-tooltip__arrow {\n  inset-inline: 12px auto;\n  translate: none;\n}\n\n.atom-tooltip--align_end:not(.atom-tooltip--placement_start):not(\n    .atom-tooltip--placement_end\n  )\n  .atom-tooltip__arrow {\n  inset-inline: auto 12px;\n  translate: none;\n}\n\n.atom-tooltip--placement_start.atom-tooltip--align_start .atom-tooltip__arrow,\n.atom-tooltip--placement_end.atom-tooltip--align_start .atom-tooltip__arrow {\n  top: 10px;\n  bottom: auto;\n  translate: none;\n}\n\n.atom-tooltip--placement_start.atom-tooltip--align_end .atom-tooltip__arrow,\n.atom-tooltip--placement_end.atom-tooltip--align_end .atom-tooltip__arrow {\n  top: auto;\n  bottom: 10px;\n  translate: none;\n}\n\n/* --- state ---------------------------------------------------------------- */\n/* data-state is the whole state model, and a script owns it. :hover cannot say\n   \"open after 100ms, and stay open while the pointer crosses the 4px gap onto\n   the bubble\", and those two delays are the difference between a tooltip and a\n   flicker. The trigger listens on hover, focus and Esc; the bubble only obeys.\n   With no attribute at all the bubble renders — static markup in the docs and\n   in the card preview never has to opt in. */\n.atom-tooltip[data-state=\"closed\"] {\n  display: none;\n}\n\n.atom-tooltip[data-state=\"open\"] {\n  animation: atom-tooltip-in var(--atom-duration-fast) var(--atom-ease-out);\n}\n\n.atom-tooltip--placement_bottom[data-state=\"open\"] {\n  animation-name: atom-tooltip-in-bottom;\n}\n\n.atom-tooltip--placement_start[data-state=\"open\"] {\n  animation-name: atom-tooltip-in-start;\n}\n\n.atom-tooltip--placement_end[data-state=\"open\"] {\n  animation-name: atom-tooltip-in-end;\n}\n\n/* Each keyframe declares `from` only, so `to` is whatever the element already\n   computes — which keeps the centring `translate` out of the animation. 4px is\n   enough to read as \"this came from that\" and short enough to finish before the\n   eye arrives. */\n@keyframes atom-tooltip-in {\n  from {\n    opacity: 0;\n    transform: translateY(4px);\n  }\n}\n\n@keyframes atom-tooltip-in-bottom {\n  from {\n    opacity: 0;\n    transform: translateY(-4px);\n  }\n}\n\n@keyframes atom-tooltip-in-start {\n  from {\n    opacity: 0;\n    transform: translateX(4px);\n  }\n}\n\n@keyframes atom-tooltip-in-end {\n  from {\n    opacity: 0;\n    transform: translateX(-4px);\n  }\n}\n\n/* base.css only shortens every animation to 0.01ms. The bubble opts out\n   completely so not even one frame can paint at the offset position. */\n@media (prefers-reduced-motion: reduce) {\n  .atom-tooltip[data-state=\"open\"] {\n    animation: none;\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/tooltip.css"
    }
  ],
  "categories": [
    "overlay"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "Tooltip"
    ],
    "docs": "poc/design-system/components/tooltip.md"
  }
}
