{
"files": [
{
"content": "defmodule Shadix.Components.ContextMenu do\n @moduledoc \"\"\"\n A right-click context menu built on client-side JS commands, Floating UI, and a\n small LiveView hook.\n\n Like the dropdown menu, this is a portal-free, fully client-driven menu, but it\n is opened by the `contextmenu` (right-click) event on the trigger area rather\n than a click, and it is positioned at the *pointer* rather than under an anchor.\n\n The `:trigger` slot defines the right-clickable area. The content\n `<div role=\"menu\">` carries the `ShadixContextMenu` hook\n (assets/ts/context_menu.ts), which listens for `contextmenu` on the trigger,\n prevents the browser's native menu, and positions the content at the pointer\n using a `@floating-ui/dom` virtual element (`computePosition` + `autoUpdate`,\n `right-start`/`flip`/`shift`). The hook watches the content's visibility via a\n `MutationObserver` to manage keyboard navigation (arrows/Home/End/Enter/\n Escape/Tab) and closes on outside `pointerdown` via the `data-on-close` JS\n (`hide_menu/1`).\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 context menu with a `:trigger` slot (the right-clickable area) and\n arbitrary menu items.\n\n The trigger is wrapped in a `<div data-context-trigger>` whose `contextmenu`\n event is intercepted by the hook. The content `<div role=\"menu\">` carries the\n `ShadixContextMenu` hook and is positioned by Floating UI at open time.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:trigger_class, :string, default: nil)\n slot(:trigger, required: true)\n slot(:inner_block, required: true)\n\n def context_menu(assigns) do\n ~H\"\"\"\n <div\n id={\"#{@id}-trigger\"}\n data-context-trigger\n data-slot=\"context-menu-trigger\"\n class={cn([\"block\", @trigger_class])}\n >\n {render_slot(@trigger)}\n </div>\n <div\n id={\"#{@id}-content\"}\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabindex=\"-1\"\n data-slot=\"context-menu-content\"\n phx-hook=\"ShadixContextMenu\"\n data-trigger={\"#{@id}-trigger\"}\n data-on-open={show_menu(@id)}\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 opens the menu with `id`.\n\n Shows the content panel with an enter transition. The `ShadixContextMenu` hook\n calls this through `data-on-open` after positioning, but it is also exported for\n composition.\n \"\"\"\n def show_menu(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 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 context_menu_item(assigns) do\n ~H\"\"\"\n <div\n role=\"menuitem\"\n tabindex=\"-1\"\n data-slot=\"context-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 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_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 </div>\n \"\"\"\n end\n\n @doc \"A non-interactive group label within a context menu.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def context_menu_label(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"context-menu-label\"\n class={cn([\"px-2 py-1.5 text-sm font-medium text-foreground\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"A horizontal separator between context menu sections.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n\n def context_menu_separator(assigns) do\n ~H\"\"\"\n <div\n role=\"separator\"\n data-slot=\"context-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 context menu item.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def context_menu_shortcut(assigns) do\n ~H\"\"\"\n <span\n data-slot=\"context-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": "context_menu.ex"
}
],
"hooks": [
{
"content": "import {\n computePosition,\n offset,\n flip,\n shift,\n autoUpdate,\n type VirtualElement,\n} from \"@floating-ui/dom\";\n\ninterface ContextMenuHook {\n el: HTMLElement;\n liveSocket: { execJS(el: HTMLElement, js: string): void };\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixContextMenu = {\n mounted(this: ContextMenuHook) {\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 // The pointer position is captured at contextmenu time and exposed to\n // Floating UI through a virtual element with a zero-size bounding rect.\n let pointer = { x: 0, y: 0 };\n\n const items = () =>\n Array.from(\n content.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not([aria-disabled=\"true\"])'),\n );\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 // Typeahead: typing printable characters moves focus to the next item whose\n // text starts with the accumulated query (matches base-ui's menu character\n // search and the WAI-ARIA menu pattern).\n let typeahead = \"\";\n let typeaheadTimer: number | undefined;\n const onTypeahead = (char: string) => {\n window.clearTimeout(typeaheadTimer);\n typeaheadTimer = window.setTimeout(() => {\n typeahead = \"\";\n }, 500);\n typeahead += char.toLowerCase();\n const list = items();\n const start = (indexOfActive() + 1) % (list.length || 1);\n // When repeating a single character, cycle through matches; otherwise\n // match from the item after the active one, wrapping around.\n const ordered = list.slice(start).concat(list.slice(0, start));\n const match = ordered.find((el) =>\n (el.textContent ?? \"\").trim().toLowerCase().startsWith(typeahead),\n );\n if (match) match.focus();\n };\n const closeJS = () => {\n const js = content.getAttribute(\"data-on-close\");\n if (js) this.liveSocket.execJS(content, js);\n };\n const openJS = () => {\n const js = content.getAttribute(\"data-on-open\");\n if (js) this.liveSocket.execJS(content, js);\n };\n\n const virtualEl: VirtualElement = {\n getBoundingClientRect: () => ({\n width: 0,\n height: 0,\n x: pointer.x,\n y: pointer.y,\n top: pointer.y,\n left: pointer.x,\n right: pointer.x,\n bottom: pointer.y,\n }),\n };\n\n const onKeydown = (e: KeyboardEvent) => {\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n focusItem(indexOfActive() + 1);\n break;\n case \"ArrowUp\":\n e.preventDefault();\n focusItem(indexOfActive() - 1);\n break;\n case \"Home\":\n e.preventDefault();\n focusItem(0);\n break;\n case \"End\":\n e.preventDefault();\n focusItem(items().length - 1);\n break;\n case \"Escape\":\n e.preventDefault();\n closeJS();\n break;\n case \"Tab\":\n closeJS();\n break;\n case \"Enter\":\n case \" \": {\n const active = document.activeElement as HTMLElement | null;\n if (active && items().includes(active)) {\n e.preventDefault();\n active.click();\n }\n break;\n }\n default:\n // Single printable character (ignore modifier combos) starts/extends\n // a typeahead search.\n if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {\n e.preventDefault();\n onTypeahead(e.key);\n }\n }\n };\n const onPointerDown = (e: Event) => {\n const t = e.target as Node;\n if (!content.contains(t)) closeJS();\n };\n\n const onContextMenu = (e: MouseEvent) => {\n e.preventDefault();\n pointer = { x: e.clientX, y: e.clientY };\n if (isOpen) {\n // Re-anchor an already-open menu to the new pointer position.\n stopAutoUpdate?.();\n startPositioning();\n } else {\n openJS();\n }\n };\n\n const startPositioning = () => {\n stopAutoUpdate = autoUpdate(virtualEl, content, () => {\n computePosition(virtualEl, content, {\n placement: \"right-start\",\n strategy: \"fixed\",\n middleware: [offset({ mainAxis: 2, alignmentAxis: 4 }), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => Object.assign(content.style, { left: `${x}px`, top: `${y}px` }));\n });\n };\n\n const openLogic = () => {\n isOpen = true;\n startPositioning();\n document.addEventListener(\"keydown\", onKeydown);\n document.addEventListener(\"pointerdown\", onPointerDown, true);\n window.requestAnimationFrame(() => focusItem(0));\n };\n const closeLogic = () => {\n isOpen = false;\n stopAutoUpdate?.();\n stopAutoUpdate = null;\n window.clearTimeout(typeaheadTimer);\n typeahead = \"\";\n document.removeEventListener(\"keydown\", onKeydown);\n document.removeEventListener(\"pointerdown\", onPointerDown, true);\n };\n\n trigger?.addEventListener(\"contextmenu\", onContextMenu);\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 trigger?.removeEventListener(\"contextmenu\", onContextMenu);\n if (isOpen) closeLogic();\n };\n },\n destroyed(this: ContextMenuHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixContextMenu",
"path": "context_menu.ts"
}
],
"name": "context_menu",
"npm_deps": [
"@floating-ui/dom"
],
"registry_deps": [
"cn"
]
}