Skip to main content

priv/registry/tooltip.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Tooltip do\n  @moduledoc \"\"\"\n  A hover/focus tooltip built on client-side JS commands, Floating UI, and a\n  small LiveView hook.\n\n  Like the hover card, this is a portal-free, fully client-driven overlay opened\n  by *hover* or *focus* rather than click. The `ShadixTooltip` hook\n  (assets/ts/tooltip.ts) attaches `mouseenter` / `focusin` listeners to the\n  trigger that run the content's `data-on-open` JS (`show_tooltip/1`) and\n  `mouseleave` / `focusout` listeners that run its `data-on-close` JS\n  (`hide_tooltip/1`), both with enter/leave transitions. While open the hook\n  positions the content above the trigger with `@floating-ui/dom`\n  (`computePosition` + `autoUpdate`, `top` placement, `offset(4)` / `flip` /\n  `shift`). There is no keyboard navigation or click-outside handling — it\n  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 tooltip with a `:trigger` slot and the tooltip text as the\n  `inner_block`.\n\n  The trigger is wrapped in a `display: contents` span so the caller's own\n  element opens the tooltip without extra markup. The content\n  `<div role=\"tooltip\">` carries the `ShadixTooltip` hook, the `data-on-open` /\n  `data-on-close` JS commands, and is positioned above the trigger by Floating UI\n  while open.\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 tooltip(assigns) do\n    ~H\"\"\"\n    <span id={\"#{@id}-trigger\"} data-slot=\"tooltip-trigger\" class=\"contents\">\n      {render_slot(@trigger)}\n    </span>\n    <div\n      id={\"#{@id}-content\"}\n      role=\"tooltip\"\n      data-slot=\"tooltip-content\"\n      phx-hook=\"ShadixTooltip\"\n      data-trigger={\"#{@id}-trigger\"}\n      data-on-open={show_tooltip(@id)}\n      data-on-close={hide_tooltip(@id)}\n      class={\n        cn([\n          \"hidden fixed left-0 top-0 z-50 w-fit rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground\",\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 tooltip with `id`.\n\n  Reveals the content panel with an enter transition. The `ShadixTooltip` hook\n  reacts to the resulting visibility change to position the tooltip above the\n  trigger with Floating UI.\n  \"\"\"\n  def show_tooltip(id) do\n    JS.show(\n      to: \"##{id}-content\",\n      transition: {\"transition-opacity ease-out duration-150\", \"opacity-0\", \"opacity-100\"}\n    )\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that hides the tooltip with `id`.\n\n  Hides the content panel with a leave transition.\n  \"\"\"\n  def hide_tooltip(id) do\n    JS.hide(\n      to: \"##{id}-content\",\n      transition: {\"transition-opacity ease-in duration-150\", \"opacity-100\", \"opacity-0\"}\n    )\n  end\nend\n",
      "path": "tooltip.ex"
    }
  ],
  "hooks": [
    {
      "content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface TooltipHook {\n  el: HTMLElement;\n  liveSocket: { execJS(el: HTMLElement, encoded: string): void };\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixTooltip = {\n  mounted(this: TooltipHook) {\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    const onOpen = content.getAttribute(\"data-on-open\");\n    const onClose = content.getAttribute(\"data-on-close\");\n\n    // Like Radix Tooltip / base-ui Tooltip (which apply floating-ui's `useRole`\n    // tooltip interaction), point the focusable trigger at the `role=\"tooltip\"`\n    // content with `aria-describedby` so screen readers announce the tooltip\n    // text as the trigger's description. Without this the content is never\n    // associated with anything and is invisible to assistive tech. It stays set\n    // while the content lives in the DOM; the APG allows a persistent\n    // description.\n    anchor?.setAttribute(\"aria-describedby\", content.id);\n\n    // The APG tooltip pattern requires Escape to dismiss a visible tooltip so a\n    // keyboard user who opened it by focusing the trigger can hide it without\n    // tabbing away. base-ui wires this through `useDismiss`.\n    const onKeydown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\" && isOpen) {\n        e.preventDefault();\n        close();\n      }\n    };\n\n    const open = () => {\n      if (onOpen) this.liveSocket.execJS(content, onOpen);\n      if (anchor && !stopAutoUpdate) {\n        stopAutoUpdate = autoUpdate(anchor, content, () => {\n          computePosition(anchor, content, {\n            placement: \"top\",\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      if (!isOpen) {\n        isOpen = true;\n        document.addEventListener(\"keydown\", onKeydown);\n      }\n    };\n    const close = () => {\n      if (onClose) this.liveSocket.execJS(content, onClose);\n      stopAutoUpdate?.();\n      stopAutoUpdate = null;\n      if (isOpen) {\n        isOpen = false;\n        document.removeEventListener(\"keydown\", onKeydown);\n      }\n    };\n\n    trigger?.addEventListener(\"mouseenter\", open);\n    trigger?.addEventListener(\"focusin\", open);\n    trigger?.addEventListener(\"mouseleave\", close);\n    trigger?.addEventListener(\"focusout\", close);\n\n    (content as unknown as { _cleanup?: () => void })._cleanup = () => {\n      trigger?.removeEventListener(\"mouseenter\", open);\n      trigger?.removeEventListener(\"focusin\", open);\n      trigger?.removeEventListener(\"mouseleave\", close);\n      trigger?.removeEventListener(\"focusout\", close);\n      if (isOpen) {\n        isOpen = false;\n        document.removeEventListener(\"keydown\", onKeydown);\n      }\n      stopAutoUpdate?.();\n      stopAutoUpdate = null;\n    };\n  },\n  destroyed(this: TooltipHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixTooltip",
      "path": "tooltip.ts"
    }
  ],
  "name": "tooltip",
  "npm_deps": [
    "@floating-ui/dom"
  ],
  "registry_deps": [
    "cn"
  ]
}