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