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 nativedisabledproperty 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.
MatExpMenualways 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 exampleinitialFocus: 'checked', or focusing the firstaria-checked="true"item by default) would replace that. Not yet filed upstream.
Out of scope¶
- Select and autocomplete. m3e-angular's
mat-exp-selectis not aMatFormFieldControlandmat-exp-autocompletehas noControlValueAccessor, so SyRF'smat-form-fieldselects, 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.