{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "popover",
  "title": "Popover",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/popover/popover.component.ts",
      "content": "import {\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  OnInit,\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  lockScroll,\n  pushDismissableLayer,\n  trapFocus,\n  uniqueId,\n  type PopperAlign,\n  type PopperSide,\n} from '@/ui/popper/popper'\n\nexport type PopoverCloseBehavior = 'auto' | 'click-outside' | 'esc' | 'manual' | 'none'\nexport type PopoverSide = PopperSide\nexport type PopoverAlign = PopperAlign\n\n/**\n * Cancelable event emitted by Content's dismiss / focus outputs (Radix semantics):\n * `preventDefault()` keeps the popover open, or skips the automatic focus move.\n */\nexport type PopoverDismissEvent = CustomEvent<{ originalEvent: Event }>\n\nconst dismissEvent = (type: string, originalEvent: Event): PopoverDismissEvent =>\n  new CustomEvent(type, { cancelable: true, detail: { originalEvent } })\n\n/**\n * Angular port of UIPKGE Popover with Radix Popover behaviour (what the React registry\n * wraps): the trigger is a directive (the `asChild` equivalent), content renders in a\n * body portal positioned against the trigger -- or a PopoverAnchor -- with flip + shift,\n * focus moves into the panel on open and back to the trigger on close, Escape /\n * outside pointer-down / focus leaving the panel dismiss, and `closeBehavior` vetoes\n * the Escape and outside-click paths exactly like the React wrapper. `persist`\n * remembers the open state in localStorage. Class strings identical to React.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-popover, [ui-popover]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"popover\"',\n    '[attr.data-uipkge]': '\"\"',\n    // Radix Root renders no element: keep the wrapper out of layout.\n    class: 'contents',\n  },\n  template: `<ng-content />`,\n})\nexport class UiPopoverComponent implements OnInit, OnChanges {\n  /** Controlled open state (pair with `openChange`). Leave unset for uncontrolled use. */\n  @Input() open?: boolean\n  @Input({ transform: booleanAttribute }) defaultOpen = false\n  /** Modal popovers trap focus and lock page scroll (Radix `modal`). */\n  @Input({ transform: booleanAttribute }) modal = false\n  /** Persist open state in localStorage: `true` = auto key, a string = that key, `false` = off. */\n  @Input() persist: string | boolean = false\n  /** Controls how the popover may be dismissed. */\n  @Input() closeBehavior: PopoverCloseBehavior = 'auto'\n  @Output() openChange = new EventEmitter<boolean>()\n\n  readonly contentId = uniqueId('popover-content')\n  private readonly autoKey = `uipkge-${uniqueId('popover')}`\n  /** First trigger registered (a Close-style trigger inside the content never replaces it). */\n  trigger?: HTMLElement\n  anchor?: HTMLElement\n  content?: UiPopoverContentComponent\n  private readonly _open = signal<boolean | null>(null)\n\n  get isOpen(): boolean {\n    if (this.open !== undefined) return this.open\n    return this._open() ?? this.defaultOpen\n  }\n\n  get storageKey(): string | null {\n    if (this.persist === false || this.persist === '') return null\n    return this.persist === true ? this.autoKey : this.persist\n  }\n\n  ngOnInit(): void {\n    const key = this.storageKey\n    if (!key) return\n    try {\n      if (localStorage.getItem(key) === '1' && !this.isOpen) {\n        this._open.set(true)\n        if (this.open === undefined) this.openChange.emit(true)\n      }\n    } catch {\n      /* storage blocked */\n    }\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.persistState(value)\n    this.content?.sync()\n  }\n\n  toggle(): void {\n    this.setOpen(!this.isOpen)\n  }\n\n  /** React composes these guards into onPointerDownOutside / onEscapeKeyDown. */\n  blocksOutsideClose(): boolean {\n    return this.closeBehavior === 'esc' || this.closeBehavior === 'manual' || this.closeBehavior === 'none'\n  }\n\n  blocksEscapeClose(): boolean {\n    return this.closeBehavior === 'click-outside' || this.closeBehavior === 'manual' || this.closeBehavior === 'none'\n  }\n\n  private persistState(value: boolean): void {\n    const key = this.storageKey\n    if (!key) return\n    try {\n      if (value) localStorage.setItem(key, '1')\n      else localStorage.removeItem(key)\n    } catch {\n      /* storage blocked */\n    }\n  }\n}\n\n/** Popover Trigger as a directive: put it on any button (Radix `PopoverTrigger asChild`). */\n@Directive({\n  selector: 'ui-popover-trigger, [ui-popover-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.type]': 'isButton ? \"button\" : null',\n    '[attr.aria-haspopup]': '\"dialog\"',\n    '[attr.aria-expanded]': 'popover.isOpen',\n    '[attr.aria-controls]': 'popover.contentId',\n    '[attr.data-state]': 'popover.isOpen ? \"open\" : \"closed\"',\n    '(click)': 'popover.toggle()',\n  },\n})\nexport class UiPopoverTriggerComponent implements OnDestroy {\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') ?? 'popover-trigger'\n  readonly popover = inject(UiPopoverComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  readonly isButton = this.el.tagName === 'BUTTON'\n\n  constructor() {\n    this.popover.trigger ??= this.el\n  }\n\n  ngOnDestroy(): void {\n    if (this.popover.trigger === this.el) this.popover.trigger = undefined\n  }\n}\n\n/** Positions the content against this element instead of the trigger (Radix PopoverAnchor). */\n@Directive({\n  selector: 'ui-popover-anchor, [ui-popover-anchor]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': '\"popover-anchor\"',\n    '[attr.data-uipkge]': '\"\"',\n  },\n})\nexport class UiPopoverAnchorComponent implements OnDestroy {\n  private readonly popover = inject(UiPopoverComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n\n  constructor() {\n    this.popover.anchor = this.el\n  }\n\n  ngOnDestroy(): void {\n    if (this.popover.anchor === this.el) this.popover.anchor = undefined\n  }\n}\n\nconst TABBABLE =\n  'button:not([disabled]),input:not([disabled]):not([type=\"hidden\"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"]),[contenteditable=\"true\"]'\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-popover-content, [ui-popover-content]',\n  standalone: true,\n  // The host stays where it is declared (no box); the panel renders in a body portal.\n  host: { '[attr.hidden]': '\"\"' },\n  template: `\n    <ng-template #tpl>\n      <div\n        [id]=\"popover.contentId\"\n        role=\"dialog\"\n        tabindex=\"-1\"\n        data-uipkge=\"\"\n        data-slot=\"popover-content\"\n        [attr.data-state]=\"state()\"\n        [attr.aria-modal]=\"popover.modal ? 'true' : null\"\n        [class]=\"panelClass\"\n      >\n        <ng-content />\n      </div>\n    </ng-template>\n  `,\n})\nexport class UiPopoverContentComponent implements OnInit, OnChanges, OnDestroy {\n  readonly popover = inject(UiPopoverComponent)\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n\n  @Input('class') className?: string\n  @Input() side: PopoverSide = 'bottom'\n  @Input() align: PopoverAlign = 'center'\n  @Input() sideOffset = 4\n  @Input() alignOffset = 0\n  @Input({ transform: booleanAttribute }) avoidCollisions = true\n  @Input() collisionPadding = 8\n  @Output() openAutoFocus = new EventEmitter<PopoverDismissEvent>()\n  @Output() closeAutoFocus = new EventEmitter<PopoverDismissEvent>()\n  @Output() escapeKeyDown = new EventEmitter<PopoverDismissEvent>()\n  @Output() pointerDownOutside = new EventEmitter<PopoverDismissEvent>()\n  @Output() focusOutside = new EventEmitter<PopoverDismissEvent>()\n  @Output() interactOutside = new EventEmitter<PopoverDismissEvent>()\n\n  @ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>\n  readonly state = signal<'open' | 'closed'>('closed')\n  private panelEl?: HTMLElement\n  private interactedOutside = false\n  private pointerDownOutsideSeen = false\n  private cleanups: (() => void)[] = []\n\n  constructor() {\n    this.popover.content = this\n  }\n\n  get panelClass(): string {\n    return 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-72 origin-(--radix-popover-content-transform-origin) 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      this.className,\n    )\n  }\n\n  ngOnInit(): void {\n    // defaultOpen / persisted / open=true on first render: no input change will call sync().\n    if (this.popover.isOpen) queueMicrotask(() => this.sync())\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.popover.isOpen && (!this.portal.attached || this.state() === 'closed')) {\n      if (this.portal.attached) this.portal.detach()\n      this.show()\n    } else if (!this.popover.isOpen && this.portal.attached && this.state() === 'open') void this.hide()\n  }\n\n  private show(): void {\n    const anchor = this.popover.anchor ?? this.popover.trigger\n    if (!anchor) return\n    this.interactedOutside = false\n    this.pointerDownOutsideSeen = false\n    this.state.set('open')\n    const host = this.portal.attach(this.tpl)\n    const panel = host.firstElementChild as HTMLElement\n    this.panelEl = panel\n    const inside = (t: Node) => panel.contains(t) || !!this.popover.trigger?.contains(t)\n    const onFocusIn = (e: FocusEvent) => {\n      const target = e.target as Node | null\n      if (!target || inside(target) || !this.popover.isOpen) return\n      const event = dismissEvent('focusOutside', e)\n      this.focusOutside.emit(event)\n      this.interactOutside.emit(event)\n      // Radix: focus landing outside right after a (vetoed) outside pointer-down doesn't dismiss.\n      if (event.defaultPrevented || this.pointerDownOutsideSeen) return\n      this.interactedOutside = true\n      this.popover.setOpen(false)\n    }\n    document.addEventListener('focusin', onFocusIn)\n    this.cleanups.push(\n      () => document.removeEventListener('focusin', onFocusIn),\n      autoPlace(anchor, panel, () => this.placeOptions(), 'popover'),\n      pushDismissableLayer({\n        contains: inside,\n        onEscape: (e) => {\n          const event = dismissEvent('escapeKeyDown', e)\n          if (this.popover.blocksEscapeClose()) event.preventDefault()\n          this.escapeKeyDown.emit(event)\n          if (!event.defaultPrevented) this.popover.setOpen(false)\n        },\n        onPointerDownOutside: (e) => {\n          const event = dismissEvent('pointerDownOutside', e)\n          if (this.popover.blocksOutsideClose()) event.preventDefault()\n          this.pointerDownOutside.emit(event)\n          this.interactOutside.emit(event)\n          this.pointerDownOutsideSeen = true\n          if (event.defaultPrevented) return\n          this.interactedOutside = true\n          this.popover.setOpen(false)\n        },\n      }),\n    )\n    if (this.popover.modal) this.cleanups.push(trapFocus(panel), lockScroll())\n    queueMicrotask(() => {\n      if (!panel.isConnected) return\n      const event = dismissEvent('openAutoFocus', new Event('focus'))\n      this.openAutoFocus.emit(event)\n      if (event.defaultPrevented) return\n      const first = panel.querySelector<HTMLElement>('[autofocus]') ?? panel.querySelector<HTMLElement>(TABBABLE)\n      ;(first ?? panel).focus({ preventScroll: true })\n      // Radix FocusScope selects the text of an auto-focused input.\n      if (first instanceof HTMLInputElement) first.select()\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, 200)\n    if (this.popover.isOpen) return\n    this.portal.detach()\n    this.panelEl = undefined\n    const event = dismissEvent('closeAutoFocus', new Event('blur'))\n    this.closeAutoFocus.emit(event)\n    // Radix: back to the trigger unless the user already moved on by interacting outside.\n    if (event.defaultPrevented || this.interactedOutside) return\n    this.popover.trigger?.focus({ preventScroll: true })\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  ngOnDestroy(): void {\n    this.cleanups.splice(0).forEach((fn) => fn())\n    this.portal.detach()\n    if (this.popover.content === this) this.popover.content = undefined\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/popover/popover.component.ts"
    },
    {
      "path": "packages/registry-angular/components/popover/index.ts",
      "content": "export {\n  UiPopoverComponent,\n  UiPopoverAnchorComponent,\n  UiPopoverContentComponent,\n  UiPopoverTriggerComponent,\n  type PopoverAlign,\n  type PopoverCloseBehavior,\n  type PopoverDismissEvent,\n  type PopoverSide,\n} from './popover.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/popover/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Click-triggered floating panel anchored to a trigger element. Radix Popover behaviour: body portal positioned with side / align / sideOffset (flip + shift), focus in and back to the trigger, Escape / outside dismiss. Supports optional persistence and configurable dismissal (click-outside, escape, manual), plus an optional anchor.",
  "categories": [
    "overlay"
  ]
}