Skip to main content

priv/registry/context_menu.json

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