forty-cdk
llms.txt

Primitives

Menubar

A horizontal bar of menus, as in a desktop application, with roving tabindex across the triggers.

forty-cdk/menubar WAI-ARIA APG

Move along the bar with the left / right arrows and open a menu with the down arrow. Once one is open, moving the pointer to another opens it without a click.

A bar of triggers (horizontal or vertical), each opening a dropdown menu, with cross-menu ArrowLeft / ArrowRight navigation and hover-after-first-open.

When to choose

  • Menubar: a persistent bar of menu triggers with roving tabindex across the bar, ArrowLeft / ArrowRight between menus, and one menu open at a time.
  • Dropdown Menu: a single trigger and its menu, with no bar and no cross-menu navigation. Choose it unless the menus genuinely belong to one bar.
  • Context Menu: the same actions opened by right-click at the pointer.
  • Toolbar: the same one-Tab-stop bar shape when the controls act directly (buttons, toggles) instead of opening menus.

Anatomy

<div forMenubar [(value)]="openMenu" ariaLabel="Application">
  <button forMenubarTrigger value="file">File</button>
  <!-- mounted when openMenu() === 'file' -->
  <div forMenuContent>
    <button forMenuItem>New file</button>
    <hr forMenuSeparator />
    <button forMenuItem>Quit</button>
  </div>

  <button forMenubarTrigger value="edit">Edit</button>
  <!-- mounted when openMenu() === 'edit' -->
  <div forMenuContent>
    <button forMenuItem>Undo</button>
    <button forMenuItem>Redo</button>
  </div>
</div>

ForMenubar ([forMenubar]) is the root: it owns value (the open trigger), orientation, dir, loop and disabled, and provides a multiplexed ForMenuContext to the active [forMenuContent]. Each ForMenubarTrigger ([forMenubarTrigger]) is a role="menuitem" button with aria-haspopup="menu" / aria-expanded / aria-controls, participating in roving tabindex and trigger-row keyboard.

The menu surface, items, separators, groups, and submenus come from the menu/ folder and are the same primitives that [forDropdownMenu] and [forContextMenu] use. The bar simply pumps a different ForMenuContext whose anchor / side / ids reflect the active trigger.

Examples

Vertical & RTL

The same menubar laid out as a vertical sidebar (orientation='vertical' makes Up / Down move between triggers) with dir='rtl'. RTL swaps the cross-menu arrow keys and floats each menu out of the opposite edge. The directive resolves the writing direction and positioning for you.

Mount shapes

The bar provides one multiplexed ForMenuContext, so a [forMenuContent] does not have to be repeated per trigger. Three shapes are supported, and all three preserve a consumer-set static id on the surface:

ShapeMarkupWhen to reach for it
One @if per trigger@if (open() === 'file') { <div forMenuContent>…</div> }The canonical shape. Each menu owns its items, and animate.enter / animate.leave fire per menu on every switch.
One shared @if@if (open() !== null) { <div forMenuContent>…</div> }One surface for the whole bar, with the consumer swapping the items. Cheaper to author; the surface is not remounted on a switch, so per-menu enter / leave animations do not replay.
Unconditionally mounted<div forMenuContent>…</div>The surface stays in the DOM for the bar's whole lifetime. Nothing mounts or unmounts, so no mount-cycle animation runs at all.

For the two shared shapes the surface belongs to no single trigger, so a static id on it stays put across a menu switch and every trigger's aria-controls resolves to it. A surface with no static id gets a generated one that follows the active trigger. In every shape the accessible name (aria-labelledby / aria-label) tracks whichever trigger's menu is currently open.

Only a plain id="…" attribute is preserved. A [id]="expr" property binding evaluates after the directive is constructed, so it is invisible to the adoption and fights the surface's own [id] binding.

API

ForMenubar

