Skip to main content

priv/registry/dropdown_menu.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.DropdownMenu do\n  @moduledoc \"\"\"\n  A dropdown menu built on client-side JS commands, Floating UI, and a small\n  LiveView hook.\n\n  Like the dialog, this is a portal-free, fully client-driven menu. The trigger\n  toggles the menu content with `toggle_menu/1` (a `Phoenix.LiveView.JS` builder\n  with enter/leave transitions); the `ShadixDropdownMenu` hook\n  (assets/ts/dropdown_menu.ts) watches the content's visibility via a\n  `MutationObserver`, positions it under the trigger with `@floating-ui/dom`\n  (`computePosition` + `autoUpdate`, `bottom-start`/`flip`/`shift`), manages\n  keyboard navigation (arrows/Home/End/Enter/Escape/Tab), and closes on outside\n  pointerdown via the `data-on-close` JS (`hide_menu/1`).\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  use Phoenix.Component\n\n  alias Phoenix.LiveView.JS\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  Renders a dropdown menu with a `:trigger` slot and arbitrary menu items.\n\n  The trigger is wrapped in a `display: contents` span wired to `toggle_menu/1`,\n  carrying the menu's ARIA wiring. The content `<div role=\\\"menu\\\">` carries the\n  `ShadixDropdownMenu` hook and is positioned by 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 dropdown_menu(assigns) do\n    ~H\"\"\"\n    <span\n      id={\"#{@id}-trigger\"}\n      phx-click={toggle_menu(@id)}\n      class=\"contents\"\n    >\n      {render_slot(@trigger)}\n    </span>\n    <div\n      id={\"#{@id}-content\"}\n      role=\"menu\"\n      tabindex=\"-1\"\n      data-slot=\"dropdown-menu-content\"\n      phx-hook=\"ShadixDropdownMenu\"\n      data-trigger={\"#{@id}-trigger\"}\n      data-on-close={hide_menu(@id)}\n      class={\n        cn([\n          \"hidden fixed left-0 top-0 z-50 min-w-[8rem] origin-top overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\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 toggles the menu with `id`.\n\n  Toggles the content panel's visibility with enter/leave transitions. The\n  `ShadixDropdownMenu` hook reacts to the resulting visibility change to position\n  the menu and wire up keyboard/outside-click handling.\n  \"\"\"\n  def toggle_menu(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 closes the menu with `id`.\n\n  Hides the content panel with a leave transition. Used by the hook's\n  `data-on-close` (Escape / Tab / outside click) and by menu items on select.\n  \"\"\"\n  def hide_menu(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\n\n  @doc \"\"\"\n  A selectable menu item.\n\n  `:id` is the *menu's* id (not the item's). On click it runs the caller's\n  `:on_select` JS and then closes the menu.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:on_select, JS, default: %JS{})\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def dropdown_menu_item(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"menuitem\"\n      tabindex=\"-1\"\n      data-slot=\"dropdown-menu-item\"\n      phx-click={\n        @on_select\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      }\n      class={\n        cn([\n          \"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"A non-interactive group label within a dropdown menu.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def dropdown_menu_label(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"dropdown-menu-label\"\n      class={cn([\"px-2 py-1.5 text-sm font-medium\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"A horizontal separator between dropdown menu sections.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def dropdown_menu_separator(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"separator\"\n      data-slot=\"dropdown-menu-separator\"\n      class={cn([\"-mx-1 my-1 h-px bg-border\", @class])}\n      {@rest}\n    />\n    \"\"\"\n  end\n\n  @doc \"A trailing keyboard-shortcut hint within a dropdown menu item.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def dropdown_menu_shortcut(assigns) do\n    ~H\"\"\"\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      class={cn([\"ml-auto text-xs tracking-widest text-muted-foreground\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </span>\n    \"\"\"\n  end\nend\n",
      "path": "dropdown_menu.ex"
    }
  ],
  "hooks": [
    {
      "content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface MenuHook {\n  el: HTMLElement;\n  liveSocket: { execJS(el: HTMLElement, js: string): void };\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixDropdownMenu = {\n  mounted(this: MenuHook) {\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    // ARIA for the popup belongs on the real interactive trigger element, not on\n    // the `display: contents` wrapper span (which has no widget role).\n    if (anchor) {\n      anchor.setAttribute(\"aria-haspopup\", \"menu\");\n      anchor.setAttribute(\"aria-controls\", content.id);\n      // Give the menu an accessible name pointing at its trigger (matches\n      // base-ui/Radix `aria-labelledby={trigger.id}`). Prefer the real\n      // interactive anchor's id; fall back to the wrapper span's id.\n      const labelId = anchor.id || trigger?.id;\n      if (labelId) content.setAttribute(\"aria-labelledby\", labelId);\n    }\n    let stopAutoUpdate: (() => void) | null = null;\n    let isOpen = false;\n\n    const items = () =>\n      Array.from(content.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not([aria-disabled=\"true\"])'));\n    const focusItem = (i: number) => {\n      const list = items();\n      if (list.length) list[(i + list.length) % list.length].focus();\n    };\n    const indexOfActive = () => items().indexOf(document.activeElement as HTMLElement);\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      switch (e.key) {\n        case \"ArrowDown\": e.preventDefault(); focusItem(indexOfActive() + 1); break;\n        case \"ArrowUp\": e.preventDefault(); focusItem(indexOfActive() - 1); break;\n        case \"Home\": e.preventDefault(); focusItem(0); break;\n        case \"End\": e.preventDefault(); focusItem(items().length - 1); break;\n        case \"Escape\": e.preventDefault(); anchor?.focus(); closeJS(); break;\n        case \"Tab\": closeJS(); break;\n        case \"Enter\":\n        case \" \": {\n          const active = document.activeElement as HTMLElement | null;\n          if (active && items().includes(active)) { e.preventDefault(); active.click(); }\n          break;\n        }\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      anchor?.setAttribute(\"aria-expanded\", \"true\");\n      if (anchor) {\n        stopAutoUpdate = autoUpdate(anchor, content, () => {\n          computePosition(anchor, 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      window.requestAnimationFrame(() => focusItem(0));\n    };\n    const closeLogic = () => {\n      isOpen = false;\n      anchor?.setAttribute(\"aria-expanded\", \"false\");\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: MenuHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixDropdownMenu",
      "path": "dropdown_menu.ts"
    }
  ],
  "name": "dropdown_menu",
  "npm_deps": [
    "@floating-ui/dom"
  ],
  "registry_deps": [
    "cn"
  ]
}