ComponentsActions & navigationMenu
On this page
Actions & navigation

Menu released

A list of commands to choose from, walked by the keyboard and closed by the choice.

  • Import@pacit/components/menu
  • Selectorspct-menu, button[pctMenuItem], [pctMenuTrigger]

Implements the W3C ARIA APG Menu Button pattern ↗

Last command: none yet

menu.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctMenu, PctMenuItem } from '@pacit/components/menu';
ExportedWhat it is
PctMenuItemApiinterfaceWhat a menu needs to know about one of its items, and nothing more. Declared HERE, beside the menu, so that the item can provide it while importing the menu — the other direction (contentChildren(PctMenuItem)) would close the import cycle. It is pct-radio's channel one component over, and for the same reason.
PCT_MENU_ITEMtokenThe channel through which an item announces itself to the menu it stands in.
PctMenucomponentpct-menuA menu: a list of commands on a panel, walked by the keyboard and chosen from.
PctMenuItemcomponentbutton[pctMenuItem]One command on a menu: role="menuitem", focusable by the walk and by nothing else.
PctMenuTriggerdirective[pctMenuTrigger]The control a menu hangs off: it opens the panel and says so about itself.
PctMenuCloseReasontype'item' | 'trigger' | 'escape' | 'outside' | 'away' | 'api'Why a menu closed. open says *that* it is shut and nothing else, and the question an application asks about a menu it did not close itself — was something chosen, or did the user walk away — is answered by this and by nothing in the boolean.
PctMenuOpenIntenttype'first' | 'last' | 'none'Where focus lands when a panel comes up — the opening's half of PctMenuCloseReason.
<button pctButton [pctMenuTrigger]="actions">Actions</button>
<pct-menu #actions>
  <button pctMenuItem (click)="rename()">Rename</button>
</pct-menu>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

Placement, and why it closed

placement asks for a side; the panel takes it when there is room and flips when there is not. (closed) reports the reason — an item, the trigger, Escape, a click outside — so a consumer can tell a choice from a dismissal.

Last closed by: not opened yet

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

A menu: a list of commands on a panel, walked by the keyboard and chosen from.

Inputs

NameTypeWhat it does
opentwo-waydefault falsebooleanWhether the panel is up. A model, because both directions are ordinary: an application opens it, and the menu closes itself on a choice, on Escape, on a press outside, on the trigger and on Tab walking out — every one of which is a panel on a layer getting out of the page's way. An inline menu has nowhere to get out of, and writes this from nobody but the application (inline).
ariaLabeldefault ''stringThe accessible name, for a menu whose trigger does not already say it. Usually neither is needed: a menu with no name of its own is named by the control that opened it, which is what the APG's own example does and what makes "Actions menu" come out of the two words already on the screen.
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere on the page.
placementdefault 'bottom'PctPlacementWhich side of the control it opens on — logical, so end is the right in an English page and the left in an Arabic one. bottom is the menu button's side and end the submenu's; the window has the last word either way (pctPlacementPositions). Inline it is read by nobody: a panel that is not positioned has no side to be on.
inlinedefault falsebooleanRenders the panel in the host, where the component was written, instead of on a layer over the page — a column of commands docked into the page rather than one lifted off it.

Outputs

NamePayloadWhen it fires
closedPctMenuCloseReasonWhy it closed. See PctMenuCloseReason.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-menu
styledisplay: contentsThe host renders nothing — everything it draws lives in the overlay. It stays in the tree because it is where the panel's severed properties are read from.
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctMenuHarness, PctMenuItemHarness, PctMenuTriggerHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
panel[data-pct-part="panel"]the floating list of commands
item[data-pct-part="item"]one command

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-menu-item-bg{pct.surface}#ffffff · #0f172aBackground of an item.
--pct-menu-item-bg-active{pct.surface-100}#f1f5f9 · #1e293bBackground of the item the keyboard or the pointer is on.
--pct-menu-item-fg{pct.text}#0f172a · #ffffffText colour of an item.
--pct-menu-item-fg-disabled{pct.text-disabled}#64748bText colour of an item while disabled.
--pct-menu-item-font-size{pct.font.size.md}14pxFont size of an item.
--pct-menu-item-gap{pct.space.3}8pxGap between an item's icon and its text.
--pct-menu-item-padding-x{pct.space.4}12pxHorizontal padding of an item.
--pct-menu-item-padding-y{pct.space.2}4pxVertical padding of an item.
--pct-menu-item-target-min{pct.target.min}24pxTouch-target floor of an item.
--pct-menu-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-menu-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-menu-panel-fg{pct.text}#0f172a · #ffffffText colour of the panel.
--pct-menu-panel-max-height320pxMaximum height of the panel.
--pct-menu-panel-padding-y{pct.space.2}4pxVertical padding of the panel.
--pct-menu-panel-radius{pct.radius.md}8pxCorner radius of the panel.
--pct-menu-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55)Shadow of the panel.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-menu-panel-bg: #f0fdfa;
  --pct-menu-item-fg: #134e4a;
  --pct-menu-item-bg-active: #ccfbf1;
}