PropertyTypeDescription
value
model
Two-way bindable. The open trigger's value, or null when none. The menubar writes it on trigger interaction, item activation, Escape, outside dismissal and cross-menu navigation.
Default: null
orientation
input
'horizontal' | 'vertical'. Drives the trigger-row arrow keys (Left/Right horizontal, Up/Down vertical).
Default: 'horizontal'
dir
input
Writing direction. RTL inverts ArrowLeft / ArrowRight on the trigger row and inside the open menu.
Default: 'ltr'
loop
input
When true, trigger-row navigation and cross-menu nav wrap at the ends.
Default: true
disabled
input
When true, every trigger interaction is a no-op.
Default: false
dismissible
input
When false, the open menu ignores Escape and outside interaction. It stays pinned open until value is flipped (consumer write, trigger / item interaction, or cross-menu nav).
Default: true
ariaLabel
input
Accessible name for the menubar (<div forMenubar aria-label="Main"> works too).
Default: null
escapeKeyDown
output
Output. Escape pressed while the open menu is the topmost dismissible layer.
Default: —
pointerDownOutside
output
Output. Pointer-down on a target outside the open menu and every trigger.
Default: —
focusOutside
output
Output. Focus moves outside the open menu and every trigger.
Default: —
interactOutside
output
Output. A composite that fires alongside the two above and shares their veto state.
Default: —
autoFocusOnOpen
output
Output. Just before focus moves to the first / last enabled item on mount. Not fired on a hover-switch, which parks focus on the hovered trigger instead.
Default: —
autoFocusOnClose
output
Output. Just before focus returns to the trigger on unmount. Not fired for Tab / outside-interaction closes, which already moved focus, nor when a sibling's menu replaces the open one. A switch (hover, cross-menu arrows, a click on another trigger) is not a close, so no return-focus move happens.
Default: —

Every output above is vetoable: each handler receives a VetoableEvent (or VetoableNativeEvent<E> when there is a native DOM event). Call preventDefault() on the emitted veto to suppress the menubar's default action; the original DOM event, when present, is on .event. The outputs are declared on [forMenubar] rather than per trigger: the bar provides a single multiplexed menu context, so one set of handlers covers whichever trigger's menu is open.

ForMenubarTrigger

PropertyTypeDescription
value
input.required
Identifier for the trigger. The menubar's value model holds this when the menu is open.
Default: —
disabled
input
Per-trigger disabled, in addition to the menubar's disabled.
Default: false
textValue
input
Overrides the string the trigger-row typeahead matches on. Empty falls back to the trigger's accessible text.
Default: ''
side / align / alignOffset / avoidCollisions / sticky / hideWhenDetached / clipUntilPositioned
input
Forwarded to the multiplexed [forMenuContent] when this trigger's menu is the one open. Same surface and same defaults as [forDropdownMenu]; side / align default from provideForMenubarDefaults.
Default: 'bottom' / 'start' / 0 / true / 'partial' / false / true
sideOffset / collisionPadding / fallbackAxisSideDirection
input
Gap (px) along the main axis / viewport collision padding (px) / side flip drops the menu to when both sides of the preferred axis overflow. Defaults from provideForMenubarDefaults.
Default: 4 / 8 / 'none'
ariaLabel
input
Manual aria-label on [forMenuContent] if the trigger isn't a meaningful name.
Default: null

Data attributes

PieceAttributeValues
[forMenubar]data-stateopen | closed
[forMenubar]data-orientationhorizontal | vertical
[forMenubar]data-disabledpresent | absent
[forMenubarTrigger]data-stateopen | closed
[forMenubarTrigger]data-orientationhorizontal | vertical
[forMenubarTrigger]data-disabledpresent | absent

Keyboard

Trigger

KeyBehavior
ClickToggle this trigger's menu. On open, focus moves to the first enabled item.
Enter / SpaceOpen this trigger's menu and focus the first enabled item; already open, focus moves to it.
ArrowDownOpen and focus the first enabled item; already open, focus moves to it.
ArrowUpOpen and focus the last enabled item; already open, focus moves to it.
ArrowLeft / ArrowRightMove focus to the previous / next enabled trigger. RTL inverts. While a menu is open, the menu switches to the focused trigger, with focus left on it.
Home / EndFocus the first / last enabled trigger. While a menu is open, the menu switches to it.
TypeaheadPrintable keys focus the first sibling trigger whose label starts with the buffered string. The label is the trigger's accessible text, so an aria-hidden icon inside it never bleeds into the match. Pass textValue="…" on [forMenubarTrigger] when announced content, such as a badge or a count, would otherwise bleed into it. While a menu is open, the menu switches to the matched trigger.

In-menu

Inside an open menu, the standard [forMenuContent] keyboard applies (see menu/README.md). The menubar adds:

KeyBehavior
ArrowLeft / ArrowRight (on a top-level item)Close the current menu and open the previous / next sibling menu, focusing its first item. RTL inverts.
ArrowRight (on a plain item inside a submenu)Collapse the whole submenu chain and open the next sibling menu, focusing its first item. ArrowLeft collapses one submenu level instead. RTL inverts.
EscapeClose the menu and return focus to its trigger.
Tab / Shift+TabClose the menu and return focus to its trigger; the natural tab sequence then exits the menubar.

