forty-cdk
llms.txt

Primitives

Menu

The shared menu surface composed by every menu-family primitive: items, checkbox / radio items, groups, separators and submenus.

forty-cdk/menu WAI-ARIA APG

Open the menu from its most common opener, a Dropdown Menu button, and walk the items with the arrow keys. Only the root and its trigger come from forty-cdk/dropdown-menu; every piece inside the surface is forty-cdk/menu.

Shared surface and item directives consumed by [forDropdownMenu] (button trigger) and [forContextMenu] (right-click), plus [forMenu], the opener-agnostic root for one menu definition driven by several openers at once. For a single opener, reach for one of the two presets; they are [forMenu] with the opener already chosen.

Anatomy

<div forMenuContent class="menu">
  <div forMenuGroup>
    <div forMenuGroupLabel>Appearance</div>
    <button forMenuCheckboxItem [(checked)]="bold">
      <span forMenuItemIndicator [forceMount]="true">✓</span>
      Bold
    </button>
  </div>

  <hr forMenuSeparator />

  <div forMenuRadioGroup [(value)]="sortBy">
    <div forMenuGroupLabel>Sort by</div>
    <button forMenuRadioItem class="menu-radio-item" value="name">
      <span forMenuItemIndicator [forceMount]="true">●</span>
      Name
    </button>
    <button forMenuRadioItem class="menu-radio-item" value="date">Date modified</button>
  </div>

  <hr forMenuSeparator />

  <button forMenuItem (activate)="save()">Save</button>

  <div forMenuSub #sub="forMenuSub">
    <button forMenuSubTrigger>More tools</button>
    <!-- @if (sub.open()) { -->
    <div forMenuSubContent>
      <button forMenuItem>Developer tools</button>
    </div>
    <!-- } -->
  </div>
</div>

For the recommended [forceMount] + opacity pattern that keeps indicator columns aligned across checkbox / radio items, see the selected-indicator alignment guide.

Examples

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ForDropdownMenu, ForDropdownMenuTrigger } from 'forty-cdk/dropdown-menu';
import {
  ForMenuCheckboxItem,
  ForMenuContent,
  ForMenuGroup,
  ForMenuGroupLabel,
  ForMenuItem,
  ForMenuItemIndicator,
  ForMenuRadioGroup,
  ForMenuRadioItem,
  ForMenuSeparator,
  ForMenuSub,
  ForMenuSubTrigger,
} from 'forty-cdk/menu';

