Skip to main content

priv/registry/hover_card.json

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