forty-cdk
llms.txt

Primitives

Dropdown Menu

A button that opens a menu of actions, with full keyboard navigation, typeahead and submenus.

forty-cdk/dropdown-menu WAI-ARIA APG

Open the menu from the trigger and walk the items with the arrow keys. The highlighted item carries data-highlighted, and typing jumps to the first match.

New to overlays in forty-cdk? Your first overlay walks a Popover from empty markup to styled-and-animated and explains the @if / open-state model and the portal → global CSS rule.

When to choose

  • Dropdown Menu: a <button> that opens a menu of commands. Items run an action and close the surface; the menu carries no form value.
  • Context Menu: the same menu surface, opened by right-click, long-press, Shift+F10 or the ContextMenu key and anchored at the pointer. Choose it when the actions belong to a region instead of to a visible control.
  • Menubar: a persistent bar of triggers with roving tabindex and cross-menu arrow navigation. Choose it when several menus sit together, as in a desktop application.
  • Select: when the surface produces a value. role="menu" announces commands; role="listbox" announces options.

Anatomy

<div forDropdownMenu #menu="forDropdownMenu" side="bottom" align="start">
  <button forDropdownMenuTrigger class="dropdown-menu-trigger">
    Options
    <span class="chevron" aria-hidden="true"></span>
  </button>
  <!-- @if (menu.open()) { -->
  <div forMenuContent>
    <button forMenuItem (activate)="cut()">Cut</button>
    <button forMenuItem (activate)="copy()">Copy</button>
    <hr forMenuSeparator />
    <div forMenuRadioGroup [(value)]="alignment">
      <button forMenuRadioItem value="left">Left</button>
      <button forMenuRadioItem value="center">Center</button>
    </div>
  </div>
  <!-- } -->
</div>

The menu items, content surface, radio groups, separators, and groups come from the menu/ folder. [forContextMenu] uses the same primitives.

Examples

Checkbox & radio items

A settings-style dropdown built from the full menu vocabulary: forMenuGroup with a forMenuGroupLabel header, forMenuCheckboxItem toggles (role menuitemcheckbox) and a forMenuRadioGroup of forMenuRadioItem options (role menuitemradio). Each item carries a forMenuItemIndicator that paints its checkmark / dot from the item's checked state. Calling preventDefault() on (activate) keeps the menu open so several options can be flipped in one pass. Try Space to toggle without closing.

Submenus

forMenuSub nests a second menu under a forMenuSubTrigger item (role menuitem, aria-haspopup=menu). The submenu owns its own open model and item collection, and its forMenuSubContent reuses the menu surface positioned to the side of the trigger. Submenus nest arbitrarily. Here a third level sits inside the second. ArrowRight opens a submenu and focuses its first item; ArrowLeft collapses back to the parent; Escape closes one level at a time.

API

ForDropdownMenu

PropertyTypeDescription
open
model
Two-way bindable. Whether the menu is shown.
Default: false
side
input
Anchor side of [forMenuContent] against the trigger. The default is read from provideForDropdownMenuDefaults for the surrounding scope.
Default: 'bottom'
align
input
Alignment along side ('start' / 'center' / 'end'). The default is read from provideForDropdownMenuDefaults for the surrounding scope.
Default: 'start'
sideOffset
input
Gap (px) between the trigger and the content along the main axis.
Default: 4
alignOffset
input
Gap (px) along the cross axis (parallel to side).
Default: 0
fallbackAxisSideDirection
input
When both sides of the preferred axis overflow, lets flip drop the menu to a perpendicular side instead of clipping. 'none' keeps only the opposite same-axis placement. The default is read from provideForDropdownMenuDefaults for the surrounding scope, so set it once for the whole app rather than per call site.
Default: 'none'
loop
input
Whether arrow navigation wraps at the ends.
Default: true
dir
input
Writing direction. In RTL, ArrowLeft opens submenus and ArrowRight closes them. The swap is automatic. Inherited by every nested [forMenuSub] underneath unless overridden.
Default: 'ltr'
disabled
input
When true, trigger interactions are ignored.
Default: false
dismissible
input
When false, Escape and outside interactions don't close.
Default: true
returnFocus
input
When true, focus returns to the trigger on close.
Default: true
ariaLabel
input
Manual aria-label on [forMenuContent] if the trigger isn't a meaningful name.
Default: null
escapeKeyDown
output
Output. Escape pressed while the menu is the topmost dismissible layer.
Default: —
pointerDownOutside
output
Output. Pointer-down on a target outside content + trigger.
Default: —
focusOutside
output
Output. Focus moves outside content + trigger.
Default: —
interactOutside
output
Output. Composite: 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.
Default: —
autoFocusOnClose
output
Output. Just before focus returns to the trigger on unmount.
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 directive's default action; the original DOM event, when present, is on .event.

(autoFocusOnOpen) / (autoFocusOnClose) are output-shape because DropdownMenu always routes close transitions through [(open)] (via the implicit openChange emitter). Dialog and Drawer take callback-shape inputs for the same pair instead: either can be closed by a direct open.set(false) that bypasses the (dismiss) output entirely, so their close hook has to be a stored function reference that still runs during teardown.

Data attributes