@Component({
  selector: 'app-menu-default-example',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ForDropdownMenu,
    ForDropdownMenuTrigger,
    ForMenuContent,
    ForMenuItem,
    ForMenuCheckboxItem,
    ForMenuRadioGroup,
    ForMenuRadioItem,
    ForMenuItemIndicator,
    ForMenuSeparator,
    ForMenuGroup,
    ForMenuGroupLabel,
    ForMenuSub,
    ForMenuSubTrigger,
  ],
  template: `
    <div forDropdownMenu #menu="forDropdownMenu">
      <button forDropdownMenuTrigger class="menu-trigger">View options</button>
      @if (menu.open()) {
        <div forMenuContent class="menu menu--wide" animate.enter="menu-pop-in">
          <div forMenuGroup>
            <div forMenuGroupLabel class="menu-label">Appearance</div>
            <button
              forMenuCheckboxItem
              class="menu-item menu-item--check"
              [(checked)]="showToolbar"
              (activate)="$event.preventDefault()"
            >
              <span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
                  <path
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="1.75"
                    d="m4.5 12.75 6 6 9-13.5"
                  />
                </svg>
              </span>
              Show toolbar
            </button>
            <button
              forMenuCheckboxItem
              class="menu-item menu-item--check"
              [(checked)]="showSidebar"
              (activate)="$event.preventDefault()"
            >
              <span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
                  <path
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="1.75"
                    d="m4.5 12.75 6 6 9-13.5"
                  />
                </svg>
              </span>
              Show sidebar
            </button>
          </div>

          <hr forMenuSeparator class="menu-separator" />

          <div forMenuGroup>
            <div forMenuGroupLabel class="menu-label">Sort by</div>
            <div forMenuRadioGroup [(value)]="sortBy">
              <button
                forMenuRadioItem
                value="name"
                class="menu-item menu-item--check"
                (activate)="$event.preventDefault()"
              >
                <span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
                  <svg viewBox="0 0 16 16" width="7" height="7" aria-hidden="true">
                    <circle cx="8" cy="8" r="8" fill="currentColor" />
                  </svg>
                </span>
                Name
              </button>
              <button
                forMenuRadioItem
                value="date"
                class="menu-item menu-item--check"
                (activate)="$event.preventDefault()"
              >
                <span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
                  <svg viewBox="0 0 16 16" width="7" height="7" aria-hidden="true">
                    <circle cx="8" cy="8" r="8" fill="currentColor" />
                  </svg>
                </span>
                Date modified
              </button>
              <button
                forMenuRadioItem
                value="size"
                class="menu-item menu-item--check"
                (activate)="$event.preventDefault()"
              >
                <span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
                  <svg viewBox="0 0 16 16" width="7" height="7" aria-hidden="true">
                    <circle cx="8" cy="8" r="8" fill="currentColor" />
                  </svg>
                </span>
                Size
              </button>
            </div>
          </div>

          <hr forMenuSeparator class="menu-separator" />

          <div forMenuSub #more="forMenuSub">
            <button forMenuSubTrigger class="menu-item menu-item--check">
              <span class="menu-indicator"></span>
              More tools
              <span class="menu-sub-arrow" aria-hidden="true">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
                  <path
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="1.75"
                    d="m8.25 4.5 7.5 7.5-7.5 7.5"
                  />
                </svg>
              </span>
            </button>
            @if (more.open()) {
              <div forMenuSubContent class="menu" animate.enter="menu-pop-in">
                <button forMenuItem class="menu-item">Developer tools</button>
                <button forMenuItem class="menu-item">Extensions</button>
                <button forMenuItem class="menu-item">Task manager</button>
              </div>
            }
          </div>
        </div>
      }
    </div>
  `,
})
export class MenuDefaultExample {
  protected readonly showToolbar = signal(true);
  protected readonly showSidebar = signal(false);
  protected readonly sortBy = signal<string | null>('name');
}

forty-cdk/menu ships no opener of its own, so the surface is always opened through another entry point. In the example above, the opener is the button of Dropdown Menu. The same pieces compose unchanged under Context Menu and Menubar, whose pages carry the demos for those openers, and [forMenu] lets several of them open one surface.

Mount/visibility convention

[forMenuContent] follows the floating-overlay convention: the consumer's signal drives @if, the directive emits (close) (forwarded by the root primitive) when it wants to be unmounted. No [hidden]. See [forDropdownMenu] and [forContextMenu] for end-to-end examples.

Item activation contract

Every item type emits a vetoable (activate) event, whose handlers receive a VetoableEvent. The default action is to close the menu after the item's state has been applied (toggle for checkbox, set value for radio). Call event.preventDefault() on the veto to keep the menu open.

<!-- Closes menu by default -->
<button forMenuItem class="menu-item" (activate)="save()">Save</button>

<!-- Stays open -->
<button
  forMenuCheckboxItem
  class="menu-checkbox-item"
  [(checked)]="bold"
  (activate)="$event.preventDefault()"
>
  Bold
</button>

Shared openers ([forMenu])

[forDropdownMenu] and [forContextMenu] each provide their own menu context. Angular resolves that context at the template's declaration site, so a single [forMenuContent] block can only ever see one of them. A table row that needs the same actions from a kebab button and from a right-click over the whole row therefore had to duplicate every item and keep the two copies in sync.

[forMenu] is the opener-agnostic root that removes the duplication: one content block, any number of heterogeneous openers.

<tr forMenu #row="forMenu" [(open)]="open" ariaLabel="Row actions">
  <!-- opener A: the whole row is the right-click region -->
  <td [forContextMenuTrigger]="row">…cells…</td>

  <!-- opener B: the kebab button at the end of the row -->
  <td>
    <button [forDropdownMenuTrigger]="row" class="kebab">⋮</button>
  </td>

  @if (open()) {
  <div forMenuContent class="menu">
    <button forMenuItem (activate)="edit()">Edit</button>
    <button forMenuItem (activate)="remove()">Delete</button>
  </div>
  }
</tr>