Last command: none yet

Accessibility

Measured, not assumed

Pattern: Menu Button — role="menu" on the panel, role="menuitem" on the rows, aria-haspopup="menu" and aria-expanded on the control. A submenu is the same pattern one level down: the item that opens one carries those same two attributes

Keyboard

keyeffecttest
ArrowDown / ArrowUpthe next/previous command, skipping what is disabled, coming round at the endsapps/sandbox-e2e/src/menu.spec.ts
Home / Endthe first/last commandapps/sandbox-e2e/src/menu.spec.ts
a letterthe first command whose text starts with what has been typed; a second letter narrows itapps/sandbox-e2e/src/menu.spec.ts
Enter / Spaceruns the command — the platform's, because a row is a <button>, and nothing here reads itapps/sandbox-e2e/src/menu.spec.ts
Escapecloses one level, focus back to what opened it: the trigger, or the row a submenu hangs onapps/sandbox-e2e/src/menu.spec.ts
Tab / Shift+Tabcloses the whole tree and gives focus back to the trigger, for the page's order to carry on; inline it is left alone — the panel is already in that order, so Tab walks out of it and closes nothingapps/sandbox-e2e/src/menu.spec.ts · libs/components/menu/src/menu.spec.ts
ArrowRightinto the submenu of the active row — logical: in a right-to-left page it is ArrowLeftapps/sandbox-e2e/src/menu.spec.ts
ArrowLeftout of a submenu, back onto the row it hangs on; in a root menu the key is left alonelibs/components/menu/src/menu.spec.ts
ArrowDown/ArrowUp on the triggeropens it at the first / at the last commandapps/sandbox-e2e/src/menu.spec.ts

The panel itself is not in the Tab order (tabindex="-1"); it takes focus only when the menu has no command to give it to, so that an empty menu is still somewhere Escape can be pressed.

