{
"files": [
{
"content": "defmodule Shadix.Components.NavigationMenu do\n @moduledoc ~S\"\"\"\n A site navigation menu with dropdown panels, built on client-side JS commands,\n Floating UI, and a small LiveView hook.\n\n Each `navigation_menu_trigger/1` is a `<button>` whose `phx-click` toggles its\n paired `navigation_menu_content/1` dropdown panel. The `ShadixNavigationMenu`\n hook (assets/ts/navigation_menu.ts) lives on each content panel: it watches the\n panel's visibility via a `MutationObserver`, positions it under the trigger with\n `@floating-ui/dom` (`computePosition` + `autoUpdate`, `bottom-start`/`flip`/\n `shift`), syncs the trigger's `aria-expanded` and `data-state` (driving the\n chevron rotation), opens on trigger hover, and closes on outside pointerdown or\n Escape.\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 NOTE: This is a focused v1. Compared to the shadcn/Radix original it omits the\n shared animated viewport, motion direction animations (`data-motion`), the\n active-link indicator, and pointer-grace hand-off between adjacent panels. Each\n trigger simply opens its own panel positioned below it; links navigate. Single\n open-at-a-time across siblings is also not enforced (panels toggle\n independently).\n \"\"\"\n use Phoenix.Component\n\n alias Phoenix.LiveView.JS\n\n import Shadix.Cn\n\n @doc \"\"\"\n Renders the navigation menu root.\n\n A `<nav role=\"navigation\">` wrapper carrying `data-slot=\"navigation-menu\"`.\n Contains a single `navigation_menu_list/1` of items.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def navigation_menu(assigns) do\n ~H\"\"\"\n <nav\n role=\"navigation\"\n data-slot=\"navigation-menu\"\n class={cn([\"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </nav>\n \"\"\"\n end\n\n @doc \"\"\"\n The horizontal list of navigation items.\n\n Renders a `<ul>` with `data-slot=\"navigation-menu-list\"`. Children are\n `navigation_menu_item/1`s.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def navigation_menu_list(assigns) do\n ~H\"\"\"\n <ul\n data-slot=\"navigation-menu-list\"\n class={cn([\"group flex flex-1 list-none items-center justify-center gap-1\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </ul>\n \"\"\"\n end\n\n @doc \"\"\"\n A single navigation item; wraps a trigger + content, or a link.\n\n Renders an `<li>` with `data-slot=\"navigation-menu-item\"`.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def navigation_menu_item(assigns) do\n ~H\"\"\"\n <li data-slot=\"navigation-menu-item\" class={cn([\"relative\", @class])} {@rest}>\n {render_slot(@inner_block)}\n </li>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n The clickable trigger for a dropdown panel. `:id` is the *item* id.\n\n Renders `<button id=\"#{id}-trigger\">` with `aria-controls=\"#{id}-content\"`\n and `aria-expanded` (kept in sync by the hook) — a disclosure button, matching\n base-ui/Radix (no `aria-haspopup`, since the panel holds links, not a\n `role=\"menu\"`). A chevron SVG rotates when\n `data-state=\"open\"`. `phx-click` toggles the paired content panel; `mouseenter`\n opens it.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def navigation_menu_trigger(assigns) do\n ~H\"\"\"\n <button\n type=\"button\"\n id={\"#{@id}-trigger\"}\n data-slot=\"navigation-menu-trigger\"\n data-state=\"closed\"\n aria-expanded=\"false\"\n aria-controls={\"#{@id}-content\"}\n phx-click={toggle_content(@id)}\n phx-mouseenter={show_content(@id)}\n class={\n cn([\n \"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent [&[data-state=open]>svg]:rotate-180\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n class=\"relative top-[1px] ml-1 size-3 transition duration-300\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n \"\"\"\n end\n\n @doc ~S\"\"\"\n The dropdown panel for a trigger. `:id` is the *item* id.\n\n Renders `<div id=\"#{id}-content\">`, hidden by default (the `hidden` class). The\n `ShadixNavigationMenu` hook here positions the panel under the\n `\"#{id}-trigger\"` button with Floating UI, syncs the trigger's\n `aria-expanded`/`data-state`, and closes the panel on outside pointerdown /\n Escape via the `data-on-close` JS.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def navigation_menu_content(assigns) do\n ~H\"\"\"\n <div\n id={\"#{@id}-content\"}\n data-slot=\"navigation-menu-content\"\n data-state=\"closed\"\n data-trigger={\"#{@id}-trigger\"}\n phx-hook=\"ShadixNavigationMenu\"\n data-on-close={hide_content(@id)}\n class={\n cn([\n \"hidden fixed left-0 top-0 z-50 w-max overflow-hidden rounded-md border bg-popover p-2 text-popover-foreground shadow\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n A styled navigation link.\n\n Renders an `<a data-slot=\"navigation-menu-link\">`. Pass `href` (and any of\n `target`/`rel`/etc.) via the global attrs.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global, include: ~w(href hreflang target rel download))\n slot(:inner_block, required: true)\n\n def navigation_menu_link(assigns) do\n ~H\"\"\"\n <a\n data-slot=\"navigation-menu-link\"\n class={\n cn([\n \"flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </a>\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that toggles the panel with `id`.\n\n The `ShadixNavigationMenu` hook reacts to the resulting visibility change to\n position the panel and wire up outside-click / Escape handling.\n \"\"\"\n def toggle_content(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 shows the panel with `id`.\n\n Used by the trigger's `mouseenter` to open the panel on hover.\n \"\"\"\n def show_content(id) do\n JS.show(\n to: \"##{id}-content\",\n transition:\n {\"transition ease-out duration-100\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"}\n )\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that closes the panel with `id`.\n\n Used by the hook's `data-on-close` (Escape / outside click).\n \"\"\"\n def hide_content(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\nend\n",
"path": "navigation_menu.ex"
}
],
"hooks": [
{
"content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface NavigationMenuHook {\n el: HTMLElement;\n liveSocket: { execJS(el: HTMLElement, js: string): void };\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixNavigationMenu = {\n mounted(this: NavigationMenuHook) {\n const content = this.el;\n const trigger = document.getElementById(content.getAttribute(\"data-trigger\") ?? \"\");\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 trigger?.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 openLogic = () => {\n isOpen = true;\n trigger?.setAttribute(\"aria-expanded\", \"true\");\n trigger?.setAttribute(\"data-state\", \"open\");\n content.setAttribute(\"data-state\", \"open\");\n if (trigger) {\n stopAutoUpdate = autoUpdate(trigger, content, () => {\n computePosition(trigger, content, {\n placement: \"bottom-start\",\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 trigger?.setAttribute(\"aria-expanded\", \"false\");\n trigger?.setAttribute(\"data-state\", \"closed\");\n content.setAttribute(\"data-state\", \"closed\");\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: NavigationMenuHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixNavigationMenu",
"path": "navigation_menu.ts"
}
],
"name": "navigation_menu",
"npm_deps": [
"@floating-ui/dom"
],
"registry_deps": [
"cn"
]
}