Submenus opened from a top-level menu work as in [forDropdownMenu]: Escape collapses one level at a time, the open-key opens, and the close-key collapses upward. When the submenu's parent is the top of a menubar, the close-key on the submenu trigger collapses the parent and switches to the previous sibling menu; the away-key on a plain item inside any submenu level collapses the whole chain and switches to the next sibling menu.

Accessibility

[forMenubar] implements the WAI-ARIA Menubar pattern. Each trigger carries role="menuitem" with aria-haspopup="menu", aria-expanded, and aria-controls. Roving tabindex keeps one trigger in the tab sequence at a time. Disabled triggers remain focusable with aria-disabled="true" per APG. The menu surface and item roles come from the shared menu/ primitives.

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).

Each trigger's menu surface is the shared [forMenuContent] (from menu/), which portals to document.body. Style it with global CSS or a class, because scoped/:host styles won't reach it. The portaled content also exposes the shared positioner custom properties (--for-floating-anchor-width / -height, --for-floating-available-width / -height, --for-floating-content-transform-origin); see Styling floating content for the full list and how to use them.

.menubar-menu-trigger[data-state='open'] {
  background: var(--accent);
}

.menubar-menu-trigger[data-disabled] {
  opacity: 0.5;
}

Behavior notes

  • One open at a time. Opening trigger B while A is open implicitly closes A and opens B with its first item focused. The exception is a hover-switch, which leaves focus on B's trigger (see below).
  • First open is intentional, subsequent are hover. While no menu is open, hovering a trigger does not auto-open, and keyboard focus alone never opens a menu. After the user opens any menu via click / keyboard, hovering a sibling trigger opens it instantly (no delay). Hover is mouse-only: touch and pen never hover, so a tap on a sibling trigger switches menus through its click instead of opening on pointerenter and then closing on the click that follows.
  • Hover-switch parks focus on the hovered trigger. Switching menus by hover is a full close/open cycle: the outgoing menu unmounts, so the focus it held has to be relocated or it would fall to <body>. It is relocated to the hovered trigger, not into the menu that just opened. This matches the APG reference implementation's menubar-navigation.js, whose onMenuitemPointerover focuses the hovered bar item and only then swaps the popups. So a mouse user sweeping across the bar to read the menus never has focus dragged through each popup in turn, and (autoFocusOnOpen) does not fire for the incoming surface: there is no focus move to veto. Keyboard handoff is seamless from there: ArrowDown / ArrowUp / Enter on the hovered trigger move focus to the first / last item of the menu that is already open, and moving along the bar (ArrowLeft / ArrowRight, Home / End, typeahead) switches the open menu to whichever trigger takes focus, so the expanded trigger is always the focused one. Every other open path (click, Enter / Space, ArrowDown / ArrowUp, cross-menu ArrowLeft / ArrowRight) still focuses an item inside the menu. This is a different mechanism from [forMenuSub], whose hover-open simply suppresses the focus move: a submenu opens alongside the menu that holds focus, whereas a menubar hover-switch destroys the focus holder and so must put focus somewhere.
  • Hover-leave does not dismiss. Moving the pointer off the bar (or off the open menu) leaves the menu open. The APG Menubar pattern prescribes no hover-leave close, and a menubar menu always holds focus while open. Dismissing it because the mouse wandered would rip focus out from under a keyboard user and strand it on <body>. Dismiss with Escape, an outside pointer interaction, Tab, or by activating an item.
  • Dismissal. Escape and an outside pointer interaction close the open menu when dismissible is true (default). [dismissible]="false" suppresses both.
  • Roving tabindex. Only one trigger is in the tab sequence at a time: the open trigger, the most-recently-focused trigger, or the first enabled one when nothing's focused.
  • Mount equals open. In the canonical shape each menu's [forMenuContent] is wrapped in @if (value() === '<id>'), so animate.enter / animate.leave fire on mount / unmount. The directive never toggles [hidden]. A surface kept mounted (see Mount shapes) runs no mount-cycle animation.
  • Disabled triggers stay focusable (per APG). They still reflect data-disabled="" and aria-disabled="true" and are skipped by ArrowLeft / ArrowRight, typeahead, and cross-menu nav.

Wrapping in a design system

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