{
"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"
]
}