{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "hover-card",
  "title": "Hover Card",
  "type": "registry:ui",
  "description": "Rich popover triggered by hover/focus instead of click. Use for inline previews — user cards on @mentions, link previews, KPI explanations. Built on reka-ui with a configurable open/close delay.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/hover-card/hover-card.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\n/**\n * <uip-hover-card> — the registry HoverCard (Root + Trigger + Content) as ONE\n * web component.\n *\n *   <uip-hover-card>\n *     <uip-button slot=\"trigger\" variant=\"link\">@uipkge</uip-button>\n *     <div slot=\"content\">…rich card markup…</div>\n *   </uip-hover-card>\n *\n * The `trigger` slot is the hover target; `slot=\"content\"` is the card (the\n * default slot aliases the trigger, so a bare child works too). Content\n * classes are React's HoverCardContent string verbatim; the card is a native\n * `popover=\"manual\"` (top layer, no portal) positioned with `computePosition`,\n * with `data-side` / `data-align` / `data-state` like Radix — on both the card\n * and the trigger.\n *\n * Opens on hover after `open-delay` ms (default 700, like Radix) and\n * immediately on keyboard focus; moving between trigger and card keeps it\n * open; leaving both closes after `close-delay` ms (default 300). Escape\n * closes. `open` reflects the state (controlled: set it directly, or\n * `show()` / `hide()`).\n *\n * Styling: the card is `part=\"content\"` — React's HoverCardContent `className`\n * becomes `class=\"[&::part(content)]:w-72\"` on the host. A `display` utility\n * must be scoped to the open state (`[&::part(content):popover-open]:flex`).\n *\n * Events: `open-change` (detail: { open }).\n */\nexport class UipHoverCard extends LitElement {\n  // :host display can't be a utility class (the host has no template of its own).\n  // `contents`: React's asChild trigger adds no box, so inline @-mentions in a\n  // paragraph keep flowing (like <uip-tooltip>).\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties = {\n    open: { type: Boolean, reflect: true },\n    side: { reflect: true },\n    align: {},\n    sideOffset: { type: Number, attribute: 'side-offset' },\n    alignOffset: { type: Number, attribute: 'align-offset' },\n    openDelay: { type: Number, attribute: 'open-delay' },\n    closeDelay: { type: Number, attribute: 'close-delay' },\n    disabled: { type: Boolean, reflect: true },\n    state: { state: true },\n    placedSide: { state: true },\n    pos: { state: true },\n  }\n\n  open = false\n  side: Side = 'bottom'\n  align: Align = 'center'\n  sideOffset = 4\n  alignOffset = 0\n  openDelay = 700\n  closeDelay = 300\n  disabled = false\n  private state: 'open' | 'closed' = 'closed'\n  private placedSide: Side = 'bottom'\n  private pos: Record<string, string> = {}\n  private openTimer?: ReturnType<typeof setTimeout>\n  private closeTimer?: ReturnType<typeof setTimeout>\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', 'hover-card')\n    this.addEventListener('pointerenter', this.onPointerEnter)\n    this.addEventListener('pointerleave', this.onPointerLeave)\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('focusin', this.onFocusIn)\n    this.removeEventListener('focusout', this.onFocusOut)\n    this.teardown()\n  }\n\n  /** The trigger: `slot=\"trigger\"` child, or the first default-slot element. */\n  get trigger(): HTMLElement | undefined {\n    const named = this.renderRoot?.querySelector<HTMLSlotElement>('slot[name=\"trigger\"]')\n    const el = named?.assignedElements({ flatten: true })[0] as HTMLElement | undefined\n    if (el) return el\n    const def = this.renderRoot?.querySelector<HTMLSlotElement>('slot:not([name])')\n    return (def?.assignedElements({ flatten: true })[0] as HTMLElement | undefined) ?? undefined\n  }\n\n  private get card() {\n    return this.renderRoot?.querySelector<HTMLElement>('[data-slot=\"hover-card-content\"]')\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 -------------------------------------------------\n  private onPointerEnter = (e: PointerEvent) => {\n    if (e.pointerType === 'touch') return\n    clearTimeout(this.closeTimer)\n    if (this.open) return\n    // Re-entering during the close grace reopens instantly (Radix behaviour).\n    clearTimeout(this.openTimer)\n    this.openTimer = setTimeout(() => this.setOpen(true), Math.max(0, this.openDelay))\n  }\n\n  private onPointerLeave = () => {\n    clearTimeout(this.openTimer)\n    clearTimeout(this.closeTimer)\n    this.closeTimer = setTimeout(() => this.setOpen(false), Math.max(0, this.closeDelay))\n  }\n\n  private onFocusIn = () => {\n    clearTimeout(this.closeTimer)\n    clearTimeout(this.openTimer)\n    this.setOpen(true)\n  }\n\n  private onFocusOut = (e: FocusEvent) => {\n    const to = e.relatedTarget as Node | null\n    // Focus moving between trigger and card (same shadow tree) keeps it open.\n    if (to && (this.contains(to) || this.renderRoot.contains(to))) return\n    this.setOpen(false)\n  }\n\n  private onCardPointerEnter = () => clearTimeout(this.closeTimer)\n\n  private onCardPointerLeave = () => {\n    clearTimeout(this.openTimer)\n    clearTimeout(this.closeTimer)\n    this.closeTimer = setTimeout(() => this.setOpen(false), Math.max(0, this.closeDelay))\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')) this.state = this.open ? 'open' : 'closed'\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (!changed.has('open') || isServer) return\n    this.syncTriggerState()\n    const card = this.card\n    if (!card) return\n    if (this.open) {\n      if (!card.matches(':popover-open')) card.showPopover()\n      addEventListener('keydown', this.onDocKeyDown, true)\n      this.updateComplete.then(() => {\n        const trigger = this.trigger\n        if (this.open && trigger && !this.stopAutoUpdate) {\n          this.stopAutoUpdate = autoUpdate(trigger, card, () => this.place())\n          Promise.all(card.getAnimations().map((a) => a.finished)).then(() => this.open && this.place(), () => {})\n        }\n      })\n    } else if (changed.get('open') === true) {\n      this.teardown()\n      let hidden = false\n      const done = () => {\n        if (hidden || this.open) return\n        hidden = true\n        if (card.matches(':popover-open')) card.hidePopover()\n      }\n      setTimeout(done, 400)\n      const anims = card.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 card = this.card\n    if (!trigger || !card) return\n    const { style, side } = computePosition(trigger, card, {\n      side: this.side,\n      align: this.align,\n      sideOffset: this.sideOffset,\n      alignOffset: this.alignOffset,\n    })\n    this.pos = style\n    this.placedSide = side\n  }\n\n  private syncTriggerState() {\n    const trigger = this.trigger\n    if (!trigger) return\n    if (!trigger.hasAttribute('data-slot')) trigger.setAttribute('data-slot', 'hover-card-trigger')\n    if (trigger.getAttribute('data-state') !== this.state) trigger.setAttribute('data-state', this.state)\n  }\n\n  render() {\n    return html`\n      <slot name=\"trigger\" @slotchange=${() => this.syncTriggerState()}></slot>\n      <slot @slotchange=${() => this.syncTriggerState()}></slot>\n      <div\n        part=\"content\"\n        popover=\"manual\"\n        data-uipkge=\"\"\n        data-slot=\"hover-card-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-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          // text-start: Chromium maps the host's `align` attribute to text-align.\n          'fixed inset-auto m-0 text-start',\n        )}\n        @pointerenter=${this.onCardPointerEnter}\n        @pointerleave=${this.onCardPointerLeave}\n      >\n        <slot name=\"content\"></slot>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-hover-card') || customElements.define('uip-hover-card', UipHoverCard)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-hover-card': UipHoverCard\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/hover-card/hover-card.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
