Skip to main content

priv/registry/popover.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Popover do\n  @moduledoc \"\"\"\n  A click-triggered popover with arbitrary content, built on client-side JS\n  commands, Floating UI, and a small LiveView hook.\n\n  Like the dropdown menu, this is a portal-free, fully client-driven overlay. The\n  trigger toggles the popover content with `toggle_popover/1` (a\n  `Phoenix.LiveView.JS` builder with enter/leave transitions); the\n  `ShadixPopover` hook (assets/ts/popover.ts) watches the content's visibility\n  via a `MutationObserver`, positions it relative to the trigger with\n  `@floating-ui/dom` (`computePosition` + `autoUpdate`, `bottom`/`flip`/`shift`),\n  and closes on `Escape` or outside pointerdown via the `data-on-close` JS\n  (`hide_popover/1`). Unlike the menu, the content is arbitrary, so there is no\n  arrow-key item navigation.\n\n  Trigger and content ids are derived from the required `:id` so `aria-controls`,\n  `data-trigger`, and the JS targets line up stably.\n  \"\"\"\n  use Phoenix.Component\n\n  alias Phoenix.LiveView.JS\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  Renders a popover with a `:trigger` slot and arbitrary content.\n\n  The trigger is wrapped in a `display: contents` span wired to `toggle_popover/1`,\n  carrying the popover's ARIA wiring. The content `<div role=\\\"dialog\\\">` carries\n  the `ShadixPopover` hook and is positioned by Floating UI at open time.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:class, :string, default: nil)\n  slot(:trigger, required: true)\n  slot(:inner_block, required: true)\n\n  def popover(assigns) do\n    ~H\"\"\"\n    <span\n      id={\"#{@id}-trigger\"}\n      phx-click={toggle_popover(@id)}\n      class=\"contents\"\n    >\n      {render_slot(@trigger)}\n    </span>\n    <div\n      id={\"#{@id}-content\"}\n      tabindex=\"-1\"\n      role=\"dialog\"\n      aria-labelledby={\"#{@id}-title\"}\n      aria-describedby={\"#{@id}-description\"}\n      data-slot=\"popover-content\"\n      phx-hook=\"ShadixPopover\"\n      data-trigger={\"#{@id}-trigger\"}\n      data-on-close={hide_popover(@id)}\n      class={\n        cn([\n          \"hidden fixed left-0 top-0 z-50 w-72 origin-top rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none\",\n          @class\n        ])\n      }\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that toggles the popover with `id`.\n\n  Toggles the content panel's visibility with enter/leave transitions. The\n  `ShadixPopover` hook reacts to the resulting visibility change to position the\n  popover and wire up Escape/outside-click handling.\n  \"\"\"\n  def toggle_popover(id) do\n    JS.toggle(\n      to: \"##{id}-content\",\n      in: {\"transition ease-out duration-100\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"},\n      out: {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n    )\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that closes the popover with `id`.\n\n  Hides the content panel with a leave transition. Used by the hook's\n  `data-on-close` (Escape / outside click).\n  \"\"\"\n  def hide_popover(id) do\n    JS.hide(\n      to: \"##{id}-content\",\n      transition:\n        {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n    )\n  end\n\n  @doc \"Header region of a popover; stacks title/description with sensible spacing.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def popover_header(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"popover-header\"\n      class={cn([\"flex flex-col gap-1 text-sm\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  Popover title. Pass the *popover's* `:id` so the title renders with\n  `\"#{id}-title\"`, matching the content panel's `aria-labelledby` and giving\n  screen-reader users the popover's accessible name.\n  \"\"\"\n  attr(:id, :string, default: nil)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def popover_title(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id && \"#{@id}-title\"}\n      data-slot=\"popover-title\"\n      class={cn([\"font-medium\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  Popover description. Pass the *popover's* `:id` so the description renders with\n  `\"#{id}-description\"`, matching the content panel's `aria-describedby`.\n  \"\"\"\n  attr(:id, :string, default: nil)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def popover_description(assigns) do\n    ~H\"\"\"\n    <p\n      id={@id && \"#{@id}-description\"}\n      data-slot=\"popover-description\"\n      class={cn([\"text-muted-foreground text-sm\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </p>\n    \"\"\"\n  end\nend\n",
      "path": "popover.ex"
    }
  ],
  "hooks": [
    {
      "content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface PopoverHook {\n  el: HTMLElement;\n  liveSocket: { execJS(el: HTMLElement, js: string): void };\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixPopover = {\n  mounted(this: PopoverHook) {\n    const content = this.el;\n    const trigger = document.getElementById(content.getAttribute(\"data-trigger\") ?? \"\");\n    // The trigger wrapper is `display: contents`, so its own bounding box is\n    // empty; anchor Floating UI to the first real child element when present.\n    const anchor = (trigger?.firstElementChild as HTMLElement | null) ?? trigger;\n    // Carry the popover ARIA wiring on the real trigger element (not the\n    // `display: contents` wrapper) so the attributes land on a focusable host.\n    anchor?.setAttribute(\"aria-haspopup\", \"dialog\");\n    anchor?.setAttribute(\"aria-controls\", content.id);\n    anchor?.setAttribute(\"aria-expanded\", \"false\");\n    let stopAutoUpdate: (() => void) | null = null;\n    let isOpen = false;\n\n    const closeJS = () => {\n      const js = content.getAttribute(\"data-on-close\");\n      if (js) this.liveSocket.execJS(content, js);\n    };\n\n    const onKeydown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        anchor?.focus();\n        closeJS();\n      }\n    };\n    const onPointerDown = (e: Event) => {\n      const t = e.target as Node;\n      if (!content.contains(t) && !(trigger && trigger.contains(t))) closeJS();\n    };\n\n    const FOCUSABLE =\n      'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),' +\n      'textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n    const openLogic = () => {\n      isOpen = true;\n      anchor?.setAttribute(\"aria-expanded\", \"true\");\n      // Move focus into the dialog popup on open (matching base-ui's\n      // FloatingFocusManager): the first tabbable element, else the panel itself.\n      const target = content.querySelector<HTMLElement>(FOCUSABLE) ?? content;\n      // Defer past the enter transition / display toggle so the element is focusable.\n      requestAnimationFrame(() => {\n        if (isOpen) target.focus();\n      });\n      if (anchor) {\n        stopAutoUpdate = autoUpdate(anchor, content, () => {\n          computePosition(anchor, content, {\n            placement: \"bottom\",\n            strategy: \"fixed\",\n            middleware: [offset(4), flip(), shift({ padding: 8 })],\n          }).then(({ x, y }) => Object.assign(content.style, { left: `${x}px`, top: `${y}px` }));\n        });\n      }\n      document.addEventListener(\"keydown\", onKeydown);\n      document.addEventListener(\"pointerdown\", onPointerDown, true);\n    };\n    const closeLogic = () => {\n      isOpen = false;\n      anchor?.setAttribute(\"aria-expanded\", \"false\");\n      stopAutoUpdate?.();\n      stopAutoUpdate = null;\n      document.removeEventListener(\"keydown\", onKeydown);\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n    };\n\n    const visible = () => getComputedStyle(content).display !== \"none\";\n    const observer = new MutationObserver(() => {\n      const v = visible();\n      if (v && !isOpen) openLogic();\n      else if (!v && isOpen) closeLogic();\n    });\n    observer.observe(content, { attributes: true, attributeFilter: [\"style\", \"class\"] });\n\n    (content as unknown as { _cleanup?: () => void })._cleanup = () => {\n      observer.disconnect();\n      if (isOpen) closeLogic();\n    };\n  },\n  destroyed(this: PopoverHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixPopover",
      "path": "popover.ts"
    }
  ],
  "name": "popover",
  "npm_deps": [
    "@floating-ui/dom"
  ],
  "registry_deps": [
    "cn"
  ]
}