Skip to main content

priv/registry/menubar.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Menubar do\n  @moduledoc \"\"\"\n  A horizontal bar of menus, like a desktop application's menubar (File, Edit,\n  View, ...), built on client-side JS commands, Floating UI, and the\n  `ShadixMenubar` LiveView hook (assets/ts/menubar.ts).\n\n  Each menu is a trigger button (`role=\"menuitem\"`, `aria-haspopup=\"menu\"`)\n  paired with a Floating-UI-positioned content panel (`role=\"menu\"`). The\n  trigger toggles its panel with `toggle_menu/1` (enter/leave transitions); the\n  hook watches each panel's visibility with a `MutationObserver`, positions it\n  under its trigger with `@floating-ui/dom` (`computePosition` + `autoUpdate`,\n  `bottom-start`/`flip`/`shift`), and coordinates the bar as a whole.\n\n  Coordination handled by the hook:\n\n    * clicking a trigger opens that menu's content below it;\n    * `ArrowLeft`/`ArrowRight` move focus between menu triggers (roving),\n      wrapping at the ends;\n    * while any menu is open, focusing/hovering another trigger switches the\n      open menu to it;\n    * `ArrowUp`/`ArrowDown`/`Home`/`End` move between the open menu's items;\n    * `Escape` closes the open menu and returns focus to its trigger;\n    * a pointerdown outside the bar closes the open menu.\n\n  Simplifications for this v1 (noted per the brief):\n\n    * no submenus, checkbox/radio items, or item groups (shadcn's\n      `MenubarSub*`, `MenubarCheckboxItem`, `MenubarRadioItem`, `MenubarGroup`\n      are not ported);\n    * triggers do not auto-open on hover before the first click — a menu must be\n      opened by click (or keyboard) first, after which hovering siblings\n      switches between them, matching the shadcn \"menubar feels open once you\n      click\" behaviour.\n\n  Each menu's trigger and content ids are derived from the required `:id` so\n  `aria-controls`, `data-trigger`, and the JS targets line up stably. All menus\n  within a bar should share a common id prefix (e.g. `\"app-menubar-file\"`,\n  `\"app-menubar-edit\"`) and the bar carries the `phx-hook` so it can find them.\n  \"\"\"\n  use Phoenix.Component\n\n  alias Phoenix.LiveView.JS\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  Renders the horizontal menubar container.\n\n  Carries `role=\"menubar\"`, `data-slot=\"menubar\"`, and the `ShadixMenubar`\n  hook, which discovers the child `menubar_menu/1` triggers/contents and wires\n  up cross-menu keyboard navigation and open/close coordination. The inner block\n  holds one `menubar_menu/1` per top-level menu.\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 menubar(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      role=\"menubar\"\n      aria-orientation=\"horizontal\"\n      data-slot=\"menubar\"\n      phx-hook=\"ShadixMenubar\"\n      class={\n        cn([\n          \"flex h-9 items-center gap-1 rounded-md border bg-background p-1 shadow-xs\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Renders a single menu within the bar: a trigger button plus its content panel.\n\n  The trigger is a `<button role=\"menuitem\" aria-haspopup=\"menu\">` carrying\n  `data-slot=\"menubar-trigger\"`; clicking it toggles the content panel via\n  `toggle_menu/1`. The content is a `<div role=\"menu\">` carrying\n  `data-slot=\"menubar-content\"` and the `data-trigger`/`data-on-close` wiring the\n  `ShadixMenubar` hook uses to position and close it. The `:label` is the text\n  shown on the trigger; the inner block holds the menu items.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:label, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:content_class, :string, default: nil)\n  slot(:inner_block, required: true)\n\n  def menubar_menu(assigns) do\n    ~H\"\"\"\n    <button\n      type=\"button\"\n      id={\"#{@id}-trigger\"}\n      role=\"menuitem\"\n      data-slot=\"menubar-trigger\"\n      tabindex=\"-1\"\n      phx-click={toggle_menu(@id)}\n      aria-haspopup=\"menu\"\n      aria-expanded=\"false\"\n      aria-controls={\"#{@id}-content\"}\n      class={\n        cn([\n          \"flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none focus:bg-accent focus:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground\",\n          @class\n        ])\n      }\n    >\n      {@label}\n    </button>\n    <div\n      id={\"#{@id}-content\"}\n      role=\"menu\"\n      tabindex=\"-1\"\n      data-slot=\"menubar-content\"\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-[12rem] origin-top-left overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\",\n          @content_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  `ShadixMenubar` hook reacts to the visibility change to position the panel and\n  switch the bar's open menu.\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 opens the menu with `id`.\n\n  Shows the content panel with an enter transition. Used by the hook's\n  `data-on-open` when switching the open menu via arrow keys or hover.\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 / outside click / switching menus) and by menu items\n  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 that 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 menubar_item(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"menuitem\"\n      tabindex=\"-1\"\n      data-slot=\"menubar-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-hidden 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 menu.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def menubar_label(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"menubar-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 menu sections.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def menubar_separator(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"separator\"\n      data-slot=\"menubar-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 menu item.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def menubar_shortcut(assigns) do\n    ~H\"\"\"\n    <span\n      data-slot=\"menubar-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": "menubar.ex"
    }
  ],
  "hooks": [
    {
      "content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface MenubarHook {\n  el: HTMLElement;\n  liveSocket: { execJS(el: HTMLElement, js: string): void };\n  mounted(): void;\n  destroyed(): void;\n}\n\ninterface Menu {\n  trigger: HTMLElement;\n  content: HTMLElement;\n  // The trigger wrapper may be `display: contents`; anchor Floating UI to the\n  // first real child element when present, else the trigger itself.\n  anchor: HTMLElement;\n  stopAutoUpdate: (() => void) | null;\n  isOpen: boolean;\n  observer: MutationObserver;\n}\n\nexport const ShadixMenubar = {\n  mounted(this: MenubarHook) {\n    const bar = this.el;\n\n    const triggers = () =>\n      Array.from(bar.querySelectorAll<HTMLElement>('[data-slot=\"menubar-trigger\"]'));\n\n    const menus: Menu[] = triggers().map((trigger) => {\n      const content = document.getElementById(\n        trigger.getAttribute(\"aria-controls\") ?? \"\",\n      ) as HTMLElement;\n      const anchor = (trigger.firstElementChild as HTMLElement | null) ?? trigger;\n      return {\n        trigger,\n        content,\n        anchor,\n        stopAutoUpdate: null,\n        isOpen: false,\n        observer: new MutationObserver(() => {}),\n      };\n    });\n\n    const openMenu = (): Menu | undefined => menus.find((m) => m.isOpen);\n\n    const items = (menu: Menu) =>\n      Array.from(\n        menu.content.querySelectorAll<HTMLElement>(\n          '[role=\"menuitem\"]:not([aria-disabled=\"true\"])',\n        ),\n      );\n    const focusItem = (menu: Menu, i: number) => {\n      const list = items(menu);\n      if (list.length) list[(i + list.length) % list.length].focus();\n    };\n    const indexOfActiveItem = (menu: Menu) =>\n      items(menu).indexOf(document.activeElement as HTMLElement);\n\n    const closeJS = (menu: Menu) => {\n      const js = menu.content.getAttribute(\"data-on-close\");\n      if (js) this.liveSocket.execJS(menu.content, js);\n    };\n    const openJS = (menu: Menu) => {\n      const js = menu.content.getAttribute(\"data-on-open\");\n      if (js) this.liveSocket.execJS(menu.content, js);\n    };\n\n    // Roving tabindex: exactly one trigger is in the page tab order at a time\n    // (WAI-ARIA APG menubar pattern). Moving focus between triggers moves the\n    // single tabindex=\"0\" with it, so Tab reaches the bar and Shift+Tab leaves\n    // it as a whole.\n    const setRovingTabindex = (active: HTMLElement) => {\n      triggers().forEach((t) =>\n        t.setAttribute(\"tabindex\", t === active ? \"0\" : \"-1\"),\n      );\n    };\n    const focusTrigger = (i: number) => {\n      const t = triggers();\n      if (!t.length) return;\n      const next = t[(i + t.length) % t.length];\n      setRovingTabindex(next);\n      next.focus();\n    };\n    const triggerIndex = (menu: Menu) => triggers().indexOf(menu.trigger);\n\n    const onKeydown = (e: KeyboardEvent) => {\n      const target = e.target as HTMLElement;\n      const onTrigger = menus.find((m) => m.trigger === target);\n      const open = openMenu();\n\n      // Roving navigation between triggers with Left/Right, anywhere in the bar.\n      if (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") {\n        const ref = onTrigger ?? open;\n        if (ref) {\n          e.preventDefault();\n          const next = triggerIndex(ref) + (e.key === \"ArrowRight\" ? 1 : -1);\n          if (open) {\n            // Switch the open menu to the next trigger.\n            closeJS(open);\n            const t = triggers();\n            const target = t[(next + t.length) % t.length];\n            const nextMenu = menus.find((m) => m.trigger === target);\n            setRovingTabindex(target);\n            target.focus();\n            if (nextMenu) openJS(nextMenu);\n          } else {\n            focusTrigger(next);\n          }\n        }\n        return;\n      }\n\n      if (onTrigger) {\n        if (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          if (!onTrigger.isOpen) openJS(onTrigger);\n          window.requestAnimationFrame(() => focusItem(onTrigger, 0));\n        }\n        return;\n      }\n\n      if (!open) return;\n\n      switch (e.key) {\n        case \"ArrowDown\":\n          e.preventDefault();\n          focusItem(open, indexOfActiveItem(open) + 1);\n          break;\n        case \"ArrowUp\":\n          e.preventDefault();\n          focusItem(open, indexOfActiveItem(open) - 1);\n          break;\n        case \"Home\":\n          e.preventDefault();\n          focusItem(open, 0);\n          break;\n        case \"End\":\n          e.preventDefault();\n          focusItem(open, items(open).length - 1);\n          break;\n        case \"Escape\":\n          e.preventDefault();\n          open.trigger.focus();\n          closeJS(open);\n          break;\n        case \"Tab\":\n          closeJS(open);\n          break;\n        case \"Enter\":\n        case \" \": {\n          const active = document.activeElement as HTMLElement | null;\n          if (active && items(open).includes(active)) {\n            e.preventDefault();\n            active.click();\n          }\n          break;\n        }\n      }\n    };\n\n    const onPointerDown = (e: Event) => {\n      const t = e.target as Node;\n      const open = openMenu();\n      if (open && !bar.contains(t) && !open.content.contains(t)) closeJS(open);\n    };\n\n    // When a menu is already open, hovering/focusing another trigger switches\n    // to it.\n    const onPointerOver = (e: Event) => {\n      const open = openMenu();\n      if (!open) return;\n      const t = e.target as HTMLElement;\n      const hovered = menus.find((m) => m.trigger.contains(t));\n      if (hovered && hovered !== open) {\n        closeJS(open);\n        openJS(hovered);\n      }\n    };\n\n    const positionFor = (menu: Menu) => {\n      menu.stopAutoUpdate = autoUpdate(menu.anchor, menu.content, () => {\n        computePosition(menu.anchor, menu.content, {\n          placement: \"bottom-start\",\n          strategy: \"fixed\",\n          middleware: [offset(4), flip(), shift({ padding: 8 })],\n        }).then(({ x, y }) =>\n          Object.assign(menu.content.style, { left: `${x}px`, top: `${y}px` }),\n        );\n      });\n    };\n\n    const openLogic = (menu: Menu) => {\n      menu.isOpen = true;\n      menu.trigger.setAttribute(\"aria-expanded\", \"true\");\n      positionFor(menu);\n    };\n    const closeLogic = (menu: Menu) => {\n      menu.isOpen = false;\n      menu.trigger.setAttribute(\"aria-expanded\", \"false\");\n      menu.stopAutoUpdate?.();\n      menu.stopAutoUpdate = null;\n    };\n\n    menus.forEach((menu) => {\n      const visible = () => getComputedStyle(menu.content).display !== \"none\";\n      menu.observer = new MutationObserver(() => {\n        const v = visible();\n        if (v && !menu.isOpen) openLogic(menu);\n        else if (!v && menu.isOpen) closeLogic(menu);\n      });\n      menu.observer.observe(menu.content, {\n        attributes: true,\n        attributeFilter: [\"style\", \"class\"],\n      });\n    });\n\n    // Keep the roving tab stop on whichever trigger currently has focus.\n    const onFocusIn = (e: FocusEvent) => {\n      const t = e.target as HTMLElement;\n      const focused = menus.find((m) => m.trigger === t);\n      if (focused) setRovingTabindex(focused.trigger);\n    };\n\n    // Seed the single tab stop on the first trigger so the bar is reachable\n    // with Tab before any interaction.\n    const first = triggers()[0];\n    if (first) setRovingTabindex(first);\n\n    document.addEventListener(\"keydown\", onKeydown);\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    bar.addEventListener(\"pointerover\", onPointerOver);\n    bar.addEventListener(\"focusin\", onFocusIn);\n\n    (bar as unknown as { _cleanup?: () => void })._cleanup = () => {\n      document.removeEventListener(\"keydown\", onKeydown);\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      bar.removeEventListener(\"pointerover\", onPointerOver);\n      bar.removeEventListener(\"focusin\", onFocusIn);\n      menus.forEach((menu) => {\n        menu.observer.disconnect();\n        if (menu.isOpen) closeLogic(menu);\n      });\n    };\n  },\n  destroyed(this: MenubarHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixMenubar",
      "path": "menubar.ts"
    }
  ],
  "name": "menubar",
  "npm_deps": [
    "@floating-ui/dom"
  ],
  "registry_deps": [
    "cn"
  ]
}