Exactly one instance is open at a time, and everything the mounted surface resolves follows the active opener (the one that fired):

  • Return focus lands on that opener, not on a single fixed trigger.
  • The anchor is the opener's own element for a button opener, or its recorded pointer / rect position for a right-click opener (contextmenu, long-press, Shift+F10, the ContextMenu key).
  • Ids are per opener, so two openers never emit the same id. The button opener's aria-controls still points at the shared surface.
  • The accessible name follows the opener's own nature. A [forDropdownMenuTrigger] button is a discrete labelling control, so an unnamed surface it opened falls back to aria-labelledby="<that button's id>". A [forContextMenuTrigger] region is not one: pointing the menu's name at a whole row would announce the row's entire text, so a region-opened surface emits no fallback. Reopening from the other opener flips it.
  • Outside-dismissal exempts the button opener only (its own click toggles, so without the exemption the same pointer-down would double-close). A left-click on the right-click region closes the menu like any other outside click.
  • Placement is the root's, unless the opener overrides it (see below).

Two boundaries worth knowing:

  • [forContextMenuTrigger] must be bound explicitly: [forContextMenuTrigger]="row" with #row="forMenu". It resolves FOR_CONTEXT_MENU_CONTEXT, which [forMenu] deliberately does not provide (forty-cdk/menu must not depend on forty-cdk/context-menu). [forDropdownMenuTrigger] resolves this root through DI like any other menu piece, so binding it is optional.
  • A shared menu with any region opener still wants [ariaLabel]. The per-opener fallback covers the button openers for free, so a button-only shared menu needs no name hook at all; but a right-click region cannot name the surface, so an instance it opened has no accessible name unless [ariaLabel] (or your own static aria-labelledby on the content) supplies one. [ariaLabel] wins over the fallback for every opener, giving the menu one name regardless of how it was opened.

Per-opener positioning

The root's positioning inputs are seeded from provideForMenuDefaults: sideOffset defaults to 0, flush against the anchor, which is what a pointer-anchored open wants. That is the wrong answer for a button opener, which wants the few pixels of clearance [forDropdownMenu] seeds. An individual opener can therefore override the placement for the opens it drives, through its trigger's [menuPositioning]:

<tr forMenu #row="forMenu" ariaLabel="Row actions">
  <td [forContextMenuTrigger]="row">…cells…</td>
  <td>
    <button [forDropdownMenuTrigger]="row" [menuPositioning]="{ sideOffset: 4 }">⋮</button>
  </td>
  …
</tr>

The button-opened menu now clears the button by 4px while a right-click still opens flush at the cursor.

  • The override carries the four placement values (side, align, sideOffset, alignOffset), and every key is optional. An omitted key resolves the root's own input, so an opener that overrides nothing (or binds null) positions exactly as the root does.
  • It applies only while that opener is the active one, and switches with the opener. Nothing leaks from the previously active one.
  • The rest of the positioning surface (avoidCollisions, fallbackAxisSideDirection, collisionPadding, sticky, hideWhenDetached, clipUntilPositioned) stays root-only: it is collision / viewport policy for the surface, not a property of the opener that fired.
  • Both trigger directives carry the input, and it resolves the same way under their own preset root ([forDropdownMenu] / [forContextMenu]), where it is simply a per-trigger spelling of the root's inputs. Bind an object literal or a signal-returned object; a new identity re-positions the mounted surface.

A shared menu whose openers all want the same placement needs none of this. Set the root's inputs instead.

API

ForMenu

Selector [forMenu], exportAs: 'forMenu'.

InputTypeDefaultNotes
open
model
falseTwo-way. (openChange) fires on internal transitions only
side
FloatingSide | undefined
'bottom'From provideForMenuDefaults; overridable per opener
align
FloatingAlign | undefined
'start'From provideForMenuDefaults; overridable per opener
sideOffset
number
0From provideForMenuDefaults; overridable per opener
alignOffset
number
0Overridable per opener via [menuPositioning]
avoidCollisions
boolean
true
fallbackAxisSideDirection
FloatingFallbackAxisSideDirection
'none'From provideForMenuDefaults
collisionPadding
number
8From provideForMenuDefaults
sticky
'partial' | 'always' | false
'partial'
hideWhenDetached
boolean
false
clipUntilPositioned
boolean
true
loop
boolean
true
dir
'ltr' | 'rtl' | null
null (ambient)Reflected to the host dir attribute
disabled
boolean
falseBlocks every opener
dismissible
boolean
true
returnFocus
boolean
trueReturns focus to the active opener
ariaLabel
string | null
nullWins over the per-opener aria-labelledby fallback

