Skip to content

Menu migration: Angular Material menus to m3e-angular

What this is: the delivery plan for moving every SyRF menu from Angular Material's mat-menu to m3e-angular's Material 3 Expressive menu (ADR-027), started in #4905. It follows the button migration: the buttons are Expressive, but the menus they open are still Material 2. This page is temporary; delete it when the flag retires.

Design

Unlike the buttons, the menu is not a directive on Angular Material's component. m3e-angular's mat-exp-menu (@camaradesuk/m3e-angular, from 0.2.0-alpha.8; SyRF pins 0.2.0-alpha.11, which keeps a submenu open when its parent is clicked after hover, camaradesuk/m3e-angular#141) is a separate component built on @angular/aria, with its own trigger ([matExpMenuTriggerFor]), lazy content (ng-template matExpMenuContent) and item directive (button[matExpMenuItem], a[matExpMenuItem]). Its trigger input is required and cannot be null, so one button cannot carry both triggers.

Piece Role
expressiveMenus (Release flag, default off) The one switch. Read by each migrated component (selectExpressiveMenus); live, without a reload. Quiet in What's New and exempt from the user guide: the same items, actions and results.
Two menus, two triggers A migrated template keeps its mat-menu and adds a mat-exp-menu with the same items. The trigger button is written twice under @if (expressiveMenus()), one with each directive. Both menus stay declared (a closed mat-exp-menu renders nothing), so template references resolve in either state. The duplicate goes when the flag retires.
Authored items Items keep their (click), [disabled] and routerLink on button/a[matExpMenuItem], with the library's slot classes (mat-exp-menu-leading, mat-exp-menu-label). A submenu is an item with [matExpMenuTriggerFor]. A choice (the sort options) is role="menuitemradio" with aria-checked. Every mat-exp-menu has a label, its accessible name.
Flag without a store A component that may render without the application store (its hosts' specs) reads the flag with injectExpressiveMenus() (shared/m3e/expressive-menus.ts): off without a store.
shared/m3e/expressive-menus-coverage.spec.ts CI guard: every template with a mat-menu has one mat-exp-menu for each, switched by expressiveMenus(), named, with the parts imported, except templates in unmigrated-menus.allowlist.json (shrink-only, each row names its batch).

Behaviour that differs from mat-menu, and that each migrated surface's spec covers:

  • A disabled Expressive item stays focusable for discovery and is disabled through aria-disabled; the native disabled property stays false.
  • Keyboard navigation, typeahead and focus return come from @angular/aria, not Angular Material's menu.
  • Opening and closing use spring motion, which respects reduced motion.

Slices

Batch Templates PR
1. Pilot The flag, the guard, the study table's options menu, Sort by submenu and phone sort menu, the notification panel's options (one routerLink) #4905
2a. Admin, notifications and section navigation notification-inbox, the three admin feature menus (features-shell, runtime-feature-flags, feature-flags-section), side-nav, studies-workspace. The selected section in a section switcher keeps active-menu-item (a global rule paints it in either menu) #4920
2b. Library and project admin The shared chip-menu (the workspace's Columns and Stage chips; focus moves to the checked item on open, one render after the menu focuses its first item; checkbox items keep the menu open), systematic-searches (the page's More actions, the list rows' More menu and the table's overflow menu). Adds injectExpressiveMenus() (shared/m3e/expressive-menus.ts) for components that may render without the store, now used by chip-menu, side-nav and runtime-feature-flags #4921
2c. Membership table membership-table: its menu renders an action template its parents supply. matExpMenuItem resolves the menu surface from where it is declared (the parent), so the parents' actions become data (SelectedItemsAction: { label, run }) the table renders in either menu #4922
3. Stage review, annotation forms and the PDF viewer annotation-form, AF2 (annotation-form-v2, question-list, quote-toolbar, unit-card-selector, unit-panel), the three dockview menus, study-pdf-toolbar —
4. The app header core/nav/nav.component.html: 8 menus, 3 nested, 12 links —

The allow-list names the batch of every template not yet migrated (21 templates and 37 menus when the pilot began; main added the phone sort menu and chip-menu since).

Upstream follow-ups

  • Initial focus on the checked item. MatExpMenu always focuses the first item when it opens. The shared chip menu moves focus to the checked item one render later (chip-menu.component.ts), which depends on the library's after-render passes in 0.2.0-alpha.11. An m3e-angular option (for example initialFocus: 'checked', or focusing the first aria-checked="true" item by default) would replace that. Not yet filed upstream.

Out of scope

  • Select and autocomplete. m3e-angular's mat-exp-select is not a MatFormFieldControl and mat-exp-autocomplete has no ControlValueAccessor, so SyRF's mat-form-field selects, with their validation and errors, stay on Angular Material until the library supports both.
  • The app header's mat-toolbar. m3e-angular's toolbar is M3's action toolbar (docked or floating, one row of actions), not the top app bar; the header takes M3 through FEAT-023's component theme instead.

Rollout and retirement

Default off everywhere. Switch it on in staging after batch 1 ships, with the admin console's runtime flags. When every batch has shipped and staging has run with it on, switch production on (owner decision), then retire the flag: delete every mat-menu, the @else triggers, MatMenuModule imports, the allow-list and this page, and settle the feature with pnpm feature:settle expressive-menus.