ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Context Menu

Right-click context menu — full menu parity (items, checkbox/radio, submenus) opening at the cursor.

Installation

bin/rails g wabi:add context_menu
bin/importmap pin @zag-js/menu @zag-js/vanilla

Pin @zag-js/menu and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.

Example

Radio group

Wrap mutually-exclusive options in a ContextMenuRadioGroup. Selecting one item unselects its siblings; pass aria_label: to name the group for screen readers.

Source

app/components/ui/context_menu.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ContextMenu < Wabi::Base
      def initialize(id: nil, open: false, portal: true, **attrs)
        @id     = id
        @open   = open
        @portal = portal
        @attrs  = attrs
      end

      def view_template(&block)
        div(
          id: @id,
          class: "inline-block",
          data: {
            controller: "wabi--context-menu",
            "wabi--context-menu-open-value":   @open.to_s,
            "wabi--context-menu-portal-value": @portal.to_s,
          }
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ContextMenuTrigger < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        button(
          type: "button",
          data: { "wabi--context-menu-target": "trigger" },
          class: user_class
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_content.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ContextMenuContent < Wabi::Base
      variants do
        base "z-50 min-w-[8rem] overflow-hidden rounded-md border border-input bg-popover p-1 " \
             "text-popover-foreground shadow-md outline-none " \
             "transition-opacity duration-150 ease-out motion-reduce:transition-none " \
             "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
             "data-[state=closed]:pointer-events-none"
      end

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--context-menu-target": "positioner" },
          class: "z-50 pointer-events-none"
        ) do
          div(
            data: { "wabi--context-menu-target": "content" },
            "data-state": "closed",
            inert: true,
            class: merge_class(tokens, user_class)
          ) do
            yield if block_given?
          end
        end
      end
    end
  end
end

app/components/ui/context_menu_item.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ContextMenuItem < Wabi::Base
      variants do
        base "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 " \
             "text-sm outline-none transition-colors " \
             "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
             "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
      end

      # `value` identifies the item in the Zag menu state machine; it is what
      # the `onSelect({ value })` callback receives, and what determines
      # highlight/selection.
      def initialize(value:, disabled: false, **attrs)
        @value    = value
        @disabled = disabled
        @attrs    = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        user_data  = @attrs.delete(:data) || {}
        div(
          role: "menuitem",
          # User data first, component data second -- component keys win on
          # collision so callers can freely add things like
          # `data: { action: "click->...", foo: "bar" }` without
          # overriding the wabi target / value attributes the controller
          # relies on.
          data: {
            **user_data,
            "wabi--context-menu-target": "item",
            "wabi-value": @value,
            "wabi-disabled": @disabled.to_s,
          },
          class: merge_class(tokens, user_class)
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_label.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ContextMenuLabel < Wabi::Base
      variants { base "px-2 py-1.5 text-sm font-semibold" }

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        # aria-hidden: "true" marks this label as decorative so screen readers
        # skip it rather than announcing an unlabelled group boundary. If you
        # need AT to announce the group name, pair this element's id with a
        # surrounding <div role="group" aria-labelledby="<id>"> instead.
        div(aria_hidden: "true", class: merge_class(tokens, user_class)) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_separator.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class ContextMenuSeparator < Wabi::Base
      variants { base "-mx-1 my-1 h-px bg-muted" }

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        div(role: "separator", class: merge_class(tokens, user_class))
      end
    end
  end
end

app/components/ui/context_menu_shortcut.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Cosmetic helper for displaying a keyboard shortcut at the right edge of a
    # menu item. The shortcut is purely visual -- wiring the actual keypress
    # handler is the caller's responsibility (Stimulus action or Hotkey lib).
    class ContextMenuShortcut < Wabi::Base
      variants { base "ml-auto text-xs tracking-widest text-muted-foreground" }

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        span(aria: { hidden: "true" }, class: merge_class(tokens, user_class)) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_checkbox_item.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Toggle-style menu item. Renders with `role="menuitemcheckbox"` and toggles
    # its own `data-state`/`aria-checked` on click via the parent
    # `wabi--context-menu` controller (no extra Stimulus action wiring needed).
    class ContextMenuCheckboxItem < Wabi::Base
      variants do
        base "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 " \
             "text-sm outline-none transition-colors " \
             "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
             "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
      end

      def initialize(value:, checked: false, disabled: false, **attrs)
        @value    = value
        @checked  = checked
        @disabled = disabled
        @attrs    = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          role: "menuitemcheckbox",
          "data-state": @checked ? "checked" : "unchecked",
          "aria-checked": @checked.to_s,
          data: {
            "wabi--context-menu-target": "optionItem",
            "wabi-value":   @value,
            "wabi-type":    "checkbox",
            "wabi-checked": @checked.to_s,
            "wabi-disabled": @disabled.to_s,
          },
          class: merge_class(tokens, user_class)
        ) do
          # Indicator (checkmark). Shown when data-state=checked; the controller
          # toggles `hidden` directly on this span each render. Could also be
          # done via Tailwind 4 group-data variants -- explicit hidden toggle
          # keeps the controller logic uniform.
          span(
            data: { "wabi--context-menu-target": "optionItemIndicator" },
            hidden: !@checked,
            class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center"
          ) do
            raw(safe('<svg aria-hidden="true" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'))
          end
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_radio_group.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Wraps RadioItems so that selecting one auto-unselects the others within
    # the same group. `name` identifies the group; each child RadioItem inherits
    # this `name` via `<div role="group">` ancestry (the controller reads
    # `data-wabi-name` from the radio item ancestor).
    class ContextMenuRadioGroup < Wabi::Base
      # aria_label: optional accessible name for the radio group. When provided,
      # aria-label is added to the group div so screen readers announce the
      # group name (e.g. aria_label: "Theme"). Alternatively, pair a ContextMenuLabel
      # id with aria-labelledby on this element for a visible label association.
      def initialize(name:, value: nil, aria_label: nil, **attrs)
        @name       = name
        @value      = value
        @aria_label = aria_label
        @attrs      = attrs
      end

      def view_template(&block)
        div(
          role: "group",
          aria_label: @aria_label,
          data: {
            "wabi--context-menu-target": "radioGroup",
            "wabi-name":  @name,
            "wabi-value": @value.to_s,
          }
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_radio_item.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Mutually-exclusive menu item. Renders with `role="menuitemradio"` and
    # registers itself with its enclosing `ContextMenuRadioGroup` via the
    # `name` argument -- selecting one radio in the same group automatically
    # unselects the rest. Initial selection comes from the RadioGroup's
    # `value:` matching this item's `value:`.
    class ContextMenuRadioItem < Wabi::Base
      variants do
        base "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 " \
             "text-sm outline-none transition-colors " \
             "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
             "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
      end

      def initialize(value:, name:, checked: false, disabled: false, **attrs)
        @value    = value
        @name     = name
        @checked  = checked
        @disabled = disabled
        @attrs    = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        user_data  = @attrs.delete(:data) || {}
        div(
          role: "menuitemradio",
          "data-state": @checked ? "checked" : "unchecked",
          "aria-checked": @checked.to_s,
          # User data first, component data second -- component keys win on
          # collision so callers can add `data: { action: "click->..." }`
          # without clobbering the wabi target attributes the controller
          # relies on for state routing.
          data: {
            **user_data,
            "wabi--context-menu-target": "optionItem",
            "wabi-value":    @value,
            "wabi-name":     @name,
            "wabi-type":     "radio",
            "wabi-checked":  @checked.to_s,
            "wabi-disabled": @disabled.to_s,
          },
          class: merge_class(tokens, user_class)
        ) do
          span(
            data: { "wabi--context-menu-target": "optionItemIndicator" },
            hidden: !@checked,
            class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center"
          ) do
            raw(safe('<svg aria-hidden="true" class="h-2 w-2 fill-current" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3"/></svg>'))
          end
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_sub.rb

# frozen_string_literal: true

require "date"
require "securerandom"

module Components
  module UI
    # Submenu wrapper. Marks a nested-menu boundary; the parent
    # `wabi--context-menu` controller creates a child Zag menu machine
    # for each `sub` target it finds and wires parent.setChild /
    # child.setParent. The `class: "contents"` makes this wrapper invisible
    # to CSS layout so the sub-trigger renders inline with its menu
    # siblings and the sub-content floats independently.
    #
    # Each instance gets a unique `data-wabi-sub-id` so the controller
    # can index machines by id across arbitrary nesting depth (N-level).
    class ContextMenuSub < Wabi::Base
      def initialize(**attrs)
        @sub_id = "sub-#{SecureRandom.uuid}"
        @attrs  = attrs
      end

      def view_template(&block)
        div(
          data: {
            "wabi--context-menu-target": "sub",
            "wabi-sub-id": @sub_id,
          },
          class: "contents"
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/context_menu_sub_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Sub-menu trigger: an item in the parent menu that ALSO opens a
    # submenu on hover / arrow-right. The parent controller applies
    # `parentApi.getTriggerItemProps(childApi)` to merge item + trigger
    # behavior. Rotates via `data-state=open` styling -- Zag toggles the
    # data attribute synchronously on hover.
    class ContextMenuSubTrigger < Wabi::Base
      variants do
        base "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 " \
             "text-sm outline-none transition-colors " \
             "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground " \
             "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground " \
             "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
      end

      def initialize(value:, disabled: false, **attrs)
        @value    = value
        @disabled = disabled
        @attrs    = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          role: "menuitem",
          "aria-haspopup": "menu",
          data: {
            "wabi--context-menu-target": "subTrigger",
            "wabi-value":    @value,
            "wabi-disabled": @disabled.to_s,
          },
          class: merge_class(tokens, user_class)
        ) do
          yield if block_given?
          raw(safe('<svg aria-hidden="true" class="ml-auto h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>'))
        end
      end
    end
  end
end

app/components/ui/context_menu_sub_content.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Floating sub-content. Mirrors ContextMenuContent's shape: a positioner
    # wrapper + a content child. The parent controller routes Zag props onto
    # `subPositioner` / `subContent` instead of `positioner` / `content` so
    # the parent menu's own content stays untouched.
    class ContextMenuSubContent < Wabi::Base
      variants do
        base "z-50 min-w-[8rem] overflow-hidden rounded-md border border-input bg-popover p-1 " \
             "text-popover-foreground shadow-md outline-none " \
             "transition-opacity duration-150 ease-out motion-reduce:transition-none " \
             "data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
             "data-[state=closed]:pointer-events-none"
      end

      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        div(
          data: { "wabi--context-menu-target": "subPositioner" },
          class: "z-50 pointer-events-none"
        ) do
          div(
            data: { "wabi--context-menu-target": "subContent" },
            "data-state": "closed",
            inert: true,
            class: merge_class(tokens, user_class)
          ) do
            yield if block_given?
          end
        end
      end
    end
  end
end

app/javascript/controllers/wabi/context_menu_controller.js

import { Controller } from "@hotwired/stimulus"
import * as menu from "@zag-js/menu"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"
import { capturePortalRefs, attachToBody, restoreFromBody } from "controllers/wabi/_shared/overlay_portal"

// Single controller owns the parent menu machine AND a child machine per
// `sub` boundary. Nested same-type controllers would collide on Stimulus
// target scoping (a target inside a child controller is hidden from the
// parent), so all sub machinery lives here. Item / option-item targets are
// routed to the right machine via closest("[...-target='sub']").
//
// N-level nesting (v0.7): each `sub` boundary carries a unique
// data-wabi-sub-id. The controller starts every sub machine, then links
// each to its parent  the closest ANCESTOR sub (walked via the DOM), or
// the root menu when there is no ancestor sub  by chaining Zag's
// setChild/setParent. This models a chain instead of a star, so a
// sub-inside-a-sub works to arbitrary depth.
export default class extends Controller {
  static targets = [
    "trigger", "positioner", "content", "item", "optionItem", "optionItemIndicator",
    "sub", "subTrigger", "subPositioner", "subContent",
  ]
  static values  = {
    open:   { type: Boolean, default: false },
    portal: { type: Boolean, default: true  },
  }

  connect() {
    capturePortalRefs(this)
    this.triggerEl = this.hasTriggerTarget ? this.triggerTarget : null

    // In-content targets captured before move.
    this.itemEls                = this.contentEl ? Array.from(this.contentEl.querySelectorAll('[data-wabi--context-menu-target="item"]')) : []
    this.optionItemEls          = this.contentEl ? Array.from(this.contentEl.querySelectorAll('[data-wabi--context-menu-target="optionItem"]')) : []
    this.optionItemIndicatorEls = this.contentEl ? Array.from(this.contentEl.querySelectorAll('[data-wabi--context-menu-target="optionItemIndicator"]')) : []
    this.subTriggerEls          = this.contentEl ? Array.from(this.contentEl.querySelectorAll('[data-wabi--context-menu-target="subTrigger"]')) : []
    this.subEls                 = this.contentEl ? Array.from(this.contentEl.querySelectorAll('[data-wabi--context-menu-target="sub"]')) : []

    // Sub portal nodes: collect content/positioner per sub by index.
    this.subContentEls    = []
    this.subPositionerEls = []
    this.subEls.forEach((subEl, idx) => {
      this.subContentEls[idx]    = subEl.querySelector("[data-wabi--context-menu-target='subContent']")
      this.subPositionerEls[idx] = subEl.querySelector("[data-wabi--context-menu-target='subPositioner']")
    })

    this.portaled = this.portalValue
    if (this.portaled) attachToBody(this)

    this.machine = new VanillaMachine(menu.machine, {
      id: this.element.id || crypto.randomUUID(),
      defaultOpen: this.openValue,
      onSelect: ({ value }) => {
        this.handleOptionToggle(value)
        this.dispatch("select", { detail: { value } })
      },
      onOpenChange: ({ open }) => {
        this.openValue = open
        if (this.contentEl) {
          if (open) this.contentEl.removeAttribute("inert")
          else      this.contentEl.setAttribute("inert", "")
        }
        this.dispatch("change", { detail: { open } })
      },
    })

    // Build a sub machine per sub boundary. Tag each `sub` element with its
    // index so render() can route items/option-items inside it.
    // Each sub also carries a unique `data-wabi-sub-id` (set by ContextMenuSub)
    // so we can look machines up by DOM element across arbitrary nesting depth.
    this.subMachines = []
    this.subMachineBySubId = {}
    this.subEls.forEach((subEl, idx) => {
      subEl.dataset.wabiSubIndex = String(idx)
      const subMachine = new VanillaMachine(menu.machine, {
        id: crypto.randomUUID(),
        onSelect: ({ value }) => {
          this.handleOptionToggle(value)
          this.dispatch("select", { detail: { value } })
        },
        onOpenChange: ({ open }) => {
          const subContEl = this.subContentEls[idx]
          if (subContEl) {
            if (open) subContEl.removeAttribute("inert")
            else      subContEl.setAttribute("inert", "")
          }
        },
      })
      this.subMachines.push(subMachine)
      // Index by the stable sub-id so parent-lookup by DOM walk is O(1).
      const subId = subEl.dataset.wabiSubId
      if (subId) this.subMachineBySubId[subId] = subMachine
    })

    // Start everything before wiring parent <-> child so both services exist.
    this.unsubscribe = this.machine.subscribe(() => this.render())
    this.machine.start()
    this.subUnsubscribes = this.subMachines.map((sub) => sub.subscribe(() => this.render()))
    this.subMachines.forEach((sub) => sub.start())

    // setChild / setParent take MenuService (NOT api). Wire after start.
    //
    // N-level chain: each sub walks up the DOM to find its closest ancestor
    // `[data-wabi--context-menu-target="sub"]`. If found, that ancestor sub's
    // machine is the parent; otherwise the root machine is the parent.
    // This generalises the flat star topology (all subs  root) to an
    // arbitrarily-deep chain (sub-inside-sub-inside-sub).
    this.subEls.forEach((subEl, idx) => {
      const subMachine = this.subMachines[idx]
      if (!subMachine) return

      const parentMachine = this._parentMachineFor(subEl)
      const parentApi = menu.connect(parentMachine.service, normalizeProps)
      parentApi.setChild(subMachine.service)
      const subApi = menu.connect(subMachine.service, normalizeProps)
      subApi.setParent(parentMachine.service)
    })

    this.render()
  }

  disconnect() {
    this.unsubscribe?.()
    this.machine?.stop()
    // Stop sub machines BEFORE portal cleanup so any final onOpenChange
    // from a stopping sub doesn't race the body-DOM restore.
    this.subUnsubscribes?.forEach((unsub) => unsub?.())
    this.subMachines?.forEach((sub) => sub.stop())
    this.subEls?.forEach((subEl) => delete subEl.dataset.wabiSubIndex)
    if (this.portaled) {
      restoreFromBody(this)
    }
  }

  // Closest ancestor sub element for a given sub (null = root level).
  _parentSubElFor(subEl) {
    return subEl.parentElement?.closest("[data-wabi--context-menu-target='sub']") || null
  }

  // The machine that owns a given sub: its ancestor sub's machine, or root.
  _parentMachineFor(subEl) {
    const ancestorSubEl = this._parentSubElFor(subEl)
    if (!ancestorSubEl) return this.machine
    const id = ancestorSubEl.dataset.wabiSubId
    return (id && this.subMachineBySubId[id]) || this.machine
  }

  // Toggles the data-wabi-checked attribute on checkbox/radio option items
  // so the next render() picks up the new checked state. Zag's menu machine
  // doesn't own this state -- callers wire it via onSelect.
  handleOptionToggle(value) {
    if (!this.optionItemEls.length) return
    const target = this.optionItemEls.find((el) => el.dataset.wabiValue === value)
    if (!target) return
    const type = target.dataset.wabiType
    if (type === "checkbox") {
      target.dataset.wabiChecked = target.dataset.wabiChecked === "true" ? "false" : "true"
    } else if (type === "radio") {
      const name = target.dataset.wabiName
      this.optionItemEls
        .filter((el) => el.dataset.wabiType === "radio" && el.dataset.wabiName === name)
        .forEach((el) => { el.dataset.wabiChecked = (el === target ? "true" : "false") })
    }
  }

  // Returns the API for the machine that owns this DOM element (parent menu
  // or one of the sub menus, by closest sub ancestor).
  apiFor(el) {
    const subEl = el.closest("[data-wabi--context-menu-target='sub']")
    if (subEl) {
      const idx = parseInt(subEl.dataset.wabiSubIndex, 10)
      const subMachine = this.subMachines[idx]
      if (subMachine) return menu.connect(subMachine.service, normalizeProps)
    }
    return menu.connect(this.machine.service, normalizeProps)
  }

  render() {
    const api = menu.connect(this.machine.service, normalizeProps)
    // Context menu opens on right-click / long-press via getContextTriggerProps
    // (anchors at the pointer position instead of the trigger element).
    if (this.triggerEl)    spreadProps(this.triggerEl,    api.getContextTriggerProps())
    if (this.positionerEl) spreadProps(this.positionerEl, api.getPositionerProps())
    if (this.contentEl) {
      spreadProps(this.contentEl, api.getContentProps())
      this.contentEl.hidden = false
    }

    // Regular menuitem items, routed by closest sub ancestor.
    this.itemEls.forEach((el) => {
      spreadProps(el, this.apiFor(el).getItemProps({
        value:    el.dataset.wabiValue,
        disabled: el.dataset.wabiDisabled === "true",
      }))
    })

    // Option items (checkbox / radio), routed by closest sub ancestor.
    this.optionItemEls.forEach((el) => {
      const value   = el.dataset.wabiValue
      const type    = el.dataset.wabiType    // "checkbox" | "radio"
      const checked = el.dataset.wabiChecked === "true"
      spreadProps(el, this.apiFor(el).getOptionItemProps({
        type, value, checked,
        disabled: el.dataset.wabiDisabled === "true",
      }))
    })

    // Option-item indicators: hidden mirrors the ancestor's data-wabi-checked.
    this.optionItemIndicatorEls.forEach((indicator) => {
      const parent = indicator.closest("[data-wabi--context-menu-target='optionItem']")
      indicator.hidden = !(parent && parent.dataset.wabiChecked === "true")
    })

    // Sub triggers: parentApi.getTriggerItemProps(childApi) merges parent
    // getItemProps + child getTriggerProps so the same element acts as
    // both an item in the parent menu AND the trigger for the submenu.
    //
    // For N-level nesting the "parent" of this sub-trigger is NOT always the
    // root menu — it is the machine that owns the menu containing this trigger.
    // We determine that by finding the closest ancestor sub element above the
    // sub boundary that encloses this trigger, then looking up that machine.
    // If no ancestor sub exists, the root machine is the owner.
    this.subTriggerEls.forEach((el) => {
      const subEl = el.closest("[data-wabi--context-menu-target='sub']")
      if (!subEl) return
      const idx = parseInt(subEl.dataset.wabiSubIndex, 10)
      const subMachine = this.subMachines[idx]
      if (!subMachine) return
      const subApi = menu.connect(subMachine.service, normalizeProps)

      // Find the parent machine: walk above subEl to the closest ancestor sub.
      const ownerMachine = this._parentMachineFor(subEl)
      const ownerApi = menu.connect(ownerMachine.service, normalizeProps)
      spreadProps(el, ownerApi.getTriggerItemProps(subApi))
    })

    // Sub positioner + content per sub.
    this.subEls.forEach((subEl, idx) => {
      const subMachine = this.subMachines[idx]
      if (!subMachine) return
      const subApi = menu.connect(subMachine.service, normalizeProps)
      const subPosEl  = this.subPositionerEls[idx]
      const subContEl = this.subContentEls[idx]
      if (subPosEl)  spreadProps(subPosEl,  subApi.getPositionerProps())
      if (subContEl) {
        spreadProps(subContEl, subApi.getContentProps())
        subContEl.hidden = false
      }
    })
  }
}

Accessibility

  • role="menu" + role="menuitem" anatomy with aria-haspopup for nested triggers.
  • Opens on right-click (contextmenu event); keyboard: ↑/↓ between items, → opens submenu, ← / Esc closes.
  • Type-ahead jumps to the first item starting with the typed character.
  • Content carries inert when closed — out of tab order and accessibility tree (Zag onOpenChange toggle).
  • Checkbox items use role="menuitemcheckbox" with aria-checked toggled by the controller.
  • Radio items use role="menuitemradio" within a role="group"; selecting one unselects siblings in the group.