Inline, one command is. A panel drawn in the page has to be reachable from the page's own order, so exactly one row carries tabindex="0" — the row the walk stands on, or the first reachable one before the first movement — and the arrows do the walking from there. It is the roving tabindex the APG writes this pattern with, which the layer deliberately does not have (0031), and the reason the two answers differ is the one thing the mode changes: over a layer Tab leaves the menu, inline it leaves the row.

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA APG pattern in the JSDoclibs/components/menu/src/menu.ts — the pattern named in the class comment, together with why focus moves here and points in the select
MeasuredKeyboard map testedapps/sandbox-e2e/src/menu.spec.ts — every row of the table above in three engines, Enter and Space among them because "the platform does it" is a claim about browsers; libs/components/menu/src/menu.spec.ts for the arrow a root menu leaves alone
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /menu view, and separately an open menu with a submenu beside it, page and panels together: role="menu" has required children and role="menuitem" a required parent, and a nested panel is the arrangement where either could go wrong
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — menu-open, with the submenu open: two panels of the page's own surface drawn side by side, where the edges and the shadows are all there is between them and the page — the sandbox's navigation blanked by the stage, so a row in that list is not a pixel here (req-quality-e2e)
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the row the user is on carries SelectedItem/SelectedItemText, the disabled row GrayText, the panel a CanvasText edge. The one state this component has is a background, and this is the mode that would flatten it
Measuredprefers-reduced-motionapps/sandbox-e2e/src/menu.spec.ts — the duration read from the panel is the reduced one, and the leave still ends
MeasuredTouch target ≥ 24×24 pxlibs/components/menu/src/menu-item.scss — min-block-size: var(--pct-menu-item-target-min) on the row, which is the target: a row runs the full width of the panel, so the pointer never has to find the text
By designSize axisnone — deliberately: a menu row is not a control with a height. req-api-size is about --pct-control-height-*, and the row's floor is a touch target rather than a size step
GapDensity axisnone — gap. The same one every component here has (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/menu.spec.ts — the submenu drawn on the starting side and the arrow keys mirrored with it. It is the one component here where the writing direction reaches the key map, not just the layout
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /menu view renders and hydrates with no NG05xx; a closed menu contributes no markup, and the trigger's aria-expanded="false" is there from the server. The inline half is measured in the unit spec instead, under ngServerMode — the flag Angular's own afterNextRender reads — because no e2e view renders an inline menu yet
MeasuredInline: a region, not a layerlibs/components/menu/src/menu.spec.ts — the panel drawn in the host with no overlay ever created, one part in the document rather than two, the Tab stop, and one case per dropped promise: no trigger report, no dismissal, no leave. Gap: no e2e view, so the mode has no axe run, no screenshot and no three-engine reading of the roving tabindex
By designFormsnone — deliberately: a menu is not a form control. It holds no value and implements no FormValueControl; a command that edits one is the consumer's
MeasuredThe page behind stays liveapps/sandbox-e2e/src/menu.spec.ts — a control beside the panel answers a press while the menu is up, and <html> is not locked. The popover's promise, measured again because it is what separates both of them from the dialog
MeasuredA tree of panelsapps/sandbox-e2e/src/menu.spec.ts (three levels, one Escape per level, choosing anywhere closing all of it, the pointer opening a submenu without taking focus into it) + libs/components/menu/src/menu.spec.ts (a press inside a submenu is not a press outside its parent)
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — the command's text measured twice, against the panel and against the row it stands on, because a menu spends half its life with one row painted; the edge against the page; the disabled row as a warning, on the same footing as every other
By designStrings through PCT_TEXTSnone — deliberately: the component writes no string of its own
MeasuredSize budgetlibs/components/size.snapshot.md — the ./menu row, with ./core beside it
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox, and the act that opens it. The first row is announced on open by all three, with its position by two: NVDA Rename, 1 of 4, VoiceOver Rename menu item (1 of 4), Orca leaving main content. · Actions · menu · Rename. — Orca gives the menu by name and role and then the item, VoiceOver the item and its role, NVDA the item and its position. On Escape the trigger reads collapsed again (Orca Actions · collapsed button. · opens menu · collapsed, VoiceOver Actions menu pop up button). The TRIGGER on arrival is as it was: all three name it and say it opens a menu — Orca Actions · collapsed button. · opens menu, NVDA File, button, collapsed, sub Menu, VoiceOver Actions menu pop up button — and the nested triggers read the same way, which is what aria-haspopup on a submenu is for
Measureddocs page/components/menu on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

81.16%mutation score280 of 345 mutants killed
21 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
5colour pairs measuredlight and dark, at every token build
1visual baselinesChromium, the sandbox's own pictures
4elements in the preview8 listeners · 1 render to settle · 4460 µs in jsdom, held exactly but for the clock

Decisions behind it

  • 0066A panel is a layer or a region, and the consumer says which
  • 0032A menu moves focus, a listbox points at it
  • 0031A panel's tab order belongs to its trigger
  • 0024The overlay layer carries what an overlay severs, and borrows the closing stack
  • 0025A panel says whether it takes focus, and the rest of the focus layer waits for a consumer
  • 0013No headless core / skin split
  • lesson-18An overlay panel renders outside the host tree
  • lesson-95A host listener cannot stop a listener the template registered first

Known limitations

  • A command is a <button>. The selector says button[pctMenuItem] and nothing else matches, so a menu of links is not this component's shape. The reason is disabled: the platform's own disabled state is the only one a consumer's (click) on the same element cannot get past (lesson-95), and a link has no such state. A command that navigates calls the router.
  • The rows are direct children. contentChildren finds an item wherever it stands, but role="menu" requires role="menuitem" children — a <div> drawn between the panel and the rows breaks the relation, and the axe case above is what would catch it.
  • No menuitemcheckbox, no menuitemradio, no separators, no groups. A menu of commands is what this is; a menu that carries state is a different pattern with a different keyboard, and it will arrive when something here needs one.
  • One trigger per menu. Focus comes back to "the trigger", so two controls opening one panel would make that "whichever registered last". Reported in dev mode rather than repaired: the fix is a menu each, and the library cannot know which of the two the author meant.
  • The pointer opens a submenu with no delay and no safe triangle. Crossing a row on the way to a submenu that is already open closes it, and the user has to go back. The alternatives — a timer, or the diagonal corridor a desktop menu draws — are both state that has to be right in three engines, and neither is worth it until somebody reports the crossing.
  • An inline menu has no leave. The overlay owned the wait that let the panel fade out, and a panel the template removes takes its transition with it — so inline it fades in (@starting-style, which is the sheet's) and then goes at once, with data-pct-leaving never written on it. The alternative is a second mechanism keeping removed markup alive for the length of a transition, and nothing has asked for one.
  • An inline menu with a [pctMenuTrigger] is reported, not repaired. The pair cannot both be meant: a control that says aria-haspopup="menu" for a panel which never pops up. Either half may be the one the author wanted — a menu that is a layer on a narrow screen and a region on a wide one drops the control with the same condition that sets the input — so the library says so in dev mode and changes nothing.
  • Typeahead does not type spaces. The space is how a button is pressed, so a menu that read it as a letter would swallow the press of every command whose label needs a second word.