Menu

v0.0.10

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

Overview of the Phoenix and LiveView Headless Menu Component

The Mishka Chelekom Headless Menu ships zero opinionated styling - only the semantic markup, full WAI-ARIA wiring, and paired-presence state (data-open/data-closed). Click (or, with open_on_hover, hover) the trigger to open the popup, anchored by side/ align with edge-flip and viewport clamp. It is composed from per-row parts - item, checkbox item, radio group / radio item, link item, group / group label, separator, and submenu trigger / submenu popup - so you can either use the idiomatic :item / :submenu slots for flat rows or compose the 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, submenu_trigger_class and submenu_popup_class. A slot's own class is added after them.

Base UI Menu example - flat items (Phoenix headless)

Base UI Menu example - flat items (Phoenix headless)

heex
<.menu
  id="baseui-menu-hero"
  side_offset={8}
  trigger_class="flex h-8 items-center justify-center gap-1.5 border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 pl-3 pr-2 text-sm font-normal text-neutral-950 dark:text-white select-none hover:not-data-[disabled]:bg-neutral-100 dark:hover:not-data-[disabled]:bg-neutral-800 data-[popup-open]:bg-neutral-100 dark:data-[popup-open]:bg-neutral-800"
  popup_class="relative origin-[var(--transform-origin)] border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 py-1 text-neutral-950 dark:text-white shadow-[0.25rem_0.25rem_0] shadow-black/12 dark:shadow-none 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"
  item_class="flex cursor-default py-2 pr-8 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950"
  separator_class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white"
>
  <:trigger>
    Song
    <.icon name="hero-chevron-down-solid" class="size-4" />
  </: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>
</.menu>

Base UI Menu example - labeled radio and checkbox groups (Phoenix headless)

Base UI Menu example - labeled radio and checkbox groups (Phoenix headless)

heex
<.menu
  id="baseui-menu-group-labels"
  side_offset={8}
  trigger_class="flex h-8 items-center justify-center gap-1.5 border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 pl-3 pr-2 text-sm font-normal text-neutral-950 dark:text-white select-none hover:not-data-[disabled]:bg-neutral-100 dark:hover:not-data-[disabled]:bg-neutral-800 data-[popup-open]:bg-neutral-100 dark:data-[popup-open]:bg-neutral-800"
  popup_class="relative origin-[var(--transform-origin)] border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 py-1 text-neutral-950 dark:text-white shadow-[0.25rem_0.25rem_0] shadow-black/12 dark:shadow-none 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"
  item_class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-8 pl-2.5 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950"
  group_label_class="py-2 pr-8 pl-[2.125rem] text-sm text-neutral-500 dark:text-neutral-400 select-none"
  indicator_class="col-start-1 data-[unchecked]:invisible"
  separator_class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white"
>
  <:trigger>
    View
    <.icon name="hero-chevron-down-solid" class="size-4" />
  </:trigger>
  <:check_icon>
    <.icon name="hero-check" class="size-4" />
  </:check_icon>
  <:item type="label">Sort</:item>
  <:item type="radio" name="view-sort" value="date" checked={true}>Date</:item>
  <:item type="radio" name="view-sort" value="name">Name</:item>
  <:item type="radio" name="view-sort" value="type">Type</:item>
  <:item type="separator" />
  <:item type="label">Workspace</:item>
  <:item type="checkbox" checked={true}>Minimap</:item>
  <:item type="checkbox" checked={true}>Search</:item>
  <:item type="checkbox" checked={false}>Sidebar</:item>
</.menu>

Base UI Menu example - nested submenu (Phoenix headless)

Base UI Menu example - nested submenu (Phoenix headless)

heex
<.menu
  id="baseui-menu-submenu"
  side_offset={8}
  trigger_class="flex h-8 items-center justify-center gap-1.5 border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 pl-3 pr-2 text-sm font-normal text-neutral-950 dark:text-white select-none hover:not-data-[disabled]:bg-neutral-100 dark:hover:not-data-[disabled]:bg-neutral-800 data-[popup-open]:bg-neutral-100 dark:data-[popup-open]:bg-neutral-800"
  popup_class="relative origin-[var(--transform-origin)] border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 py-1 text-neutral-950 dark:text-white shadow-[0.25rem_0.25rem_0] shadow-black/12 dark:shadow-none 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"
