Skip to main content

priv/registry/navigation_menu.json

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