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