{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "hover-card",
  "title": "Hover Card",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/hover-card/hover-card.component.ts",
      "content": "import {\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  Output,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport {\n  BodyPortal,\n  afterExitAnimation,\n  autoPlace,\n  computePlacement,\n  pushDismissableLayer,\n  type PopperAlign,\n  type PopperSide,\n  type PlaceOptions,\n} from '@/ui/popper/popper'\n\nexport type HoverCardSide = PopperSide\nexport type HoverCardAlign = PopperAlign\n\n/**\n * autoPlace() positions on the next animation frame; place once synchronously too so the\n * panel never paints (or catches a fast click's pointerup) at the viewport origin.\n */\nfunction placeAndTrack(anchor: Element, panel: HTMLElement, options: () => PlaceOptions, prefix: string): () => void {\n  const stop = autoPlace(anchor, panel, options, prefix)\n  const p = computePlacement(\n    anchor.getBoundingClientRect(),\n    { width: panel.offsetWidth, height: panel.offsetHeight },\n    options(),\n  )\n  panel.style.left = `${Math.round(p.x)}px`\n  panel.style.top = `${Math.round(p.y)}px`\n  panel.setAttribute('data-side', p.side)\n  panel.setAttribute('data-align', p.align)\n  return stop\n}\n\nexport const HOVER_CARD_CONTENT_CLASS =\n  'bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-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-64 rounded-md border p-4 shadow-md outline-hidden motion-safe:data-[state=closed]:duration-[var(--dur-exit)] motion-safe:data-[state=open]:duration-200'\n\n/**\n * Angular port of UIPKGE HoverCard with Radix HoverCard behaviour: pointer-enter (not\n * touch) or focus on the trigger opens after `openDelay`, pointer-leave / blur closes\n * after `closeDelay`, and moving the pointer onto the card cancels the close so it can\n * be read and clicked. Escape and outside pointer-downs dismiss at once. Content renders\n * in a body portal positioned against the trigger (flip + shift). Class strings are\n * identical to the React source.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-hover-card, [ui-hover-card]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"hover-card\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n    // Radix Root renders no element: keep the wrapper out of layout.\n    class: 'contents',\n  },\n  template: `<ng-content />`,\n})\nexport class UiHoverCardComponent implements OnChanges, OnDestroy {\n  @Input() open?: boolean\n  @Input() defaultOpen = false\n  @Input() openDelay = 700\n  @Input() closeDelay = 300\n  @Output() openChange = new EventEmitter<boolean>()\n\n  private readonly _open = signal<boolean | null>(null)\n  private openTimer: ReturnType<typeof setTimeout> | undefined\n  private closeTimer: ReturnType<typeof setTimeout> | undefined\n  /** Text selected inside the card keeps it open until an explicit dismiss (Radix). */\n  hasSelection = false\n  isPointerDownOnContent = false\n  trigger?: HTMLElement\n  content?: UiHoverCardContentComponent\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  ngOnChanges(): void {\n    this.content?.sync()\n  }\n\n  setOpen(value: boolean): void {\n    if (value === this.isOpen) return\n    this._open.set(value)\n    this.openChange.emit(value)\n    this.content?.sync()\n  }\n\n  /** Radix `onOpen`: cancel a pending close, open after the delay. */\n  handleOpen(): void {\n    clearTimeout(this.closeTimer)\n    clearTimeout(this.openTimer)\n    this.openTimer = setTimeout(() => this.setOpen(true), this.openDelay)\n  }\n\n  /** Radix `onClose`: cancel a pending open, close after the delay unless the user is selecting text. */\n  handleClose(): void {\n    clearTimeout(this.openTimer)\n    if (!this.hasSelection && !this.isPointerDownOnContent) {\n      clearTimeout(this.closeTimer)\n      this.closeTimer = setTimeout(() => this.setOpen(false), this.closeDelay)\n    }\n  }\n\n  /** Escape / outside pointer-down: close immediately. */\n  handleDismiss(): void {\n    clearTimeout(this.openTimer)\n    clearTimeout(this.closeTimer)\n    this.setOpen(false)\n  }\n\n  ngOnDestroy(): void {\n    clearTimeout(this.openTimer)\n    clearTimeout(this.closeTimer)\n  }\n}\n\n/** Radix `excludeTouch`: hover intent never comes from a finger. */\nconst isTouch = (event: PointerEvent) => event.pointerType === 'touch'\n\n/** The Radix `asChild` trigger: put it on the link / button that should reveal the card. */\n@Directive({\n  selector: 'ui-hover-card-trigger, [ui-hover-card-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-state]': 'card.isOpen ? \"open\" : \"closed\"',\n    '(pointerenter)': 'onPointerEnter($event)',\n    '(pointerleave)': 'onPointerLeave($event)',\n    '(focus)': 'card.handleOpen()',\n    '(blur)': 'card.handleClose()',\n    '(touchstart)': '$event.preventDefault()',\n  },\n})\nexport class UiHoverCardTriggerComponent {\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') ?? 'hover-card-trigger'\n  readonly card = inject(UiHoverCardComponent)\n\n  constructor() {\n    this.card.trigger = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  }\n\n  onPointerEnter(event: PointerEvent): void {\n    if (!isTouch(event)) this.card.handleOpen()\n  }\n\n  onPointerLeave(event: PointerEvent): void {\n    if (!isTouch(event)) this.card.handleClose()\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-hover-card-content, [ui-hover-card-content]',\n  standalone: true,\n  // The host stays where it is declared (no box); the card itself renders in a body portal.\n  host: { class: 'hidden' },\n  template: `\n    <ng-template #panel>\n      <div\n        data-slot=\"hover-card-content\"\n        data-uipkge=\"\"\n        [attr.data-state]=\"state()\"\n        [class]=\"panelClass\"\n        (pointerenter)=\"onPointerEnter($event)\"\n        (pointerleave)=\"onPointerLeave($event)\"\n        (pointerdown)=\"onPointerDown()\"\n      >\n        <ng-content />\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiHoverCardContentComponent implements OnChanges, OnDestroy {\n  readonly card = inject(UiHoverCardComponent)\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n\n  @Input('class') className?: string\n  @Input() side: HoverCardSide = 'bottom'\n  @Input() align: HoverCardAlign = 'center'\n  @Input() sideOffset = 4\n  @Input() alignOffset = 0\n  @Input({ transform: booleanAttribute }) avoidCollisions = true\n  @Input() collisionPadding = 0\n\n  @ViewChild('panel', { static: true }) panelTpl!: TemplateRef<unknown>\n  panelEl?: HTMLElement\n  readonly state = signal<'open' | 'closed'>('closed')\n  private cleanups: (() => void)[] = []\n  private previousBodyUserSelect = ''\n\n  constructor() {\n    this.card.content = this\n  }\n\n  get panelClass(): string {\n    return cn(HOVER_CARD_CONTENT_CLASS, this.className)\n  }\n\n  ngOnChanges(): void {\n    if (this.portal.attached) this.sync()\n  }\n\n  /** Mirrors root open state into the portal (called by the root on every change). */\n  sync(): void {\n    if (this.card.isOpen && (!this.portal.attached || this.state() === 'closed')) {\n      // Reopened mid exit-animation: drop the closing card and start fresh.\n      if (this.portal.attached) this.portal.detach()\n      this.show()\n    } else if (!this.card.isOpen && this.portal.attached && this.state() === 'open') void this.hide()\n  }\n\n  private show(): void {\n    const trigger = this.card.trigger\n    if (!trigger) return\n    this.state.set('open')\n    const panel = this.portal.attach(this.panelTpl).firstElementChild as HTMLElement\n    this.panelEl = panel\n    const onPointerUp = () => {\n      this.releaseSelection()\n      this.card.isPointerDownOnContent = false\n      setTimeout(() => {\n        if ((document.getSelection()?.toString() ?? '') !== '') this.card.hasSelection = true\n      })\n    }\n    document.addEventListener('pointerup', onPointerUp)\n    this.cleanups.push(\n      placeAndTrack(trigger, panel, () => this.placeOptions(), 'hover-card'),\n      pushDismissableLayer({\n        contains: (t) => panel.contains(t),\n        onEscape: () => this.card.handleDismiss(),\n        onPointerDownOutside: () => this.card.handleDismiss(),\n      }),\n      () => {\n        document.removeEventListener('pointerup', onPointerUp)\n        this.releaseSelection()\n        this.card.hasSelection = false\n        this.card.isPointerDownOnContent = false\n      },\n    )\n    // Radix: the card is a preview, not a tab stop -- its focusables leave the tab order.\n    queueMicrotask(() => {\n      for (const el of panel.querySelectorAll<HTMLElement>('*')) if (el.tabIndex >= 0) el.setAttribute('tabindex', '-1')\n    })\n  }\n\n  private async hide(): Promise<void> {\n    this.state.set('closed')\n    const panel = this.panelEl\n    this.cleanups.splice(0).forEach((fn) => fn())\n    await afterExitAnimation(panel)\n    if (this.card.isOpen) return\n    this.portal.detach()\n    this.panelEl = undefined\n  }\n\n  private placeOptions() {\n    return {\n      side: this.side,\n      align: this.align,\n      sideOffset: this.sideOffset,\n      alignOffset: this.alignOffset,\n      collisionPadding: this.collisionPadding,\n      avoidCollisions: this.avoidCollisions,\n    }\n  }\n\n  onPointerEnter(event: PointerEvent): void {\n    if (!isTouch(event)) this.card.handleOpen()\n  }\n\n  onPointerLeave(event: PointerEvent): void {\n    if (!isTouch(event)) this.card.handleClose()\n  }\n\n  /** Radix contains text selection to the card while the pointer is down on it. */\n  onPointerDown(): void {\n    this.card.hasSelection = false\n    this.card.isPointerDownOnContent = true\n    if (!this.panelEl) return\n    this.previousBodyUserSelect = document.body.style.userSelect\n    document.body.style.userSelect = 'none'\n    this.panelEl.style.userSelect = 'text'\n  }\n\n  private releaseSelection(): void {\n    if (this.panelEl?.style.userSelect !== 'text') return\n    document.body.style.userSelect = this.previousBodyUserSelect\n    this.panelEl.style.userSelect = ''\n  }\n\n  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/hover-card/hover-card.component.ts"
    },
    {
      "path": "packages/registry-angular/components/hover-card/index.ts",
      "content": "export {\n  UiHoverCardComponent,\n  UiHoverCardTriggerComponent,\n  UiHoverCardContentComponent,\n  HOVER_CARD_CONTENT_CLASS,\n  type HoverCardSide,\n  type HoverCardAlign,\n} from './hover-card.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/hover-card/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Rich popover triggered by hover/focus instead of click. Use for inline previews — user cards on @mentions, link previews, KPI explanations. Radix HoverCard behaviour: configurable open/close delays, stays open while the pointer is on the card, portalled + positioned with flip/shift, Escape and outside clicks dismiss.",
  "categories": [
    "overlay"
  ]
}