Outputs match the other trigger-anchored overlays: (escapeKeyDown), (pointerDownOutside), (focusOutside), (interactOutside), (autoFocusOnOpen), (autoFocusOnClose). All of them are vetoable via preventDefault().

Data attributes

PieceAttributeValues
[forMenu]data-stateopen | closed
[forMenu]data-disabledpresent | absent
[forMenuContent] / [forMenuSubContent]data-stateopen | closed
[forMenuItem]data-disabledpresent | absent
[forMenuItem]data-highlightedpresent | absent
[forMenuCheckboxItem]data-statechecked | unchecked
[forMenuCheckboxItem]data-disabledpresent | absent
[forMenuCheckboxItem]data-highlightedpresent | absent
[forMenuRadioItem]data-statechecked | unchecked
[forMenuRadioItem]data-disabledpresent | absent
[forMenuRadioItem]data-highlightedpresent | absent
[forMenuItemIndicator]data-statechecked | unchecked
[forMenuSub]data-stateopen | closed
[forMenuSub]data-disabledpresent | absent
[forMenuSubTrigger]data-stateopen | closed
[forMenuSubTrigger]data-disabledpresent | absent
[forMenuSeparator]data-orientationhorizontal | vertical

Keyboard

  • ArrowDown / ArrowUp: move focus to the next / previous enabled item, wrapping by default.

  • Home / End: jump to first / last enabled item.

  • Enter / click: activate the focused item via native <button> semantics. Closes the menu unless the consumer calls event.preventDefault() on (activate).

  • Space activates the focused item:

    • On a plain [forMenuItem], behaves like Enter / click (closes the menu).
    • On [forMenuCheckboxItem] and [forMenuRadioItem], toggles checked / sets the group value, emits (activate), and never closes the menu (per APG), so users can flip several options before dismissing. Calling event.preventDefault() on (activate) is unnecessary for Space (the menu already stays open) but is still respected on Enter / click.
  • Tab / Shift+Tab: close the menu and return focus to the trigger. Inside a submenu, propagates upward and tears down the entire chain.

  • Escape: close the menu and return focus to the trigger. Inside a submenu, closes only that level (parent stays open).

  • ArrowRight (on a [forMenuSubTrigger]): open the submenu and focus its first item. (LTR.)

  • Enter / Space / click (on a [forMenuSubTrigger]): open the submenu and focus its first item. On a submenu that is already open, for instance after a hover opened it, they move focus into it and never close it.

  • ArrowLeft (on an item inside a submenu): close the submenu and return focus to the [forMenuSubTrigger].

  • Typeahead: single printable characters move focus to the first item whose text starts with the buffered string. Disabled items are skipped. By default the match is run against the item's accessible text, so an aria-hidden subtree (such as the [forMenuItemIndicator] glyph or a decorative icon) never bleeds into it, while visually-hidden but announced content still counts. Pass textValue="…" on [forMenuItem], [forMenuCheckboxItem], or [forMenuRadioItem] to override the matched string when announced text such as a kbd hint or a badge would otherwise bleed into it.

    <!-- Without textValue, prefix-match would compare against "3 Archive" -->
    <button forMenuItem class="menu-item" textValue="Archive">
      <span class="badge">3</span>
      Archive
    </button>

Accessibility

