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/vanillaPin @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
Back
Forward
Reload
Show Bookmarks
More Tools
Save Page As…
Keyboard Shortcuts
render Components::UI::ContextMenu.new do
render Components::UI::ContextMenuTrigger.new(
class: "flex items-center justify-center w-64 h-24 rounded-lg border-2 border-dashed " "border-muted-foreground/40 text-sm text-muted-foreground select-none"
) { "Right-click here" }
render Components::UI::ContextMenuContent.new do
render Components::UI::ContextMenuItem.new(value: "back") { "Back" }
render Components::UI::ContextMenuItem.new(value: "forward") { "Forward" }
render Components::UI::ContextMenuItem.new(value: "reload") { "Reload" }
render Components::UI::ContextMenuSeparator.new
render Components::UI::ContextMenuCheckboxItem.new(value: "bookmarks", checked: true) { "Show Bookmarks" }
render Components::UI::ContextMenuSeparator.new
render Components::UI::ContextMenuSub.new do
render Components::UI::ContextMenuSubTrigger.new(value: "more") { "More Tools" }
render Components::UI::ContextMenuSubContent.new do
render Components::UI::ContextMenuItem.new(value: "save") { "Save Page As…" }
render Components::UI::ContextMenuItem.new(value: "shortcuts") { "Keyboard Shortcuts" }
end
end
end
end
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.
Light
Dark
System
render Components::UI::ContextMenu.new do
render Components::UI::ContextMenuTrigger.new(
class: "flex items-center justify-center w-64 h-24 rounded-lg border-2 border-dashed " "border-muted-foreground/40 text-sm text-muted-foreground select-none"
) { "Right-click here" }
render Components::UI::ContextMenuContent.new do
render Components::UI::ContextMenuLabel.new { "Theme" }
render Components::UI::ContextMenuRadioGroup.new(name: "theme", value: "system", aria_label: "Theme") do
render Components::UI::ContextMenuRadioItem.new(value: "light", name: "theme") { "Light" }
render Components::UI::ContextMenuRadioItem.new(value: "dark", name: "theme") { "Dark" }
render Components::UI::ContextMenuRadioItem.new(value: "system", name: "theme", checked: true) { "System" }
end
end
end
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.