Context Menu

v0.0.10

v0.0.10StableHeadlessStatelessAccessibleJSApache-2.0
Installation mix mishka.ui.gen.headless context_menu
Source View Source

Overview of the Phoenix and LiveView Headless Context Menu Component

The Mishka Chelekom Headless Context Menu ships zero opinionated styling - only the semantic markup, full WAI-ARIA wiring, and paired-presence state (data-open/data-closed). Right-click or long-press the trigger area to open the popup at the cursor, clamped to the viewport. It is composed from per-row function components - item, checkbox item, radio group / radio item, link item, separator, group, and submenu - so you can either use the idiomatic :item / :submenu slots for flat rows or compose the context_menu_* row components as children for arbitrary order and nested submenus - even mix both. Every part is addressable through a *_class attribute, so you style it entirely with your own Tailwind classes while the accessibility and behavior keep working underneath.

The root takes class, trigger_class and popup_class. For the rows its :item and :submenu slots draw, it also takes item_class (every row, and the trigger of a submenu), separator_class, group_label_class, indicator_class (the check and the dot), chevron_class, submenu_class and submenu_popup_class. A slot's own class is added after them.

Base UI Context Menu example - flat items (Phoenix headless)

Base UI Context Menu example - flat items (Phoenix headless)

Right click here
heex
<.context_menu
  id="baseui-context_menu-hero"
  trigger_class="flex h-[12rem] w-[15rem] items-center justify-center rounded-none border border-neutral-950 bg-white text-neutral-950 select-none font-normal dark:border-white dark:bg-neutral-950 dark:text-white"
  popup_class="origin-[var(--transform-origin)] border border-neutral-950 bg-white py-1 text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 outline-hidden transition-[scale,opacity] duration-100 ease-out data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none"
  item_class="flex cursor-default py-2 pr-8 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400"
  separator_class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white"
>
  <:trigger>Right click here</:trigger>
  <:item>Add to Library</:item>
  <:item>Add to Playlist</:item>
  <:item type="separator" />
  <:item>Play Next</:item>
  <:item>Play Last</:item>
  <:item type="separator" />
  <:item>Favorite</:item>
  <:item>Share</:item>
</.context_menu>

Base UI Context Menu example - nested submenu (Phoenix headless)

Base UI Context Menu example - nested submenu (Phoenix headless)

Right click here
heex
<.context_menu
  id="baseui-context_menu-submenu"
  trigger_class="flex h-[12rem] w-[15rem] items-center justify-center rounded-none border border-neutral-950 bg-white text-neutral-950 select-none font-normal dark:border-white dark:bg-neutral-950 dark:text-white"
  popup_class="origin-[var(--transform-origin)] border border-neutral-950 bg-white py-1 text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 outline-hidden transition-[scale,opacity] duration-100 ease-out data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none"
>
  <:trigger>Right click here</:trigger>
  <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
    Add to Library
  </.context_menu_item>
  <.context_menu_submenu
    id="baseui-context_menu-submenu-playlist"
    label="Add to Playlist"
    trigger_class="flex cursor-default items-center justify-between gap-4 py-2 pr-2 pl-4 text-sm leading-4 outline-hidden select-none data-[popup-open]:relative data-[popup-open]:z-0 data-[popup-open]:before:absolute data-[popup-open]:before:inset-x-1 data-[popup-open]:before:inset-y-0 data-[popup-open]:before:z-[-1] data-[popup-open]:before:bg-neutral-100 data-[popup-open]:before:content-[''] data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[highlighted]:data-[popup-open]:before:bg-neutral-950 data-[disabled]:text-neutral-500 dark:data-[popup-open]:before:bg-neutral-800 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[highlighted]:data-[popup-open]:before:bg-white dark:data-[disabled]:text-neutral-400"
    popup_class="origin-[var(--transform-origin)] border border-neutral-950 bg-white py-1 text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 outline-hidden transition-[scale,opacity] duration-100 ease-out data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none"
  >
    <:chevron><.icon name="hero-play-solid" class="size-3" /></:chevron>
    <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
      Get Up!
    </.context_menu_item>
    <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
      Inside Out
    </.context_menu_item>
    <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
      Night Beats
    </.context_menu_item>
    <.context_menu_separator class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white" />
    <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
      New playlist…
    </.context_menu_item>
  </.context_menu_submenu>
  <.context_menu_separator class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white" />
  <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
    Play Next
  </.context_menu_item>
  <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
    Play Last
  </.context_menu_item>
  <.context_menu_separator class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white" />
  <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
    Favorite
  </.context_menu_item>
  <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
    Share
  </.context_menu_item>
</.context_menu>

Context Menu example - labels, checked rows and a submenu (Phoenix headless)

The rows come from the :item slot, so the root's class attributes style them. item_class styles every row and the submenu trigger, group_label_class the two labels, indicator_class the check and the dot, and separator_class the line. chevron_class, submenu_class and submenu_popup_class style the arrow, the wrapper and the popup of the :submenu slot. The rows inside that slot are context_menu_item components, which take their own class.

Context Menu example - labels, checked rows and a submenu (Phoenix headless)

