{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tooltip",
  "title": "Tooltip",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/tooltip/tooltip.component.ts",
      "content": "import {\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  InjectionToken,\n  Input,\n  OnDestroy,\n  Output,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  booleanAttribute,\n  forwardRef,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport {\n  BodyPortal,\n  afterExitAnimation,\n  autoPlace,\n  pushDismissableLayer,\n  uniqueId,\n  type Placement,\n  type PopperAlign,\n  type PopperSide,\n} from '@/ui/popper/popper'\n\nexport type TooltipSide = PopperSide\nexport type TooltipAlign = PopperAlign\n\n/** Delay settings a TooltipProvider (or any ancestor, e.g. SidebarProvider) supplies. */\nexport interface TooltipConfig {\n  delayDuration: number\n  skipDelayDuration: number\n  disableHoverableContent: boolean\n}\nexport const UI_TOOLTIP_CONFIG = new InjectionToken<TooltipConfig>('UI_TOOLTIP_CONFIG')\n\n/** Radix defaults: 700ms ambient open delay, 300ms window where neighbours open instantly. */\nconst DEFAULT_CONFIG: TooltipConfig = { delayDuration: 700, skipDelayDuration: 300, disableHoverableContent: false }\nlet lastClosedAt = 0\n\nexport const TOOLTIP_CONTENT_CLASS =\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'\nconst ARROW_CLASS =\n  'bg-foreground fill-foreground z-50 block size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]'\n\n/** Radix Popper adds the measured arrow height to sideOffset, so the arrow tip sits sideOffset from the anchor. */\nfunction arrowHeight(wrapper: HTMLElement | null): number {\n  return (wrapper?.firstElementChild as HTMLElement | null)?.offsetHeight ?? 0\n}\n\n/** Radix popper arrow placement: wrapper pinned to the edge facing the anchor, rotated per side. */\nfunction placeArrow(wrapper: HTMLElement | null, anchor: Element, floating: HTMLElement, p: Placement): void {\n  if (!wrapper) return\n  const a = anchor.getBoundingClientRect()\n  const f = floating.getBoundingClientRect()\n  const vertical = p.side === 'top' || p.side === 'bottom'\n  const base = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[p.side]\n  Object.assign(wrapper.style, {\n    position: 'absolute',\n    top: '',\n    bottom: '',\n    left: '',\n    right: '',\n    transformOrigin: { top: '', right: '0 0', bottom: 'center 0', left: '100% 0' }[p.side],\n    transform: {\n      top: 'translateY(100%)',\n      right: 'translateY(50%) rotate(90deg) translateX(-50%)',\n      bottom: 'rotate(180deg)',\n      left: 'translateY(50%) rotate(-90deg) translateX(50%)',\n    }[p.side],\n  })\n  wrapper.style.setProperty(base, '0px')\n  if (vertical) wrapper.style.left = `${Math.max(4, Math.min(f.width - 14, a.left + a.width / 2 - f.left - 5))}px`\n  else wrapper.style.top = `${Math.max(4, Math.min(f.height - 14, a.top + a.height / 2 - f.top - 5))}px`\n}\n\n/** Open/close timing shared by the compound Tooltip and the [uiTooltip] directive. */\nclass TooltipTiming {\n  private timer: ReturnType<typeof setTimeout> | undefined\n  constructor(\n    private readonly config: () => TooltipConfig,\n    private readonly apply: (open: boolean, how: 'delayed' | 'instant') => void,\n  ) {}\n\n  enter(): void {\n    clearTimeout(this.timer)\n    const c = this.config()\n    const skip = Date.now() - lastClosedAt < c.skipDelayDuration\n    if (skip || c.delayDuration <= 0) this.apply(true, 'instant')\n    else this.timer = setTimeout(() => this.apply(true, 'delayed'), c.delayDuration)\n  }\n\n  focus(): void {\n    clearTimeout(this.timer)\n    this.apply(true, 'instant')\n  }\n\n  leave(): void {\n    clearTimeout(this.timer)\n    this.apply(false, 'instant')\n  }\n\n  destroy(): void {\n    clearTimeout(this.timer)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tooltip-provider, [ui-tooltip-provider]',\n  standalone: true,\n  host: { class: 'contents' },\n  providers: [{ provide: UI_TOOLTIP_CONFIG, useExisting: forwardRef(() => UiTooltipProviderComponent) }],\n  template: `<ng-content />`,\n})\nexport class UiTooltipProviderComponent implements TooltipConfig {\n  @Input() delayDuration = 700\n  @Input() skipDelayDuration = 300\n  @Input({ transform: booleanAttribute }) disableHoverableContent = false\n}\n\n/**\n * Angular port of UIPKGE Tooltip with Radix behaviour: hover opens after the provider\n * delay (instant for neighbours within the skip window), focus opens instantly,\n * pointer-leave / blur / pointer-down / Escape close, content renders in a body portal\n * positioned against the trigger with an arrow. Class strings identical to Vue/React.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tooltip, [ui-tooltip]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"tooltip\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n    class: 'contents',\n  },\n  template: `<ng-content />`,\n})\nexport class UiTooltipComponent implements OnDestroy {\n  private readonly provided = inject(UI_TOOLTIP_CONFIG, { optional: true })\n  @Input() open?: boolean\n  @Input() defaultOpen = false\n  /** Per-tooltip override of the provider's delay. */\n  @Input() delayDuration?: number\n  @Input({ transform: booleanAttribute }) disableHoverableContent = false\n  @Output() openChange = new EventEmitter<boolean>()\n\n  readonly contentId = uniqueId('tooltip-content')\n  private readonly _open = signal<boolean | null>(null)\n  openHow: 'delayed' | 'instant' = 'instant'\n  trigger?: HTMLElement\n  content?: UiTooltipContentComponent\n  readonly timing = new TooltipTiming(\n    () => ({\n      ...DEFAULT_CONFIG,\n      ...pickConfig(this.provided),\n      ...(this.delayDuration !== undefined ? { delayDuration: this.delayDuration } : {}),\n    }),\n    (open, how) => {\n      this.openHow = how\n      this.setOpen(open)\n    },\n  )\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  setOpen(value: boolean): void {\n    if (value === this.isOpen) return\n    if (!value) lastClosedAt = Date.now()\n    this._open.set(value)\n    this.openChange.emit(value)\n    this.content?.sync()\n  }\n\n  ngOnDestroy(): void {\n    this.timing.destroy()\n  }\n}\n\nfunction pickConfig(c: Partial<TooltipConfig> | null): Partial<TooltipConfig> {\n  if (!c) return {}\n  return { delayDuration: c.delayDuration, skipDelayDuration: c.skipDelayDuration }\n}\n\n@Directive({\n  selector: 'ui-tooltip-trigger, [ui-tooltip-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'tooltip.isOpen ? tooltip.openHow + \"-open\" : \"closed\"',\n    '[attr.aria-describedby]': 'tooltip.isOpen ? tooltip.contentId : null',\n    '(pointerenter)': 'onPointerEnter($event)',\n    '(pointerleave)': 'tooltip.timing.leave()',\n    '(pointerdown)': 'tooltip.timing.leave()',\n    '(focus)': 'tooltip.timing.focus()',\n    '(blur)': 'tooltip.timing.leave()',\n  },\n})\nexport class UiTooltipTriggerComponent {\n  // Radix Slot: the trigger's data-slot overrides a wrapped component's own (ui-button's\n  // \"button\"), but a data-slot written on the element itself wins.\n  readonly dataSlot: string = inject(ElementRef).nativeElement.getAttribute('data-slot') ?? 'tooltip-trigger'\n  readonly tooltip = inject(UiTooltipComponent)\n\n  constructor() {\n    this.tooltip.trigger = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  }\n\n  onPointerEnter(event: PointerEvent): void {\n    if (event.pointerType !== 'touch') this.tooltip.timing.enter()\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tooltip-content, [ui-tooltip-content]',\n  standalone: true,\n  host: { class: 'hidden' },\n  template: `\n    <ng-template #panel>\n      <div\n        [id]=\"tooltip.contentId\"\n        role=\"tooltip\"\n        data-slot=\"tooltip-content\"\n        data-uipkge=\"\"\n        [attr.data-state]=\"state()\"\n        [class]=\"panelClass\"\n      >\n        <ng-content />\n        <span data-tooltip-arrow=\"\"><span [class]=\"arrowClass\"></span></span>\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiTooltipContentComponent implements OnDestroy {\n  readonly tooltip = inject(UiTooltipComponent)\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n  @Input('class') className?: string\n  @Input() side: TooltipSide = 'top'\n  @Input() align: TooltipAlign = 'center'\n  @Input() sideOffset = 4\n  @Input() alignOffset = 0\n  /** React passes `hidden` to suppress the tooltip (e.g. sidebar expanded); same here. */\n  @Input({ transform: booleanAttribute }) hidden = false\n  @ViewChild('panel', { static: true }) panelTpl!: TemplateRef<unknown>\n  readonly arrowClass = ARROW_CLASS\n  readonly state = signal<'delayed-open' | 'instant-open' | 'closed'>('closed')\n  private panelEl?: HTMLElement\n  private cleanups: (() => void)[] = []\n\n  constructor() {\n    this.tooltip.content = this\n  }\n\n  get panelClass(): string {\n    return cn(TOOLTIP_CONTENT_CLASS, this.className)\n  }\n\n  sync(): void {\n    const show = this.tooltip.isOpen && !this.hidden\n    if (show && (!this.portal.attached || this.state() === 'closed')) {\n      if (this.portal.attached) this.portal.detach()\n      this.show()\n    } else if (!show && this.portal.attached && this.state() !== 'closed') void this.hide()\n  }\n\n  private show(): void {\n    const trigger = this.tooltip.trigger\n    if (!trigger) return\n    this.state.set(this.tooltip.openHow === 'delayed' ? 'delayed-open' : 'instant-open')\n    const panel = this.portal.attach(this.panelTpl).firstElementChild as HTMLElement\n    this.panelEl = panel\n    const arrow = panel.querySelector<HTMLElement>('[data-tooltip-arrow]')\n    this.cleanups.push(\n      autoPlace(\n        trigger,\n        panel,\n        () => ({\n          side: this.side,\n          align: this.align,\n          sideOffset: this.sideOffset + arrowHeight(arrow),\n          alignOffset: this.alignOffset,\n          collisionPadding: 0,\n          avoidCollisions: true,\n        }),\n        'tooltip',\n        (p) => placeArrow(arrow, trigger, panel, p),\n      ),\n      pushDismissableLayer({\n        contains: () => true,\n        onEscape: () => this.tooltip.setOpen(false),\n        onPointerDownOutside: () => {},\n      }),\n    )\n  }\n\n  private async hide(): Promise<void> {\n    this.state.set('closed')\n    this.cleanups.splice(0).forEach((fn) => fn())\n    await afterExitAnimation(this.panelEl)\n    if (this.tooltip.isOpen && !this.hidden) return\n    this.portal.detach()\n  }\n\n  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n  }\n}\n\n/**\n * Shorthand for a text tooltip on any element: `<button [uiTooltip]=\"'Save'\" tooltipSide=\"right\">`.\n * Same timing, placement, arrow and classes as the compound Tooltip. SidebarMenuButton\n * composes it (hostDirectives) for the collapsed icon-rail labels, like React's `tooltip` prop.\n */\n@Directive({\n  selector: '[uiTooltip]',\n  standalone: true,\n  host: {\n    '(pointerenter)': 'onPointerEnter($event)',\n    '(pointerleave)': 'timing.leave()',\n    '(pointerdown)': 'timing.leave()',\n    '(focus)': 'timing.focus()',\n    '(blur)': 'timing.leave()',\n    '[attr.aria-describedby]': 'panel ? id : null',\n  },\n})\nexport class UiTooltipDirective implements OnDestroy {\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  private readonly provided = inject(UI_TOOLTIP_CONFIG, { optional: true })\n  @Input('uiTooltip') text?: string | null\n  @Input() tooltipSide: TooltipSide = 'top'\n  @Input() tooltipAlign: TooltipAlign = 'center'\n  @Input() tooltipSideOffset = 4\n  @Input({ transform: booleanAttribute }) tooltipDisabled = false\n  /** Host components composing this directive can suppress it dynamically (sidebar: only when collapsed). */\n  disabledWhen?: () => boolean\n\n  readonly id = uniqueId('tooltip-content')\n  panel: HTMLElement | null = null\n  private cleanups: (() => void)[] = []\n  readonly timing = new TooltipTiming(\n    () => ({ ...DEFAULT_CONFIG, ...pickConfig(this.provided) }),\n    (open, how) => (open ? this.show(how) : void this.hide()),\n  )\n\n  onPointerEnter(event: PointerEvent): void {\n    if (event.pointerType !== 'touch') this.timing.enter()\n  }\n\n  private show(how: 'delayed' | 'instant'): void {\n    if (!this.text || this.tooltipDisabled || this.disabledWhen?.()) return\n    if (this.panel) this.teardown()\n    const panel = document.createElement('div')\n    panel.id = this.id\n    panel.setAttribute('role', 'tooltip')\n    panel.setAttribute('data-slot', 'tooltip-content')\n    panel.setAttribute('data-uipkge', '')\n    panel.setAttribute('data-state', `${how}-open`)\n    panel.className = TOOLTIP_CONTENT_CLASS\n    panel.textContent = this.text\n    const wrapper = document.createElement('span')\n    const arrow = document.createElement('span')\n    arrow.className = ARROW_CLASS\n    wrapper.appendChild(arrow)\n    panel.appendChild(wrapper)\n    document.body.appendChild(panel)\n    this.panel = panel\n    this.cleanups.push(\n      autoPlace(\n        this.el,\n        panel,\n        () => ({\n          side: this.tooltipSide,\n          align: this.tooltipAlign,\n          sideOffset: this.tooltipSideOffset + arrowHeight(wrapper),\n          alignOffset: 0,\n          collisionPadding: 0,\n          avoidCollisions: true,\n        }),\n        'tooltip',\n        (p) => placeArrow(wrapper, this.el, panel, p),\n      ),\n      pushDismissableLayer({ contains: () => true, onEscape: () => void this.hide(), onPointerDownOutside: () => {} }),\n    )\n  }\n\n  private async hide(): Promise<void> {\n    const panel = this.panel\n    if (!panel) return\n    lastClosedAt = Date.now()\n    panel.setAttribute('data-state', 'closed')\n    this.cleanups.splice(0).forEach((fn) => fn())\n    await afterExitAnimation(panel)\n    if (this.panel === panel) this.teardown()\n  }\n\n  private teardown(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.panel?.remove()\n    this.panel = null\n  }\n\n  ngOnDestroy(): void {\n    this.timing.destroy()\n    this.teardown()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/tooltip/tooltip.component.ts"
    },
    {
      "path": "packages/registry-angular/components/tooltip/index.ts",
      "content": "export {\n  UiTooltipComponent,\n  UiTooltipProviderComponent,\n  UiTooltipContentComponent,\n  UiTooltipTriggerComponent,\n  UiTooltipDirective,\n  UI_TOOLTIP_CONFIG,\n  TOOLTIP_CONTENT_CLASS,\n  type TooltipConfig,\n  type TooltipSide,\n  type TooltipAlign,\n} from './tooltip.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/tooltip/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Small popover triggered by hover/focus, used for short labels — icon-button names, abbreviation expansions, keyboard shortcuts. Positioned by the built-in popper (flips and shifts to stay on screen) and respects `prefers-reduced-motion`.",
  "categories": [
    "overlay"
  ]
}