@ozankurt/context-menu

Submenus

A row becomes a submenu simply by having items. Nest as deep as you like: Move to opens a panel that itself contains Projects. Where items is a function that returns a promise, as on Add label, the panel shows a loading state in the right place and swaps in the real list when it arrives.

Right click a message, then hover Move to

Inbox
No pending changes
Nesting
{
  label: 'Move to',
  items: [
    { label: 'Inbox' },
    { label: 'Archive' },
    {
      label: 'Projects',
      items: [
        { label: 'Northwind' },
        { label: 'Contoso' },
      ],
    },
  ],
}

A submenu passes its parent panel as the rectangle to avoid, so it flanks the whole panel rather than covering it, while still lining up with the row that opened it. Move the pointer diagonally towards an open submenu and the parent row keeps it open: the safe triangle covers the path your hand actually takes.

Loading from the server
{
  label: 'Add label',
  items: async (meta) => {
    const labels = await fetchLabels(meta.id)

    return labels.map((label) => ({
      type: 'checkbox',
      label: label.name,
      checked: label.applied,
    }))
  },
}

A resolver that settles immediately renders nothing extra. One that actually waits shows a loading panel first, and one that rejects shows an error panel and emits error rather than closing, because the user asked for this menu and telling them it failed beats making the right click look broken.