Right click here
heex
<.context_menu
  id="context-menu-slots"
  trigger_class="flex h-[9rem] w-[15rem] items-center justify-center rounded-none border border-neutral-950 bg-white text-neutral-950 select-none font-normal dark:border-white dark:bg-neutral-950 dark:text-white"
  popup_class="origin-[var(--transform-origin)] border border-neutral-950 bg-white py-1 text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 outline-hidden transition-[scale,opacity] duration-100 ease-out data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none"
  item_class="relative flex items-center py-2 pr-6 pl-8 text-sm leading-4 outline-hidden select-none data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[popup-open]:z-0 data-[popup-open]:before:absolute data-[popup-open]:before:inset-x-1 data-[popup-open]:before:inset-y-0 data-[popup-open]:before:z-[-1] data-[popup-open]:before:bg-neutral-100 data-[popup-open]:before:content-[''] data-[highlighted]:data-[popup-open]:before:bg-neutral-950 data-[disabled]:text-neutral-500 dark:data-[popup-open]:before:bg-neutral-800 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[highlighted]:data-[popup-open]:before:bg-white dark:data-[disabled]:text-neutral-400"
  separator_class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white"
  group_label_class="py-2 pr-6 pl-8 text-sm leading-4 text-neutral-500 select-none dark:text-neutral-400"
  indicator_class="absolute left-2.5 flex size-4 items-center justify-center text-xs data-[unchecked]:invisible"
  chevron_class="ml-auto pl-6"
  submenu_class="mt-1 border-t border-neutral-950 pt-1 dark:border-white"
  submenu_popup_class="origin-[var(--transform-origin)] border border-neutral-950 bg-white py-1 text-neutral-950 shadow-[0.25rem_0.25rem_0] shadow-black/12 outline-hidden transition-[scale,opacity] duration-100 ease-out data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 dark:border-white dark:bg-neutral-950 dark:text-white dark:shadow-none"
>
  <:trigger>Right click here</:trigger>
  <:item type="label">Sort by</:item>
  <:item type="radio" name="context-sort" value="name" checked>Name</:item>
  <:item type="radio" name="context-sort" value="date">Date modified</:item>
  <:item type="separator" />
  <:item type="label">View</:item>
  <:item type="checkbox" checked>Hidden files</:item>
  <:item type="checkbox">File extensions</:item>
  <:submenu label="Open with">
    <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
      Preview
    </.context_menu_item>
    <.context_menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm leading-4 outline-hidden select-none data-[highlighted]:relative data-[highlighted]:z-0 data-[highlighted]:text-white data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:z-[-1] data-[highlighted]:before:bg-neutral-950 data-[highlighted]:before:content-[''] data-[disabled]:text-neutral-500 dark:data-[highlighted]:text-neutral-950 dark:data-[highlighted]:before:bg-white dark:data-[disabled]:text-neutral-400">
      Text editor
    </.context_menu_item>
  </:submenu>
</.context_menu>

Class attributes

The class attributes on the root style the rows its slots draw. item_class reaches an item, a link, a checkbox row, a radio row and the trigger of a submenu. separator_class reaches a separator row and group_label_class a label row. indicator_class styles the check or the dot of a checkbox or radio row. chevron_class, submenu_class and submenu_popup_class style the arrow, the wrapper and the popup of a submenu. They come before the slot's own class, so a row can still add classes of its own. A :submenu slot also takes trigger_class and popup_class for its own trigger and popup.

The context_menu_* components do not read these. Each takes its own class, and the ones that draw a part of their own take one more: indicator_class on context_menu_checkbox and context_menu_radio, group_label_class on context_menu_group and context_menu_radio_group, and trigger_class, popup_class and chevron_class on context_menu_submenu. The rows inside a :submenu slot are these components.

Parts & data attributes

Each part carries a stable data-part and a structural chelekom-context_menu__* class you can target, plus paired-presence state for open/closed transitions. The class attributes in the Purpose column are the root's, for the rows its slots draw.

Partdata-partState / ARIAPurpose
Triggertriggeraria-haspopup, aria-expanded, aria-controls, data-popup-openThe area to right-click / long-press to open the menu at the cursor; takes trigger_class.
Popuppopuprole="menu", data-open, data-closed, data-starting-styleThe menu surface, positioned fixed at the cursor; takes popup_class.
Itemitemrole="menuitem", data-highlighted, data-disabledA plain, activatable row; takes item_class.
Checkbox itemcheckbox-itemrole="menuitemcheckbox", aria-checked, data-checked, data-unchecked, data-highlighted, data-disabledA toggleable row; takes item_class.
Radio group / Radio itemradio-group / radio-itemrole="group" / role="menuitemradio", aria-checked, data-value, data-checked, data-unchecked, data-highlighted, data-disabledSingle-select rows sharing a name/group; a radio row takes item_class.
Checkbox / radio indicatorcheckbox-item-indicator / radio-item-indicatoraria-hidden, data-checked, data-uncheckedThe check or the dot inside a row; takes indicator_class.
Link itemlink-itemrole="menuitem", data-highlightedRenders as an anchor with an href; takes item_class.
Separatorseparatorrole="separator", data-orientationVisual divider between rows; takes separator_class.
Group / Group labelgroup / group-labelrole="group" / role="presentation"Labeled row groupings; the label takes group_label_class.
Submenusubmenu-Wraps a submenu trigger and its popup; takes submenu_class.
Submenu trigger / Submenu popupsubmenu-trigger / submenu-popuparia-haspopup, aria-expanded, aria-controls, data-popup-open, data-highlighted, data-disabled, data-open, data-closedOpens a nested menu on hover or ArrowRight; the trigger takes item_class and the popup submenu_popup_class.
Submenu chevronsubmenu-chevronaria-hiddenThe arrow in a submenu trigger; takes chevron_class.

Accessibility

Follows the WAI-ARIA Menu pattern: focus moves into the popup on open and is restored to the previous element on close. Up/Down navigate, Home/End jump to the first/last enabled row, ArrowRight opens a submenu, ArrowLeft or Escape closes it, and typing performs typeahead. Enter or Space activates the highlighted row.