{
"files": [
{
"content": "defmodule Shadix.Components.HoverCard do\n @moduledoc \"\"\"\n A hover-triggered rich card built on client-side JS commands, Floating UI, and\n a small LiveView hook.\n\n Like the tooltip, this is a portal-free, fully client-driven overlay opened by\n *hover* rather than click: the `ShadixHoverCard` hook (assets/ts/hover_card.ts)\n attaches `mouseenter` / `focusin` and `mouseleave` / `focusout` listeners to\n the trigger, running the content's `data-on-open` / `data-on-close`\n `Phoenix.LiveView.JS` commands (which run enter/leave transitions) and\n positioning the content relative to the trigger with `@floating-ui/dom`\n (`computePosition` + `autoUpdate`, `bottom` placement). There is no keyboard\n navigation or click-outside handling — it simply follows the pointer/focus.\n\n Trigger and content ids are derived from the required `:id` so `data-trigger`\n 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 hover card with a `:trigger` slot and arbitrary card content.\n\n The trigger is wrapped in a `display: contents` span wired to mouse/focus\n events, so the caller's own element opens the card without extra markup. The\n content `<div>` carries the `ShadixHoverCard` hook and is positioned by\n 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 hover_card(assigns) do\n ~H\"\"\"\n <span id={\"#{@id}-trigger\"} data-slot=\"hover-card-trigger\" class=\"contents\">\n {render_slot(@trigger)}\n </span>\n <div\n id={\"#{@id}-content\"}\n data-slot=\"hover-card-content\"\n phx-hook=\"ShadixHoverCard\"\n data-trigger={\"#{@id}-trigger\"}\n data-on-open={show_hover_card(@id)}\n data-on-close={hide_hover_card(@id)}\n class={\n cn([\n \"hidden fixed left-0 top-0 z-50 w-64 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 shows the hover card with `id`.\n\n Shows the content panel with an enter transition and dispatches\n `shadix:hover-card-open` so the `ShadixHoverCard` hook positions it under the\n trigger with Floating UI.\n \"\"\"\n def show_hover_card(id) do\n %JS{}\n |> JS.show(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-out duration-150\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"}\n )\n |> JS.dispatch(\"shadix:hover-card-open\", to: \"##{id}-content\")\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that hides the hover card with `id`.\n\n Hides the content panel with a leave transition and dispatches\n `shadix:hover-card-close` so the `ShadixHoverCard` hook stops positioning it.\n \"\"\"\n def hide_hover_card(id) do\n %JS{}\n |> JS.hide(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-in duration-100\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n )\n |> JS.dispatch(\"shadix:hover-card-close\", to: \"##{id}-content\")\n end\nend\n",
"path": "hover_card.ex"
}
],
"hooks": [
{
"content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface HoverCardHook {\n el: HTMLElement;\n liveSocket: { execJS(el: HTMLElement, js: string): void };\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixHoverCard = {\n mounted(this: HoverCardHook) {\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 let stopAutoUpdate: (() => void) | null = null;\n let isOpen = false;\n\n // Like Radix HoverCard / base-ui PreviewCard, Escape dismisses the card so a\n // keyboard user who opened it by focusing the trigger can close it without\n // having to tab away from the trigger.\n const onKeydown = (e: KeyboardEvent) => {\n if (e.key === \"Escape\" && isOpen) {\n e.preventDefault();\n onClose();\n }\n };\n\n const position = () => {\n if (!anchor) return;\n stopAutoUpdate?.();\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\n const stopPosition = () => {\n stopAutoUpdate?.();\n stopAutoUpdate = null;\n };\n\n const onOpen = () => {\n const js = content.getAttribute(\"data-on-open\");\n if (js) this.liveSocket.execJS(content, js);\n position();\n if (!isOpen) {\n isOpen = true;\n document.addEventListener(\"keydown\", onKeydown);\n }\n };\n\n const onClose = () => {\n const js = content.getAttribute(\"data-on-close\");\n if (js) this.liveSocket.execJS(content, js);\n stopPosition();\n if (isOpen) {\n isOpen = false;\n document.removeEventListener(\"keydown\", onKeydown);\n }\n };\n\n if (trigger) {\n trigger.addEventListener(\"mouseenter\", onOpen);\n trigger.addEventListener(\"focusin\", onOpen);\n trigger.addEventListener(\"mouseleave\", onClose);\n trigger.addEventListener(\"focusout\", onClose);\n }\n\n (content as unknown as { _cleanup?: () => void })._cleanup = () => {\n stopPosition();\n if (isOpen) {\n isOpen = false;\n document.removeEventListener(\"keydown\", onKeydown);\n }\n if (trigger) {\n trigger.removeEventListener(\"mouseenter\", onOpen);\n trigger.removeEventListener(\"focusin\", onOpen);\n trigger.removeEventListener(\"mouseleave\", onClose);\n trigger.removeEventListener(\"focusout\", onClose);\n }\n };\n },\n\n destroyed(this: HoverCardHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixHoverCard",
"path": "hover_card.ts"
}
],
"name": "hover_card",
"npm_deps": [
"@floating-ui/dom"
],
"registry_deps": [
"cn"
]
}