{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "popover",
  "title": "Popover",
  "type": "registry:ui",
  "description": "Click-triggered floating panel anchored to a trigger element. Supports optional localStorage persistence and configurable dismissal (click-outside, escape, manual). Built on @radix-ui/react-popover with collision detection.",
  "categories": [
    "overlay"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/popover/popover.ts",
      "content": "import { LitElement, css, html, isServer } 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\nexport type PopoverCloseBehavior = 'auto' | 'click-outside' | 'esc' | 'manual' | 'none'\n\nlet uid = 0\n\nconst FOCUSABLE =\n  'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\n// Radix's --radix-popover-content-transform-origin, from the placed side/align.\nconst alignPct: Record<Align, string> = { start: '0%', center: '50%', end: '100%' }\nfunction transformOrigin(side: Side, align: Align) {\n  const a = alignPct[align]\n  return { top: `${a} 100%`, bottom: `${a} 0%`, left: `100% ${a}`, right: `0% ${a}` }[side]\n}\n\n/**\n * <uip-popover> — the registry Popover (Popover + PopoverTrigger +\n * PopoverContent) as ONE web component.\n *\n *   <uip-popover side=\"bottom\" align=\"start\" class=\"[&::part(content)]:w-80\">\n *     <uip-button slot=\"trigger\" variant=\"outline\">Open popover</uip-button>\n *     …content…\n *   </uip-popover>\n *\n * `slot=\"trigger\"` toggles it on click; the default slot is the content. The\n * content is a native `popover=\"manual\"` (top layer, no portal) positioned\n * with `computePosition`, with `data-side` / `data-align` / `data-state` like\n * Radix. On open, focus moves to the first focusable element in the content\n * (or the content itself); on close it returns to the trigger — except after\n * a click outside, like Radix.\n *\n * - `close-behavior`: `auto` (default) | `click-outside` | `esc` | `manual` |\n *   `none` — which dismissals are allowed (React's `closeBehavior`).\n * - `persist`: localStorage key for the open state (empty attribute = an\n *   auto-generated key), like React's `persist`.\n * - Styling: the panel is `part=\"content\"` — React's PopoverContent\n *   `className` becomes `class=\"[&::part(content)]:w-80 [&::part(content)]:p-0\"`\n *   on the host (the page's Tailwind compiles those rules).\n * - Any content element with `data-popover-close` closes it on click.\n *\n * The trigger gets `aria-haspopup=\"dialog\"`, `aria-expanded` and\n * `data-state`. It can't point `aria-controls` at the content: ARIA ids don't\n * cross shadow roots.\n *\n * Events: `open-change` (detail: { open }).\n */\nexport class UipPopover extends LitElement {\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: inline-flex; }`]\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    closeBehavior: { attribute: 'close-behavior' },\n    persist: {},\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  closeBehavior: PopoverCloseBehavior = 'auto'\n  persist?: string\n  private state: 'open' | 'closed' = 'closed'\n  private placedSide: Side = 'bottom'\n  private pos: Record<string, string> = {}\n  private readonly autoKey = `uipkge-popover-${++uid}`\n  private stopAutoUpdate?: () => void\n  private returnFocus = true\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', 'popover')\n    // Hydrate a persisted open state.\n    const key = this.storageKey\n    if (key && !isServer) {\n      try {\n        if (localStorage.getItem(key) === '1' && !this.open) this.setOpen(true)\n      } catch {\n        /* storage unavailable */\n      }\n    }\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.teardown()\n  }\n\n  private get storageKey() {\n    if (this.persist === undefined || this.persist === null) return null\n    return this.persist || this.autoKey\n  }\n\n  get trigger(): HTMLElement | undefined {\n    const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot[name=\"trigger\"]')\n    return slot?.assignedElements({ flatten: true })[0] as HTMLElement | undefined\n  }\n\n  private get panel() {\n    return this.renderRoot?.querySelector<HTMLElement>('[data-slot=\"popover-content\"]')\n  }\n\n  show() {\n    this.setOpen(true)\n  }\n\n  hide() {\n    this.setOpen(false)\n  }\n\n  toggle() {\n    this.setOpen(!this.open)\n  }\n\n  private setOpen(open: boolean) {\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  // --- dismissal (Radix DismissableLayer + closeBehavior) --------------------\n  private allows(kind: 'outside' | 'esc') {\n    const b = this.closeBehavior\n    if (b === 'manual' || b === 'none') return false\n    if (kind === 'outside') return b !== 'esc'\n    return b !== 'click-outside'\n  }\n\n  private onDocPointerDown = (e: PointerEvent) => {\n    if (e.composedPath().includes(this)) return\n    if (!this.allows('outside')) return\n    this.returnFocus = false\n    this.setOpen(false)\n  }\n\n  private onDocKeyDown = (e: KeyboardEvent) => {\n    if (e.key !== 'Escape' || !this.open) return\n    if (!this.allows('esc')) return\n    e.stopPropagation()\n    this.setOpen(false)\n  }\n\n  // Radix closes a non-modal popover when focus moves to an element outside it.\n  private onFocusOut = (e: FocusEvent) => {\n    const to = e.relatedTarget as Node | null\n    if (!this.open || !to) return\n    if (this.contains(to) || this.renderRoot.contains(to)) return\n    this.returnFocus = false\n    this.setOpen(false)\n  }\n\n  private onContentClick(e: MouseEvent) {\n    const closer = e.composedPath().find((n) => n instanceof Element && n.hasAttribute('data-popover-close'))\n    if (closer) this.setOpen(false)\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 (isServer) return\n    const trigger = this.trigger\n    if (trigger) {\n      trigger.setAttribute('aria-haspopup', 'dialog')\n      trigger.setAttribute('aria-expanded', String(this.open))\n      trigger.setAttribute('data-state', this.state)\n    }\n    if (!changed.has('open')) return\n    this.savePersisted()\n    const panel = this.panel\n    if (!panel) return\n    if (this.open) {\n      this.returnFocus = true\n      if (!panel.matches(':popover-open')) panel.showPopover()\n      addEventListener('pointerdown', this.onDocPointerDown, true)\n      addEventListener('keydown', this.onDocKeyDown, true)\n      this.addEventListener('focusout', this.onFocusOut)\n      this.updateComplete.then(() => {\n        if (!this.open) return\n        if (trigger && !this.stopAutoUpdate) {\n          this.stopAutoUpdate = autoUpdate(trigger, panel, () => 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(panel.getAnimations().map((a) => a.finished)).then(() => this.open && this.place(), () => {})\n        }\n        this.focusFirst(panel)\n      })\n    } else if (changed.get('open') === true) {\n      this.teardown()\n      const active = document.activeElement\n      if (this.returnFocus && (!active || active === document.body || this.contains(active))) trigger?.focus()\n      let hidden = false\n      const done = () => {\n        if (hidden || this.open) return\n        hidden = true\n        if (panel.matches(':popover-open')) panel.hidePopover()\n      }\n      setTimeout(done, 400)\n      const anims = panel.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('pointerdown', this.onDocPointerDown, true)\n    removeEventListener('keydown', this.onDocKeyDown, true)\n    this.removeEventListener('focusout', this.onFocusOut)\n  }\n\n  private savePersisted() {\n    const key = this.storageKey\n    if (!key) return\n    try {\n      if (this.open) localStorage.setItem(key, '1')\n      else localStorage.removeItem(key)\n    } catch {\n      /* storage unavailable */\n    }\n  }\n\n  /** First focusable slotted element (custom elements that delegate focus count), else the panel. */\n  private focusFirst(panel: HTMLElement) {\n    const slot = panel.querySelector<HTMLSlotElement>('slot:not([name])')\n    for (const root of slot?.assignedElements({ flatten: true }) ?? []) {\n      for (const el of [root, ...root.querySelectorAll('*')]) {\n        if (el.matches(FOCUSABLE) || (el.shadowRoot?.delegatesFocus && !el.hasAttribute('disabled'))) {\n          ;(el as HTMLElement).focus()\n          return\n        }\n      }\n    }\n    panel.focus()\n  }\n\n  private place() {\n    const trigger = this.trigger\n    const panel = this.panel\n    if (!trigger || !panel) return\n    const { style, side, align } = computePosition(trigger, panel, {\n      side: this.side,\n      align: this.align,\n      sideOffset: this.sideOffset,\n      alignOffset: this.alignOffset,\n    })\n    this.pos = { ...style, '--radix-popover-content-transform-origin': transformOrigin(side, align) }\n    this.placedSide = side\n  }\n\n  private onTriggerClick() {\n    this.toggle()\n  }\n\n  render() {\n    return html`\n      <slot name=\"trigger\" @click=${this.onTriggerClick} @slotchange=${() => this.requestUpdate()}></slot>\n      <div\n        part=\"content\"\n        role=\"dialog\"\n        tabindex=\"-1\"\n        popover=\"manual\"\n        data-uipkge=\"\"\n        data-slot=\"popover-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-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          // text-start: Chromium maps an `align` attribute on ANY element (the\n          // host's align=\"end\") to text-align, which the content would inherit.\n          'fixed inset-auto m-0 text-start',\n        )}\n        @click=${this.onContentClick}\n      >\n        <slot></slot>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-popover') || customElements.define('uip-popover', UipPopover)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-popover': UipPopover\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/popover/popover.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