PieceAttributeValues
[forDropdownMenu]data-stateopen | closed
[forDropdownMenu]data-disabledpresent | absent
[forDropdownMenuTrigger]data-stateopen | closed
[forDropdownMenuTrigger]data-disabledpresent | absent

The menu items, content surface, radio groups, separators, and groups live in the menu/ folder. See menu → Styling for their data-state / data-highlighted / data-disabled attributes and the content-surface CSS custom properties.

Keyboard

KeyBehavior
ClickToggles the menu. On open, focus moves to the first enabled item.
Enter / Space / ArrowDownOpens the menu and focuses the first enabled item; on an already-open menu, moves focus there.
ArrowUpOpens the menu and focuses the last enabled item; on an already-open menu, moves focus there.

The open keys never close the menu, because the APG menu-button pattern gives them no close semantics (that's Escape, or a pointer click on the trigger). Pressing one while the menu is already open moves focus into it, which is what makes them useful after an (autoFocusOnOpen)-vetoed open left focus on the trigger. A menu with no enabled item moves nothing.

Once focus is in the menu, see menu/README.md for the in-menu keyboard.

Accessibility

[forDropdownMenu] implements the WAI-ARIA Menu Button pattern. The trigger wires aria-haspopup="menu", aria-expanded, and aria-controls; the menu surface and item roles come from the shared menu/ primitives.

A disabled trigger (its own [disabled], or the root's) reflects through a single channel: the native disabled attribute plus the data-disabled styling hook. No aria-disabled is emitted, because the trigger is a real single-purpose <button> and the native attribute already conveys the state to assistive technology. Style the disabled trigger off [disabled] or [data-disabled], never [aria-disabled].

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

The menu content ([forMenuContent]) portals to document.body, so a class scoped to your trigger's component cannot reach it. 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 (--for-floating-anchor-width / --for-floating-anchor-height, --for-floating-available-width / --for-floating-available-height, and --for-floating-content-transform-origin), documented in full in Styling floating content.

.dropdown-menu-trigger .chevron {
  transition: transform 150ms;
}
.dropdown-menu-trigger[data-state='open'] .chevron {
  transform: rotate(180deg);
}

Behavior notes

  • Mount equals open. The directive does not toggle [hidden]. Instead, @if (open()) controls presence so animate.enter / animate.leave fire on the natural mount cycle.
  • Trigger is exempt from outside-pointer / outside-focus checks. Without this, clicking the trigger to close would race with its own toggle handler and reopen immediately.
  • Initial focus depends on the opening key. Click / Space / Enter / ArrowDown focus the first enabled item; ArrowUp focuses the last enabled item. The same keys re-focus that item when the menu is already open.
  • Selecting an item closes the menu by default. To keep the menu open after activation (multi-select pattern), call $event.preventDefault() in the item's (activate) handler.

The minimal "click trigger → show menu" case needs neither a separate open signal nor a two-way binding. [forDropdownMenu] is exportAs: 'forDropdownMenu', so expose the directive instance with a template reference variable (#menu="forDropdownMenu") and drive the @if straight off its own open() signal, as above. Trigger interactions, item activation, Escape, and outside dismissal all flip it.

Reach for the explicit [(open)]="mySignal" model binding only when the component class needs to read or drive open state (to open it programmatically, persist it, or react to it elsewhere):

<div forDropdownMenu [(open)]="open">
  <button forDropdownMenuTrigger class="dropdown-menu-trigger">Options</button>
  @if (open()) {
  <div forMenuContent>…</div>
  }
</div>

Triggers stamped from outside-declared templates

Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forDropdownMenuTrigger] declared in a template outside the root throws the orphan error even when the template is rendered inside the root via ngTemplateOutlet. For that case the selector attribute accepts the root reference as a value, routerLink-style. Grab it with #root="forDropdownMenu" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.

<div forDropdownMenu #root="forDropdownMenu">
  <ng-container *ngTemplateOutlet="trig; context: { root }" />
  @if (root.open()) {
  <div forMenuContent>…</div>
  }
</div>

<ng-template #trig let-root="root">
  <button [forDropdownMenuTrigger]="root">Options</button>
</ng-template>

Sharing one menu with a second opener

[forDropdownMenu] is a single-opener preset: one root, one button trigger. When the same actions must also be reachable another way (the canonical case being a table row with a kebab button and a right-click region over the whole row), bind the trigger to a [forMenu] root instead, which drives one [forMenuContent] block from any number of openers. See Shared openers.

<tr forMenu #row="forMenu" [(open)]="open" ariaLabel="Row actions">
  <td [forContextMenuTrigger]="row">…cells…</td>
  <td>
    <button [forDropdownMenuTrigger]="row" [menuPositioning]="{ sideOffset: 4 }">⋮</button>
  </td>
  <!-- one content block, no duplication -->
</tr>

[menuPositioning] is the trigger's own placement override: a partial { side, align, sideOffset, alignOffset }, each key falling back to the root's input when omitted. It exists because a shared root cannot pick offsets that suit heterogeneous openers: the sideOffset: 4 above keeps the button-opened menu clear of the button while a sibling right-click region still opens flush at the cursor. Under a [forDropdownMenu] root it resolves the same way, where it is simply a per-trigger spelling of the root's inputs. See Per-opener positioning.

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.