{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tooltip",
  "title": "Tooltip",
  "type": "registry:ui",
  "description": "Small popover triggered by hover/focus, used for short labels — icon-button names, abbreviation expansions, keyboard shortcuts. Auto-positions and respects `prefers-reduced-motion`.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/tooltip/tooltip.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { autoUpdate, computePosition, type Align, type Side } from '../../lib/position'\n\ntype TooltipState = 'closed' | 'delayed-open' | 'instant-open'\n\n// Radix TooltipProvider behaviour, shared by every <uip-tooltip> on the page:\n// only one tooltip is open at a time, and once one has been open, moving to a\n// neighbour within `skipDelayDuration` opens it instantly.\nlet current: UipTooltip | undefined\nlet lastClosedAt = 0\n\nlet uid = 0\n\n// Radix's <Arrow> svg is 10×5, but the popper measures its RENDERED size — the\n// `size-2.5` class makes it 10×10 — and adds that height to sideOffset\n// (measured on the React page: 4 + 10 = 14px gap).\nconst ARROW_W = 10\nconst ARROW_H = 5\nconst ARROW_OFFSET = 10\n\n// Radix popper arrow wrapper placement, keyed by the side the content sits on.\nconst arrowWrapper: Record<Side, Record<string, string>> = {\n  top: { bottom: '0', transformOrigin: '', transform: 'translateY(100%)' },\n  right: { left: '0', transformOrigin: '0 0', transform: 'translateY(50%) rotate(90deg) translateX(-50%)' },\n  bottom: { top: '0', transformOrigin: 'center 0', transform: 'rotate(180deg)' },\n  left: { right: '0', transformOrigin: '100% 0', transform: 'translateY(50%) rotate(-90deg) translateX(50%)' },\n}\n\n/**\n * <uip-tooltip> — the registry Tooltip (Tooltip + TooltipTrigger +\n * TooltipContent + TooltipProvider) as ONE web component.\n *\n *   <uip-tooltip content=\"Settings\" side=\"top\">\n *     <uip-button size=\"icon\" aria-label=\"Settings\">…</uip-button>\n *   </uip-tooltip>\n *\n * The first default-slot element is the trigger. Content is the `content`\n * attribute (text) or a `slot=\"content\"` element (rich markup). Opens on hover\n * after `delay-duration` ms and immediately on keyboard focus; closes on\n * pointer leave, blur, click on the trigger and Escape. Focus never moves, so\n * it stays on the trigger.\n *\n * The bubble is a native `popover=\"manual\"` (top layer, no portal) positioned\n * with `computePosition`; `data-side` / `data-align` / `data-state` match\n * Radix, on the bubble and the trigger: `delayed-open` after a hover delay\n * (also with `delay-duration=\"0\"`), `instant-open` on keyboard focus, on a\n * skip-delay reopen and for a programmatic `open`, else `closed`.\n *\n * Styling: the bubble is `part=\"content\"` — React's TooltipContent\n * `className` becomes `class=\"[&::part(content)]:…\"` on the host. A `display`\n * utility must be scoped to the open state, or it overrides the closed\n * popover's `display: none`: `[&::part(content):popover-open]:flex`.\n *\n * Accessibility: the content can't be referenced by id from a light-DOM\n * trigger (ARIA ids don't cross shadow roots), so the element mirrors the\n * content text onto the trigger as `aria-description`.\n *\n * Events: `open-change` (detail: { open }).\n */\nexport class UipTooltip extends LitElement {\n  // :host display can't be a utility class (the host has no template of its own).\n  // `contents`: React's TooltipTrigger asChild adds no box, so the trigger keeps\n  // its own display (an inline <span> wrapper stays inline, not a flex item).\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties = {\n    open: { type: Boolean, reflect: true },\n    content: {},\n    side: { reflect: true },\n    align: {},\n    sideOffset: { type: Number, attribute: 'side-offset' },\n    alignOffset: { type: Number, attribute: 'align-offset' },\n    delayDuration: { type: Number, attribute: 'delay-duration' },\n    skipDelayDuration: { type: Number, attribute: 'skip-delay-duration' },\n    disabled: { type: Boolean, reflect: true },\n    state: { state: true },\n    placedSide: { state: true },\n    pos: { state: true },\n    arrowPos: { state: true },\n  }\n\n  open = false\n  content?: string\n  side: Side = 'top'\n  align: Align = 'center'\n  sideOffset = 4\n  alignOffset = 0\n  delayDuration = 700\n  skipDelayDuration = 300\n  disabled = false\n  private state: TooltipState = 'closed'\n  private placedSide: Side = 'top'\n  private pos: Record<string, string> = {}\n  private arrowPos: Record<string, string> = {}\n  private readonly contentId = `uip-tooltip-${++uid}`\n  private openTimer?: ReturnType<typeof setTimeout>\n  private closeTimer?: ReturnType<typeof setTimeout>\n  private pointerDown = false\n  private stopAutoUpdate?: () => void\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'tooltip')\n    this.addEventListener('pointerenter', this.onPointerEnter)\n    this.addEventListener('pointerleave', this.onPointerLeave)\n    this.addEventListener('pointerdown', this.onPointerDown)\n    this.addEventListener('focusin', this.onFocusIn)\n    this.addEventListener('focusout', this.onFocusOut)\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.removeEventListener('pointerenter', this.onPointerEnter)\n    this.removeEventListener('pointerleave', this.onPointerLeave)\n    this.removeEventListener('pointerdown', this.onPointerDown)\n    this.removeEventListener('focusin', this.onFocusIn)\n    this.removeEventListener('focusout', this.onFocusOut)\n    this.teardown()\n    if (current === this) current = undefined\n  }\n\n  /** The first slotted element in the default slot. */\n  get trigger(): HTMLElement | undefined {\n    const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot:not([name])')\n    return (slot?.assignedElements({ flatten: true })[0] as HTMLElement | undefined) ?? undefined\n  }\n\n  private get bubble() {\n    return this.renderRoot?.querySelector<HTMLElement>('[role=tooltip]')\n  }\n\n  show() {\n    this.setOpen(true)\n  }\n\n  hide() {\n    this.setOpen(false)\n  }\n\n  private setOpen(open: boolean) {\n    clearTimeout(this.openTimer)\n    clearTimeout(this.closeTimer)\n    if (open && this.disabled) return\n    if (this.open === open) return\n    this.open = open\n    this.dispatchEvent(new CustomEvent('open-change', { detail: { open }, bubbles: true, composed: true }))\n  }\n\n  // --- trigger interaction (Radix TooltipTrigger) ----------------------------\n  private onPointerEnter = (e: PointerEvent) => {\n    if (e.pointerType === 'touch') return\n    clearTimeout(this.closeTimer)\n    if (this.open) return\n    const skip = Date.now() - lastClosedAt < this.skipDelayDuration || current?.open\n    if (skip) {\n      this.state = 'instant-open'\n      this.setOpen(true)\n      return\n    }\n    clearTimeout(this.openTimer)\n    // Like Radix, a zero delay still takes this path and reports `delayed-open`.\n    this.openTimer = setTimeout(() => {\n      this.state = 'delayed-open'\n      this.setOpen(true)\n    }, Math.max(0, this.delayDuration))\n  }\n\n  // Short grace period so the pointer can cross the gap onto the content\n  // (Radix keeps hoverable content open with a pointer grace area).\n  private onPointerLeave = () => {\n    clearTimeout(this.openTimer)\n    clearTimeout(this.closeTimer)\n    this.closeTimer = setTimeout(() => this.setOpen(false), 100)\n  }\n\n  private onPointerDown = () => {\n    this.pointerDown = true\n    addEventListener('pointerup', () => (this.pointerDown = false), { once: true })\n    // Radix closes the tooltip when the trigger is pressed.\n    if (this.open) this.setOpen(false)\n  }\n\n  private onFocusIn = () => {\n    if (this.pointerDown) return\n    this.state = 'instant-open'\n    this.setOpen(true)\n  }\n\n  private onFocusOut = (e: FocusEvent) => {\n    if (e.relatedTarget && this.contains(e.relatedTarget as Node)) return\n    this.setOpen(false)\n  }\n\n  private onDocKeyDown = (e: KeyboardEvent) => {\n    if (e.key === 'Escape' && this.open) {\n      e.stopPropagation()\n      this.setOpen(false)\n    }\n  }\n\n  // --- open / close ----------------------------------------------------------\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (!changed.has('open')) return\n    if (this.open && this.state === 'closed') this.state = 'instant-open'\n    if (!this.open) this.state = 'closed'\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (changed.has('content')) this.describeTrigger()\n    if (!changed.has('open') || isServer) return\n    this.syncTriggerState()\n    const bubble = this.bubble\n    if (!bubble) return\n    if (this.open) {\n      if (current && current !== this) current.hide()\n      // eslint-disable-next-line @typescript-eslint/no-this-alias -- deliberate singleton: tracks the open tooltip across instances\n      current = this\n      if (!bubble.matches(':popover-open')) bubble.showPopover()\n      addEventListener('keydown', this.onDocKeyDown, true)\n      // Start positioning after this update so place() doesn't update mid-update.\n      this.updateComplete.then(() => {\n        const trigger = this.trigger\n        if (this.open && trigger && !this.stopAutoUpdate) {\n          this.stopAutoUpdate = autoUpdate(trigger, bubble, () => this.place())\n          // computePosition measures getBoundingClientRect(), which includes the\n          // zoom-in-95 entry transform; re-place at full size once it ends.\n          Promise.all(bubble.getAnimations().map((a) => a.finished)).then(() => this.open && this.place(), () => {})\n        }\n      })\n    } else if (changed.get('open') === true) {\n      this.teardown()\n      lastClosedAt = Date.now()\n      if (current === this) current = undefined\n      // Play the exit animation (data-state=closed, rendered by this update),\n      // then hide. The timeout covers tabs where animations never finish.\n      let hidden = false\n      const done = () => {\n        if (hidden || this.open) return\n        hidden = true\n        if (bubble.matches(':popover-open')) bubble.hidePopover()\n      }\n      setTimeout(done, 400)\n      const anims = bubble.getAnimations()\n      if (anims.length) Promise.all(anims.map((a) => a.finished)).then(done, done)\n      else done()\n    }\n  }\n\n  private teardown() {\n    this.stopAutoUpdate?.()\n    this.stopAutoUpdate = undefined\n    removeEventListener('keydown', this.onDocKeyDown, true)\n  }\n\n  private place() {\n    const trigger = this.trigger\n    const bubble = this.bubble\n    if (!trigger || !bubble) return\n    const { style, side } = computePosition(trigger, bubble, {\n      side: this.side,\n      align: this.align,\n      sideOffset: this.sideOffset + ARROW_OFFSET,\n      alignOffset: this.alignOffset,\n    })\n    this.pos = style\n    this.placedSide = side\n    // Arrow points at the trigger's centre, clamped inside the content.\n    const a = trigger.getBoundingClientRect()\n    const f = { left: parseFloat(style.left), top: parseFloat(style.top), ...bubble.getBoundingClientRect().toJSON() }\n    const vertical = side === 'top' || side === 'bottom'\n    const offset = vertical\n      ? Math.min(Math.max(a.left + a.width / 2 - f.left - ARROW_W / 2, 0), f.width - ARROW_W)\n      : Math.min(Math.max(a.top + a.height / 2 - f.top - ARROW_W / 2, 0), f.height - ARROW_W)\n    this.arrowPos = {\n      position: 'absolute',\n      ...(vertical ? { left: `${offset}px` } : { top: `${offset}px` }),\n      ...arrowWrapper[side],\n    }\n  }\n\n  private contentText() {\n    if (this.content) return this.content\n    const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot[name=\"content\"]')\n    return slot?.assignedNodes({ flatten: true }).map((n) => n.textContent ?? '').join(' ').replace(/\\s+/g, ' ').trim()\n  }\n\n  /** Radix TooltipTrigger carries `data-state` (closed from the start). */\n  private syncTriggerState() {\n    const trigger = this.trigger\n    if (trigger && trigger.getAttribute('data-state') !== this.state) trigger.setAttribute('data-state', this.state)\n  }\n\n  private describeTrigger() {\n    const trigger = this.trigger\n    if (!trigger) return\n    this.syncTriggerState()\n    trigger.setAttribute('data-slot', trigger.getAttribute('data-slot') ?? 'tooltip-trigger')\n    const text = this.contentText()\n    if (text) trigger.setAttribute('aria-description', text)\n  }\n\n  render() {\n    return html`\n      <slot @slotchange=${() => this.describeTrigger()}></slot>\n      <div\n        part=\"content\"\n        id=${this.contentId}\n        role=\"tooltip\"\n        popover=\"manual\"\n        data-uipkge=\"\"\n        data-slot=\"tooltip-content\"\n        data-state=${this.state}\n        data-side=${this.placedSide}\n        data-align=${this.align}\n        style=${styleMap(this.pos)}\n        class=${cn(\n          'bg-foreground text-background motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:data-[state=closed]:animate-out motion-safe:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',\n          // overflow-visible: the UA popover `overflow: auto` would clip the arrow.\n          // text-start: Chromium maps the host's `align` attribute to text-align.\n          'fixed inset-auto m-0 overflow-visible text-start',\n        )}\n      >\n        <slot name=\"content\" @slotchange=${() => this.describeTrigger()}>${this.content ?? nothing}</slot>\n        <span style=${styleMap(this.arrowPos)}>\n          <svg\n            class=\"bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\"\n            width=${ARROW_W}\n            height=${ARROW_H}\n            viewBox=\"0 0 30 10\"\n            preserveAspectRatio=\"none\"\n            aria-hidden=\"true\"\n          >\n            <polygon points=\"0,0 30,0 15,10\"></polygon>\n          </svg>\n        </span>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-tooltip') || customElements.define('uip-tooltip', UipTooltip)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-tooltip': UipTooltip\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/tooltip/tooltip.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