>
  <:trigger>
    Song
    <.icon name="hero-chevron-down-solid" class="size-4" />
  </:trigger>
  <.menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950">
    Add to Library
  </.menu_item>
  <.menu_submenu
    id="baseui-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 select-none outline-hidden data-[popup-open]:relative data-[popup-open]:before:absolute data-[popup-open]:before:inset-x-1 data-[popup-open]:before:inset-y-0 data-[popup-open]:before:-z-10 data-[popup-open]:before:bg-neutral-100 dark:data-[popup-open]:before:bg-neutral-800 data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950 data-[highlighted]:data-[popup-open]:before:bg-neutral-950 dark:data-[highlighted]:data-[popup-open]:before:bg-white"
    popup_class="relative origin-[var(--transform-origin)] border border-neutral-950 dark:border-white bg-white dark:bg-neutral-950 py-1 text-neutral-950 dark:text-white shadow-[0.25rem_0.25rem_0] shadow-black/12 dark:shadow-none 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"
  >
    <:chevron>
      <.icon name="hero-chevron-right-solid" class="size-4" />
    </:chevron>
    <.menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950">
      Get Up!
    </.menu_item>
    <.menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950">
      Inside Out
    </.menu_item>
    <.menu_separator class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white" />
    <.menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950">
      New playlist…
    </.menu_item>
  </.menu_submenu>
  <.menu_separator class="mx-1 my-1 h-px bg-neutral-950 dark:bg-white" />
  <.menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950">
    Play Next
  </.menu_item>
  <.menu_item class="flex cursor-default py-2 pr-6 pl-4 text-sm select-none outline-hidden data-[highlighted]:relative data-[highlighted]:before:absolute data-[highlighted]:before:inset-x-1 data-[highlighted]:before:inset-y-0 data-[highlighted]:before:-z-10 data-[highlighted]:before:bg-neutral-950 dark:data-[highlighted]:before:bg-white data-[highlighted]:text-white dark:data-[highlighted]:text-neutral-950">
    Play Last
  </.menu_item>
</.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, and chevron_class the arrow of a submenu. submenu_class, submenu_trigger_class and submenu_popup_class style the wrapper, the trigger and the popup of a submenu. They come before the slot's own class, and an :item slot adds its own indicator_class after the root's, so a row can still add classes of its own. The :check_icon slot replaces the glyph in every checkbox and radio indicator.

The 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 menu_checkbox and menu_radio, label_class on menu_group and menu_radio_group, and trigger_class, popup_class and chevron_class on 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-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-open, data-disabledThe button that opens the menu; takes trigger_class.
Popuppopuprole="menu", data-open, data-closed, data-side, data-alignThe menu surface, anchored and flipped to stay in the viewport; takes popup_class.
Itemitemrole="menuitem", data-highlighted, data-disabledA plain, activatable row; takes item_class.
Checkbox item / indicatorcheckbox-item / checkbox-item-indicatorrole="menuitemcheckbox", aria-checked, data-checked, data-unchecked, data-highlighted, data-disabledA toggleable row with a checked indicator span; the row takes item_class and the indicator indicator_class.
Radio group / Radio item / indicatorradio-group / radio-item / radio-item-indicatorrole="group" / role="menuitemradio", aria-checked, data-value, data-checked, data-unchecked, data-highlighted, data-disabledSingle-select rows sharing a name; the row takes item_class and the indicator indicator_class.
Link itemlink-itemrole="menuitem", data-highlightedRenders as an anchor with an href; takes item_class.
Group / Group labelgroup / group-labelrole="group" / role="presentation", aria-labelledbyLabeled row groupings; a label row takes group_label_class.
Separatorseparatorrole="separator", data-orientationA visual divider between rows; takes separator_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 submenu_trigger_class, the popup submenu_popup_class.
Submenu chevronsubmenu-chevronaria-hiddenThe arrow in a submenu trigger; takes chevron_class.

Accessibility

Follows the WAI-ARIA Menu Button pattern: focus moves into the popup on open and is restored to the trigger 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.