Implements the WAI-ARIA Menu pattern for the surface (role="menu") and for items (menuitem / menuitemcheckbox / menuitemradio).

  • Apply each item directive to a <button> so Space / Enter activation come from native button behavior.
  • Disabled items keep tabindex="-1" and aria-disabled="true" (never the native disabled attribute). They are skipped by arrow-key navigation, typeahead, Home/End, and pointer hover, and click / keyboard activation are no-ops, but they stay in the DOM so screen readers can still announce them.
  • The role="menu" surface takes its accessible name from a consumer-set static aria-labelledby on [forMenuContent] when present (it is preserved, never clobbered), else from the root's ariaLabel (reflected as aria-label); with neither it falls back to aria-labelledby pointing at the trigger that opened it: the [forDropdownMenuTrigger] button, the [forMenubarTrigger], or the [forMenuSubTrigger]. The fallback is suppressed for a trigger that is not a discrete labelling control: [forContextMenu]'s right-click region is the whole row / card, so pointing the menu's name at it would announce that entire text, and [ariaLabel] (or your own aria-labelledby) is the way to name a context menu. [forMenu] decides per active opener rather than per root, so a shared menu names itself after the button that opened it and emits nothing when a region did.
  • [forMenuSeparator] never registers with the menu's item collection, so it's skipped during navigation and typeahead automatically. It carries role="separator" and emits aria-orientation only for orientation="vertical", because horizontal is the ARIA default; data-orientation is always stamped for styling. Set decorative when the surrounding items already convey the split. Setting it switches the line to role="none" and drops aria-orientation, matching the shared separator emission policy.
  • [forMenuGroup] is purely advisory grouping: items inside still register flatly with the parent menu, so navigation flows through groups without interruption.
  • [forMenuGroup] and [forMenuRadioGroup] both expose role="group"; give either an accessible name by projecting a [forMenuGroupLabel] inside it, which the group references via aria-labelledby.
  • [forMenuRadioGroup]'s [(value)] is string | null, and null is the "nothing selected" state, so bind a signal<string | null>(…). The empty string is a legitimate item value, so a [forMenuRadioItem] value="" (a "None" / "Any" option in a sort-order or filter menu) is only checked once the user picks it, exactly like any other value.
  • Submenus use side="right" align="start" by default in LTR and side="left" align="start" in RTL. Set [dir]="'rtl'" on the top-level [forDropdownMenu] / [forContextMenu] and every nested [forMenuSub] inherits it (and flips side, ArrowLeft/Right semantics, etc.). Override per-submenu with [dir] or [side] if a specific submenu needs to render against the opposite direction.
  • In RTL, ArrowLeft opens a submenu and ArrowRight closes it back to the parent. The swap mirrors the visual flip of the menu chain.
  • data-highlighted="" is reflected on the focused [forMenuItem] / [forMenuCheckboxItem] / [forMenuRadioItem] so consumers can paint a uniform focus ring shared with the listbox / select / combobox primitives. The attribute is intent-driven: opening a menu with the pointer focuses the first item without highlighting it (no "preselected" look on mouse open), while a keyboard open (Enter / Space / ArrowDown / ArrowUp on the trigger, Shift+F10 for context menus) highlights the initially focused item. Arrow / Home / End / typeahead navigation always highlights the focused item.
  • Hover follows the pointer. Moving the mouse over an enabled item focuses and highlights it, so the keyboard highlight and the mouse hover never disagree. There is a single "active candidate" at a time. Hovering an adjacent item moves the highlight with it; hovering a disabled item is inert. When the pointer leaves the menu surface the highlight clears, while DOM focus stays anchored on the item so keyboard navigation continues from there. This means [data-highlighted] is the only hover styling hook you need. You do not add a separate :hover rule (it would fight the highlight). Touch / pen never hover, so this applies to mouse input only.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).

[forMenuContent] / [forMenuSubContent] portal to document.body, so a class scoped to your trigger's component cannot reach the surface. Style it with global CSS or a class you pass through (see Styling floating content). The content host also exposes the shared positioner custom properties, tabulated below and documented in full in Styling floating content: --for-floating-anchor-width / --for-floating-anchor-height, --for-floating-available-width / --for-floating-available-height, and --for-floating-content-transform-origin.

CSS custom properties

[forMenuContent] / [forMenuSubContent] are portaled to document.body and get their position resolved by floating-ui. The resolved geometry is exposed as custom properties on the content host (cleared on close). These also drive the content surface for [forDropdownMenu] and [forContextMenu], which reuse [forMenuContent]:

Custom propertyType / rangeMeaning
--for-floating-anchor-widthpxAnchor (trigger) width. Match it with width: var(--for-floating-anchor-width).
--for-floating-anchor-heightpxAnchor (trigger) height.
--for-floating-available-widthpxSpace available along the inline axis (floating-ui size middleware). Clamp with max-width.
--for-floating-available-heightpxSpace available along the block axis. Clamp with max-height.
--for-floating-content-transform-origin<origin> keywordstransform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger.
.menu-item[data-highlighted],
.menu-checkbox-item[data-highlighted],
.menu-radio-item[data-highlighted] {
  background: rgba(0, 0, 0, 0.06);
}
.menu-item[data-state='open'] .menu-sub-arrow {
  transform: rotate(90deg);
}

Wrapping in a design system

Subclass the root and re-provide FOR_MENU_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.